@nearform/fling 0.4.0 → 0.5.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/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.4.0",
1091
+ version: "0.5.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",
@@ -1308,7 +1308,9 @@ var ENV = {
1308
1308
  configDir: "FLING_CONFIG_DIR",
1309
1309
  debug: "FLING_DEBUG",
1310
1310
  /** Where to look for files given by name (the .mcpb extension sets it from its settings). */
1311
- downloadsDir: "FLING_DOWNLOADS_DIR"
1311
+ downloadsDir: "FLING_DOWNLOADS_DIR",
1312
+ /** A server the local MCP server adds to the remembered ones on start (the .mcpb bakes it in). */
1313
+ addServer: "FLING_ADD_SERVER"
1312
1314
  };
1313
1315
  var serverEntrySchema = z9.object({
1314
1316
  /** What the server said about itself at login. A copy this CLI can't read is just refetched. */
@@ -1327,6 +1329,9 @@ function fromEnv(value) {
1327
1329
  const v = value?.trim();
1328
1330
  return v && !v.startsWith("${") ? v : void 0;
1329
1331
  }
1332
+ function serverToAdd(env = process.env) {
1333
+ return fromEnv(env[ENV.addServer]);
1334
+ }
1330
1335
  function resolveConfigDir(env = process.env) {
1331
1336
  return env[ENV.configDir] ?? join(env.HOME ?? homedir(), CONFIG_DIR_NAME);
1332
1337
  }
@@ -3841,11 +3846,132 @@ async function findFiles(opts, find = {}) {
3841
3846
  return { files, searched: roots, complete };
3842
3847
  }
3843
3848
 
3849
+ // src/mcp-servers.ts
3850
+ function serverLabel(url) {
3851
+ const u = new URL(url);
3852
+ return `${u.host}${u.pathname}`.replace(/\/+$/, "");
3853
+ }
3854
+ function describeServer(s) {
3855
+ const facts = [
3856
+ s.instance?.name,
3857
+ s.signedIn ? s.email ? `signed in as ${s.email}` : "signed in" : "signed out",
3858
+ s.current ? "current: the user's usual one" : void 0
3859
+ ].filter(Boolean);
3860
+ return `${serverLabel(s.url)} (${facts.join("; ")})`;
3861
+ }
3862
+ function knownServersOf(config) {
3863
+ return Object.entries(config.servers).map(([url, entry]) => ({
3864
+ url,
3865
+ instance: entry.instance,
3866
+ current: url === config.current,
3867
+ signedIn: Boolean(entry.apiKey),
3868
+ ...entry.apiKey && entry.user?.email ? { email: entry.user.email } : {}
3869
+ }));
3870
+ }
3871
+ var listing = (servers) => servers.map((s) => `- ${describeServer(s)}`).join("\n");
3872
+ var ServerNeededError = class extends CliError {
3873
+ servers;
3874
+ constructor(servers, tool2) {
3875
+ super(
3876
+ `Not run: several Flings are set up on this computer, so ${tool2} needs \`server\`.
3877
+ ${listing(servers)}
3878
+ Unless the conversation already says which Fling to use, ask the user (suggest their usual one, marked current). Then call ${tool2} again with \`server\` (e.g. "${serverLabel(servers[0]?.url ?? "https://fling.example.com")}"), and pass the same \`server\` on every call for the rest of the conversation.`
3879
+ );
3880
+ this.name = "ServerNeededError";
3881
+ this.servers = servers;
3882
+ }
3883
+ };
3884
+ function rememberedServer(config, input) {
3885
+ const servers = knownServersOf(config);
3886
+ const known = servers.length ? `The Flings set up on this computer:
3887
+ ${listing(servers)}` : "No Fling is set up on this computer yet.";
3888
+ let match;
3889
+ try {
3890
+ match = matchServer(Object.keys(config.servers), input, { loose: true });
3891
+ } catch (error) {
3892
+ throw new CliError(error.message, { hint: known });
3893
+ }
3894
+ if (!match.remembered)
3895
+ throw new CliError(`"${input}" is not a Fling set up on this computer`, {
3896
+ hint: `${known}
3897
+ To add one, call fling_login with its address as \`server\`.`
3898
+ });
3899
+ return match.url;
3900
+ }
3901
+ var isPinned = (config) => config.serverUrlSource === "flag" || config.serverUrlSource === "env";
3902
+ async function within(promise, ms, fallback) {
3903
+ let timer;
3904
+ const late = new Promise((r) => {
3905
+ timer = setTimeout(() => r(fallback), ms);
3906
+ });
3907
+ try {
3908
+ return await Promise.race([promise.catch(() => fallback), late]);
3909
+ } finally {
3910
+ clearTimeout(timer);
3911
+ }
3912
+ }
3913
+ var START_FETCH_MS = 5e3;
3914
+ async function addServerOnStart(store, address, fetch2) {
3915
+ if (!address) return;
3916
+ let url;
3917
+ try {
3918
+ url = normalizeServerUrl(address);
3919
+ } catch {
3920
+ return;
3921
+ }
3922
+ if (url in (await store.load()).servers) return;
3923
+ const instance = await within(fetchInstance(url, fetch2), START_FETCH_MS, null);
3924
+ await store.putServer(url, instance ? { instance } : {});
3925
+ }
3926
+ async function knownServers(store, fetch2, pin) {
3927
+ const resolved = await store.resolve({ serverUrl: pin });
3928
+ const config = await store.load();
3929
+ const all = knownServersOf(config);
3930
+ const servers = isPinned(resolved) ? resolved.serverUrl ? [
3931
+ all.find((s) => s.url === resolved.serverUrl) ?? {
3932
+ url: resolved.serverUrl,
3933
+ current: false,
3934
+ signedIn: Boolean(resolved.apiKey)
3935
+ }
3936
+ ] : [] : all;
3937
+ return Promise.all(
3938
+ servers.map(async (s) => {
3939
+ if (s.instance) return s;
3940
+ const instance = await within(fetchInstance(s.url, fetch2), START_FETCH_MS, null);
3941
+ if (instance && s.url in config.servers)
3942
+ await store.putServer(s.url, { instance }, { makeCurrent: false }).catch(() => {
3943
+ });
3944
+ return { ...s, instance };
3945
+ })
3946
+ );
3947
+ }
3948
+ function serverArgDescription(servers) {
3949
+ const [only] = servers;
3950
+ if (servers.length > 1)
3951
+ return `Which Fling: ${servers.map((s) => serverLabel(s.url)).join(", ")} (an address, host or unique part of it). Required while several Flings are set up: the one settled on for this conversation, on every call`;
3952
+ return `Which Fling, by address or host (any set up on this computer; see fling_servers). Omit it to use ${only ? serverLabel(only.url) : "the one this computer is signed in to"}`;
3953
+ }
3954
+ function serverToolNote(servers) {
3955
+ if (servers.length < 2) return "";
3956
+ return ` Pass \`server\`: several Flings are set up (${servers.map((s) => serverLabel(s.url)).join(", ")}), and every call names the one settled on for this conversation.`;
3957
+ }
3958
+ function serversInstructions(servers) {
3959
+ if (servers.length < 2) return void 0;
3960
+ const usual = servers.find((s) => s.current);
3961
+ return [
3962
+ `Several Flings are set up on this computer:
3963
+ ${listing(servers)}`,
3964
+ `Settle which one this conversation uses before anything else: from the user's words ("on ${serverLabel(servers[0]?.url ?? "")}", "on the ${servers[0]?.instance?.name ?? "other"} one") or, if they don't say, by asking them${usual ? ` (suggest ${serverLabel(usual.url)}, their usual one)` : ""}. Say which Fling you are using, and pass it as \`server\` on every call for the rest of the conversation; switch only when the user asks. Calls without \`server\` do not run while several Flings are set up. Each Fling has its own sites, sign-in and sites domain: give the user the links that come back, which name the Fling.`,
3965
+ "This list was read when this server started and may have changed: call fling_servers to see the Flings set up now. To sign in to a Fling, or add one, call fling_login with its address as `server`."
3966
+ ].join("\n\n");
3967
+ }
3968
+
3844
3969
  // src/mcp.ts
3845
3970
  var LOGIN_HINT = `Not logged in to Fling. Call fling_login (it opens the browser to sign in), or ask the user to run \`${NPX_COMMAND} login\` in a terminal (in Claude Code: \`! ${NPX_COMMAND} login\`), then try again.`;
3846
- var NO_SERVER_HINT = `No Fling server is set up on this computer. Ask the user for their Fling's address and to run \`${NPX_COMMAND} login <url>\` in a terminal (in Claude Code: \`! ${NPX_COMMAND} login <url>\`), then try again.`;
3971
+ var NO_SERVER_HINT = `No Fling server is set up on this computer. Ask the user for their Fling's address, then call fling_login with it as \`server\` (or ask them to run \`${NPX_COMMAND} login <url>\` in a terminal; in Claude Code: \`! ${NPX_COMMAND} login <url>\`), then try again.`;
3847
3972
  var mcpLocalInstructions = (w, opts = {}) => [
3848
3973
  `${w.name} publishes static sites (HTML/CSS/JS, images, documents) at ${w.sitesPattern}. This server runs on the user's computer.`,
3974
+ ...[serversInstructions(opts.servers ?? [])].filter((p) => p !== void 0),
3849
3975
  "Content you created in this conversation (an HTML page, a report, a small app): call fling_publish with every file inline (text as utf8, binary as base64). Never pass file paths from your sandbox (e.g. /mnt/user-data/outputs/\u2026, /sessions/\u2026) \u2014 this computer cannot see them \u2014 and never curl or upload from the sandbox.",
3850
3976
  'A file or folder the user has on this computer (a downloaded .html, a .zip, a build folder): call fling_deploy with its path, or just its file name (e.g. "Pitch Deck.html") \u2014 it is looked up in Downloads, Desktop, Documents/Claude and Claude Desktop output folders. Use fling_find_files to see recent candidates when unsure which file the user means.',
3851
3977
  "Confirm the site name with the user before publishing (fling_check_name shows whether it is free and, for a path, the generated name). Names are global, first come first served; when one is taken, offer the suggestions.",
@@ -3855,20 +3981,26 @@ var mcpLocalInstructions = (w, opts = {}) => [
3855
3981
  `Sites are private (only ${w.privateAudience}) by default; only make one public if asked. Use fling_share for a link people who cannot sign in can open. ${opts.comments ? "Always give the user the links that come back: the review link first, as the one to share for feedback, then the site URL." : "Always give the user the URL that comes back."}`,
3856
3982
  ...opts.comments ? [COMMENTS_NOTE(w)] : []
3857
3983
  ].join("\n\n");
3858
- var toolDef = (def) => ({
3859
- title: def.title,
3860
- description: def.description,
3861
- inputSchema: def.inputSchema,
3862
- annotations: def.annotations
3863
- });
3984
+ var onFling = (url, instance) => `
3985
+ Fling: ${serverLabel(url)}${instance ? ` (${instance.name})` : ""}`;
3864
3986
  function createMcpServer(deps = {}) {
3865
3987
  const store = deps.store ?? createConfigStore();
3866
3988
  const fetchImpl = deps.fetch ?? ((input, init) => fetch(input, init));
3867
3989
  const cwd = deps.cwd ?? process.cwd();
3868
3990
  const locate = deps.locate ?? { ...systemLocateOptions(), cwd };
3869
3991
  const waitReady = deps.waitReady ?? createReadyWaiter();
3870
- const wording = mcpWording(deps.instance);
3992
+ const servers = deps.servers ?? [];
3993
+ const several = servers.length > 1;
3994
+ const wording = mcpWording(several ? null : servers[0]?.instance);
3871
3995
  const mcpTools2 = mcpToolsFor(wording);
3996
+ const serverArg = z11.string().min(1).max(500).optional().describe(serverArgDescription(servers));
3997
+ const note = serverToolNote(servers);
3998
+ const serverTool = (def, description = def.description) => ({
3999
+ title: def.title,
4000
+ description: `${description}${note}`,
4001
+ inputSchema: { ...def.inputSchema, server: serverArg },
4002
+ annotations: def.annotations
4003
+ });
3872
4004
  async function edgeReady(url, paths) {
3873
4005
  const r = await waitReady(url, paths).catch(() => ({ ready: false, waitedMs: 0 }));
3874
4006
  return {
@@ -3877,27 +4009,44 @@ function createMcpServer(deps = {}) {
3877
4009
  Not reachable yet at ${url}: ${NOT_READY_NOTE}.`
3878
4010
  };
3879
4011
  }
3880
- async function client() {
3881
- const config = await store.resolve({ serverUrl: deps.server });
4012
+ async function choose(server2, tool2) {
4013
+ if (server2 !== void 0)
4014
+ return store.resolve({ serverUrl: rememberedServer(await store.load(), server2) });
4015
+ const resolved = await store.resolve({ serverUrl: deps.server });
4016
+ if (isPinned(resolved)) return resolved;
4017
+ const config = await store.load();
4018
+ const known = knownServersOf(config);
4019
+ if (known.length > 1) throw new ServerNeededError(known, tool2);
4020
+ const only = known[0];
4021
+ return !resolved.serverUrl && only ? store.resolve({ serverUrl: only.url }) : resolved;
4022
+ }
4023
+ async function target(server2, tool2) {
4024
+ const config = await choose(server2, tool2);
3882
4025
  if (!config.serverUrl) throw new NoServerError("fling");
3883
4026
  if (!config.apiKey) throw new CliError("Not logged in", { exitCode: EXIT_NOT_LOGGED_IN });
3884
- return createApiClient({ serverUrl: config.serverUrl, apiKey: config.apiKey, fetch: fetchImpl });
3885
- }
3886
- async function sitesBase() {
3887
- const config = await store.resolve({ serverUrl: deps.server });
3888
- return sitesBaseOf(await loadInstance(store, config, fetchImpl));
4027
+ const url = config.serverUrl;
4028
+ let instance;
4029
+ return {
4030
+ client: createApiClient({ serverUrl: url, apiKey: config.apiKey, fetch: fetchImpl }),
4031
+ url,
4032
+ config,
4033
+ instance: () => {
4034
+ instance ??= loadInstance(store, config, fetchImpl);
4035
+ return instance;
4036
+ }
4037
+ };
3889
4038
  }
3890
- async function run(op) {
4039
+ async function run(tool2, server2, op) {
3891
4040
  try {
3892
- const { summary, data } = await op(await client());
3893
- return { content: [{ type: "text", text: `${summary}
4041
+ const { summary, data } = await op(await target(server2, tool2));
4042
+ return textResult(`${summary}
3894
4043
 
3895
- ${JSON.stringify(data, null, 2)}` }] };
4044
+ ${JSON.stringify(data, null, 2)}`);
3896
4045
  } catch (error) {
3897
- return { isError: true, content: [{ type: "text", text: describeError(error) }] };
4046
+ return failure(error);
3898
4047
  }
3899
4048
  }
3900
- async function deployFolder(c, dir, opts) {
4049
+ async function deployFolder(t2, dir, opts) {
3901
4050
  let local = await buildManifest(dir);
3902
4051
  local.skipped.dotfiles.unshift(...opts.skippedDotfiles ?? []);
3903
4052
  const skippedNote = describeSkipped(local.skipped).map((l) => `
@@ -3924,14 +4073,14 @@ ${l}.`).join("");
3924
4073
  }
3925
4074
  }
3926
4075
  const result = await deploy(
3927
- { client: c, fetch: fetchImpl },
4076
+ { client: t2.client, fetch: fetchImpl },
3928
4077
  { dir, name: opts.name, manifest: local, spa: opts.spa, public: opts.public }
3929
4078
  ).catch((error) => {
3930
4079
  if (!(error instanceof NameTakenError)) throw error;
3931
4080
  const how = opts.nameGiven ? "" : ` (generated from ${opts.nameFrom ?? "the folder"})`;
3932
4081
  const pick = error.suggestions.length ? ` Free alternatives: ${error.suggestions.join(", ")}.` : "";
3933
4082
  throw new CliError(
3934
- `Not deployed: the site name "${error.siteName}"${how} is taken by someone else.${pick} Ask the user which name to use (one of these or their own), then call ${opts.retryWith} again with \`name\`.`
4083
+ `Not deployed: the site name "${error.siteName}"${how} is taken by someone else on ${serverLabel(t2.url)}.${pick} Ask the user which name to use (one of these or their own), then call ${opts.retryWith} again with \`name\`.`
3935
4084
  );
3936
4085
  });
3937
4086
  const what = result.status === "unchanged" ? "Unchanged \u2014 already live" : result.status === "repointed" ? `Restored version ${result.versionId}` : `Deployed ${result.fileCount} files (uploaded ${result.uploaded})`;
@@ -3942,19 +4091,20 @@ Warnings:
3942
4091
  result.url,
3943
4092
  local.files.map((f) => f.path)
3944
4093
  );
3945
- const instance = deps.instance;
4094
+ const instance = await t2.instance();
3946
4095
  const review = instance && commentsEnabled(instance) ? reviewUrl(instance.appOrigin, result.name) : void 0;
3947
- const listing = generatedIndex ? " \u2014 with a generated index.html listing the HTML pages" : "";
3948
- const head = review ? `${what}${listing}.
4096
+ const listing2 = generatedIndex ? " \u2014 with a generated index.html listing the HTML pages" : "";
4097
+ const head = review ? `${what}${listing2}.
3949
4098
  ${shareForFeedback(review, instance?.name ?? "Fling")}
3950
- Site: ${result.url} (${result.site.visibility})` : `${what}: ${result.url} (${result.site.visibility})${listing}`;
4099
+ Site: ${result.url} (${result.site.visibility})` : `${what}: ${result.url} (${result.site.visibility})${listing2}`;
3951
4100
  return {
3952
4101
  kind: "deployed",
3953
- summary: `${head}${warnings}${skippedNote}${edge.note}
4102
+ summary: `${head}${warnings}${skippedNote}${edge.note}${onFling(t2.url, instance)}
3954
4103
  To check exactly which files the site has, call fling_files with name "${result.name}".`,
3955
4104
  data: {
3956
4105
  status: result.status,
3957
4106
  ready: edge.ready,
4107
+ server: t2.url,
3958
4108
  name: result.name,
3959
4109
  nameGenerated: !opts.nameGiven,
3960
4110
  versionId: result.versionId,
@@ -3970,7 +4120,8 @@ To check exactly which files the site has, call fling_files with name "${result.
3970
4120
  { name: "fling", version: VERSION },
3971
4121
  {
3972
4122
  instructions: mcpLocalInstructions(wording, {
3973
- comments: commentsEnabled(deps.instance)
4123
+ comments: servers.some((s) => commentsEnabled(s.instance)),
4124
+ servers
3974
4125
  })
3975
4126
  }
3976
4127
  );
@@ -3979,7 +4130,7 @@ To check exactly which files the site has, call fling_files with name "${result.
3979
4130
  "fling_deploy",
3980
4131
  {
3981
4132
  title: "Deploy a file or folder from this computer",
3982
- description: `Fling something that is on the user's computer and return its stable URL (${wording.sitesPattern}): a folder of static files (e.g. a build output like dist/), a single .html file (served as the site's index.html), a .zip of a site (extracted; a single top-level folder is unwrapped), or any other single file (.pdf, .svg, .png\u2026, served next to a generated page that shows it). \`path\` can be absolute, relative to the working directory, or just a file name ("Pitch Deck.html"): names are looked up, newest first, in Downloads, Desktop, Documents/Claude and Claude Desktop output folders, and the result says where the file was found. Never pass paths from your own sandbox (/mnt/user-data/\u2026, /sessions/\u2026): for content you created, use fling_publish. Without \`name\`, a name is generated: from the file name for files ("Pitch Deck (1).html" \u2192 pitch-deck), from the project and folder for folders (~/work/rocketpod/docs \u2192 rocketpod-docs). Site names are one global namespace, first come first served \u2014 confirm the name with the user first (fling_check_name with \`path\` shows the generated one). If the name is taken it fails with free suggestions \u2014 ask the user to pick one, then retry with \`name\`. If there is no index.html but there are HTML pages, it does not deploy: ask the user whether to generate an index page listing them (\`listHtml: true\`) or deploy without one (\`listHtml: false\`), then retry. Re-deploying identical content is a no-op. Sites are private (only ${wording.privateAudience}) unless \`public\` is set. ` + NEVER_PUBLISHED,
4133
+ description: `Fling something that is on the user's computer and return its stable URL (${wording.sitesPattern}): a folder of static files (e.g. a build output like dist/), a single .html file (served as the site's index.html), a .zip of a site (extracted; a single top-level folder is unwrapped), or any other single file (.pdf, .svg, .png\u2026, served next to a generated page that shows it). \`path\` can be absolute, relative to the working directory, or just a file name ("Pitch Deck.html"): names are looked up, newest first, in Downloads, Desktop, Documents/Claude and Claude Desktop output folders, and the result says where the file was found. Never pass paths from your own sandbox (/mnt/user-data/\u2026, /sessions/\u2026): for content you created, use fling_publish. Without \`name\`, a name is generated: from the file name for files ("Pitch Deck (1).html" \u2192 pitch-deck), from the project and folder for folders (~/work/rocketpod/docs \u2192 rocketpod-docs). Site names are one global namespace, first come first served \u2014 confirm the name with the user first (fling_check_name with \`path\` shows the generated one). If the name is taken it fails with free suggestions \u2014 ask the user to pick one, then retry with \`name\`. If there is no index.html but there are HTML pages, it does not deploy: ask the user whether to generate an index page listing them (\`listHtml: true\`) or deploy without one (\`listHtml: false\`), then retry. Re-deploying identical content is a no-op. Sites are private (only ${wording.privateAudience}) unless \`public\` is set. ${NEVER_PUBLISHED}${note}`,
3983
4134
  inputSchema: {
3984
4135
  path: z11.string().min(1).describe(
3985
4136
  "File or folder on this computer: absolute, relative to the working directory, or just its name (looked up in Downloads, Desktop and Claude output folders)"
@@ -3989,16 +4140,17 @@ To check exactly which files the site has, call fling_files with name "${result.
3989
4140
  ),
3990
4141
  spa: spa2,
3991
4142
  public: isPublic,
3992
- listHtml: listHtml2
4143
+ listHtml: listHtml2,
4144
+ server: serverArg
3993
4145
  },
3994
4146
  annotations: { destructiveHint: false, idempotentHint: true, openWorldHint: true }
3995
4147
  },
3996
- (args) => run(async (c) => {
4148
+ (args) => run("fling_deploy", args.server, async (t2) => {
3997
4149
  const located = await resolveInput(args.path, locate);
3998
4150
  const prepared = await prepareInput(located.path);
3999
4151
  try {
4000
4152
  const name = args.name !== void 0 ? resolveSiteName(prepared.dir, args.name) : prepared.defaultName;
4001
- const outcome = await deployFolder(c, prepared.dir, {
4153
+ const outcome = await deployFolder(t2, prepared.dir, {
4002
4154
  name,
4003
4155
  nameGiven: args.name !== void 0,
4004
4156
  nameFrom: prepared.kind === "folder" ? "the folder" : "the file name",
@@ -4052,33 +4204,31 @@ ${lines.join("\n")}` : `Nothing found from the last ${args.days ?? 14} days${arg
4052
4204
  searched: searched.map((r) => ({ label: r.label, path: r.path })),
4053
4205
  ...complete ? {} : { truncated: true }
4054
4206
  };
4055
- return {
4056
- content: [{ type: "text", text: `${summary}
4207
+ return textResult(`${summary}
4057
4208
 
4058
- ${JSON.stringify(data, null, 2)}` }]
4059
- };
4209
+ ${JSON.stringify(data, null, 2)}`);
4060
4210
  } catch (error) {
4061
- return { isError: true, content: [{ type: "text", text: describeError(error) }] };
4211
+ return failure(error);
4062
4212
  }
4063
4213
  }
4064
4214
  );
4065
4215
  const publishDef = mcpTools2.publish;
4066
4216
  server.registerTool(
4067
4217
  publishDef.name,
4068
- {
4069
- ...toolDef(publishDef),
4218
+ serverTool(
4219
+ publishDef,
4070
4220
  // This server has no chunked upload tools; big sites go through fling_deploy instead.
4071
- description: publishDef.description.replace(
4221
+ publishDef.description.replace(
4072
4222
  /\(whole site, up to [^;]*; bigger sites: fling_upload_begin \/ fling_upload_file \/ fling_upload_finish\)/,
4073
4223
  "(whole site; for a big site that is already on this computer, use fling_deploy with its folder)"
4074
4224
  )
4075
- },
4076
- (args) => run(async (c) => {
4225
+ ),
4226
+ (args) => run(publishDef.name, args.server, async (t2) => {
4077
4227
  const name = resolveSiteName(".", args.name);
4078
4228
  const tmp = await mkdtemp3(join6(tmpdir3(), "fling-publish-"));
4079
4229
  try {
4080
4230
  const { skippedDotfiles } = await writeInlineFiles(tmp, args.files);
4081
- const outcome = await deployFolder(c, tmp, {
4231
+ return await deployFolder(t2, tmp, {
4082
4232
  skippedDotfiles,
4083
4233
  name,
4084
4234
  nameGiven: true,
@@ -4087,7 +4237,6 @@ ${JSON.stringify(data, null, 2)}` }]
4087
4237
  listHtml: args.listHtml,
4088
4238
  retryWith: "fling_publish"
4089
4239
  });
4090
- return outcome;
4091
4240
  } finally {
4092
4241
  await rm2(tmp, { recursive: true, force: true });
4093
4242
  }
@@ -4096,14 +4245,15 @@ ${JSON.stringify(data, null, 2)}` }]
4096
4245
  server.registerTool(
4097
4246
  "fling_check_name",
4098
4247
  {
4099
- ...toolDef(mcpTools2.checkName),
4100
- description: `${mcpTools2.checkName.description} Pass \`path\` instead of \`name\` to see the name fling_deploy would generate for a file or folder on this computer (same lookup as fling_deploy), so you can confirm it with the user.`,
4248
+ ...serverTool(mcpTools2.checkName),
4249
+ description: `${mcpTools2.checkName.description} Pass \`path\` instead of \`name\` to see the name fling_deploy would generate for a file or folder on this computer (same lookup as fling_deploy), so you can confirm it with the user.${note}`,
4101
4250
  inputSchema: {
4102
4251
  name: z11.string().min(1).max(200).optional().describe("Site name to check"),
4103
- path: z11.string().optional().describe("File or folder whose generated default name to check (path or file name)")
4252
+ path: z11.string().optional().describe("File or folder whose generated default name to check (path or file name)"),
4253
+ server: serverArg
4104
4254
  }
4105
4255
  },
4106
- (args) => run(async (c) => {
4256
+ (args) => run("fling_check_name", args.server, async (t2) => {
4107
4257
  if (args.name === void 0 && args.path === void 0)
4108
4258
  throw new CliError("Pass `name` or `path`");
4109
4259
  let name = args.name;
@@ -4112,8 +4262,8 @@ ${JSON.stringify(data, null, 2)}` }]
4112
4262
  source = await resolveInput(args.path, locate);
4113
4263
  name = defaultNameFor(source.path, source.isDirectory);
4114
4264
  }
4115
- const check2 = await checkName(c, name);
4116
- const base = check2.status === "invalid" ? null : await sitesBase();
4265
+ const check2 = await checkName(t2.client, name);
4266
+ const base = check2.status === "invalid" ? null : sitesBaseOf(await t2.instance());
4117
4267
  const url = base ? siteUrlFor(name, base) : null;
4118
4268
  const suggestions = check2.suggestions.length ? ` Suggestions: ${check2.suggestions.join(", ")}.` : "";
4119
4269
  const summary = {
@@ -4127,59 +4277,77 @@ ${JSON.stringify(data, null, 2)}` }]
4127
4277
  data: {
4128
4278
  ...check2,
4129
4279
  url,
4280
+ server: t2.url,
4130
4281
  ...source ? { generated: true, source: { path: source.path, foundIn: source.foundIn } } : {}
4131
4282
  }
4132
4283
  };
4133
4284
  })
4134
4285
  );
4135
- let pendingLogin;
4286
+ const pendingLogins = /* @__PURE__ */ new Map();
4136
4287
  server.registerTool(
4137
4288
  "fling_login",
4138
4289
  {
4139
4290
  title: "Sign in to Fling",
4140
- description: `Sign the user in to ${wording.name}: opens their browser to sign in with ${wording.signIn} and stores the key on this computer (shared with the fling CLI). Waits up to ~30 seconds; if sign-in is not finished by then, it keeps waiting in the background for up to 5 minutes \u2014 tell the user to finish signing in in the browser, then retry what you were doing. Call it when another tool says the user is not logged in.`,
4141
- inputSchema: {},
4291
+ description: `Sign the user in to ${several ? "a Fling" : wording.name}: opens their browser to sign in with ${wording.signIn} and stores the key on this computer (shared with the fling CLI). Waits up to ~30 seconds; if sign-in is not finished by then, it keeps waiting in the background for up to 5 minutes \u2014 tell the user to finish signing in in the browser, then retry what you were doing. Call it when another tool says the user is not logged in. Pass \`server\` (its address) to sign in to another Fling, including one not set up on this computer yet: it is remembered, and becomes the current one only when none is.` + note,
4292
+ inputSchema: {
4293
+ server: z11.string().min(1).max(500).optional().describe(
4294
+ `The Fling to sign in to: its address (e.g. "https://fling.example.com"), or the host of one set up on this computer. ${several ? `Required while several Flings are set up (${servers.map((s) => serverLabel(s.url)).join(", ")})` : `Omit it to use ${servers[0] ? serverLabel(servers[0].url) : "the one this computer knows"}`}`
4295
+ )
4296
+ },
4142
4297
  annotations: { readOnlyHint: false, openWorldHint: true }
4143
4298
  },
4144
- async () => {
4299
+ async (args) => {
4145
4300
  try {
4146
- const config = await store.resolve({ serverUrl: deps.server });
4147
- const serverUrl = config.serverUrl;
4301
+ let serverUrl;
4302
+ if (args.server !== void 0) {
4303
+ const config2 = await store.load();
4304
+ try {
4305
+ serverUrl = matchServer(Object.keys(config2.servers), args.server, { loose: true }).url;
4306
+ } catch (error) {
4307
+ const known = knownServersOf(config2).map((s) => `- ${describeServer(s)}`);
4308
+ throw new CliError(error.message, {
4309
+ hint: known.length ? `The Flings set up on this computer:
4310
+ ${known.join("\n")}` : ""
4311
+ });
4312
+ }
4313
+ } else {
4314
+ serverUrl = (await choose(void 0, "fling_login")).serverUrl;
4315
+ }
4148
4316
  if (!serverUrl) throw new NoServerError("fling");
4149
- if (config.apiKey && !pendingLogin) {
4317
+ const config = await store.resolve({ serverUrl });
4318
+ let login = pendingLogins.get(serverUrl);
4319
+ if (config.apiKey && !login) {
4150
4320
  const me = await whoami(
4151
- createApiClient({
4152
- serverUrl,
4153
- apiKey: config.apiKey,
4154
- fetch: fetchImpl
4155
- })
4321
+ createApiClient({ serverUrl, apiKey: config.apiKey, fetch: fetchImpl })
4156
4322
  ).catch(() => null);
4157
- if (me) return textResult(`Already signed in to ${config.serverUrl} as ${me.email}.`);
4323
+ if (me) return textResult(`Already signed in to ${serverUrl} as ${me.email}.`);
4158
4324
  }
4159
- if (!pendingLogin) {
4160
- const login = {
4325
+ if (!login) {
4326
+ const started = {
4161
4327
  done: Promise.resolve()
4162
4328
  };
4163
- login.done = loginFlow({
4329
+ started.done = loginFlow({
4164
4330
  serverUrl,
4165
4331
  store,
4166
4332
  log: silentLogger,
4167
4333
  open: deps.open ?? (async (url) => (await import("open")).default(url)),
4168
4334
  fetch: fetchImpl,
4169
4335
  timeoutMs: deps.loginTimeoutMs,
4170
- // Signing in to the extension's server leaves the CLI's current server as it is.
4336
+ // Signing in to another Fling leaves the CLI's current server as it is.
4171
4337
  makeCurrent: !config.current || config.current === serverUrl,
4172
4338
  onAuthUrl: (url) => {
4173
- login.url = url;
4339
+ started.url = url;
4174
4340
  }
4175
4341
  });
4176
- login.done.catch(() => {
4342
+ const key = serverUrl;
4343
+ started.done.catch(() => {
4177
4344
  }).finally(() => {
4178
- if (pendingLogin === login) pendingLogin = void 0;
4345
+ if (pendingLogins.get(key) === started) pendingLogins.delete(key);
4179
4346
  });
4180
- pendingLogin = login;
4347
+ pendingLogins.set(serverUrl, started);
4348
+ login = started;
4181
4349
  }
4182
- const current = pendingLogin;
4350
+ const current = login;
4183
4351
  const waited = await Promise.race([
4184
4352
  current.done.then(
4185
4353
  (r) => ({ ok: true, r }),
@@ -4192,34 +4360,82 @@ ${JSON.stringify(data, null, 2)}` }]
4192
4360
  `Finish signing in in your browser, then try again. (The sign-in page is open; if it did not open, visit: ${current.url ?? "the Fling site"} \u2014 this stays valid for 5 minutes.)`
4193
4361
  );
4194
4362
  if (!waited.ok) throw waited.e;
4195
- return textResult(`Signed in as ${waited.r.user.email} (${waited.r.serverUrl}).`);
4363
+ return textResult(
4364
+ `Signed in as ${waited.r.user.email} (${waited.r.serverUrl}, ${waited.r.instance.name}).`
4365
+ );
4366
+ } catch (error) {
4367
+ return failure(error);
4368
+ }
4369
+ }
4370
+ );
4371
+ server.registerTool(
4372
+ "fling_servers",
4373
+ {
4374
+ title: "List the Flings on this computer",
4375
+ description: `List the Flings set up on this computer, read now: each one's address, name, who is signed in (or "signed out") and which is current, the user's usual one. Call it when unsure which Fling the user means or which exist; the list may have changed since this conversation started. Pass \`use\` to make one the current Fling, the same as running \`${NPX_COMMAND} use <server>\`: it changes the default of the fling CLI too, so only do it when the user asks. It does not replace passing \`server\` to the other tools while several Flings are set up.`,
4376
+ inputSchema: {
4377
+ use: z11.string().min(1).max(500).optional().describe(
4378
+ "Make this Fling current (address, host or unique part of it), like `fling use`"
4379
+ )
4380
+ },
4381
+ annotations: { readOnlyHint: false, idempotentHint: true, openWorldHint: false }
4382
+ },
4383
+ async (args) => {
4384
+ try {
4385
+ let switched = "";
4386
+ if (args.use !== void 0) {
4387
+ const url = rememberedServer(await store.load(), args.use);
4388
+ await store.setCurrent(url);
4389
+ switched = `Now using ${serverLabel(url)} as the current Fling (as \`fling use\` does).
4390
+ `;
4391
+ }
4392
+ const config = await store.load();
4393
+ const known = knownServersOf(config);
4394
+ const pinned = await store.resolve({ serverUrl: deps.server });
4395
+ const summary = known.length ? `${switched}${known.length} Fling(s) set up on this computer:
4396
+ ${known.map((s) => `- ${describeServer(s)}`).join("\n")}` + (isPinned(pinned) && pinned.serverUrl ? `
4397
+ Calls without \`server\` go to ${serverLabel(pinned.serverUrl)} (set by this server's configuration).` : known.length > 1 ? "\nPass `server` on every call: the Fling settled on for this conversation." : "") : NO_SERVER_HINT;
4398
+ const data = {
4399
+ current: config.current ?? null,
4400
+ servers: known.map((s) => ({
4401
+ url: s.url,
4402
+ host: serverLabel(s.url),
4403
+ instance: s.instance?.name ?? null,
4404
+ signedIn: s.signedIn,
4405
+ email: s.email ?? null,
4406
+ current: s.current
4407
+ }))
4408
+ };
4409
+ return textResult(`${summary}
4410
+
4411
+ ${JSON.stringify(data, null, 2)}`);
4196
4412
  } catch (error) {
4197
- return { isError: true, content: [{ type: "text", text: describeError(error) }] };
4413
+ return failure(error);
4198
4414
  }
4199
4415
  }
4200
4416
  );
4201
4417
  const t = mcpTools2;
4202
4418
  server.registerTool(
4203
4419
  t.list.name,
4204
- toolDef(t.list),
4205
- (args) => run(async (c) => {
4206
- const sites = await listSites(c, { archived: args.archived });
4207
- return { summary: `${sites.length} site(s)`, data: { sites } };
4420
+ serverTool(t.list),
4421
+ (args) => run(t.list.name, args.server, async ({ client, url }) => {
4422
+ const sites = await listSites(client, { archived: args.archived });
4423
+ return { summary: `${sites.length} site(s) on ${serverLabel(url)}`, data: { sites } };
4208
4424
  })
4209
4425
  );
4210
4426
  server.registerTool(
4211
4427
  t.info.name,
4212
- toolDef(t.info),
4213
- (args) => run(async (c) => {
4214
- const site = await getSite(c, args.name);
4428
+ serverTool(t.info),
4429
+ (args) => run(t.info.name, args.server, async ({ client }) => {
4430
+ const site = await getSite(client, args.name);
4215
4431
  return { summary: `${site.name}: ${site.url}`, data: site };
4216
4432
  })
4217
4433
  );
4218
4434
  server.registerTool(
4219
4435
  t.files.name,
4220
- toolDef(t.files),
4221
- (args) => run(async (c) => {
4222
- const result = await versionFiles(c, args.name, args.versionId);
4436
+ serverTool(t.files),
4437
+ (args) => run(t.files.name, args.server, async ({ client }) => {
4438
+ const result = await versionFiles(client, args.name, args.versionId);
4223
4439
  return {
4224
4440
  summary: describeVersionFiles(result.name, result, {
4225
4441
  current: result.current,
@@ -4231,15 +4447,15 @@ ${JSON.stringify(data, null, 2)}` }]
4231
4447
  );
4232
4448
  server.registerTool(
4233
4449
  t.share.name,
4234
- toolDef(t.share),
4235
- (args) => run(async (c) => {
4450
+ serverTool(t.share),
4451
+ (args) => run(t.share.name, args.server, async ({ client }) => {
4236
4452
  if (args.refresh && args.revoke) throw new CliError("Use either refresh or revoke, not both");
4237
- const result = await share(c, args.name, args);
4453
+ const result = await share(client, args.name, args);
4238
4454
  if (result.action === "revoked")
4239
4455
  return { summary: `Share link for ${args.name} revoked`, data: result };
4240
4456
  let edge = { ready: true, note: "" };
4241
4457
  if (result.action !== "existing") {
4242
- const files = await versionFiles(c, args.name).catch(() => null);
4458
+ const files = await versionFiles(client, args.name).catch(() => null);
4243
4459
  edge = await edgeReady(result.shareUrl, files?.files.map((f) => f.path) ?? []);
4244
4460
  }
4245
4461
  return {
@@ -4250,78 +4466,86 @@ ${JSON.stringify(data, null, 2)}` }]
4250
4466
  );
4251
4467
  server.registerTool(
4252
4468
  t.setVisibility.name,
4253
- toolDef(t.setVisibility),
4254
- (args) => run(async (c) => {
4255
- const site = await setVisibility(c, args.name, args.visibility);
4469
+ serverTool(t.setVisibility),
4470
+ (args) => run(t.setVisibility.name, args.server, async ({ client }) => {
4471
+ const site = await setVisibility(client, args.name, args.visibility);
4256
4472
  return { summary: `${site.name} is now ${site.visibility}: ${site.url}`, data: site };
4257
4473
  })
4258
4474
  );
4259
4475
  server.registerTool(
4260
4476
  t.archive.name,
4261
- toolDef(t.archive),
4262
- (args) => run(async (c) => {
4263
- const site = await archiveSite(c, args.name, { unarchive: args.unarchive });
4477
+ serverTool(t.archive),
4478
+ (args) => run(t.archive.name, args.server, async ({ client }) => {
4479
+ const site = await archiveSite(client, args.name, { unarchive: args.unarchive });
4264
4480
  return { summary: `${site.name} ${args.unarchive ? "restored" : "archived"}`, data: site };
4265
4481
  })
4266
4482
  );
4267
4483
  server.registerTool(
4268
4484
  t.delete.name,
4269
- toolDef(t.delete),
4270
- (args) => run(async (c) => {
4271
- const result = await deleteSite(c, args.name);
4272
- return { summary: `${args.name} deleted`, data: result };
4485
+ serverTool(t.delete),
4486
+ (args) => run(t.delete.name, args.server, async ({ client, url }) => {
4487
+ const result = await deleteSite(client, args.name);
4488
+ return { summary: `${args.name} deleted from ${serverLabel(url)}`, data: result };
4273
4489
  })
4274
4490
  );
4275
4491
  server.registerTool(
4276
4492
  t.rollback.name,
4277
- toolDef(t.rollback),
4278
- (args) => run(async (c) => {
4279
- const site = await rollback(c, args.name, args.versionId);
4493
+ serverTool(t.rollback),
4494
+ (args) => run(t.rollback.name, args.server, async ({ client }) => {
4495
+ const site = await rollback(client, args.name, args.versionId);
4280
4496
  return {
4281
4497
  summary: `${site.name} now serves version ${site.currentVersionId}: ${site.url}`,
4282
4498
  data: site
4283
4499
  };
4284
4500
  })
4285
4501
  );
4286
- if (deps.instance && commentsEnabled(deps.instance)) {
4287
- const appOrigin = deps.instance.appOrigin;
4288
- const review = (name) => reviewUrl(appOrigin, name);
4502
+ if (servers.some((s) => commentsEnabled(s.instance))) {
4503
+ async function reviewOn(target2) {
4504
+ const instance = await target2.instance();
4505
+ if (instance?.capabilities.comments === false)
4506
+ throw new CliError(`${instance.name} (${serverLabel(target2.url)}) has comments turned off`);
4507
+ const appOrigin = instance?.appOrigin ?? target2.url;
4508
+ return {
4509
+ review: (name) => reviewUrl(appOrigin, name),
4510
+ fling: onFling(target2.url, instance)
4511
+ };
4512
+ }
4289
4513
  server.registerTool(
4290
4514
  t.commentsList.name,
4291
- toolDef(t.commentsList),
4292
- (args) => run(async (c) => {
4293
- const list = await listComments(c, args.name, { path: args.path });
4515
+ serverTool(t.commentsList),
4516
+ (args) => run(t.commentsList.name, args.server, async (target2) => {
4517
+ const { review, fling } = await reviewOn(target2);
4518
+ const list = await listComments(target2.client, args.name, { path: args.path });
4294
4519
  const reviewPage2 = review(list.site.name);
4295
4520
  return {
4296
- summary: describeComments(list, {
4297
- includeResolved: args.includeResolved,
4298
- reviewUrl: reviewPage2
4299
- }),
4521
+ summary: `${describeComments(list, { includeResolved: args.includeResolved, reviewUrl: reviewPage2 })}${fling}`,
4300
4522
  data: { ...list, reviewUrl: reviewPage2 }
4301
4523
  };
4302
4524
  })
4303
4525
  );
4304
4526
  server.registerTool(
4305
4527
  t.commentAdd.name,
4306
- toolDef(t.commentAdd),
4307
- (args) => run(async (c) => {
4308
- const comment = await addComment(c, args.name, {
4528
+ serverTool(t.commentAdd),
4529
+ (args) => run(t.commentAdd.name, args.server, async (target2) => {
4530
+ const { review, fling } = await reviewOn(target2);
4531
+ const comment = await addComment(target2.client, args.name, {
4309
4532
  body: args.body,
4310
4533
  path: args.path,
4311
4534
  parentId: args.replyTo
4312
4535
  });
4313
4536
  const what = comment.parentId ? `Replied to thread ${comment.parentId}` : "Comment added";
4314
4537
  return {
4315
- summary: `${what} on ${args.name} (${comment.path}, id ${comment.id}). Review page: ${review(args.name)}`,
4538
+ summary: `${what} on ${args.name} (${comment.path}, id ${comment.id}). Review page: ${review(args.name)}${fling}`,
4316
4539
  data: comment
4317
4540
  };
4318
4541
  })
4319
4542
  );
4320
4543
  server.registerTool(
4321
4544
  t.commentResolve.name,
4322
- toolDef(t.commentResolve),
4323
- (args) => run(async (c) => {
4324
- const comment = await resolveComment(c, args.name, args.id, !args.reopen);
4545
+ serverTool(t.commentResolve),
4546
+ (args) => run(t.commentResolve.name, args.server, async (target2) => {
4547
+ await reviewOn(target2);
4548
+ const comment = await resolveComment(target2.client, args.name, args.id, !args.reopen);
4325
4549
  return {
4326
4550
  summary: `Thread ${comment.id} on ${args.name} ${args.reopen ? "reopened" : "resolved"}`,
4327
4551
  data: comment
@@ -4330,22 +4554,24 @@ ${JSON.stringify(data, null, 2)}` }]
4330
4554
  );
4331
4555
  server.registerTool(
4332
4556
  t.commentDelete.name,
4333
- toolDef(t.commentDelete),
4334
- (args) => run(async (c) => {
4335
- const result = await deleteComment(c, args.name, args.id);
4557
+ serverTool(t.commentDelete),
4558
+ (args) => run(t.commentDelete.name, args.server, async (target2) => {
4559
+ await reviewOn(target2);
4560
+ const result = await deleteComment(target2.client, args.name, args.id);
4336
4561
  return { summary: `Comment ${args.id} on ${args.name} deleted`, data: result };
4337
4562
  })
4338
4563
  );
4339
4564
  }
4340
4565
  server.registerTool(
4341
4566
  t.whoami.name,
4342
- toolDef(t.whoami),
4343
- () => run(async (c) => {
4344
- const me = await whoami(c);
4345
- const base = await sitesBase();
4567
+ serverTool(t.whoami),
4568
+ (args) => run(t.whoami.name, args.server, async (target2) => {
4569
+ const me = await whoami(target2.client);
4570
+ const instance = await target2.instance();
4571
+ const base = sitesBaseOf(instance);
4346
4572
  return {
4347
- summary: `Signed in as ${me.email} (sites are served at ${sitesPattern(base)})`,
4348
- data: me
4573
+ summary: `Signed in to ${serverLabel(target2.url)}${instance ? ` (${instance.name})` : ""} as ${me.email} (sites are served at ${sitesPattern(base)})`,
4574
+ data: { ...me, server: target2.url }
4349
4575
  };
4350
4576
  })
4351
4577
  );
@@ -4382,6 +4608,25 @@ async function writeInlineFiles(dir, files) {
4382
4608
  if (secretFiles.length > 0) throw new SecretFilesError(secretFiles);
4383
4609
  return { skippedDotfiles };
4384
4610
  }
4611
+ function failure(error) {
4612
+ if (error instanceof ServerNeededError) {
4613
+ const data = {
4614
+ status: "needs_server",
4615
+ servers: error.servers.map((s) => ({
4616
+ url: s.url,
4617
+ host: serverLabel(s.url),
4618
+ instance: s.instance?.name ?? null,
4619
+ signedIn: s.signedIn,
4620
+ email: s.email ?? null,
4621
+ current: s.current
4622
+ }))
4623
+ };
4624
+ return textResult(`${error.message}
4625
+
4626
+ ${JSON.stringify(data, null, 2)}`);
4627
+ }
4628
+ return { isError: true, content: [{ type: "text", text: describeError(error) }] };
4629
+ }
4385
4630
  function describeError(error) {
4386
4631
  if (error instanceof NoServerError) return NO_SERVER_HINT;
4387
4632
  if (error instanceof CliError && error.exitCode === EXIT_NOT_LOGGED_IN) return LOGIN_HINT;
@@ -4393,8 +4638,11 @@ ${error.hint}` : error.message;
4393
4638
  }
4394
4639
  async function runMcpServer(deps = {}) {
4395
4640
  const store = deps.store ?? createConfigStore();
4396
- const instance = deps.instance ?? await store.resolve({ serverUrl: deps.server }).then((config) => loadInstance(store, config, deps.fetch ?? ((i, init) => fetch(i, init)))).catch(() => null);
4397
- const server = createMcpServer({ ...deps, store, instance });
4641
+ const fetchImpl = deps.fetch ?? ((i, init) => fetch(i, init));
4642
+ await addServerOnStart(store, serverToAdd(), fetchImpl).catch(() => {
4643
+ });
4644
+ const servers = deps.servers ?? await knownServers(store, fetchImpl, deps.server).catch(() => []);
4645
+ const server = createMcpServer({ ...deps, store, servers });
4398
4646
  await server.connect(new StdioServerTransport());
4399
4647
  }
4400
4648