@buildinternet/uploads 0.1.0 → 0.2.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
@@ -5,66 +5,58 @@ import { parseCommandArgs, flagString, flagBool, flagInt, UsageError, } from "./
5
5
  import { resolvePutDefaults, workspaceMismatch, workspaceFromToken, } from "./config.js";
6
6
  import { buildMarkdown } from "./embed.js";
7
7
  import { UploadsError } from "./errors.js";
8
+ import { writeJson, writeStdout } from "./io.js";
8
9
  import { ghAttachmentKey, ghKeyPrefix, attachmentsCommentBody, } from "./github.js";
9
10
  import { resolveRepo, resolveCurrentPullRequest, execRunner, upsertAttachmentsComment, } from "./github-gh.js";
10
- async function writeStdout(text) {
11
- if (!process.stdout.write(text)) {
12
- await new Promise((resolve) => process.stdout.once("drain", resolve));
13
- }
14
- }
15
- async function writeJson(value) {
16
- await writeStdout(JSON.stringify(value, null, 2) + "\n");
17
- }
18
11
  // --- put ---
19
- const PUT_HELP = `uploads put <file> [options]
20
-
21
- Upload an image for GitHub embeds. Use "-" for stdin.
22
-
23
- Options:
24
- --key <key> Object key (default: <prefix>/<repo>/<ref>/<name>-<hash>.<ext>)
25
- --prefix <path> Key prefix (default: screenshots, or UPLOADS_DEFAULT_PREFIX)
26
- --repo <owner/repo> Repo segment (default: git remote, or UPLOADS_DEFAULT_REPO)
27
- --ref <id> PR/issue/branch segment (default: today, or UPLOADS_DEFAULT_REF)
28
- --alt <text> Alt text (default: filename)
29
- --width <px> <img width=…> markdown (or UPLOADS_DEFAULT_WIDTH)
30
- --content-type <mime> Override Content-Type
31
- --no-git Don't derive --repo from git (or UPLOADS_NO_GIT=1)
32
- --workspace, -w <name> Override workspace (wins over UPLOADS_WORKSPACE and token inference)
33
- --format human|url|markdown|json
34
- --pr <num> Attach to a pull request: key gh/<owner>/<repo>/pull/<num>/<name> (stable URL, no hash)
35
- --issue <num> Attach to an issue: key gh/<owner>/<repo>/issues/<num>/<name>
36
- --comment With --pr/--issue: create/update the attachments comment via your local gh auth
37
-
38
- Examples:
39
- uploads put ./shot.png --repo myorg/myapp --ref 1722 --alt "New cards" --width 700
40
- uploads --env-file .env put ./shot.png
41
- uploads --env-file .env put ./after.png --pr 123 --comment
12
+ const PUT_HELP = `uploads put <file> [options]
13
+
14
+ Upload an image for GitHub embeds. Use "-" for stdin.
15
+
16
+ Options:
17
+ --key <key> Object key (default: <prefix>/<repo>/<ref>/<name>-<hash>.<ext>)
18
+ --prefix <path> Key prefix (default: screenshots, or UPLOADS_DEFAULT_PREFIX)
19
+ --repo <owner/repo> Repo segment (default: git remote, or UPLOADS_DEFAULT_REPO)
20
+ --ref <id> PR/issue/branch segment (default: today, or UPLOADS_DEFAULT_REF)
21
+ --alt <text> Alt text (default: filename)
22
+ --width <px> <img width=…> markdown (or UPLOADS_DEFAULT_WIDTH)
23
+ --content-type <mime> Override Content-Type
24
+ --no-git Don't derive --repo from git (or UPLOADS_NO_GIT=1)
25
+ --workspace, -w <name> Override workspace (wins over UPLOADS_WORKSPACE and token inference)
26
+ --format human|url|markdown|json
27
+ --pr <num> Attach to a pull request: key gh/<owner>/<repo>/pull/<num>/<name> (stable URL, no hash)
28
+ --issue <num> Attach to an issue: key gh/<owner>/<repo>/issues/<num>/<name>
29
+ --comment With --pr/--issue: create/update the attachments comment via your local gh auth
30
+
31
+ Examples:
32
+ uploads put ./shot.png --repo myorg/myapp --ref 1722 --alt "New cards" --width 700
33
+ uploads --env-file .env put ./shot.png
34
+ uploads --env-file .env put ./after.png --pr 123 --comment
42
35
  `;
43
- /** Reads --pr/--issue (+ --repo) into a GhTarget; undefined when neither flag is present. */
44
- function ghTargetFromFlags(flags, run) {
45
- const pr = flagInt(flags, "--pr", "--pr");
46
- const issue = flagInt(flags, "--issue", "--issue");
36
+ /**
37
+ * Turns a pr/issue pair (+ optional repo) into a GhTarget; undefined when
38
+ * neither is present. Shared by the CLI flags and the MCP tool arguments.
39
+ */
40
+ export function makeGhTarget(pr, issue, repoArg, run) {
47
41
  if (pr === undefined && issue === undefined)
48
42
  return undefined;
49
43
  if (pr !== undefined && issue !== undefined) {
50
44
  throw new UsageError("--pr and --issue are mutually exclusive");
51
45
  }
52
- const repo = resolveRepo(flagString(flags, "--repo"), run);
46
+ const repo = resolveRepo(repoArg, run);
53
47
  return { repo, kind: pr !== undefined ? "pull" : "issues", num: (pr ?? issue) };
54
48
  }
49
+ /** Reads --pr/--issue (+ --repo) into a GhTarget; undefined when neither flag is present. */
50
+ function ghTargetFromFlags(flags, run) {
51
+ return makeGhTarget(flagInt(flags, "--pr", "--pr"), flagInt(flags, "--issue", "--issue"), flagString(flags, "--repo"), run);
52
+ }
55
53
  /**
56
54
  * List every attachment under the target's prefix and create/update the
57
55
  * managed comment. Throws on gh failure — callers decide whether that is
58
56
  * fatal (`comment` command) or a warning (`put --comment`).
59
57
  */
60
- async function syncAttachmentsComment(ctx, target, run) {
61
- const items = [];
62
- let cursor;
63
- do {
64
- const page = await ctx.client.list({ prefix: ghKeyPrefix(target), cursor });
65
- items.push(...page.items.map(({ key, url }) => ({ key, url })));
66
- cursor = page.cursor ?? undefined;
67
- } while (cursor);
58
+ export async function syncAttachmentsComment(client, target, run) {
59
+ const items = (await client.listAll({ prefix: ghKeyPrefix(target) })).map(({ key, url }) => ({ key, url }));
68
60
  if (items.length === 0)
69
61
  return { action: "skipped", count: 0 };
70
62
  const body = attachmentsCommentBody(items);
@@ -72,23 +64,23 @@ async function syncAttachmentsComment(ctx, target, run) {
72
64
  return { action: created ? "created" : "updated", count: items.length };
73
65
  }
74
66
  // --- attach ---
75
- const ATTACH_HELP = `uploads attach <file...> [options]
76
-
77
- Upload one or more stable PR/issue attachments and maintain a single GitHub
78
- comment. With no target, uses the pull request for the current branch.
79
-
80
- Options:
81
- --pr <num> Attach to this pull request
82
- --issue <num> Attach to this issue
83
- --repo <owner/repo> Repository (default: gh/git inference)
84
- --no-comment Upload only; don't create/update the managed comment
85
- --content-type <mime> Override Content-Type (applied to every file)
86
- --workspace, -w <name> Override workspace
87
-
88
- Examples:
89
- uploads attach ./before.png ./after.png
90
- uploads attach ./shot.png --pr 123 --repo myorg/myapp
91
- uploads attach ./artifact.zip --issue 45 --no-comment
67
+ const ATTACH_HELP = `uploads attach <file...> [options]
68
+
69
+ Upload one or more stable PR/issue attachments and maintain a single GitHub
70
+ comment. With no target, uses the pull request for the current branch.
71
+
72
+ Options:
73
+ --pr <num> Attach to this pull request
74
+ --issue <num> Attach to this issue
75
+ --repo <owner/repo> Repository (default: gh/git inference)
76
+ --no-comment Upload only; don't create/update the managed comment
77
+ --content-type <mime> Override Content-Type (applied to every file)
78
+ --workspace, -w <name> Override workspace
79
+
80
+ Examples:
81
+ uploads attach ./before.png ./after.png
82
+ uploads attach ./shot.png --pr 123 --repo myorg/myapp
83
+ uploads attach ./artifact.zip --issue 45 --no-comment
92
84
  `;
93
85
  export async function runAttach(ctx, args, help = false, run = execRunner) {
94
86
  const parsed = parseCommandArgs(args);
@@ -124,7 +116,7 @@ export async function runAttach(ctx, args, help = false, run = execRunner) {
124
116
  let commentError;
125
117
  if (!parsed.flags.has("--no-comment")) {
126
118
  try {
127
- comment = await syncAttachmentsComment(ctx, target, run);
119
+ comment = await syncAttachmentsComment(ctx.client, target, run);
128
120
  }
129
121
  catch (err) {
130
122
  commentError = err instanceof Error ? err.message : String(err);
@@ -230,7 +222,7 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
230
222
  }
231
223
  if (wantComment && ghTarget) {
232
224
  try {
233
- const sync = await syncAttachmentsComment(ctx, ghTarget, run);
225
+ const sync = await syncAttachmentsComment(ctx.client, ghTarget, run);
234
226
  if (!ctx.quiet && format === "human") {
235
227
  process.stderr.write(`>> attachments comment ${sync.action}\n`);
236
228
  }
@@ -243,14 +235,14 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
243
235
  return 0;
244
236
  }
245
237
  // --- list ---
246
- const LIST_HELP = `uploads list [--prefix <p>] [--pr <num> | --issue <num>] [--repo <owner/name>] [--limit <n>] [--cursor <c>] [--all] [--workspace <name>]
247
-
248
- Default prefix: UPLOADS_DEFAULT_PREFIX (screenshots if unset).
249
-
250
- Examples:
251
- uploads list --prefix screenshots/
252
- uploads list --pr 123
253
- uploads list --all --json
238
+ const LIST_HELP = `uploads list [--prefix <p>] [--pr <num> | --issue <num>] [--repo <owner/name>] [--limit <n>] [--cursor <c>] [--all] [--workspace <name>]
239
+
240
+ Default prefix: UPLOADS_DEFAULT_PREFIX (screenshots if unset).
241
+
242
+ Examples:
243
+ uploads list --prefix screenshots/
244
+ uploads list --pr 123
245
+ uploads list --all --json
254
246
  `;
255
247
  export async function runList(ctx, args, help = false, run = execRunner) {
256
248
  const parsed = parseCommandArgs(args);
@@ -270,13 +262,8 @@ export async function runList(ctx, args, help = false, run = execRunner) {
270
262
  const limit = flagInt(parsed.flags, "--limit", "--limit");
271
263
  const cursor = flagString(parsed.flags, "--cursor");
272
264
  if (flagBool(parsed.flags, "--all")) {
273
- const items = [];
274
- let next = cursor;
275
- do {
276
- const page = await ctx.client.list({ prefix, limit, cursor: next ?? undefined });
277
- items.push(...page.items);
278
- next = page.cursor;
279
- } while (next);
265
+ // --all may start from a caller-provided --cursor and drains from there.
266
+ const items = await ctx.client.listAll({ prefix, limit, cursor });
280
267
  if (ctx.json)
281
268
  await writeJson({ items, cursor: null });
282
269
  else
@@ -296,10 +283,10 @@ export async function runList(ctx, args, help = false, run = execRunner) {
296
283
  return 0;
297
284
  }
298
285
  // --- delete ---
299
- const DELETE_HELP = `uploads delete <key> [--dry-run] [--workspace <name>]
300
-
301
- Examples:
302
- uploads delete screenshots/myapp/42/shot-a1b2c3.png
286
+ const DELETE_HELP = `uploads delete <key> [--dry-run] [--workspace <name>]
287
+
288
+ Examples:
289
+ uploads delete screenshots/myapp/42/shot-a1b2c3.png
303
290
  `;
304
291
  export async function runDelete(ctx, args, help = false) {
305
292
  const parsed = parseCommandArgs(args);
@@ -327,16 +314,16 @@ export async function runDelete(ctx, args, help = false) {
327
314
  return 0;
328
315
  }
329
316
  // --- comment ---
330
- const COMMENT_HELP = `uploads comment (--pr <num> | --issue <num>) [--repo <owner/name>] [--workspace <name>]
331
-
332
- Create or update the managed attachments comment on a GitHub PR or issue,
333
- listing everything uploaded for it. Uses your local gh auth. Finds its own
334
- prior comment via a hidden marker and edits it in place; never touches other
335
- comments or the description.
336
-
337
- Examples:
338
- uploads --env-file .env comment --pr 123
339
- uploads comment --issue 45 --repo buildinternet/uploads
317
+ const COMMENT_HELP = `uploads comment (--pr <num> | --issue <num>) [--repo <owner/name>] [--workspace <name>]
318
+
319
+ Create or update the managed attachments comment on a GitHub PR or issue,
320
+ listing everything uploaded for it. Uses your local gh auth. Finds its own
321
+ prior comment via a hidden marker and edits it in place; never touches other
322
+ comments or the description.
323
+
324
+ Examples:
325
+ uploads --env-file .env comment --pr 123
326
+ uploads comment --issue 45 --repo buildinternet/uploads
340
327
  `;
341
328
  export async function runComment(ctx, args, help = false, run = execRunner) {
342
329
  const parsed = parseCommandArgs(args);
@@ -347,7 +334,7 @@ export async function runComment(ctx, args, help = false, run = execRunner) {
347
334
  const target = ghTargetFromFlags(parsed.flags, run);
348
335
  if (!target)
349
336
  throw new UsageError("comment requires --pr or --issue");
350
- const result = await syncAttachmentsComment(ctx, target, run);
337
+ const result = await syncAttachmentsComment(ctx.client, target, run);
351
338
  if (ctx.json) {
352
339
  await writeJson({ ...target, ...result });
353
340
  }
@@ -358,14 +345,91 @@ export async function runComment(ctx, args, help = false, run = execRunner) {
358
345
  }
359
346
  return 0;
360
347
  }
348
+ // --- usage / reconcile / purge ---
349
+ const USAGE_HELP = `uploads usage [--workspace <name>]
350
+
351
+ Show workspace storage and monthly upload counters (and limits when set).
352
+
353
+ Examples:
354
+ uploads --env-file .env usage
355
+ uploads usage --json
356
+ `;
357
+ export async function runUsage(ctx, args, help = false) {
358
+ if (help || parseCommandArgs(args).help) {
359
+ process.stderr.write(USAGE_HELP);
360
+ return 0;
361
+ }
362
+ const result = await ctx.client.usage();
363
+ if (ctx.json) {
364
+ await writeJson(result);
365
+ return 0;
366
+ }
367
+ const lines = [
368
+ `workspace: ${result.workspace}`,
369
+ `bytes: ${result.bytes}${result.maxStorageBytes != null ? ` / ${result.maxStorageBytes} (${result.storageRemainingBytes} remaining)` : ""}`,
370
+ `objects: ${result.objects}`,
371
+ `uploads: ${result.uploadsInPeriod} this period (${result.periodStart})${result.maxUploadsPerPeriod != null ? ` / ${result.maxUploadsPerPeriod} (${result.uploadsRemaining} remaining)` : ""}`,
372
+ `updated: ${result.updatedAt}`,
373
+ ];
374
+ await writeStdout(lines.join("\n") + "\n");
375
+ return 0;
376
+ }
377
+ const RECONCILE_HELP = `uploads reconcile [--workspace <name>]
378
+
379
+ Rebuild ledger bytes/objects from storage (source of truth). Preserves the
380
+ monthly upload counter. Requires files:write.
381
+
382
+ Examples:
383
+ uploads --env-file .env reconcile
384
+ `;
385
+ export async function runReconcile(ctx, args, help = false) {
386
+ if (help || parseCommandArgs(args).help) {
387
+ process.stderr.write(RECONCILE_HELP);
388
+ return 0;
389
+ }
390
+ const result = await ctx.client.reconcile();
391
+ if (ctx.json) {
392
+ await writeJson(result);
393
+ return 0;
394
+ }
395
+ await writeStdout(result.changed
396
+ ? `reconciled ${result.workspace}: ${result.previous.bytes}→${result.bytes} bytes, ${result.previous.objects}→${result.objects} objects\n`
397
+ : `reconciled ${result.workspace}: unchanged (${result.bytes} bytes, ${result.objects} objects)\n`);
398
+ return 0;
399
+ }
400
+ const PURGE_HELP = `uploads purge-expired [--workspace <name>]
401
+
402
+ Delete objects older than the workspace retentionDays setting, then reconcile.
403
+ Skips if retention is unset. Requires files:delete.
404
+
405
+ Examples:
406
+ uploads --env-file .env purge-expired
407
+ `;
408
+ export async function runPurgeExpired(ctx, args, help = false) {
409
+ if (help || parseCommandArgs(args).help) {
410
+ process.stderr.write(PURGE_HELP);
411
+ return 0;
412
+ }
413
+ const result = await ctx.client.purgeExpired();
414
+ if (ctx.json) {
415
+ await writeJson(result);
416
+ return 0;
417
+ }
418
+ if ("skipped" in result) {
419
+ await writeStdout(`skipped: ${result.reason}\n`);
420
+ return 0;
421
+ }
422
+ await writeStdout(`purged ${result.deleted} object(s), freed ${result.freedBytes} bytes (retention ${result.retentionDays}d)\n`);
423
+ return 0;
424
+ }
361
425
  // --- health & doctor ---
362
- const HEALTH_HELP = `uploads health
363
-
364
- API liveness (no auth).
365
-
366
- Examples:
367
- uploads health
368
- uploads --api-url http://localhost:8787 health
426
+ const HEALTH_HELP = `uploads health
427
+
428
+ API liveness (no auth).
429
+
430
+ Examples:
431
+ uploads health
432
+ uploads --api-url http://localhost:8787 health
369
433
  `;
370
434
  export async function runHealth(ctx, args, help = false) {
371
435
  if (help || parseCommandArgs(args).help) {
@@ -383,31 +447,28 @@ export async function runHealth(ctx, args, help = false) {
383
447
  await writeStdout(result.ok ? `ok (${ctx.apiUrl})\n` : `unhealthy (${ctx.apiUrl})\n`);
384
448
  return result.ok ? 0 : 1;
385
449
  }
386
- const DOCTOR_HELP = `uploads doctor [--workspace <name>]
387
-
388
- Checks API health, token auth, and workspace/token alignment.
389
-
390
- Examples:
391
- uploads --env-file .env doctor
392
- uploads --workspace acme --env-file .env doctor
450
+ const DOCTOR_HELP = `uploads doctor [--workspace <name>]
451
+
452
+ Checks API health, token auth, and workspace/token alignment.
453
+
454
+ Examples:
455
+ uploads --env-file .env doctor
456
+ uploads --workspace acme --env-file .env doctor
393
457
  `;
394
- export async function runDoctor(ctx, args, help = false) {
395
- if (help || parseCommandArgs(args).help) {
396
- process.stderr.write(DOCTOR_HELP);
397
- return 0;
398
- }
399
- const mismatch = workspaceMismatch(ctx.config);
458
+ /** Doctor's health + auth + workspace checks, shared by the CLI and the MCP tool. */
459
+ export async function buildDoctorReport(config, client) {
460
+ const mismatch = workspaceMismatch(config);
400
461
  const hints = [];
401
462
  if (mismatch)
402
463
  hints.push(mismatch);
403
- if (ctx.config.apiUrl.includes("localhost") || ctx.config.apiUrl.includes("127.0.0.1")) {
464
+ if (config.apiUrl.includes("localhost") || config.apiUrl.includes("127.0.0.1")) {
404
465
  hints.push("local API uses dev KV — prod tokens won't work unless minted with --local");
405
466
  }
406
- const health = await ctx.client.health();
467
+ const health = await client.health();
407
468
  let authOk = false;
408
469
  let authError;
409
470
  try {
410
- await ctx.client.list({ limit: 1 });
471
+ await client.list({ limit: 1 });
411
472
  authOk = true;
412
473
  }
413
474
  catch (err) {
@@ -416,35 +477,67 @@ export async function runDoctor(ctx, args, help = false) {
416
477
  hints.push("if this token works on api.uploads.sh, set UPLOADS_API_URL=https://api.uploads.sh");
417
478
  }
418
479
  }
419
- if (!ctx.config.configExists && !ctx.config.token) {
420
- hints.push(`run uploads setup to configure ${ctx.config.configPath}`);
480
+ let usage;
481
+ if (authOk) {
482
+ try {
483
+ const snap = await client.usage();
484
+ usage = {
485
+ ok: true,
486
+ bytes: snap.bytes,
487
+ objects: snap.objects,
488
+ uploadsInPeriod: snap.uploadsInPeriod,
489
+ };
490
+ }
491
+ catch (err) {
492
+ usage = {
493
+ ok: false,
494
+ error: err instanceof UploadsError ? err.message : String(err),
495
+ };
496
+ }
497
+ }
498
+ if (!config.configExists && !config.token) {
499
+ hints.push(`run uploads setup to configure ${config.configPath}`);
421
500
  }
422
- const report = {
501
+ return {
423
502
  ok: health.ok && authOk,
424
- apiUrl: ctx.config.apiUrl,
425
- workspace: ctx.config.workspace,
426
- workspaceSource: ctx.config.workspaceSource,
427
- workspaceFromToken: workspaceFromToken(ctx.config.token),
428
- configPath: ctx.config.configPath,
429
- configExists: ctx.config.configExists,
503
+ apiUrl: config.apiUrl,
504
+ workspace: config.workspace,
505
+ workspaceSource: config.workspaceSource,
506
+ workspaceFromToken: workspaceFromToken(config.token),
507
+ configPath: config.configPath,
508
+ configExists: config.configExists,
430
509
  health,
431
510
  auth: { ok: authOk, error: authError },
511
+ usage,
512
+ warning: mismatch,
432
513
  hints,
433
514
  };
515
+ }
516
+ export async function runDoctor(ctx, args, help = false) {
517
+ if (help || parseCommandArgs(args).help) {
518
+ process.stderr.write(DOCTOR_HELP);
519
+ return 0;
520
+ }
521
+ const report = await buildDoctorReport(ctx.config, ctx.client);
434
522
  if (ctx.json) {
435
523
  await writeJson(report);
436
524
  return report.ok ? 0 : 1;
437
525
  }
438
526
  const lines = [
439
- `config: ${ctx.config.configPath}${ctx.config.configExists ? "" : " (missing)"}`,
440
- `api: ${ctx.config.apiUrl} (${health.ok ? "ok" : "failed"})`,
441
- `workspace: ${ctx.config.workspace}`,
442
- `auth: ${authOk ? "ok" : `failed — ${authError ?? "no token"}`}`,
527
+ `config: ${report.configPath}${report.configExists ? "" : " (missing)"}`,
528
+ `api: ${report.apiUrl} (${report.health.ok ? "ok" : "failed"})`,
529
+ `workspace: ${report.workspace}`,
530
+ `auth: ${report.auth.ok ? "ok" : `failed — ${report.auth.error ?? "no token"}`}`,
443
531
  ];
444
- if (mismatch)
445
- lines.push(`warning: ${mismatch}`);
446
- for (const h of hints)
447
- if (h !== mismatch)
532
+ if (report.usage) {
533
+ lines.push(report.usage.ok
534
+ ? `usage: ${report.usage.bytes} bytes, ${report.usage.objects} objects, ${report.usage.uploadsInPeriod} uploads this period`
535
+ : `usage: failed ${report.usage.error ?? "unknown"}`);
536
+ }
537
+ if (report.warning)
538
+ lines.push(`warning: ${report.warning}`);
539
+ for (const h of report.hints)
540
+ if (h !== report.warning)
448
541
  lines.push(`hint: ${h}`);
449
542
  await writeStdout(lines.join("\n") + "\n");
450
543
  return report.ok ? 0 : 1;
@@ -1,4 +1,4 @@
1
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
1
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
2
  import { dirname } from "node:path";
3
3
  import { homedir } from "node:os";
4
4
  export const UPLOADS_CONFIG_KEYS = [
@@ -162,6 +162,10 @@ const INIT_HEADER = `# uploads.sh CLI — shared buildinternet config
162
162
  /** Create or update UPLOADS_* keys in the shared config file. Preserves other keys. */
163
163
  export function writeConfigKeys(path, keys, opts) {
164
164
  const entries = Object.entries(keys).filter(([, v]) => v !== undefined && v !== "");
165
+ for (const [key, value] of entries) {
166
+ if (/[\r\n]/.test(value))
167
+ throw new Error(`invalid newline in ${key}`);
168
+ }
165
169
  if (entries.length === 0) {
166
170
  throw new Error("no config values to write");
167
171
  }
@@ -187,7 +191,15 @@ export function writeConfigKeys(path, keys, opts) {
187
191
  updated.push(key);
188
192
  }
189
193
  }
190
- writeFileSync(path, lines.join("\n").replace(/\n*$/, "\n"), "utf8");
194
+ const tmp = `${path}.tmp-${process.pid}-${Date.now()}`;
195
+ writeFileSync(tmp, lines.join("\n").replace(/\n*$/, "\n"), { encoding: "utf8", mode: 0o600 });
196
+ renameSync(tmp, path);
197
+ try {
198
+ chmodSync(path, 0o600);
199
+ }
200
+ catch {
201
+ /* Windows/filesystems may not support modes. */
202
+ }
191
203
  return { path, created: !existed, updated };
192
204
  }
193
205
  export function configValuesFromClient(config, defaults) {
package/dist/config.js CHANGED
@@ -141,6 +141,7 @@ export function resolveConfig(flags) {
141
141
  function missingTokenMessage(configPath) {
142
142
  return [
143
143
  "UPLOADS_TOKEN is required.",
144
+ " uploads login # exchange an admin-provided enrollment code",
144
145
  ` uploads setup --token <token> # guided setup → ${configPath}`,
145
146
  ` uploads config init --token <token> # writes ${configPath}`,
146
147
  " or set UPLOADS_TOKEN in env, pass --token, or use --env-file",
package/dist/errors.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export type UploadsErrorCode = "MISSING_TOKEN" | "NO_PUBLIC_URL" | "NOT_FOUND" | "UNAUTHORIZED" | "INVALID_KEY" | "API_ERROR" | "NETWORK" | "USAGE";
1
+ export type UploadsErrorCode = "MISSING_TOKEN" | "NO_PUBLIC_URL" | "NOT_FOUND" | "UNAUTHORIZED" | "INVALID_KEY" | "STORAGE_QUOTA" | "UPLOAD_BUDGET" | "API_ERROR" | "NETWORK" | "USAGE";
2
2
  export declare class UploadsError extends Error {
3
3
  readonly code: UploadsErrorCode;
4
4
  readonly status?: number;
package/dist/index.d.ts CHANGED
@@ -2,6 +2,6 @@ export { inferContentType, buildMarkdown } from "./embed.js";
2
2
  export { sanitizeKeySegment, sha256Short, deriveRepoFromGit, buildScreenshotKey } from "./keys.js";
3
3
  export { DEFAULT_API_URL, DEFAULT_WORKSPACE, UPLOADS_CONFIG_KEYS, defaultConfigPath, resolveConfigPath, loadConfigFile, loadEnvFile, resolveApiUrl, resolveConfig, describeConfigSources, redactToken, writeConfigKeys, configValuesFromClient, putDefaultsToConfigValues, resolvePutDefaults, mergePutDefaults, workspaceFromToken, workspaceMismatch, type UploadsClientConfig, type ResolvedConfig, type WorkspaceSource, type ConfigValueSource, type ConfigSources, type UploadsConfigKey, type UploadsConfigValues, type PutDefaults, } from "./config.js";
4
4
  export { UploadsError, type UploadsErrorCode } from "./errors.js";
5
- export { createUploadsClient, type UploadsClient, type PutOptions, type ListOptions, type PutResult, type ListItem, type ListResult, type HeadResult, type DeleteResult, type HealthResult, } from "./client.js";
5
+ export { createUploadsClient, type UploadsClient, type PutOptions, type ListOptions, type PutResult, type ListItem, type ListResult, type HeadResult, type DeleteResult, type HealthResult, type UsageResult, type ReconcileResult, type PurgeExpiredResult, type PurgeExpiredResponse, } from "./client.js";
6
6
  export { ATTACHMENTS_MARKER, attachmentsCommentBody, ghAttachmentKey, ghKeyPrefix, isValidRepo, parseRepoFromRemoteUrl, type AttachmentItem, type GhTarget, type GhTargetKind, } from "./github.js";
7
7
  export { execRunner, resolveRepo, upsertAttachmentsComment, type CommandRunner, } from "./github-gh.js";
package/dist/io.d.ts ADDED
@@ -0,0 +1,3 @@
1
+ /** Backpressure-aware stdout helpers shared by the CLI commands and the stdio MCP transport. */
2
+ export declare function writeStdout(text: string): Promise<void>;
3
+ export declare function writeJson(value: unknown): Promise<void>;
package/dist/io.js ADDED
@@ -0,0 +1,9 @@
1
+ /** Backpressure-aware stdout helpers shared by the CLI commands and the stdio MCP transport. */
2
+ export async function writeStdout(text) {
3
+ if (!process.stdout.write(text)) {
4
+ await new Promise((resolve) => process.stdout.once("drain", resolve));
5
+ }
6
+ }
7
+ export async function writeJson(value) {
8
+ await writeStdout(JSON.stringify(value, null, 2) + "\n");
9
+ }
@@ -0,0 +1,4 @@
1
+ export type ToolArgs = Record<string, unknown>;
2
+ export declare function usage(msg: string): never;
3
+ export declare function optString(args: ToolArgs, name: string): string | undefined;
4
+ export declare function optPosInt(args: ToolArgs, name: string): number | undefined;
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Argument helpers shared by the stdio MCP tool set (./tools.ts) and the
3
+ * remote worker's tool set (apps/mcp). Runtime-agnostic — usable from
4
+ * Workers as well as Node.
5
+ */
6
+ import { UploadsError } from "../errors.js";
7
+ export function usage(msg) {
8
+ throw new UploadsError(msg, "USAGE");
9
+ }
10
+ export function optString(args, name) {
11
+ const v = args[name];
12
+ if (v === undefined || v === null)
13
+ return undefined;
14
+ if (typeof v !== "string")
15
+ usage(`${name} must be a string`);
16
+ return v;
17
+ }
18
+ export function optPosInt(args, name) {
19
+ const v = args[name];
20
+ if (v === undefined || v === null)
21
+ return undefined;
22
+ if (typeof v !== "number" || !Number.isInteger(v) || v <= 0) {
23
+ usage(`${name} must be a positive integer`);
24
+ }
25
+ return v;
26
+ }
@@ -0,0 +1,19 @@
1
+ export { optPosInt, optString, usage, type ToolArgs } from "./args.js";
2
+ export interface McpTool {
3
+ name: string;
4
+ description: string;
5
+ /** Hand-written JSON Schema for the tool's arguments. */
6
+ inputSchema: Record<string, unknown>;
7
+ handler: (args: Record<string, unknown>) => Promise<unknown>;
8
+ }
9
+ export interface McpServer {
10
+ /** Handle one JSON-RPC line. Undefined for notifications / client responses. */
11
+ handleLine(line: string): Promise<string | undefined>;
12
+ }
13
+ export declare function createMcpServer(opts: {
14
+ serverInfo: {
15
+ name: string;
16
+ version: string;
17
+ };
18
+ tools: McpTool[];
19
+ }): McpServer;