@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.
@@ -0,0 +1,133 @@
1
+ import { flagBool, flagString, parseCommandArgs, UsageError, } from "../cli-args.js";
2
+ import { resolveConfig } from "../config.js";
3
+ import { execRunner } from "../github-gh.js";
4
+ export const DEFAULT_MCP_URL = "https://agents.uploads.sh/mcp";
5
+ const SKILL_SOURCE = "buildinternet/uploads";
6
+ const SKILL_NAME = "uploads-cli";
7
+ const INSTALL_HELP = `uploads install — set up agent integrations (skill + remote MCP)
8
+
9
+ Installs the uploads-cli agent skill and registers the hosted MCP server
10
+ with Claude Code. The remote MCP endpoint infers your workspace from the
11
+ bearer token, so only the token is needed.
12
+
13
+ Usage:
14
+ uploads install [skill|mcp|all] (default: all)
15
+
16
+ What runs:
17
+ skill npx -y skills add ${SKILL_SOURCE} --skill ${SKILL_NAME}
18
+ mcp claude mcp add --transport http uploads ${DEFAULT_MCP_URL} \\
19
+ --header "Authorization: Bearer <token>"
20
+
21
+ Options:
22
+ --url <endpoint> Remote MCP endpoint (default: ${DEFAULT_MCP_URL})
23
+ --name <name> MCP server name in the client (default: uploads)
24
+ --dry-run Print the commands without running them
25
+
26
+ Examples:
27
+ uploads install
28
+ uploads install skill
29
+ uploads install mcp --dry-run
30
+ `;
31
+ /**
32
+ * Masks the configured token (and any Bearer credential) in text destined
33
+ * for stdout/stderr/JSON — command echoes, child-process output, and error
34
+ * messages can all embed it.
35
+ */
36
+ function redactor(token) {
37
+ return (text) => {
38
+ let out = text.replace(/Bearer \S+/g, "Bearer ***");
39
+ if (token)
40
+ out = out.split(token).join("***");
41
+ return out;
42
+ };
43
+ }
44
+ function runStep(run, command) {
45
+ try {
46
+ const output = run(command[0], command.slice(1)).trim();
47
+ return { command, ok: true, output: output || undefined };
48
+ }
49
+ catch (err) {
50
+ const message = err instanceof Error ? err.message : String(err);
51
+ // execFileSync's ENOENT means the binary itself is missing.
52
+ const hint = err.code === "ENOENT"
53
+ ? `${command[0]} not found on PATH — run manually: ${command.join(" ")}`
54
+ : message;
55
+ return { command, ok: false, error: hint };
56
+ }
57
+ }
58
+ export async function runInstall(args, opts, help = false) {
59
+ const parsed = parseCommandArgs(args);
60
+ if (help || parsed.help) {
61
+ process.stderr.write(INSTALL_HELP);
62
+ return 0;
63
+ }
64
+ const target = parsed.positionals[0] ?? "all";
65
+ if (!["skill", "mcp", "all"].includes(target)) {
66
+ throw new UsageError(`unknown install target: ${target} (expected skill, mcp, or all)`);
67
+ }
68
+ const url = flagString(parsed.flags, "--url") ?? DEFAULT_MCP_URL;
69
+ const name = flagString(parsed.flags, "--name") ?? "uploads";
70
+ const dryRun = flagBool(parsed.flags, "--dry-run");
71
+ const run = opts.runner ?? execRunner;
72
+ const results = {};
73
+ let redact = redactor(undefined);
74
+ if (target === "skill" || target === "all") {
75
+ const command = ["npx", "-y", "skills", "add", SKILL_SOURCE, "--skill", SKILL_NAME];
76
+ results.skill = dryRun ? { command, ok: true, skipped: "dry-run" } : runStep(run, command);
77
+ }
78
+ if (target === "mcp" || target === "all") {
79
+ const config = resolveConfig({
80
+ apiUrl: opts.globals.apiUrl,
81
+ workspace: opts.globals.workspace,
82
+ token: opts.globals.token,
83
+ envFile: opts.globals.envFile,
84
+ requireToken: !dryRun,
85
+ });
86
+ const bearer = config.token || "<token>";
87
+ redact = redactor(config.token || undefined);
88
+ const command = [
89
+ "claude",
90
+ "mcp",
91
+ "add",
92
+ "--transport",
93
+ "http",
94
+ name,
95
+ url,
96
+ "--header",
97
+ `Authorization: Bearer ${bearer}`,
98
+ ];
99
+ results.mcp = dryRun ? { command, ok: true, skipped: "dry-run" } : runStep(run, command);
100
+ }
101
+ const failed = Object.values(results).some((r) => !r.ok);
102
+ if (opts.json) {
103
+ // Never echo the token in structured output — commands, child output,
104
+ // and error text can all embed it.
105
+ const redacted = Object.fromEntries(Object.entries(results).map(([key, r]) => [
106
+ key,
107
+ {
108
+ ...r,
109
+ command: r.command.map(redact),
110
+ output: r.output === undefined ? undefined : redact(r.output),
111
+ error: r.error === undefined ? undefined : redact(r.error),
112
+ },
113
+ ]));
114
+ process.stdout.write(JSON.stringify({ ok: !failed, steps: redacted }, null, 2) + "\n");
115
+ return failed ? 1 : 0;
116
+ }
117
+ for (const [step, r] of Object.entries(results)) {
118
+ const shown = redact(r.command.join(" "));
119
+ if (r.skipped)
120
+ process.stdout.write(`${step}: would run — ${shown}\n`);
121
+ else if (r.ok) {
122
+ process.stdout.write(`${step}: ok — ${shown}\n`);
123
+ if (r.output)
124
+ process.stdout.write(` ${redact(r.output).split("\n").join("\n ")}\n`);
125
+ }
126
+ else
127
+ process.stderr.write(`${step}: failed — ${redact(r.error ?? "")}\n`);
128
+ }
129
+ if (!failed && !dryRun) {
130
+ process.stderr.write("hint: restart your agent session to pick up the new skill/server\n");
131
+ }
132
+ return failed ? 1 : 0;
133
+ }
@@ -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
+ }
@@ -0,0 +1,4 @@
1
+ import { type GlobalFlags } from "../cli-args.js";
2
+ export declare function runMcp(args: string[], opts: {
3
+ globals: GlobalFlags;
4
+ }, help?: boolean): Promise<number>;
@@ -0,0 +1,39 @@
1
+ import { createRequire } from "node:module";
2
+ import { parseCommandArgs } from "../cli-args.js";
3
+ import { createMcpServer } from "../mcp/server.js";
4
+ import { serveStdio } from "../mcp/stdio.js";
5
+ import { createUploadsMcpTools } from "../mcp/tools.js";
6
+ const MCP_HELP = `uploads [globals] mcp
7
+
8
+ Serve the Model Context Protocol (MCP) over stdio for agent clients. Tools
9
+ mirror the CLI commands: put, attach, list, delete, usage, reconcile,
10
+ purge_expired, comment, health, doctor.
11
+ Global flags before "mcp" (--api-url, --token, --workspace, --env-file)
12
+ configure every tool call; a per-call "workspace" argument overrides
13
+ --workspace, like the CLI's per-command flag.
14
+
15
+ Example MCP client config:
16
+ {
17
+ "command": "uploads",
18
+ "args": ["--env-file", "/path/.env", "mcp"]
19
+ }
20
+
21
+ Examples:
22
+ uploads --env-file .env mcp
23
+ uploads --token up_default_… mcp
24
+ `;
25
+ // Same relative depth from src/commands/ and dist/commands/, so this works
26
+ // both under vitest (src) and at runtime (dist).
27
+ const { version } = createRequire(import.meta.url)("../../package.json");
28
+ export async function runMcp(args, opts, help = false) {
29
+ if (help || parseCommandArgs(args).help) {
30
+ process.stderr.write(MCP_HELP);
31
+ return 0;
32
+ }
33
+ const server = createMcpServer({
34
+ serverInfo: { name: "uploads", version },
35
+ tools: createUploadsMcpTools({ globals: opts.globals }),
36
+ });
37
+ await serveStdio(server);
38
+ return 0;
39
+ }
@@ -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
  }
@@ -1,5 +1,6 @@
1
1
  import { type UploadsClient } from "./client.js";
2
2
  import { type ResolvedConfig } from "./config.js";
3
+ import { type GhTarget } from "./github.js";
3
4
  import { type CommandRunner } from "./github-gh.js";
4
5
  export interface CliContext {
5
6
  config: ResolvedConfig;
@@ -8,12 +9,58 @@ export interface CliContext {
8
9
  quiet: boolean;
9
10
  envFile?: string;
10
11
  }
12
+ /**
13
+ * Turns a pr/issue pair (+ optional repo) into a GhTarget; undefined when
14
+ * neither is present. Shared by the CLI flags and the MCP tool arguments.
15
+ */
16
+ export declare function makeGhTarget(pr: number | undefined, issue: number | undefined, repoArg: string | undefined, run: CommandRunner): GhTarget | undefined;
17
+ /**
18
+ * List every attachment under the target's prefix and create/update the
19
+ * managed comment. Throws on gh failure — callers decide whether that is
20
+ * fatal (`comment` command) or a warning (`put --comment`).
21
+ */
22
+ export declare function syncAttachmentsComment(client: UploadsClient, target: GhTarget, run: CommandRunner): Promise<{
23
+ action: "created" | "updated" | "skipped";
24
+ count: number;
25
+ }>;
11
26
  export declare function runAttach(ctx: CliContext, args: string[], help?: boolean, run?: CommandRunner): Promise<number>;
12
27
  export declare function runPut(ctx: CliContext, args: string[], help?: boolean, run?: CommandRunner): Promise<number>;
13
28
  export declare function runList(ctx: CliContext, args: string[], help?: boolean, run?: CommandRunner): Promise<number>;
14
29
  export declare function runDelete(ctx: CliContext, args: string[], help?: boolean): Promise<number>;
15
30
  export declare function runComment(ctx: CliContext, args: string[], help?: boolean, run?: CommandRunner): Promise<number>;
31
+ export declare function runUsage(ctx: CliContext, args: string[], help?: boolean): Promise<number>;
32
+ export declare function runReconcile(ctx: CliContext, args: string[], help?: boolean): Promise<number>;
33
+ export declare function runPurgeExpired(ctx: CliContext, args: string[], help?: boolean): Promise<number>;
16
34
  export declare function runHealth(ctx: Pick<CliContext, "json"> & {
17
35
  apiUrl: string;
18
36
  }, args: string[], help?: boolean): Promise<number>;
37
+ export interface DoctorReport {
38
+ ok: boolean;
39
+ apiUrl: string;
40
+ workspace: string;
41
+ workspaceSource: ResolvedConfig["workspaceSource"];
42
+ workspaceFromToken: string | undefined;
43
+ configPath: string;
44
+ configExists: boolean;
45
+ health: {
46
+ ok: boolean;
47
+ };
48
+ auth: {
49
+ ok: boolean;
50
+ error: string | undefined;
51
+ };
52
+ /** Usage snapshot when auth works (optional fields when the endpoint fails). */
53
+ usage?: {
54
+ ok: boolean;
55
+ bytes?: number;
56
+ objects?: number;
57
+ uploadsInPeriod?: number;
58
+ error?: string;
59
+ };
60
+ /** Workspace/token mismatch warning (also present in hints). */
61
+ warning?: string;
62
+ hints: string[];
63
+ }
64
+ /** Doctor's health + auth + workspace checks, shared by the CLI and the MCP tool. */
65
+ export declare function buildDoctorReport(config: ResolvedConfig, client: UploadsClient): Promise<DoctorReport>;
19
66
  export declare function runDoctor(ctx: CliContext, args: string[], help?: boolean): Promise<number>;