@buildinternet/uploads 0.27.0 → 0.29.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 +1 -1
- package/dist/cli-brand.js +4 -1
- package/dist/cli-catalog.js +18 -2
- package/dist/cli-help.js +30 -11
- package/dist/cli.js +9 -0
- package/dist/client.d.ts +8 -0
- package/dist/client.js +7 -0
- package/dist/commands/hook.d.ts +29 -0
- package/dist/commands/hook.js +204 -0
- package/dist/commands/install.d.ts +10 -0
- package/dist/commands/install.js +50 -8
- 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/hooks-install.d.ts +22 -0
- package/dist/hooks-install.js +112 -0
- package/dist/install-source.d.ts +14 -0
- package/dist/install-source.js +42 -0
- package/dist/mcp/tools.js +4 -1
- package/dist/telemetry.js +1 -0
- package/dist/update-check.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -175,7 +175,7 @@ Config layers (first match wins): CLI flags → env vars → `--env-file` → `~
|
|
|
175
175
|
|
|
176
176
|
Or with `UPLOADS_TOKEN`/`UPLOADS_WORKSPACE` in the environment or user config. Claude Code: `claude mcp add uploads -- uploads --env-file /path/to/.env mcp`.
|
|
177
177
|
|
|
178
|
-
For HTTP clients there's also a hosted variant at `https://agents.uploads.sh/mcp` — the workspace is inferred from the bearer token, so only the URL and token are needed (`https://agents.uploads.sh/<workspace>/mcp` and the `mcp.uploads.sh` hostname also work). Tools: file operations (including `get_metadata` / `set_metadata` / `find_files`) plus `gallery_create`, `gallery_get`, `gallery_add`, `gallery_link`, and `gallery_find_by_reference`; all use the same bearer-token workspace scopes and gallery URLs come from the API — see `apps/mcp` in the repo. The hosted `put` also accepts a `metadata` param. `uploads install` registers the skills + hosted MCP (short progress; `--verbose` for underlying output). Its `put` takes no content type: the stored type is sniffed server-side from the bytes and checked against the workspace allowlist, and writes are rate limited per workspace.
|
|
178
|
+
For HTTP clients there's also a hosted variant at `https://agents.uploads.sh/mcp` — the workspace is inferred from the bearer token, so only the URL and token are needed (`https://agents.uploads.sh/<workspace>/mcp` and the `mcp.uploads.sh` hostname also work). Tools: file operations (including `get_metadata` / `set_metadata` / `find_files`) plus `gallery_create`, `gallery_get`, `gallery_add`, `gallery_link`, and `gallery_find_by_reference`; all use the same bearer-token workspace scopes and gallery URLs come from the API — see `apps/mcp` in the repo. The hosted `put` also accepts a `metadata` param. `uploads install` registers the skills + hosted MCP + Grok/Cursor hooks (short progress; `--verbose` for underlying output). Claude and Codex use their plugins for the same pre-PR screenshot reminder (`uploads hook pre-pr-screenshot`). Its `put` takes no content type: the stored type is sniffed server-side from the bytes and checked against the workspace allowlist, and writes are rate limited per workspace.
|
|
179
179
|
|
|
180
180
|
## Programmatic use
|
|
181
181
|
|
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
|
@@ -180,14 +180,30 @@ export const ROOT_COMMANDS = [
|
|
|
180
180
|
{ name: "setup", summary: "Inspect/configure advanced CLI settings" },
|
|
181
181
|
{
|
|
182
182
|
name: "install",
|
|
183
|
-
summary: "Install
|
|
183
|
+
summary: "Install agent skills, remote MCP, and harness hooks",
|
|
184
184
|
essential: true,
|
|
185
185
|
subcommands: [
|
|
186
186
|
{ name: "skill", summary: "Install the agent skills only" },
|
|
187
187
|
{ name: "mcp", summary: "Register the remote MCP server only" },
|
|
188
|
-
{ name: "
|
|
188
|
+
{ name: "hooks", summary: "Install PR screenshot hooks for Grok/Cursor" },
|
|
189
|
+
{ name: "all", summary: "Install skills, MCP, and hooks (default)" },
|
|
189
190
|
],
|
|
190
191
|
},
|
|
192
|
+
{
|
|
193
|
+
name: "hook",
|
|
194
|
+
summary: "Agent harness hook handlers (stdin → advisory JSON)",
|
|
195
|
+
subcommands: [
|
|
196
|
+
{
|
|
197
|
+
name: "pre-pr-screenshot",
|
|
198
|
+
summary: "Remind to stage screenshots before gh pr create",
|
|
199
|
+
},
|
|
200
|
+
],
|
|
201
|
+
},
|
|
202
|
+
{
|
|
203
|
+
name: "update",
|
|
204
|
+
summary: "Update the CLI, then refresh the agent skills + MCP registration",
|
|
205
|
+
essential: true,
|
|
206
|
+
},
|
|
191
207
|
{
|
|
192
208
|
name: "login",
|
|
193
209
|
summary: "Sign in via browser (or an enrollment code) and save credentials",
|
package/dist/cli-help.js
CHANGED
|
@@ -6,24 +6,44 @@ const CMD_WIDTH = 22;
|
|
|
6
6
|
function toRow(c) {
|
|
7
7
|
return [c.usage ?? c.name, c.summary];
|
|
8
8
|
}
|
|
9
|
-
/**
|
|
9
|
+
/**
|
|
10
|
+
* Display order for the short essentials help. Membership is *not* declared
|
|
11
|
+
* here — it comes from `essential: true` in cli-catalog.ts, the one source of
|
|
12
|
+
* truth (issue #491: this array used to decide membership too, and had already
|
|
13
|
+
* drifted, silently keeping `screenshot` out of short help). This list only
|
|
14
|
+
* says what order the essential commands appear in; the check below fails the
|
|
15
|
+
* build-time module load if the two ever disagree in either direction.
|
|
16
|
+
*/
|
|
10
17
|
const ESSENTIAL_ORDER = [
|
|
11
18
|
"put",
|
|
12
19
|
"attach",
|
|
20
|
+
"screenshot",
|
|
13
21
|
"login",
|
|
14
22
|
"whoami",
|
|
15
23
|
"list",
|
|
16
24
|
"delete",
|
|
17
25
|
"doctor",
|
|
18
26
|
"install",
|
|
27
|
+
"update",
|
|
19
28
|
];
|
|
20
29
|
/** Day-to-day commands shown on bare `uploads` / `uploads help`. */
|
|
21
|
-
const ESSENTIALS =
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
});
|
|
30
|
+
const ESSENTIALS = (() => {
|
|
31
|
+
const essential = ROOT_COMMANDS.filter((c) => c.essential);
|
|
32
|
+
const rank = new Map(ESSENTIAL_ORDER.map((name, i) => [name, i]));
|
|
33
|
+
const unordered = essential.filter((c) => !rank.has(c.name));
|
|
34
|
+
if (unordered.length > 0) {
|
|
35
|
+
throw new Error(`cli-help ESSENTIAL_ORDER missing essential command(s): ${unordered.map((c) => c.name).join(", ")}`);
|
|
36
|
+
}
|
|
37
|
+
const names = new Set(essential.map((c) => c.name));
|
|
38
|
+
const stale = ESSENTIAL_ORDER.filter((name) => !names.has(name));
|
|
39
|
+
if (stale.length > 0) {
|
|
40
|
+
throw new Error(`cli-help ESSENTIAL_ORDER names non-essential command(s): ${stale.join(", ")}`);
|
|
41
|
+
}
|
|
42
|
+
return essential
|
|
43
|
+
.slice()
|
|
44
|
+
.sort((a, b) => rank.get(a.name) - rank.get(b.name))
|
|
45
|
+
.map(toRow);
|
|
46
|
+
})();
|
|
27
47
|
/** Full catalog (same surface as before, still discoverable via help --all). */
|
|
28
48
|
const ALL_COMMANDS = ROOT_COMMANDS.map(toRow);
|
|
29
49
|
function rows(style, items) {
|
|
@@ -147,10 +167,9 @@ ${section(style, "Examples:")}
|
|
|
147
167
|
${style.command("uploads logout")}
|
|
148
168
|
${style.command("uploads --version")}
|
|
149
169
|
|
|
150
|
-
${section(style, "Agent/MCP:")} ${style.body("`uploads install` sets up
|
|
151
|
-
${style.body("
|
|
152
|
-
${style.body("`uploads mcp` for local stdio
|
|
153
|
-
${style.body("from @buildinternet/uploads/agent on the Worker.")}
|
|
170
|
+
${section(style, "Agent/MCP:")} ${style.body("`uploads install` sets up skills, hosted MCP, and hooks for")}
|
|
171
|
+
${style.body("Grok/Cursor. Claude and Codex use their plugins for the same PR-screenshot")}
|
|
172
|
+
${style.body("hook. Run `uploads mcp` for local stdio.")}
|
|
154
173
|
|
|
155
174
|
${style.muted("Tip: uploads help essentials only")}
|
|
156
175
|
${style.muted(" uploads help --all this full listing")}
|
package/dist/cli.js
CHANGED
|
@@ -12,6 +12,8 @@ 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 { runHook } from "./commands/hook.js";
|
|
16
|
+
import { runUpdate } from "./commands/update.js";
|
|
15
17
|
import { runCompletion } from "./commands/completion.js";
|
|
16
18
|
import { runLogout, runWhoami } from "./commands/session.js";
|
|
17
19
|
import { runTelemetry } from "./commands/telemetry.js";
|
|
@@ -268,6 +270,13 @@ export async function runCli(argv) {
|
|
|
268
270
|
case "install":
|
|
269
271
|
code = await runInstall(cmdArgs, { globals: parsed.globals, json }, showHelp);
|
|
270
272
|
break;
|
|
273
|
+
case "hook":
|
|
274
|
+
// Fail-open harness hooks — no token required, no telemetry noise.
|
|
275
|
+
code = await runHook(cmdArgs, showHelp);
|
|
276
|
+
break;
|
|
277
|
+
case "update":
|
|
278
|
+
code = await runUpdate(cmdArgs, { globals: parsed.globals }, showHelp);
|
|
279
|
+
break;
|
|
271
280
|
case "completion":
|
|
272
281
|
case "completions":
|
|
273
282
|
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)),
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `uploads hook pre-pr-screenshot` — agent PreToolUse / beforeShellExecution
|
|
3
|
+
* handler. When the shell command is `gh pr create`, the branch touches UI
|
|
4
|
+
* files, and nothing is staged on uploads.sh, emit a non-blocking advisory.
|
|
5
|
+
*
|
|
6
|
+
* Always fail-open. Disable with UPLOADS_HOOK_DISABLE=1.
|
|
7
|
+
*/
|
|
8
|
+
export type HookDeps = {
|
|
9
|
+
stdin: string;
|
|
10
|
+
testFiles?: string;
|
|
11
|
+
cwd?: string;
|
|
12
|
+
countStaged?: (branch: string) => Promise<number | null>;
|
|
13
|
+
isFork?: () => boolean | null;
|
|
14
|
+
git?: {
|
|
15
|
+
isRepo: () => boolean;
|
|
16
|
+
branch: () => string | null;
|
|
17
|
+
changedFiles: () => string[];
|
|
18
|
+
};
|
|
19
|
+
};
|
|
20
|
+
/** Claude/Codex: tool_input.command · Grok: toolInput.command · Cursor: command */
|
|
21
|
+
export declare function shellCommandFromHookInput(raw: unknown): string;
|
|
22
|
+
export declare function isCursorHookInput(raw: unknown): boolean;
|
|
23
|
+
export declare function looksLikeGhPrCreate(command: string): boolean;
|
|
24
|
+
export declare function isVisualPath(filePath: string): boolean;
|
|
25
|
+
export declare function anyVisual(files: string[]): boolean;
|
|
26
|
+
export declare function formatAdvisory(message: string, cursor: boolean): string;
|
|
27
|
+
/** Returns advisory JSON, or null when silent. Never throws for product paths. */
|
|
28
|
+
export declare function runPrePrScreenshot(deps: HookDeps): Promise<string | null>;
|
|
29
|
+
export declare function runHook(args: string[], help?: boolean): Promise<number>;
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `uploads hook pre-pr-screenshot` — agent PreToolUse / beforeShellExecution
|
|
3
|
+
* handler. When the shell command is `gh pr create`, the branch touches UI
|
|
4
|
+
* files, and nothing is staged on uploads.sh, emit a non-blocking advisory.
|
|
5
|
+
*
|
|
6
|
+
* Always fail-open. Disable with UPLOADS_HOOK_DISABLE=1.
|
|
7
|
+
*/
|
|
8
|
+
import { execFileSync } from "node:child_process";
|
|
9
|
+
import { createUploadsClient } from "../client.js";
|
|
10
|
+
import { resolveConfig } from "../config.js";
|
|
11
|
+
import { writeCommandHelp } from "../cli-style.js";
|
|
12
|
+
const HOOK_CMD = "pre-pr-screenshot";
|
|
13
|
+
const VISUAL_EXT = /\.(astro|tsx|jsx|vue|svelte|html|css|scss|less)$/i;
|
|
14
|
+
const EMAIL_PATH = /(?:^|\/)email\//i;
|
|
15
|
+
const FIND_TIMEOUT_MS = 5_000;
|
|
16
|
+
const HOOK_HELP = `uploads hook <name> — agent harness hook handlers (stdin JSON → stdout JSON)
|
|
17
|
+
|
|
18
|
+
Usage:
|
|
19
|
+
uploads hook pre-pr-screenshot
|
|
20
|
+
|
|
21
|
+
Invoked by Claude Code / Codex / Grok / Cursor hooks. Never blocks.
|
|
22
|
+
|
|
23
|
+
pre-pr-screenshot
|
|
24
|
+
If the shell command is \`gh pr create\`, the branch touches UI files, and
|
|
25
|
+
no screenshots are staged for the branch, emit an advisory to stage with
|
|
26
|
+
\`uploads attach … --branch\`.
|
|
27
|
+
|
|
28
|
+
Disable with UPLOADS_HOOK_DISABLE=1.
|
|
29
|
+
`;
|
|
30
|
+
/** Claude/Codex: tool_input.command · Grok: toolInput.command · Cursor: command */
|
|
31
|
+
export function shellCommandFromHookInput(raw) {
|
|
32
|
+
if (!raw || typeof raw !== "object")
|
|
33
|
+
return "";
|
|
34
|
+
const o = raw;
|
|
35
|
+
const toolInput = (o.tool_input ?? o.toolInput);
|
|
36
|
+
if (toolInput && typeof toolInput.command === "string")
|
|
37
|
+
return toolInput.command;
|
|
38
|
+
if (typeof o.command === "string")
|
|
39
|
+
return o.command;
|
|
40
|
+
return "";
|
|
41
|
+
}
|
|
42
|
+
export function isCursorHookInput(raw) {
|
|
43
|
+
if (!raw || typeof raw !== "object")
|
|
44
|
+
return false;
|
|
45
|
+
const o = raw;
|
|
46
|
+
return "conversation_id" in o || "workspace_roots" in o || "cursor_version" in o;
|
|
47
|
+
}
|
|
48
|
+
export function looksLikeGhPrCreate(command) {
|
|
49
|
+
return command.includes("gh pr create");
|
|
50
|
+
}
|
|
51
|
+
export function isVisualPath(filePath) {
|
|
52
|
+
return VISUAL_EXT.test(filePath) || EMAIL_PATH.test(filePath);
|
|
53
|
+
}
|
|
54
|
+
export function anyVisual(files) {
|
|
55
|
+
return files.some(isVisualPath);
|
|
56
|
+
}
|
|
57
|
+
export function formatAdvisory(message, cursor) {
|
|
58
|
+
if (cursor) {
|
|
59
|
+
return JSON.stringify({ additional_context: message, agentMessage: message });
|
|
60
|
+
}
|
|
61
|
+
return JSON.stringify({
|
|
62
|
+
hookSpecificOutput: {
|
|
63
|
+
hookEventName: "PreToolUse",
|
|
64
|
+
additionalContext: message,
|
|
65
|
+
},
|
|
66
|
+
systemMessage: message,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
function runGit(args, cwd, timeoutMs = 5_000) {
|
|
70
|
+
try {
|
|
71
|
+
return execFileSync("git", args, {
|
|
72
|
+
cwd,
|
|
73
|
+
encoding: "utf8",
|
|
74
|
+
timeout: timeoutMs,
|
|
75
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
76
|
+
}).trim();
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return "";
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
function defaultGit(cwd) {
|
|
83
|
+
return {
|
|
84
|
+
isRepo: () => runGit(["rev-parse", "--is-inside-work-tree"], cwd) === "true",
|
|
85
|
+
branch: () => {
|
|
86
|
+
const b = runGit(["rev-parse", "--abbrev-ref", "HEAD"], cwd);
|
|
87
|
+
return !b || b === "HEAD" ? null : b;
|
|
88
|
+
},
|
|
89
|
+
changedFiles: () => {
|
|
90
|
+
const defaultBranch = runGit(["remote", "show", "origin"], cwd, 8_000)
|
|
91
|
+
.split("\n")
|
|
92
|
+
.map((l) => l.match(/HEAD branch:\s*(.+)/)?.[1]?.trim())
|
|
93
|
+
.find(Boolean) || "main";
|
|
94
|
+
const mergeBase = runGit(["merge-base", `origin/${defaultBranch}`, "HEAD"], cwd) ||
|
|
95
|
+
runGit(["merge-base", defaultBranch, "HEAD"], cwd);
|
|
96
|
+
const diff = mergeBase
|
|
97
|
+
? runGit(["diff", "--name-only", mergeBase, "HEAD"], cwd)
|
|
98
|
+
: runGit(["diff", "--name-only", "HEAD"], cwd);
|
|
99
|
+
return diff ? diff.split("\n").filter(Boolean) : [];
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
async function defaultCountStaged(branch) {
|
|
104
|
+
try {
|
|
105
|
+
const config = resolveConfig({ requireToken: false });
|
|
106
|
+
if (!config.token)
|
|
107
|
+
return null;
|
|
108
|
+
const client = createUploadsClient(config);
|
|
109
|
+
const result = await Promise.race([
|
|
110
|
+
client.findFiles({ "gh.branch": branch.toLowerCase() }, { limit: 1 }),
|
|
111
|
+
new Promise((_, reject) => {
|
|
112
|
+
setTimeout(() => reject(new Error("find timeout")), FIND_TIMEOUT_MS);
|
|
113
|
+
}),
|
|
114
|
+
]);
|
|
115
|
+
return Array.isArray(result.items) ? result.items.length : 0;
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
function defaultIsFork(cwd) {
|
|
122
|
+
try {
|
|
123
|
+
const out = execFileSync("gh", ["repo", "view", "--json", "isFork", "-q", ".isFork"], {
|
|
124
|
+
cwd,
|
|
125
|
+
encoding: "utf8",
|
|
126
|
+
timeout: 3_000,
|
|
127
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
128
|
+
}).trim();
|
|
129
|
+
if (out === "true")
|
|
130
|
+
return true;
|
|
131
|
+
if (out === "false")
|
|
132
|
+
return false;
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
/** Returns advisory JSON, or null when silent. Never throws for product paths. */
|
|
140
|
+
export async function runPrePrScreenshot(deps) {
|
|
141
|
+
if (process.env.UPLOADS_HOOK_DISABLE === "1")
|
|
142
|
+
return null;
|
|
143
|
+
let raw;
|
|
144
|
+
try {
|
|
145
|
+
raw = deps.stdin.trim() ? JSON.parse(deps.stdin) : null;
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
const command = shellCommandFromHookInput(raw);
|
|
151
|
+
if (!command || !looksLikeGhPrCreate(command))
|
|
152
|
+
return null;
|
|
153
|
+
const cwd = deps.cwd ?? process.cwd();
|
|
154
|
+
const git = deps.git ?? defaultGit(cwd);
|
|
155
|
+
if (!git.isRepo())
|
|
156
|
+
return null;
|
|
157
|
+
const branch = git.branch();
|
|
158
|
+
if (!branch)
|
|
159
|
+
return null;
|
|
160
|
+
const testFiles = deps.testFiles ?? process.env.UPLOADS_HOOK_TEST_FILES;
|
|
161
|
+
const changed = testFiles ? testFiles.split("\n").filter(Boolean) : git.changedFiles();
|
|
162
|
+
if (!anyVisual(changed))
|
|
163
|
+
return null;
|
|
164
|
+
const staged = await (deps.countStaged ?? defaultCountStaged)(branch);
|
|
165
|
+
// null = error/unconfigured → fail open; >0 = already staged
|
|
166
|
+
if (staged === null || staged > 0)
|
|
167
|
+
return null;
|
|
168
|
+
const fork = (deps.isFork ?? (() => defaultIsFork(cwd)))();
|
|
169
|
+
const forkNote = fork === true
|
|
170
|
+
? " Note: this looks like a fork branch, so staged screenshots won't auto-promote into the PR comment yet (see issue #317) — attach them manually if you use uploads."
|
|
171
|
+
: "";
|
|
172
|
+
const message = `This PR touches UI files (astro/tsx/jsx/vue/svelte/html/css/scss/less or an /email/ path) but no screenshots are staged for branch '${branch}' on uploads.sh. ` +
|
|
173
|
+
`Consider running \`uploads attach <shot.png> --branch --state after\` (and a --state before if useful) before or after opening the PR — the managed attachments comment assembles from staged files automatically.${forkNote}`;
|
|
174
|
+
return formatAdvisory(message, isCursorHookInput(raw));
|
|
175
|
+
}
|
|
176
|
+
async function readStdin() {
|
|
177
|
+
if (process.stdin.isTTY)
|
|
178
|
+
return "";
|
|
179
|
+
const chunks = [];
|
|
180
|
+
for await (const chunk of process.stdin) {
|
|
181
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
182
|
+
}
|
|
183
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
184
|
+
}
|
|
185
|
+
export async function runHook(args, help = false) {
|
|
186
|
+
const name = args[0];
|
|
187
|
+
if (help || !name || name === "--help" || name === "-h") {
|
|
188
|
+
writeCommandHelp(HOOK_HELP);
|
|
189
|
+
return 0;
|
|
190
|
+
}
|
|
191
|
+
if (name !== HOOK_CMD) {
|
|
192
|
+
process.stderr.write(`unknown hook: ${name} (expected ${HOOK_CMD})\n`);
|
|
193
|
+
return 2;
|
|
194
|
+
}
|
|
195
|
+
try {
|
|
196
|
+
const out = await runPrePrScreenshot({ stdin: await readStdin() });
|
|
197
|
+
if (out)
|
|
198
|
+
process.stdout.write(out);
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
// fail-open
|
|
202
|
+
}
|
|
203
|
+
return 0;
|
|
204
|
+
}
|
|
@@ -1,8 +1,18 @@
|
|
|
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;
|
|
7
15
|
runner?: CommandRunner;
|
|
16
|
+
/** Override home for hook installs (tests). */
|
|
17
|
+
home?: string;
|
|
8
18
|
}, help?: boolean): Promise<number>;
|
package/dist/commands/install.js
CHANGED
|
@@ -2,28 +2,35 @@ import { flagBool, flagString, parseCommandArgs, UsageError, } from "../cli-args
|
|
|
2
2
|
import { resolveConfig } from "../config.js";
|
|
3
3
|
import { execRunner } from "../github-gh.js";
|
|
4
4
|
import { writeCommandHelp } from "../cli-style.js";
|
|
5
|
+
import { HOOK_COMMAND, installHookManifests } from "../hooks-install.js";
|
|
5
6
|
export const DEFAULT_MCP_URL = "https://agents.uploads.sh/mcp";
|
|
6
7
|
const SKILL_SOURCE = "buildinternet/uploads";
|
|
7
8
|
const SKILL_NAMES = ["uploads-cli", "github-screenshots"];
|
|
8
|
-
const INSTALL_HELP = `uploads install — set up agent integrations (skills + remote MCP)
|
|
9
|
+
const INSTALL_HELP = `uploads install — set up agent integrations (skills + remote MCP + hooks)
|
|
9
10
|
|
|
10
|
-
Installs the github-screenshots and uploads-cli agent skills
|
|
11
|
-
|
|
12
|
-
|
|
11
|
+
Installs the github-screenshots and uploads-cli agent skills, registers the
|
|
12
|
+
hosted MCP server with Claude Code, and installs the PR screenshot reminder
|
|
13
|
+
hook for Grok / Cursor when those tools are present. The remote MCP endpoint
|
|
14
|
+
infers your workspace from the bearer token, so only the token is needed.
|
|
15
|
+
|
|
16
|
+
Claude Code and Codex ship the same reminder via their plugins (same command:
|
|
17
|
+
\`${HOOK_COMMAND}\`) — install those plugins instead of relying on this step.
|
|
13
18
|
|
|
14
19
|
Usage:
|
|
15
|
-
uploads install [skill|mcp|all] (default: all)
|
|
20
|
+
uploads install [skill|mcp|hooks|all] (default: all)
|
|
16
21
|
|
|
17
22
|
What it does:
|
|
18
23
|
skill Agent skills (via npx skills) — github-screenshots: visuals into
|
|
19
24
|
PRs/issues; uploads-cli: full CLI reference
|
|
20
25
|
mcp Hosted MCP server in Claude Code — put, list, attach, galleries
|
|
26
|
+
hooks PR screenshot reminder for Grok / Cursor (user-global manifests)
|
|
21
27
|
|
|
22
28
|
What runs under the hood:
|
|
23
29
|
skill npx -y skills add ${SKILL_SOURCE} --skill <name> -g -y -a '*'
|
|
24
30
|
(once per skill: ${SKILL_NAMES.join(", ")})
|
|
25
31
|
mcp claude mcp add --transport http uploads ${DEFAULT_MCP_URL} \\
|
|
26
32
|
--header "Authorization: Bearer <token>"
|
|
33
|
+
hooks write/merge ~/.grok/hooks/… and ~/.cursor/hooks.json when present
|
|
27
34
|
|
|
28
35
|
Options:
|
|
29
36
|
--url <endpoint> Remote MCP endpoint (default: ${DEFAULT_MCP_URL})
|
|
@@ -35,6 +42,7 @@ Examples:
|
|
|
35
42
|
uploads install
|
|
36
43
|
uploads install skill
|
|
37
44
|
uploads install mcp
|
|
45
|
+
uploads install hooks
|
|
38
46
|
uploads install --dry-run
|
|
39
47
|
`;
|
|
40
48
|
/** Mask Bearer credentials and the configured token in any printed text. */
|
|
@@ -46,7 +54,7 @@ function redactor(token) {
|
|
|
46
54
|
return out;
|
|
47
55
|
};
|
|
48
56
|
}
|
|
49
|
-
function runStep(run, command) {
|
|
57
|
+
export function runStep(run, command) {
|
|
50
58
|
try {
|
|
51
59
|
const output = run(command[0], command.slice(1)).trim();
|
|
52
60
|
return { command, ok: true, output: output || undefined };
|
|
@@ -123,6 +131,18 @@ function printSuccessFooter(steps, signedIn) {
|
|
|
123
131
|
process.stdout.write("\nNot signed in yet? Run `uploads login` once so put/attach/MCP can authenticate.\n");
|
|
124
132
|
}
|
|
125
133
|
}
|
|
134
|
+
function printHookResults(writes) {
|
|
135
|
+
if (writes.length === 0) {
|
|
136
|
+
process.stdout.write("hooks: nothing to do (no ~/.grok or ~/.cursor; Claude/Codex use their plugins)\n");
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
for (const w of writes) {
|
|
140
|
+
if (w.error)
|
|
141
|
+
process.stderr.write(`hooks:${w.path}: skipped — ${w.error}\n`);
|
|
142
|
+
else
|
|
143
|
+
process.stdout.write(`hooks:${w.path}: ${w.action}\n`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
126
146
|
export async function runInstall(args, opts, help = false) {
|
|
127
147
|
const parsed = parseCommandArgs(args);
|
|
128
148
|
if (help || parsed.help) {
|
|
@@ -130,8 +150,8 @@ export async function runInstall(args, opts, help = false) {
|
|
|
130
150
|
return 0;
|
|
131
151
|
}
|
|
132
152
|
const target = parsed.positionals[0] ?? "all";
|
|
133
|
-
if (!["skill", "mcp", "all"].includes(target)) {
|
|
134
|
-
throw new UsageError(`unknown install target: ${target} (expected skill, mcp, or all)`);
|
|
153
|
+
if (!["skill", "mcp", "hooks", "all"].includes(target)) {
|
|
154
|
+
throw new UsageError(`unknown install target: ${target} (expected skill, mcp, hooks, or all)`);
|
|
135
155
|
}
|
|
136
156
|
const url = flagString(parsed.flags, "--url") ?? DEFAULT_MCP_URL;
|
|
137
157
|
const name = flagString(parsed.flags, "--name") ?? "uploads";
|
|
@@ -143,6 +163,7 @@ export async function runInstall(args, opts, help = false) {
|
|
|
143
163
|
const signedIn = Boolean(token);
|
|
144
164
|
const redact = redactor(token);
|
|
145
165
|
const results = {};
|
|
166
|
+
let hookWrites = [];
|
|
146
167
|
if (target === "skill" || target === "all") {
|
|
147
168
|
if (human)
|
|
148
169
|
process.stdout.write("Installing skills…\n");
|
|
@@ -169,6 +190,22 @@ export async function runInstall(args, opts, help = false) {
|
|
|
169
190
|
results.mcp = dryRun ? { command, ok: true, skipped: "dry-run" } : runStep(run, command);
|
|
170
191
|
}
|
|
171
192
|
}
|
|
193
|
+
if (target === "hooks" || target === "all") {
|
|
194
|
+
if (human)
|
|
195
|
+
process.stdout.write("Installing agent hooks…\n");
|
|
196
|
+
hookWrites = installHookManifests({ home: opts.home, dryRun });
|
|
197
|
+
// Surface as a synthetic step so --json / failure accounting stay simple.
|
|
198
|
+
const hookErrors = hookWrites.filter((w) => w.error);
|
|
199
|
+
results.hooks = {
|
|
200
|
+
command: [HOOK_COMMAND],
|
|
201
|
+
ok: hookErrors.length === 0,
|
|
202
|
+
skipped: dryRun ? "dry-run" : undefined,
|
|
203
|
+
output: hookWrites
|
|
204
|
+
.map((w) => `${w.path}: ${w.action}${w.error ? ` (${w.error})` : ""}`)
|
|
205
|
+
.join("\n"),
|
|
206
|
+
error: hookErrors.length > 0 ? hookErrors.map((w) => w.error).join("; ") : undefined,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
172
209
|
const failed = Object.values(results).some((r) => !r.ok);
|
|
173
210
|
if (opts.json) {
|
|
174
211
|
const steps = Object.fromEntries(Object.entries(results).map(([key, r]) => [
|
|
@@ -185,6 +222,11 @@ export async function runInstall(args, opts, help = false) {
|
|
|
185
222
|
return failed ? 1 : 0;
|
|
186
223
|
}
|
|
187
224
|
printHumanSteps(results, redact, verbose);
|
|
225
|
+
// Path-level detail for hooks (printHumanSteps only shows the synthetic step).
|
|
226
|
+
if ((target === "hooks" || target === "all") &&
|
|
227
|
+
(dryRun || (human && (verbose || hookWrites.some((w) => w.action !== "skipped"))))) {
|
|
228
|
+
printHookResults(hookWrites);
|
|
229
|
+
}
|
|
188
230
|
const skillResults = Object.entries(results)
|
|
189
231
|
.filter(([step]) => step.startsWith("skill:"))
|
|
190
232
|
.map(([, r]) => r);
|
|
@@ -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,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Install thin user-global hook manifests for Grok and Cursor.
|
|
3
|
+
*
|
|
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
|
|
6
|
+
* ~/.codex/hooks.json here, or the reminder would fire twice.
|
|
7
|
+
*
|
|
8
|
+
* Idempotent: skip when our command string is already present.
|
|
9
|
+
*/
|
|
10
|
+
export declare const HOOK_COMMAND = "uploads hook pre-pr-screenshot";
|
|
11
|
+
export type HookWriteResult = {
|
|
12
|
+
path: string;
|
|
13
|
+
action: "wrote" | "merged" | "skipped" | "would-write" | "would-merge";
|
|
14
|
+
error?: string;
|
|
15
|
+
};
|
|
16
|
+
export type InstallHooksOptions = {
|
|
17
|
+
home?: string;
|
|
18
|
+
dryRun?: boolean;
|
|
19
|
+
targets?: Array<"grok" | "cursor">;
|
|
20
|
+
};
|
|
21
|
+
/** User-global manifests for Grok + Cursor only. */
|
|
22
|
+
export declare function installHookManifests(opts?: InstallHooksOptions): HookWriteResult[];
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Install thin user-global hook manifests for Grok and Cursor.
|
|
3
|
+
*
|
|
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
|
|
6
|
+
* ~/.codex/hooks.json here, or the reminder would fire twice.
|
|
7
|
+
*
|
|
8
|
+
* Idempotent: skip when our command string is already present.
|
|
9
|
+
*/
|
|
10
|
+
import fs from "node:fs";
|
|
11
|
+
import os from "node:os";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
export const HOOK_COMMAND = "uploads hook pre-pr-screenshot";
|
|
14
|
+
const TIMEOUT_SEC = 15;
|
|
15
|
+
const GROK_PAYLOAD = {
|
|
16
|
+
description: "Advisory reminder to stage screenshots on uploads.sh before opening a PR that touches UI files.",
|
|
17
|
+
hooks: {
|
|
18
|
+
PreToolUse: [
|
|
19
|
+
{
|
|
20
|
+
matcher: "Bash",
|
|
21
|
+
hooks: [
|
|
22
|
+
{
|
|
23
|
+
type: "command",
|
|
24
|
+
command: HOOK_COMMAND,
|
|
25
|
+
timeout: TIMEOUT_SEC,
|
|
26
|
+
statusMessage: "Checking staged screenshots",
|
|
27
|
+
},
|
|
28
|
+
],
|
|
29
|
+
},
|
|
30
|
+
],
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
function containsCommand(filePath) {
|
|
34
|
+
try {
|
|
35
|
+
return fs.readFileSync(filePath, "utf8").includes(HOOK_COMMAND);
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
function writeJson(filePath, value) {
|
|
42
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
43
|
+
fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
|
44
|
+
}
|
|
45
|
+
function commit(filePath, next, dryRun, existed) {
|
|
46
|
+
if (containsCommand(filePath))
|
|
47
|
+
return { path: filePath, action: "skipped" };
|
|
48
|
+
if (dryRun) {
|
|
49
|
+
return { path: filePath, action: existed ? "would-merge" : "would-write" };
|
|
50
|
+
}
|
|
51
|
+
writeJson(filePath, next);
|
|
52
|
+
return { path: filePath, action: existed ? "merged" : "wrote" };
|
|
53
|
+
}
|
|
54
|
+
function writeGrok(home, dryRun) {
|
|
55
|
+
const filePath = path.join(home, ".grok", "hooks", "uploads-pre-pr-screenshot.json");
|
|
56
|
+
return commit(filePath, GROK_PAYLOAD, dryRun, fs.existsSync(filePath));
|
|
57
|
+
}
|
|
58
|
+
function mergeCursor(home, dryRun) {
|
|
59
|
+
const filePath = path.join(home, ".cursor", "hooks.json");
|
|
60
|
+
const existed = fs.existsSync(filePath);
|
|
61
|
+
let existing = { version: 1, hooks: {} };
|
|
62
|
+
if (existed) {
|
|
63
|
+
try {
|
|
64
|
+
const raw = JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
65
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
66
|
+
return { path: filePath, action: "skipped", error: "hooks.json is not an object" };
|
|
67
|
+
}
|
|
68
|
+
existing = raw;
|
|
69
|
+
}
|
|
70
|
+
catch (err) {
|
|
71
|
+
return {
|
|
72
|
+
path: filePath,
|
|
73
|
+
action: "skipped",
|
|
74
|
+
error: `could not parse existing hooks.json: ${err instanceof Error ? err.message : String(err)}`,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
const hooks = existing.hooks && typeof existing.hooks === "object"
|
|
79
|
+
? { ...existing.hooks }
|
|
80
|
+
: {};
|
|
81
|
+
const list = Array.isArray(hooks.beforeShellExecution) ? [...hooks.beforeShellExecution] : [];
|
|
82
|
+
list.push({ command: HOOK_COMMAND, timeout: TIMEOUT_SEC });
|
|
83
|
+
hooks.beforeShellExecution = list;
|
|
84
|
+
existing.hooks = hooks;
|
|
85
|
+
if (existing.version === undefined)
|
|
86
|
+
existing.version = 1;
|
|
87
|
+
return commit(filePath, existing, dryRun, existed);
|
|
88
|
+
}
|
|
89
|
+
/** User-global manifests for Grok + Cursor only. */
|
|
90
|
+
export function installHookManifests(opts = {}) {
|
|
91
|
+
const home = opts.home ?? os.homedir();
|
|
92
|
+
const dryRun = Boolean(opts.dryRun);
|
|
93
|
+
const targets = opts.targets ??
|
|
94
|
+
[
|
|
95
|
+
fs.existsSync(path.join(home, ".grok")) && "grok",
|
|
96
|
+
fs.existsSync(path.join(home, ".cursor")) && "cursor",
|
|
97
|
+
].filter(Boolean);
|
|
98
|
+
const results = [];
|
|
99
|
+
for (const t of targets) {
|
|
100
|
+
try {
|
|
101
|
+
results.push(t === "grok" ? writeGrok(home, dryRun) : mergeCursor(home, dryRun));
|
|
102
|
+
}
|
|
103
|
+
catch (err) {
|
|
104
|
+
results.push({
|
|
105
|
+
path: t,
|
|
106
|
+
action: "skipped",
|
|
107
|
+
error: err instanceof Error ? err.message : String(err),
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return results;
|
|
112
|
+
}
|
|
@@ -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/telemetry.js
CHANGED
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.
|