@buildinternet/uploads 0.22.0 → 0.23.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/commands.js CHANGED
@@ -12,7 +12,8 @@ import { imageFactsFromBytes } from "./image-facts.js";
12
12
  import { parseMetaFlags, validateMetaMap } from "./metadata.js";
13
13
  import { mergeDerivedMeta, nearMissMetaWarnings, validateStateValue } from "./metadata-vocab.js";
14
14
  import { ghAttachmentKey, ghBranchAttachmentKey, ghKeyPrefix, ghMetadataFromTarget, ghMetadataForBranch, attachmentsCommentBody, attachmentsMarker, normalizeGithubCoordinate, } from "./github.js";
15
- import { resolveRepo, resolveCurrentPullRequest, resolveCurrentBranch, classifyGhNumber, execRunner, ghMetadataFromTargetWithTitle, upsertAttachmentsComment, } from "./github-gh.js";
15
+ import { resolveRepo, resolveCurrentPullRequest, resolveCurrentBranch, resolveDefaultBranch, classifyGhNumber, execRunner, timedExecRunner, ghMetadataFromTargetWithTitle, upsertAttachmentsComment, } from "./github-gh.js";
16
+ import { deriveRepoFromGit } from "./keys.js";
16
17
  import { resolvePutPrefix } from "./destinations.js";
17
18
  import { optimizeImageForUpload, rewriteKeyExtension, } from "./optimize.js";
18
19
  import { applyFrame, resolveFrameId } from "./frame.js";
@@ -111,6 +112,10 @@ Options:
111
112
  --dry-run Print key + public URL without uploading; reports if the key would replace
112
113
  (or, on a strict key, be refused). Not with --comment/--gallery
113
114
 
115
+ A bare put (no --pr/--issue/--key) on a non-default git branch prints a one-line
116
+ nudge toward --pr/attach --branch (stderr in human mode, a "hint" field in
117
+ --format json). Suppress with --quiet, UPLOADS_NO_NUDGE=1, or config UPLOADS_NO_NUDGE=1.
118
+
114
119
  Exit codes: 0 ok · 2 usage/token/file · 3 auth/policy · 4 network · 1 other (incl. partial multi-file failure).
115
120
  Scripted formats (json|url|markdown) also print failures on stdout.
116
121
 
@@ -1026,6 +1031,8 @@ async function runAttachBranch(ctx, parsed, branch, run) {
1026
1031
  }
1027
1032
  if (!ctx.quiet && uploads.length > 0) {
1028
1033
  process.stderr.write(`>> find these later: uploads find gh.branch=${branch.toLowerCase()}\n`);
1034
+ process.stderr.write(`>> staged: these auto-attach to this branch's PR when it opens ` +
1035
+ `(or run \`uploads attach --promote\` after opening)\n`);
1029
1036
  }
1030
1037
  }
1031
1038
  return failures.length === 0 ? 0 : 1;
@@ -1077,6 +1084,78 @@ async function runAttachPromoteOnly(ctx, parsed, run) {
1077
1084
  }
1078
1085
  return 0;
1079
1086
  }
1087
+ /** Bounds the best-effort `gh pr view` lookup the put nudge (issue #393) makes
1088
+ * on top of the normal put flow — long enough for a real gh call, short
1089
+ * enough to never be felt as a hang. */
1090
+ const PUT_NUDGE_GH_TIMEOUT_MS = 3000;
1091
+ /**
1092
+ * The bare-put nudge's wording (issue #393): teaches `--pr`/`attach --branch`
1093
+ * as an upgrade from a targetless `put`. `pr` present → names the PR;
1094
+ * otherwise a generic variant that still points at `--pr <num>`. Used
1095
+ * verbatim for both the human-mode stderr line and the JSON `hint` field.
1096
+ */
1097
+ function putNudgeText(branch, pr) {
1098
+ const prClause = pr !== undefined ? ` (PR #${pr} open) — rerun with --pr ${pr}` : ` — rerun with --pr <num>`;
1099
+ return (`note: on branch ${branch}${prClause} for a stable key plus a managed comment ` +
1100
+ `that collects this PR's media, or stage pre-PR files with: uploads attach <file> --branch`);
1101
+ }
1102
+ /**
1103
+ * Best-effort bare-put nudge (issue #393): fires only when `put` has no
1104
+ * targeting flag at all (`--pr`/`--issue`/`--key`; `--branch` too, though
1105
+ * `put` doesn't currently accept it — defensive parity with `attach`), is
1106
+ * inside a git repo (reusing `deriveRepoFromGit`, the same detection the
1107
+ * default screenshot key's repo segment uses), and the current branch isn't
1108
+ * the default one. Never throws — any failure (not a repo, detached HEAD,
1109
+ * `gh` missing/unauthenticated/timed out) degrades to "no nudge" or, once a
1110
+ * branch is already known, to the generic no-PR wording. Must never affect
1111
+ * put's exit code, stdout, or upload behavior.
1112
+ */
1113
+ function resolvePutNudge(opts) {
1114
+ const { ctx, flags, ghTarget, keyHint, noGit, defaults, run } = opts;
1115
+ if (ctx.quiet)
1116
+ return undefined;
1117
+ if (defaults.noNudge)
1118
+ return undefined;
1119
+ if (ghTarget || keyHint || noGit)
1120
+ return undefined;
1121
+ if (flags.has("--branch"))
1122
+ return undefined; // not a real put flag today; defensive only
1123
+ try {
1124
+ if (deriveRepoFromGit(run) === undefined)
1125
+ return undefined; // not a (usable) git repo
1126
+ let branch;
1127
+ try {
1128
+ branch = resolveCurrentBranch(run);
1129
+ }
1130
+ catch {
1131
+ return undefined; // detached HEAD, or git unavailable
1132
+ }
1133
+ const defaultBranch = resolveDefaultBranch(run);
1134
+ const onDefaultBranch = defaultBranch
1135
+ ? branch === defaultBranch
1136
+ : branch === "main" || branch === "master"; // undetermined: err toward not nudging
1137
+ if (onDefaultBranch)
1138
+ return undefined;
1139
+ let pr;
1140
+ try {
1141
+ // Only swap in the bounded runner for the real subprocess path — an
1142
+ // injected `run` (tests, or a future caller) is trusted to already be
1143
+ // fast/fake, and execFileSync's `timeout` option is meaningless
1144
+ // against anything that isn't actually shelling out.
1145
+ const timed = run === execRunner ? timedExecRunner(PUT_NUDGE_GH_TIMEOUT_MS) : run;
1146
+ const repoArg = flagString(flags, "--repo") ?? defaults.repo;
1147
+ const repo = resolveRepo(repoArg, timed);
1148
+ pr = resolveCurrentPullRequest(repo, timed).num;
1149
+ }
1150
+ catch {
1151
+ pr = undefined; // gh missing/unauthenticated/timed out/no open PR — generic wording
1152
+ }
1153
+ return putNudgeText(branch, pr);
1154
+ }
1155
+ catch {
1156
+ return undefined;
1157
+ }
1158
+ }
1080
1159
  export async function runPut(ctx, args, help = false, run = execRunner) {
1081
1160
  if (help) {
1082
1161
  writeCommandHelp(PUT_HELP);
@@ -1227,6 +1306,18 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
1227
1306
  }
1228
1307
  }
1229
1308
  }
1309
+ // Bare-put nudge (issue #393): computed once, used for both the trailing
1310
+ // stderr line (human mode) and the JSON `hint` field below. Best-effort —
1311
+ // see resolvePutNudge; never affects exit code, stdout, or the upload.
1312
+ const nudge = resolvePutNudge({
1313
+ ctx,
1314
+ flags: parsed.flags,
1315
+ ghTarget,
1316
+ keyHint,
1317
+ noGit,
1318
+ defaults,
1319
+ run,
1320
+ });
1230
1321
  const logHuman = !ctx.quiet && format === "human";
1231
1322
  if (logHuman) {
1232
1323
  if (multi) {
@@ -1308,6 +1399,7 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
1308
1399
  failures,
1309
1400
  comment,
1310
1401
  commentError,
1402
+ ...(nudge ? { hint: nudge } : {}),
1311
1403
  });
1312
1404
  }
1313
1405
  else {
@@ -1338,6 +1430,8 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
1338
1430
  for (const failure of failures) {
1339
1431
  process.stderr.write(`warning: could not upload ${failure.file}: ${failure.error.message}\n`);
1340
1432
  }
1433
+ if (nudge)
1434
+ process.stderr.write(`${nudge}\n`);
1341
1435
  }
1342
1436
  return failures.length === 0 && !galleryHadError ? 0 : 1;
1343
1437
  }
@@ -1369,6 +1463,7 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
1369
1463
  frame: result.frame,
1370
1464
  gallery,
1371
1465
  ...(dryRun ? { dryRun: true } : {}),
1466
+ ...(nudge ? { hint: nudge } : {}),
1372
1467
  });
1373
1468
  break;
1374
1469
  case "url":
@@ -1388,6 +1483,8 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
1388
1483
  if (gallery?.error) {
1389
1484
  process.stderr.write(`warning: upload succeeded but adding it to gallery ${gallery.id} failed: ${gallery.error.message}\n`);
1390
1485
  }
1486
+ if (nudge && format !== "json")
1487
+ process.stderr.write(`${nudge}\n`);
1391
1488
  return gallery?.error ? 1 : 0;
1392
1489
  }
1393
1490
  // --- galleries ---
@@ -1,5 +1,5 @@
1
1
  import type { UploadsClientConfig } from "./config.js";
2
- export declare const UPLOADS_CONFIG_KEYS: readonly ["UPLOADS_API_URL", "UPLOADS_WORKSPACE", "UPLOADS_TOKEN", "UPLOADS_DEFAULT_PREFIX", "UPLOADS_DEFAULT_REPO", "UPLOADS_DEFAULT_REF", "UPLOADS_DEFAULT_WIDTH", "UPLOADS_NO_GIT", "UPLOADS_NO_OPTIMIZE", "UPLOADS_KEEP_EXIF", "UPLOADS_NO_AUTO_META", "UPLOADS_SCREENSHOT_VIA"];
2
+ export declare const UPLOADS_CONFIG_KEYS: readonly ["UPLOADS_API_URL", "UPLOADS_WORKSPACE", "UPLOADS_TOKEN", "UPLOADS_DEFAULT_PREFIX", "UPLOADS_DEFAULT_REPO", "UPLOADS_DEFAULT_REF", "UPLOADS_DEFAULT_WIDTH", "UPLOADS_NO_GIT", "UPLOADS_NO_OPTIMIZE", "UPLOADS_KEEP_EXIF", "UPLOADS_NO_AUTO_META", "UPLOADS_SCREENSHOT_VIA", "UPLOADS_NO_NUDGE"];
3
3
  export type UploadsConfigKey = (typeof UPLOADS_CONFIG_KEYS)[number];
4
4
  export type UploadsConfigValues = Partial<Record<UploadsConfigKey, string>>;
5
5
  export interface PutDefaults {
@@ -14,6 +14,8 @@ export interface PutDefaults {
14
14
  keepExif?: boolean;
15
15
  /** When true, `put` does NOT auto-resolve/stamp gh.* on the default path. */
16
16
  noAutoMeta?: boolean;
17
+ /** When true, `put` never prints the bare-put --pr/attach nudge (issue #393). */
18
+ noNudge?: boolean;
17
19
  }
18
20
  declare const PUT_DEFAULT_KEY_MAP: Record<keyof PutDefaults, UploadsConfigKey>;
19
21
  export declare function putDefaultsToConfigValues(defaults: PutDefaults): UploadsConfigValues;
@@ -14,6 +14,7 @@ export const UPLOADS_CONFIG_KEYS = [
14
14
  "UPLOADS_KEEP_EXIF",
15
15
  "UPLOADS_NO_AUTO_META",
16
16
  "UPLOADS_SCREENSHOT_VIA",
17
+ "UPLOADS_NO_NUDGE",
17
18
  ];
18
19
  const PUT_DEFAULT_KEY_MAP = {
19
20
  prefix: "UPLOADS_DEFAULT_PREFIX",
@@ -24,6 +25,7 @@ const PUT_DEFAULT_KEY_MAP = {
24
25
  noOptimize: "UPLOADS_NO_OPTIMIZE",
25
26
  keepExif: "UPLOADS_KEEP_EXIF",
26
27
  noAutoMeta: "UPLOADS_NO_AUTO_META",
28
+ noNudge: "UPLOADS_NO_NUDGE",
27
29
  };
28
30
  function isTruthyConfigFlag(value) {
29
31
  if (!value)
@@ -49,6 +51,8 @@ export function putDefaultsToConfigValues(defaults) {
49
51
  out.UPLOADS_KEEP_EXIF = "1";
50
52
  if (defaults.noAutoMeta)
51
53
  out.UPLOADS_NO_AUTO_META = "1";
54
+ if (defaults.noNudge)
55
+ out.UPLOADS_NO_NUDGE = "1";
52
56
  return out;
53
57
  }
54
58
  function parsePutDefaultsFromRaw(raw) {
@@ -72,6 +76,8 @@ function parsePutDefaultsFromRaw(raw) {
72
76
  out.keepExif = true;
73
77
  if (isTruthyConfigFlag(raw.UPLOADS_NO_AUTO_META))
74
78
  out.noAutoMeta = true;
79
+ if (isTruthyConfigFlag(raw.UPLOADS_NO_NUDGE))
80
+ out.noNudge = true;
75
81
  return out;
76
82
  }
77
83
  function parsePutDefaultsFromEnv() {
@@ -92,6 +98,8 @@ function parsePutDefaultsFromEnv() {
92
98
  raw.UPLOADS_KEEP_EXIF = process.env.UPLOADS_KEEP_EXIF;
93
99
  if (process.env.UPLOADS_NO_AUTO_META)
94
100
  raw.UPLOADS_NO_AUTO_META = process.env.UPLOADS_NO_AUTO_META;
101
+ if (process.env.UPLOADS_NO_NUDGE)
102
+ raw.UPLOADS_NO_NUDGE = process.env.UPLOADS_NO_NUDGE;
95
103
  return parsePutDefaultsFromRaw(raw);
96
104
  }
97
105
  /** XDG default shared across buildinternet skills (github-screenshots, uploads, …). */
@@ -163,6 +171,8 @@ export function mergePutDefaults(...layers) {
163
171
  out.keepExif = layer.keepExif;
164
172
  if (layer.noAutoMeta != null)
165
173
  out.noAutoMeta = layer.noAutoMeta;
174
+ if (layer.noNudge != null)
175
+ out.noNudge = layer.noNudge;
166
176
  }
167
177
  return out;
168
178
  }
@@ -2,6 +2,14 @@ import { type GhTarget } from "./github.js";
2
2
  /** Runs a command and returns stdout; throws on non-zero exit. Injectable for tests. */
3
3
  export type CommandRunner = (cmd: string, args: string[], input?: string) => string;
4
4
  export declare const execRunner: CommandRunner;
5
+ /**
6
+ * A `CommandRunner` bounded by `timeoutMs` (node's native `execFileSync`
7
+ * `timeout` option). There is no other subprocess-timeout wrapper in this
8
+ * codebase to reuse, so this is the minimal one: for a best-effort lookup
9
+ * that must never block its caller for long (e.g. the bare-`put` nudge's `gh
10
+ * pr view` check, issue #393), pass this instead of the default `execRunner`.
11
+ */
12
+ export declare const timedExecRunner: (timeoutMs: number) => CommandRunner;
5
13
  /**
6
14
  * Resolve "owner/name". Order: explicit --repo (validated) → `gh repo view`
7
15
  * (fork-aware) → parse the origin remote → UsageError.
@@ -11,6 +19,14 @@ export declare function resolveRepo(explicit: string | undefined, run?: CommandR
11
19
  export declare function resolveCurrentPullRequest(repo: string, run?: CommandRunner): GhTarget;
12
20
  /** Resolve the current git branch (`--branch` with no value). Throws UsageError on detached HEAD or outside a git repo. */
13
21
  export declare function resolveCurrentBranch(run?: CommandRunner): string;
22
+ /**
23
+ * Best-effort default-branch name via the local `origin/HEAD` ref (no
24
+ * network call — just reads the ref git already cached from the last
25
+ * fetch/clone). Returns undefined when it can't be determined (no origin,
26
+ * `origin/HEAD` never set, not a git repo) — callers should treat that as
27
+ * "unknown", not "no default branch exists".
28
+ */
29
+ export declare function resolveDefaultBranch(run?: CommandRunner): string | undefined;
14
30
  /**
15
31
  * Classify a bare PR/issue number via the GitHub API so the default `put`
16
32
  * path can stamp the right `gh.kind`. Returns undefined on any failure (gh
package/dist/github-gh.js CHANGED
@@ -3,6 +3,19 @@ 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
  export const execRunner = (cmd, args, input) => execFileSync(cmd, args, { encoding: "utf8", input, stdio: ["pipe", "pipe", "pipe"] });
6
+ /**
7
+ * A `CommandRunner` bounded by `timeoutMs` (node's native `execFileSync`
8
+ * `timeout` option). There is no other subprocess-timeout wrapper in this
9
+ * codebase to reuse, so this is the minimal one: for a best-effort lookup
10
+ * that must never block its caller for long (e.g. the bare-`put` nudge's `gh
11
+ * pr view` check, issue #393), pass this instead of the default `execRunner`.
12
+ */
13
+ export const timedExecRunner = (timeoutMs) => (cmd, args, input) => execFileSync(cmd, args, {
14
+ encoding: "utf8",
15
+ input,
16
+ stdio: ["pipe", "pipe", "pipe"],
17
+ timeout: timeoutMs,
18
+ });
6
19
  /**
7
20
  * Resolve "owner/name". Order: explicit --repo (validated) → `gh repo view`
8
21
  * (fork-aware) → parse the origin remote → UsageError.
@@ -82,6 +95,26 @@ export function resolveCurrentBranch(run = execRunner) {
82
95
  }
83
96
  return branch;
84
97
  }
98
+ /**
99
+ * Best-effort default-branch name via the local `origin/HEAD` ref (no
100
+ * network call — just reads the ref git already cached from the last
101
+ * fetch/clone). Returns undefined when it can't be determined (no origin,
102
+ * `origin/HEAD` never set, not a git repo) — callers should treat that as
103
+ * "unknown", not "no default branch exists".
104
+ */
105
+ export function resolveDefaultBranch(run = execRunner) {
106
+ try {
107
+ const out = run("git", ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"]).trim();
108
+ if (!out)
109
+ return undefined;
110
+ const slash = out.indexOf("/");
111
+ const branch = slash === -1 ? out : out.slice(slash + 1);
112
+ return branch || undefined;
113
+ }
114
+ catch {
115
+ return undefined;
116
+ }
117
+ }
85
118
  /**
86
119
  * Classify a bare PR/issue number via the GitHub API so the default `put`
87
120
  * path can stamp the right `gh.kind`. Returns undefined on any failure (gh
package/dist/github.d.ts CHANGED
@@ -86,6 +86,18 @@ export interface AttachmentItem {
86
86
  path?: string;
87
87
  state?: string;
88
88
  };
89
+ /**
90
+ * Poster frame for a video (issue #299), server-computed like `embedUrl` —
91
+ * never taken from client-settable metadata. Absent means "no poster", and
92
+ * the renderer falls back to the bullet link.
93
+ */
94
+ posterUrl?: string | null;
95
+ /** Derived video facts used for the caption and display width. */
96
+ videoMeta?: {
97
+ durationSeconds?: number;
98
+ width?: number;
99
+ height?: number;
100
+ };
89
101
  }
90
102
  /** A public gallery linked to the PR or issue whose managed comment is syncing. */
91
103
  export interface GalleryCommentItem {
package/dist/github.js CHANGED
@@ -171,6 +171,34 @@ export function attachmentImageWidth(filename) {
171
171
  }
172
172
  return ATTACHMENT_IMAGE_WIDTH_DEFAULT;
173
173
  }
174
+ /** `m:ss` under an hour, `h:mm:ss` at or above one. */
175
+ function formatDuration(seconds) {
176
+ const total = Math.floor(seconds);
177
+ const s = total % 60;
178
+ const m = Math.floor(total / 60) % 60;
179
+ const h = Math.floor(total / 3600);
180
+ const ss = String(s).padStart(2, "0");
181
+ if (h === 0)
182
+ return `${m}:${ss}`;
183
+ return `${h}:${String(m).padStart(2, "0")}:${ss}`;
184
+ }
185
+ /**
186
+ * Display width for a video poster. Real dimensions only *select* among the
187
+ * width constants — a raw 1920 would blow out the comment column — and the
188
+ * result is capped at the real width so a small clip is never upscaled.
189
+ */
190
+ function posterImageWidth(videoMeta, filename) {
191
+ const w = videoMeta?.width ?? 0;
192
+ const h = videoMeta?.height ?? 0;
193
+ if (w <= 0 || h <= 0)
194
+ return attachmentImageWidth(filename);
195
+ const chosen = h > w
196
+ ? ATTACHMENT_IMAGE_WIDTH_PORTRAIT
197
+ : w / h >= 16 / 9
198
+ ? ATTACHMENT_IMAGE_WIDTH_WIDE
199
+ : ATTACHMENT_IMAGE_WIDTH_DEFAULT;
200
+ return Math.min(chosen, w);
201
+ }
174
202
  function escapeHtmlAttr(s) {
175
203
  return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
176
204
  }
@@ -196,14 +224,19 @@ function escapeMarkdownText(s) {
196
224
  * `PATCH /v1/:workspace/files/:key` can set any valid metadata value. A
197
225
  * whitespace-only value passes that validation (length-1 printable ASCII), so
198
226
  * treat it as absent rather than rendering a dangling separator.
227
+ *
228
+ * Bare `/` is stored/searchable but omitted from captions (issue #375) —
229
+ * alone it is a stray character, and as a prefix next to `state` it is
230
+ * noise. Only exact `/` after trim is suppressed.
199
231
  */
200
232
  function metaCaptionParts(meta) {
201
233
  const parts = [];
202
- for (const value of [meta?.path, meta?.state]) {
203
- const trimmed = value?.trim();
204
- if (trimmed)
205
- parts.push(trimmed);
206
- }
234
+ const path = meta?.path?.trim();
235
+ if (path && path !== "/")
236
+ parts.push(path);
237
+ const state = meta?.state?.trim();
238
+ if (state)
239
+ parts.push(state);
207
240
  return parts;
208
241
  }
209
242
  /** `<sub>` caption body for an inline image, or null when there is nothing to say. */
@@ -254,13 +287,29 @@ export function attachmentsCommentBody(items, galleries = [], marker = ATTACHMEN
254
287
  const src = item.embedUrl ?? item.url;
255
288
  const link = item.pageUrl ?? stable; // click-through: file page when known, else raw
256
289
  const isImage = Boolean(src) && inferContentType(name).startsWith("image/");
257
- if (isImage && inlinedImages >= MAX_INLINE_ATTACHMENT_IMAGES) {
290
+ const isPosterVideo = Boolean(item.posterUrl) && inferContentType(name).startsWith("video/");
291
+ const inlines = isImage || isPosterVideo;
292
+ if (inlines && inlinedImages >= MAX_INLINE_ATTACHMENT_IMAGES) {
258
293
  // Cap hit — defer to the collapsed overflow list below rather than
259
294
  // embedding every remaining image inline.
260
295
  overflowImages.push(item);
261
296
  continue;
262
297
  }
263
- if (isImage) {
298
+ if (isPosterVideo) {
299
+ inlinedImages++;
300
+ const w = posterImageWidth(item.videoMeta, name);
301
+ const href = escapeHtmlAttr(link ?? item.posterUrl);
302
+ lines.push(`<a href="${href}"><img width="${w}" alt="${escapeHtmlAttr(name)}" src="${escapeHtmlAttr(item.posterUrl)}"></a>`);
303
+ // GitHub strips <video>, so a still frame needs an explicit affordance
304
+ // or it reads as a screenshot.
305
+ const parts = ["▶ Play video"];
306
+ if (item.videoMeta?.durationSeconds != null) {
307
+ parts.push(formatDuration(item.videoMeta.durationSeconds));
308
+ }
309
+ parts.push(...metaCaptionParts(item.meta).map(escapeHtmlText));
310
+ lines.push(`<sub>${parts.join(" · ")}</sub>`, "");
311
+ }
312
+ else if (isImage) {
264
313
  inlinedImages++;
265
314
  // Markdown ![]() has no width control — phone frames become full-column giants.
266
315
  // img src uses embed host when available (Camo revalidates); click-through prefers the file page.
package/dist/keys.d.ts CHANGED
@@ -1,6 +1,13 @@
1
1
  export declare function sanitizeKeySegment(s: string): string;
2
2
  export declare function sha256Short(bytes: Uint8Array): Promise<string>;
3
- export declare function deriveRepoFromGit(): string | undefined;
3
+ /**
4
+ * `run` is optional and structurally matches `CommandRunner` (github-gh.ts)
5
+ * without importing it — callers that already have an injected runner (e.g.
6
+ * the put nudge, issue #393) can pass it through for testability; the
7
+ * default (no `run`) preserves the original direct-`execSync` behavior for
8
+ * every existing caller.
9
+ */
10
+ export declare function deriveRepoFromGit(run?: (cmd: string, args: string[], input?: string) => string): string | undefined;
4
11
  export declare function buildScreenshotKey(opts: {
5
12
  filename: string;
6
13
  fileBytes: Uint8Array;
package/dist/keys.js CHANGED
@@ -9,9 +9,18 @@ export async function sha256Short(bytes) {
9
9
  .join("")
10
10
  .slice(0, 6);
11
11
  }
12
- export function deriveRepoFromGit() {
12
+ /**
13
+ * `run` is optional and structurally matches `CommandRunner` (github-gh.ts)
14
+ * without importing it — callers that already have an injected runner (e.g.
15
+ * the put nudge, issue #393) can pass it through for testability; the
16
+ * default (no `run`) preserves the original direct-`execSync` behavior for
17
+ * every existing caller.
18
+ */
19
+ export function deriveRepoFromGit(run) {
13
20
  try {
14
- const url = execSync("git config --get remote.origin.url", { encoding: "utf8" }).trim();
21
+ const url = run
22
+ ? run("git", ["config", "--get", "remote.origin.url"]).trim()
23
+ : execSync("git config --get remote.origin.url", { encoding: "utf8" }).trim();
15
24
  const match = url.match(/[/:]([^/]+?)(?:\.git)?$/);
16
25
  return match?.[1];
17
26
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@buildinternet/uploads",
3
- "version": "0.22.0",
3
+ "version": "0.23.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,