@buildinternet/uploads 0.8.0 → 0.10.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.
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Session commands: whoami / status (show identity) and logout (clear token).
3
+ */
4
+ import { describeConfigSources, loadConfigFile, redactToken, removeConfigKeys, resolveConfig, resolveConfigPath, workspaceFromToken, } from "../config.js";
5
+ import { flagBool, flagString, parseCommandArgs } from "../cli-args.js";
6
+ import { writeCommandHelp } from "../cli-style.js";
7
+ import { writeJson } from "../io.js";
8
+ const WHOAMI_HELP = `uploads whoami
9
+
10
+ Show the active CLI identity: workspace, token (redacted), API URL, and where
11
+ each value came from. Alias: uploads status.
12
+
13
+ Does not call the API (offline). Use uploads doctor to verify the token works.
14
+
15
+ Options:
16
+ --path <file> Config file to inspect (default: shared buildinternet config)
17
+ --json JSON on stdout
18
+
19
+ Examples:
20
+ uploads whoami
21
+ uploads status --json
22
+ uploads whoami --path ./my.env
23
+ `;
24
+ const LOGOUT_HELP = `uploads logout
25
+
26
+ Remove the saved UPLOADS_TOKEN from the shared config file so this machine is
27
+ no longer signed in for the CLI. Does not revoke the token on the server.
28
+
29
+ Environment variables (UPLOADS_TOKEN) are not unset — export them yourself if set.
30
+
31
+ Options:
32
+ --path <file> Config file to edit (default: shared buildinternet config)
33
+ --json JSON on stdout
34
+
35
+ Examples:
36
+ uploads logout
37
+ uploads logout --path ./my.env
38
+ `;
39
+ export function buildWhoamiReport(opts) {
40
+ const flags = {
41
+ envFile: opts.envFile,
42
+ token: opts.token,
43
+ workspace: opts.workspace,
44
+ apiUrl: opts.apiUrl,
45
+ };
46
+ const config = resolveConfig({ ...flags, requireToken: false });
47
+ const sources = describeConfigSources(flags);
48
+ const tokenInConfig = Boolean(loadConfigFile(config.configPath).UPLOADS_TOKEN);
49
+ return {
50
+ signedIn: Boolean(config.token),
51
+ workspace: config.workspace,
52
+ workspaceSource: sources.workspace,
53
+ workspaceFromToken: config.token ? workspaceFromToken(config.token) : undefined,
54
+ token: redactToken(config.token || undefined),
55
+ tokenSource: sources.token,
56
+ tokenInConfig,
57
+ apiUrl: config.apiUrl,
58
+ apiUrlSource: sources.apiUrl,
59
+ configPath: config.configPath,
60
+ configExists: config.configExists,
61
+ };
62
+ }
63
+ function formatWhoami(report) {
64
+ const cfg = `${report.configPath}${report.configExists ? "" : " (missing)"}`;
65
+ const lines = [
66
+ `signed in: ${report.signedIn ? "yes" : "no"}`,
67
+ `workspace: ${report.workspace} (${report.workspaceSource})`,
68
+ ];
69
+ if (report.workspaceFromToken && report.workspaceFromToken !== report.workspace) {
70
+ lines.push(`token ws: ${report.workspaceFromToken} (encoded in token)`);
71
+ }
72
+ if (report.signedIn) {
73
+ lines.push(`token: ${report.token} (${report.tokenSource})`);
74
+ }
75
+ lines.push(`api: ${report.apiUrl} (${report.apiUrlSource})`);
76
+ lines.push(`config: ${cfg}`);
77
+ if (!report.signedIn) {
78
+ lines.push("", "hint: run uploads login to sign in");
79
+ }
80
+ else if (report.tokenSource === "env" && !report.tokenInConfig) {
81
+ lines.push("", "note: token is from the environment, not the config file", " uploads logout only clears the config file");
82
+ }
83
+ return lines.join("\n") + "\n";
84
+ }
85
+ function wantsJson(opts, parsed) {
86
+ return Boolean(opts.json || flagBool(parsed.flags, "--json"));
87
+ }
88
+ export async function runWhoami(args, opts, help = false) {
89
+ const parsed = parseCommandArgs(args);
90
+ if (help || parsed.help) {
91
+ writeCommandHelp(WHOAMI_HELP);
92
+ return 0;
93
+ }
94
+ const report = buildWhoamiReport({
95
+ envFile: flagString(parsed.flags, "--path") ?? opts.envFile,
96
+ token: opts.token,
97
+ workspace: opts.workspace,
98
+ apiUrl: opts.apiUrl,
99
+ });
100
+ if (wantsJson(opts, parsed))
101
+ await writeJson(report);
102
+ else
103
+ process.stdout.write(formatWhoami(report));
104
+ return report.signedIn ? 0 : 1;
105
+ }
106
+ export async function runLogout(args, opts, help = false) {
107
+ const parsed = parseCommandArgs(args);
108
+ if (help || parsed.help) {
109
+ writeCommandHelp(LOGOUT_HELP);
110
+ return 0;
111
+ }
112
+ const path = flagString(parsed.flags, "--path") ?? resolveConfigPath({ envFile: opts.envFile });
113
+ const hadFileToken = Boolean(loadConfigFile(path).UPLOADS_TOKEN);
114
+ const envTokenStillSet = Boolean(process.env.UPLOADS_TOKEN);
115
+ const result = removeConfigKeys(path, ["UPLOADS_TOKEN"]);
116
+ const payload = {
117
+ path: result.path,
118
+ removed: result.removed,
119
+ configExisted: result.existed,
120
+ hadTokenInConfig: hadFileToken,
121
+ envTokenStillSet,
122
+ };
123
+ if (wantsJson(opts, parsed)) {
124
+ await writeJson(payload);
125
+ return 0;
126
+ }
127
+ if (result.removed.includes("UPLOADS_TOKEN")) {
128
+ process.stdout.write(`signed out — removed UPLOADS_TOKEN from ${result.path}\n`);
129
+ }
130
+ else if (!result.existed) {
131
+ process.stdout.write(`no config file at ${result.path} — already signed out\n`);
132
+ }
133
+ else {
134
+ process.stdout.write(`no UPLOADS_TOKEN in ${result.path} — already signed out\n`);
135
+ }
136
+ if (envTokenStillSet) {
137
+ process.stderr.write("note: UPLOADS_TOKEN is still set in the environment; unset it to fully sign out\n");
138
+ }
139
+ return 0;
140
+ }
@@ -2,6 +2,7 @@ import { createUploadsClient } from "../client.js";
2
2
  import { DEFAULT_API_URL, DEFAULT_WORKSPACE, describeConfigSources, putDefaultsToConfigValues, redactToken, resolveApiUrl, resolveConfig, resolveConfigPath, resolvePutDefaults, writeConfigKeys, workspaceFromToken, } from "../config.js";
3
3
  import { flagBool, flagInt, flagString, parseCommandArgs, UsageError } from "../cli-args.js";
4
4
  import { UploadsError } from "../errors.js";
5
+ import { writeCommandHelp } from "../cli-style.js";
5
6
  const SETUP_HELP = `uploads setup — guided CLI configuration
6
7
 
7
8
  Writes UPLOADS_* keys to the shared buildinternet config file and prints
@@ -99,7 +100,7 @@ function formatWizard(status) {
99
100
  export async function runSetup(args, opts, help = false) {
100
101
  const parsed = parseCommandArgs(args);
101
102
  if (help || parsed.help) {
102
- process.stderr.write(SETUP_HELP);
103
+ writeCommandHelp(SETUP_HELP);
103
104
  return 0;
104
105
  }
105
106
  const apiUrl = flagString(parsed.flags, "--api-url");
package/dist/commands.js CHANGED
@@ -9,12 +9,14 @@ import { UploadsError } from "./errors.js";
9
9
  import { writeJson, writeStdout } from "./io.js";
10
10
  import { parseMetaFlags, validateMetaMap } from "./metadata.js";
11
11
  import { ghAttachmentKey, ghKeyPrefix, ghMetadataFromTarget, attachmentsCommentBody, normalizeGithubCoordinate, } from "./github.js";
12
- import { resolveRepo, resolveCurrentPullRequest, execRunner, upsertAttachmentsComment, } from "./github-gh.js";
12
+ import { resolveRepo, resolveCurrentPullRequest, classifyGhNumber, execRunner, upsertAttachmentsComment, } from "./github-gh.js";
13
13
  import { resolvePutPrefix } from "./destinations.js";
14
14
  import { optimizeImageForUpload, rewriteKeyExtension, } from "./optimize.js";
15
15
  import { applyFrame, resolveFrameId } from "./frame.js";
16
16
  import { buildCliProvenance } from "./provenance.js";
17
+ import { formatByteSize } from "./format-bytes.js";
17
18
  import { packageVersion } from "./package-version.js";
19
+ import { writeCommandHelp } from "./cli-style.js";
18
20
  /** Read a local file (or `-` for stdin). Missing path → FILE_NOT_FOUND (exit 2). */
19
21
  export function readFileArg(fileArg) {
20
22
  try {
@@ -44,6 +46,11 @@ Uploads are public. --pr/--issue keys include the repo, number, and filename and
44
46
  remain public even for private/internal GitHub repositories. Upload only media
45
47
  that is safe at a predictable public URL.
46
48
 
49
+ Re-uploading the same key overwrites in place (no prompt) so embeds hot-swap;
50
+ human mode prints ">> replaced existing object (same URL)" after a real put,
51
+ or ">> would replace existing object (same URL)" on --dry-run when the key
52
+ already exists.
53
+
47
54
  Human/json output includes durable url and (when dual-host applies) embedUrl.
48
55
  MARKDOWN prefers embedUrl for GitHub. Override: UPLOADS_EMBED_PUBLIC_BASE_URL.
49
56
 
@@ -65,6 +72,8 @@ Options:
65
72
  --optimize-quality <1-100> WebP quality (default: 85)
66
73
  --keep-exif Keep EXIF/XMP/ICC when optimizing (default: strip for privacy)
67
74
  --no-git Don't derive --repo from git (or UPLOADS_NO_GIT=1)
75
+ --auto Resolve current PR/issue and stamp gh.* metadata (default on)
76
+ --no-auto Skip gh.* auto-resolution (also skipped by --no-git or UPLOADS_NO_AUTO_META=1)
68
77
  --workspace, -w <name> Override workspace (wins over UPLOADS_WORKSPACE and token inference)
69
78
  --format human|url|markdown|json
70
79
  --pr <num> Attach to a pull request: key gh/<owner>/<repo>/pull/<num>/<name> (stable URL, no hash)
@@ -75,7 +84,7 @@ Options:
75
84
  Re-uploading to an existing key WITH --meta replaces that file's
76
85
  entire metadata set; without --meta the existing metadata is
77
86
  preserved. Use "uploads meta set" to edit individual keys.
78
- --dry-run Print key + public URL without uploading. Not with --comment/--gallery
87
+ --dry-run Print key + public URL without uploading; reports if the key would replace an existing object. Not with --comment/--gallery
79
88
 
80
89
  Exit codes: 0 ok · 2 usage/token/file · 3 auth/policy · 4 network · 1 other.
81
90
  Scripted formats (json|url|markdown) also print failures on stdout.
@@ -107,6 +116,24 @@ export function makeGhTarget(pr, issue, repoArg, run) {
107
116
  function ghTargetFromFlags(flags, run) {
108
117
  return makeGhTarget(flagInt(flags, "--pr", "--pr"), flagInt(flags, "--issue", "--issue"), flagString(flags, "--repo"), run);
109
118
  }
119
+ /**
120
+ * Best-effort GitHub target for the default put path (no --pr/--issue). A
121
+ * numeric --ref is classified as pull vs issue; otherwise the current branch's
122
+ * PR is resolved. Never throws — any failure yields undefined so the upload
123
+ * proceeds without gh metadata.
124
+ */
125
+ function resolveAutoGhTarget(repoArg, ref, run) {
126
+ try {
127
+ const repo = resolveRepo(repoArg, run);
128
+ if (ref !== undefined && /^\d+$/.test(ref) && Number(ref) > 0) {
129
+ return classifyGhNumber(repo, Number.parseInt(ref, 10), run);
130
+ }
131
+ return resolveCurrentPullRequest(repo, run);
132
+ }
133
+ catch {
134
+ return undefined;
135
+ }
136
+ }
110
137
  /** Shared put/attach optimize flags + UPLOADS_NO_OPTIMIZE default. */
111
138
  export function optimizeOptionsFromFlags(flags, defaults) {
112
139
  if (flags.has("--no-optimize") && typeof flags.get("--no-optimize") === "string") {
@@ -128,13 +155,20 @@ export function optimizeOptionsFromFlags(flags, defaults) {
128
155
  }
129
156
  function formatOptimizeNote(opt) {
130
157
  if (opt.optimized) {
131
- return `optimized ${opt.originalBytes} → ${opt.outputBytes} bytes (${opt.filename})`;
158
+ return `optimized ${formatByteSize(opt.originalBytes)} → ${formatByteSize(opt.outputBytes)} (${opt.filename})`;
132
159
  }
133
160
  if (opt.skippedReason && opt.skippedReason !== "disabled") {
134
161
  return `optimize skipped (${opt.skippedReason})`;
135
162
  }
136
163
  return undefined;
137
164
  }
165
+ function writeReplacedNote(replaced, quiet, dryRun = false) {
166
+ if (!quiet && replaced) {
167
+ process.stderr.write(dryRun
168
+ ? `>> would replace existing object (same URL)\n`
169
+ : `>> replaced existing object (same URL)\n`);
170
+ }
171
+ }
138
172
  /** Frame (optional) then optimize — shared by put/attach/MCP. */
139
173
  export async function prepareImageForUpload(bytes, filename, opts) {
140
174
  let currentBytes = bytes;
@@ -239,6 +273,10 @@ Attachments are public and their repo/number/filename keys are predictable.
239
273
  Private/internal GitHub repository visibility does not restrict access; upload
240
274
  only media that is safe at a public URL.
241
275
 
276
+ Same filename under the same PR/issue overwrites in place (no prompt) so the
277
+ URL and every embed hot-swap. Human mode prints ">> replaced existing object
278
+ (same URL)" when that happens.
279
+
242
280
  Still images are optimized to WebP by default (same as put). Use --no-optimize
243
281
  to upload originals. Optional --frame wraps images in device/browser chrome.
244
282
 
@@ -273,11 +311,11 @@ Examples:
273
311
  export async function runAttach(ctx, args, help = false, run = execRunner) {
274
312
  const parsed = parseCommandArgs(args);
275
313
  if (help || parsed.help) {
276
- process.stderr.write(ATTACH_HELP);
314
+ writeCommandHelp(ATTACH_HELP);
277
315
  return 0;
278
316
  }
279
317
  if (parsed.positionals.length === 0) {
280
- process.stderr.write(ATTACH_HELP);
318
+ writeCommandHelp(ATTACH_HELP);
281
319
  return 2;
282
320
  }
283
321
  if (parsed.flags.has("--no-comment") && typeof parsed.flags.get("--no-comment") === "string") {
@@ -328,6 +366,7 @@ export async function runAttach(ctx, args, help = false, run = execRunner) {
328
366
  }),
329
367
  metadata,
330
368
  });
369
+ writeReplacedNote(result.replaced, ctx.quiet || ctx.json);
331
370
  const embedSrc = urlForGithubEmbed(result.url, result.embedUrl);
332
371
  results.push({
333
372
  ...result,
@@ -363,22 +402,27 @@ export async function runAttach(ctx, args, help = false, run = execRunner) {
363
402
  }
364
403
  if (!ctx.quiet && comment)
365
404
  process.stderr.write(`>> attachments comment ${comment.action}\n`);
405
+ // attach auto-writes gh.* metadata; point the user at how to find it later.
406
+ if (!ctx.quiet) {
407
+ const ref = ghMetadataFromTarget(target)["gh.ref"];
408
+ process.stderr.write(`>> find these later: uploads find gh.ref=${ref}\n`);
409
+ }
366
410
  }
367
411
  return 0;
368
412
  }
369
413
  export async function runPut(ctx, args, help = false, run = execRunner) {
370
414
  if (help) {
371
- process.stderr.write(PUT_HELP);
415
+ writeCommandHelp(PUT_HELP);
372
416
  return 0;
373
417
  }
374
418
  const parsed = parseCommandArgs(args);
375
419
  if (parsed.help) {
376
- process.stderr.write(PUT_HELP);
420
+ writeCommandHelp(PUT_HELP);
377
421
  return 0;
378
422
  }
379
423
  const fileArg = parsed.positionals[0];
380
424
  if (!fileArg) {
381
- process.stderr.write(PUT_HELP);
425
+ writeCommandHelp(PUT_HELP);
382
426
  return 2;
383
427
  }
384
428
  const keyHint = flagString(parsed.flags, "--key");
@@ -390,13 +434,19 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
390
434
  const nameFlag = flagString(parsed.flags, "--name");
391
435
  const dryRun = flagBool(parsed.flags, "--dry-run");
392
436
  // Validate --meta up front (fail fast, before reading/optimizing the file).
393
- const metadata = (() => {
437
+ const userMeta = (() => {
394
438
  const pairs = flagValues(parsed.flags, "--meta");
395
439
  return pairs.length > 0 ? parseMetaFlags(pairs) : undefined;
396
440
  })();
397
441
  if (wantComment && typeof parsed.flags.get("--comment") === "string") {
398
442
  throw new UsageError("--comment takes no value — place it after the file argument");
399
443
  }
444
+ if (parsed.flags.has("--auto") && typeof parsed.flags.get("--auto") === "string") {
445
+ throw new UsageError("--auto takes no value");
446
+ }
447
+ if (parsed.flags.has("--no-auto") && typeof parsed.flags.get("--no-auto") === "string") {
448
+ throw new UsageError("--no-auto takes no value");
449
+ }
400
450
  if (wantComment && !ghTarget)
401
451
  throw new UsageError("--comment requires --pr or --issue");
402
452
  if (ghTarget) {
@@ -474,6 +524,43 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
474
524
  process.stderr.write(`>> ${note}\n`);
475
525
  }
476
526
  const noGit = flagBool(parsed.flags, "--no-git") || defaults.noGit === true;
527
+ // gh.* metadata: explicit --pr/--issue target wins over --meta; otherwise
528
+ // best-effort auto resolution (on by default) where --meta wins. --no-git,
529
+ // --no-auto, or UPLOADS_NO_AUTO_META disable auto; --auto forces past the
530
+ // config default but never past --no-git (no repo to resolve).
531
+ let metadata = userMeta;
532
+ let attachedRef;
533
+ if (ghTarget) {
534
+ const merged = { ...userMeta, ...ghMetadataFromTarget(ghTarget) };
535
+ validateMetaMap(merged); // enforce 24-key/8KB caps on the merged map (matches attach)
536
+ metadata = merged;
537
+ attachedRef = merged["gh.ref"];
538
+ }
539
+ else {
540
+ const autoEnabled = !noGit &&
541
+ !flagBool(parsed.flags, "--no-auto") &&
542
+ (flagBool(parsed.flags, "--auto") || defaults.noAutoMeta !== true);
543
+ if (autoEnabled) {
544
+ const autoTarget = resolveAutoGhTarget(flagString(parsed.flags, "--repo") ?? defaults.repo, flagString(parsed.flags, "--ref") ?? defaults.ref, run);
545
+ if (autoTarget) {
546
+ const autoMeta = ghMetadataFromTarget(autoTarget);
547
+ const merged = { ...autoMeta, ...userMeta };
548
+ // Auto resolution must never fail the upload: if merging the gh.* pairs
549
+ // would exceed the metadata caps, drop them and upload with --meta only.
550
+ try {
551
+ validateMetaMap(merged);
552
+ metadata = merged;
553
+ attachedRef = merged["gh.ref"];
554
+ }
555
+ catch {
556
+ // keep metadata = userMeta (already validated); skip auto gh.*
557
+ }
558
+ }
559
+ }
560
+ }
561
+ if (attachedRef && !ctx.quiet && format === "human") {
562
+ process.stderr.write(`>> attached to ${attachedRef}\n`);
563
+ }
477
564
  let key = ghTarget ? ghAttachmentKey(ghTarget, filename) : keyHint;
478
565
  if (key && prepared.optimized)
479
566
  key = rewriteKeyExtension(key, filename);
@@ -494,6 +581,8 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
494
581
  }),
495
582
  metadata,
496
583
  });
584
+ if (format === "human")
585
+ writeReplacedNote(result.replaced, ctx.quiet, dryRun);
497
586
  const embedSrc = urlForGithubEmbed(result.url, result.embedUrl);
498
587
  const markdown = buildMarkdown(embedSrc, { alt, width });
499
588
  let gallery;
@@ -605,7 +694,7 @@ export async function runGallery(ctx, args, help = false) {
605
694
  const parsed = parseCommandArgs(args);
606
695
  const action = parsed.positionals[0];
607
696
  if (help || parsed.help || !action) {
608
- process.stderr.write(GALLERY_HELP);
697
+ writeCommandHelp(GALLERY_HELP);
609
698
  return help || parsed.help ? 0 : 2;
610
699
  }
611
700
  switch (action) {
@@ -805,14 +894,21 @@ async function runFindFiles(ctx, filters, flags) {
805
894
  if (ctx.json)
806
895
  await writeJson(result);
807
896
  else
808
- for (const item of result.items)
809
- await writeStdout(`${item.key}${item.url ? ` ${item.url}` : ""}\n`);
897
+ for (const item of result.items) {
898
+ // LIST_HELP promises matched metadata in the output; render it inline
899
+ // (sorted for stable output) so human mode honors that, not just --json.
900
+ const meta = Object.entries(item.metadata)
901
+ .toSorted(([a], [b]) => a.localeCompare(b))
902
+ .map(([k, v]) => `${k}=${v}`)
903
+ .join(" ");
904
+ await writeStdout(`${item.key}${item.url ? ` ${item.url}` : ""}${meta ? ` ${meta}` : ""}\n`);
905
+ }
810
906
  return 0;
811
907
  }
812
908
  export async function runList(ctx, args, help = false, run = execRunner) {
813
909
  const parsed = parseCommandArgs(args);
814
910
  if (help || parsed.help) {
815
- process.stderr.write(LIST_HELP);
911
+ writeCommandHelp(LIST_HELP);
816
912
  return 0;
817
913
  }
818
914
  const metaPairs = flagValues(parsed.flags, "--meta");
@@ -870,11 +966,11 @@ Examples:
870
966
  export async function runFind(ctx, args, help = false) {
871
967
  const parsed = parseCommandArgs(args);
872
968
  if (help || parsed.help) {
873
- process.stderr.write(FIND_HELP);
969
+ writeCommandHelp(FIND_HELP);
874
970
  return 0;
875
971
  }
876
972
  if (parsed.positionals.length === 0) {
877
- process.stderr.write(FIND_HELP);
973
+ writeCommandHelp(FIND_HELP);
878
974
  return 2;
879
975
  }
880
976
  const filters = parseMetaFlags(parsed.positionals);
@@ -899,7 +995,7 @@ export async function runMeta(ctx, args, help = false) {
899
995
  const parsed = parseCommandArgs(args);
900
996
  const action = parsed.positionals[0];
901
997
  if (help || parsed.help || !action) {
902
- process.stderr.write(META_HELP);
998
+ writeCommandHelp(META_HELP);
903
999
  return help || parsed.help ? 0 : 2;
904
1000
  }
905
1001
  switch (action) {
@@ -910,6 +1006,11 @@ export async function runMeta(ctx, args, help = false) {
910
1006
  const result = await ctx.client.getMetadata(key);
911
1007
  if (ctx.json)
912
1008
  await writeJson(result);
1009
+ else if (Object.keys(result.metadata).length === 0) {
1010
+ // Empty stdout reads as failure; a stderr note keeps stdout parseable.
1011
+ if (!ctx.quiet)
1012
+ process.stderr.write("(no metadata)\n");
1013
+ }
913
1014
  else
914
1015
  for (const [k, v] of Object.entries(result.metadata))
915
1016
  await writeStdout(`${k}=${v}\n`);
@@ -954,12 +1055,12 @@ Examples:
954
1055
  export async function runDelete(ctx, args, help = false) {
955
1056
  const parsed = parseCommandArgs(args);
956
1057
  if (help || parsed.help) {
957
- process.stderr.write(DELETE_HELP);
1058
+ writeCommandHelp(DELETE_HELP);
958
1059
  return 0;
959
1060
  }
960
1061
  const key = parsed.positionals[0];
961
1062
  if (!key) {
962
- process.stderr.write(DELETE_HELP);
1063
+ writeCommandHelp(DELETE_HELP);
963
1064
  return 2;
964
1065
  }
965
1066
  if (flagBool(parsed.flags, "--dry-run")) {
@@ -991,7 +1092,7 @@ Examples:
991
1092
  export async function runComment(ctx, args, help = false, run = execRunner) {
992
1093
  const parsed = parseCommandArgs(args);
993
1094
  if (help || parsed.help) {
994
- process.stderr.write(COMMENT_HELP);
1095
+ writeCommandHelp(COMMENT_HELP);
995
1096
  return 0;
996
1097
  }
997
1098
  const target = ghTargetFromFlags(parsed.flags, run);
@@ -1019,7 +1120,7 @@ Examples:
1019
1120
  `;
1020
1121
  export async function runUsage(ctx, args, help = false) {
1021
1122
  if (help || parseCommandArgs(args).help) {
1022
- process.stderr.write(USAGE_HELP);
1123
+ writeCommandHelp(USAGE_HELP);
1023
1124
  return 0;
1024
1125
  }
1025
1126
  const result = await ctx.client.usage();
@@ -1047,7 +1148,7 @@ Examples:
1047
1148
  `;
1048
1149
  export async function runReconcile(ctx, args, help = false) {
1049
1150
  if (help || parseCommandArgs(args).help) {
1050
- process.stderr.write(RECONCILE_HELP);
1151
+ writeCommandHelp(RECONCILE_HELP);
1051
1152
  return 0;
1052
1153
  }
1053
1154
  const result = await ctx.client.reconcile();
@@ -1070,7 +1171,7 @@ Examples:
1070
1171
  `;
1071
1172
  export async function runPurgeExpired(ctx, args, help = false) {
1072
1173
  if (help || parseCommandArgs(args).help) {
1073
- process.stderr.write(PURGE_HELP);
1174
+ writeCommandHelp(PURGE_HELP);
1074
1175
  return 0;
1075
1176
  }
1076
1177
  const result = await ctx.client.purgeExpired();
@@ -1096,7 +1197,7 @@ Examples:
1096
1197
  `;
1097
1198
  export async function runHealth(ctx, args, help = false) {
1098
1199
  if (help || parseCommandArgs(args).help) {
1099
- process.stderr.write(HEALTH_HELP);
1200
+ writeCommandHelp(HEALTH_HELP);
1100
1201
  return 0;
1101
1202
  }
1102
1203
  const result = await createUploadsClient({
@@ -1180,7 +1281,7 @@ export async function buildDoctorReport(config, client) {
1180
1281
  }
1181
1282
  export async function runDoctor(ctx, args, help = false) {
1182
1283
  if (help || parseCommandArgs(args).help) {
1183
- process.stderr.write(DOCTOR_HELP);
1284
+ writeCommandHelp(DOCTOR_HELP);
1184
1285
  return 0;
1185
1286
  }
1186
1287
  const report = await buildDoctorReport(ctx.config, ctx.client);
@@ -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"];
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"];
3
3
  export type UploadsConfigKey = (typeof UPLOADS_CONFIG_KEYS)[number];
4
4
  export type UploadsConfigValues = Partial<Record<UploadsConfigKey, string>>;
5
5
  export interface PutDefaults {
@@ -12,6 +12,8 @@ export interface PutDefaults {
12
12
  noOptimize?: boolean;
13
13
  /** When true, optimize keeps EXIF/XMP/ICC (default strips). */
14
14
  keepExif?: boolean;
15
+ /** When true, `put` does NOT auto-resolve/stamp gh.* on the default path. */
16
+ noAutoMeta?: boolean;
15
17
  }
16
18
  declare const PUT_DEFAULT_KEY_MAP: Record<keyof PutDefaults, UploadsConfigKey>;
17
19
  export declare function putDefaultsToConfigValues(defaults: PutDefaults): UploadsConfigValues;
@@ -38,4 +40,13 @@ export declare function writeConfigKeys(path: string, keys: UploadsConfigValues,
38
40
  updated: string[];
39
41
  };
40
42
  export declare function configValuesFromClient(config: Partial<UploadsClientConfig>, defaults?: PutDefaults): UploadsConfigValues;
43
+ /**
44
+ * Remove keys from the shared config file (e.g. logout clears UPLOADS_TOKEN).
45
+ * No-op for missing file/keys. Preserves other lines and comments.
46
+ */
47
+ export declare function removeConfigKeys(path: string, keys: readonly string[]): {
48
+ path: string;
49
+ removed: string[];
50
+ existed: boolean;
51
+ };
41
52
  export { PUT_DEFAULT_KEY_MAP };
@@ -12,6 +12,7 @@ export const UPLOADS_CONFIG_KEYS = [
12
12
  "UPLOADS_NO_GIT",
13
13
  "UPLOADS_NO_OPTIMIZE",
14
14
  "UPLOADS_KEEP_EXIF",
15
+ "UPLOADS_NO_AUTO_META",
15
16
  ];
16
17
  const PUT_DEFAULT_KEY_MAP = {
17
18
  prefix: "UPLOADS_DEFAULT_PREFIX",
@@ -21,6 +22,7 @@ const PUT_DEFAULT_KEY_MAP = {
21
22
  noGit: "UPLOADS_NO_GIT",
22
23
  noOptimize: "UPLOADS_NO_OPTIMIZE",
23
24
  keepExif: "UPLOADS_KEEP_EXIF",
25
+ noAutoMeta: "UPLOADS_NO_AUTO_META",
24
26
  };
25
27
  function isTruthyConfigFlag(value) {
26
28
  if (!value)
@@ -44,6 +46,8 @@ export function putDefaultsToConfigValues(defaults) {
44
46
  out.UPLOADS_NO_OPTIMIZE = "1";
45
47
  if (defaults.keepExif)
46
48
  out.UPLOADS_KEEP_EXIF = "1";
49
+ if (defaults.noAutoMeta)
50
+ out.UPLOADS_NO_AUTO_META = "1";
47
51
  return out;
48
52
  }
49
53
  function parsePutDefaultsFromRaw(raw) {
@@ -65,6 +69,8 @@ function parsePutDefaultsFromRaw(raw) {
65
69
  out.noOptimize = true;
66
70
  if (isTruthyConfigFlag(raw.UPLOADS_KEEP_EXIF))
67
71
  out.keepExif = true;
72
+ if (isTruthyConfigFlag(raw.UPLOADS_NO_AUTO_META))
73
+ out.noAutoMeta = true;
68
74
  return out;
69
75
  }
70
76
  function parsePutDefaultsFromEnv() {
@@ -83,6 +89,8 @@ function parsePutDefaultsFromEnv() {
83
89
  raw.UPLOADS_NO_OPTIMIZE = process.env.UPLOADS_NO_OPTIMIZE;
84
90
  if (process.env.UPLOADS_KEEP_EXIF)
85
91
  raw.UPLOADS_KEEP_EXIF = process.env.UPLOADS_KEEP_EXIF;
92
+ if (process.env.UPLOADS_NO_AUTO_META)
93
+ raw.UPLOADS_NO_AUTO_META = process.env.UPLOADS_NO_AUTO_META;
86
94
  return parsePutDefaultsFromRaw(raw);
87
95
  }
88
96
  /** XDG default shared across buildinternet skills (github-screenshots, uploads, …). */
@@ -152,6 +160,8 @@ export function mergePutDefaults(...layers) {
152
160
  out.noOptimize = layer.noOptimize;
153
161
  if (layer.keepExif != null)
154
162
  out.keepExif = layer.keepExif;
163
+ if (layer.noAutoMeta != null)
164
+ out.noAutoMeta = layer.noAutoMeta;
155
165
  }
156
166
  return out;
157
167
  }
@@ -216,15 +226,7 @@ export function writeConfigKeys(path, keys, opts) {
216
226
  updated.push(key);
217
227
  }
218
228
  }
219
- const tmp = `${path}.tmp-${process.pid}-${Date.now()}`;
220
- writeFileSync(tmp, lines.join("\n").replace(/\n*$/, "\n"), { encoding: "utf8", mode: 0o600 });
221
- renameSync(tmp, path);
222
- try {
223
- chmodSync(path, 0o600);
224
- }
225
- catch {
226
- /* Windows/filesystems may not support modes. */
227
- }
229
+ writeConfigFileAtomic(path, lines.join("\n"));
228
230
  return { path, created: !existed, updated };
229
231
  }
230
232
  export function configValuesFromClient(config, defaults) {
@@ -238,4 +240,42 @@ export function configValuesFromClient(config, defaults) {
238
240
  Object.assign(out, putDefaultsToConfigValues(defaults ?? {}));
239
241
  return out;
240
242
  }
243
+ /**
244
+ * Remove keys from the shared config file (e.g. logout clears UPLOADS_TOKEN).
245
+ * No-op for missing file/keys. Preserves other lines and comments.
246
+ */
247
+ export function removeConfigKeys(path, keys) {
248
+ if (!existsSync(path))
249
+ return { path, removed: [], existed: false };
250
+ const keySet = new Set(keys);
251
+ const removed = [];
252
+ const kept = [];
253
+ for (const line of readFileSync(path, "utf8").split("\n")) {
254
+ const parsed = parseEnvLine(line);
255
+ if (parsed && keySet.has(parsed.key)) {
256
+ if (!removed.includes(parsed.key))
257
+ removed.push(parsed.key);
258
+ continue;
259
+ }
260
+ kept.push(line);
261
+ }
262
+ if (removed.length === 0)
263
+ return { path, removed: [], existed: true };
264
+ // Drop consecutive blank lines left by removals
265
+ const collapsed = kept.filter((line, i) => !(line === "" && i > 0 && kept[i - 1] === ""));
266
+ writeConfigFileAtomic(path, collapsed.join("\n"));
267
+ return { path, removed, existed: true };
268
+ }
269
+ /** Atomic write + mode 0o600 (best-effort on platforms without chmod). */
270
+ function writeConfigFileAtomic(path, body) {
271
+ const tmp = `${path}.tmp-${process.pid}-${Date.now()}`;
272
+ writeFileSync(tmp, body.replace(/\n*$/, "\n"), { encoding: "utf8", mode: 0o600 });
273
+ renameSync(tmp, path);
274
+ try {
275
+ chmodSync(path, 0o600);
276
+ }
277
+ catch {
278
+ /* Windows/filesystems may not support modes. */
279
+ }
280
+ }
241
281
  export { PUT_DEFAULT_KEY_MAP };
package/dist/config.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export { defaultConfigPath, resolveConfigPath, loadConfigFile, redactToken, writeConfigKeys, configValuesFromClient, putDefaultsToConfigValues, resolvePutDefaults, mergePutDefaults, UPLOADS_CONFIG_KEYS, type UploadsConfigKey, type UploadsConfigValues, type PutDefaults, } from "./config-file.js";
1
+ export { defaultConfigPath, resolveConfigPath, loadConfigFile, redactToken, writeConfigKeys, removeConfigKeys, configValuesFromClient, putDefaultsToConfigValues, resolvePutDefaults, mergePutDefaults, UPLOADS_CONFIG_KEYS, type UploadsConfigKey, type UploadsConfigValues, type PutDefaults, } from "./config-file.js";
2
2
  export interface UploadsClientConfig {
3
3
  apiUrl: string;
4
4
  workspace: string;
package/dist/config.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
2
  import { loadConfigFile, resolveConfigPath } from "./config-file.js";
3
3
  import { UploadsError } from "./errors.js";
4
- export { defaultConfigPath, resolveConfigPath, loadConfigFile, redactToken, writeConfigKeys, configValuesFromClient, putDefaultsToConfigValues, resolvePutDefaults, mergePutDefaults, UPLOADS_CONFIG_KEYS, } from "./config-file.js";
4
+ export { defaultConfigPath, resolveConfigPath, loadConfigFile, redactToken, writeConfigKeys, removeConfigKeys, configValuesFromClient, putDefaultsToConfigValues, resolvePutDefaults, mergePutDefaults, UPLOADS_CONFIG_KEYS, } from "./config-file.js";
5
5
  export const DEFAULT_API_URL = "https://api.uploads.sh";
6
6
  export const DEFAULT_WORKSPACE = "default";
7
7
  const TOKEN_WORKSPACE_RE = /^up_([a-z0-9][a-z0-9-]{1,62})_/;