@buildinternet/uploads 0.1.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/README.md ADDED
@@ -0,0 +1,64 @@
1
+ # @buildinternet/uploads
2
+
3
+ CLI and client for **uploads.sh** — upload files, get public URLs, and produce GitHub-ready markdown. Successor to the R2 scripts in `buildinternet-skills/github-screenshots`.
4
+
5
+ ## CLI
6
+
7
+ Binary: `uploads` (also `pnpm uploads` from repo root after `pnpm install`).
8
+
9
+ Install globally or run a pinned version without installing:
10
+
11
+ ```bash
12
+ npm install --global @buildinternet/uploads
13
+ npx @buildinternet/uploads@0.1.0 --help
14
+ ```
15
+
16
+ ```bash
17
+ pnpm uploads setup --env-file .env
18
+ pnpm uploads attach ./before.png ./after.png --env-file .env
19
+ pnpm uploads put ./shot.png --env-file .env
20
+ pnpm uploads put ./after.png --pr 123 --comment --env-file .env
21
+ pnpm uploads doctor --env-file .env
22
+ ```
23
+
24
+ Commands: `attach`, `put`, `comment`, `list`, `delete`, `setup`, `config`, `doctor`, `health`.
25
+
26
+ `attach` is the agent-friendly default for GitHub media. It accepts one or more files,
27
+ infers the pull request for the current branch via `gh`, uploads stable URLs, and creates
28
+ or updates one managed attachments comment. Use `--pr`, `--issue`, and `--repo` to select
29
+ the target explicitly, or `--no-comment` to upload without changing GitHub comments.
30
+
31
+ Config layers (first match wins): CLI flags → env vars → `--env-file` → `~/.config/buildinternet/config`. See `config.example` for keys.
32
+
33
+ ## Programmatic use
34
+
35
+ ```ts
36
+ import { createUploadsClient } from "@buildinternet/uploads";
37
+ ```
38
+
39
+ Agent/MCP helpers: `@buildinternet/uploads/agent` (`createUploadsWorkerFileTools` for Workers).
40
+
41
+ ## Layout
42
+
43
+ ```
44
+ src/
45
+ cli.ts Entry + help
46
+ commands.ts put, list, delete, comment, …
47
+ client.ts HTTP client for the API
48
+ github.ts PR/issue key paths + attachment comments
49
+ embed.ts Markdown image output
50
+ bin/uploads.js Bin shim
51
+ ```
52
+
53
+ ## Commands
54
+
55
+ ```bash
56
+ pnpm build # tsc → dist/
57
+ pnpm typecheck
58
+ pnpm test
59
+ pnpm pack:check # verify the npm tarball contents
60
+ ```
61
+
62
+ Maintainer release instructions: [`docs/releasing.md`](../../docs/releasing.md).
63
+
64
+ Agent-oriented usage: [`skills/uploads-cli/SKILL.md`](../../skills/uploads-cli/SKILL.md). REST details: [`docs/api.md`](../../docs/api.md).
package/bin/uploads.js ADDED
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env node
2
+ import { runCli } from "../dist/cli.js";
3
+
4
+ runCli(process.argv)
5
+ .then((code) => process.exit(code ?? 0))
6
+ .catch((err) => {
7
+ console.error(err);
8
+ process.exit(1);
9
+ });
@@ -0,0 +1,8 @@
1
+ import type { Files } from "files-sdk";
2
+ import { type FileToolsOptions } from "files-sdk/ai-sdk";
3
+ export type { FileReadToolName, FileToolName, FileWriteToolName } from "files-sdk/ai-sdk";
4
+ /**
5
+ * Worker-side agent tools (Mode A). Pass `Files` from `createStorage()` —
6
+ * reuses files-sdk tool schemas; do not duplicate MCP tool definitions here.
7
+ */
8
+ export declare function createUploadsWorkerFileTools(files: Files, opts?: Omit<FileToolsOptions, "files">): import("files-sdk/ai-sdk").FileTools | import("files-sdk/ai-sdk").ReadOnlyFileTools;
package/dist/agent.js ADDED
@@ -0,0 +1,24 @@
1
+ import { createFileTools } from "files-sdk/ai-sdk";
2
+ /**
3
+ * Worker-side agent tools (Mode A). Pass `Files` from `createStorage()` —
4
+ * reuses files-sdk tool schemas; do not duplicate MCP tool definitions here.
5
+ */
6
+ export function createUploadsWorkerFileTools(files, opts = {}) {
7
+ const { overrides, requireApproval, ...rest } = opts;
8
+ return createFileTools({
9
+ files,
10
+ requireApproval: requireApproval ?? {
11
+ deleteFile: true,
12
+ uploadFile: false,
13
+ copyFile: true,
14
+ signUploadUrl: true,
15
+ },
16
+ overrides: {
17
+ uploadFile: {
18
+ description: "Upload a file for public hosting (e.g. GitHub embeds). Prefer keys under screenshots/.",
19
+ },
20
+ ...overrides,
21
+ },
22
+ ...rest,
23
+ });
24
+ }
@@ -0,0 +1,39 @@
1
+ export interface GlobalFlags {
2
+ apiUrl?: string;
3
+ workspace?: string;
4
+ token?: string;
5
+ envFile?: string;
6
+ json?: boolean;
7
+ quiet?: boolean;
8
+ }
9
+ export interface ParsedArgv {
10
+ globals: GlobalFlags;
11
+ help: boolean;
12
+ command?: string;
13
+ /** Args starting at the command name (includes command-specific flags). */
14
+ rest: string[];
15
+ }
16
+ export declare function isHelpFlag(arg: string): boolean;
17
+ /**
18
+ * Parse global flags that appear before the subcommand. Stops at the first
19
+ * positional token (the command name) or an unrecognized flag.
20
+ */
21
+ export declare function parseArgv(argv: string[]): ParsedArgv;
22
+ export declare class UsageError extends Error {
23
+ constructor(message: string);
24
+ }
25
+ export interface CommandFlags {
26
+ positionals: string[];
27
+ flags: Map<string, string | boolean>;
28
+ help: boolean;
29
+ }
30
+ /**
31
+ * Parse command-specific args. Supports `--flag value`, `--flag=value`, and
32
+ * boolean `--flag` flags.
33
+ */
34
+ export declare function parseCommandArgs(args: string[]): CommandFlags;
35
+ export declare function flagString(flags: CommandFlags["flags"], name: string): string | undefined;
36
+ export declare function flagBool(flags: CommandFlags["flags"], name: string): boolean;
37
+ /** Command-level workspace override (`--workspace` / `-w`). */
38
+ export declare function commandWorkspace(flags: CommandFlags["flags"]): string | undefined;
39
+ export declare function flagInt(flags: CommandFlags["flags"], name: string, label: string): number | undefined;
@@ -0,0 +1,129 @@
1
+ const VALUE_GLOBALS = new Set(["--api-url", "--workspace", "-w", "--token", "--env-file"]);
2
+ export function isHelpFlag(arg) {
3
+ return arg === "-h" || arg === "--help";
4
+ }
5
+ /**
6
+ * Parse global flags that appear before the subcommand. Stops at the first
7
+ * positional token (the command name) or an unrecognized flag.
8
+ */
9
+ export function parseArgv(argv) {
10
+ const args = argv.slice(2);
11
+ const globals = {};
12
+ let help = false;
13
+ let i = 0;
14
+ while (i < args.length) {
15
+ const arg = args[i];
16
+ if (isHelpFlag(arg)) {
17
+ help = true;
18
+ i++;
19
+ continue;
20
+ }
21
+ if (arg === "--json") {
22
+ globals.json = true;
23
+ i++;
24
+ continue;
25
+ }
26
+ if (arg === "--quiet") {
27
+ globals.quiet = true;
28
+ i++;
29
+ continue;
30
+ }
31
+ if (VALUE_GLOBALS.has(arg)) {
32
+ const value = args[i + 1];
33
+ if (!value || value.startsWith("-")) {
34
+ throw new UsageError(`missing value for ${arg}`);
35
+ }
36
+ switch (arg) {
37
+ case "--api-url":
38
+ globals.apiUrl = value;
39
+ break;
40
+ case "--workspace":
41
+ case "-w":
42
+ globals.workspace = value;
43
+ break;
44
+ case "--token":
45
+ globals.token = value;
46
+ break;
47
+ case "--env-file":
48
+ globals.envFile = value;
49
+ break;
50
+ }
51
+ i += 2;
52
+ continue;
53
+ }
54
+ if (arg.startsWith("-"))
55
+ break;
56
+ break;
57
+ }
58
+ const rest = args.slice(i);
59
+ return { globals, help, command: rest[0], rest };
60
+ }
61
+ export class UsageError extends Error {
62
+ constructor(message) {
63
+ super(message);
64
+ this.name = "UsageError";
65
+ }
66
+ }
67
+ /**
68
+ * Parse command-specific args. Supports `--flag value`, `--flag=value`, and
69
+ * boolean `--flag` flags.
70
+ */
71
+ export function parseCommandArgs(args) {
72
+ const positionals = [];
73
+ const flags = new Map();
74
+ let help = false;
75
+ let i = 0;
76
+ while (i < args.length) {
77
+ const arg = args[i];
78
+ if (isHelpFlag(arg)) {
79
+ help = true;
80
+ i++;
81
+ continue;
82
+ }
83
+ if (arg.startsWith("--")) {
84
+ const eq = arg.indexOf("=");
85
+ if (eq !== -1) {
86
+ flags.set(arg.slice(0, eq), arg.slice(eq + 1));
87
+ i++;
88
+ continue;
89
+ }
90
+ const name = arg;
91
+ const next = args[i + 1];
92
+ if (next && !next.startsWith("-")) {
93
+ flags.set(name, next);
94
+ i += 2;
95
+ continue;
96
+ }
97
+ flags.set(name, true);
98
+ i++;
99
+ continue;
100
+ }
101
+ positionals.push(arg);
102
+ i++;
103
+ }
104
+ return { positionals, flags, help };
105
+ }
106
+ export function flagString(flags, name) {
107
+ const value = flags.get(name);
108
+ return typeof value === "string" ? value : undefined;
109
+ }
110
+ export function flagBool(flags, name) {
111
+ return flags.get(name) === true;
112
+ }
113
+ /** Command-level workspace override (`--workspace` / `-w`). */
114
+ export function commandWorkspace(flags) {
115
+ return flagString(flags, "--workspace") ?? flagString(flags, "-w");
116
+ }
117
+ export function flagInt(flags, name, label) {
118
+ const raw = flagString(flags, name);
119
+ if (raw === undefined)
120
+ return undefined;
121
+ if (!/^\d+$/.test(raw)) {
122
+ throw new UsageError(`invalid ${label}: must be a positive integer (got ${raw})`);
123
+ }
124
+ const n = Number.parseInt(raw, 10);
125
+ if (!Number.isFinite(n) || n <= 0) {
126
+ throw new UsageError(`invalid ${label}: must be a positive integer (got ${raw})`);
127
+ }
128
+ return n;
129
+ }
package/dist/cli.d.ts ADDED
@@ -0,0 +1 @@
1
+ export declare function runCli(argv: string[]): Promise<number>;
package/dist/cli.js ADDED
@@ -0,0 +1,158 @@
1
+ import { createUploadsClient } from "./client.js";
2
+ import { resolveApiUrl, resolveConfig } from "./config.js";
3
+ import { UploadsError } from "./errors.js";
4
+ import { commandWorkspace, isHelpFlag, parseArgv, parseCommandArgs, UsageError, } from "./cli-args.js";
5
+ import { runPut, runAttach, runList, runDelete, runHealth, runDoctor, runComment, } from "./commands.js";
6
+ import { runConfig } from "./commands/config.js";
7
+ import { runSetup } from "./commands/setup.js";
8
+ const ROOT_HELP = `uploads — CLI for uploads.sh (GitHub image embeds)
9
+
10
+ Usage:
11
+ uploads [globals] <command> [args]
12
+
13
+ Config (first match wins, per key):
14
+ CLI flags --api-url, --token, --workspace
15
+ environment UPLOADS_API_URL, UPLOADS_TOKEN, UPLOADS_WORKSPACE
16
+ --env-file <path>
17
+ $BUILDINTERNET_CONFIG
18
+ ~/.config/buildinternet/config (shared with github-screenshots)
19
+
20
+ Workspace (within config layers):
21
+ --workspace, -w override — global (before command) or per-command (after)
22
+ UPLOADS_WORKSPACE env / config file
23
+ (else inferred from token up_<name>_…, else "default")
24
+
25
+ Other globals (before command):
26
+ --api-url <url> default: https://api.uploads.sh
27
+ --token <token> or UPLOADS_TOKEN
28
+ --env-file <path>
29
+ --json JSON on stdout
30
+ --quiet
31
+
32
+ Commands:
33
+ attach <file...> Attach media to the current PR (stable URLs + managed comment)
34
+ put <file> Upload (+ URL + markdown for GitHub)
35
+ comment Create/update a PR/issue attachments comment (via gh)
36
+ list List objects
37
+ delete <key> Delete object
38
+ setup Guided config + token minting steps
39
+ config Show path, init, or set shared config
40
+ doctor Health + auth + workspace checks
41
+ health API liveness (no auth)
42
+
43
+ Put/list defaults (config file or env):
44
+ UPLOADS_DEFAULT_PREFIX, UPLOADS_DEFAULT_REPO, UPLOADS_DEFAULT_REF
45
+ UPLOADS_DEFAULT_WIDTH, UPLOADS_NO_GIT
46
+
47
+ Examples:
48
+ uploads setup
49
+ uploads setup --token up_default_… --repo myorg/myapp
50
+ uploads attach ./before.png ./after.png
51
+ uploads put ./shot.png --ref 42
52
+ uploads doctor
53
+
54
+ Agent/MCP: use createUploadsWorkerFileTools() from @buildinternet/uploads/agent on the Worker.
55
+ `;
56
+ function createContext(globals, requireToken, commandArgs) {
57
+ const cmdWorkspace = commandWorkspace(parseCommandArgs(commandArgs).flags);
58
+ const config = resolveConfig({
59
+ apiUrl: globals.apiUrl,
60
+ workspace: cmdWorkspace ?? globals.workspace,
61
+ token: globals.token,
62
+ envFile: globals.envFile,
63
+ requireToken,
64
+ });
65
+ return {
66
+ config,
67
+ client: createUploadsClient(config),
68
+ json: globals.json ?? false,
69
+ quiet: globals.quiet ?? false,
70
+ envFile: globals.envFile,
71
+ };
72
+ }
73
+ function exitCode(err) {
74
+ if (err instanceof UsageError)
75
+ return 2;
76
+ if (err instanceof UploadsError) {
77
+ switch (err.code) {
78
+ case "MISSING_TOKEN":
79
+ case "USAGE":
80
+ return 2;
81
+ case "UNAUTHORIZED":
82
+ case "NOT_FOUND":
83
+ return 3;
84
+ case "NETWORK":
85
+ return 4;
86
+ default:
87
+ return 1;
88
+ }
89
+ }
90
+ return 1;
91
+ }
92
+ function errorOut(err, json) {
93
+ const payload = err instanceof UploadsError
94
+ ? { error: err.message, code: err.code, status: err.status }
95
+ : err instanceof UsageError
96
+ ? { error: err.message, code: "USAGE" }
97
+ : { error: err instanceof Error ? err.message : String(err) };
98
+ if (json)
99
+ process.stdout.write(JSON.stringify(payload, null, 2) + "\n");
100
+ else {
101
+ const msg = payload.error;
102
+ if (msg.includes("\n"))
103
+ process.stderr.write(`${msg}\n`);
104
+ else
105
+ process.stderr.write(`error: ${msg}\n`);
106
+ }
107
+ }
108
+ export async function runCli(argv) {
109
+ try {
110
+ const parsed = parseArgv(argv);
111
+ const json = parsed.globals.json ?? false;
112
+ if (!parsed.command) {
113
+ process.stderr.write(ROOT_HELP);
114
+ return parsed.help ? 0 : 2;
115
+ }
116
+ const cmdArgs = parsed.rest.slice(1);
117
+ const showHelp = parsed.help || cmdArgs.some(isHelpFlag);
118
+ switch (parsed.command) {
119
+ case "health":
120
+ return runHealth({ apiUrl: resolveApiUrl(parsed.globals), json }, cmdArgs, showHelp);
121
+ case "config":
122
+ return runConfig(cmdArgs, { json, envFile: parsed.globals.envFile }, showHelp);
123
+ case "setup":
124
+ return runSetup(cmdArgs, { json, envFile: parsed.globals.envFile }, showHelp);
125
+ case "attach":
126
+ case "put":
127
+ case "list":
128
+ case "delete":
129
+ case "doctor":
130
+ case "comment": {
131
+ const ctx = createContext(parsed.globals, !showHelp, cmdArgs);
132
+ switch (parsed.command) {
133
+ case "attach":
134
+ return runAttach(ctx, cmdArgs, showHelp);
135
+ case "put":
136
+ return runPut(ctx, cmdArgs, showHelp);
137
+ case "comment":
138
+ return runComment(ctx, cmdArgs, showHelp);
139
+ case "list":
140
+ return runList(ctx, cmdArgs, showHelp);
141
+ case "delete":
142
+ return runDelete(ctx, cmdArgs, showHelp);
143
+ case "doctor":
144
+ return runDoctor(ctx, cmdArgs, showHelp);
145
+ }
146
+ }
147
+ default:
148
+ process.stderr.write(`unknown command: ${parsed.command}\n\n${ROOT_HELP}`);
149
+ return 2;
150
+ }
151
+ }
152
+ catch (err) {
153
+ errorOut(err, argv.includes("--json"));
154
+ if (err instanceof UsageError && !argv.includes("--json"))
155
+ process.stderr.write(`\n${ROOT_HELP}`);
156
+ return exitCode(err);
157
+ }
158
+ }
@@ -0,0 +1,55 @@
1
+ import type { UploadsClientConfig } from "./config.js";
2
+ export interface PutOptions {
3
+ key?: string;
4
+ contentType?: string;
5
+ prefix?: string;
6
+ repo?: string;
7
+ ref?: string;
8
+ deriveRepoFromGit?: boolean;
9
+ }
10
+ export interface ListOptions {
11
+ prefix?: string;
12
+ limit?: number;
13
+ cursor?: string;
14
+ }
15
+ export interface PutResult {
16
+ workspace: string;
17
+ key: string;
18
+ url: string;
19
+ size: number;
20
+ contentType: string;
21
+ }
22
+ export interface ListItem {
23
+ key: string;
24
+ url: string | null;
25
+ size?: number;
26
+ uploaded?: string;
27
+ }
28
+ export interface ListResult {
29
+ items: ListItem[];
30
+ cursor: string | null;
31
+ }
32
+ export interface HeadResult {
33
+ key: string;
34
+ url: string | null;
35
+ size: number;
36
+ contentType: string;
37
+ uploaded?: string;
38
+ }
39
+ export interface DeleteResult {
40
+ key: string;
41
+ deleted: boolean;
42
+ }
43
+ export interface HealthResult {
44
+ ok: boolean;
45
+ }
46
+ export declare function createUploadsClient(config: UploadsClientConfig): {
47
+ put(body: Uint8Array, opts: PutOptions & {
48
+ filename: string;
49
+ }): Promise<PutResult>;
50
+ list(opts?: ListOptions): Promise<ListResult>;
51
+ delete(key: string): Promise<DeleteResult>;
52
+ head(key: string): Promise<HeadResult>;
53
+ health(): Promise<HealthResult>;
54
+ };
55
+ export type UploadsClient = ReturnType<typeof createUploadsClient>;
package/dist/client.js ADDED
@@ -0,0 +1,97 @@
1
+ import { inferContentType } from "./embed.js";
2
+ import { UploadsError } from "./errors.js";
3
+ import { buildScreenshotKey } from "./keys.js";
4
+ function encodeKeyPath(key) {
5
+ return key.split("/").map(encodeURIComponent).join("/");
6
+ }
7
+ function filesBase(config) {
8
+ return `${config.apiUrl}/v1/${encodeURIComponent(config.workspace)}/files`;
9
+ }
10
+ function mapApiError(status, error) {
11
+ const normalized = error.toLowerCase();
12
+ if (status === 401 || normalized === "unauthorized") {
13
+ return new UploadsError(error, "UNAUTHORIZED", status);
14
+ }
15
+ if (status === 404 || normalized === "not found") {
16
+ return new UploadsError(error, "NOT_FOUND", status);
17
+ }
18
+ if (status === 400 && normalized === "invalid key") {
19
+ return new UploadsError(error, "INVALID_KEY", status);
20
+ }
21
+ return new UploadsError(error, "API_ERROR", status);
22
+ }
23
+ async function parseErrorResponse(res) {
24
+ const body = await res.json().catch(() => ({}));
25
+ const message = typeof body === "object" && body && "error" in body && typeof body.error === "string"
26
+ ? body.error
27
+ : res.statusText || "request failed";
28
+ return mapApiError(res.status, message);
29
+ }
30
+ export function createUploadsClient(config) {
31
+ async function request(method, path, opts) {
32
+ const headers = { ...opts?.headers };
33
+ if (opts?.auth !== false) {
34
+ headers.Authorization = `Bearer ${config.token}`;
35
+ }
36
+ let res;
37
+ try {
38
+ res = await fetch(path, {
39
+ method,
40
+ headers,
41
+ body: opts?.body,
42
+ });
43
+ }
44
+ catch (err) {
45
+ const message = err instanceof Error ? err.message : "network request failed";
46
+ throw new UploadsError(message, "NETWORK");
47
+ }
48
+ if (!res.ok) {
49
+ throw await parseErrorResponse(res);
50
+ }
51
+ if (res.status === 204)
52
+ return undefined;
53
+ return (await res.json());
54
+ }
55
+ return {
56
+ async put(body, opts) {
57
+ const key = opts.key ??
58
+ (await buildScreenshotKey({
59
+ filename: opts.filename,
60
+ fileBytes: body,
61
+ prefix: opts.prefix,
62
+ repo: opts.repo,
63
+ ref: opts.ref,
64
+ deriveRepoFromGit: opts.deriveRepoFromGit,
65
+ }));
66
+ const contentType = opts.contentType ?? inferContentType(opts.filename);
67
+ const result = await request("PUT", `${filesBase(config)}/${encodeKeyPath(key)}`, {
68
+ body,
69
+ headers: { "Content-Type": contentType },
70
+ });
71
+ if (result.url == null) {
72
+ throw new UploadsError("upload succeeded but workspace has no publicBaseUrl", "NO_PUBLIC_URL", 201);
73
+ }
74
+ return { ...result, url: result.url };
75
+ },
76
+ async list(opts = {}) {
77
+ const params = new URLSearchParams();
78
+ if (opts.prefix)
79
+ params.set("prefix", opts.prefix);
80
+ if (opts.limit != null)
81
+ params.set("limit", String(opts.limit));
82
+ if (opts.cursor)
83
+ params.set("cursor", opts.cursor);
84
+ const qs = params.toString();
85
+ return request("GET", `${filesBase(config)}${qs ? `?${qs}` : ""}`);
86
+ },
87
+ async delete(key) {
88
+ return request("DELETE", `${filesBase(config)}/${encodeKeyPath(key)}`);
89
+ },
90
+ async head(key) {
91
+ return request("GET", `${filesBase(config)}/${encodeKeyPath(key)}`);
92
+ },
93
+ async health() {
94
+ return request("GET", `${config.apiUrl}/health`, { auth: false });
95
+ },
96
+ };
97
+ }
@@ -0,0 +1,4 @@
1
+ export declare function runConfig(args: string[], opts: {
2
+ json?: boolean;
3
+ envFile?: string;
4
+ }, help?: boolean): Promise<number>;