@nearform/fling 0.3.0 → 0.4.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/CHANGELOG.md CHANGED
@@ -2,6 +2,23 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.4.0
6
+
7
+ - **Several Flings.** The CLI remembers every Fling you sign in to, each with its own API key, and
8
+ uses the current one. `fling login <url>` and `fling setup <url>` add a Fling (or refresh its
9
+ login) and make it current without signing you out of the others. `fling use` lists them, with
10
+ each one's name and your email; `fling use <url>` switches (a full address, its host or the start
11
+ of it). `fling logout <url>` signs out of another Fling and forgets it; a bare `fling logout`
12
+ still removes the current Fling's key and keeps the Fling. `fling whoami` shows which Fling is
13
+ current.
14
+ - `--server` and `FLING_URL` pick a remembered Fling for one command and use its stored key (an
15
+ address you haven't signed in to has no key; `FLING_API_KEY` still overrides). The local MCP
16
+ server uses the current Fling unless `FLING_URL` (the Claude Desktop extension's **Fling server**)
17
+ names one, and signing in from it makes that Fling current only when no other is.
18
+ - Upgrading: `~/.fling/config.json` from 0.3 is read as-is, as one remembered Fling that is
19
+ current. It is rewritten in the new shape (`current` plus `servers`, keyed by address) the next
20
+ time the CLI saves it, so an older CLI can no longer read it after that.
21
+
5
22
  ## 0.3.0
6
23
 
7
24
  **Breaking: no default server.** The CLI works with any Fling deployment, so it no longer has one
package/README.md CHANGED
@@ -35,6 +35,7 @@ This does everything a new machine needs:
35
35
  1. **Signs you in.** The CLI reads the server's `/.well-known/fling` (its name, where its sites
36
36
  live, how people sign in) and remembers it. Your browser opens for sign-in and an API key is
37
37
  stored in `~/.fling/config.json` (mode 0600). This is skipped if you're already signed in.
38
+ The server becomes your current one; logins to other Flings are kept.
38
39
  2. **Adds Fling to Claude Code.** If `claude` is on your PATH, setup installs the Fling plugin
39
40
  (skill + MCP server) from the `nearform/fling` marketplace, or updates it if it's already
40
41
  installed. `--scope project|local` installs it for this project only (default `user`).
@@ -45,8 +46,25 @@ On a terminal it asks before steps 2 and 3. With `--yes` it does them without as
45
46
 
46
47
  To sign in only, use `fling login <url>` (the URL is remembered: later `fling login` needs none).
47
48
  There is no default server: until you log in, commands say to run `fling login <url>`.
48
- `fling whoami` shows who you are, and `fling logout` removes the key (and keeps the server). For CI,
49
- set `FLING_URL` and `FLING_API_KEY` instead.
49
+ `fling whoami` shows who you are and which server is current. For CI, set `FLING_URL` and
50
+ `FLING_API_KEY` instead.
51
+
52
+ ### Several Flings
53
+
54
+ The CLI remembers every Fling you sign in to, each with its own key, and uses the current one.
55
+ `fling login <url>` or `fling setup <url>` adds a Fling (or signs in to it again) and makes it
56
+ current; the others stay signed in.
57
+
58
+ ```sh
59
+ fling use # list them: * marks the current one, with its name and your email
60
+ fling use other.example.com # switch: a full address, its host, or the start of it
61
+ fling --server other.example.com ls # one command against another Fling, with its own key
62
+ ```
63
+
64
+ `FLING_URL` picks a Fling the same way for every command run with it, and `FLING_API_KEY`
65
+ overrides the stored key. `fling logout` removes the current Fling's key and keeps it current (a
66
+ bare `fling login` signs in again); `fling logout <url>` signs out of another Fling and forgets
67
+ it.
50
68
 
51
69
  ## Deploy
52
70
 
@@ -186,7 +204,7 @@ feedback (`reviewUrl` in `--json`); reviewers sign in to use it.
186
204
  | `fling uncomment <name> <id> [--yes]` | delete a comment (a thread with its replies): your own, or any on your site |
187
205
 
188
206
  Global options: `--json` prints machine-readable output, and `--server <url>` points one command at
189
- another server. Exit codes are `0` ok, `1` error, `2` not logged in and `3` forbidden.
207
+ another server (a remembered one brings its key; see [Several Flings](#several-flings)). Exit codes are `0` ok, `1` error, `2` not logged in and `3` forbidden.
190
208
 
191
209
  Environment: `FLING_API_KEY`, `FLING_URL`, `FLING_CONFIG_DIR`, `FLING_DEBUG`, `FLING_DOWNLOADS_DIR`
192
210
  (where the MCP server looks for files given by name first), `FLING_NO_WAIT=1` (after a deploy or a new share
@@ -210,7 +228,8 @@ Or install it by hand in Claude Code:
210
228
 
211
229
  Log in once with `fling login <url>` (or `npx @nearform/fling login <url>` if it is not installed).
212
230
  The MCP server reuses that login, and its tool descriptions name your Fling and where its sites
213
- live.
231
+ live. It re-reads the config on every call, so after `fling use <url>` it talks to the new current
232
+ Fling without a restart.
214
233
 
215
234
  The MCP tools:
216
235
 
@@ -246,8 +265,10 @@ without uploading anything from Claude's sandbox.
246
265
  3. Optional settings: **Fling server** (filled in with the Fling you downloaded it from; empty means
247
266
  the one the CLI is logged in to) and **Downloads folder** (default `~/Downloads`).
248
267
  4. Ask Claude to "fling Pitch Deck.html from my Downloads". If you're not signed in, Claude calls
249
- `fling_login` and your browser opens. The extension and the CLI share the login in
250
- `~/.fling/config.json`.
268
+ `fling_login` and your browser opens. The extension and the CLI share the logins in
269
+ `~/.fling/config.json`. With **Fling server** set, the extension always uses that Fling (and its
270
+ stored key), whichever one the CLI is using; signing in from the extension doesn't change the
271
+ CLI's current Fling.
251
272
 
252
273
  It needs Node.js 20 or later, which Claude Desktop provides, on macOS or Windows.
253
274
 
package/dist/cli.js CHANGED
@@ -1088,7 +1088,7 @@ function exitCodeFor(error) {
1088
1088
  // package.json
1089
1089
  var package_default = {
1090
1090
  name: "@nearform/fling",
1091
- version: "0.3.0",
1091
+ version: "0.4.0",
1092
1092
  license: "MIT",
1093
1093
  description: "Fling a folder of static files at a Fling server and get a link back. CLI + MCP server.",
1094
1094
  type: "module",
@@ -1310,16 +1310,15 @@ var ENV = {
1310
1310
  /** Where to look for files given by name (the .mcpb extension sets it from its settings). */
1311
1311
  downloadsDir: "FLING_DOWNLOADS_DIR"
1312
1312
  };
1313
- var cliConfigSchema = z9.object({
1314
- serverUrl: z9.string().url().optional(),
1315
- /** What `serverUrl` said about itself at login. A copy a newer CLI can't read is just refetched. */
1313
+ var serverEntrySchema = z9.object({
1314
+ /** What the server said about itself at login. A copy this CLI can't read is just refetched. */
1316
1315
  instance: InstanceMetadataSchema.optional().catch(void 0),
1317
- apiKey: z9.string().min(1).optional(),
1316
+ apiKey: z9.string().min(1).optional().catch(void 0),
1318
1317
  user: z9.object({
1319
1318
  email: z9.string().optional(),
1320
1319
  name: z9.string().optional(),
1321
1320
  userSlug: z9.string().optional()
1322
- }).optional()
1321
+ }).optional().catch(void 0)
1323
1322
  });
1324
1323
  function systemLocateOptions(env = process.env) {
1325
1324
  return { cwd: process.cwd(), home: homedir(), platform: process.platform, env };
@@ -1331,6 +1330,37 @@ function fromEnv(value) {
1331
1330
  function resolveConfigDir(env = process.env) {
1332
1331
  return env[ENV.configDir] ?? join(env.HOME ?? homedir(), CONFIG_DIR_NAME);
1333
1332
  }
1333
+ function safeNormalize(input) {
1334
+ if (typeof input !== "string") return void 0;
1335
+ try {
1336
+ return normalizeServerUrl(input);
1337
+ } catch {
1338
+ return void 0;
1339
+ }
1340
+ }
1341
+ function isObject(value) {
1342
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1343
+ }
1344
+ function parseConfig(json) {
1345
+ if (!isObject(json)) throw new Error("expected a JSON object");
1346
+ const servers = {};
1347
+ let current;
1348
+ if ("servers" in json) {
1349
+ if (isObject(json.servers)) {
1350
+ for (const [key, value] of Object.entries(json.servers)) {
1351
+ const url = safeNormalize(key);
1352
+ const entry = isObject(value) ? serverEntrySchema.safeParse(value) : void 0;
1353
+ if (url && entry?.success) servers[url] = { ...servers[url], ...entry.data };
1354
+ }
1355
+ }
1356
+ current = safeNormalize(json.current);
1357
+ } else {
1358
+ current = safeNormalize(json.serverUrl);
1359
+ if (current) servers[current] = serverEntrySchema.parse(json);
1360
+ }
1361
+ if (current && !servers[current]) servers[current] = {};
1362
+ return current ? { current, servers } : { servers };
1363
+ }
1334
1364
  function createConfigStore(options = {}) {
1335
1365
  const env = options.env ?? process.env;
1336
1366
  const dir = options.dir ?? resolveConfigDir(env);
@@ -1340,7 +1370,7 @@ function createConfigStore(options = {}) {
1340
1370
  try {
1341
1371
  raw = await readFile(file, "utf8");
1342
1372
  } catch (error) {
1343
- if (error.code === "ENOENT") return {};
1373
+ if (error.code === "ENOENT") return { servers: {} };
1344
1374
  throw error;
1345
1375
  }
1346
1376
  let json;
@@ -1349,57 +1379,115 @@ function createConfigStore(options = {}) {
1349
1379
  } catch {
1350
1380
  throw new Error(`Config file ${file} is not valid JSON`);
1351
1381
  }
1352
- const parsed = cliConfigSchema.safeParse(json);
1353
- if (!parsed.success) {
1354
- throw new Error(`Config file ${file} is invalid: ${parsed.error.issues[0]?.message}`);
1382
+ try {
1383
+ return parseConfig(json);
1384
+ } catch (error) {
1385
+ throw new Error(`Config file ${file} is invalid: ${error.message}`);
1355
1386
  }
1356
- return parsed.data;
1357
1387
  }
1358
1388
  async function save(config) {
1359
1389
  await mkdir(dir, { recursive: true, mode: 448 });
1360
1390
  await chmod(dir, 448).catch(() => {
1361
1391
  });
1362
- await writeFile(file, `${JSON.stringify(config, null, 2)}
1392
+ const body = { current: config.current, servers: config.servers };
1393
+ await writeFile(file, `${JSON.stringify(body, null, 2)}
1363
1394
  `, { mode: 384 });
1364
1395
  await chmod(file, 384);
1365
1396
  }
1397
+ async function edit(change) {
1398
+ const config = await load();
1399
+ change(config);
1400
+ await save(config);
1401
+ return config;
1402
+ }
1366
1403
  return {
1367
1404
  dir,
1368
1405
  file,
1369
1406
  load,
1370
1407
  save,
1371
- async update(patch) {
1372
- const next = { ...await load(), ...patch };
1373
- await save(next);
1374
- return next;
1408
+ putServer(serverUrl, patch, opts = {}) {
1409
+ const url = normalizeServerUrl(serverUrl);
1410
+ return edit((config) => {
1411
+ config.servers[url] = { ...config.servers[url], ...patch };
1412
+ if (opts.makeCurrent ?? !config.current) config.current = url;
1413
+ });
1414
+ },
1415
+ async setCurrent(serverUrl) {
1416
+ const url = normalizeServerUrl(serverUrl);
1417
+ await edit((config) => {
1418
+ config.servers[url] ??= {};
1419
+ config.current = url;
1420
+ });
1375
1421
  },
1376
- async clearCredentials() {
1377
- const { serverUrl, instance } = await load();
1378
- await save({ serverUrl, instance });
1422
+ async clearCredentials(serverUrl) {
1423
+ const url = normalizeServerUrl(serverUrl);
1424
+ await edit((config) => {
1425
+ const entry = config.servers[url];
1426
+ if (entry) config.servers[url] = entry.instance ? { instance: entry.instance } : {};
1427
+ });
1428
+ },
1429
+ async removeServer(serverUrl) {
1430
+ const url = normalizeServerUrl(serverUrl);
1431
+ await edit((config) => {
1432
+ delete config.servers[url];
1433
+ if (config.current === url) config.current = void 0;
1434
+ });
1379
1435
  },
1380
1436
  async resolve(overrides = {}) {
1381
1437
  const config = await load();
1382
1438
  const envUrl = fromEnv(env[ENV.url]);
1383
1439
  const envKey = fromEnv(env[ENV.apiKey]);
1384
- const [rawUrl, serverUrlSource] = overrides.serverUrl ? [overrides.serverUrl, "flag"] : envUrl ? [envUrl, "env"] : config.serverUrl ? [config.serverUrl, "config"] : [void 0, "none"];
1385
- const serverUrl = rawUrl === void 0 ? void 0 : normalizeServerUrl(rawUrl);
1386
- const [apiKey, apiKeySource] = envKey ? [envKey, "env"] : config.apiKey ? [config.apiKey, "config"] : [void 0, "none"];
1387
- const stored = config.serverUrl && normalizeServerUrl(config.serverUrl);
1440
+ const [rawUrl, serverUrlSource] = overrides.serverUrl ? [overrides.serverUrl, "flag"] : envUrl ? [envUrl, "env"] : config.current ? [config.current, "config"] : [void 0, "none"];
1441
+ const serverUrl = rawUrl === void 0 ? void 0 : matchServer(Object.keys(config.servers), rawUrl, { loose: true }).url;
1442
+ const entry = serverUrl === void 0 ? void 0 : config.servers[serverUrl];
1443
+ const [apiKey, apiKeySource] = envKey ? [envKey, "env"] : entry?.apiKey ? [entry.apiKey, "config"] : [void 0, "none"];
1388
1444
  return {
1389
1445
  serverUrl,
1390
1446
  serverUrlSource,
1391
- instance: serverUrl && stored === serverUrl ? config.instance : void 0,
1447
+ current: config.current,
1448
+ remembered: entry !== void 0,
1449
+ instance: entry?.instance,
1392
1450
  apiKey,
1393
1451
  apiKeySource,
1394
- user: config.user,
1452
+ user: entry?.user,
1395
1453
  debug: Boolean(env[ENV.debug])
1396
1454
  };
1397
1455
  }
1398
1456
  };
1399
1457
  }
1458
+ var HAS_SCHEME = /^[a-z][a-z0-9+.-]*:\/\//i;
1459
+ function matchServer(remembered, input, options = {}) {
1460
+ const trimmed = input.trim();
1461
+ const normalized = safeNormalize(trimmed);
1462
+ if (normalized && remembered.includes(normalized)) return { url: normalized, remembered: true };
1463
+ if (!HAS_SCHEME.test(trimmed)) {
1464
+ const bare = trimmed.replace(/\/+$/, "").toLowerCase();
1465
+ const parts = remembered.map((url) => {
1466
+ const u = new URL(url);
1467
+ return { url, host: u.host, hostname: u.hostname, address: `${u.host}${u.pathname}` };
1468
+ });
1469
+ const tiers = [
1470
+ parts.filter((p) => p.host === bare || p.address === bare),
1471
+ options.loose && bare ? parts.filter((p) => p.address.startsWith(bare) || p.hostname.endsWith(`.${bare}`)) : []
1472
+ ];
1473
+ for (const found of tiers) {
1474
+ const [only] = found;
1475
+ if (only && found.length === 1) return { url: only.url, remembered: true };
1476
+ if (found.length > 1)
1477
+ throw new CliError(`"${input}" matches more than one server you have signed in to`, {
1478
+ hint: `Use the full address: ${found.map((p) => p.url).join(", ")}`
1479
+ });
1480
+ }
1481
+ }
1482
+ if (!normalized)
1483
+ throw new CliError(`"${input}" is not a server address`, {
1484
+ hint: "Use your Fling's address, e.g. https://fling.example.com."
1485
+ });
1486
+ return { url: normalized, remembered: false };
1487
+ }
1400
1488
  function normalizeServerUrl(input) {
1401
1489
  const trimmed = input.trim();
1402
- const withScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`;
1490
+ const withScheme = HAS_SCHEME.test(trimmed) ? trimmed : `https://${trimmed}`;
1403
1491
  const url = new URL(withScheme);
1404
1492
  if (url.protocol !== "https:" && url.protocol !== "http:") {
1405
1493
  throw new Error(`Not an http(s) URL: ${input}`);
@@ -1437,7 +1525,8 @@ async function loadInstance(store, config, fetch2) {
1437
1525
  if (!config.serverUrl) return null;
1438
1526
  try {
1439
1527
  const instance = await fetchInstance(config.serverUrl, fetch2);
1440
- if (config.serverUrlSource === "config") await store.update({ instance });
1528
+ if (config.remembered)
1529
+ await store.putServer(config.serverUrl, { instance }, { makeCurrent: false });
1441
1530
  return instance;
1442
1531
  } catch {
1443
1532
  return null;
@@ -1469,12 +1558,15 @@ async function loginFlow(options) {
1469
1558
  const { key } = await callback.result;
1470
1559
  const client = createApiClient({ serverUrl, apiKey: key, fetch: options.fetch });
1471
1560
  const user = await client.request("GET", "/api/me", { schema: MeSchema });
1472
- await store.update({
1561
+ await store.putServer(
1473
1562
  serverUrl,
1474
- instance,
1475
- apiKey: key,
1476
- user: { email: user.email, name: user.name, userSlug: user.userSlug }
1477
- });
1563
+ {
1564
+ instance,
1565
+ apiKey: key,
1566
+ user: { email: user.email, name: user.name, userSlug: user.userSlug }
1567
+ },
1568
+ { makeCurrent: options.makeCurrent ?? true }
1569
+ );
1478
1570
  const keyPrefix = redactKey(key);
1479
1571
  log.success(`Signed in as ${user.email}`);
1480
1572
  log.hint(`Server: ${serverUrl}`);
@@ -1950,45 +2042,120 @@ function commentsEnabled(instance) {
1950
2042
  }
1951
2043
 
1952
2044
  // src/commands/auth.ts
1953
- function loginServer(ctx, url) {
2045
+ async function loginServer(ctx, url) {
1954
2046
  if (url === void 0) return requireServer(ctx);
1955
- try {
1956
- return normalizeServerUrl(url);
1957
- } catch {
1958
- throw new CliError(`"${url}" is not a server address`, {
1959
- hint: `Run \`${ctx.binName} login <url>\` with your Fling's address, e.g. https://fling.example.com.`
1960
- });
1961
- }
2047
+ const { servers } = await ctx.store.load();
2048
+ return matchServer(Object.keys(servers), url).url;
1962
2049
  }
1963
2050
  async function runLogin(ctx, url) {
1964
2051
  const result = await loginFlow({
1965
- serverUrl: loginServer(ctx, url),
2052
+ serverUrl: await loginServer(ctx, url),
1966
2053
  store: ctx.store,
1967
2054
  log: ctx.log,
1968
2055
  open: ctx.open,
1969
- fetch: ctx.fetch
2056
+ fetch: ctx.fetch,
2057
+ makeCurrent: true
1970
2058
  });
1971
2059
  if (ctx.json) ctx.out.data(result, () => "");
1972
2060
  }
1973
- async function runLogout(ctx) {
2061
+ async function runUse(ctx, url) {
2062
+ const config = await ctx.store.load();
2063
+ const urls = Object.keys(config.servers);
2064
+ const overridden = ctx.config.serverUrlSource === "env" || ctx.config.serverUrlSource === "flag" ? ctx.config.serverUrl : void 0;
2065
+ const overrideHint = () => {
2066
+ if (overridden)
2067
+ ctx.log.hint(
2068
+ `${ctx.config.serverUrlSource === "env" ? "FLING_URL" : "--server"} is set: commands run with it use ${overridden}.`
2069
+ );
2070
+ };
2071
+ if (url === void 0) {
2072
+ const servers = urls.map((server) => {
2073
+ const entry2 = config.servers[server] ?? {};
2074
+ return {
2075
+ url: server,
2076
+ current: server === config.current,
2077
+ instance: entry2.instance?.name ?? null,
2078
+ email: entry2.apiKey ? entry2.user?.email ?? null : null,
2079
+ signedIn: Boolean(entry2.apiKey)
2080
+ };
2081
+ });
2082
+ if (servers.length === 0 && !ctx.json) {
2083
+ ctx.log.info("No Fling servers yet");
2084
+ ctx.log.hint(`Run \`${ctx.binName} login <url>\` with your Fling's address.`);
2085
+ return;
2086
+ }
2087
+ const width = Math.max(...servers.map((s) => s.url.length));
2088
+ ctx.out.data(
2089
+ { current: config.current ?? null, servers },
2090
+ () => servers.map(
2091
+ (s) => [
2092
+ s.current ? chalk5.bold("*") : " ",
2093
+ s.current ? chalk5.bold(s.url.padEnd(width)) : s.url.padEnd(width),
2094
+ s.instance ?? chalk5.dim("(unknown)"),
2095
+ s.signedIn ? s.email ?? chalk5.dim("(signed in)") : chalk5.dim("(signed out)")
2096
+ ].join(" ")
2097
+ ).join("\n")
2098
+ );
2099
+ if (!ctx.json) {
2100
+ ctx.log.hint(`* = current. Switch with \`${ctx.binName} use <url>\`.`);
2101
+ overrideHint();
2102
+ }
2103
+ return;
2104
+ }
2105
+ const match = matchServer(urls, url, { loose: true });
2106
+ if (!match.remembered)
2107
+ throw new CliError(`Not signed in to ${match.url} on this computer`, {
2108
+ hint: `Run \`${ctx.binName} login ${match.url}\` to add it, or \`${ctx.binName} use\` to list your servers.`
2109
+ });
2110
+ await ctx.store.setCurrent(match.url);
2111
+ const entry = config.servers[match.url] ?? {};
2112
+ const name = entry.instance?.name;
2113
+ const email = entry.apiKey ? entry.user?.email : void 0;
2114
+ ctx.out.data(
2115
+ { current: match.url, instance: name ?? null, email: email ?? null },
2116
+ () => `Now using ${chalk5.bold(match.url)}${name ? chalk5.dim(` (${name})`) : ""}`
2117
+ );
2118
+ if (!ctx.json) {
2119
+ if (entry.apiKey) ctx.log.hint(`Signed in as ${email ?? "an unknown user"}.`);
2120
+ else ctx.log.hint(`Signed out there: run \`${ctx.binName} login\` to sign in.`);
2121
+ overrideHint();
2122
+ }
2123
+ }
2124
+ async function runLogout(ctx, url) {
1974
2125
  const config = await ctx.store.load();
1975
- if (!config.apiKey) {
1976
- ctx.log.info("Not logged in \u2014 nothing to do");
2126
+ const target = url === void 0 ? ctx.config.serverUrl : matchServer(Object.keys(config.servers), url, { loose: true }).url;
2127
+ const entry = target === void 0 ? void 0 : config.servers[target];
2128
+ if (target === void 0 || entry === void 0) {
2129
+ ctx.log.info(`Not logged in${target ? ` to ${target}` : ""} \u2014 nothing to do`);
1977
2130
  return;
1978
2131
  }
1979
- await ctx.store.clearCredentials();
1980
- ctx.log.success("Logged out; API key removed from this machine");
1981
- if (ctx.config.serverUrl)
1982
- ctx.log.hint(`Revoke it on the server under Connections at ${ctx.config.serverUrl}`);
2132
+ if (target === config.current) {
2133
+ if (!entry.apiKey) {
2134
+ ctx.log.info("Not logged in \u2014 nothing to do");
2135
+ return;
2136
+ }
2137
+ await ctx.store.clearCredentials(target);
2138
+ ctx.log.success("Logged out; API key removed from this machine");
2139
+ } else {
2140
+ await ctx.store.removeServer(target);
2141
+ ctx.log.success(
2142
+ entry.apiKey ? `Logged out of ${target}; API key and server removed from this machine` : `Removed ${target} from this machine`
2143
+ );
2144
+ }
2145
+ if (entry.apiKey) ctx.log.hint(`Revoke it on the server under Connections at ${target}`);
1983
2146
  }
1984
2147
  async function runWhoami(ctx) {
1985
2148
  const client = requireClient(ctx);
1986
2149
  const me = await whoami(client);
1987
2150
  const instance = await loadInstance(ctx.store, ctx.config, ctx.fetch);
2151
+ const { serverUrl, current } = ctx.config;
2152
+ const others = Object.keys((await ctx.store.load()).servers).filter((s) => s !== serverUrl);
1988
2153
  ctx.out.data(
1989
2154
  {
1990
2155
  ...me,
1991
- serverUrl: ctx.config.serverUrl,
2156
+ serverUrl,
2157
+ serverUrlSource: ctx.config.serverUrlSource,
2158
+ current: current ?? null,
1992
2159
  instance: instance?.name ?? null,
1993
2160
  apiKey: redactKey(ctx.config.apiKey),
1994
2161
  apiKeySource: ctx.config.apiKeySource
@@ -1996,8 +2163,15 @@ async function runWhoami(ctx) {
1996
2163
  () => [
1997
2164
  `${chalk5.bold("User:")} ${me.name} <${me.email}>`,
1998
2165
  `${chalk5.bold("Sites:")} ${sitesPattern(sitesBaseOf(instance))} ${chalk5.dim("(names are first come, first served)")}`,
1999
- `${chalk5.bold("Server:")} ${ctx.config.serverUrl}${instance ? chalk5.dim(` (${instance.name})`) : ""}`,
2000
- `${chalk5.bold("API key:")} ${redactKey(ctx.config.apiKey)} ${chalk5.dim(`(from ${ctx.config.apiKeySource})`)}`
2166
+ `${chalk5.bold("Server:")} ${serverUrl}${instance ? chalk5.dim(` (${instance.name})`) : ""} ${chalk5.dim(
2167
+ serverUrl === current ? "(current)" : `(from ${ctx.config.serverUrlSource === "env" ? "FLING_URL" : "--server"}; current: ${current ?? "none"})`
2168
+ )}`,
2169
+ `${chalk5.bold("API key:")} ${redactKey(ctx.config.apiKey)} ${chalk5.dim(`(from ${ctx.config.apiKeySource})`)}`,
2170
+ ...others.length > 0 ? [
2171
+ chalk5.dim(
2172
+ `${others.length} other server${others.length === 1 ? "" : "s"} on this computer: \`${ctx.binName} use\` lists them`
2173
+ )
2174
+ ] : []
2001
2175
  ].join("\n")
2002
2176
  );
2003
2177
  }
@@ -3119,12 +3293,13 @@ async function runSetup(baseCtx, opts, deps = {}) {
3119
3293
  `);
3120
3294
  intro(chalk8.bold(" fling setup "), { output: process.stderr });
3121
3295
  }
3296
+ const login = await stepLogin(ctx, opts.url);
3122
3297
  const results = {
3123
- login: await stepLogin(ctx, opts.url),
3298
+ login: login.result,
3124
3299
  claudeCode: "skipped",
3125
3300
  claudeDesktop: "skipped"
3126
3301
  };
3127
- const after = results.login === "done" ? { ...ctx, config: await ctx.store.resolve() } : ctx;
3302
+ const after = login.serverUrl ? { ...ctx, config: await ctx.store.resolve({ serverUrl: login.serverUrl }) } : ctx;
3128
3303
  results.claudeCode = await stepClaudeCode(after, run, scope, ask, fancy);
3129
3304
  results.claudeDesktop = await stepClaudeDesktop(after, deps, ask, fancy);
3130
3305
  const failed = Object.values(results).includes("failed");
@@ -3137,10 +3312,22 @@ async function runSetup(baseCtx, opts, deps = {}) {
3137
3312
  if (failed) process.exitCode = 1;
3138
3313
  }
3139
3314
  async function stepLogin(ctx, url) {
3140
- const sameServer = url === void 0 || safeNormalize(url) === ctx.config.serverUrl;
3141
- if (ctx.config.apiKey && sameServer) {
3315
+ let serverUrl;
3316
+ let target;
3317
+ try {
3318
+ serverUrl = await loginServer(ctx, url);
3319
+ target = { ...ctx, config: await ctx.store.resolve({ serverUrl }) };
3320
+ } catch (error) {
3321
+ ctx.log.error(error instanceof Error ? error.message : String(error));
3322
+ return { result: "failed" };
3323
+ }
3324
+ return { result: await signIn(target, serverUrl), serverUrl };
3325
+ }
3326
+ async function signIn(ctx, serverUrl) {
3327
+ if (ctx.config.apiKey) {
3142
3328
  try {
3143
3329
  const me = await whoami(requireClient(ctx));
3330
+ if (ctx.config.remembered) await ctx.store.setCurrent(serverUrl);
3144
3331
  ctx.log.success(`Signed in as ${chalk8.bold(me.name)} ${chalk8.dim(`<${me.email}>`)}`);
3145
3332
  return "already";
3146
3333
  } catch (error) {
@@ -3152,20 +3339,13 @@ async function stepLogin(ctx, url) {
3152
3339
  }
3153
3340
  }
3154
3341
  try {
3155
- await runLogin({ ...ctx, json: false }, url);
3342
+ await runLogin({ ...ctx, json: false }, serverUrl);
3156
3343
  return "done";
3157
3344
  } catch (error) {
3158
3345
  ctx.log.error(error instanceof Error ? error.message : String(error));
3159
3346
  return "failed";
3160
3347
  }
3161
3348
  }
3162
- function safeNormalize(url) {
3163
- try {
3164
- return normalizeServerUrl(url);
3165
- } catch {
3166
- return void 0;
3167
- }
3168
- }
3169
3349
  async function stepClaudeCode(ctx, run, scope, ask, fancy) {
3170
3350
  const status = await claudeCodeStatus(run);
3171
3351
  if (status.claude === "missing") {
@@ -3987,6 +4167,8 @@ ${JSON.stringify(data, null, 2)}` }]
3987
4167
  open: deps.open ?? (async (url) => (await import("open")).default(url)),
3988
4168
  fetch: fetchImpl,
3989
4169
  timeoutMs: deps.loginTimeoutMs,
4170
+ // Signing in to the extension's server leaves the CLI's current server as it is.
4171
+ makeCurrent: !config.current || config.current === serverUrl,
3990
4172
  onAuthUrl: (url) => {
3991
4173
  login.url = url;
3992
4174
  }
@@ -4266,7 +4448,10 @@ function routeOptionsFor(program2) {
4266
4448
 
4267
4449
  // src/cli.ts
4268
4450
  var program = new Command();
4269
- program.name(BIN_NAME).description("Fling a folder of static files at Fling and get a link back").version(VERSION).option("--server <url>", `server URL (default: $${ENV.url}, else the one you logged in to)`).option("--json", "print JSON instead of human output", false).showHelpAfterError().configureHelp({
4451
+ program.name(BIN_NAME).description("Fling a folder of static files at Fling and get a link back").version(VERSION).option(
4452
+ "--server <url>",
4453
+ `server for this command: a URL, or the host of one you signed in to (default: $${ENV.url}, else the current one)`
4454
+ ).option("--json", "print JSON instead of human output", false).showHelpAfterError().configureHelp({
4270
4455
  sortSubcommands: false,
4271
4456
  styleTitle: (title) => chalk10.bold(brand.primary(title)),
4272
4457
  styleCommandText: (text2) => chalk10.bold(text2),
@@ -4282,6 +4467,7 @@ program.name(BIN_NAME).description("Fling a folder of static files at Fling and
4282
4467
  ${chalk10.bold(brand.primary("Examples:"))}
4283
4468
  $ ${BIN_NAME} login https://fling.example.com sign in to your Fling (once; there is no default)
4284
4469
  $ ${BIN_NAME} setup sign in and add Fling to Claude Code / Claude Desktop
4470
+ $ ${BIN_NAME} use list the Flings you signed in to; \`use <url>\` switches
4285
4471
  $ ${BIN_NAME} deploy ./dist --name my-app \u2192 https://my-app.<your sites domain>
4286
4472
  $ ${BIN_NAME} ./dist the same: an existing path deploys
4287
4473
  $ ${BIN_NAME} ./dist --yes no prompts (CI): use the generated name, fail if it is taken
@@ -4342,22 +4528,27 @@ program.command("deploy [path]").helpGroup("Deploy:").description(
4342
4528
  )
4343
4529
  );
4344
4530
  program.command("setup [url]").helpGroup("Setup:").description(
4345
- "sign in (to the Fling at url, the first time), then add the Fling plugin to Claude Code and the extension to Claude Desktop"
4531
+ "sign in (to the Fling at url, which becomes current), then add the Fling plugin to Claude Code and the extension to Claude Desktop"
4346
4532
  ).option("-y, --yes", "do every step without asking").option("-s, --scope <scope>", "Claude Code plugin scope: user, project or local", "user").action(
4347
4533
  action(
4348
4534
  (ctx, url, opts) => runSetup(ctx, { ...opts, url })
4349
4535
  )
4350
4536
  );
4351
4537
  program.command("login [url]").helpGroup("Setup:").description(
4352
- "sign in through the browser to the Fling at url (remembered; later logins can omit it) and store an API key"
4538
+ "sign in through the browser to the Fling at url and store an API key; it becomes the current server (default: the current one)"
4353
4539
  ).action(
4354
4540
  action(async (ctx, url) => {
4355
4541
  await runLogin(ctx, url);
4356
4542
  ctx.log.hint(`Next: \`${BIN_NAME} setup\` adds Fling to Claude Code and Claude Desktop.`);
4357
4543
  })
4358
4544
  );
4359
- program.command("logout").helpGroup("Setup:").description("remove the stored API key").action(action((ctx) => runLogout(ctx)));
4360
- program.command("whoami").helpGroup("Setup:").description("show the signed-in user").action(action((ctx) => runWhoami(ctx)));
4545
+ program.command("use [url]").helpGroup("Setup:").description(
4546
+ "switch to another Fling you signed in to (a URL or its host); without url, list them"
4547
+ ).action(action((ctx, url) => runUse(ctx, url)));
4548
+ program.command("logout [url]").helpGroup("Setup:").description(
4549
+ "remove a server's API key (default: the current server's, which stays current); any other server is forgotten"
4550
+ ).action(action((ctx, url) => runLogout(ctx, url)));
4551
+ program.command("whoami").helpGroup("Setup:").description("show the signed-in user and the current server").action(action((ctx) => runWhoami(ctx)));
4361
4552
  program.commandsGroup("Setup:").helpCommand("help [command]", "show help for a command");
4362
4553
  program.command("ls").helpGroup("Sites:").alias("list").description("list your sites").option("--archived", "list archived sites").action(action((ctx, opts) => runList(ctx, opts)));
4363
4554
  program.command("info <name>").helpGroup("Sites:").description("show a site").action(action((ctx, name) => runInfo(ctx, name)));