@buildinternet/uploads 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,284 @@
1
+ import { parseCommandArgs, UsageError } from "../cli-args.js";
2
+ import { COMPLETION_SHELLS, GLOBAL_FLAGS, LIST_LIKE_FLAGS, PUT_LIKE_FLAGS, ROOT_COMMANDS, isCompletionShell, } from "../cli-catalog.js";
3
+ import { writeCommandHelp } from "../cli-style.js";
4
+ const HELP = `uploads completion <shell>
5
+
6
+ Print a shell completion script to stdout. Source it or install it so Tab
7
+ completes commands, subcommands, and common flags.
8
+
9
+ Shells:
10
+ bash Bash (complete -F)
11
+ zsh Zsh (#compdef)
12
+ fish Fish (complete -c)
13
+
14
+ Examples:
15
+ # zsh (Oh My Zsh / fpath)
16
+ uploads completion zsh > ~/.zsh/completions/_uploads
17
+
18
+ # bash
19
+ uploads completion bash > ~/.local/share/bash-completion/completions/uploads
20
+ # or for the current session:
21
+ eval "$(uploads completion bash)"
22
+
23
+ # fish
24
+ uploads completion fish > ~/.config/fish/completions/uploads.fish
25
+ `;
26
+ function bashScript() {
27
+ const cmds = ROOT_COMMANDS.map((c) => c.name).join(" ");
28
+ const globals = GLOBAL_FLAGS.map((g) => g.flag).join(" ");
29
+ const putFlags = PUT_LIKE_FLAGS.join(" ");
30
+ const listFlags = LIST_LIKE_FLAGS.join(" ");
31
+ const subMaps = ROOT_COMMANDS.filter((c) => c.subcommands?.length).map((c) => {
32
+ const names = c.subcommands.map((s) => s.name).join(" ");
33
+ return ` ${c.name}) subs="${names}" ;;`;
34
+ });
35
+ // Shell `$var` / `$(…)` must not be JS-escaped (`\$`); only shell `${…}` needs `\${…}`
36
+ // so the template literal doesn't treat it as interpolation.
37
+ return `# uploads bash completion — generated by: uploads completion bash
38
+ # shellcheck shell=bash disable=SC2207
39
+
40
+ _uploads() {
41
+ local cur prev words cword
42
+ if declare -F _init_completion >/dev/null 2>&1; then
43
+ _init_completion -n : || return
44
+ else
45
+ cur="\${COMP_WORDS[COMP_CWORD]}"
46
+ prev="\${COMP_WORDS[COMP_CWORD-1]}"
47
+ words=("\${COMP_WORDS[@]}")
48
+ cword=\${COMP_CWORD}
49
+ fi
50
+
51
+ local -a root_cmds=(${cmds})
52
+ local -a globals=(${globals})
53
+ local -a put_flags=(${putFlags})
54
+ local -a list_flags=(${listFlags})
55
+
56
+ # Find the first non-global positional (the subcommand).
57
+ local cmd="" i=1
58
+ while [[ $i -lt $cword ]]; do
59
+ local w="\${words[i]}"
60
+ case "$w" in
61
+ --api-url|--token|--workspace|-w|--env-file)
62
+ i=$((i + 2))
63
+ continue
64
+ ;;
65
+ --json|--quiet|--version|-V|--help|-h|--all)
66
+ i=$((i + 1))
67
+ continue
68
+ ;;
69
+ -*)
70
+ i=$((i + 1))
71
+ continue
72
+ ;;
73
+ *)
74
+ cmd="$w"
75
+ break
76
+ ;;
77
+ esac
78
+ done
79
+
80
+ if [[ -z "$cmd" ]]; then
81
+ if [[ "$cur" == -* ]]; then
82
+ COMPREPLY=( $(compgen -W "\${globals[*]}" -- "$cur") )
83
+ else
84
+ COMPREPLY=( $(compgen -W "\${root_cmds[*]}" -- "$cur") )
85
+ fi
86
+ return
87
+ fi
88
+
89
+ local subs=""
90
+ case "$cmd" in
91
+ ${subMaps.join("\n")}
92
+ esac
93
+
94
+ # Completing the subcommand word (or first arg after command).
95
+ if [[ "$prev" == "$cmd" && -n "$subs" ]]; then
96
+ COMPREPLY=( $(compgen -W "$subs" -- "$cur") )
97
+ return
98
+ fi
99
+
100
+ if [[ "$cur" == -* ]]; then
101
+ case "$cmd" in
102
+ put|attach)
103
+ COMPREPLY=( $(compgen -W "\${put_flags[*]}" -- "$cur") )
104
+ ;;
105
+ list|find)
106
+ COMPREPLY=( $(compgen -W "\${list_flags[*]}" -- "$cur") )
107
+ ;;
108
+ *)
109
+ COMPREPLY=( $(compgen -W "--help -h --workspace -w --json --quiet" -- "$cur") )
110
+ ;;
111
+ esac
112
+ return
113
+ fi
114
+
115
+ # File paths for upload-style commands.
116
+ case "$cmd" in
117
+ put|attach)
118
+ COMPREPLY=( $(compgen -f -- "$cur") )
119
+ ;;
120
+ esac
121
+ }
122
+
123
+ complete -o default -F _uploads uploads
124
+ `;
125
+ }
126
+ function zshScript() {
127
+ const cmdDescribe = ROOT_COMMANDS.map((c) => {
128
+ const desc = c.summary.replace(/'/g, "'\\''");
129
+ return ` '${c.name}:${desc}'`;
130
+ }).join("\n");
131
+ const globalArgs = GLOBAL_FLAGS.map((g) => {
132
+ const desc = g.summary.replace(/'/g, "'\\''");
133
+ // Short and long flags as separate specs when needed.
134
+ if (g.flag === "-w")
135
+ return ` '(-w --workspace)'{-w,--workspace}'[${desc}]:workspace:'`;
136
+ if (g.flag === "--workspace")
137
+ return null; // paired with -w
138
+ if (g.flag === "-V")
139
+ return ` '(-V --version)'{-V,--version}'[${desc}]'`;
140
+ if (g.flag === "--version")
141
+ return null;
142
+ if (g.flag === "-h")
143
+ return ` '(-h --help)'{-h,--help}'[${desc}]'`;
144
+ if (g.flag === "--help")
145
+ return null;
146
+ if (g.flag === "--api-url")
147
+ return ` '--api-url[${desc}]:url:'`;
148
+ if (g.flag === "--token")
149
+ return ` '--token[${desc}]:token:'`;
150
+ if (g.flag === "--env-file")
151
+ return ` '--env-file[${desc}]:file:_files'`;
152
+ return ` '${g.flag}[${desc}]'`;
153
+ })
154
+ .filter(Boolean)
155
+ .join("\n");
156
+ const subCases = ROOT_COMMANDS.filter((c) => c.subcommands?.length)
157
+ .map((c) => {
158
+ const lines = c
159
+ .subcommands.map((s) => {
160
+ const desc = s.summary.replace(/'/g, "'\\''");
161
+ return ` '${s.name}:${desc}'`;
162
+ })
163
+ .join("\n");
164
+ return ` ${c.name})
165
+ local -a subs
166
+ subs=(
167
+ ${lines}
168
+ )
169
+ _describe 'subcommand' subs
170
+ ;;`;
171
+ })
172
+ .join("\n");
173
+ return `#compdef uploads
174
+ # uploads zsh completion — generated by: uploads completion zsh
175
+
176
+ _uploads() {
177
+ local -a commands
178
+ commands=(
179
+ ${cmdDescribe}
180
+ )
181
+
182
+ local context state state_descr line
183
+ typeset -A opt_args
184
+
185
+ _arguments -C -s -S \\
186
+ ${globalArgs} \\
187
+ '1:command:->cmds' \\
188
+ '*::arg:->args'
189
+
190
+ case $state in
191
+ cmds)
192
+ _describe -t commands 'uploads command' commands
193
+ ;;
194
+ args)
195
+ case $line[1] in
196
+ ${subCases}
197
+ put|attach)
198
+ _files
199
+ ;;
200
+ esac
201
+ ;;
202
+ esac
203
+ }
204
+
205
+ _uploads "$@"
206
+ `;
207
+ }
208
+ function fishScript() {
209
+ const lines = [
210
+ `# uploads fish completion — generated by: uploads completion fish`,
211
+ `complete -c uploads -e`,
212
+ `complete -c uploads -f`,
213
+ ];
214
+ for (const g of GLOBAL_FLAGS) {
215
+ const short = g.flag.length === 2 && g.flag.startsWith("-") && !g.flag.startsWith("--");
216
+ if (short) {
217
+ // Pair handled with long form when possible
218
+ continue;
219
+ }
220
+ const esc = g.summary.replace(/'/g, "\\'");
221
+ if (g.flag === "--workspace") {
222
+ lines.push(`complete -c uploads -s w -l workspace -d '${esc}' -r`);
223
+ }
224
+ else if (g.flag === "--version") {
225
+ lines.push(`complete -c uploads -s V -l version -d '${esc}'`);
226
+ }
227
+ else if (g.flag === "--help") {
228
+ lines.push(`complete -c uploads -s h -l help -d '${esc}'`);
229
+ }
230
+ else if (g.flag === "--api-url" || g.flag === "--token" || g.flag === "--env-file") {
231
+ lines.push(`complete -c uploads -l ${g.flag.slice(2)} -d '${esc}' -r`);
232
+ }
233
+ else {
234
+ lines.push(`complete -c uploads -l ${g.flag.slice(2)} -d '${esc}'`);
235
+ }
236
+ }
237
+ for (const c of ROOT_COMMANDS) {
238
+ const esc = c.summary.replace(/'/g, "\\'");
239
+ lines.push(`complete -c uploads -n '__fish_use_subcommand' -a '${c.name}' -d '${esc}'`);
240
+ if (c.subcommands?.length) {
241
+ for (const s of c.subcommands) {
242
+ const sEsc = s.summary.replace(/'/g, "\\'");
243
+ // --all under help is a flag-like token; fish still accepts it as -a
244
+ lines.push(`complete -c uploads -n '__fish_seen_subcommand_from ${c.name}' -a '${s.name}' -d '${sEsc}'`);
245
+ }
246
+ }
247
+ }
248
+ for (const flag of PUT_LIKE_FLAGS) {
249
+ if (!flag.startsWith("--"))
250
+ continue;
251
+ lines.push(`complete -c uploads -n '__fish_seen_subcommand_from put attach' -l ${flag.slice(2)}`);
252
+ }
253
+ for (const flag of LIST_LIKE_FLAGS) {
254
+ if (!flag.startsWith("--"))
255
+ continue;
256
+ lines.push(`complete -c uploads -n '__fish_seen_subcommand_from list find' -l ${flag.slice(2)}`);
257
+ }
258
+ // File completion for put/attach
259
+ lines.push(`complete -c uploads -n '__fish_seen_subcommand_from put attach' -F`);
260
+ return lines.join("\n") + "\n";
261
+ }
262
+ export function generateCompletionScript(shell) {
263
+ switch (shell) {
264
+ case "bash":
265
+ return bashScript();
266
+ case "zsh":
267
+ return zshScript();
268
+ case "fish":
269
+ return fishScript();
270
+ }
271
+ }
272
+ export async function runCompletion(args, help = false) {
273
+ const parsed = parseCommandArgs(args);
274
+ if (help || parsed.help || parsed.positionals.length === 0) {
275
+ writeCommandHelp(HELP);
276
+ return help || parsed.help ? 0 : 2;
277
+ }
278
+ const shell = parsed.positionals[0]?.toLowerCase() ?? "";
279
+ if (!isCompletionShell(shell)) {
280
+ throw new UsageError(`unknown shell: ${parsed.positionals[0]} (expected ${COMPLETION_SHELLS.join(", ")})`);
281
+ }
282
+ process.stdout.write(generateCompletionScript(shell));
283
+ return 0;
284
+ }
@@ -1,6 +1,7 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { DEFAULT_API_URL, DEFAULT_WORKSPACE, UPLOADS_CONFIG_KEYS, describeConfigSources, redactToken, resolveConfig, resolveConfigPath, resolvePutDefaults, writeConfigKeys, } from "../config.js";
3
3
  import { flagBool, flagString, parseCommandArgs, UsageError } from "../cli-args.js";
4
+ import { writeCommandHelp } from "../cli-style.js";
4
5
  const CONFIG_HELP = `uploads config — manage shared buildinternet config
5
6
 
6
7
  Shared file (with github-screenshots and other skills):
@@ -41,7 +42,7 @@ function writeJson(payload) {
41
42
  export async function runConfig(args, opts, help = false) {
42
43
  const parsed = parseCommandArgs(args);
43
44
  if (help || parsed.help || parsed.positionals.length === 0) {
44
- process.stderr.write(CONFIG_HELP);
45
+ writeCommandHelp(CONFIG_HELP);
45
46
  return 0;
46
47
  }
47
48
  const sub = parsed.positionals[0];
@@ -57,13 +58,14 @@ export async function runConfig(args, opts, help = false) {
57
58
  case "set":
58
59
  return runConfigSet(rest, subArgs, opts, help);
59
60
  default:
60
- process.stderr.write(`unknown config subcommand: ${sub}\n\n${CONFIG_HELP}`);
61
+ process.stderr.write(`unknown config subcommand: ${sub}\n\n`);
62
+ writeCommandHelp(CONFIG_HELP);
61
63
  return 2;
62
64
  }
63
65
  }
64
66
  async function runConfigPath(args, opts, help) {
65
67
  if (help || parseCommandArgs(args).help) {
66
- process.stderr.write(`uploads config path\n\nPrint the resolved config file path.\n`);
68
+ writeCommandHelp(`uploads config path\n\nPrint the resolved config file path.\n`);
67
69
  return 0;
68
70
  }
69
71
  const path = resolveConfigPath({ envFile: opts.envFile });
@@ -76,7 +78,7 @@ async function runConfigPath(args, opts, help) {
76
78
  }
77
79
  async function runConfigShow(args, opts, help) {
78
80
  if (help || parseCommandArgs(args).help) {
79
- process.stderr.write(`uploads config show\n\nShow effective settings (token redacted).\n`);
81
+ writeCommandHelp(`uploads config show\n\nShow effective settings (token redacted).\n`);
80
82
  return 0;
81
83
  }
82
84
  const config = resolveConfig({ envFile: opts.envFile, requireToken: false });
@@ -117,7 +119,7 @@ async function runConfigShow(args, opts, help) {
117
119
  async function runConfigInit(args, opts, help) {
118
120
  const parsed = parseCommandArgs(args);
119
121
  if (help || parsed.help) {
120
- process.stderr.write(`uploads config init [options]
122
+ writeCommandHelp(`uploads config init [options]
121
123
 
122
124
  Create or update UPLOADS_* keys in the shared config file.
123
125
 
@@ -173,7 +175,7 @@ Examples:
173
175
  async function runConfigSet(positionals, args, opts, help) {
174
176
  const parsed = parseCommandArgs(args);
175
177
  if (help || parsed.help) {
176
- process.stderr.write(`uploads config set <key> <value> [--path <file>] [--force]
178
+ writeCommandHelp(`uploads config set <key> <value> [--path <file>] [--force]
177
179
 
178
180
  Examples:
179
181
  uploads config set UPLOADS_TOKEN up_default_…
@@ -184,7 +186,7 @@ Examples:
184
186
  const key = positionals[0];
185
187
  const value = positionals[1];
186
188
  if (!key || !value) {
187
- process.stderr.write(`uploads config set <key> <value>\n`);
189
+ writeCommandHelp(`uploads config set <key> <value>\n`);
188
190
  return 2;
189
191
  }
190
192
  if (!VALID_KEYS.has(key)) {
@@ -1,6 +1,7 @@
1
1
  import { flagBool, flagString, parseCommandArgs, UsageError, } from "../cli-args.js";
2
2
  import { resolveConfig } from "../config.js";
3
3
  import { execRunner } from "../github-gh.js";
4
+ import { writeCommandHelp } from "../cli-style.js";
4
5
  export const DEFAULT_MCP_URL = "https://agents.uploads.sh/mcp";
5
6
  const SKILL_SOURCE = "buildinternet/uploads";
6
7
  const SKILL_NAME = "uploads-cli";
@@ -123,7 +124,7 @@ function printSuccessFooter(steps, signedIn) {
123
124
  export async function runInstall(args, opts, help = false) {
124
125
  const parsed = parseCommandArgs(args);
125
126
  if (help || parsed.help) {
126
- process.stderr.write(INSTALL_HELP);
127
+ writeCommandHelp(INSTALL_HELP);
127
128
  return 0;
128
129
  }
129
130
  const target = parsed.positionals[0] ?? "all";
@@ -4,6 +4,7 @@
4
4
  */
5
5
  import { createWorkspaceInvite, listMintWorkspaces } from "../client.js";
6
6
  import { flagBool, flagString, parseCommandArgs, UsageError } from "../cli-args.js";
7
+ import { writeCommandHelp } from "../cli-style.js";
7
8
  import { defaultDeviceIo, obtainDeviceAccessToken, resolveAuthUrl, } from "./login.js";
8
9
  const HELP = `uploads invite create [options]
9
10
 
@@ -53,7 +54,7 @@ export function resolveInviteWorkspace(workspaces, requested) {
53
54
  export async function runInvite(args, opts, help = false, io = defaultDeviceIo) {
54
55
  const parsed = parseCommandArgs(args);
55
56
  if (help || parsed.help) {
56
- process.stderr.write(HELP);
57
+ writeCommandHelp(HELP);
57
58
  return 0;
58
59
  }
59
60
  if (parsed.positionals[0] !== "create") {
@@ -88,13 +89,25 @@ export async function runInvite(args, opts, help = false, io = defaultDeviceIo)
88
89
  invitationId: result.invitation.id,
89
90
  status: result.invitation.status,
90
91
  acceptUrl: result.acceptUrl ?? null,
92
+ emailConfigured: result.emailConfigured ?? null,
91
93
  };
92
94
  if (opts.json)
93
95
  process.stdout.write(JSON.stringify(payload, null, 2) + "\n");
94
96
  else {
95
97
  process.stdout.write(`Invited ${email} to ${workspace} as ${role} (${result.invitation.status}).\n`);
98
+ if (result.emailConfigured === true) {
99
+ process.stdout.write(`Invitation emailed to ${email}.\n`);
100
+ }
101
+ else if (result.emailConfigured === false) {
102
+ process.stdout.write("Email isn't configured on this install — share the accept link yourself.\n");
103
+ }
96
104
  if (result.acceptUrl) {
97
- process.stdout.write(`Accept link (share if email isn't configured):\n ${result.acceptUrl}\n`);
105
+ const label = result.emailConfigured === true
106
+ ? "Accept link (backup)"
107
+ : result.emailConfigured === false
108
+ ? "Accept link"
109
+ : "Accept link (share if email isn't configured)";
110
+ process.stdout.write(`${label}:\n ${result.acceptUrl}\n`);
98
111
  }
99
112
  process.stdout.write("They accept, then run: uploads login\n");
100
113
  }
@@ -17,6 +17,10 @@ export interface DeviceLoginIo {
17
17
  now: () => number;
18
18
  openUrl: (url: string) => void;
19
19
  write: (text: string) => void;
20
+ /** Whether the CLI can prompt the user (a real TTY, not a script/CI pipe). */
21
+ isTTY: boolean;
22
+ /** Prompt for a new workspace name when the account has zero. */
23
+ promptWorkspaceName: () => Promise<string>;
20
24
  }
21
25
  export declare const defaultDeviceIo: DeviceLoginIo;
22
26
  /**
@@ -1,10 +1,12 @@
1
1
  import { hostname } from "node:os";
2
+ import { createInterface } from "node:readline/promises";
2
3
  import { spawn } from "node:child_process";
3
4
  import { stdin, stdout } from "node:process";
4
5
  import { loadConfigFile, redactToken, resolveConfigPath, writeConfigKeys, workspaceFromToken, } from "../config.js";
5
- import { createUploadsClient, exchangeEnrollment, listMintWorkspaces, mintWorkspaceToken, requestDeviceCode, requestDeviceToken, } from "../client.js";
6
+ import { createUploadsClient, createWorkspaceRequest, exchangeEnrollment, listMintWorkspaces, mintWorkspaceToken, requestDeviceCode, requestDeviceToken, } from "../client.js";
6
7
  import { flagBool, flagString, parseCommandArgs, UsageError } from "../cli-args.js";
7
8
  import { parseScopes } from "./admin-enrollment.js";
9
+ import { writeCommandHelp } from "../cli-style.js";
8
10
  const HELP = `uploads login [options]
9
11
 
10
12
  Sign in and save workspace credentials. With no flags, opens a browser to
@@ -14,6 +16,9 @@ code only if you were given one from before device login (fallback path).
14
16
  Options:
15
17
  --workspace <name> Workspace to mint a token for (device flow; required if
16
18
  your account can access more than one)
19
+ --create With --workspace: create the workspace first if your
20
+ account doesn't have it yet (device flow only) — lets
21
+ scripted/agent logins provision without a prompt
17
22
  --scopes <list> Comma-separated scopes (default: files:read,files:write)
18
23
  --label <text> Token label (default: this machine's hostname)
19
24
  --auth-url <url> Auth base (default: https://auth.uploads.sh)
@@ -30,6 +35,7 @@ Options:
30
35
  Examples:
31
36
  uploads login
32
37
  uploads login --workspace acme
38
+ uploads login --workspace acme --create # provision if it doesn't exist
33
39
  uploads login --code upe_… --force # fallback: pre-existing invite
34
40
  printf '%s' upe_… | uploads login --code-stdin --non-interactive
35
41
  `;
@@ -157,6 +163,15 @@ function openUrl(url) {
157
163
  // ignore — the URL is printed for manual navigation.
158
164
  }
159
165
  }
166
+ async function promptWorkspaceName() {
167
+ const rl = createInterface({ input: stdin, output: process.stderr });
168
+ try {
169
+ return (await rl.question("no workspaces yet — enter a name to create one (lowercase, hyphens): ")).trim();
170
+ }
171
+ finally {
172
+ rl.close();
173
+ }
174
+ }
160
175
  export const defaultDeviceIo = {
161
176
  sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
162
177
  now: () => Date.now(),
@@ -164,6 +179,8 @@ export const defaultDeviceIo = {
164
179
  write: (text) => {
165
180
  process.stderr.write(text);
166
181
  },
182
+ isTTY: Boolean(stdin.isTTY),
183
+ promptWorkspaceName,
167
184
  };
168
185
  /**
169
186
  * Browser device-authorization session only (no workspace token mint).
@@ -187,8 +204,11 @@ async function runDeviceLogin(parsed, opts, io) {
187
204
  const scopes = parseScopes(flagString(parsed.flags, "--scopes"));
188
205
  const label = flagString(parsed.flags, "--label") ?? safeHostname();
189
206
  const requestedWorkspace = flagString(parsed.flags, "--workspace");
207
+ // Make the target explicit: a bare `uploads login` on a self-hosted install
208
+ // would otherwise silently sign in to the cloud service.
209
+ io.write(`signing in to ${opts.authUrl} (self-hosted? pass --api-url or set UPLOADS_API_URL)\n\n`);
190
210
  const accessToken = await obtainDeviceAccessToken(opts.authUrl, { noOpen: opts.noOpen }, io);
191
- const workspace = await resolveMintWorkspace(opts.apiUrl, accessToken, requestedWorkspace);
211
+ const workspace = await resolveMintWorkspace(opts.apiUrl, accessToken, requestedWorkspace, io, flagBool(parsed.flags, "--create"));
192
212
  const minted = await mintWorkspaceToken(opts.apiUrl, accessToken, { workspace, scopes, label });
193
213
  return { workspace: minted.workspace, token: minted.token, apiUrl: opts.apiUrl };
194
214
  }
@@ -235,18 +255,36 @@ export async function pollForDeviceToken(authUrl, code, io) {
235
255
  throw new UsageError("timed out waiting for device authorization");
236
256
  }
237
257
  /**
238
- * Pick the workspace to mint for. An explicit --workspace wins; otherwise, if
239
- * the account can access exactly one workspace, use it and if it can access
258
+ * Pick the workspace to mint for. An explicit --workspace wins (with --create,
259
+ * it's provisioned first when the account doesn't have it); otherwise, if the
260
+ * account can access exactly one workspace, use it — and if it can access
240
261
  * several, require the flag rather than guessing.
241
262
  */
242
- async function resolveMintWorkspace(apiUrl, accessToken, requested) {
243
- if (requested)
244
- return requested;
263
+ async function resolveMintWorkspace(apiUrl, accessToken, requested, io, create = false) {
264
+ if (requested) {
265
+ if (!create)
266
+ return requested;
267
+ // Idempotent from the caller's view: an existing membership just mints.
268
+ const { workspaces } = await listMintWorkspaces(apiUrl, accessToken);
269
+ if (workspaces.some((w) => w.workspace === requested))
270
+ return requested;
271
+ const created = await createWorkspaceRequest(apiUrl, accessToken, requested);
272
+ io.write(`created workspace "${created.name}" — files will get public URLs under ${created.publicBaseUrl}/\n`);
273
+ return created.name;
274
+ }
245
275
  const { workspaces } = await listMintWorkspaces(apiUrl, accessToken);
246
276
  if (workspaces.length === 1)
247
277
  return workspaces[0].workspace;
248
278
  if (workspaces.length === 0) {
249
- throw new UsageError("your account has no workspace access yet — ask an administrator for an invitation");
279
+ if (!io.isTTY) {
280
+ throw new UsageError("your account has no workspace access yet — pass `--workspace <name> --create` to provision one, run `uploads login` interactively, or ask an administrator for an invitation");
281
+ }
282
+ const name = (await io.promptWorkspaceName()).trim();
283
+ if (!name)
284
+ throw new UsageError("workspace creation cancelled");
285
+ const created = await createWorkspaceRequest(apiUrl, accessToken, name);
286
+ io.write(`created workspace "${created.name}" — files will get public URLs under ${created.publicBaseUrl}/\n`);
287
+ return created.name;
250
288
  }
251
289
  const names = workspaces.map((w) => w.workspace).join(", ");
252
290
  throw new UsageError(`multiple workspaces available (${names}); pass --workspace <name>`);
@@ -254,7 +292,7 @@ async function resolveMintWorkspace(apiUrl, accessToken, requested) {
254
292
  export async function runLogin(args, opts, help = false, deviceIo = defaultDeviceIo) {
255
293
  const parsed = parseCommandArgs(args);
256
294
  if (help || parsed.help) {
257
- process.stderr.write(HELP);
295
+ writeCommandHelp(HELP);
258
296
  return 0;
259
297
  }
260
298
  const apiUrl = flagString(parsed.flags, "--api-url") ?? opts.apiUrl ?? "https://api.uploads.sh";
@@ -265,8 +303,12 @@ export async function runLogin(args, opts, help = false, deviceIo = defaultDevic
265
303
  throw new UsageError(`credentials already exist in ${path}; use --force to replace them`);
266
304
  if (process.env.UPLOADS_TOKEN && !force)
267
305
  throw new UsageError("UPLOADS_TOKEN is already set in the environment; unset it or use --force");
306
+ if (flagBool(parsed.flags, "--create") && !flagString(parsed.flags, "--workspace"))
307
+ throw new UsageError("--create requires --workspace <name>");
268
308
  let result;
269
309
  if (hasEnrollmentSource(parsed)) {
310
+ if (flagBool(parsed.flags, "--create"))
311
+ throw new UsageError("--create is device-flow only; enrollment codes are workspace-bound");
270
312
  const code = await resolveEnrollmentCode(parsed);
271
313
  result = await exchangeEnrollment(apiUrl, code);
272
314
  }
@@ -312,6 +354,7 @@ export async function runLogin(args, opts, help = false, deviceIo = defaultDevic
312
354
  const payload = {
313
355
  ok: doctor.ok,
314
356
  configPath: path,
357
+ apiUrl: savedApiUrl,
315
358
  workspace: result.workspace,
316
359
  token: redactToken(result.token),
317
360
  doctor: checked ? doctor : { skipped: true },
@@ -319,8 +362,10 @@ export async function runLogin(args, opts, help = false, deviceIo = defaultDevic
319
362
  if (opts.json)
320
363
  process.stdout.write(JSON.stringify(payload, null, 2) + "\n");
321
364
  else {
322
- process.stdout.write(`saved credentials to ${path}\nworkspace: ${result.workspace}\ntoken: ${redactToken(result.token)}\n`);
365
+ process.stdout.write(`saved credentials to ${path}\napi: ${savedApiUrl}\nworkspace: ${result.workspace}\ntoken: ${redactToken(result.token)}\n`);
323
366
  process[doctor.ok ? "stdout" : "stderr"].write(`doctor: ${checked ? (doctor.ok ? "ok" : `failed — ${doctor.error}`) : "skipped"}\n`);
367
+ if (doctor.ok)
368
+ process.stdout.write("\nusing a coding agent? run `uploads install` to add the uploads skill + MCP server to Claude Code\n");
324
369
  }
325
370
  return doctor.ok ? 0 : 1;
326
371
  }
@@ -3,6 +3,7 @@ import { createMcpServer } from "../mcp/server.js";
3
3
  import { serveStdio } from "../mcp/stdio.js";
4
4
  import { createUploadsMcpTools } from "../mcp/tools.js";
5
5
  import { packageVersion } from "../package-version.js";
6
+ import { writeCommandHelp } from "../cli-style.js";
6
7
  const MCP_HELP = `uploads [globals] mcp
7
8
 
8
9
  Serve the Model Context Protocol (MCP) over stdio for agent clients. Tools
@@ -24,7 +25,7 @@ Examples:
24
25
  `;
25
26
  export async function runMcp(args, opts, help = false) {
26
27
  if (help || parseCommandArgs(args).help) {
27
- process.stderr.write(MCP_HELP);
28
+ writeCommandHelp(MCP_HELP);
28
29
  return 0;
29
30
  }
30
31
  const server = createMcpServer({
@@ -0,0 +1,31 @@
1
+ export interface WhoamiReport {
2
+ signedIn: boolean;
3
+ workspace: string;
4
+ workspaceSource: string;
5
+ workspaceFromToken: string | undefined;
6
+ token: string;
7
+ tokenSource: string;
8
+ /** True when the config file holds UPLOADS_TOKEN. */
9
+ tokenInConfig: boolean;
10
+ apiUrl: string;
11
+ apiUrlSource: string;
12
+ configPath: string;
13
+ configExists: boolean;
14
+ }
15
+ export declare function buildWhoamiReport(opts: {
16
+ envFile?: string;
17
+ token?: string;
18
+ workspace?: string;
19
+ apiUrl?: string;
20
+ }): WhoamiReport;
21
+ export declare function runWhoami(args: string[], opts: {
22
+ json?: boolean;
23
+ envFile?: string;
24
+ token?: string;
25
+ workspace?: string;
26
+ apiUrl?: string;
27
+ }, help?: boolean): Promise<number>;
28
+ export declare function runLogout(args: string[], opts: {
29
+ json?: boolean;
30
+ envFile?: string;
31
+ }, help?: boolean): Promise<number>;