@mercury-fw/cli 0.34.0 → 0.35.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/src/render.ts CHANGED
@@ -5,7 +5,8 @@
5
5
  * from `template/` imported as text, so they are bundled with the CLI; the
6
6
  * config, the manifest, the env example, the compose file, the persona and the
7
7
  * README are generated from the selection. Channels and plugins are always
8
- * written in catalog order, whatever order they were chosen in.
8
+ * written in catalog order, whatever order they were chosen in; the auth
9
+ * provider, when there is one, after the channels.
9
10
  */
10
11
  import { DEFAULT_PERSONA_TONE } from "@mercury-fw/core";
11
12
  import { CATALOG, type CatalogEntry, type EnvVar } from "./catalog.ts";
@@ -25,6 +26,8 @@ export type RenderInput = {
25
26
  role: string;
26
27
  channels: string[];
27
28
  plugins: string[];
29
+ /** The HTTP channel's auth provider: required with it, refused without it. */
30
+ auth?: string;
28
31
  versions: Record<string, string>;
29
32
  };
30
33
 
@@ -47,8 +50,9 @@ const CORE_ENV: EnvVar[] = [
47
50
  ];
48
51
 
49
52
  const CONFIG_HEADER = `/**
50
- * This app's composition: the tool plugins and channels it runs, how the lists
51
- * its plugins hand over read, and the assistant's persona. The entrypoints
53
+ * This app's composition: the tool plugins and channels it runs, the auth
54
+ * provider when a channel needs one, how the lists its plugins hand over read,
55
+ * and the assistant's persona. The entrypoints
52
56
  * (\`src/index.ts\`, \`src/repl.ts\`) hand this config to \`composeMercury\`.
53
57
  *
54
58
  * Every tool plugin and channel declared here is active.
@@ -61,18 +65,19 @@ export function renderApp(input: RenderInput): Map<string, string> {
61
65
  validate(input);
62
66
  const channels = selected("channel", input.channels);
63
67
  const tools = selected("tool", input.plugins);
68
+ const auth = selected("auth", input.auth === undefined ? [] : [input.auth])[0];
64
69
  const assistantName = input.assistantName.trim();
65
70
 
66
71
  return new Map([
67
72
  [".dockerignore", dockerignore],
68
- [".env.example", renderEnv(channels, tools)],
73
+ [".env.example", renderEnv(channels, auth, tools)],
69
74
  [".gitignore", gitignore],
70
75
  ["Dockerfile", dockerfile],
71
- ["README.md", renderReadme(input.name, channels, tools)],
76
+ ["README.md", renderReadme(input.name, channels, auth, tools)],
72
77
  ["docker-compose.yml", renderCompose(input.name, channels.some((c) => c.id === "http"))],
73
78
  ["markdown.d.ts", markdownDts],
74
- ["mercury.config.ts", renderConfig(channels, tools)],
75
- ["package.json", renderPackageJson(input.name, channels, tools, input.versions)],
79
+ ["mercury.config.ts", renderConfig(channels, auth, tools)],
80
+ ["package.json", renderPackageJson(input.name, channels, auth, tools, input.versions)],
76
81
  ["persona/identity.md", `You are ${assistantName}, ${input.role.trim().replace(/\.+$/, "")}.\n`],
77
82
  // A replacer function, not a string: in a replacement string "$&" and the
78
83
  // like are patterns, and the name must land verbatim.
@@ -107,30 +112,44 @@ function validate(input: RenderInput): void {
107
112
  if (/[\r\n]/.test(input.assistantName) || /[\r\n]/.test(input.role)) {
108
113
  throw new Error("The assistant name and role must each be one line");
109
114
  }
110
- const selection = selectionError(input.channels, input.plugins);
115
+ const selection = selectionError(input.channels, input.plugins, input.auth) ?? pairingError(input.channels, input.auth);
111
116
  if (selection !== undefined) {
112
117
  throw new Error(selection);
113
118
  }
114
119
  }
115
120
 
116
- /** Names the first channel or plugin id the catalog doesn't have, with the
117
- * valid ones, or undefined when all are known. Shared with the command, which
118
- * checks the flags before asking anything. */
119
- export function selectionError(channels: string[], plugins: string[]): string | undefined {
120
- for (const [kind, ids] of [
121
- ["channel", channels],
122
- ["tool", plugins],
121
+ /** The ids of the catalog entries of `kind`. */
122
+ const idsOf = (kind: CatalogEntry["kind"]): string[] => CATALOG.filter((e) => e.kind === kind).map((e) => e.id);
123
+
124
+ /** Names the first channel, plugin or auth provider id the catalog doesn't
125
+ * have, with the valid ones, or undefined when all are known. Shared with the
126
+ * command, which checks the flags before asking anything. */
127
+ export function selectionError(channels: string[], plugins: string[], auth?: string): string | undefined {
128
+ for (const [kind, ids, label] of [
129
+ ["channel", channels, "channel"],
130
+ ["tool", plugins, "plugin"],
131
+ ["auth", auth === undefined ? [] : [auth], "auth provider"],
123
132
  ] as const) {
124
- const valid = CATALOG.filter((e) => e.kind === kind).map((e) => e.id);
133
+ const valid = idsOf(kind);
125
134
  const unknown = ids.find((id) => !valid.includes(id));
126
135
  if (unknown !== undefined) {
127
- const label = kind === "channel" ? "channel" : "plugin";
128
136
  return `Unknown ${label} "${unknown}" (valid: ${valid.join(", ")})`;
129
137
  }
130
138
  }
131
139
  return undefined;
132
140
  }
133
141
 
142
+ /** What's wrong with pairing the HTTP channel and the auth provider: it
143
+ * doesn't start without one, and one without it has nothing to authenticate.
144
+ * Undefined when they go together. Shared with the command, which checks it
145
+ * before writing when nothing will ask (`--yes`). */
146
+ export function pairingError(channels: string[], auth?: string): string | undefined {
147
+ const http = channels.includes("http");
148
+ if (http && auth === undefined) return `The http channel needs an auth provider (valid: ${idsOf("auth").join(", ")})`;
149
+ if (!http && auth !== undefined) return `The auth provider "${auth}" goes with the http channel, which isn't chosen`;
150
+ return undefined;
151
+ }
152
+
134
153
  /** The catalog entries of `kind` among `ids`, deduplicated, in catalog order. */
135
154
  function selected(kind: CatalogEntry["kind"], ids: string[]): CatalogEntry[] {
136
155
  return CATALOG.filter((e) => e.kind === kind && ids.includes(e.id));
@@ -139,7 +158,7 @@ function selected(kind: CatalogEntry["kind"], ids: string[]): CatalogEntry[] {
139
158
  /** `mercury.config.ts`: imports, the formatter helpers of the plugins that have
140
159
  * any, then the config with each plugin (wrapped in the formatter when it has
141
160
  * starting rules) and channel. */
142
- function renderConfig(channels: CatalogEntry[], tools: CatalogEntry[]): string {
161
+ function renderConfig(channels: CatalogEntry[], auth: CatalogEntry | undefined, tools: CatalogEntry[]): string {
143
162
  const withRules = tools.filter((t) => t.formatter);
144
163
  const imports = ['import { defineMercuryConfig } from "@mercury-fw/core";'];
145
164
  if (withRules.length > 0) {
@@ -149,7 +168,7 @@ function renderConfig(channels: CatalogEntry[], tools: CatalogEntry[]): string {
149
168
  const names = t.formatter ? `${t.exportName}, type ${t.formatter.displaysType}` : t.exportName;
150
169
  imports.push(`import { ${names} } from "${t.package}";`);
151
170
  }
152
- for (const c of channels) {
171
+ for (const c of [...channels, ...(auth ? [auth] : [])]) {
153
172
  imports.push(`import { ${c.exportName} } from "${c.package}";`);
154
173
  }
155
174
  imports.push('import identity from "./persona/identity.md" with { type: "text" };');
@@ -171,6 +190,7 @@ function renderConfig(channels: CatalogEntry[], tools: CatalogEntry[]): string {
171
190
  " persona: { identity, tone },",
172
191
  plugins,
173
192
  channelList,
193
+ ...(auth ? [` auth: ${auth.exportName},`] : []),
174
194
  "});",
175
195
  "",
176
196
  ].join("\n");
@@ -197,10 +217,11 @@ function renderPluginEntry(t: CatalogEntry): string {
197
217
  function renderPackageJson(
198
218
  name: string,
199
219
  channels: CatalogEntry[],
220
+ auth: CatalogEntry | undefined,
200
221
  tools: CatalogEntry[],
201
222
  versions: Record<string, string>,
202
223
  ): string {
203
- const packages = ["@mercury-fw/core", ...channels.map((c) => c.package), ...tools.map((t) => t.package)];
224
+ const packages = ["@mercury-fw/core", ...[...channels, ...(auth ? [auth] : []), ...tools].map((e) => e.package)];
204
225
  if (tools.some((t) => t.formatter)) {
205
226
  packages.push("@mercury-fw/formatter");
206
227
  }
@@ -236,10 +257,10 @@ function renderPackageJson(
236
257
 
237
258
  /** The env example: the core's variables, then a section per chosen entry
238
259
  * that reads any variable. */
239
- function renderEnv(channels: CatalogEntry[], tools: CatalogEntry[]): string {
260
+ function renderEnv(channels: CatalogEntry[], auth: CatalogEntry | undefined, tools: CatalogEntry[]): string {
240
261
  const block = (vars: EnvVar[]) => vars.map((v) => `# ${v.comment}\n${v.name}=${v.value ?? ""}`).join("\n");
241
262
  const sections = [block(CORE_ENV)];
242
- for (const entry of [...channels, ...tools]) {
263
+ for (const entry of [...channels, ...(auth ? [auth] : []), ...tools]) {
243
264
  if (entry.env.length > 0) {
244
265
  sections.push(`# --- ${entry.id}\n${block(entry.env)}`);
245
266
  }
@@ -254,7 +275,7 @@ function renderEnv(channels: CatalogEntry[], tools: CatalogEntry[]): string {
254
275
  function renderCompose(name: string, hasHttp: boolean): string {
255
276
  const httpPort = hasHttp
256
277
  ? [
257
- " # The HTTP surface, on the host: no authentication, keep it off the public network.",
278
+ " # The HTTP surface, on the host: every route but its OpenAPI document needs a token.",
258
279
  " ports:",
259
280
  ' - "${HTTP_SURFACE_PORT:-4100}:${HTTP_SURFACE_PORT:-4100}"',
260
281
  ]
@@ -296,14 +317,14 @@ function renderCompose(name: string, hasHttp: boolean): string {
296
317
  }
297
318
 
298
319
  /** The app's README: what it was scaffolded with and how to run it. */
299
- function renderReadme(name: string, channels: CatalogEntry[], tools: CatalogEntry[]): string {
320
+ function renderReadme(name: string, channels: CatalogEntry[], auth: CatalogEntry | undefined, tools: CatalogEntry[]): string {
300
321
  const list = (entries: CatalogEntry[]) => (entries.length > 0 ? entries.map((e) => e.id).join(", ") : "none");
301
322
  return `# ${name}
302
323
 
303
324
  A Mercury app, scaffolded by \`mfw create\`.
304
325
 
305
326
  - Channels: ${list(channels)}
306
- - Tool plugins: ${list(tools)}
327
+ ${auth ? `- Auth: ${auth.id}\n` : ""}- Tool plugins: ${list(tools)}
307
328
 
308
329
  ## Layout
309
330
 
@@ -330,17 +351,17 @@ mfw repl
330
351
  \`\`\`
331
352
 
332
353
  \`bun install\` here gives your editor, \`bun run typecheck\` and the app's own \`mfw\` (the one a global \`mfw\` runs inside the app) the packages (tool plugins download their CLI binary as they install); the image installs its own copy when it builds. \`mfw start\` builds the image and starts the app with Qdrant in the background, \`mfw repl\` opens a terminal conversation with the assistant. \`mfw --help\` lists the rest: stopping and restarting, logs, a shell in the container, the wiki and the memory, and resetting them.
333
- ${renderHttpSection(channels)}${CREDENTIALS_SECTION}`;
354
+ ${renderHttpSection(channels, auth)}${CREDENTIALS_SECTION}`;
334
355
  }
335
356
 
336
- /** The README section on the HTTP surface, for an app with the HTTP channel;
337
- * nothing without it. */
338
- function renderHttpSection(channels: CatalogEntry[]): string {
339
- if (!channels.some((c) => c.id === "http")) return "";
357
+ /** The README section on the HTTP surface, for an app with the HTTP channel
358
+ * (and so its auth provider); nothing without it. */
359
+ function renderHttpSection(channels: CatalogEntry[], auth: CatalogEntry | undefined): string {
360
+ if (!channels.some((c) => c.id === "http") || auth === undefined) return "";
340
361
  return `
341
362
  ## HTTP surface
342
363
 
343
- The HTTP channel listens on \`http://<host>:4100\`, the port in \`HTTP_SURFACE_PORT\` (the compose file publishes whatever port it holds on the host). It has no authentication: whoever reaches the port can talk to the assistant, so keep it on a network you trust.
364
+ The HTTP channel listens on \`http://<host>:4100\`, the port in \`HTTP_SURFACE_PORT\` (the compose file publishes whatever port it holds on the host). Every route but its OpenAPI document needs \`Authorization: Bearer <token>\`, and the auth provider, \`${auth.id}\` (\`${auth.package}\`), decides who the token belongs to: its variables are in the env example. If the provider can't load, the channel doesn't start and the logs say why.
344
365
  `;
345
366
  }
346
367
 
package/src/versions.ts CHANGED
@@ -50,14 +50,22 @@ async function latestVersion(
50
50
  }
51
51
 
52
52
  /** Maps the framework packages to the CLI's version and each of `packages`
53
- * (plugins and channels) to the registry's `latest`. Rejects naming the
54
- * package the registry doesn't have, or saying the registry can't be reached. */
53
+ * (plugins, channels, auth providers) to the registry's `latest`. A package
54
+ * in `local` (packed tarballs, `--local-packages`) takes that version instead,
55
+ * a framework one included, and the registry isn't asked for it. Rejects
56
+ * naming the package the registry doesn't have, or saying the registry can't
57
+ * be reached. */
55
58
  export async function appVersions(
56
59
  packages: string[],
57
- opts: { registry: string; fetchFn?: typeof fetch },
60
+ opts: { registry: string; fetchFn?: typeof fetch; local?: Record<string, string> },
58
61
  ): Promise<Record<string, string>> {
59
- const versions: Record<string, string> = Object.fromEntries(FRAMEWORK_PACKAGES.map((p) => [p, cliVersion()]));
60
- const latest = await Promise.all(packages.map(async (name) => [name, await latestVersion(name, opts)] as const));
62
+ const local = opts.local ?? {};
63
+ const versions: Record<string, string> = Object.fromEntries(FRAMEWORK_PACKAGES.map((p) => [p, local[p] ?? cliVersion()]));
64
+ for (const name of packages) {
65
+ if (local[name] !== undefined) versions[name] = local[name];
66
+ }
67
+ const remote = packages.filter((name) => local[name] === undefined);
68
+ const latest = await Promise.all(remote.map(async (name) => [name, await latestVersion(name, opts)] as const));
61
69
  for (const [name, version] of latest) {
62
70
  versions[name] = version;
63
71
  }
package/src/wizard.ts CHANGED
@@ -17,6 +17,8 @@ export type Answers = {
17
17
  role: string;
18
18
  channels: string[];
19
19
  plugins: string[];
20
+ /** The HTTP channel's auth provider, there exactly when that channel is. */
21
+ auth?: string;
20
22
  gitRemote?: string;
21
23
  };
22
24
 
@@ -65,8 +67,24 @@ export async function askAnswers(args: CreateArgs, dir: string): Promise<Answers
65
67
  validate: (v) => (v === undefined || v === "" || v.trim() ? undefined : "The role can't be empty"),
66
68
  });
67
69
  if (p.isCancel(role)) return cancelled();
68
- const channels = await pick("channel", "Channels (space to select, none is fine: the REPL always works)", args.channels ?? []);
70
+ // A --auth with no --channels starts from the HTTP channel it goes with.
71
+ const initialChannels = args.channels ?? (args.auth !== undefined ? ["http"] : []);
72
+ const channels = await pick("channel", "Channels (space to select, none is fine: the REPL always works)", initialChannels);
69
73
  if (channels === undefined) return cancelled();
74
+ // The HTTP channel doesn't start without an auth provider, so choosing it
75
+ // means choosing one; without it there's nothing to ask.
76
+ let auth: string | undefined;
77
+ if (channels.includes("http")) {
78
+ const picked = await p.select({
79
+ message: "Auth provider for the HTTP channel (who may call it)",
80
+ options: CATALOG.filter((e) => e.kind === "auth").map((e) => ({ value: e.id, label: e.id, hint: e.package })),
81
+ ...(args.auth !== undefined ? { initialValue: args.auth } : {}),
82
+ });
83
+ if (p.isCancel(picked)) return cancelled();
84
+ auth = picked;
85
+ } else if (args.auth !== undefined) {
86
+ p.log.warn(`--auth ${args.auth} ignored: it goes with the http channel, which isn't chosen`);
87
+ }
70
88
  const plugins = await pick("tool", "Tool plugins (space to select)", args.plugins ?? []);
71
89
  if (plugins === undefined) return cancelled();
72
90
  // Asked only when there will be a repository and the flag didn't say.
@@ -85,6 +103,7 @@ export async function askAnswers(args: CreateArgs, dir: string): Promise<Answers
85
103
  `App: ${name}`,
86
104
  `Assistant: You are ${assistantName}, ${role}.`,
87
105
  `Channels: ${channels.length > 0 ? channels.join(", ") : "none"}`,
106
+ ...(auth !== undefined ? [`Auth: ${auth}`] : []),
88
107
  `Tool plugins: ${plugins.length > 0 ? plugins.join(", ") : "none"}`,
89
108
  ...(args.git ? [`Origin: ${gitRemote ?? "none"}`] : []),
90
109
  ].join("\n"),
@@ -92,7 +111,15 @@ export async function askAnswers(args: CreateArgs, dir: string): Promise<Answers
92
111
  );
93
112
  const ok = await p.confirm({ message: `Create it in ${dir}?` });
94
113
  if (p.isCancel(ok) || !ok) return cancelled();
95
- return { name, assistantName, role, channels, plugins, ...(gitRemote !== undefined ? { gitRemote } : {}) };
114
+ return {
115
+ name,
116
+ assistantName,
117
+ role,
118
+ channels,
119
+ plugins,
120
+ ...(auth !== undefined ? { auth } : {}),
121
+ ...(gitRemote !== undefined ? { gitRemote } : {}),
122
+ };
96
123
  }
97
124
 
98
125
  function cancelled(): undefined {