@buildinternet/uploads 0.22.1 → 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
 
@@ -1079,6 +1084,78 @@ async function runAttachPromoteOnly(ctx, parsed, run) {
1079
1084
  }
1080
1085
  return 0;
1081
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
+ }
1082
1159
  export async function runPut(ctx, args, help = false, run = execRunner) {
1083
1160
  if (help) {
1084
1161
  writeCommandHelp(PUT_HELP);
@@ -1229,6 +1306,18 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
1229
1306
  }
1230
1307
  }
1231
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
+ });
1232
1321
  const logHuman = !ctx.quiet && format === "human";
1233
1322
  if (logHuman) {
1234
1323
  if (multi) {
@@ -1310,6 +1399,7 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
1310
1399
  failures,
1311
1400
  comment,
1312
1401
  commentError,
1402
+ ...(nudge ? { hint: nudge } : {}),
1313
1403
  });
1314
1404
  }
1315
1405
  else {
@@ -1340,6 +1430,8 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
1340
1430
  for (const failure of failures) {
1341
1431
  process.stderr.write(`warning: could not upload ${failure.file}: ${failure.error.message}\n`);
1342
1432
  }
1433
+ if (nudge)
1434
+ process.stderr.write(`${nudge}\n`);
1343
1435
  }
1344
1436
  return failures.length === 0 && !galleryHadError ? 0 : 1;
1345
1437
  }
@@ -1371,6 +1463,7 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
1371
1463
  frame: result.frame,
1372
1464
  gallery,
1373
1465
  ...(dryRun ? { dryRun: true } : {}),
1466
+ ...(nudge ? { hint: nudge } : {}),
1374
1467
  });
1375
1468
  break;
1376
1469
  case "url":
@@ -1390,6 +1483,8 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
1390
1483
  if (gallery?.error) {
1391
1484
  process.stderr.write(`warning: upload succeeded but adding it to gallery ${gallery.id} failed: ${gallery.error.message}\n`);
1392
1485
  }
1486
+ if (nudge && format !== "json")
1487
+ process.stderr.write(`${nudge}\n`);
1393
1488
  return gallery?.error ? 1 : 0;
1394
1489
  }
1395
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
  }
@@ -259,13 +287,29 @@ export function attachmentsCommentBody(items, galleries = [], marker = ATTACHMEN
259
287
  const src = item.embedUrl ?? item.url;
260
288
  const link = item.pageUrl ?? stable; // click-through: file page when known, else raw
261
289
  const isImage = Boolean(src) && inferContentType(name).startsWith("image/");
262
- 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) {
263
293
  // Cap hit — defer to the collapsed overflow list below rather than
264
294
  // embedding every remaining image inline.
265
295
  overflowImages.push(item);
266
296
  continue;
267
297
  }
268
- 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) {
269
313
  inlinedImages++;
270
314
  // Markdown ![]() has no width control — phone frames become full-column giants.
271
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.1",
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,