@buildinternet/uploads 0.14.0 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli-catalog.js +7 -0
- package/dist/cli.js +6 -2
- package/dist/client.d.ts +50 -0
- package/dist/client.js +31 -0
- package/dist/commands/screenshot.js +34 -5
- package/dist/commands.d.ts +41 -1
- package/dist/commands.js +350 -17
- package/dist/github-gh.d.ts +9 -1
- package/dist/github-gh.js +35 -3
- package/dist/github.d.ts +35 -1
- package/dist/github.js +88 -7
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/mcp/tools.js +11 -11
- package/package.json +1 -1
package/dist/cli-catalog.js
CHANGED
|
@@ -25,6 +25,7 @@ export const PUT_LIKE_FLAGS = [
|
|
|
25
25
|
"--ref",
|
|
26
26
|
"--pr",
|
|
27
27
|
"--issue",
|
|
28
|
+
"--branch",
|
|
28
29
|
"--comment",
|
|
29
30
|
"--no-comment",
|
|
30
31
|
"--format",
|
|
@@ -75,6 +76,7 @@ export const SCREENSHOT_FLAGS = [
|
|
|
75
76
|
"--no-git",
|
|
76
77
|
"--pr",
|
|
77
78
|
"--issue",
|
|
79
|
+
"--branch",
|
|
78
80
|
"--comment",
|
|
79
81
|
"--gallery",
|
|
80
82
|
"--meta",
|
|
@@ -131,6 +133,11 @@ export const ROOT_COMMANDS = [
|
|
|
131
133
|
name: "comment",
|
|
132
134
|
summary: "Create/update a PR/issue attachments comment (via gh)",
|
|
133
135
|
},
|
|
136
|
+
{
|
|
137
|
+
name: "github",
|
|
138
|
+
summary: "Claim/inspect this workspace's binding to a GitHub repo",
|
|
139
|
+
subcommands: [{ name: "link", summary: "Claim or inspect the repo binding" }],
|
|
140
|
+
},
|
|
134
141
|
{
|
|
135
142
|
name: "list",
|
|
136
143
|
summary: "List objects (--meta k=v filters by queryable metadata)",
|
package/dist/cli.js
CHANGED
|
@@ -4,7 +4,7 @@ import { UploadsError } from "./errors.js";
|
|
|
4
4
|
import { commandWorkspace, flagString, isHelpFlag, parseArgv, parseCommandArgs, UsageError, } from "./cli-args.js";
|
|
5
5
|
import { formatRootHelp, wantsFullHelp } from "./cli-help.js";
|
|
6
6
|
import { colorEnabled, createStyle } from "./cli-style.js";
|
|
7
|
-
import { runPut, runAttach, runList, runFind, runMeta, runDelete, runHealth, runDoctor, runComment, runUsage, runReconcile, runPurgeExpired, runGallery, } from "./commands.js";
|
|
7
|
+
import { runPut, runAttach, runList, runFind, runMeta, runDelete, runHealth, runDoctor, runComment, runGithub, runUsage, runReconcile, runPurgeExpired, runGallery, } from "./commands.js";
|
|
8
8
|
import { runConfig } from "./commands/config.js";
|
|
9
9
|
import { runSetup } from "./commands/setup.js";
|
|
10
10
|
import { runLogin } from "./commands/login.js";
|
|
@@ -278,7 +278,8 @@ export async function runCli(argv) {
|
|
|
278
278
|
case "reconcile":
|
|
279
279
|
case "purge-expired":
|
|
280
280
|
case "doctor":
|
|
281
|
-
case "comment":
|
|
281
|
+
case "comment":
|
|
282
|
+
case "github": {
|
|
282
283
|
const ctx = createContext(parsed.globals, !showHelp, cmdArgs);
|
|
283
284
|
switch (parsed.command) {
|
|
284
285
|
case "attach":
|
|
@@ -296,6 +297,9 @@ export async function runCli(argv) {
|
|
|
296
297
|
case "comment":
|
|
297
298
|
code = await runComment(ctx, cmdArgs, showHelp);
|
|
298
299
|
break;
|
|
300
|
+
case "github":
|
|
301
|
+
code = await runGithub(ctx, cmdArgs, showHelp);
|
|
302
|
+
break;
|
|
299
303
|
case "list":
|
|
300
304
|
code = await runList(ctx, cmdArgs, showHelp);
|
|
301
305
|
break;
|
package/dist/client.d.ts
CHANGED
|
@@ -73,6 +73,8 @@ export interface ListItem {
|
|
|
73
73
|
key: string;
|
|
74
74
|
url: string | null;
|
|
75
75
|
embedUrl?: string | null;
|
|
76
|
+
/** Canonical `/f/` page URL when the API provides it. Absent on older API deployments. */
|
|
77
|
+
pageUrl?: string;
|
|
76
78
|
size?: number;
|
|
77
79
|
uploaded?: string;
|
|
78
80
|
}
|
|
@@ -180,7 +182,36 @@ export type GithubCommentResult = {
|
|
|
180
182
|
} | {
|
|
181
183
|
posted: false;
|
|
182
184
|
reason: GithubCommentDeclineReason;
|
|
185
|
+
message?: string;
|
|
186
|
+
fixUrl?: string;
|
|
187
|
+
required?: string[];
|
|
183
188
|
};
|
|
189
|
+
/** `POST /v1/:workspace/github/promote` request/response (server contract, PR #310). */
|
|
190
|
+
export interface PromoteBranchAttachmentsOptions {
|
|
191
|
+
repo: string;
|
|
192
|
+
num: number;
|
|
193
|
+
branch: string;
|
|
194
|
+
}
|
|
195
|
+
export interface PromoteSkip {
|
|
196
|
+
key: string;
|
|
197
|
+
reason: string;
|
|
198
|
+
}
|
|
199
|
+
export interface PromoteBranchAttachmentsResult {
|
|
200
|
+
promoted: string[];
|
|
201
|
+
skipped: PromoteSkip[];
|
|
202
|
+
}
|
|
203
|
+
/** `GET`/`POST /v1/:workspace/github/link` result (server contract, phase 4b). */
|
|
204
|
+
export interface GithubLinkResult {
|
|
205
|
+
repo: string;
|
|
206
|
+
linked: boolean;
|
|
207
|
+
workspace: string | null;
|
|
208
|
+
source: string | null;
|
|
209
|
+
createdAt: string | null;
|
|
210
|
+
}
|
|
211
|
+
/** POST-only: whether THIS call's workspace ended up owning the binding. */
|
|
212
|
+
export interface GithubLinkClaimResult extends GithubLinkResult {
|
|
213
|
+
claimed: boolean;
|
|
214
|
+
}
|
|
184
215
|
export interface HealthResult {
|
|
185
216
|
ok: boolean;
|
|
186
217
|
}
|
|
@@ -412,6 +443,25 @@ export declare function createUploadsClient(config: UploadsClientConfig): {
|
|
|
412
443
|
num: number;
|
|
413
444
|
kind: "pull" | "issues";
|
|
414
445
|
}): Promise<GithubCommentResult>;
|
|
446
|
+
/**
|
|
447
|
+
* Promote a workspace's branch-staged attachments into a PR's stable
|
|
448
|
+
* attachment prefix (server contract, PR #310 — degrade-safe callers
|
|
449
|
+
* treat any failure, including a 404 from an older/self-hosted worker
|
|
450
|
+
* that doesn't have this route yet, as "nothing promoted").
|
|
451
|
+
*/
|
|
452
|
+
promoteBranchAttachments(opts: PromoteBranchAttachmentsOptions): Promise<PromoteBranchAttachmentsResult>;
|
|
453
|
+
/** Current binding for `repo`, or `{ linked: false }` if unclaimed. Throws
|
|
454
|
+
* `UploadsError` (status 404) on an older/self-hosted server without this
|
|
455
|
+
* route — callers treat that as "bindings unsupported". */
|
|
456
|
+
githubLinkStatus(repo: string): Promise<GithubLinkResult>;
|
|
457
|
+
/**
|
|
458
|
+
* Explicitly claim `repo` for this workspace (first-claim-wins — see
|
|
459
|
+
* github-repo-links.ts server-side). `claimed: false` in the result means
|
|
460
|
+
* the repo is already bound to a DIFFERENT workspace; this call never
|
|
461
|
+
* steals it. Throws `UploadsError` (status 404) on an older/self-hosted
|
|
462
|
+
* server without this route.
|
|
463
|
+
*/
|
|
464
|
+
githubLinkClaim(repo: string): Promise<GithubLinkClaimResult>;
|
|
415
465
|
health(): Promise<HealthResult>;
|
|
416
466
|
/** Workspace storage / upload counters (+ limits when configured). */
|
|
417
467
|
usage(): Promise<UsageResult>;
|
package/dist/client.js
CHANGED
|
@@ -458,6 +458,37 @@ export function createUploadsClient(config) {
|
|
|
458
458
|
headers: { "Content-Type": "application/json" },
|
|
459
459
|
});
|
|
460
460
|
},
|
|
461
|
+
/**
|
|
462
|
+
* Promote a workspace's branch-staged attachments into a PR's stable
|
|
463
|
+
* attachment prefix (server contract, PR #310 — degrade-safe callers
|
|
464
|
+
* treat any failure, including a 404 from an older/self-hosted worker
|
|
465
|
+
* that doesn't have this route yet, as "nothing promoted").
|
|
466
|
+
*/
|
|
467
|
+
async promoteBranchAttachments(opts) {
|
|
468
|
+
return request("POST", `${config.apiUrl}/v1/${encodeURIComponent(config.workspace)}/github/promote`, {
|
|
469
|
+
body: new TextEncoder().encode(JSON.stringify(opts)),
|
|
470
|
+
headers: { "Content-Type": "application/json" },
|
|
471
|
+
});
|
|
472
|
+
},
|
|
473
|
+
/** Current binding for `repo`, or `{ linked: false }` if unclaimed. Throws
|
|
474
|
+
* `UploadsError` (status 404) on an older/self-hosted server without this
|
|
475
|
+
* route — callers treat that as "bindings unsupported". */
|
|
476
|
+
async githubLinkStatus(repo) {
|
|
477
|
+
return request("GET", `${config.apiUrl}/v1/${encodeURIComponent(config.workspace)}/github/link?repo=${encodeURIComponent(repo)}`);
|
|
478
|
+
},
|
|
479
|
+
/**
|
|
480
|
+
* Explicitly claim `repo` for this workspace (first-claim-wins — see
|
|
481
|
+
* github-repo-links.ts server-side). `claimed: false` in the result means
|
|
482
|
+
* the repo is already bound to a DIFFERENT workspace; this call never
|
|
483
|
+
* steals it. Throws `UploadsError` (status 404) on an older/self-hosted
|
|
484
|
+
* server without this route.
|
|
485
|
+
*/
|
|
486
|
+
async githubLinkClaim(repo) {
|
|
487
|
+
return request("POST", `${config.apiUrl}/v1/${encodeURIComponent(config.workspace)}/github/link`, {
|
|
488
|
+
body: new TextEncoder().encode(JSON.stringify({ repo })),
|
|
489
|
+
headers: { "Content-Type": "application/json" },
|
|
490
|
+
});
|
|
491
|
+
},
|
|
461
492
|
async health() {
|
|
462
493
|
return request("GET", `${config.apiUrl}/health`, { auth: false });
|
|
463
494
|
},
|
|
@@ -2,11 +2,12 @@ import { readFileSync, writeFileSync } from "node:fs";
|
|
|
2
2
|
import { basename } from "node:path";
|
|
3
3
|
import { flagBool, flagInt, flagString, flagValues, parseCommandArgs, UsageError, } from "../cli-args.js";
|
|
4
4
|
import { writeCommandHelp } from "../cli-style.js";
|
|
5
|
-
import { frameOptionsFromFlags, ghTargetFromFlags, optimizeOptionsFromFlags, syncAttachmentsComment, commentViaSuffix, uploadPreparedImage, } from "../commands.js";
|
|
5
|
+
import { branchFromFlags, frameOptionsFromFlags, ghTargetFromFlags, optimizeOptionsFromFlags, syncAttachmentsComment, commentViaSuffix, uploadPreparedImage, } from "../commands.js";
|
|
6
6
|
import { resolvePutDefaults } from "../config.js";
|
|
7
7
|
import { loadDefaultsRaw, resolveScreenshotDefaults } from "../config-file.js";
|
|
8
8
|
import { resolvePutPrefix } from "../destinations.js";
|
|
9
|
-
import { execRunner, ghMetadataFromTargetWithTitle } from "../github-gh.js";
|
|
9
|
+
import { execRunner, ghMetadataFromTargetWithTitle, resolveRepo, } from "../github-gh.js";
|
|
10
|
+
import { ghBranchAttachmentKey, ghMetadataForBranch } from "../github.js";
|
|
10
11
|
import { parseMetaFlags, validateMetaMap } from "../metadata.js";
|
|
11
12
|
import { writeJson, writeStdout } from "../io.js";
|
|
12
13
|
import { assertHideSelector, captureScreenshot, parseViewport, parseWaitUntil, } from "../screenshot.js";
|
|
@@ -66,6 +67,11 @@ Options:
|
|
|
66
67
|
--keep-exif Keep EXIF/XMP/ICC when optimizing
|
|
67
68
|
--pr <num> Attach to a pull request (stable URL, no hash)
|
|
68
69
|
--issue <num> Attach to an issue
|
|
70
|
+
--branch [name] Stage against a branch, pre-PR (default: current git branch):
|
|
71
|
+
key gh/<owner>/<repo>/branch/<branch>/<name>; not with
|
|
72
|
+
--pr/--issue/--comment/--key/--ref/--prefix. No managed
|
|
73
|
+
comment exists yet — promoting into the PR's comment once
|
|
74
|
+
one opens ships in a later phase.
|
|
69
75
|
--comment With --pr/--issue: update the managed attachments comment.
|
|
70
76
|
Posts as uploads-sh[bot] when the GitHub App is installed;
|
|
71
77
|
otherwise via local gh.
|
|
@@ -84,6 +90,7 @@ Examples:
|
|
|
84
90
|
uploads screenshot http://localhost:3000 --via local --full-page
|
|
85
91
|
uploads screenshot https://uploads.sh --pr 128 --comment
|
|
86
92
|
uploads screenshot ./card.html --no-upload --out ./card.png
|
|
93
|
+
uploads screenshot https://app.example/settings --branch
|
|
87
94
|
`;
|
|
88
95
|
function colorSchemeFromFlags(flags) {
|
|
89
96
|
const dark = flagBool(flags, "--dark");
|
|
@@ -164,9 +171,13 @@ captureImpl = captureScreenshot) {
|
|
|
164
171
|
const destFlag = flagString(parsed.flags, "--destination");
|
|
165
172
|
const prefixFlag = flagString(parsed.flags, "--prefix");
|
|
166
173
|
const ghTarget = ghTargetFromFlags(parsed.flags, run);
|
|
174
|
+
const branchArg = branchFromFlags(parsed.flags, run);
|
|
167
175
|
const wantComment = parsed.flags.has("--comment");
|
|
168
176
|
const galleryId = flagString(parsed.flags, "--gallery");
|
|
169
177
|
const dryRun = flagBool(parsed.flags, "--dry-run");
|
|
178
|
+
if (branchArg !== undefined && ghTarget) {
|
|
179
|
+
throw new UsageError("--branch cannot be combined with --pr/--issue");
|
|
180
|
+
}
|
|
170
181
|
if (wantComment && !ghTarget)
|
|
171
182
|
throw new UsageError("--comment requires --pr or --issue");
|
|
172
183
|
if (ghTarget) {
|
|
@@ -177,6 +188,16 @@ captureImpl = captureScreenshot) {
|
|
|
177
188
|
if (prefixFlag)
|
|
178
189
|
throw new UsageError("--prefix cannot be combined with --pr/--issue");
|
|
179
190
|
}
|
|
191
|
+
if (branchArg !== undefined) {
|
|
192
|
+
if (wantComment)
|
|
193
|
+
throw new UsageError("--branch cannot be combined with --comment");
|
|
194
|
+
if (keyHint)
|
|
195
|
+
throw new UsageError("--key cannot be combined with --branch");
|
|
196
|
+
if (flagString(parsed.flags, "--ref"))
|
|
197
|
+
throw new UsageError("--ref cannot be combined with --branch");
|
|
198
|
+
if (prefixFlag)
|
|
199
|
+
throw new UsageError("--prefix cannot be combined with --branch");
|
|
200
|
+
}
|
|
180
201
|
if (dryRun) {
|
|
181
202
|
if (wantComment)
|
|
182
203
|
throw new UsageError("--dry-run cannot be combined with --comment");
|
|
@@ -185,13 +206,14 @@ captureImpl = captureScreenshot) {
|
|
|
185
206
|
if (noUpload)
|
|
186
207
|
throw new UsageError("--dry-run cannot be combined with --no-upload");
|
|
187
208
|
}
|
|
209
|
+
const branchRepo = branchArg !== undefined ? resolveRepo(flagString(parsed.flags, "--repo"), run) : undefined;
|
|
188
210
|
let resolvedPrefix;
|
|
189
211
|
try {
|
|
190
212
|
resolvedPrefix = resolvePutPrefix({
|
|
191
213
|
destination: destFlag,
|
|
192
214
|
prefix: prefixFlag,
|
|
193
215
|
key: keyHint,
|
|
194
|
-
ghAttachment: Boolean(ghTarget),
|
|
216
|
+
ghAttachment: Boolean(ghTarget) || branchArg !== undefined,
|
|
195
217
|
});
|
|
196
218
|
}
|
|
197
219
|
catch (err) {
|
|
@@ -218,6 +240,10 @@ captureImpl = captureScreenshot) {
|
|
|
218
240
|
metadata = { ...metaExtras, ...ghMetadataFromTargetWithTitle(ghTarget, run) };
|
|
219
241
|
validateMetaMap(metadata);
|
|
220
242
|
}
|
|
243
|
+
else if (branchArg !== undefined) {
|
|
244
|
+
metadata = { ...metaExtras, ...ghMetadataForBranch(branchRepo, branchArg) };
|
|
245
|
+
validateMetaMap(metadata);
|
|
246
|
+
}
|
|
221
247
|
else if (Object.keys(metaExtras).length > 0) {
|
|
222
248
|
validateMetaMap(metaExtras);
|
|
223
249
|
}
|
|
@@ -260,12 +286,15 @@ captureImpl = captureScreenshot) {
|
|
|
260
286
|
}
|
|
261
287
|
const repo = flagString(parsed.flags, "--repo") ?? putDefaults.repo;
|
|
262
288
|
const ref = flagString(parsed.flags, "--ref") ?? putDefaults.ref;
|
|
289
|
+
const branchKey = branchArg !== undefined
|
|
290
|
+
? ghBranchAttachmentKey(branchRepo, branchArg, captured.filename)
|
|
291
|
+
: undefined;
|
|
263
292
|
const alt = altFlag ?? basename(captured.filename);
|
|
264
293
|
const { result, prepared, markdown } = await uploadPreparedImage(ctx.client, captured.png, captured.filename, {
|
|
265
294
|
frame: frameOpts,
|
|
266
295
|
optimize: optimizeOpts,
|
|
267
296
|
ghTarget,
|
|
268
|
-
key: keyHint,
|
|
297
|
+
key: keyHint ?? branchKey,
|
|
269
298
|
prefix: resolvedPrefix ?? putDefaults.prefix,
|
|
270
299
|
repo,
|
|
271
300
|
ref,
|
|
@@ -295,7 +324,7 @@ captureImpl = captureScreenshot) {
|
|
|
295
324
|
let commentError;
|
|
296
325
|
if (wantComment && ghTarget) {
|
|
297
326
|
try {
|
|
298
|
-
comment = await syncAttachmentsComment(ctx.client, ghTarget, run);
|
|
327
|
+
comment = await syncAttachmentsComment(ctx.client, ghTarget, run, ctx.config.workspace);
|
|
299
328
|
if (logHuman)
|
|
300
329
|
process.stderr.write(`>> attachments comment ${comment.action}${commentViaSuffix(comment.via)}\n`);
|
|
301
330
|
}
|
package/dist/commands.d.ts
CHANGED
|
@@ -28,6 +28,14 @@ export declare function readFileArg(fileArg: string): Uint8Array;
|
|
|
28
28
|
export declare function makeGhTarget(pr: number | undefined, issue: number | undefined, repoArg: string | undefined, run: CommandRunner): GhTarget | undefined;
|
|
29
29
|
/** Reads --pr/--issue (+ --repo) into a GhTarget; undefined when neither flag is present. */
|
|
30
30
|
export declare function ghTargetFromFlags(flags: CommandFlags["flags"], run: CommandRunner): GhTarget | undefined;
|
|
31
|
+
/**
|
|
32
|
+
* Reads `--branch [name]` — an optional-value flag: `--branch` alone resolves
|
|
33
|
+
* the current git branch (`resolveCurrentBranch`); `--branch feature/x` uses
|
|
34
|
+
* the given name verbatim. Returns undefined when the flag is absent at all
|
|
35
|
+
* (distinct from an empty/whitespace value, which is rejected). Throws
|
|
36
|
+
* UsageError if `--branch` is given more than once.
|
|
37
|
+
*/
|
|
38
|
+
export declare function branchFromFlags(flags: CommandFlags["flags"], run: CommandRunner): string | undefined;
|
|
31
39
|
/** Shared put/attach optimize flags + UPLOADS_NO_OPTIMIZE default. */
|
|
32
40
|
export declare function optimizeOptionsFromFlags(flags: CommandFlags["flags"], defaults: PutDefaults): OptimizeImageOptions;
|
|
33
41
|
export type PreparedUpload = OptimizeImageResult & {
|
|
@@ -104,7 +112,7 @@ export interface AttachmentsCommentResult {
|
|
|
104
112
|
}
|
|
105
113
|
/** Human-mode suffix noting who posted the managed comment. */
|
|
106
114
|
export declare function commentViaSuffix(via: AttachmentsCommentResult["via"]): string;
|
|
107
|
-
export declare function syncAttachmentsComment(client: UploadsClient, target: GhTarget, run: CommandRunner): Promise<AttachmentsCommentResult>;
|
|
115
|
+
export declare function syncAttachmentsComment(client: UploadsClient, target: GhTarget, run: CommandRunner, workspace?: string): Promise<AttachmentsCommentResult>;
|
|
108
116
|
export type AttachUploadItem = PutResult & {
|
|
109
117
|
file: string;
|
|
110
118
|
markdown: string;
|
|
@@ -150,6 +158,37 @@ export declare function uploadAttachments(opts: {
|
|
|
150
158
|
failures: AttachFailure[];
|
|
151
159
|
firstError?: unknown;
|
|
152
160
|
}>;
|
|
161
|
+
/** A branch to stage attachments against pre-PR (`uploads attach --branch`). */
|
|
162
|
+
export interface BranchTarget {
|
|
163
|
+
repo: string;
|
|
164
|
+
branch: string;
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Prepare + put each path as a branch-staged attachment (pre-PR) with
|
|
168
|
+
* bounded concurrency. Same shape as `uploadAttachments`, keyed under
|
|
169
|
+
* `gh/<owner>/<repo>/branch/<branch>/<filename>` instead of a PR/issue
|
|
170
|
+
* number. Never syncs the managed comment — callers must not call
|
|
171
|
+
* `syncAttachmentsComment` for a branch target.
|
|
172
|
+
*/
|
|
173
|
+
export declare function uploadBranchAttachments(opts: {
|
|
174
|
+
client: UploadsClient;
|
|
175
|
+
target: BranchTarget;
|
|
176
|
+
files: readonly string[];
|
|
177
|
+
contentType?: string;
|
|
178
|
+
optimize: OptimizeImageOptions;
|
|
179
|
+
frame: {
|
|
180
|
+
frameId?: string;
|
|
181
|
+
frameUrl?: string;
|
|
182
|
+
frameFit?: "cover" | "contain";
|
|
183
|
+
};
|
|
184
|
+
metadata?: Record<string, string>;
|
|
185
|
+
provenanceClient?: string;
|
|
186
|
+
concurrency?: number;
|
|
187
|
+
}): Promise<{
|
|
188
|
+
uploads: AttachUploadItem[];
|
|
189
|
+
failures: AttachFailure[];
|
|
190
|
+
firstError?: unknown;
|
|
191
|
+
}>;
|
|
153
192
|
export type PutUploadItem = PutResult & {
|
|
154
193
|
file: string;
|
|
155
194
|
markdown: string;
|
|
@@ -199,6 +238,7 @@ export declare function runFind(ctx: CliContext, args: string[], help?: boolean)
|
|
|
199
238
|
export declare function runMeta(ctx: CliContext, args: string[], help?: boolean): Promise<number>;
|
|
200
239
|
export declare function runDelete(ctx: CliContext, args: string[], help?: boolean): Promise<number>;
|
|
201
240
|
export declare function runComment(ctx: CliContext, args: string[], help?: boolean, run?: CommandRunner): Promise<number>;
|
|
241
|
+
export declare function runGithub(ctx: CliContext, args: string[], help?: boolean, run?: CommandRunner): Promise<number>;
|
|
202
242
|
export declare function runUsage(ctx: CliContext, args: string[], help?: boolean): Promise<number>;
|
|
203
243
|
export declare function runReconcile(ctx: CliContext, args: string[], help?: boolean): Promise<number>;
|
|
204
244
|
export declare function runPurgeExpired(ctx: CliContext, args: string[], help?: boolean): Promise<number>;
|
package/dist/commands.js
CHANGED
|
@@ -9,8 +9,8 @@ import { urlForGithubEmbed } from "./public-urls.js";
|
|
|
9
9
|
import { UploadsError } from "./errors.js";
|
|
10
10
|
import { writeJson, writeStdout } from "./io.js";
|
|
11
11
|
import { parseMetaFlags, validateMetaMap } from "./metadata.js";
|
|
12
|
-
import { ghAttachmentKey, ghKeyPrefix, ghMetadataFromTarget, attachmentsCommentBody, normalizeGithubCoordinate, } from "./github.js";
|
|
13
|
-
import { resolveRepo, resolveCurrentPullRequest, classifyGhNumber, execRunner, ghMetadataFromTargetWithTitle, upsertAttachmentsComment, } from "./github-gh.js";
|
|
12
|
+
import { ghAttachmentKey, ghBranchAttachmentKey, ghKeyPrefix, ghMetadataFromTarget, ghMetadataForBranch, attachmentsCommentBody, attachmentsMarker, normalizeGithubCoordinate, } from "./github.js";
|
|
13
|
+
import { resolveRepo, resolveCurrentPullRequest, resolveCurrentBranch, classifyGhNumber, execRunner, ghMetadataFromTargetWithTitle, upsertAttachmentsComment, } from "./github-gh.js";
|
|
14
14
|
import { resolvePutPrefix } from "./destinations.js";
|
|
15
15
|
import { optimizeImageForUpload, rewriteKeyExtension, } from "./optimize.js";
|
|
16
16
|
import { applyFrame, resolveFrameId } from "./frame.js";
|
|
@@ -130,6 +130,25 @@ export function makeGhTarget(pr, issue, repoArg, run) {
|
|
|
130
130
|
export function ghTargetFromFlags(flags, run) {
|
|
131
131
|
return makeGhTarget(flagInt(flags, "--pr", "--pr"), flagInt(flags, "--issue", "--issue"), flagString(flags, "--repo"), run);
|
|
132
132
|
}
|
|
133
|
+
/**
|
|
134
|
+
* Reads `--branch [name]` — an optional-value flag: `--branch` alone resolves
|
|
135
|
+
* the current git branch (`resolveCurrentBranch`); `--branch feature/x` uses
|
|
136
|
+
* the given name verbatim. Returns undefined when the flag is absent at all
|
|
137
|
+
* (distinct from an empty/whitespace value, which is rejected). Throws
|
|
138
|
+
* UsageError if `--branch` is given more than once.
|
|
139
|
+
*/
|
|
140
|
+
export function branchFromFlags(flags, run) {
|
|
141
|
+
if (!flags.has("--branch"))
|
|
142
|
+
return undefined;
|
|
143
|
+
const raw = flags.get("--branch");
|
|
144
|
+
if (Array.isArray(raw))
|
|
145
|
+
throw new UsageError("--branch may only be given once");
|
|
146
|
+
if (raw === true)
|
|
147
|
+
return resolveCurrentBranch(run);
|
|
148
|
+
if (typeof raw === "string" && raw.trim().length > 0)
|
|
149
|
+
return raw;
|
|
150
|
+
throw new UsageError("--branch requires a non-empty branch name");
|
|
151
|
+
}
|
|
133
152
|
/**
|
|
134
153
|
* Best-effort GitHub target for the default put path (no --pr/--issue). A
|
|
135
154
|
* numeric --ref is classified as pull vs issue; otherwise the current branch's
|
|
@@ -278,7 +297,7 @@ export function frameOptionsFromFlags(flags) {
|
|
|
278
297
|
export function commentViaSuffix(via) {
|
|
279
298
|
return via === "bot" ? " (uploads-sh[bot])" : " (via gh)";
|
|
280
299
|
}
|
|
281
|
-
export async function syncAttachmentsComment(client, target, run) {
|
|
300
|
+
export async function syncAttachmentsComment(client, target, run, workspace) {
|
|
282
301
|
try {
|
|
283
302
|
const bot = await client.upsertGithubComment({
|
|
284
303
|
repo: target.repo,
|
|
@@ -287,13 +306,24 @@ export async function syncAttachmentsComment(client, target, run) {
|
|
|
287
306
|
});
|
|
288
307
|
if (bot.posted)
|
|
289
308
|
return { action: bot.action, count: bot.count, via: "bot" };
|
|
309
|
+
// Installed-but-unapproved is a fixable misconfiguration, not a silent
|
|
310
|
+
// degrade: tell the user (and how to fix it) before falling back to gh.
|
|
311
|
+
if (bot.reason === "forbidden" && bot.message) {
|
|
312
|
+
process.stderr.write(`note: ${bot.message}${bot.fixUrl ? `\n ${bot.fixUrl}` : ""}\n` +
|
|
313
|
+
`Posting via local gh in the meantime.\n`);
|
|
314
|
+
}
|
|
290
315
|
}
|
|
291
316
|
catch {
|
|
292
317
|
// Endpoint absent/unreachable (self-hosted, network, older worker) — fall
|
|
293
318
|
// through to the gh path below.
|
|
294
319
|
}
|
|
295
320
|
// gh fallback: gather from this workspace's own data and post via local `gh`.
|
|
296
|
-
|
|
321
|
+
// Note (issue #304): this CLI process has no server-side WorkspaceRecord in
|
|
322
|
+
// scope, so it cannot honor a workspace's githubCommentLinkToFilePage=false
|
|
323
|
+
// — it always links to the file page here, matching the default. This only
|
|
324
|
+
// diverges from the bot-posted comment for a workspace that both sets the
|
|
325
|
+
// flag false and falls through to this gh-fallback path.
|
|
326
|
+
const items = (await client.listAll({ prefix: ghKeyPrefix(target) })).map(({ key, url, embedUrl, pageUrl }) => ({ key, url, embedUrl, pageUrl }));
|
|
297
327
|
const galleries = [];
|
|
298
328
|
let cursor;
|
|
299
329
|
do {
|
|
@@ -329,8 +359,9 @@ export async function syncAttachmentsComment(client, target, run) {
|
|
|
329
359
|
}));
|
|
330
360
|
if (items.length === 0 && previewGalleries.length === 0)
|
|
331
361
|
return { action: "skipped", count: 0, via: "gh" };
|
|
332
|
-
const
|
|
333
|
-
const
|
|
362
|
+
const marker = attachmentsMarker(workspace);
|
|
363
|
+
const body = attachmentsCommentBody(items, previewGalleries, marker);
|
|
364
|
+
const { created } = upsertAttachmentsComment(target, body, run, marker);
|
|
334
365
|
return {
|
|
335
366
|
action: created ? "created" : "updated",
|
|
336
367
|
count: items.length + previewGalleries.length,
|
|
@@ -357,9 +388,37 @@ URL and every embed hot-swap. Human mode prints ">> replaced existing object
|
|
|
357
388
|
Still images are optimized to WebP by default (same as put). Use --no-optimize
|
|
358
389
|
to upload originals. Optional --frame wraps images in device/browser chrome.
|
|
359
390
|
|
|
391
|
+
Branch staging (pre-PR): --branch [name] stages files against a git branch
|
|
392
|
+
before a pull request exists, e.g. for a coding agent working a branch that
|
|
393
|
+
hasn't opened a PR yet. Key: gh/<owner>/<repo>/branch/<branch>/<filename>
|
|
394
|
+
("/" in the branch name sanitizes to "-", e.g. feature/x -> feature-x).
|
|
395
|
+
With no value, --branch resolves the current git branch. Staged files are
|
|
396
|
+
public like every other attachment — same public-URL caveat applies. There is
|
|
397
|
+
no managed comment for a branch (no PR/issue to comment on yet); --branch
|
|
398
|
+
never runs the comment sync and cannot combine with --pr/--issue/--comment.
|
|
399
|
+
|
|
400
|
+
Promotion: once a PR exists, staged files for the current branch are picked
|
|
401
|
+
up automatically the first time you attach to that PR (a plain "uploads
|
|
402
|
+
attach <file> --pr <num>", or the inferred-PR default with no target flags) —
|
|
403
|
+
they're copied into the PR's attachment prefix before the managed comment is
|
|
404
|
+
built, so they show up in the same run. Pass --no-promote to skip that. If
|
|
405
|
+
you'd rather promote without attaching a new file (e.g. right after
|
|
406
|
+
"gh pr create" with nothing new to upload), run "uploads attach --promote"
|
|
407
|
+
with no file arguments — it resolves the PR the same way, promotes, and
|
|
408
|
+
refreshes the comment; it exits 0 even if nothing was staged. --promote only
|
|
409
|
+
takes effect with zero files and cannot combine with --branch/--issue/
|
|
410
|
+
--no-promote. Promotion never applies to issues. Staged files stay findable
|
|
411
|
+
with "uploads find gh.branch=<branch>" either way.
|
|
412
|
+
|
|
360
413
|
Options:
|
|
361
414
|
--pr <num> Attach to this pull request
|
|
362
415
|
--issue <num> Attach to this issue
|
|
416
|
+
--branch [name] Stage against a branch, pre-PR (default: current git branch);
|
|
417
|
+
not with --pr/--issue/--comment
|
|
418
|
+
--promote No files: promote branch-staged attachments into the
|
|
419
|
+
resolved PR and refresh the comment; not with
|
|
420
|
+
--branch/--issue/--no-promote
|
|
421
|
+
--no-promote Skip auto-promoting branch-staged attachments (default path only)
|
|
363
422
|
--repo <owner/repo> Repository (default: gh/git inference)
|
|
364
423
|
--no-comment Upload only; don't create/update the managed comment
|
|
365
424
|
--content-type <mime> Override Content-Type (applied to every file; ignored when optimize rewrites)
|
|
@@ -373,7 +432,8 @@ Options:
|
|
|
373
432
|
--workspace, -w <name> Override workspace
|
|
374
433
|
--meta <k=v> Extra queryable metadata (repeatable; value may contain "=").
|
|
375
434
|
gh.repo/gh.kind/gh.number/gh.ref are always set from the resolved
|
|
376
|
-
target
|
|
435
|
+
target (or gh.repo/gh.kind/gh.branch/gh.staged-at with --branch) —
|
|
436
|
+
a --meta pair with the same key is overridden by it.
|
|
377
437
|
Because attach always sends its own gh.* pairs, re-attaching to
|
|
378
438
|
the same key always replaces that file's entire metadata set
|
|
379
439
|
(never preserves) — use "uploads meta set" to add to it instead.
|
|
@@ -384,13 +444,17 @@ Examples:
|
|
|
384
444
|
uploads attach ./shot.png --pr 123 --repo myorg/myapp
|
|
385
445
|
uploads attach ./artifact.zip --issue 45 --no-comment
|
|
386
446
|
uploads attach ./shot.png --meta app=myapp --meta page=settings
|
|
447
|
+
uploads attach ./shot.png --branch
|
|
448
|
+
uploads attach ./shot.png --branch feature/new-settings
|
|
449
|
+
uploads attach --promote
|
|
387
450
|
`;
|
|
388
451
|
/**
|
|
389
|
-
*
|
|
390
|
-
*
|
|
452
|
+
* Shared prepare + put loop for both PR/issue attach (`uploadAttachments`)
|
|
453
|
+
* and branch-staged attach (`uploadBranchAttachments`) — bounded concurrency,
|
|
454
|
+
* per-file errors collect in `failures` (does not throw). `firstError` is the
|
|
391
455
|
* original cause of the first failure — for rethrowing single-file CLI paths.
|
|
392
456
|
*/
|
|
393
|
-
|
|
457
|
+
async function uploadAttachmentBatch(opts) {
|
|
394
458
|
if (opts.files.some((f) => f === "-")) {
|
|
395
459
|
throw new UsageError("attach does not support stdin; pass one or more file paths");
|
|
396
460
|
}
|
|
@@ -403,7 +467,7 @@ export async function uploadAttachments(opts) {
|
|
|
403
467
|
});
|
|
404
468
|
const result = await opts.client.put(prepared.bytes, {
|
|
405
469
|
filename: prepared.filename,
|
|
406
|
-
key:
|
|
470
|
+
key: opts.keyFor(prepared.filename),
|
|
407
471
|
contentType: prepared.optimized ? prepared.contentType : opts.contentType,
|
|
408
472
|
provenance: buildCliProvenance({
|
|
409
473
|
sourceName,
|
|
@@ -450,6 +514,30 @@ export async function uploadAttachments(opts) {
|
|
|
450
514
|
}
|
|
451
515
|
return { uploads, failures, firstError };
|
|
452
516
|
}
|
|
517
|
+
/**
|
|
518
|
+
* Prepare + put each path as a PR/issue attachment with bounded concurrency.
|
|
519
|
+
* Per-file errors collect in `failures` (does not throw). `firstError` is the
|
|
520
|
+
* original cause of the first failure — for rethrowing single-file CLI paths.
|
|
521
|
+
*/
|
|
522
|
+
export async function uploadAttachments(opts) {
|
|
523
|
+
return uploadAttachmentBatch({
|
|
524
|
+
...opts,
|
|
525
|
+
keyFor: (filename) => ghAttachmentKey(opts.target, filename),
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
/**
|
|
529
|
+
* Prepare + put each path as a branch-staged attachment (pre-PR) with
|
|
530
|
+
* bounded concurrency. Same shape as `uploadAttachments`, keyed under
|
|
531
|
+
* `gh/<owner>/<repo>/branch/<branch>/<filename>` instead of a PR/issue
|
|
532
|
+
* number. Never syncs the managed comment — callers must not call
|
|
533
|
+
* `syncAttachmentsComment` for a branch target.
|
|
534
|
+
*/
|
|
535
|
+
export async function uploadBranchAttachments(opts) {
|
|
536
|
+
return uploadAttachmentBatch({
|
|
537
|
+
...opts,
|
|
538
|
+
keyFor: (filename) => ghBranchAttachmentKey(opts.target.repo, opts.target.branch, filename),
|
|
539
|
+
});
|
|
540
|
+
}
|
|
453
541
|
function errorDetail(err) {
|
|
454
542
|
if (err instanceof UploadsError)
|
|
455
543
|
return { message: err.message, code: err.code, status: err.status };
|
|
@@ -527,18 +615,68 @@ export async function uploadPuts(opts) {
|
|
|
527
615
|
}
|
|
528
616
|
return { uploads, failures, firstError };
|
|
529
617
|
}
|
|
618
|
+
/**
|
|
619
|
+
* Best-effort call to `POST /v1/:workspace/github/promote` (server contract,
|
|
620
|
+
* PR #310). Degrade-safe like `syncAttachmentsComment`'s bot path: an older
|
|
621
|
+
* or self-hosted worker without this route (404), a forbidden token (403),
|
|
622
|
+
* or a network error all collapse to "nothing promoted" — the caller must
|
|
623
|
+
* never let this fail the attach. Returns undefined on any failure.
|
|
624
|
+
*/
|
|
625
|
+
async function attemptPromoteBranch(client, target, branch) {
|
|
626
|
+
try {
|
|
627
|
+
return await client.promoteBranchAttachments({ repo: target.repo, num: target.num, branch });
|
|
628
|
+
}
|
|
629
|
+
catch {
|
|
630
|
+
return undefined;
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
/** Human-mode note for a promotion that actually promoted something. */
|
|
634
|
+
function promotionNote(promotion, branch) {
|
|
635
|
+
const n = promotion.promoted.length;
|
|
636
|
+
const branchSuffix = branch ? ` from branch ${branch}` : "";
|
|
637
|
+
return `>> promoted ${n} staged attachment${n === 1 ? "" : "s"}${branchSuffix}\n`;
|
|
638
|
+
}
|
|
530
639
|
export async function runAttach(ctx, args, help = false, run = execRunner) {
|
|
531
640
|
const parsed = parseCommandArgs(args);
|
|
532
641
|
if (help || parsed.help) {
|
|
533
642
|
writeCommandHelp(ATTACH_HELP);
|
|
534
643
|
return 0;
|
|
535
644
|
}
|
|
645
|
+
if (parsed.flags.has("--no-comment") && typeof parsed.flags.get("--no-comment") === "string") {
|
|
646
|
+
throw new UsageError("--no-comment takes no value — place it after the file arguments");
|
|
647
|
+
}
|
|
648
|
+
if (parsed.flags.has("--promote") && typeof parsed.flags.get("--promote") === "string") {
|
|
649
|
+
throw new UsageError("--promote takes no value — place it after the file arguments");
|
|
650
|
+
}
|
|
651
|
+
if (parsed.flags.has("--no-promote") && typeof parsed.flags.get("--no-promote") === "string") {
|
|
652
|
+
throw new UsageError("--no-promote takes no value — place it after the file arguments");
|
|
653
|
+
}
|
|
654
|
+
if (parsed.flags.has("--promote")) {
|
|
655
|
+
if (parsed.positionals.length > 0) {
|
|
656
|
+
throw new UsageError("--promote takes no file arguments — attaching a file to a PR already auto-promotes " +
|
|
657
|
+
"staged files; use `uploads attach <file> --pr <num>` instead");
|
|
658
|
+
}
|
|
659
|
+
if (parsed.flags.has("--branch"))
|
|
660
|
+
throw new UsageError("--promote cannot be combined with --branch");
|
|
661
|
+
if (parsed.flags.has("--issue"))
|
|
662
|
+
throw new UsageError("--promote cannot be combined with --issue");
|
|
663
|
+
if (parsed.flags.has("--no-promote"))
|
|
664
|
+
throw new UsageError("--promote cannot be combined with --no-promote");
|
|
665
|
+
return runAttachPromoteOnly(ctx, parsed, run);
|
|
666
|
+
}
|
|
536
667
|
if (parsed.positionals.length === 0) {
|
|
537
668
|
writeCommandHelp(ATTACH_HELP);
|
|
538
669
|
return 2;
|
|
539
670
|
}
|
|
540
|
-
|
|
541
|
-
|
|
671
|
+
const branchArg = branchFromFlags(parsed.flags, run);
|
|
672
|
+
if (branchArg !== undefined) {
|
|
673
|
+
if (parsed.flags.has("--pr"))
|
|
674
|
+
throw new UsageError("--branch cannot be combined with --pr");
|
|
675
|
+
if (parsed.flags.has("--issue"))
|
|
676
|
+
throw new UsageError("--branch cannot be combined with --issue");
|
|
677
|
+
if (parsed.flags.has("--comment"))
|
|
678
|
+
throw new UsageError("--branch cannot be combined with --comment");
|
|
679
|
+
return runAttachBranch(ctx, parsed, branchArg, run);
|
|
542
680
|
}
|
|
543
681
|
const explicitTarget = ghTargetFromFlags(parsed.flags, run);
|
|
544
682
|
const target = explicitTarget ??
|
|
@@ -574,12 +712,33 @@ export async function runAttach(ctx, args, help = false, run = execRunner) {
|
|
|
574
712
|
if (uploads.length === 0 && failures.length === 1 && parsed.positionals.length === 1) {
|
|
575
713
|
throw firstError instanceof Error ? firstError : new Error(String(firstError));
|
|
576
714
|
}
|
|
715
|
+
// Auto-promote: before the comment sync, best-effort promote this
|
|
716
|
+
// workspace's own branch-staged attachments (from an earlier `attach
|
|
717
|
+
// --branch` while the PR didn't exist yet) into this PR's attachment
|
|
718
|
+
// prefix, so the comment gather below sees them in the same invocation.
|
|
719
|
+
// Never for issues (branch staging only ever targets a future PR), never
|
|
720
|
+
// with --no-promote, and silently skipped (no client call at all) when the
|
|
721
|
+
// current git branch can't be resolved (detached HEAD, not a repo) — this
|
|
722
|
+
// must never fail the attach itself.
|
|
723
|
+
let promotion;
|
|
724
|
+
let promotedBranch;
|
|
725
|
+
if (target.kind === "pull" && !parsed.flags.has("--no-promote")) {
|
|
726
|
+
try {
|
|
727
|
+
promotedBranch = resolveCurrentBranch(run);
|
|
728
|
+
}
|
|
729
|
+
catch {
|
|
730
|
+
promotedBranch = undefined;
|
|
731
|
+
}
|
|
732
|
+
if (promotedBranch !== undefined) {
|
|
733
|
+
promotion = await attemptPromoteBranch(ctx.client, target, promotedBranch);
|
|
734
|
+
}
|
|
735
|
+
}
|
|
577
736
|
let comment;
|
|
578
737
|
let commentError;
|
|
579
738
|
// Skip comment refresh when every upload failed — nothing new from this batch.
|
|
580
739
|
if (!parsed.flags.has("--no-comment") && uploads.length > 0) {
|
|
581
740
|
try {
|
|
582
|
-
comment = await syncAttachmentsComment(ctx.client, target, run);
|
|
741
|
+
comment = await syncAttachmentsComment(ctx.client, target, run, ctx.config.workspace);
|
|
583
742
|
}
|
|
584
743
|
catch (err) {
|
|
585
744
|
commentError = err instanceof Error ? err.message : String(err);
|
|
@@ -587,7 +746,14 @@ export async function runAttach(ctx, args, help = false, run = execRunner) {
|
|
|
587
746
|
}
|
|
588
747
|
}
|
|
589
748
|
if (ctx.json) {
|
|
590
|
-
await writeJson({
|
|
749
|
+
await writeJson({
|
|
750
|
+
target,
|
|
751
|
+
uploads,
|
|
752
|
+
failures,
|
|
753
|
+
comment,
|
|
754
|
+
commentError,
|
|
755
|
+
promotion: promotion ?? null,
|
|
756
|
+
});
|
|
591
757
|
}
|
|
592
758
|
else {
|
|
593
759
|
for (const result of uploads) {
|
|
@@ -606,6 +772,9 @@ export async function runAttach(ctx, args, help = false, run = execRunner) {
|
|
|
606
772
|
for (const failure of failures) {
|
|
607
773
|
process.stderr.write(`warning: could not upload ${failure.file}: ${failure.error.message}\n`);
|
|
608
774
|
}
|
|
775
|
+
if (!ctx.quiet && promotion && promotion.promoted.length > 0) {
|
|
776
|
+
process.stderr.write(promotionNote(promotion, promotedBranch));
|
|
777
|
+
}
|
|
609
778
|
if (!ctx.quiet && comment)
|
|
610
779
|
process.stderr.write(`>> attachments comment ${comment.action}${commentViaSuffix(comment.via)}\n`);
|
|
611
780
|
if (!ctx.quiet && uploads.length > 0) {
|
|
@@ -615,6 +784,114 @@ export async function runAttach(ctx, args, help = false, run = execRunner) {
|
|
|
615
784
|
}
|
|
616
785
|
return failures.length === 0 ? 0 : 1;
|
|
617
786
|
}
|
|
787
|
+
/**
|
|
788
|
+
* `attach --branch` path: stages files under
|
|
789
|
+
* `gh/<owner>/<repo>/branch/<branch>/<filename>` instead of a PR/issue
|
|
790
|
+
* number. Never syncs the managed comment (there is no PR/issue to comment
|
|
791
|
+
* on yet, and the comment-gatherer only lists PR/issue prefixes anyway —
|
|
792
|
+
* branch-staged keys are invisible to it by construction).
|
|
793
|
+
*/
|
|
794
|
+
async function runAttachBranch(ctx, parsed, branch, run) {
|
|
795
|
+
const repo = resolveRepo(flagString(parsed.flags, "--repo"), run);
|
|
796
|
+
const defaults = resolvePutDefaults({ envFile: ctx.envFile });
|
|
797
|
+
const optimizeOpts = optimizeOptionsFromFlags(parsed.flags, defaults);
|
|
798
|
+
const frameOpts = frameOptionsFromFlags(parsed.flags);
|
|
799
|
+
const contentTypeOverride = flagString(parsed.flags, "--content-type");
|
|
800
|
+
const metaExtras = parseMetaFlags(flagValues(parsed.flags, "--meta"));
|
|
801
|
+
const metadata = { ...metaExtras, ...ghMetadataForBranch(repo, branch) };
|
|
802
|
+
validateMetaMap(metadata);
|
|
803
|
+
const logHuman = !ctx.quiet && !ctx.json;
|
|
804
|
+
if (logHuman) {
|
|
805
|
+
const n = parsed.positionals.length;
|
|
806
|
+
process.stderr.write(`>> uploading ${n} file${n === 1 ? "" : "s"} (staged for branch ${branch})\n`);
|
|
807
|
+
}
|
|
808
|
+
const target = { repo, branch };
|
|
809
|
+
const { uploads, failures, firstError } = await uploadBranchAttachments({
|
|
810
|
+
client: ctx.client,
|
|
811
|
+
target,
|
|
812
|
+
files: parsed.positionals,
|
|
813
|
+
contentType: contentTypeOverride,
|
|
814
|
+
optimize: optimizeOpts,
|
|
815
|
+
frame: frameOpts,
|
|
816
|
+
metadata,
|
|
817
|
+
});
|
|
818
|
+
// Single-file total failure: rethrow so CLI exit codes stay auth/network-aware.
|
|
819
|
+
if (uploads.length === 0 && failures.length === 1 && parsed.positionals.length === 1) {
|
|
820
|
+
throw firstError instanceof Error ? firstError : new Error(String(firstError));
|
|
821
|
+
}
|
|
822
|
+
if (ctx.json) {
|
|
823
|
+
await writeJson({ target, uploads, failures });
|
|
824
|
+
}
|
|
825
|
+
else {
|
|
826
|
+
for (const result of uploads) {
|
|
827
|
+
if (logHuman) {
|
|
828
|
+
if (result.frame?.framed) {
|
|
829
|
+
process.stderr.write(`>> ${basename(result.file)}: framed with ${result.frame.frameId}\n`);
|
|
830
|
+
}
|
|
831
|
+
const note = formatOptimizeNote(result.optimize);
|
|
832
|
+
if (note)
|
|
833
|
+
process.stderr.write(`>> ${basename(result.file)}: ${note}\n`);
|
|
834
|
+
writeReplacedNote(result.replaced, false);
|
|
835
|
+
}
|
|
836
|
+
const embedLine = result.embedUrl ? `EMBED: ${result.embedUrl}\n` : "";
|
|
837
|
+
await writeStdout(`URL: ${result.url}\n${embedLine}MARKDOWN: ${result.markdown}\n`);
|
|
838
|
+
}
|
|
839
|
+
for (const failure of failures) {
|
|
840
|
+
process.stderr.write(`warning: could not upload ${failure.file}: ${failure.error.message}\n`);
|
|
841
|
+
}
|
|
842
|
+
if (!ctx.quiet && uploads.length > 0) {
|
|
843
|
+
process.stderr.write(`>> find these later: uploads find gh.branch=${branch.toLowerCase()}\n`);
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
return failures.length === 0 ? 0 : 1;
|
|
847
|
+
}
|
|
848
|
+
/**
|
|
849
|
+
* `attach --promote` with zero file arguments: resolve the PR target (same
|
|
850
|
+
* resolution as the default `runAttach` path), promote this workspace's
|
|
851
|
+
* branch-staged attachments into it, then run the comment sync — useful
|
|
852
|
+
* right after `gh pr create` when the PR was opened without a fresh attach
|
|
853
|
+
* (auto-promotion on the default path only fires when you attach a file).
|
|
854
|
+
* Unlike the default path's best-effort branch resolution, this is an
|
|
855
|
+
* explicit user action: `resolveCurrentBranch` throwing (detached HEAD, not
|
|
856
|
+
* a repo) propagates as a UsageError instead of silently skipping. Always
|
|
857
|
+
* exits 0 — an empty staging prefix is success, not a failure.
|
|
858
|
+
*/
|
|
859
|
+
async function runAttachPromoteOnly(ctx, parsed, run) {
|
|
860
|
+
const explicitTarget = ghTargetFromFlags(parsed.flags, run);
|
|
861
|
+
const target = explicitTarget ??
|
|
862
|
+
resolveCurrentPullRequest(resolveRepo(flagString(parsed.flags, "--repo"), run), run);
|
|
863
|
+
const branch = resolveCurrentBranch(run);
|
|
864
|
+
const promotion = await attemptPromoteBranch(ctx.client, target, branch);
|
|
865
|
+
let comment;
|
|
866
|
+
let commentError;
|
|
867
|
+
if (!parsed.flags.has("--no-comment")) {
|
|
868
|
+
try {
|
|
869
|
+
comment = await syncAttachmentsComment(ctx.client, target, run, ctx.config.workspace);
|
|
870
|
+
}
|
|
871
|
+
catch (err) {
|
|
872
|
+
commentError = err instanceof Error ? err.message : String(err);
|
|
873
|
+
process.stderr.write(`warning: promotion succeeded but the GitHub comment failed (is gh installed and authenticated?): ${commentError}\n`);
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
if (ctx.json) {
|
|
877
|
+
await writeJson({
|
|
878
|
+
target,
|
|
879
|
+
uploads: [],
|
|
880
|
+
failures: [],
|
|
881
|
+
comment,
|
|
882
|
+
commentError,
|
|
883
|
+
promotion: promotion ?? null,
|
|
884
|
+
});
|
|
885
|
+
}
|
|
886
|
+
else {
|
|
887
|
+
if (!ctx.quiet && promotion && promotion.promoted.length > 0) {
|
|
888
|
+
process.stderr.write(promotionNote(promotion, branch));
|
|
889
|
+
}
|
|
890
|
+
if (!ctx.quiet && comment)
|
|
891
|
+
process.stderr.write(`>> attachments comment ${comment.action}${commentViaSuffix(comment.via)}\n`);
|
|
892
|
+
}
|
|
893
|
+
return 0;
|
|
894
|
+
}
|
|
618
895
|
export async function runPut(ctx, args, help = false, run = execRunner) {
|
|
619
896
|
if (help) {
|
|
620
897
|
writeCommandHelp(PUT_HELP);
|
|
@@ -815,7 +1092,7 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
|
|
|
815
1092
|
let commentError;
|
|
816
1093
|
if (wantComment && ghTarget && uploads.length > 0) {
|
|
817
1094
|
try {
|
|
818
|
-
comment = await syncAttachmentsComment(ctx.client, ghTarget, run);
|
|
1095
|
+
comment = await syncAttachmentsComment(ctx.client, ghTarget, run, ctx.config.workspace);
|
|
819
1096
|
if (logHuman)
|
|
820
1097
|
process.stderr.write(`>> attachments comment ${comment.action}${commentViaSuffix(comment.via)}\n`);
|
|
821
1098
|
}
|
|
@@ -1359,7 +1636,7 @@ export async function runComment(ctx, args, help = false, run = execRunner) {
|
|
|
1359
1636
|
const target = ghTargetFromFlags(parsed.flags, run);
|
|
1360
1637
|
if (!target)
|
|
1361
1638
|
throw new UsageError("comment requires --pr or --issue");
|
|
1362
|
-
const result = await syncAttachmentsComment(ctx.client, target, run);
|
|
1639
|
+
const result = await syncAttachmentsComment(ctx.client, target, run, ctx.config.workspace);
|
|
1363
1640
|
if (ctx.json) {
|
|
1364
1641
|
await writeJson({ ...target, ...result });
|
|
1365
1642
|
}
|
|
@@ -1371,6 +1648,62 @@ export async function runComment(ctx, args, help = false, run = execRunner) {
|
|
|
1371
1648
|
}
|
|
1372
1649
|
return 0;
|
|
1373
1650
|
}
|
|
1651
|
+
// --- github link ---
|
|
1652
|
+
const GITHUB_HELP = `uploads github link [--repo <owner/name>] [--status] [--workspace <name>]
|
|
1653
|
+
|
|
1654
|
+
Claim or inspect this workspace's binding to a GitHub repo (see the managed
|
|
1655
|
+
attachments comment / webhook auto-promotion, which use this binding).
|
|
1656
|
+
First-claim-wins: claiming an already-bound repo never steals it from
|
|
1657
|
+
whichever workspace claimed it first — the command reports who owns it
|
|
1658
|
+
instead.
|
|
1659
|
+
|
|
1660
|
+
--repo defaults the same way as --pr/--issue elsewhere (gh repo view, then
|
|
1661
|
+
the git remote). --status only inspects the current binding (files:read);
|
|
1662
|
+
without it, the command claims the repo (files:write).
|
|
1663
|
+
|
|
1664
|
+
Examples:
|
|
1665
|
+
uploads github link
|
|
1666
|
+
uploads github link --repo buildinternet/uploads
|
|
1667
|
+
uploads github link --status
|
|
1668
|
+
`;
|
|
1669
|
+
function formatGithubLink(repo, result) {
|
|
1670
|
+
return result.workspace
|
|
1671
|
+
? `${repo} is bound to workspace "${result.workspace}"${result.source ? ` (${result.source})` : ""}\n`
|
|
1672
|
+
: `${repo} is not bound to any workspace\n`;
|
|
1673
|
+
}
|
|
1674
|
+
export async function runGithub(ctx, args, help = false, run = execRunner) {
|
|
1675
|
+
const parsed = parseCommandArgs(args);
|
|
1676
|
+
const action = parsed.positionals[0];
|
|
1677
|
+
if (help || parsed.help || !action) {
|
|
1678
|
+
writeCommandHelp(GITHUB_HELP);
|
|
1679
|
+
return help || parsed.help ? 0 : 2;
|
|
1680
|
+
}
|
|
1681
|
+
if (action !== "link")
|
|
1682
|
+
throw new UsageError(`unknown github subcommand: ${action}`);
|
|
1683
|
+
const repo = resolveRepo(flagString(parsed.flags, "--repo"), run);
|
|
1684
|
+
const statusOnly = flagBool(parsed.flags, "--status");
|
|
1685
|
+
let result;
|
|
1686
|
+
try {
|
|
1687
|
+
result = statusOnly
|
|
1688
|
+
? await ctx.client.githubLinkStatus(repo)
|
|
1689
|
+
: await ctx.client.githubLinkClaim(repo);
|
|
1690
|
+
}
|
|
1691
|
+
catch (err) {
|
|
1692
|
+
if (err instanceof UploadsError && err.status === 404) {
|
|
1693
|
+
throw new UsageError("server does not support repo bindings yet (404) — upgrade the uploads.sh API/self-hosted worker");
|
|
1694
|
+
}
|
|
1695
|
+
throw err;
|
|
1696
|
+
}
|
|
1697
|
+
if (ctx.json) {
|
|
1698
|
+
await writeJson(result);
|
|
1699
|
+
return 0;
|
|
1700
|
+
}
|
|
1701
|
+
if (!statusOnly && result.claimed === false) {
|
|
1702
|
+
process.stderr.write(`note: ${repo} is already bound to a different workspace ("${result.workspace}") — first-claim-wins, not overwritten\n`);
|
|
1703
|
+
}
|
|
1704
|
+
await writeStdout(formatGithubLink(repo, result));
|
|
1705
|
+
return 0;
|
|
1706
|
+
}
|
|
1374
1707
|
// --- usage / reconcile / purge ---
|
|
1375
1708
|
const USAGE_HELP = `uploads usage [--workspace <name>]
|
|
1376
1709
|
|
package/dist/github-gh.d.ts
CHANGED
|
@@ -9,6 +9,8 @@ export declare const execRunner: CommandRunner;
|
|
|
9
9
|
export declare function resolveRepo(explicit: string | undefined, run?: CommandRunner): string;
|
|
10
10
|
/** Resolve the pull request associated with the current branch. */
|
|
11
11
|
export declare function resolveCurrentPullRequest(repo: string, run?: CommandRunner): GhTarget;
|
|
12
|
+
/** Resolve the current git branch (`--branch` with no value). Throws UsageError on detached HEAD or outside a git repo. */
|
|
13
|
+
export declare function resolveCurrentBranch(run?: CommandRunner): string;
|
|
12
14
|
/**
|
|
13
15
|
* Classify a bare PR/issue number via the GitHub API so the default `put`
|
|
14
16
|
* path can stamp the right `gh.kind`. Returns undefined on any failure (gh
|
|
@@ -39,7 +41,13 @@ export declare function ghMetadataFromTargetWithTitle(target: GhTarget, run?: Co
|
|
|
39
41
|
* Create the managed attachments comment, or edit it in place if it already
|
|
40
42
|
* exists. Never touches any other comment. Body is passed via stdin
|
|
41
43
|
* (`-F body=@-`) so it is never shell-interpolated.
|
|
44
|
+
*
|
|
45
|
+
* `marker` identifies which comment to hunt for (see `findManagedComment`);
|
|
46
|
+
* `body` is expected to already carry that same marker as its first line
|
|
47
|
+
* (built via `attachmentsCommentBody(items, galleries, marker)`), so patching
|
|
48
|
+
* an adopted legacy comment migrates it to the namespaced marker in place.
|
|
49
|
+
* Defaults to the shared legacy marker for backward compatibility.
|
|
42
50
|
*/
|
|
43
|
-
export declare function upsertAttachmentsComment(target: GhTarget, body: string, run?: CommandRunner): {
|
|
51
|
+
export declare function upsertAttachmentsComment(target: GhTarget, body: string, run?: CommandRunner, marker?: string): {
|
|
44
52
|
created: boolean;
|
|
45
53
|
};
|
package/dist/github-gh.js
CHANGED
|
@@ -68,6 +68,20 @@ export function resolveCurrentPullRequest(repo, run = execRunner) {
|
|
|
68
68
|
}
|
|
69
69
|
throw new UsageError("could not infer a pull request for the current branch — pass --pr <num> or --issue <num>");
|
|
70
70
|
}
|
|
71
|
+
/** Resolve the current git branch (`--branch` with no value). Throws UsageError on detached HEAD or outside a git repo. */
|
|
72
|
+
export function resolveCurrentBranch(run = execRunner) {
|
|
73
|
+
let branch;
|
|
74
|
+
try {
|
|
75
|
+
branch = run("git", ["rev-parse", "--abbrev-ref", "HEAD"]).trim();
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
throw new UsageError("could not determine the current git branch — pass --branch <name> or run inside a git repo");
|
|
79
|
+
}
|
|
80
|
+
if (branch === "" || branch === "HEAD") {
|
|
81
|
+
throw new UsageError("could not determine the current branch (detached HEAD) — pass --branch <name>");
|
|
82
|
+
}
|
|
83
|
+
return branch;
|
|
84
|
+
}
|
|
71
85
|
/**
|
|
72
86
|
* Classify a bare PR/issue number via the GitHub API so the default `put`
|
|
73
87
|
* path can stamp the right `gh.kind`. Returns undefined on any failure (gh
|
|
@@ -141,23 +155,41 @@ export function ghMetadataFromTargetWithTitle(target, run = execRunner) {
|
|
|
141
155
|
* PR comments live on the issues endpoint, so one path covers PRs and issues.
|
|
142
156
|
* `--paginate` follows Link headers and merges every page into one array, so the
|
|
143
157
|
* marker comment is found even on threads past 100 comments.
|
|
158
|
+
*
|
|
159
|
+
* Hunts for `marker` (the namespaced, per-workspace marker) first; when none
|
|
160
|
+
* is found, falls back to a comment carrying the shared legacy
|
|
161
|
+
* `ATTACHMENTS_MARKER` (pre-4b, unnamespaced) so it can be adopted and
|
|
162
|
+
* migrated in place. When `marker` IS the legacy marker (no workspace to
|
|
163
|
+
* namespace with) this collapses to a single hunt, unchanged from pre-4b
|
|
164
|
+
* behavior.
|
|
144
165
|
*/
|
|
145
|
-
function findManagedComment(target, run) {
|
|
166
|
+
function findManagedComment(target, run, marker) {
|
|
146
167
|
const raw = run("gh", [
|
|
147
168
|
"api",
|
|
148
169
|
`repos/${target.repo}/issues/${target.num}/comments?per_page=100`,
|
|
149
170
|
"--paginate",
|
|
150
171
|
]);
|
|
151
172
|
const comments = JSON.parse(raw);
|
|
173
|
+
const namespacedHit = comments.find((c) => typeof c.body === "string" && c.body.includes(marker));
|
|
174
|
+
if (namespacedHit)
|
|
175
|
+
return namespacedHit;
|
|
176
|
+
if (marker === ATTACHMENTS_MARKER)
|
|
177
|
+
return undefined;
|
|
152
178
|
return comments.find((c) => typeof c.body === "string" && c.body.includes(ATTACHMENTS_MARKER));
|
|
153
179
|
}
|
|
154
180
|
/**
|
|
155
181
|
* Create the managed attachments comment, or edit it in place if it already
|
|
156
182
|
* exists. Never touches any other comment. Body is passed via stdin
|
|
157
183
|
* (`-F body=@-`) so it is never shell-interpolated.
|
|
184
|
+
*
|
|
185
|
+
* `marker` identifies which comment to hunt for (see `findManagedComment`);
|
|
186
|
+
* `body` is expected to already carry that same marker as its first line
|
|
187
|
+
* (built via `attachmentsCommentBody(items, galleries, marker)`), so patching
|
|
188
|
+
* an adopted legacy comment migrates it to the namespaced marker in place.
|
|
189
|
+
* Defaults to the shared legacy marker for backward compatibility.
|
|
158
190
|
*/
|
|
159
|
-
export function upsertAttachmentsComment(target, body, run = execRunner) {
|
|
160
|
-
const existing = findManagedComment(target, run);
|
|
191
|
+
export function upsertAttachmentsComment(target, body, run = execRunner, marker = ATTACHMENTS_MARKER) {
|
|
192
|
+
const existing = findManagedComment(target, run, marker);
|
|
161
193
|
if (existing) {
|
|
162
194
|
run("gh", [
|
|
163
195
|
"api",
|
package/dist/github.d.ts
CHANGED
|
@@ -22,6 +22,26 @@ export declare function ghKeyPrefix(target: GhTarget): string;
|
|
|
22
22
|
* (unlike buildScreenshotKey).
|
|
23
23
|
*/
|
|
24
24
|
export declare function ghAttachmentKey(target: GhTarget, filename: string): string;
|
|
25
|
+
/**
|
|
26
|
+
* Branch-staged attachment key prefix: `gh/<owner>/<repo>/branch/<branch>/`.
|
|
27
|
+
* Pre-PR staging (Phase 1 of branch-staged attachments) — no PR/issue number
|
|
28
|
+
* exists yet. `branch` goes through `sanitizeKeySegment` like every other key
|
|
29
|
+
* segment, so e.g. `feature/x` becomes `feature-x`.
|
|
30
|
+
*/
|
|
31
|
+
export declare function ghBranchKeyPrefix(repo: string, branch: string): string;
|
|
32
|
+
/** Branch-staged attachment key: `ghBranchKeyPrefix` + the sanitized filename. */
|
|
33
|
+
export declare function ghBranchAttachmentKey(repo: string, branch: string, filename: string): string;
|
|
34
|
+
/**
|
|
35
|
+
* `gh.*` metadata for a branch-staged attach: `gh.repo`, `gh.kind=branch`,
|
|
36
|
+
* `gh.branch` (lowercased), and `gh.staged-at` (ISO 8601 UTC, no fractional
|
|
37
|
+
* seconds). No `gh.number`/`gh.ref`/`gh.title` — there is no PR/issue yet.
|
|
38
|
+
* Throws `UsageError` when the lowercased branch name fails the metadata
|
|
39
|
+
* value rule (printable ASCII, 1-512 chars) — unlike `gh.title`'s best-effort
|
|
40
|
+
* degrade, a branch name the caller explicitly chose (via `--branch` or the
|
|
41
|
+
* current git branch) is worth failing loudly on rather than silently
|
|
42
|
+
* dropping from the metadata set.
|
|
43
|
+
*/
|
|
44
|
+
export declare function ghMetadataForBranch(repo: string, branch: string, now?: Date): Record<string, string>;
|
|
25
45
|
/**
|
|
26
46
|
* The four `gh.*` queryable-metadata pairs `uploads attach` writes
|
|
27
47
|
* automatically (`.context/2026-07-13-file-metadata-design.md`). `gh.kind`
|
|
@@ -36,11 +56,25 @@ export declare function ghAttachmentKey(target: GhTarget, filename: string): str
|
|
|
36
56
|
export declare function ghMetadataFromTarget(target: GhTarget): Record<string, string>;
|
|
37
57
|
/** Hidden marker identifying the one comment this CLI manages. Never change it — existing comments are found by exact match. */
|
|
38
58
|
export declare const ATTACHMENTS_MARKER = "<!-- uploads.sh:attachments -->";
|
|
59
|
+
/**
|
|
60
|
+
* Per-workspace marker (`<!-- uploads.sh:attachments ws=<workspace> -->`) so
|
|
61
|
+
* two workspaces managing the same repo don't clobber each other's comment.
|
|
62
|
+
* Falls back to the shared legacy marker when `workspace` is missing or does
|
|
63
|
+
* not look like a safe slug — degrade, don't guess or risk breaking the
|
|
64
|
+
* comment's HTML.
|
|
65
|
+
*/
|
|
66
|
+
export declare function attachmentsMarker(workspace?: string): string;
|
|
67
|
+
/** Max attachments embedded as inline `<img>` tags before the rest collapse
|
|
68
|
+
* into a `<details>` link list. Keeps very large threads from becoming a wall
|
|
69
|
+
* of images. */
|
|
70
|
+
export declare const MAX_INLINE_ATTACHMENT_IMAGES = 16;
|
|
39
71
|
export interface AttachmentItem {
|
|
40
72
|
key: string;
|
|
41
73
|
url: string | null;
|
|
42
74
|
/** Prefer for `<img src>` on GitHub (Camo-friendly host). Falls back to `url`. */
|
|
43
75
|
embedUrl?: string | null;
|
|
76
|
+
/** Canonical `/f/` file-page URL (server-computed). Preferred click-through target; falls back to `url`. */
|
|
77
|
+
pageUrl?: string | null;
|
|
44
78
|
}
|
|
45
79
|
/** A public gallery linked to the PR or issue whose managed comment is syncing. */
|
|
46
80
|
export interface GalleryCommentItem {
|
|
@@ -70,4 +104,4 @@ export declare function attachmentImageWidth(filename: string): number;
|
|
|
70
104
|
* Render the one marker-owned GitHub comment. When there are no galleries this
|
|
71
105
|
* intentionally preserves the legacy attachment-only body byte-for-byte.
|
|
72
106
|
*/
|
|
73
|
-
export declare function attachmentsCommentBody(items: AttachmentItem[], galleries?: GalleryCommentItem[]): string;
|
|
107
|
+
export declare function attachmentsCommentBody(items: AttachmentItem[], galleries?: GalleryCommentItem[], marker?: string): string;
|
package/dist/github.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { inferContentType } from "./embed.js";
|
|
2
2
|
import { sanitizeKeySegment } from "./keys.js";
|
|
3
|
+
import { UsageError } from "./cli-args.js";
|
|
4
|
+
import { isMetaValueSafe } from "./metadata.js";
|
|
3
5
|
const REPO_RE = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
|
|
4
6
|
export function isValidRepo(repo) {
|
|
5
7
|
return REPO_RE.test(repo);
|
|
@@ -63,6 +65,42 @@ export function ghKeyPrefix(target) {
|
|
|
63
65
|
export function ghAttachmentKey(target, filename) {
|
|
64
66
|
return `${ghKeyPrefix(target)}${sanitizeKeySegment(filename)}`;
|
|
65
67
|
}
|
|
68
|
+
/**
|
|
69
|
+
* Branch-staged attachment key prefix: `gh/<owner>/<repo>/branch/<branch>/`.
|
|
70
|
+
* Pre-PR staging (Phase 1 of branch-staged attachments) — no PR/issue number
|
|
71
|
+
* exists yet. `branch` goes through `sanitizeKeySegment` like every other key
|
|
72
|
+
* segment, so e.g. `feature/x` becomes `feature-x`.
|
|
73
|
+
*/
|
|
74
|
+
export function ghBranchKeyPrefix(repo, branch) {
|
|
75
|
+
const [owner, name] = repo.split("/");
|
|
76
|
+
return `gh/${sanitizeKeySegment(owner)}/${sanitizeKeySegment(name)}/branch/${sanitizeKeySegment(branch)}/`;
|
|
77
|
+
}
|
|
78
|
+
/** Branch-staged attachment key: `ghBranchKeyPrefix` + the sanitized filename. */
|
|
79
|
+
export function ghBranchAttachmentKey(repo, branch, filename) {
|
|
80
|
+
return `${ghBranchKeyPrefix(repo, branch)}${sanitizeKeySegment(filename)}`;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* `gh.*` metadata for a branch-staged attach: `gh.repo`, `gh.kind=branch`,
|
|
84
|
+
* `gh.branch` (lowercased), and `gh.staged-at` (ISO 8601 UTC, no fractional
|
|
85
|
+
* seconds). No `gh.number`/`gh.ref`/`gh.title` — there is no PR/issue yet.
|
|
86
|
+
* Throws `UsageError` when the lowercased branch name fails the metadata
|
|
87
|
+
* value rule (printable ASCII, 1-512 chars) — unlike `gh.title`'s best-effort
|
|
88
|
+
* degrade, a branch name the caller explicitly chose (via `--branch` or the
|
|
89
|
+
* current git branch) is worth failing loudly on rather than silently
|
|
90
|
+
* dropping from the metadata set.
|
|
91
|
+
*/
|
|
92
|
+
export function ghMetadataForBranch(repo, branch, now = new Date()) {
|
|
93
|
+
const branchLower = branch.toLowerCase();
|
|
94
|
+
if (!isMetaValueSafe(branchLower)) {
|
|
95
|
+
throw new UsageError(`invalid --branch: "${branch}" must be printable ASCII to stage as gh.branch metadata`);
|
|
96
|
+
}
|
|
97
|
+
return {
|
|
98
|
+
"gh.repo": repo.toLowerCase(),
|
|
99
|
+
"gh.kind": "branch",
|
|
100
|
+
"gh.branch": branchLower,
|
|
101
|
+
"gh.staged-at": now.toISOString().replace(/\.\d{3}Z$/, "Z"),
|
|
102
|
+
};
|
|
103
|
+
}
|
|
66
104
|
/**
|
|
67
105
|
* The four `gh.*` queryable-metadata pairs `uploads attach` writes
|
|
68
106
|
* automatically (`.context/2026-07-13-file-metadata-design.md`). `gh.kind`
|
|
@@ -85,6 +123,28 @@ export function ghMetadataFromTarget(target) {
|
|
|
85
123
|
}
|
|
86
124
|
/** Hidden marker identifying the one comment this CLI manages. Never change it — existing comments are found by exact match. */
|
|
87
125
|
export const ATTACHMENTS_MARKER = "<!-- uploads.sh:attachments -->";
|
|
126
|
+
/** Workspace slugs are `[a-z0-9-]`-ish; only markers built from a slug matching
|
|
127
|
+
* this are trusted as a distinct namespace — anything else (empty, unsafe
|
|
128
|
+
* chars) degrades to the shared legacy marker rather than emitting untrusted
|
|
129
|
+
* text into comment HTML. */
|
|
130
|
+
const WORKSPACE_SLUG_RE = /^[a-z0-9-]{1,64}$/;
|
|
131
|
+
/**
|
|
132
|
+
* Per-workspace marker (`<!-- uploads.sh:attachments ws=<workspace> -->`) so
|
|
133
|
+
* two workspaces managing the same repo don't clobber each other's comment.
|
|
134
|
+
* Falls back to the shared legacy marker when `workspace` is missing or does
|
|
135
|
+
* not look like a safe slug — degrade, don't guess or risk breaking the
|
|
136
|
+
* comment's HTML.
|
|
137
|
+
*/
|
|
138
|
+
export function attachmentsMarker(workspace) {
|
|
139
|
+
if (workspace && WORKSPACE_SLUG_RE.test(workspace)) {
|
|
140
|
+
return `<!-- uploads.sh:attachments ws=${workspace} -->`;
|
|
141
|
+
}
|
|
142
|
+
return ATTACHMENTS_MARKER;
|
|
143
|
+
}
|
|
144
|
+
/** Max attachments embedded as inline `<img>` tags before the rest collapse
|
|
145
|
+
* into a `<details>` link list. Keeps very large threads from becoming a wall
|
|
146
|
+
* of images. */
|
|
147
|
+
export const MAX_INLINE_ATTACHMENT_IMAGES = 16;
|
|
88
148
|
/** Default max width for images in the managed attachments comment (HTML img). */
|
|
89
149
|
export const ATTACHMENT_IMAGE_WIDTH_DEFAULT = 400;
|
|
90
150
|
/** Portrait / device mockups — keep phones readable, not full-column. */
|
|
@@ -116,10 +176,10 @@ function escapeHtmlText(s) {
|
|
|
116
176
|
* Render the one marker-owned GitHub comment. When there are no galleries this
|
|
117
177
|
* intentionally preserves the legacy attachment-only body byte-for-byte.
|
|
118
178
|
*/
|
|
119
|
-
export function attachmentsCommentBody(items, galleries = []) {
|
|
179
|
+
export function attachmentsCommentBody(items, galleries = [], marker = ATTACHMENTS_MARKER) {
|
|
120
180
|
const sorted = items.toSorted((a, b) => a.key.localeCompare(b.key));
|
|
121
181
|
const sortedGalleries = galleries.toSorted((a, b) => a.title.localeCompare(b.title) || a.url.localeCompare(b.url));
|
|
122
|
-
const lines = [
|
|
182
|
+
const lines = [marker];
|
|
123
183
|
if (sortedGalleries.length > 0) {
|
|
124
184
|
lines.push("### 🖼️ Galleries", "");
|
|
125
185
|
for (const gallery of sortedGalleries) {
|
|
@@ -136,27 +196,48 @@ export function attachmentsCommentBody(items, galleries = []) {
|
|
|
136
196
|
}
|
|
137
197
|
if (sorted.length > 0 || sortedGalleries.length === 0)
|
|
138
198
|
lines.push("### 📎 Attachments", "");
|
|
199
|
+
let inlinedImages = 0;
|
|
200
|
+
const overflowImages = [];
|
|
139
201
|
for (const item of sorted) {
|
|
140
202
|
const name = item.key.slice(item.key.lastIndexOf("/") + 1);
|
|
141
203
|
const stable = item.url;
|
|
142
204
|
const src = item.embedUrl ?? item.url;
|
|
143
|
-
|
|
205
|
+
const link = item.pageUrl ?? stable; // click-through: file page when known, else raw
|
|
206
|
+
const isImage = Boolean(src) && inferContentType(name).startsWith("image/");
|
|
207
|
+
if (isImage && inlinedImages >= MAX_INLINE_ATTACHMENT_IMAGES) {
|
|
208
|
+
// Cap hit — defer to the collapsed overflow list below rather than
|
|
209
|
+
// embedding every remaining image inline.
|
|
210
|
+
overflowImages.push(item);
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
if (isImage) {
|
|
214
|
+
inlinedImages++;
|
|
144
215
|
// Markdown ![]() has no width control — phone frames become full-column giants.
|
|
145
|
-
// img src uses embed host when available (Camo revalidates); click-through
|
|
216
|
+
// img src uses embed host when available (Camo revalidates); click-through prefers the file page.
|
|
146
217
|
const w = attachmentImageWidth(name);
|
|
147
218
|
const alt = escapeHtmlAttr(name);
|
|
148
|
-
const href = escapeHtmlAttr(
|
|
219
|
+
const href = escapeHtmlAttr(link ?? src);
|
|
149
220
|
const imgSrc = escapeHtmlAttr(src);
|
|
150
221
|
lines.push(`<a href="${href}"><img width="${w}" alt="${alt}" src="${imgSrc}"></a>`);
|
|
151
222
|
lines.push("");
|
|
152
223
|
}
|
|
153
|
-
else if (
|
|
154
|
-
lines.push(`- [${name}](${
|
|
224
|
+
else if (link) {
|
|
225
|
+
lines.push(`- [${name}](${link})`);
|
|
155
226
|
}
|
|
156
227
|
else {
|
|
157
228
|
lines.push(`- ${name}`);
|
|
158
229
|
}
|
|
159
230
|
}
|
|
231
|
+
if (overflowImages.length > 0) {
|
|
232
|
+
const n = overflowImages.length;
|
|
233
|
+
lines.push(`<details><summary>${n} more attachment${n === 1 ? "" : "s"}</summary>`, "");
|
|
234
|
+
for (const item of overflowImages) {
|
|
235
|
+
const name = item.key.slice(item.key.lastIndexOf("/") + 1);
|
|
236
|
+
const link = item.pageUrl ?? item.url;
|
|
237
|
+
lines.push(link ? `- [${name}](${link})` : `- ${name}`);
|
|
238
|
+
}
|
|
239
|
+
lines.push("", "</details>", "");
|
|
240
|
+
}
|
|
160
241
|
lines.push('<sub>Maintained by <a href="https://uploads.sh">uploads.sh</a> — re-uploading a file with the same name updates it everywhere it is embedded.</sub>');
|
|
161
242
|
return lines.join("\n");
|
|
162
243
|
}
|
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, isValidRepo, parseRepoFromRemoteUrl, type AttachmentItem, type GhTarget, type GhTargetKind, } from "./github.js";
|
|
10
|
+
export { ATTACHMENTS_MARKER, attachmentsCommentBody, 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, isValidRepo, parseRepoFromRemoteUrl, } from "./github.js";
|
|
10
|
+
export { ATTACHMENTS_MARKER, attachmentsCommentBody, 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/dist/mcp/tools.js
CHANGED
|
@@ -123,11 +123,11 @@ export function createUploadsMcpTools(opts) {
|
|
|
123
123
|
});
|
|
124
124
|
return { config, client: clientFactory(config) };
|
|
125
125
|
}
|
|
126
|
-
const syncComment = async (client, target) => {
|
|
126
|
+
const syncComment = async (client, target, workspace) => {
|
|
127
127
|
let comment;
|
|
128
128
|
let commentError;
|
|
129
129
|
try {
|
|
130
|
-
comment = await syncAttachmentsComment(client, target, run);
|
|
130
|
+
comment = await syncAttachmentsComment(client, target, run, workspace);
|
|
131
131
|
}
|
|
132
132
|
catch (err) {
|
|
133
133
|
// Uploads already succeeded; the comment is best-effort by design.
|
|
@@ -402,7 +402,7 @@ export function createUploadsMcpTools(opts) {
|
|
|
402
402
|
catch (err) {
|
|
403
403
|
usage(err instanceof Error ? err.message : String(err));
|
|
404
404
|
}
|
|
405
|
-
const { client } = clientFor(args);
|
|
405
|
+
const { config, client } = clientFor(args);
|
|
406
406
|
const defaults = resolvePutDefaults({ envFile: globals.envFile });
|
|
407
407
|
const frameOpts = mcpFrameOptions(args);
|
|
408
408
|
const optimizeOpts = mcpOptimizeOptions(args, defaults);
|
|
@@ -436,7 +436,7 @@ export function createUploadsMcpTools(opts) {
|
|
|
436
436
|
throw new ToolBatchError(batchFailureMessage(failures), { uploads, failures });
|
|
437
437
|
}
|
|
438
438
|
if (wantComment && target && uploads.length > 0) {
|
|
439
|
-
const { comment, commentError } = await syncComment(client, target);
|
|
439
|
+
const { comment, commentError } = await syncComment(client, target, config.workspace);
|
|
440
440
|
return { uploads, failures, comment, commentError };
|
|
441
441
|
}
|
|
442
442
|
return { uploads, failures };
|
|
@@ -469,7 +469,7 @@ export function createUploadsMcpTools(opts) {
|
|
|
469
469
|
filename: prepared.filename,
|
|
470
470
|
};
|
|
471
471
|
if (wantComment && target) {
|
|
472
|
-
const { comment, commentError } = await syncComment(client, target);
|
|
472
|
+
const { comment, commentError } = await syncComment(client, target, config.workspace);
|
|
473
473
|
return { ...result, markdown, optimize, frame: prepared.frame, comment, commentError };
|
|
474
474
|
}
|
|
475
475
|
return {
|
|
@@ -504,7 +504,7 @@ export function createUploadsMcpTools(opts) {
|
|
|
504
504
|
...(dryRun ? { dryRun: true } : {}),
|
|
505
505
|
};
|
|
506
506
|
if (wantComment && target) {
|
|
507
|
-
const { comment, commentError } = await syncComment(client, target);
|
|
507
|
+
const { comment, commentError } = await syncComment(client, target, config.workspace);
|
|
508
508
|
return { ...flat, comment, commentError };
|
|
509
509
|
}
|
|
510
510
|
return flat;
|
|
@@ -761,7 +761,7 @@ export function createUploadsMcpTools(opts) {
|
|
|
761
761
|
...(dryRun ? { dryRun: true } : {}),
|
|
762
762
|
};
|
|
763
763
|
if (wantComment && target) {
|
|
764
|
-
const { comment, commentError } = await syncComment(client, target);
|
|
764
|
+
const { comment, commentError } = await syncComment(client, target, config.workspace);
|
|
765
765
|
return { ...flat, comment, commentError };
|
|
766
766
|
}
|
|
767
767
|
return flat;
|
|
@@ -821,7 +821,7 @@ export function createUploadsMcpTools(opts) {
|
|
|
821
821
|
const explicitTarget = ghTargetFromArgs(args, run);
|
|
822
822
|
const target = explicitTarget ??
|
|
823
823
|
resolveCurrentPullRequest(resolveRepo(optString(args, "repo"), run), run);
|
|
824
|
-
const { client } = clientFor(args);
|
|
824
|
+
const { config, client } = clientFor(args);
|
|
825
825
|
const contentType = optString(args, "contentType");
|
|
826
826
|
const defaults = resolvePutDefaults({ envFile: globals.envFile });
|
|
827
827
|
const frameOpts = mcpFrameOptions(args);
|
|
@@ -856,7 +856,7 @@ export function createUploadsMcpTools(opts) {
|
|
|
856
856
|
}
|
|
857
857
|
if (optBool(args, "noComment"))
|
|
858
858
|
return { target, uploads, failures };
|
|
859
|
-
const { comment, commentError } = await syncComment(client, target);
|
|
859
|
+
const { comment, commentError } = await syncComment(client, target, config.workspace);
|
|
860
860
|
return { target, uploads, failures, comment, commentError };
|
|
861
861
|
},
|
|
862
862
|
},
|
|
@@ -1062,8 +1062,8 @@ export function createUploadsMcpTools(opts) {
|
|
|
1062
1062
|
const target = ghTargetFromArgs(args, run);
|
|
1063
1063
|
if (!target)
|
|
1064
1064
|
usage("comment requires pr or issue");
|
|
1065
|
-
const { client } = clientFor(args);
|
|
1066
|
-
const result = await syncAttachmentsComment(client, target, run);
|
|
1065
|
+
const { config, client } = clientFor(args);
|
|
1066
|
+
const result = await syncAttachmentsComment(client, target, run, config.workspace);
|
|
1067
1067
|
return { ...target, ...result };
|
|
1068
1068
|
},
|
|
1069
1069
|
},
|