@buildinternet/uploads 0.1.0 → 0.1.1

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 CHANGED
@@ -1,64 +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).
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 CHANGED
File without changes
package/dist/cli.js CHANGED
@@ -5,53 +5,57 @@ import { commandWorkspace, isHelpFlag, parseArgv, parseCommandArgs, UsageError,
5
5
  import { runPut, runAttach, runList, runDelete, runHealth, runDoctor, runComment, } from "./commands.js";
6
6
  import { runConfig } from "./commands/config.js";
7
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.
8
+ import { runLogin } from "./commands/login.js";
9
+ import { runAdmin } from "./commands/admin-enrollment.js";
10
+ const ROOT_HELP = `uploads — CLI for uploads.sh (GitHub image embeds)
11
+
12
+ Usage:
13
+ uploads [globals] <command> [args]
14
+
15
+ Config (first match wins, per key):
16
+ CLI flags --api-url, --token, --workspace
17
+ environment UPLOADS_API_URL, UPLOADS_TOKEN, UPLOADS_WORKSPACE
18
+ --env-file <path>
19
+ $BUILDINTERNET_CONFIG
20
+ ~/.config/buildinternet/config (shared with github-screenshots)
21
+
22
+ Workspace (within config layers):
23
+ --workspace, -w override global (before command) or per-command (after)
24
+ UPLOADS_WORKSPACE env / config file
25
+ (else inferred from token up_<name>_…, else "default")
26
+
27
+ Other globals (before command):
28
+ --api-url <url> default: https://api.uploads.sh
29
+ --token <token> or UPLOADS_TOKEN
30
+ --env-file <path>
31
+ --json JSON on stdout
32
+ --quiet
33
+
34
+ Commands:
35
+ attach <file...> Attach media to the current PR (stable URLs + managed comment)
36
+ put <file> Upload (+ URL + markdown for GitHub)
37
+ comment Create/update a PR/issue attachments comment (via gh)
38
+ list List objects
39
+ delete <key> Delete object
40
+ setup Inspect/configure advanced CLI settings
41
+ login Exchange an enrollment code and configure credentials
42
+ admin Admin enrollment management
43
+ config Show path, init, or set shared config
44
+ doctor Health + auth + workspace checks
45
+ health API liveness (no auth)
46
+
47
+ Put/list defaults (config file or env):
48
+ UPLOADS_DEFAULT_PREFIX, UPLOADS_DEFAULT_REPO, UPLOADS_DEFAULT_REF
49
+ UPLOADS_DEFAULT_WIDTH, UPLOADS_NO_GIT
50
+
51
+ Examples:
52
+ uploads setup
53
+ uploads setup --token up_default_… --repo myorg/myapp
54
+ uploads attach ./before.png ./after.png
55
+ uploads put ./shot.png --ref 42
56
+ uploads doctor
57
+
58
+ Agent/MCP: use createUploadsWorkerFileTools() from @buildinternet/uploads/agent on the Worker.
55
59
  `;
56
60
  function createContext(globals, requireToken, commandArgs) {
57
61
  const cmdWorkspace = commandWorkspace(parseCommandArgs(commandArgs).flags);
@@ -122,6 +126,10 @@ export async function runCli(argv) {
122
126
  return runConfig(cmdArgs, { json, envFile: parsed.globals.envFile }, showHelp);
123
127
  case "setup":
124
128
  return runSetup(cmdArgs, { json, envFile: parsed.globals.envFile }, showHelp);
129
+ case "login":
130
+ return runLogin(cmdArgs, { json, apiUrl: resolveApiUrl(parsed.globals) }, showHelp);
131
+ case "admin":
132
+ return runAdmin(cmdArgs, { json, apiUrl: resolveApiUrl(parsed.globals) }, showHelp);
125
133
  case "attach":
126
134
  case "put":
127
135
  case "list":
package/dist/client.d.ts CHANGED
@@ -43,6 +43,26 @@ export interface DeleteResult {
43
43
  export interface HealthResult {
44
44
  ok: boolean;
45
45
  }
46
+ export interface EnrollmentExchangeResult {
47
+ apiUrl?: string;
48
+ workspace: string;
49
+ token: string;
50
+ scopes?: Array<"files:read" | "files:write" | "files:delete">;
51
+ expiresAt?: string;
52
+ }
53
+ export interface EnrollmentCreateResult {
54
+ code: string;
55
+ expiresAt: string;
56
+ tokenExpiresAt: string;
57
+ }
58
+ export declare function exchangeEnrollment(apiUrl: string, code: string): Promise<EnrollmentExchangeResult>;
59
+ export declare function createEnrollment(apiUrl: string, adminToken: string, input: {
60
+ workspace?: string;
61
+ label?: string;
62
+ enrollmentSeconds?: number;
63
+ tokenExpiresInSeconds?: number;
64
+ scopes?: Array<"files:read" | "files:write" | "files:delete">;
65
+ }): Promise<EnrollmentCreateResult>;
46
66
  export declare function createUploadsClient(config: UploadsClientConfig): {
47
67
  put(body: Uint8Array, opts: PutOptions & {
48
68
  filename: string;
package/dist/client.js CHANGED
@@ -1,6 +1,32 @@
1
1
  import { inferContentType } from "./embed.js";
2
2
  import { UploadsError } from "./errors.js";
3
3
  import { buildScreenshotKey } from "./keys.js";
4
+ async function jsonRequest(url, init) {
5
+ let res;
6
+ try {
7
+ res = await fetch(url, init);
8
+ }
9
+ catch (err) {
10
+ throw new UploadsError(err instanceof Error ? err.message : "network request failed", "NETWORK");
11
+ }
12
+ if (!res.ok)
13
+ throw await parseErrorResponse(res);
14
+ return (await res.json());
15
+ }
16
+ export function exchangeEnrollment(apiUrl, code) {
17
+ return jsonRequest(`${apiUrl.replace(/\/$/, "")}/auth/enrollments/exchange`, {
18
+ method: "POST",
19
+ headers: { "Content-Type": "application/json" },
20
+ body: JSON.stringify({ code }),
21
+ });
22
+ }
23
+ export function createEnrollment(apiUrl, adminToken, input) {
24
+ return jsonRequest(`${apiUrl.replace(/\/$/, "")}/admin/enrollments`, {
25
+ method: "POST",
26
+ headers: { Authorization: `Bearer ${adminToken}`, "Content-Type": "application/json" },
27
+ body: JSON.stringify(input),
28
+ });
29
+ }
4
30
  function encodeKeyPath(key) {
5
31
  return key.split("/").map(encodeURIComponent).join("/");
6
32
  }
@@ -0,0 +1,7 @@
1
+ type FileScope = "files:read" | "files:write" | "files:delete";
2
+ export declare function parseScopes(raw: string | undefined): FileScope[] | undefined;
3
+ export declare function runAdmin(args: string[], opts: {
4
+ json?: boolean;
5
+ apiUrl?: string;
6
+ }, help?: boolean): Promise<number>;
7
+ export {};
@@ -0,0 +1,59 @@
1
+ import { createEnrollment } from "../client.js";
2
+ import { flagInt, flagString, parseCommandArgs, UsageError } from "../cli-args.js";
3
+ const HELP = `uploads admin enrollment create [options]
4
+
5
+ Admin-only: create a short-lived, one-time enrollment code.
6
+
7
+ Options:
8
+ --admin-token <token> Or ADMIN_TOKEN (UPLOADS_ADMIN_TOKEN is a legacy alias)
9
+ --workspace <name> Default: default
10
+ --label <label>
11
+ --expires-in <seconds> Default: server policy
12
+ --token-expires-in <seconds> Upload token lifetime (default: server policy)
13
+ --scopes <list> Comma-separated files:read,files:write,files:delete
14
+ --api-url <url> Default: https://api.uploads.sh
15
+ `;
16
+ const FILE_SCOPES = new Set(["files:read", "files:write", "files:delete"]);
17
+ export function parseScopes(raw) {
18
+ if (raw === undefined)
19
+ return undefined;
20
+ const scopes = raw
21
+ .split(",")
22
+ .map((scope) => scope.trim())
23
+ .filter(Boolean);
24
+ if (scopes.length === 0)
25
+ throw new UsageError("--scopes must contain at least one scope");
26
+ const invalid = scopes.find((scope) => !FILE_SCOPES.has(scope));
27
+ if (invalid)
28
+ throw new UsageError(`invalid scope: ${invalid} (expected files:read, files:write, or files:delete)`);
29
+ return [...new Set(scopes)];
30
+ }
31
+ export async function runAdmin(args, opts, help = false) {
32
+ const parsed = parseCommandArgs(args);
33
+ if (help || parsed.help) {
34
+ process.stderr.write(HELP);
35
+ return 0;
36
+ }
37
+ if (parsed.positionals[0] !== "enrollment" || parsed.positionals[1] !== "create")
38
+ throw new UsageError("expected: uploads admin enrollment create");
39
+ const adminToken = flagString(parsed.flags, "--admin-token") ??
40
+ process.env.ADMIN_TOKEN ??
41
+ process.env.UPLOADS_ADMIN_TOKEN;
42
+ if (!adminToken)
43
+ throw new UsageError("ADMIN_TOKEN is required for admin enrollment creation");
44
+ const apiUrl = flagString(parsed.flags, "--api-url") ?? opts.apiUrl ?? "https://api.uploads.sh";
45
+ const workspace = flagString(parsed.flags, "--workspace") ?? "default";
46
+ const label = flagString(parsed.flags, "--label");
47
+ const result = await createEnrollment(apiUrl, adminToken, {
48
+ workspace,
49
+ label,
50
+ enrollmentSeconds: flagInt(parsed.flags, "--expires-in", "--expires-in"),
51
+ tokenExpiresInSeconds: flagInt(parsed.flags, "--token-expires-in", "--token-expires-in"),
52
+ scopes: parseScopes(flagString(parsed.flags, "--scopes")),
53
+ });
54
+ if (opts.json)
55
+ process.stdout.write(JSON.stringify({ workspace, label: label ?? null, ...result }, null, 2) + "\n");
56
+ else
57
+ process.stdout.write(`Enrollment code (share once): ${result.code}\nworkspace: ${workspace}\nexpires: ${result.expiresAt}\n`);
58
+ return 0;
59
+ }
@@ -0,0 +1,11 @@
1
+ import { parseCommandArgs } from "../cli-args.js";
2
+ export declare function validateEnrollmentCode(raw: string): string;
3
+ export declare function resolveEnrollmentCode(parsed: ReturnType<typeof parseCommandArgs>, io?: {
4
+ isTTY: boolean;
5
+ readLine: () => Promise<string>;
6
+ hiddenPrompt: () => Promise<string>;
7
+ }): Promise<string>;
8
+ export declare function runLogin(args: string[], opts: {
9
+ json?: boolean;
10
+ apiUrl?: string;
11
+ }, help?: boolean): Promise<number>;
@@ -0,0 +1,160 @@
1
+ import { stdin, stdout } from "node:process";
2
+ import { loadConfigFile, redactToken, resolveConfigPath, writeConfigKeys, workspaceFromToken, } from "../config.js";
3
+ import { exchangeEnrollment, createUploadsClient } from "../client.js";
4
+ import { flagBool, flagString, parseCommandArgs, UsageError } from "../cli-args.js";
5
+ const HELP = `uploads login [options]
6
+
7
+ Exchange a one-time enrollment code for workspace credentials, save them, and
8
+ verify access. Ask your uploads.sh administrator for an enrollment code.
9
+
10
+ Options:
11
+ --code <code> Code in argv (may be visible in shell history/process lists)
12
+ --code-stdin Read one line from stdin
13
+ --non-interactive Never prompt
14
+ --api-url <url> API base (default: https://api.uploads.sh)
15
+ --path <file> Config destination
16
+ --force Replace existing saved credentials
17
+ --no-check Skip doctor verification
18
+ `;
19
+ export function validateEnrollmentCode(raw) {
20
+ const code = raw.trim();
21
+ if (!/^upe_[A-Za-z0-9_-]{20,}$/.test(code))
22
+ throw new UsageError("invalid enrollment code");
23
+ return code;
24
+ }
25
+ async function readLine() {
26
+ let out = "";
27
+ for await (const chunk of stdin) {
28
+ out += String(chunk);
29
+ if (out.includes("\n"))
30
+ break;
31
+ }
32
+ return out.split(/\r?\n/, 1)[0] ?? "";
33
+ }
34
+ async function hiddenPrompt() {
35
+ if (!stdin.isTTY || typeof stdin.setRawMode !== "function")
36
+ return readLine();
37
+ stdout.write("Enrollment code: ");
38
+ stdin.setRawMode(true);
39
+ stdin.resume();
40
+ return new Promise((resolve, reject) => {
41
+ let value = "";
42
+ let settled = false;
43
+ const done = (err) => {
44
+ if (settled)
45
+ return;
46
+ settled = true;
47
+ stdin.off("data", onData);
48
+ stdin.off("error", onError);
49
+ try {
50
+ stdin.setRawMode(false);
51
+ }
52
+ finally {
53
+ stdin.pause();
54
+ stdout.write("\n");
55
+ }
56
+ if (err)
57
+ reject(err);
58
+ else
59
+ resolve(value);
60
+ };
61
+ const onData = (chunk) => {
62
+ for (const char of chunk.toString("utf8")) {
63
+ if (char === "\r" || char === "\n")
64
+ return done();
65
+ if (char === "\u0003")
66
+ return done(new UsageError("login cancelled"));
67
+ if (char === "\u007f")
68
+ value = value.slice(0, -1);
69
+ else
70
+ value += char;
71
+ }
72
+ };
73
+ const onError = (err) => done(err);
74
+ stdin.on("data", onData);
75
+ stdin.on("error", onError);
76
+ });
77
+ }
78
+ export async function resolveEnrollmentCode(parsed, io = {
79
+ isTTY: Boolean(stdin.isTTY),
80
+ readLine,
81
+ hiddenPrompt,
82
+ }) {
83
+ const direct = flagString(parsed.flags, "--code");
84
+ const env = process.env.UPLOADS_ENROLLMENT_CODE;
85
+ const fromStdin = flagBool(parsed.flags, "--code-stdin");
86
+ const sources = [Boolean(direct), Boolean(env), fromStdin].filter(Boolean).length;
87
+ if (sources > 1)
88
+ throw new UsageError("provide enrollment code through only one source");
89
+ if (direct)
90
+ return validateEnrollmentCode(direct);
91
+ if (env)
92
+ return validateEnrollmentCode(env);
93
+ if (fromStdin)
94
+ return validateEnrollmentCode(await io.readLine());
95
+ if (flagBool(parsed.flags, "--non-interactive"))
96
+ throw new UsageError("enrollment code required in non-interactive mode");
97
+ if (!io.isTTY)
98
+ return validateEnrollmentCode(await io.readLine());
99
+ return validateEnrollmentCode(await io.hiddenPrompt());
100
+ }
101
+ export async function runLogin(args, opts, help = false) {
102
+ const parsed = parseCommandArgs(args);
103
+ if (help || parsed.help) {
104
+ process.stderr.write(HELP);
105
+ return 0;
106
+ }
107
+ const apiUrl = flagString(parsed.flags, "--api-url") ?? opts.apiUrl ?? "https://api.uploads.sh";
108
+ const path = flagString(parsed.flags, "--path") ?? resolveConfigPath();
109
+ const force = flagBool(parsed.flags, "--force");
110
+ const existing = loadConfigFile(path);
111
+ if (existing.UPLOADS_TOKEN && !force)
112
+ throw new UsageError(`credentials already exist in ${path}; use --force to replace them`);
113
+ if (process.env.UPLOADS_TOKEN && !force)
114
+ throw new UsageError("UPLOADS_TOKEN is already set in the environment; unset it or use --force");
115
+ const code = await resolveEnrollmentCode(parsed);
116
+ const result = await exchangeEnrollment(apiUrl, code);
117
+ const encoded = workspaceFromToken(result.token);
118
+ if (!encoded || encoded !== result.workspace || /[\r\n]/.test(result.token))
119
+ throw new UsageError("enrollment returned invalid credentials");
120
+ const savedApiUrl = result.apiUrl ?? apiUrl;
121
+ const write = writeConfigKeys(path, {
122
+ UPLOADS_API_URL: savedApiUrl,
123
+ UPLOADS_WORKSPACE: result.workspace,
124
+ UPLOADS_TOKEN: result.token,
125
+ }, { force });
126
+ if (!["UPLOADS_API_URL", "UPLOADS_WORKSPACE", "UPLOADS_TOKEN"].every((key) => write.updated.includes(key)))
127
+ throw new UsageError("credentials were not fully written; retry with --force");
128
+ const checked = !flagBool(parsed.flags, "--no-check");
129
+ let doctor = { ok: true, error: undefined };
130
+ if (checked) {
131
+ try {
132
+ const client = createUploadsClient({
133
+ apiUrl: savedApiUrl,
134
+ workspace: result.workspace,
135
+ token: result.token,
136
+ });
137
+ const health = await client.health();
138
+ if (!health.ok)
139
+ throw new Error("API unhealthy");
140
+ await client.list({ limit: 1 });
141
+ }
142
+ catch (err) {
143
+ doctor = { ok: false, error: err instanceof Error ? err.message : String(err) };
144
+ }
145
+ }
146
+ const payload = {
147
+ ok: doctor.ok,
148
+ configPath: path,
149
+ workspace: result.workspace,
150
+ token: redactToken(result.token),
151
+ doctor: checked ? doctor : { skipped: true },
152
+ };
153
+ if (opts.json)
154
+ process.stdout.write(JSON.stringify(payload, null, 2) + "\n");
155
+ else {
156
+ process.stdout.write(`saved credentials to ${path}\nworkspace: ${result.workspace}\ntoken: ${redactToken(result.token)}\n`);
157
+ process[doctor.ok ? "stdout" : "stderr"].write(`doctor: ${checked ? (doctor.ok ? "ok" : `failed — ${doctor.error}`) : "skipped"}\n`);
158
+ }
159
+ return doctor.ok ? 0 : 1;
160
+ }
@@ -13,7 +13,7 @@ With flags: saves provided values, then optionally verifies with doctor.
13
13
  Options:
14
14
  --api-url <url> API base (default: ${DEFAULT_API_URL})
15
15
  --workspace, -w <name>
16
- --token <token> Bearer token (mint via admin endpoint — see below)
16
+ --token <token> Existing bearer token (or use uploads login)
17
17
  --prefix <path> Default key prefix for put/list (default: screenshots)
18
18
  --repo <owner/repo> Default repo segment for put
19
19
  --ref <id> Default ref segment for put (PR/issue/branch/date)
@@ -41,15 +41,6 @@ function buildStatus(envFile) {
41
41
  sources: describeConfigSources({ envFile }),
42
42
  };
43
43
  }
44
- function mintTokenCommand(apiUrl, workspace) {
45
- const body = workspace && workspace !== DEFAULT_WORKSPACE
46
- ? ` \\\n -H "Content-Type: application/json" \\\n -d '{"workspace":"${workspace}","label":"cli"}'`
47
- : "";
48
- return [
49
- `curl -XPOST ${apiUrl}/admin/tokens \\`,
50
- ` -H "Authorization: Bearer $ADMIN_TOKEN"${body}`,
51
- ].join("\n");
52
- }
53
44
  function formatWizard(status) {
54
45
  const lines = ["uploads setup", ""];
55
46
  lines.push(`Config file: ${status.configPath}`);
@@ -72,11 +63,10 @@ function formatWizard(status) {
72
63
  }
73
64
  lines.push("");
74
65
  if (!status.token) {
75
- lines.push("Step 1 — Mint a token");
76
- lines.push(" You need ADMIN_TOKEN for the API (ask your uploads.sh admin, or set it locally in apps/api/.dev.vars).");
77
- lines.push(" Mint:");
78
- lines.push(` ${mintTokenCommand(status.apiUrl, status.workspace)}`);
79
- lines.push(" Save:");
66
+ lines.push("Step 1 — Sign in");
67
+ lines.push(" Ask your uploads.sh administrator for a one-time enrollment code, then run:");
68
+ lines.push(" uploads login");
69
+ lines.push(" If you already have a bearer token:");
80
70
  lines.push(" uploads setup --token up_<workspace>_…");
81
71
  lines.push("");
82
72
  }
@@ -217,7 +207,7 @@ export async function runSetup(args, opts, help = false) {
217
207
  process.stderr.write("hint: run uploads doctor to verify\n");
218
208
  }
219
209
  else {
220
- process.stderr.write("hint: run uploads setup to see minting instructions\n");
210
+ process.stderr.write("hint: run uploads login with an admin-provided enrollment code\n");
221
211
  }
222
212
  return doctorOk === false ? 1 : 0;
223
213
  }
package/dist/commands.js CHANGED
@@ -16,29 +16,29 @@ async function writeJson(value) {
16
16
  await writeStdout(JSON.stringify(value, null, 2) + "\n");
17
17
  }
18
18
  // --- 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
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
42
42
  `;
43
43
  /** Reads --pr/--issue (+ --repo) into a GhTarget; undefined when neither flag is present. */
44
44
  function ghTargetFromFlags(flags, run) {
@@ -72,23 +72,23 @@ async function syncAttachmentsComment(ctx, target, run) {
72
72
  return { action: created ? "created" : "updated", count: items.length };
73
73
  }
74
74
  // --- 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
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
92
92
  `;
93
93
  export async function runAttach(ctx, args, help = false, run = execRunner) {
94
94
  const parsed = parseCommandArgs(args);
@@ -243,14 +243,14 @@ export async function runPut(ctx, args, help = false, run = execRunner) {
243
243
  return 0;
244
244
  }
245
245
  // --- 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
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
254
254
  `;
255
255
  export async function runList(ctx, args, help = false, run = execRunner) {
256
256
  const parsed = parseCommandArgs(args);
@@ -296,10 +296,10 @@ export async function runList(ctx, args, help = false, run = execRunner) {
296
296
  return 0;
297
297
  }
298
298
  // --- delete ---
299
- const DELETE_HELP = `uploads delete <key> [--dry-run] [--workspace <name>]
300
-
301
- Examples:
302
- uploads delete screenshots/myapp/42/shot-a1b2c3.png
299
+ const DELETE_HELP = `uploads delete <key> [--dry-run] [--workspace <name>]
300
+
301
+ Examples:
302
+ uploads delete screenshots/myapp/42/shot-a1b2c3.png
303
303
  `;
304
304
  export async function runDelete(ctx, args, help = false) {
305
305
  const parsed = parseCommandArgs(args);
@@ -327,16 +327,16 @@ export async function runDelete(ctx, args, help = false) {
327
327
  return 0;
328
328
  }
329
329
  // --- 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
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
340
340
  `;
341
341
  export async function runComment(ctx, args, help = false, run = execRunner) {
342
342
  const parsed = parseCommandArgs(args);
@@ -359,13 +359,13 @@ export async function runComment(ctx, args, help = false, run = execRunner) {
359
359
  return 0;
360
360
  }
361
361
  // --- 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
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
369
369
  `;
370
370
  export async function runHealth(ctx, args, help = false) {
371
371
  if (help || parseCommandArgs(args).help) {
@@ -383,13 +383,13 @@ export async function runHealth(ctx, args, help = false) {
383
383
  await writeStdout(result.ok ? `ok (${ctx.apiUrl})\n` : `unhealthy (${ctx.apiUrl})\n`);
384
384
  return result.ok ? 0 : 1;
385
385
  }
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
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
393
393
  `;
394
394
  export async function runDoctor(ctx, args, help = false) {
395
395
  if (help || parseCommandArgs(args).help) {
@@ -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/package.json CHANGED
@@ -1,60 +1,60 @@
1
- {
2
- "name": "@buildinternet/uploads",
3
- "version": "0.1.0",
4
- "description": "CLI and client for uploads.sh — workspace-scoped image hosting for GitHub embeds",
5
- "type": "module",
6
- "license": "MIT",
7
- "repository": {
8
- "type": "git",
9
- "url": "git+https://github.com/buildinternet/uploads.git",
10
- "directory": "packages/uploads"
11
- },
12
- "types": "./dist/index.d.ts",
13
- "exports": {
14
- ".": {
15
- "types": "./dist/index.d.ts",
16
- "import": "./dist/index.js"
17
- },
18
- "./agent": {
19
- "types": "./dist/agent.d.ts",
20
- "import": "./dist/agent.js"
21
- }
22
- },
23
- "bin": {
24
- "uploads": "./bin/uploads.js"
25
- },
26
- "files": [
27
- "bin",
28
- "dist",
29
- "README.md"
30
- ],
31
- "scripts": {
32
- "test": "vitest run",
33
- "typecheck": "tsc --noEmit",
34
- "build": "tsc",
35
- "pack:check": "node ./scripts/check-pack.mjs",
36
- "prepublishOnly": "npm run build"
37
- },
38
- "engines": {
39
- "node": ">=22"
40
- },
41
- "peerDependencies": {
42
- "files-sdk": "^2.1.0"
43
- },
44
- "peerDependenciesMeta": {
45
- "files-sdk": {
46
- "optional": true
47
- }
48
- },
49
- "devDependencies": {
50
- "@types/node": "^26.1.0",
51
- "ai": "^6.0.0",
52
- "files-sdk": "^2.1.0",
53
- "typescript": "^6.0.3",
54
- "vitest": "^4.1.10"
55
- },
56
- "publishConfig": {
57
- "access": "public",
58
- "provenance": true
59
- }
60
- }
1
+ {
2
+ "name": "@buildinternet/uploads",
3
+ "version": "0.1.1",
4
+ "description": "CLI and client for uploads.sh — workspace-scoped image hosting for GitHub embeds",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/buildinternet/uploads.git",
10
+ "directory": "packages/uploads"
11
+ },
12
+ "types": "./dist/index.d.ts",
13
+ "exports": {
14
+ ".": {
15
+ "types": "./dist/index.d.ts",
16
+ "import": "./dist/index.js"
17
+ },
18
+ "./agent": {
19
+ "types": "./dist/agent.d.ts",
20
+ "import": "./dist/agent.js"
21
+ }
22
+ },
23
+ "bin": {
24
+ "uploads": "bin/uploads.js"
25
+ },
26
+ "files": [
27
+ "bin",
28
+ "dist",
29
+ "README.md"
30
+ ],
31
+ "scripts": {
32
+ "test": "vitest run",
33
+ "typecheck": "tsc --noEmit",
34
+ "build": "tsc",
35
+ "pack:check": "node ./scripts/check-pack.mjs",
36
+ "prepublishOnly": "npm run build"
37
+ },
38
+ "engines": {
39
+ "node": ">=22"
40
+ },
41
+ "peerDependencies": {
42
+ "files-sdk": "^2.1.0"
43
+ },
44
+ "peerDependenciesMeta": {
45
+ "files-sdk": {
46
+ "optional": true
47
+ }
48
+ },
49
+ "devDependencies": {
50
+ "@types/node": "^26.1.0",
51
+ "ai": "^6.0.0",
52
+ "files-sdk": "^2.1.0",
53
+ "typescript": "^6.0.3",
54
+ "vitest": "^4.1.10"
55
+ },
56
+ "publishConfig": {
57
+ "access": "public",
58
+ "provenance": true
59
+ }
60
+ }