@buildinternet/uploads 0.33.0 → 0.34.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 +2 -0
- package/dist/commands/install.d.ts +11 -0
- package/dist/commands/install.js +138 -33
- package/dist/commands.js +49 -17
- package/dist/comment-config.d.ts +66 -0
- package/dist/comment-config.js +188 -0
- package/dist/github-gh.js +21 -2
- package/dist/github.d.ts +22 -1
- package/dist/github.js +67 -27
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -17,6 +17,8 @@ uploads --version
|
|
|
17
17
|
uploads attach ./before.png ./after.png
|
|
18
18
|
uploads screenshot https://app.example --pr 123 --comment # capture + host in one step
|
|
19
19
|
uploads screenshot ./report.html --dark --selector "main"
|
|
20
|
+
uploads screenshot http://localhost:3000 --via local --annotate ./callouts.json
|
|
21
|
+
uploads annotate ./shot.png --spec ./callouts.json --out ./shot.marked.png
|
|
20
22
|
uploads put ./shot.png
|
|
21
23
|
uploads put ./shot.png --destination screenshots
|
|
22
24
|
uploads put ./shot.png --no-optimize
|
|
@@ -8,6 +8,17 @@ export interface StepResult {
|
|
|
8
8
|
error?: string;
|
|
9
9
|
output?: string;
|
|
10
10
|
}
|
|
11
|
+
/** npm 7+ for `npx -y`. Node 22 ships npm 10+; this catches ancient/system npm. */
|
|
12
|
+
export declare const MIN_NPM_MAJOR_FOR_SKILLS = 7;
|
|
13
|
+
/** Install guidance when a host binary is missing (not "run manually: <same binary>"). */
|
|
14
|
+
export declare function missingBinaryHint(binary: string): string;
|
|
15
|
+
export declare function npmTooOldHint(version: string): string;
|
|
16
|
+
/**
|
|
17
|
+
* Probe npx/npm once before the per-skill loop. Returns an error string when
|
|
18
|
+
* tooling is missing/too old; undefined means skill steps should proceed.
|
|
19
|
+
* Non-ENOENT npx failures are left for the real `skills add` to surface.
|
|
20
|
+
*/
|
|
21
|
+
export declare function probeSkillTooling(run: CommandRunner): string | undefined;
|
|
11
22
|
export declare function runStep(run: CommandRunner, command: string[]): StepResult;
|
|
12
23
|
export declare function runInstall(args: string[], opts: {
|
|
13
24
|
globals: GlobalFlags;
|
package/dist/commands/install.js
CHANGED
|
@@ -33,9 +33,11 @@ What it does:
|
|
|
33
33
|
|
|
34
34
|
What runs under the hood:
|
|
35
35
|
skill npx -y skills add ${SKILL_SOURCE} --skill <name> -g -y -a '*'
|
|
36
|
-
(once per skill: ${SKILL_NAMES.join(", ")}
|
|
36
|
+
(once per skill: ${SKILL_NAMES.join(", ")}; needs Node 22+ / npm 7+
|
|
37
|
+
with npx on PATH — missing tooling fails once with install guidance)
|
|
37
38
|
mcp claude mcp add --transport http uploads ${DEFAULT_MCP_URL} \\
|
|
38
39
|
--header "Authorization: Bearer <token>"
|
|
40
|
+
(needs the Claude Code CLI on PATH)
|
|
39
41
|
hooks write/merge ~/.grok/hooks/… and ~/.cursor/hooks.json when present
|
|
40
42
|
|
|
41
43
|
Options:
|
|
@@ -51,6 +53,8 @@ Examples:
|
|
|
51
53
|
uploads install hooks
|
|
52
54
|
uploads install --dry-run
|
|
53
55
|
`;
|
|
56
|
+
/** npm 7+ for `npx -y`. Node 22 ships npm 10+; this catches ancient/system npm. */
|
|
57
|
+
export const MIN_NPM_MAJOR_FOR_SKILLS = 7;
|
|
54
58
|
/** Mask Bearer credentials and the configured token in any printed text. */
|
|
55
59
|
function redactor(token) {
|
|
56
60
|
return (text) => {
|
|
@@ -60,6 +64,55 @@ function redactor(token) {
|
|
|
60
64
|
return out;
|
|
61
65
|
};
|
|
62
66
|
}
|
|
67
|
+
function isEnoent(err) {
|
|
68
|
+
return err?.code === "ENOENT";
|
|
69
|
+
}
|
|
70
|
+
/** Install guidance when a host binary is missing (not "run manually: <same binary>"). */
|
|
71
|
+
export function missingBinaryHint(binary) {
|
|
72
|
+
switch (binary) {
|
|
73
|
+
case "npx":
|
|
74
|
+
case "npm":
|
|
75
|
+
return (`${binary} not found on PATH — skill install needs Node.js (npm includes npx). ` +
|
|
76
|
+
`Install Node 22+ from https://nodejs.org (or your package manager), open a new shell, ` +
|
|
77
|
+
`confirm \`${binary} --version\` works, then re-run \`uploads install skill\`.`);
|
|
78
|
+
case "claude":
|
|
79
|
+
return (`claude not found on PATH — MCP install needs the Claude Code CLI. ` +
|
|
80
|
+
`Install it from https://docs.anthropic.com/en/docs/claude-code, ensure \`claude\` is on PATH, ` +
|
|
81
|
+
`then re-run \`uploads install mcp\`. Skills and hooks still work without Claude Code.`);
|
|
82
|
+
default:
|
|
83
|
+
return `${binary} not found on PATH — install it and ensure it is available in this shell.`;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
export function npmTooOldHint(version) {
|
|
87
|
+
return (`npm ${version} is too old for skill install (need npm ${MIN_NPM_MAJOR_FOR_SKILLS}+ for \`npx -y\`). ` +
|
|
88
|
+
`Upgrade Node/npm (Node 22+ recommended: https://nodejs.org), confirm \`npm --version\`, ` +
|
|
89
|
+
`then re-run \`uploads install skill\`.`);
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Probe npx/npm once before the per-skill loop. Returns an error string when
|
|
93
|
+
* tooling is missing/too old; undefined means skill steps should proceed.
|
|
94
|
+
* Non-ENOENT npx failures are left for the real `skills add` to surface.
|
|
95
|
+
*/
|
|
96
|
+
export function probeSkillTooling(run) {
|
|
97
|
+
try {
|
|
98
|
+
run("npx", ["--version"]);
|
|
99
|
+
}
|
|
100
|
+
catch (err) {
|
|
101
|
+
return isEnoent(err) ? missingBinaryHint("npx") : undefined;
|
|
102
|
+
}
|
|
103
|
+
try {
|
|
104
|
+
const out = run("npm", ["--version"]).trim();
|
|
105
|
+
const major = Number.parseInt(out.split(".")[0] ?? "", 10);
|
|
106
|
+
if (Number.isFinite(major) && major < MIN_NPM_MAJOR_FOR_SKILLS) {
|
|
107
|
+
return npmTooOldHint(out);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
catch (err) {
|
|
111
|
+
if (isEnoent(err))
|
|
112
|
+
return missingBinaryHint("npm");
|
|
113
|
+
}
|
|
114
|
+
return undefined;
|
|
115
|
+
}
|
|
63
116
|
export function runStep(run, command) {
|
|
64
117
|
try {
|
|
65
118
|
const output = run(command[0], command.slice(1)).trim();
|
|
@@ -67,10 +120,11 @@ export function runStep(run, command) {
|
|
|
67
120
|
}
|
|
68
121
|
catch (err) {
|
|
69
122
|
const message = err instanceof Error ? err.message : String(err);
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
:
|
|
73
|
-
|
|
123
|
+
return {
|
|
124
|
+
command,
|
|
125
|
+
ok: false,
|
|
126
|
+
error: isEnoent(err) ? missingBinaryHint(command[0]) : message,
|
|
127
|
+
};
|
|
74
128
|
}
|
|
75
129
|
}
|
|
76
130
|
function skillCommand(skill) {
|
|
@@ -115,31 +169,63 @@ function peekToken(globals) {
|
|
|
115
169
|
return undefined;
|
|
116
170
|
}
|
|
117
171
|
}
|
|
118
|
-
function
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
172
|
+
function printOneHumanStep(step, r, redact, verbose, mcpName) {
|
|
173
|
+
const cmd = redact(r.command.join(" "));
|
|
174
|
+
if (r.skipped === "dry-run") {
|
|
175
|
+
process.stdout.write(`${step}: would run — ${cmd}\n`);
|
|
176
|
+
}
|
|
177
|
+
else if (r.skipped === "sign-in") {
|
|
178
|
+
process.stdout.write(`${step}: skipped — ${redact(r.error ?? "needs sign-in")}\n`);
|
|
179
|
+
}
|
|
180
|
+
else if (r.skipped === "already-configured") {
|
|
181
|
+
process.stdout.write(`${step}: already configured — "${mcpName}" is registered in Claude Code (nothing to do)\n` +
|
|
182
|
+
` To re-register (e.g. with a new token): claude mcp remove ${mcpName} && uploads install mcp\n`);
|
|
183
|
+
}
|
|
184
|
+
else if (r.ok) {
|
|
185
|
+
process.stdout.write(`${step}: ok\n`);
|
|
186
|
+
if (verbose && r.output) {
|
|
187
|
+
process.stdout.write(` ${redact(r.output).split("\n").join("\n ")}\n`);
|
|
130
188
|
}
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
189
|
+
}
|
|
190
|
+
else {
|
|
191
|
+
process.stderr.write(`${step}: failed — ${redact(r.error ?? "")}\n`);
|
|
192
|
+
if (verbose)
|
|
193
|
+
process.stderr.write(` command: ${cmd}\n`);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
/** Shared error when every skill step failed identically; otherwise undefined. */
|
|
197
|
+
function identicalSkillFailure(skillEntries) {
|
|
198
|
+
if (skillEntries.length < 2)
|
|
199
|
+
return undefined;
|
|
200
|
+
const error = skillEntries[0]?.[1].error;
|
|
201
|
+
if (!error)
|
|
202
|
+
return undefined;
|
|
203
|
+
const allSame = skillEntries.every(([, r]) => !r.ok && !r.skipped && r.error === error);
|
|
204
|
+
return allSame ? error : undefined;
|
|
205
|
+
}
|
|
206
|
+
/** Collapse identical skill failures to one `skills:` line (missing npx, old npm, …). */
|
|
207
|
+
function printHumanSteps(results, redact, verbose, mcpName) {
|
|
208
|
+
const entries = Object.entries(results);
|
|
209
|
+
const skillEntries = entries.filter(([step]) => step.startsWith("skill:"));
|
|
210
|
+
const sharedError = identicalSkillFailure(skillEntries);
|
|
211
|
+
if (sharedError !== undefined) {
|
|
212
|
+
process.stderr.write(`skills: failed — ${redact(sharedError)}\n`);
|
|
213
|
+
if (verbose) {
|
|
214
|
+
for (const [step, r] of skillEntries) {
|
|
215
|
+
process.stderr.write(` ${step}: ${redact(r.command.join(" "))}\n`);
|
|
135
216
|
}
|
|
136
217
|
}
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
218
|
+
}
|
|
219
|
+
else {
|
|
220
|
+
for (const [step, r] of skillEntries) {
|
|
221
|
+
printOneHumanStep(step, r, redact, verbose, mcpName);
|
|
141
222
|
}
|
|
142
223
|
}
|
|
224
|
+
for (const [step, r] of entries) {
|
|
225
|
+
if (step.startsWith("skill:"))
|
|
226
|
+
continue;
|
|
227
|
+
printOneHumanStep(step, r, redact, verbose, mcpName);
|
|
228
|
+
}
|
|
143
229
|
}
|
|
144
230
|
function printSuccessFooter(steps, signedIn) {
|
|
145
231
|
process.stdout.write(`\nDone — ${steps.join(" and ")} ready.\n` +
|
|
@@ -187,11 +273,18 @@ export async function runInstall(args, opts, help = false) {
|
|
|
187
273
|
if (target === "skill" || target === "all") {
|
|
188
274
|
if (human)
|
|
189
275
|
process.stdout.write("Installing skills…\n");
|
|
276
|
+
const toolingError = dryRun ? undefined : probeSkillTooling(run);
|
|
190
277
|
for (const skill of SKILL_NAMES) {
|
|
191
278
|
const command = skillCommand(skill);
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
279
|
+
if (dryRun) {
|
|
280
|
+
results[`skill:${skill}`] = { command, ok: true, skipped: "dry-run" };
|
|
281
|
+
}
|
|
282
|
+
else if (toolingError) {
|
|
283
|
+
results[`skill:${skill}`] = { command, ok: false, error: toolingError };
|
|
284
|
+
}
|
|
285
|
+
else {
|
|
286
|
+
results[`skill:${skill}`] = runStep(run, command);
|
|
287
|
+
}
|
|
195
288
|
}
|
|
196
289
|
}
|
|
197
290
|
if (target === "mcp" || target === "all") {
|
|
@@ -264,14 +357,26 @@ export async function runInstall(args, opts, help = false) {
|
|
|
264
357
|
printSuccessFooter(stepLabels, signedIn);
|
|
265
358
|
}
|
|
266
359
|
else if (failed && !dryRun && skillsOk && results.mcp && !results.mcp.ok) {
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
360
|
+
let next;
|
|
361
|
+
if (results.mcp.skipped === "sign-in") {
|
|
362
|
+
next = "Sign in with `uploads login`, then re-run `uploads install mcp`.";
|
|
363
|
+
}
|
|
364
|
+
else if (results.mcp.error?.includes("not found on PATH")) {
|
|
365
|
+
next = "Install the Claude Code CLI (or skip MCP), then re-run `uploads install mcp`.";
|
|
366
|
+
}
|
|
367
|
+
else {
|
|
368
|
+
next = "Fix the MCP step above, then re-run `uploads install mcp`.";
|
|
369
|
+
}
|
|
270
370
|
process.stdout.write(`\nSkills are installed. ${next}\n`);
|
|
271
371
|
}
|
|
272
372
|
else if (failed && !dryRun && skillsFailed) {
|
|
273
|
-
//
|
|
274
|
-
|
|
373
|
+
// Closing guidance when skills fail (issue #191: used to be per-step only).
|
|
374
|
+
const skillErrText = skillResults.map((r) => r.error ?? "").join("\n");
|
|
375
|
+
const toolingMissing = /npx not found|npm not found|too old for skill install/i.test(skillErrText);
|
|
376
|
+
process.stdout.write(toolingMissing
|
|
377
|
+
? "\nSkill install needs a working Node.js toolchain (npx + npm 7+ on PATH). " +
|
|
378
|
+
"Fix that, then re-run `uploads install skill`.\n"
|
|
379
|
+
: "\nSkill install incomplete. Fix the errors above, then re-run `uploads install skill`.\n");
|
|
275
380
|
}
|
|
276
381
|
return failed ? 1 : 0;
|
|
277
382
|
}
|
package/dist/commands.js
CHANGED
|
@@ -5,6 +5,7 @@ import { createUploadsClient, } from "./client.js";
|
|
|
5
5
|
import { parseCommandArgs, flagString, flagBool, flagInt, flagValues, UsageError, } from "./cli-args.js";
|
|
6
6
|
import { resolvePutDefaults, workspaceMismatch, workspaceFromToken, } from "./config.js";
|
|
7
7
|
import { buildMarkdown } from "./embed.js";
|
|
8
|
+
import { readLocalRepoCommentConfig, resolveCommentOptions } from "./comment-config.js";
|
|
8
9
|
import { urlForGithubEmbed } from "./public-urls.js";
|
|
9
10
|
import { UploadsError } from "./errors.js";
|
|
10
11
|
import { writeJson, writeStdout } from "./io.js";
|
|
@@ -12,7 +13,7 @@ import { imageFactsFromBytes } from "./image-facts.js";
|
|
|
12
13
|
import { parseMetaFlags, validateMetaMap } from "./metadata.js";
|
|
13
14
|
import { mergeDerivedMeta, nearMissMetaWarnings, validateStateValue } from "./metadata-vocab.js";
|
|
14
15
|
import { mergeSidecarMeta } from "./sidecar.js";
|
|
15
|
-
import { ghAttachmentKey, ghBranchAttachmentKey, ghBranchKeyPrefix, ghKeyPrefix, ghMetadataFromTarget, parseGhKey, ghMetadataForBranch, attachmentsCommentBody, attachmentsMarker, normalizeGithubCoordinate, } from "./github.js";
|
|
16
|
+
import { ghAttachmentKey, ghBranchAttachmentKey, ghBranchKeyPrefix, ghKeyPrefix, ghMetadataFromTarget, parseGhKey, ghMetadataForBranch, attachmentsCommentBody, attachmentsMarker, AUTO_RENDER_OPTIONS, GH_FALLBACK_AUTHOR_NOTE, normalizeGithubCoordinate, } from "./github.js";
|
|
16
17
|
import { resolveRepo, resolveCurrentPullRequest, resolveCurrentBranch, resolveDefaultBranch, classifyGhNumber, execRunner, timedExecRunner, ghMetadataFromTargetWithTitle, upsertAttachmentsComment, } from "./github-gh.js";
|
|
17
18
|
import { deriveRepoFromGit } from "./keys.js";
|
|
18
19
|
import { resolvePutPrefix } from "./destinations.js";
|
|
@@ -104,9 +105,13 @@ Options:
|
|
|
104
105
|
--format human|url|markdown|json
|
|
105
106
|
--pr <num> Attach to a pull request: key gh/<owner>/<repo>/pull/<num>/<name> (stable URL, no hash)
|
|
106
107
|
--issue <num> Attach to an issue: key gh/<owner>/<repo>/issues/<num>/<name>
|
|
107
|
-
--comment With --pr/--issue
|
|
108
|
-
|
|
109
|
-
|
|
108
|
+
--comment With --pr/--issue, the managed comment sync runs by
|
|
109
|
+
default; --comment is accepted as a no-op for
|
|
110
|
+
back-compat (kept redundant with the default).
|
|
111
|
+
--no-comment With --pr/--issue: skip updating the managed comment
|
|
112
|
+
with attachments and linked galleries. Otherwise it
|
|
113
|
+
posts as uploads-sh[bot] when the GitHub App is
|
|
114
|
+
installed, or via local gh as a fallback.
|
|
110
115
|
--gallery <id> Add the uploaded object(s) to this public gallery
|
|
111
116
|
--meta <k=v> Queryable custom metadata (repeatable; value may contain "="): key ^[a-z][a-z0-9._-]{0,63}$, value 1-512 printable ASCII, max 24 pairs
|
|
112
117
|
Re-uploading to an existing key WITH --meta replaces that file's
|
|
@@ -117,7 +122,7 @@ Options:
|
|
|
117
122
|
--replace Allow overwriting an existing object on a strict (--key/default) key
|
|
118
123
|
(or UPLOADS_OVERWRITE=1). No effect on --pr/--issue, which always overwrite.
|
|
119
124
|
--dry-run Print key + public URL without uploading; reports if the key would replace
|
|
120
|
-
(or, on a strict key, be refused). Not with --
|
|
125
|
+
(or, on a strict key, be refused). Not with --gallery
|
|
121
126
|
|
|
122
127
|
A bare put (no --pr/--issue/--key) on a non-default git branch prints a one-line
|
|
123
128
|
nudge toward --pr/attach --branch (stderr in human mode, a "hint" field in
|
|
@@ -543,7 +548,31 @@ export async function syncAttachmentsComment(client, target, run, workspace, opt
|
|
|
543
548
|
}
|
|
544
549
|
}));
|
|
545
550
|
const marker = attachmentsMarker(workspace);
|
|
546
|
-
|
|
551
|
+
// Honor a committed .uploads.yml on the working tree (issue #307). This CLI
|
|
552
|
+
// process has no server-side WorkspaceRecord in scope (see the note above),
|
|
553
|
+
// so it resolves against `null` workspace defaults — a documented
|
|
554
|
+
// divergence from the bot path (commands.ts:728 / apps/api's
|
|
555
|
+
// resolveRepoCommentOptions, which also layers the workspace's own
|
|
556
|
+
// githubComment* fields).
|
|
557
|
+
let renderOptions = AUTO_RENDER_OPTIONS;
|
|
558
|
+
try {
|
|
559
|
+
const root = run("git", ["rev-parse", "--show-toplevel"]).trim();
|
|
560
|
+
const { config } = readLocalRepoCommentConfig(root);
|
|
561
|
+
const { options } = resolveCommentOptions(config, null);
|
|
562
|
+
renderOptions = {
|
|
563
|
+
imageWidth: options.imageWidth,
|
|
564
|
+
maxInlineImages: options.maxInlineImages,
|
|
565
|
+
metaPath: options.metaPath,
|
|
566
|
+
metaState: options.metaState,
|
|
567
|
+
note: options.note,
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
catch {
|
|
571
|
+
// Not a git repo, or the config file couldn't be read — fall back to auto.
|
|
572
|
+
}
|
|
573
|
+
// Append only on the local-gh path: bot posts already carry the uploads-sh
|
|
574
|
+
// bot identity, so this note would be wrong there.
|
|
575
|
+
const body = `${attachmentsCommentBody(items, previewGalleries, marker, renderOptions)}\n${GH_FALLBACK_AUTHOR_NOTE}`;
|
|
547
576
|
const count = items.length + previewGalleries.length;
|
|
548
577
|
// Empty (count 0) renders the neutral empty-state body but must not create a
|
|
549
578
|
// comment — it only rewrites one that already exists (`action: "skipped"`
|
|
@@ -1502,7 +1531,10 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
|
|
|
1502
1531
|
const destFlag = flagString(parsed.flags, "--destination");
|
|
1503
1532
|
const prefixFlag = flagString(parsed.flags, "--prefix");
|
|
1504
1533
|
const ghTarget = ghTargetFromFlags(parsed.flags, run);
|
|
1505
|
-
|
|
1534
|
+
// Comment sync runs by default with --pr/--issue (matches `attach`); opt
|
|
1535
|
+
// out with --no-comment. --comment is accepted as a redundant no-op for
|
|
1536
|
+
// back-compat with scripts written before this default flipped (#537).
|
|
1537
|
+
const wantComment = !parsed.flags.has("--no-comment");
|
|
1506
1538
|
const galleryId = flagString(parsed.flags, "--gallery");
|
|
1507
1539
|
const nameFlag = flagString(parsed.flags, "--name");
|
|
1508
1540
|
const dryRun = flagBool(parsed.flags, "--dry-run");
|
|
@@ -1521,17 +1553,21 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
|
|
|
1521
1553
|
validateMetaMap(merged);
|
|
1522
1554
|
return merged;
|
|
1523
1555
|
})();
|
|
1524
|
-
if (
|
|
1556
|
+
if (parsed.flags.has("--comment") && typeof parsed.flags.get("--comment") === "string") {
|
|
1525
1557
|
throw new UsageError("--comment takes no value — place it after the file argument");
|
|
1526
1558
|
}
|
|
1559
|
+
if (parsed.flags.has("--no-comment") && typeof parsed.flags.get("--no-comment") === "string") {
|
|
1560
|
+
throw new UsageError("--no-comment takes no value");
|
|
1561
|
+
}
|
|
1527
1562
|
if (parsed.flags.has("--auto") && typeof parsed.flags.get("--auto") === "string") {
|
|
1528
1563
|
throw new UsageError("--auto takes no value");
|
|
1529
1564
|
}
|
|
1530
1565
|
if (parsed.flags.has("--no-auto") && typeof parsed.flags.get("--no-auto") === "string") {
|
|
1531
1566
|
throw new UsageError("--no-auto takes no value");
|
|
1532
1567
|
}
|
|
1533
|
-
if (
|
|
1534
|
-
throw new UsageError("--comment requires --pr or --issue");
|
|
1568
|
+
if (parsed.flags.has("--no-comment") && !ghTarget) {
|
|
1569
|
+
throw new UsageError("--no-comment requires --pr or --issue");
|
|
1570
|
+
}
|
|
1535
1571
|
if (multi) {
|
|
1536
1572
|
if (keyHint)
|
|
1537
1573
|
throw new UsageError("--key cannot be combined with multiple files");
|
|
@@ -1558,12 +1594,8 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
|
|
|
1558
1594
|
if (keyHint)
|
|
1559
1595
|
throw new UsageError("--name cannot be combined with --key");
|
|
1560
1596
|
}
|
|
1561
|
-
if (dryRun)
|
|
1562
|
-
|
|
1563
|
-
throw new UsageError("--dry-run cannot be combined with --comment");
|
|
1564
|
-
if (galleryId)
|
|
1565
|
-
throw new UsageError("--dry-run cannot be combined with --gallery");
|
|
1566
|
-
}
|
|
1597
|
+
if (dryRun && galleryId)
|
|
1598
|
+
throw new UsageError("--dry-run cannot be combined with --gallery");
|
|
1567
1599
|
let resolvedPrefix;
|
|
1568
1600
|
try {
|
|
1569
1601
|
resolvedPrefix = resolvePutPrefix({
|
|
@@ -1758,7 +1790,7 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
|
|
|
1758
1790
|
}
|
|
1759
1791
|
let comment;
|
|
1760
1792
|
let commentError;
|
|
1761
|
-
if (wantComment && ghTarget && uploads.length > 0) {
|
|
1793
|
+
if (wantComment && ghTarget && !dryRun && uploads.length > 0) {
|
|
1762
1794
|
try {
|
|
1763
1795
|
comment = await syncAttachmentsComment(ctx.client, ghTarget, run, ctx.config.workspace);
|
|
1764
1796
|
if (logHuman)
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Published-CLI COPY of the workspace parser/resolver (issue #307)
|
|
3
|
+
* (packages/comment-config/src/index.ts). The published CLI imports no
|
|
4
|
+
* @uploads/* package, so that private package cannot be shared here — its
|
|
5
|
+
* parser/resolver is copied verbatim below. Kept in sync by
|
|
6
|
+
* test/fixtures/comment-config-golden.json, asserted from both sides (this
|
|
7
|
+
* package's comment-config.test.ts and the canonical package's
|
|
8
|
+
* index.test.ts) — change both copies together. `readLocalRepoCommentConfig`
|
|
9
|
+
* below is CLI-only: it reads the six candidate config paths off the working
|
|
10
|
+
* tree (the server instead fetches them from GitHub's contents API —
|
|
11
|
+
* apps/api/src/repo-comment-config.ts).
|
|
12
|
+
*/
|
|
13
|
+
export interface RepoCommentConfig {
|
|
14
|
+
imageWidth?: "auto" | "full" | number;
|
|
15
|
+
maxInlineImages?: number;
|
|
16
|
+
metaPath?: boolean;
|
|
17
|
+
metaState?: boolean;
|
|
18
|
+
linkToFilePage?: boolean;
|
|
19
|
+
note?: string;
|
|
20
|
+
}
|
|
21
|
+
export interface WorkspaceCommentDefaults {
|
|
22
|
+
imageWidth?: "full" | number;
|
|
23
|
+
maxInlineImages?: number;
|
|
24
|
+
showMetadata?: boolean;
|
|
25
|
+
linkToFilePage?: boolean;
|
|
26
|
+
note?: string;
|
|
27
|
+
}
|
|
28
|
+
export interface ResolvedCommentOptions {
|
|
29
|
+
imageWidth: "auto" | "full" | number;
|
|
30
|
+
maxInlineImages: number;
|
|
31
|
+
metaPath: boolean;
|
|
32
|
+
metaState: boolean;
|
|
33
|
+
linkToFilePage: boolean;
|
|
34
|
+
note: string | null;
|
|
35
|
+
}
|
|
36
|
+
export type OptionSource = "repo" | "workspace" | "auto";
|
|
37
|
+
export declare const AUTO_COMMENT_OPTIONS: ResolvedCommentOptions;
|
|
38
|
+
export declare const NOTE_MAX_CHARS = 500;
|
|
39
|
+
export declare function parseRepoCommentConfig(text: string, format: "yaml" | "json"): {
|
|
40
|
+
config: RepoCommentConfig | null;
|
|
41
|
+
warnings: string[];
|
|
42
|
+
};
|
|
43
|
+
export declare function resolveCommentOptions(repo: RepoCommentConfig | null, ws: WorkspaceCommentDefaults | null): {
|
|
44
|
+
options: ResolvedCommentOptions;
|
|
45
|
+
source: Record<keyof ResolvedCommentOptions, OptionSource>;
|
|
46
|
+
};
|
|
47
|
+
/**
|
|
48
|
+
* Candidate paths, checked in this order — the first hit wins. Must match
|
|
49
|
+
* the server's REPO_CONFIG_PATHS exactly (apps/api/src/repo-comment-config.ts)
|
|
50
|
+
* so a committed config resolves identically whether the bot or the local
|
|
51
|
+
* gh fallback renders the comment.
|
|
52
|
+
*/
|
|
53
|
+
export declare const REPO_CONFIG_PATHS: readonly [".uploads.yml", ".uploads.yaml", ".uploads.json", ".github/uploads.yml", ".github/uploads.yaml", ".github/uploads.json"];
|
|
54
|
+
/**
|
|
55
|
+
* Read the repo's comment config off the local working tree — the CLI has no
|
|
56
|
+
* GitHub App installation token to fetch via the contents API, so this reads
|
|
57
|
+
* `rootDir` directly. Returns the first candidate that exists (readable or
|
|
58
|
+
* not); an unreadable file (permissions, or a directory at that path) is
|
|
59
|
+
* treated as absent and the search continues to the next candidate, matching
|
|
60
|
+
* a 404 in the server-side fetch.
|
|
61
|
+
*/
|
|
62
|
+
export declare function readLocalRepoCommentConfig(rootDir: string): {
|
|
63
|
+
config: RepoCommentConfig | null;
|
|
64
|
+
path: string | null;
|
|
65
|
+
warnings: string[];
|
|
66
|
+
};
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Published-CLI COPY of the workspace parser/resolver (issue #307)
|
|
3
|
+
* (packages/comment-config/src/index.ts). The published CLI imports no
|
|
4
|
+
* @uploads/* package, so that private package cannot be shared here — its
|
|
5
|
+
* parser/resolver is copied verbatim below. Kept in sync by
|
|
6
|
+
* test/fixtures/comment-config-golden.json, asserted from both sides (this
|
|
7
|
+
* package's comment-config.test.ts and the canonical package's
|
|
8
|
+
* index.test.ts) — change both copies together. `readLocalRepoCommentConfig`
|
|
9
|
+
* below is CLI-only: it reads the six candidate config paths off the working
|
|
10
|
+
* tree (the server instead fetches them from GitHub's contents API —
|
|
11
|
+
* apps/api/src/repo-comment-config.ts).
|
|
12
|
+
*/
|
|
13
|
+
import fs from "node:fs";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import { parse as parseYaml } from "yaml";
|
|
16
|
+
export const AUTO_COMMENT_OPTIONS = {
|
|
17
|
+
imageWidth: "auto",
|
|
18
|
+
maxInlineImages: 16, // MAX_INLINE_ATTACHMENT_IMAGES — renderer copies own the constant
|
|
19
|
+
metaPath: true,
|
|
20
|
+
metaState: true,
|
|
21
|
+
linkToFilePage: true,
|
|
22
|
+
note: null,
|
|
23
|
+
};
|
|
24
|
+
export const NOTE_MAX_CHARS = 500;
|
|
25
|
+
const WIDTH_MIN = 160;
|
|
26
|
+
const WIDTH_MAX = 1000;
|
|
27
|
+
const MAX_INLINE_MIN = 1;
|
|
28
|
+
const MAX_INLINE_MAX = 48;
|
|
29
|
+
const clamp = (n, lo, hi) => Math.min(hi, Math.max(lo, Math.round(n)));
|
|
30
|
+
export function parseRepoCommentConfig(text, format) {
|
|
31
|
+
const warnings = [];
|
|
32
|
+
let root;
|
|
33
|
+
try {
|
|
34
|
+
root = format === "json" ? JSON.parse(text) : parseYaml(text);
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return { config: null, warnings: ["config file could not be parsed; ignoring it"] };
|
|
38
|
+
}
|
|
39
|
+
if (typeof root !== "object" || root === null || Array.isArray(root)) {
|
|
40
|
+
return { config: null, warnings };
|
|
41
|
+
}
|
|
42
|
+
const comment = root.comment;
|
|
43
|
+
if (typeof comment !== "object" || comment === null || Array.isArray(comment)) {
|
|
44
|
+
return { config: null, warnings };
|
|
45
|
+
}
|
|
46
|
+
const c = comment;
|
|
47
|
+
const config = {};
|
|
48
|
+
// imageWidth: "auto" | "full" | finite number (clamped)
|
|
49
|
+
if ("imageWidth" in c) {
|
|
50
|
+
const v = c.imageWidth;
|
|
51
|
+
if (v === "auto" || v === "full")
|
|
52
|
+
config.imageWidth = v;
|
|
53
|
+
else if (typeof v === "number" && Number.isFinite(v))
|
|
54
|
+
config.imageWidth = clamp(v, WIDTH_MIN, WIDTH_MAX);
|
|
55
|
+
else
|
|
56
|
+
warnings.push(`imageWidth: expected "auto", "full", or a number; dropped`);
|
|
57
|
+
}
|
|
58
|
+
// maxInlineImages: finite number (clamped)
|
|
59
|
+
if ("maxInlineImages" in c) {
|
|
60
|
+
const v = c.maxInlineImages;
|
|
61
|
+
if (typeof v === "number" && Number.isFinite(v))
|
|
62
|
+
config.maxInlineImages = clamp(v, MAX_INLINE_MIN, MAX_INLINE_MAX);
|
|
63
|
+
else
|
|
64
|
+
warnings.push(`maxInlineImages: expected a number; dropped`);
|
|
65
|
+
}
|
|
66
|
+
// linkToFilePage: boolean
|
|
67
|
+
if ("linkToFilePage" in c) {
|
|
68
|
+
const v = c.linkToFilePage;
|
|
69
|
+
if (typeof v === "boolean")
|
|
70
|
+
config.linkToFilePage = v;
|
|
71
|
+
else
|
|
72
|
+
warnings.push(`linkToFilePage: expected a boolean; dropped`);
|
|
73
|
+
}
|
|
74
|
+
// meta.path / meta.state: booleans nested under `meta`
|
|
75
|
+
if ("meta" in c) {
|
|
76
|
+
const v = c.meta;
|
|
77
|
+
if (typeof v === "object" && v !== null && !Array.isArray(v)) {
|
|
78
|
+
const meta = v;
|
|
79
|
+
if ("path" in meta) {
|
|
80
|
+
if (typeof meta.path === "boolean")
|
|
81
|
+
config.metaPath = meta.path;
|
|
82
|
+
else
|
|
83
|
+
warnings.push(`meta.path: expected a boolean; dropped`);
|
|
84
|
+
}
|
|
85
|
+
if ("state" in meta) {
|
|
86
|
+
if (typeof meta.state === "boolean")
|
|
87
|
+
config.metaState = meta.state;
|
|
88
|
+
else
|
|
89
|
+
warnings.push(`meta.state: expected a boolean; dropped`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
warnings.push(`meta: expected an object; dropped`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
// note: non-empty trimmed string, max NOTE_MAX_CHARS (never truncated)
|
|
97
|
+
if ("note" in c) {
|
|
98
|
+
const v = c.note;
|
|
99
|
+
if (typeof v === "string") {
|
|
100
|
+
const trimmed = v.trim();
|
|
101
|
+
if (trimmed.length === 0) {
|
|
102
|
+
// empty/whitespace note is treated as absent
|
|
103
|
+
}
|
|
104
|
+
else if (trimmed.length > NOTE_MAX_CHARS) {
|
|
105
|
+
warnings.push(`note: longer than ${NOTE_MAX_CHARS} characters; dropped (not truncated)`);
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
config.note = trimmed;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
else {
|
|
112
|
+
warnings.push(`note: expected a string; dropped`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return { config, warnings };
|
|
116
|
+
}
|
|
117
|
+
export function resolveCommentOptions(repo, ws) {
|
|
118
|
+
const wsAsRepo = {
|
|
119
|
+
...(ws?.imageWidth !== undefined ? { imageWidth: ws.imageWidth } : {}),
|
|
120
|
+
...(ws?.maxInlineImages !== undefined ? { maxInlineImages: ws.maxInlineImages } : {}),
|
|
121
|
+
...(ws?.showMetadata !== undefined
|
|
122
|
+
? { metaPath: ws.showMetadata, metaState: ws.showMetadata }
|
|
123
|
+
: {}),
|
|
124
|
+
...(ws?.linkToFilePage !== undefined ? { linkToFilePage: ws.linkToFilePage } : {}),
|
|
125
|
+
...(ws?.note ? { note: ws.note } : {}),
|
|
126
|
+
};
|
|
127
|
+
const options = { ...AUTO_COMMENT_OPTIONS };
|
|
128
|
+
const source = Object.fromEntries(Object.keys(AUTO_COMMENT_OPTIONS).map((k) => [k, "auto"]));
|
|
129
|
+
const apply = (cfg, from) => {
|
|
130
|
+
for (const key of [
|
|
131
|
+
"imageWidth",
|
|
132
|
+
"maxInlineImages",
|
|
133
|
+
"metaPath",
|
|
134
|
+
"metaState",
|
|
135
|
+
"linkToFilePage",
|
|
136
|
+
]) {
|
|
137
|
+
if (cfg[key] !== undefined && source[key] === "auto") {
|
|
138
|
+
options[key] = cfg[key];
|
|
139
|
+
source[key] = from;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
if (cfg.note !== undefined && source.note === "auto") {
|
|
143
|
+
options.note = cfg.note;
|
|
144
|
+
source.note = from;
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
if (repo)
|
|
148
|
+
apply(repo, "repo");
|
|
149
|
+
apply(wsAsRepo, "workspace");
|
|
150
|
+
return { options, source };
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Candidate paths, checked in this order — the first hit wins. Must match
|
|
154
|
+
* the server's REPO_CONFIG_PATHS exactly (apps/api/src/repo-comment-config.ts)
|
|
155
|
+
* so a committed config resolves identically whether the bot or the local
|
|
156
|
+
* gh fallback renders the comment.
|
|
157
|
+
*/
|
|
158
|
+
export const REPO_CONFIG_PATHS = [
|
|
159
|
+
".uploads.yml",
|
|
160
|
+
".uploads.yaml",
|
|
161
|
+
".uploads.json",
|
|
162
|
+
".github/uploads.yml",
|
|
163
|
+
".github/uploads.yaml",
|
|
164
|
+
".github/uploads.json",
|
|
165
|
+
];
|
|
166
|
+
/**
|
|
167
|
+
* Read the repo's comment config off the local working tree — the CLI has no
|
|
168
|
+
* GitHub App installation token to fetch via the contents API, so this reads
|
|
169
|
+
* `rootDir` directly. Returns the first candidate that exists (readable or
|
|
170
|
+
* not); an unreadable file (permissions, or a directory at that path) is
|
|
171
|
+
* treated as absent and the search continues to the next candidate, matching
|
|
172
|
+
* a 404 in the server-side fetch.
|
|
173
|
+
*/
|
|
174
|
+
export function readLocalRepoCommentConfig(rootDir) {
|
|
175
|
+
for (const candidate of REPO_CONFIG_PATHS) {
|
|
176
|
+
let text;
|
|
177
|
+
try {
|
|
178
|
+
text = fs.readFileSync(join(rootDir, candidate), "utf8");
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
const format = candidate.endsWith(".json") ? "json" : "yaml";
|
|
184
|
+
const { config, warnings } = parseRepoCommentConfig(text, format);
|
|
185
|
+
return { config, path: candidate, warnings };
|
|
186
|
+
}
|
|
187
|
+
return { config: null, path: null, warnings: [] };
|
|
188
|
+
}
|
package/dist/github-gh.js
CHANGED
|
@@ -2,7 +2,26 @@ import { execFileSync } from "node:child_process";
|
|
|
2
2
|
import { UsageError } from "./cli-args.js";
|
|
3
3
|
import { ATTACHMENTS_MARKER, ghMetadataFromTarget, isValidRepo, parseRepoFromRemoteUrl, } from "./github.js";
|
|
4
4
|
import { META_VALUE_MAX, isMetaValueSafe } from "./metadata.js";
|
|
5
|
-
|
|
5
|
+
/**
|
|
6
|
+
* Windows npm shims are `.cmd`/`.bat`; bare `execFileSync` ENOENTs them.
|
|
7
|
+
* Retry once with `shell: true` so PATHEXT resolves the shim. Other platforms
|
|
8
|
+
* and non-ENOENT errors stay on the no-shell path. Args are from our CLI, not
|
|
9
|
+
* free-form shell strings.
|
|
10
|
+
*/
|
|
11
|
+
function execFileSyncCompat(cmd, args, opts) {
|
|
12
|
+
try {
|
|
13
|
+
return execFileSync(cmd, args, opts);
|
|
14
|
+
}
|
|
15
|
+
catch (err) {
|
|
16
|
+
const isWinShim = process.platform === "win32" &&
|
|
17
|
+
err.code === "ENOENT" &&
|
|
18
|
+
!/\.(cmd|bat|exe|com)$/i.test(cmd);
|
|
19
|
+
if (isWinShim)
|
|
20
|
+
return execFileSync(cmd, args, { ...opts, shell: true });
|
|
21
|
+
throw err;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
export const execRunner = (cmd, args, input) => execFileSyncCompat(cmd, args, { encoding: "utf8", input, stdio: ["pipe", "pipe", "pipe"] });
|
|
6
25
|
/**
|
|
7
26
|
* A `CommandRunner` bounded by `timeoutMs` (node's native `execFileSync`
|
|
8
27
|
* `timeout` option). There is no other subprocess-timeout wrapper in this
|
|
@@ -10,7 +29,7 @@ export const execRunner = (cmd, args, input) => execFileSync(cmd, args, { encodi
|
|
|
10
29
|
* that must never block its caller for long (e.g. the bare-`put` nudge's `gh
|
|
11
30
|
* pr view` check, issue #393), pass this instead of the default `execRunner`.
|
|
12
31
|
*/
|
|
13
|
-
export const timedExecRunner = (timeoutMs) => (cmd, args, input) =>
|
|
32
|
+
export const timedExecRunner = (timeoutMs) => (cmd, args, input) => execFileSyncCompat(cmd, args, {
|
|
14
33
|
encoding: "utf8",
|
|
15
34
|
input,
|
|
16
35
|
stdio: ["pipe", "pipe", "pipe"],
|
package/dist/github.d.ts
CHANGED
|
@@ -74,6 +74,22 @@ export declare function attachmentsMarker(workspace?: string): string;
|
|
|
74
74
|
* into a `<details>` link list. Keeps very large threads from becoming a wall
|
|
75
75
|
* of images. */
|
|
76
76
|
export declare const MAX_INLINE_ATTACHMENT_IMAGES = 16;
|
|
77
|
+
/**
|
|
78
|
+
* Per-render knobs for the managed comment (issue #307), sourced from repo
|
|
79
|
+
* comment config. `imageWidth: "auto"` preserves today's per-item width
|
|
80
|
+
* heuristics (`attachmentImageWidth`/`posterImageWidth`/pair cap); `"full"`
|
|
81
|
+
* omits the `width` attribute entirely; a number overrides every width site.
|
|
82
|
+
*/
|
|
83
|
+
export interface CommentRenderOptions {
|
|
84
|
+
imageWidth: "auto" | "full" | number;
|
|
85
|
+
maxInlineImages: number;
|
|
86
|
+
metaPath: boolean;
|
|
87
|
+
metaState: boolean;
|
|
88
|
+
note: string | null;
|
|
89
|
+
}
|
|
90
|
+
/** Today's behavior, expressed as options — the default for every caller that
|
|
91
|
+
* hasn't opted into repo comment config. */
|
|
92
|
+
export declare const AUTO_RENDER_OPTIONS: CommentRenderOptions;
|
|
77
93
|
export interface AttachmentItem {
|
|
78
94
|
key: string;
|
|
79
95
|
url: string | null;
|
|
@@ -137,4 +153,9 @@ export declare const ATTACHMENT_IMAGE_WIDTH_PAIR = 320;
|
|
|
137
153
|
* Render the one marker-owned GitHub comment. When there are no galleries this
|
|
138
154
|
* intentionally preserves the legacy attachment-only body byte-for-byte.
|
|
139
155
|
*/
|
|
140
|
-
export declare function attachmentsCommentBody(items: AttachmentItem[], galleries?: GalleryCommentItem[], marker?: string): string;
|
|
156
|
+
export declare function attachmentsCommentBody(items: AttachmentItem[], galleries?: GalleryCommentItem[], marker?: string, options?: CommentRenderOptions): string;
|
|
157
|
+
/**
|
|
158
|
+
* Extra footer line appended only on the local-`gh` fallback path (never on
|
|
159
|
+
* bot-authored comments). Short note that the App isn't on this repo yet.
|
|
160
|
+
*/
|
|
161
|
+
export declare const GH_FALLBACK_AUTHOR_NOTE = "<sub><a href=\"https://github.com/apps/uploads-sh\">Install the uploads GitHub App</a> for bot-managed comments.</sub>";
|
package/dist/github.js
CHANGED
|
@@ -162,6 +162,15 @@ export function attachmentsMarker(workspace) {
|
|
|
162
162
|
* into a `<details>` link list. Keeps very large threads from becoming a wall
|
|
163
163
|
* of images. */
|
|
164
164
|
export const MAX_INLINE_ATTACHMENT_IMAGES = 16;
|
|
165
|
+
/** Today's behavior, expressed as options — the default for every caller that
|
|
166
|
+
* hasn't opted into repo comment config. */
|
|
167
|
+
export const AUTO_RENDER_OPTIONS = {
|
|
168
|
+
imageWidth: "auto",
|
|
169
|
+
maxInlineImages: MAX_INLINE_ATTACHMENT_IMAGES,
|
|
170
|
+
metaPath: true,
|
|
171
|
+
metaState: true,
|
|
172
|
+
note: null,
|
|
173
|
+
};
|
|
165
174
|
/** Default max width for images in the managed attachments comment (HTML img). */
|
|
166
175
|
export const ATTACHMENT_IMAGE_WIDTH_DEFAULT = 400;
|
|
167
176
|
/** Portrait / device mockups — keep phones readable, not full-column. */
|
|
@@ -241,19 +250,19 @@ function escapeMarkdownText(s) {
|
|
|
241
250
|
* alone it is a stray character, and as a prefix next to `state` it is
|
|
242
251
|
* noise. Only exact `/` after trim is suppressed.
|
|
243
252
|
*/
|
|
244
|
-
function metaCaptionParts(meta) {
|
|
253
|
+
function metaCaptionParts(meta, options) {
|
|
245
254
|
const parts = [];
|
|
246
255
|
const path = meta?.path?.trim();
|
|
247
|
-
if (path && path !== "/")
|
|
256
|
+
if (options.metaPath && path && path !== "/")
|
|
248
257
|
parts.push(path);
|
|
249
258
|
const state = meta?.state?.trim();
|
|
250
|
-
if (state)
|
|
259
|
+
if (options.metaState && state)
|
|
251
260
|
parts.push(state);
|
|
252
261
|
return parts;
|
|
253
262
|
}
|
|
254
263
|
/** `<sub>` caption body for an inline image, or null when there is nothing to say. */
|
|
255
|
-
function metaCaptionHtml(meta) {
|
|
256
|
-
const parts = metaCaptionParts(meta);
|
|
264
|
+
function metaCaptionHtml(meta, options) {
|
|
265
|
+
const parts = metaCaptionParts(meta, options);
|
|
257
266
|
return parts.length > 0 ? parts.map(escapeHtmlText).join(" · ") : null;
|
|
258
267
|
}
|
|
259
268
|
/**
|
|
@@ -261,12 +270,32 @@ function metaCaptionHtml(meta) {
|
|
|
261
270
|
* HTML-escapes first, then markdown-escapes: HTML escaping introduces no
|
|
262
271
|
* backslashes or brackets, so the markdown pass cannot corrupt its entities.
|
|
263
272
|
*/
|
|
264
|
-
function metaCaptionMarkdown(meta) {
|
|
265
|
-
const parts = metaCaptionParts(meta);
|
|
273
|
+
function metaCaptionMarkdown(meta, options) {
|
|
274
|
+
const parts = metaCaptionParts(meta, options);
|
|
266
275
|
if (parts.length === 0)
|
|
267
276
|
return "";
|
|
268
277
|
return ` · ${parts.map((p) => escapeMarkdownText(escapeHtmlText(p))).join(" · ")}`;
|
|
269
278
|
}
|
|
279
|
+
/** Resolved pixel width for an image site, or `null` meaning "omit the width
|
|
280
|
+
* attribute". `"auto"` defers to the caller's per-item heuristic (`autoPx`);
|
|
281
|
+
* `"full"` always omits; a number always wins. */
|
|
282
|
+
function resolvedWidth(autoPx, options) {
|
|
283
|
+
if (options.imageWidth === "auto")
|
|
284
|
+
return autoPx;
|
|
285
|
+
if (options.imageWidth === "full")
|
|
286
|
+
return null;
|
|
287
|
+
return options.imageWidth;
|
|
288
|
+
}
|
|
289
|
+
/** Every `<img>` tag in the managed comment goes through here so the
|
|
290
|
+
* omit-width-attribute case ("full") can't drift between call sites.
|
|
291
|
+
*
|
|
292
|
+
* `escapedAlt`/`escapedSrc` must already be attribute-escaped (via
|
|
293
|
+
* `escapeHtmlAttr`) by the caller — this function interpolates them as-is
|
|
294
|
+
* and does not escape them itself. */
|
|
295
|
+
function imgTag(w, escapedAlt, escapedSrc) {
|
|
296
|
+
const widthAttr = w === null ? "" : ` width="${w}"`;
|
|
297
|
+
return `<img${widthAttr} alt="${escapedAlt}" src="${escapedSrc}">`;
|
|
298
|
+
}
|
|
270
299
|
/** Extract the filename stem's before/after token (issue #419 fallback pairing).
|
|
271
300
|
* `base` is the stem lowercased with the token removed; `null` when the stem
|
|
272
301
|
* carries no recognizable before/after token. Requires a separator (`-`, `_`,
|
|
@@ -363,32 +392,35 @@ function pairAttachments(items, isImageAt) {
|
|
|
363
392
|
* than a standalone image so two side by side stay under GitHub's comment
|
|
364
393
|
* column width (and don't overflow on mobile). */
|
|
365
394
|
export const ATTACHMENT_IMAGE_WIDTH_PAIR = 320;
|
|
366
|
-
function renderPairCell(item, label) {
|
|
395
|
+
function renderPairCell(item, label, options) {
|
|
367
396
|
const name = item.key.slice(item.key.lastIndexOf("/") + 1);
|
|
368
397
|
const src = item.embedUrl ?? item.url;
|
|
369
398
|
const link = item.pageUrl ?? item.url;
|
|
370
|
-
const
|
|
399
|
+
const autoPx = Math.min(attachmentImageWidth(name), ATTACHMENT_IMAGE_WIDTH_PAIR);
|
|
400
|
+
const w = resolvedWidth(autoPx, options);
|
|
371
401
|
const alt = escapeHtmlAttr(name);
|
|
372
402
|
const href = escapeHtmlAttr((link ?? src));
|
|
373
403
|
const imgSrc = escapeHtmlAttr(src);
|
|
374
|
-
const caption = metaCaptionHtml(item.meta);
|
|
404
|
+
const caption = metaCaptionHtml(item.meta, options);
|
|
375
405
|
const captionHtml = caption ? `<br><sub>${caption}</sub>` : "";
|
|
376
|
-
return `<td align="center"><sub><strong>${label}</strong></sub><br><a href="${href}"
|
|
406
|
+
return `<td align="center"><sub><strong>${label}</strong></sub><br><a href="${href}">${imgTag(w, alt, imgSrc)}</a>${captionHtml}</td>`;
|
|
377
407
|
}
|
|
378
408
|
/** One side-by-side before/after row (issue #419): a single HTML table so
|
|
379
409
|
* GitHub renders both images on one line, with `Before`/`After` labels and
|
|
380
410
|
* each side's usual path/state caption preserved underneath. */
|
|
381
|
-
function renderPairRow(beforeItem, afterItem) {
|
|
382
|
-
return `<table><tr>${renderPairCell(beforeItem, "Before")}${renderPairCell(afterItem, "After")}</tr></table>`;
|
|
411
|
+
function renderPairRow(beforeItem, afterItem, options) {
|
|
412
|
+
return `<table><tr>${renderPairCell(beforeItem, "Before", options)}${renderPairCell(afterItem, "After", options)}</tr></table>`;
|
|
383
413
|
}
|
|
384
414
|
/**
|
|
385
415
|
* Render the one marker-owned GitHub comment. When there are no galleries this
|
|
386
416
|
* intentionally preserves the legacy attachment-only body byte-for-byte.
|
|
387
417
|
*/
|
|
388
|
-
export function attachmentsCommentBody(items, galleries = [], marker = ATTACHMENTS_MARKER) {
|
|
418
|
+
export function attachmentsCommentBody(items, galleries = [], marker = ATTACHMENTS_MARKER, options = AUTO_RENDER_OPTIONS) {
|
|
389
419
|
const sorted = items.toSorted((a, b) => a.key.localeCompare(b.key));
|
|
390
420
|
const sortedGalleries = galleries.toSorted((a, b) => a.title.localeCompare(b.title) || a.url.localeCompare(b.url));
|
|
391
421
|
const lines = [marker];
|
|
422
|
+
if (options.note)
|
|
423
|
+
lines.push(options.note, "");
|
|
392
424
|
if (sortedGalleries.length > 0) {
|
|
393
425
|
lines.push("### 🖼️ Galleries", "");
|
|
394
426
|
for (const gallery of sortedGalleries) {
|
|
@@ -397,7 +429,8 @@ export function attachmentsCommentBody(items, galleries = [], marker = ATTACHMEN
|
|
|
397
429
|
for (const preview of gallery.previews ?? []) {
|
|
398
430
|
const previewHref = preview.itemUrl ? escapeHtmlAttr(preview.itemUrl) : href;
|
|
399
431
|
const previewSrc = escapeHtmlAttr(preview.embedUrl ?? preview.url);
|
|
400
|
-
|
|
432
|
+
const previewW = resolvedWidth(320, options);
|
|
433
|
+
lines.push(`<a href="${previewHref}">${imgTag(previewW, escapeHtmlAttr(preview.alt), previewSrc)}</a>`);
|
|
401
434
|
}
|
|
402
435
|
lines.push(`<sub><a href="${href}">Open gallery</a></sub>`, "");
|
|
403
436
|
}
|
|
@@ -421,12 +454,12 @@ export function attachmentsCommentBody(items, galleries = [], marker = ATTACHMEN
|
|
|
421
454
|
const partnerIdx = partnerOf.get(idx);
|
|
422
455
|
if (partnerIdx !== undefined) {
|
|
423
456
|
const partner = sorted[partnerIdx];
|
|
424
|
-
if (inlinedImages + 2 <=
|
|
457
|
+
if (inlinedImages + 2 <= options.maxInlineImages) {
|
|
425
458
|
inlinedImages += 2;
|
|
426
459
|
consumedByPair.add(partnerIdx);
|
|
427
460
|
const beforeItem = roleOf.get(idx) === "before" ? item : partner;
|
|
428
461
|
const afterItem = roleOf.get(idx) === "before" ? partner : item;
|
|
429
|
-
lines.push(renderPairRow(beforeItem, afterItem), "");
|
|
462
|
+
lines.push(renderPairRow(beforeItem, afterItem, options), "");
|
|
430
463
|
continue;
|
|
431
464
|
}
|
|
432
465
|
// Cap already full for a two-image row — degrade this pair to two
|
|
@@ -442,7 +475,7 @@ export function attachmentsCommentBody(items, galleries = [], marker = ATTACHMEN
|
|
|
442
475
|
const isImage = Boolean(src) && inferContentType(name).startsWith("image/");
|
|
443
476
|
const isPosterVideo = Boolean(item.posterUrl) && inferContentType(name).startsWith("video/");
|
|
444
477
|
const inlines = isImage || isPosterVideo;
|
|
445
|
-
if (inlines && inlinedImages >=
|
|
478
|
+
if (inlines && inlinedImages >= options.maxInlineImages) {
|
|
446
479
|
// Cap hit — defer to the collapsed overflow list below rather than
|
|
447
480
|
// embedding every remaining image inline.
|
|
448
481
|
overflowImages.push(item);
|
|
@@ -450,37 +483,39 @@ export function attachmentsCommentBody(items, galleries = [], marker = ATTACHMEN
|
|
|
450
483
|
}
|
|
451
484
|
if (isPosterVideo) {
|
|
452
485
|
inlinedImages++;
|
|
453
|
-
const
|
|
486
|
+
const autoPx = posterImageWidth(item.videoMeta, name);
|
|
487
|
+
const w = resolvedWidth(autoPx, options);
|
|
454
488
|
const href = escapeHtmlAttr(link ?? item.posterUrl);
|
|
455
|
-
lines.push(`<a href="${href}"
|
|
489
|
+
lines.push(`<a href="${href}">${imgTag(w, escapeHtmlAttr(name), escapeHtmlAttr(item.posterUrl))}</a>`);
|
|
456
490
|
// GitHub strips <video>, so a still frame needs an explicit affordance
|
|
457
491
|
// or it reads as a screenshot.
|
|
458
492
|
const parts = ["▶ Play video"];
|
|
459
493
|
if (item.videoMeta?.durationSeconds != null) {
|
|
460
494
|
parts.push(formatDuration(item.videoMeta.durationSeconds));
|
|
461
495
|
}
|
|
462
|
-
parts.push(...metaCaptionParts(item.meta).map(escapeHtmlText));
|
|
496
|
+
parts.push(...metaCaptionParts(item.meta, options).map(escapeHtmlText));
|
|
463
497
|
lines.push(`<sub>${parts.join(" · ")}</sub>`, "");
|
|
464
498
|
}
|
|
465
499
|
else if (isImage) {
|
|
466
500
|
inlinedImages++;
|
|
467
501
|
// Markdown ![]() has no width control — phone frames become full-column giants.
|
|
468
502
|
// img src uses embed host when available (Camo revalidates); click-through prefers the file page.
|
|
469
|
-
const
|
|
503
|
+
const autoPx = attachmentImageWidth(name);
|
|
504
|
+
const w = resolvedWidth(autoPx, options);
|
|
470
505
|
const alt = escapeHtmlAttr(name);
|
|
471
506
|
const href = escapeHtmlAttr(link ?? src);
|
|
472
507
|
const imgSrc = escapeHtmlAttr(src);
|
|
473
|
-
lines.push(`<a href="${href}"
|
|
474
|
-
const caption = metaCaptionHtml(item.meta);
|
|
508
|
+
lines.push(`<a href="${href}">${imgTag(w, alt, imgSrc)}</a>`);
|
|
509
|
+
const caption = metaCaptionHtml(item.meta, options);
|
|
475
510
|
if (caption)
|
|
476
511
|
lines.push(`<sub>${caption}</sub>`);
|
|
477
512
|
lines.push("");
|
|
478
513
|
}
|
|
479
514
|
else if (link) {
|
|
480
|
-
lines.push(`- [${name}](${link})${metaCaptionMarkdown(item.meta)}`);
|
|
515
|
+
lines.push(`- [${name}](${link})${metaCaptionMarkdown(item.meta, options)}`);
|
|
481
516
|
}
|
|
482
517
|
else {
|
|
483
|
-
lines.push(`- ${name}${metaCaptionMarkdown(item.meta)}`);
|
|
518
|
+
lines.push(`- ${name}${metaCaptionMarkdown(item.meta, options)}`);
|
|
484
519
|
}
|
|
485
520
|
}
|
|
486
521
|
if (overflowImages.length > 0) {
|
|
@@ -489,7 +524,7 @@ export function attachmentsCommentBody(items, galleries = [], marker = ATTACHMEN
|
|
|
489
524
|
for (const item of overflowImages) {
|
|
490
525
|
const name = item.key.slice(item.key.lastIndexOf("/") + 1);
|
|
491
526
|
const link = item.pageUrl ?? item.url;
|
|
492
|
-
const suffix = metaCaptionMarkdown(item.meta);
|
|
527
|
+
const suffix = metaCaptionMarkdown(item.meta, options);
|
|
493
528
|
lines.push(link ? `- [${name}](${link})${suffix}` : `- ${name}${suffix}`);
|
|
494
529
|
}
|
|
495
530
|
lines.push("", "</details>", "");
|
|
@@ -506,3 +541,8 @@ export function attachmentsCommentBody(items, galleries = [], marker = ATTACHMEN
|
|
|
506
541
|
lines.push('<sub>Add media: <code>uploads put <file> --pr <N> --comment</code> (or <code>--issue <N></code>) · <a href="https://uploads.sh/docs/github-app">docs</a></sub>');
|
|
507
542
|
return lines.join("\n");
|
|
508
543
|
}
|
|
544
|
+
/**
|
|
545
|
+
* Extra footer line appended only on the local-`gh` fallback path (never on
|
|
546
|
+
* bot-authored comments). Short note that the App isn't on this repo yet.
|
|
547
|
+
*/
|
|
548
|
+
export const GH_FALLBACK_AUTHOR_NOTE = '<sub><a href="https://github.com/apps/uploads-sh">Install the uploads GitHub App</a> for bot-managed comments.</sub>';
|
package/dist/index.d.ts
CHANGED
|
@@ -7,7 +7,7 @@ export { UploadsError, type UploadsErrorCode } from "./errors.js";
|
|
|
7
7
|
export { createUploadsClient, type UploadsClient, type PutOptions, type ProvenanceInput, type ListOptions, type PutResult, type ListItem, type ListResult, type HeadResult, type DeleteResult, type GalleryItem, type Gallery, type GallerySummary, type GalleryListOptions, type GalleryListResult, type CreateGalleryOptions, type AddGalleryItemOptions, type DeleteGalleryOptions, type HealthResult, type UsageResult, type ReconcileResult, type PurgeExpiredResult, type PurgeExpiredResponse, type FindFilesOptions, type FindFilesItem, type FindFilesResult, type GetMetadataResult, type PatchMetadataOptions, } from "./client.js";
|
|
8
8
|
export { buildCliProvenance } from "./provenance.js";
|
|
9
9
|
export { META_KEY_RE, META_VALUE_MAX, META_MAX_KEYS, META_MAX_TOTAL_BYTES, validateMetaEntry, parseMetaPair, parseMetaFlags, } from "./metadata.js";
|
|
10
|
-
export { ATTACHMENTS_MARKER, attachmentsCommentBody, ghAttachmentKey, ghKeyPrefix, ghMetadataFromTarget, ghBranchAttachmentKey, ghBranchKeyPrefix, ghMetadataForBranch, isValidRepo, parseRepoFromRemoteUrl, type AttachmentItem, type GhTarget, type GhTargetKind, } from "./github.js";
|
|
10
|
+
export { ATTACHMENTS_MARKER, attachmentsCommentBody, GH_FALLBACK_AUTHOR_NOTE, ghAttachmentKey, ghKeyPrefix, ghMetadataFromTarget, ghBranchAttachmentKey, ghBranchKeyPrefix, ghMetadataForBranch, isValidRepo, parseRepoFromRemoteUrl, type AttachmentItem, type GhTarget, type GhTargetKind, } from "./github.js";
|
|
11
11
|
export { DEFAULT_OPTIMIZE_MAX_EDGE, DEFAULT_OPTIMIZE_QUALITY, optimizeImageForUpload, rewriteKeyExtension, withImageExtension, type OptimizeImageOptions, type OptimizeImageResult, type OptimizeOutputFormat, } from "./optimize.js";
|
|
12
12
|
export { FRAME_PRESETS, applyFrame, listFramePresets, resolveFrameId, type FrameFit, type FrameOptions, type FrameResult, } from "./frame.js";
|
|
13
13
|
export { execRunner, resolveRepo, upsertAttachmentsComment, type CommandRunner, } from "./github-gh.js";
|
package/dist/index.js
CHANGED
|
@@ -7,7 +7,7 @@ export { UploadsError } from "./errors.js";
|
|
|
7
7
|
export { createUploadsClient, } from "./client.js";
|
|
8
8
|
export { buildCliProvenance } from "./provenance.js";
|
|
9
9
|
export { META_KEY_RE, META_VALUE_MAX, META_MAX_KEYS, META_MAX_TOTAL_BYTES, validateMetaEntry, parseMetaPair, parseMetaFlags, } from "./metadata.js";
|
|
10
|
-
export { ATTACHMENTS_MARKER, attachmentsCommentBody, ghAttachmentKey, ghKeyPrefix, ghMetadataFromTarget, ghBranchAttachmentKey, ghBranchKeyPrefix, ghMetadataForBranch, isValidRepo, parseRepoFromRemoteUrl, } from "./github.js";
|
|
10
|
+
export { ATTACHMENTS_MARKER, attachmentsCommentBody, GH_FALLBACK_AUTHOR_NOTE, ghAttachmentKey, ghKeyPrefix, ghMetadataFromTarget, ghBranchAttachmentKey, ghBranchKeyPrefix, ghMetadataForBranch, isValidRepo, parseRepoFromRemoteUrl, } from "./github.js";
|
|
11
11
|
export { DEFAULT_OPTIMIZE_MAX_EDGE, DEFAULT_OPTIMIZE_QUALITY, optimizeImageForUpload, rewriteKeyExtension, withImageExtension, } from "./optimize.js";
|
|
12
12
|
export { FRAME_PRESETS, applyFrame, listFramePresets, resolveFrameId, } from "./frame.js";
|
|
13
13
|
export { execRunner, resolveRepo, upsertAttachmentsComment, } from "./github-gh.js";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@buildinternet/uploads",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.34.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,
|
|
@@ -61,7 +61,8 @@
|
|
|
61
61
|
"opentype.js": "^2.0.0",
|
|
62
62
|
"perfect-freehand": "^1.2.3",
|
|
63
63
|
"roughjs": "^4.6.6",
|
|
64
|
-
"sharp": "^0.35.3"
|
|
64
|
+
"sharp": "^0.35.3",
|
|
65
|
+
"yaml": "^2.6.0"
|
|
65
66
|
},
|
|
66
67
|
"optionalDependencies": {
|
|
67
68
|
"playwright-core": "^1.61.1"
|