@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/catalog.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The channels and tool plugins `mfw create` can put in a new app. Written
2
+ * The channels, auth providers and tool plugins `mfw create` can put in a new app. Written
3
3
  * by hand for now: once the packages are published, each plugin will describe
4
4
  * itself (kind, export, env vars) and this list goes away. `catalog.test.ts`
5
5
  * checks every entry against the real package until then.
@@ -9,12 +9,13 @@
9
9
  * example: `value` is the example value, empty when the deployer must fill it. */
10
10
  export type EnvVar = { name: string; comment: string; value?: string };
11
11
 
12
- /** A channel or tool plugin the app can include. `id` is the plugin's own
13
- * `name` (what the logs and `dependsOn` call it), `exportName` the value the
14
- * app's config imports from `package`. */
12
+ /** A channel, auth provider or tool plugin the app can include. `id` is the
13
+ * plugin's own `name` (what the logs and `dependsOn` call it), `exportName` the
14
+ * value the app's config imports from `package`. An auth provider goes with the
15
+ * HTTP channel, which doesn't start without one. */
15
16
  export type CatalogEntry = {
16
17
  id: string;
17
- kind: "channel" | "tool";
18
+ kind: "channel" | "auth" | "tool";
18
19
  package: string;
19
20
  exportName: string;
20
21
  env: EnvVar[];
@@ -55,10 +56,32 @@ export const CATALOG: CatalogEntry[] = [
55
56
  package: "@mercury-fw/channel-http",
56
57
  exportName: "httpChannel",
57
58
  env: [
58
- { name: "HTTP_SURFACE_PORT", comment: "Port of the HTTP surface (no authentication: keep it off the public network)", value: "4100" },
59
+ { name: "HTTP_SURFACE_PORT", comment: "Port of the HTTP surface", value: "4100" },
59
60
  { name: "HTTP_SURFACE_CORS_ORIGIN", comment: "Origin allowed to call the HTTP surface from a browser, if any" },
60
61
  ],
61
62
  },
63
+ {
64
+ id: "oidc",
65
+ kind: "auth",
66
+ package: "@mercury-fw/auth-oidc",
67
+ exportName: "oidcAuth",
68
+ env: [
69
+ { name: "OIDC_ISSUER", comment: "Required: the OpenID Connect issuer's URL, as tokens carry it in iss (https://<instance>.zitadel.cloud)" },
70
+ { name: "OIDC_AUDIENCE", comment: "Required: the client id the UI calling the HTTP surface is registered with on the issuer (tokens carry it in aud)" },
71
+ ],
72
+ },
73
+ {
74
+ id: "static",
75
+ kind: "auth",
76
+ package: "@mercury-fw/auth-static",
77
+ exportName: "staticAuth",
78
+ env: [
79
+ {
80
+ name: "AUTH_STATIC_TOKENS",
81
+ comment: 'Required: JSON from bearer token to user, test tokens only: {"<token>": {"id": "alice", "displayName": "Alice"}}',
82
+ },
83
+ ],
84
+ },
62
85
  {
63
86
  id: "jira",
64
87
  kind: "tool",
package/src/e2e/define.ts CHANGED
@@ -1,16 +1,22 @@
1
1
  /**
2
2
  * The shape of an e2e test, as a test file imports it
3
3
  * (`import { e2e } from "@mercury-fw/cli/e2e"`): the plugins and channels the
4
- * app must have, and cases of turns sent to the app's real model through its
5
- * REPL, each with checks on the calls it made and the answer it gave.
6
- * `mfw e2e` runs them (`runner.ts`).
4
+ * app must have, and cases of turns sent to the app's real model, each with
5
+ * checks on the calls it made and the answer it gave. A case talks to the
6
+ * app's REPL, or to its HTTP surface as one of the test's users (each a token
7
+ * the app's auth provider knows). `mfw e2e` runs them (`runner.ts`).
7
8
  */
8
9
  import type { Call, TurnData } from "./dump.ts";
9
10
 
10
11
  export type { Call } from "./dump.ts";
11
12
 
12
- /** One turn as a check sees it: its calls, its answer, how long it took. */
13
- export type Turn = TurnData & { seconds: number };
13
+ /** One turn as a check sees it: its calls, its answer, how long it took, and
14
+ * on HTTP the response's status (200, or 401 for a token the app refused).
15
+ * On HTTP a call's `input` is the detail line the surface shows (the command,
16
+ * for a CLI tool) and its `output` is there only for a call staged for
17
+ * confirmation (`{ pendingConfirmation, token, summary }`): the stream carries
18
+ * no tool results. */
19
+ export type Turn = TurnData & { seconds: number; status?: number };
14
20
 
15
21
  /** A case's run: every turn in order, and the last one. */
16
22
  export type Run = { turns: Turn[]; last: Turn };
@@ -42,11 +48,22 @@ export type Expect = {
42
48
  that(label: string, condition: boolean): void;
43
49
  };
44
50
 
45
- /** One case: the turns sent, in one REPL session, and the checks on them. */
51
+ /** A turn's text: a message, or a function of the turn before (a follow-up, a confirmation token). */
52
+ export type TurnText = string | ((previous: Turn) => string);
53
+
54
+ /** One case: the turns sent, in one session (one REPL, or one HTTP
55
+ * conversation), and the checks on them. */
46
56
  export type E2eCase = {
47
57
  name: string;
48
- /** A message, or a function of the turn before (a follow-up, a confirmation token). */
49
- turns: Array<string | ((previous: Turn) => string)>;
58
+ /** Where the turns go: the REPL (the default) or the HTTP surface, which
59
+ * must be running (`mfw start`). */
60
+ channel?: "repl" | "http";
61
+ /** On HTTP, the user (one of the test's `users`) the turns are sent as. */
62
+ as?: string;
63
+ /** The turns; on HTTP one can name its own user, so a case can stage an
64
+ * action as one person and try to confirm it as another, in the same
65
+ * conversation id. */
66
+ turns: Array<TurnText | { text: TurnText; as: string }>;
50
67
  /** How many times to run it (the model isn't deterministic); default 1. */
51
68
  repeat?: number;
52
69
  /** How many runs must pass; default every one. */
@@ -58,6 +75,9 @@ export type E2eCase = {
58
75
 
59
76
  /** An e2e test: what the app must have, and its cases. */
60
77
  export type E2eTest = {
78
+ /** The users the HTTP cases send their turns as: a name and the token the
79
+ * app's auth provider accepts for it (a test token, like `static`'s). */
80
+ users?: Record<string, string>;
61
81
  /** Catalog ids of the tool plugins the app must have (`jira`, …). */
62
82
  plugins?: string[];
63
83
  /** Catalog ids of the channels the app must have (`http`, …). */
@@ -0,0 +1,122 @@
1
+ /**
2
+ * The HTTP session `mfw e2e` drives for a case on the HTTP surface: one
3
+ * conversation (a `conversationId` of its own), each turn a `POST /turn` with
4
+ * the user's token, read off the event stream into the calls and the answer
5
+ * a check looks at. The app's service must be running (`mfw start`); the
6
+ * surface's URL comes from `commands.ts`.
7
+ *
8
+ * The stream carries no tool results, so a call has its tool's name, the
9
+ * detail line the surface shows as input, and an output only when it was
10
+ * staged for confirmation (the `pending` event's token), shaped like the one
11
+ * a REPL dump has, so a confirmation turn reads the token the same way.
12
+ */
13
+ import type { Call, TurnData } from "./dump.ts";
14
+ import type { HttpReply, Session } from "./runner.ts";
15
+
16
+ export type HttpSessionOptions = {
17
+ /** The surface's base URL, e.g. `http://localhost:4100`. */
18
+ baseUrl: string;
19
+ /** How long one turn may take. */
20
+ timeoutMs: number;
21
+ };
22
+
23
+ /** Every `event:`/`data:` pair in an SSE body, in order. */
24
+ function events(body: string): Array<{ name: string; data: Record<string, unknown> }> {
25
+ return body
26
+ .split("\n\n")
27
+ .map((block) => {
28
+ const name = /^event: (.*)$/m.exec(block)?.[1];
29
+ const data = /^data: (.*)$/m.exec(block)?.[1];
30
+ return name === undefined || data === undefined ? undefined : { name, data: JSON.parse(data) as Record<string, unknown> };
31
+ })
32
+ .filter((e) => e !== undefined);
33
+ }
34
+
35
+ /** The calls and the answer in the body of a `/turn` stream; throws on an
36
+ * `error` event, or when the stream has no `final` one. */
37
+ export function turnFromSse(body: string): TurnData {
38
+ const calls: Array<Call & { id: unknown }> = [];
39
+ // A pending event's output, waiting for its call's tool_finish when it came first.
40
+ const unclaimed: unknown[] = [];
41
+ let answer: string | undefined;
42
+ for (const { name, data } of events(body)) {
43
+ if (name === "tool" && typeof data.name === "string") {
44
+ // A tool event with no name is Mercury saving to memory, not a call.
45
+ calls.push({ id: data.toolCallId, tool: data.name, input: data.detail, output: undefined, ok: false, pending: false });
46
+ } else if (name === "tool_finish") {
47
+ const call = calls.find((c) => c.id === data.toolCallId);
48
+ if (call === undefined) continue;
49
+ call.pending = data.outcome === "pending";
50
+ call.ok = data.outcome === "success" || call.pending;
51
+ if (call.pending && unclaimed.length > 0) call.output = unclaimed.shift();
52
+ } else if (name === "pending") {
53
+ const output = { pendingConfirmation: true, token: data.token, summary: data.command };
54
+ const call = calls.findLast((c) => c.pending && c.output === undefined);
55
+ if (call !== undefined) call.output = output;
56
+ else unclaimed.push(output);
57
+ } else if (name === "final") {
58
+ answer = String(data.text ?? "");
59
+ } else if (name === "error") {
60
+ throw new Error(`the turn failed in the app: ${String(data.message)}`);
61
+ }
62
+ }
63
+ if (answer === undefined) throw new Error("the stream ended without a final event");
64
+ return { calls: calls.map(({ id: _, ...call }) => call), answer };
65
+ }
66
+
67
+ /** Opens a session on the surface at `opts.baseUrl`: nothing is sent until the first turn. */
68
+ export async function openHttpSession(opts: HttpSessionOptions): Promise<Session<HttpReply>> {
69
+ const conversationId = `e2e-${crypto.randomUUID()}`;
70
+ const url = `${opts.baseUrl.replace(/\/+$/, "")}/turn`;
71
+ return {
72
+ turn: async (text, token) => {
73
+ const signal = AbortSignal.timeout(opts.timeoutMs);
74
+ let res: Response;
75
+ let body: string;
76
+ try {
77
+ res = await fetch(url, {
78
+ method: "POST",
79
+ headers: { "content-type": "application/json", ...(token !== undefined ? { authorization: `Bearer ${token}` } : {}) },
80
+ body: JSON.stringify({ text, conversationId }),
81
+ signal,
82
+ });
83
+ body = await res.text();
84
+ } catch (err) {
85
+ if (signal.aborted) throw new Error(`no reply within ${opts.timeoutMs / 1000} s`);
86
+ throw new Error(`can't reach the HTTP surface at ${opts.baseUrl}: is the app running (mfw start)? (${err instanceof Error ? err.message : String(err)})`);
87
+ }
88
+ if (res.status !== 200) {
89
+ let error = body;
90
+ try {
91
+ error = String((JSON.parse(body) as { error?: unknown }).error ?? body);
92
+ } catch {
93
+ /* not JSON: the body as it is */
94
+ }
95
+ return { turn: { calls: [], answer: error }, status: res.status };
96
+ }
97
+ return { turn: turnFromSse(body), status: res.status };
98
+ },
99
+ close: async () => {},
100
+ };
101
+ }
102
+
103
+ /** The surface's URL on this host, from `docker compose ps --format json`
104
+ * (one object per line, or an array on older compose; any line that isn't
105
+ * JSON, like a warning on stderr, skipped): the first TCP port `service`
106
+ * publishes, on localhost. Undefined when it isn't running or publishes nothing. */
107
+ export function surfaceUrlFromPs(output: string, service: string): string | undefined {
108
+ type Row = { Service?: unknown; Publishers?: Array<{ PublishedPort?: unknown; Protocol?: unknown }> };
109
+ const rows = output.split("\n").flatMap((line): Row[] => {
110
+ try {
111
+ const parsed = JSON.parse(line) as Row | Row[];
112
+ return Array.isArray(parsed) ? parsed : [parsed];
113
+ } catch {
114
+ return [];
115
+ }
116
+ });
117
+ const port = rows
118
+ .filter((r) => r.Service === service)
119
+ .flatMap((r) => r.Publishers ?? [])
120
+ .find((p) => typeof p.PublishedPort === "number" && p.PublishedPort > 0 && (p.Protocol ?? "tcp") === "tcp")?.PublishedPort;
121
+ return port === undefined ? undefined : `http://localhost:${port}`;
122
+ }
package/src/e2e/runner.ts CHANGED
@@ -1,25 +1,34 @@
1
1
  /**
2
2
  * Runs e2e tests against an app: for each case (as many times as it
3
- * repeats), a fresh REPL session gets the case's turns, the checks run on
4
- * what each turn did, and every check is printed; the exit code says whether
5
- * every case passed enough runs. The session, the container commands and the
6
- * report's destination are injected (`session.ts` and `commands.ts` hold the
7
- * real ones), so the tests drive the runner with scripted turns.
3
+ * repeats), a fresh session (the REPL, or one conversation on the HTTP
4
+ * surface) gets the case's turns, the checks run on what each turn did, and
5
+ * every check is printed; the exit code says whether every case passed enough
6
+ * runs. The sessions, the container commands and the report's destination are
7
+ * injected (`session.ts`, `http-session.ts` and `commands.ts` hold the real
8
+ * ones), so the tests drive the runner with scripted turns.
8
9
  */
9
10
  import { CATALOG } from "../catalog.ts";
10
11
  import type { Context, E2eCase, E2eTest, Run, Turn } from "./define.ts";
11
- import { turnFromDump } from "./dump.ts";
12
+ import { turnFromDump, type TurnData } from "./dump.ts";
12
13
  import { createExpect, type Check } from "./expect.ts";
13
14
 
14
- /** A REPL session in the app: `turn` sends one line and resolves with what
15
- * `/dump` wrote for it and what the REPL printed meanwhile. */
16
- export type Session = {
17
- turn: (line: string) => Promise<{ dump: unknown; output: string }>;
15
+ /** What a REPL turn gives back: what `/dump` wrote for it and what the REPL printed meanwhile. */
16
+ export type ReplReply = { dump: unknown; output: string };
17
+ /** What an HTTP turn gives back: the turn read off its stream, and the response's status. */
18
+ export type HttpReply = { turn: TurnData; status: number };
19
+
20
+ /** A session in the app: `turn` sends one turn (on HTTP with the user's
21
+ * token) and resolves with what came back. */
22
+ export type Session<Reply extends ReplReply | HttpReply = ReplReply | HttpReply> = {
23
+ turn: (line: string, token?: string) => Promise<Reply>;
18
24
  close: () => Promise<void>;
19
25
  };
20
26
 
27
+ /** Where a case's turns go. */
28
+ export type Channel = "repl" | "http";
29
+
21
30
  export type RunnerDeps = {
22
- openSession: () => Promise<Session>;
31
+ openSession: (channel: Channel) => Promise<Session>;
23
32
  cli: Context["cli"];
24
33
  /** The app's dependencies, to check the test's plugins and channels against. */
25
34
  appPackages: Record<string, string>;
@@ -43,9 +52,11 @@ function answerFromOutput(output: string): string {
43
52
 
44
53
  /** The test's plugins and channels the app doesn't depend on, as a message, or undefined. */
45
54
  function missing(test: E2eTest, packages: Record<string, string>): string | undefined {
55
+ // A case on the HTTP surface needs the channel, listed or not.
56
+ const channels = new Set([...(test.channels ?? []), ...(test.cases.some((c) => c.channel === "http") ? ["http"] : [])]);
46
57
  const wanted = [
47
58
  ...(test.plugins ?? []).map((id) => ({ id, kind: "tool" as const, word: "plugin" })),
48
- ...(test.channels ?? []).map((id) => ({ id, kind: "channel" as const, word: "channel" })),
59
+ ...[...channels].map((id) => ({ id, kind: "channel" as const, word: "channel" })),
49
60
  ];
50
61
  const lacking = wanted.flatMap(({ id, kind, word }) => {
51
62
  const pkg = CATALOG.find((e) => e.kind === kind && e.id === id)?.package;
@@ -74,7 +85,7 @@ export async function runE2e(
74
85
  continue;
75
86
  }
76
87
  for (const c of test.cases) {
77
- const result = await runCase(c, opts.repeat, deps);
88
+ const result = await runCase(c, test.users ?? {}, opts.repeat, deps);
78
89
  report.push({ file, case: c.name, ...result });
79
90
  if (!result.passed) code = 1;
80
91
  }
@@ -84,14 +95,19 @@ export async function runE2e(
84
95
  }
85
96
 
86
97
  /** Runs one case as many times as it repeats, printing each run's checks and the verdict. */
87
- async function runCase(c: E2eCase, repeatOverride: number | undefined, deps: RunnerDeps): Promise<{ passed: boolean; runs: RunReport[] }> {
98
+ async function runCase(
99
+ c: E2eCase,
100
+ users: Record<string, string>,
101
+ repeatOverride: number | undefined,
102
+ deps: RunnerDeps,
103
+ ): Promise<{ passed: boolean; runs: RunReport[] }> {
88
104
  const repeat = repeatOverride ?? c.repeat ?? 1;
89
105
  const minPasses = Math.min(c.minPasses ?? repeat, repeat);
90
106
  deps.print(` ${c.name}`);
91
107
  const runs: RunReport[] = [];
92
108
  for (let i = 1; i <= repeat; i++) {
93
109
  if (repeat > 1) deps.print(` run ${i}/${repeat}`);
94
- const run = await runOnce(c, deps);
110
+ const run = await runOnce(c, users, deps);
95
111
  const indent = repeat > 1 ? " " : " ";
96
112
  for (const check of run.checks) {
97
113
  deps.print(`${indent}${check.ok ? "✓" : "✗"} ${check.label}${check.ok || check.detail === undefined ? "" : `: ${check.detail}`}`);
@@ -108,23 +124,47 @@ async function runCase(c: E2eCase, repeatOverride: number | undefined, deps: Run
108
124
  return { passed, runs };
109
125
  }
110
126
 
127
+ /** The token a turn is sent with: none on the REPL, its user's (the turn's
128
+ * own, or the case's) on HTTP; throws when that doesn't add up. */
129
+ function tokenFor(channel: Channel, user: string | undefined, users: Record<string, string>): string | undefined {
130
+ if (channel === "repl") {
131
+ if (user !== undefined) throw new Error('"as" goes with channel "http"');
132
+ return undefined;
133
+ }
134
+ if (user === undefined) throw new Error('an HTTP turn needs a user: "as" on the case or on the turn');
135
+ const token = users[user];
136
+ if (token === undefined) throw new Error(`unknown user "${user}" (the test's users: ${Object.keys(users).join(", ") || "none"})`);
137
+ return token;
138
+ }
139
+
111
140
  /** One run of a case in a fresh session: `before`, the turns, the checks, `after`. */
112
- async function runOnce(c: E2eCase, deps: RunnerDeps): Promise<RunReport> {
141
+ async function runOnce(c: E2eCase, users: Record<string, string>, deps: RunnerDeps): Promise<RunReport> {
142
+ const channel: Channel = c.channel ?? "repl";
113
143
  const ctx: Context = { cli: deps.cli };
114
144
  const turns: Turn[] = [];
115
145
  let checks: Check[] = [];
116
146
  try {
117
147
  await c.before?.(ctx);
118
- const session = await deps.openSession();
148
+ // Every turn's user is settled before the session opens, so a case that
149
+ // doesn't add up costs nothing.
150
+ const planned = c.turns.map((next) => {
151
+ const { text, as } = typeof next === "object" ? next : { text: next, as: undefined };
152
+ return { text, token: tokenFor(channel, as ?? c.as, users) };
153
+ });
154
+ const session = await deps.openSession(channel);
119
155
  try {
120
- for (const next of c.turns) {
121
- const line = typeof next === "string" ? next : next(turns.at(-1)!);
122
- if (line.includes("\n")) throw new Error("a turn must be one line (the REPL reads one line per turn)");
156
+ for (const { text, token } of planned) {
157
+ const line = typeof text === "string" ? text : text(turns.at(-1)!);
158
+ if (channel === "repl" && line.includes("\n")) throw new Error("a turn must be one line (the REPL reads one line per turn)");
123
159
  const start = deps.now();
124
- const { dump, output } = await session.turn(line);
160
+ const reply = await session.turn(line, token);
125
161
  const seconds = (deps.now() - start) / 1000;
126
- const data = turnFromDump(dump);
127
- const answer = data.calls.length === 0 && data.answer === "" ? answerFromOutput(output) : data.answer;
162
+ if ("status" in reply) {
163
+ turns.push({ ...reply.turn, seconds, status: reply.status });
164
+ continue;
165
+ }
166
+ const data = turnFromDump(reply.dump);
167
+ const answer = data.calls.length === 0 && data.answer === "" ? answerFromOutput(reply.output) : data.answer;
128
168
  turns.push({ calls: data.calls, answer, seconds });
129
169
  }
130
170
  } finally {
@@ -9,7 +9,7 @@
9
9
  */
10
10
  import { readFileSync } from "node:fs";
11
11
  import { join } from "node:path";
12
- import type { Session } from "./runner.ts";
12
+ import type { ReplReply, Session } from "./runner.ts";
13
13
 
14
14
  export type ReplSessionOptions = {
15
15
  /** The command that starts the REPL. */
@@ -27,7 +27,7 @@ export type ReplSessionOptions = {
27
27
  /** Starts the REPL and returns the session over it once the REPL is ready
28
28
  * (its first prompt is out), so a turn's time is only the turn's; throws
29
29
  * when the REPL exits or doesn't get there in time. */
30
- export async function openReplSession(opts: ReplSessionOptions): Promise<Session> {
30
+ export async function openReplSession(opts: ReplSessionOptions): Promise<Session<ReplReply>> {
31
31
  const proc = Bun.spawn(opts.argv, { cwd: opts.cwd, stdin: "pipe", stdout: "pipe", stderr: "pipe" });
32
32
  let stdout = "";
33
33
  let stderr = "";
package/src/main.ts CHANGED
@@ -5,15 +5,16 @@
5
5
  * commands operate an existing app from inside its folder (see
6
6
  * `app/commands.ts`). The command line itself is declared in `program.ts`.
7
7
  */
8
- import { existsSync, readFileSync } from "node:fs";
8
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
9
9
  import { basename, dirname, join, resolve } from "node:path";
10
10
  import type { CreateArgs } from "./args.ts";
11
11
  import { CATALOG } from "./catalog.ts";
12
12
  import { kebabCase } from "./naming.ts";
13
13
  import { runProgram } from "./program.ts";
14
- import { renderApp, selectionError } from "./render.ts";
14
+ import { pairingError, renderApp, selectionError } from "./render.ts";
15
15
  import { appVersions, cliVersion, newerCli, registryFrom } from "./versions.ts";
16
16
  import { appCommands, terminalDeps, type AppDeps } from "./app/commands.ts";
17
+ import { copyPacks, readPacks, withLocalOverrides } from "./app/local-packages.ts";
17
18
  import { findApp, type App } from "./app/find-app.ts";
18
19
  import { askAnswers, DEFAULT_ASSISTANT_NAME, DEFAULT_ROLE, type Answers } from "./wizard.ts";
19
20
  import { finishApp, finishMessage, spawnRun, type Run } from "./finish.ts";
@@ -27,6 +28,7 @@ function answersFromFlags(args: CreateArgs, defaultName: string): Answers {
27
28
  role: args.role ?? DEFAULT_ROLE,
28
29
  channels: args.channels ?? [],
29
30
  plugins: args.plugins ?? [],
31
+ ...(args.auth !== undefined ? { auth: args.auth } : {}),
30
32
  ...(args.gitRemote !== undefined ? { gitRemote: args.gitRemote } : {}),
31
33
  };
32
34
  }
@@ -104,8 +106,12 @@ function remoteError(args: CreateArgs): string | undefined {
104
106
  /** `mfw create`: returns the exit code. `rawArgs` are the arguments after
105
107
  * `create` as typed, for a relaunch. */
106
108
  async function create(args: CreateArgs, rawArgs: string[], relaunch: Relaunch, globalInstall: boolean, run: Run): Promise<number> {
107
- const relaunched = await relaunchIfStale(rawArgs, relaunch, globalInstall);
108
- if (relaunched !== undefined) return relaunched;
109
+ // An app made on local tarballs is made by this CLI, whatever the registry
110
+ // has: that's the point of packing them.
111
+ if (args.localPackages === undefined) {
112
+ const relaunched = await relaunchIfStale(rawArgs, relaunch, globalInstall);
113
+ if (relaunched !== undefined) return relaunched;
114
+ }
109
115
  // The folder is created in kebab case, only its own name: the parent path is
110
116
  // taken as typed. Its name is also the app name's default.
111
117
  const typed = resolve(args.dir);
@@ -115,23 +121,38 @@ async function create(args: CreateArgs, rawArgs: string[], relaunch: Relaunch, g
115
121
  }
116
122
  const dir = join(dirname(typed), folder);
117
123
  // What the command line already settles is checked before any question, so
118
- // the wizard is never answered for nothing.
124
+ // the wizard is never answered for nothing. The HTTP channel and its auth
125
+ // provider are paired here with --yes, or when the flags give both;
126
+ // otherwise the wizard asks.
127
+ const pairedByFlags = args.yes || (args.channels !== undefined && args.auth !== undefined);
119
128
  const early =
120
129
  targetError(dir) ??
121
- selectionError(args.channels ?? [], args.plugins ?? []) ??
130
+ selectionError(args.channels ?? [], args.plugins ?? [], args.auth) ??
131
+ (pairedByFlags ? pairingError(args.channels ?? [], args.auth) : undefined) ??
122
132
  remoteError(args);
123
133
  if (early !== undefined) {
124
134
  throw new Error(early);
125
135
  }
136
+ const packs = args.localPackages === undefined ? undefined : await readPacks(args.localPackages, dir);
126
137
  const answers = args.yes ? answersFromFlags(args, folder) : await askAnswers(args, dir);
127
138
  if (answers === undefined) {
128
139
  return 1;
129
140
  }
130
- const chosen = CATALOG.filter(
131
- (e) => (e.kind === "channel" ? answers.channels : answers.plugins).includes(e.id),
141
+ const chosen = CATALOG.filter((e) =>
142
+ e.kind === "auth" ? e.id === answers.auth : (e.kind === "channel" ? answers.channels : answers.plugins).includes(e.id),
132
143
  ).map((e) => e.package);
133
- const versions = await appVersions(chosen, { registry: registryFrom(process.env.MFW_REGISTRY) });
144
+ const versions = await appVersions(chosen, {
145
+ registry: registryFrom(process.env.MFW_REGISTRY),
146
+ ...(packs !== undefined ? { local: Object.fromEntries(packs.map((p) => [p.name, p.version])) } : {}),
147
+ });
134
148
  writeApp(dir, renderApp({ ...answers, versions }));
149
+ if (packs !== undefined && args.localPackages !== undefined) {
150
+ // What `mfw local-packages` does, before the install sees the manifest.
151
+ const manifestPath = join(dir, "package.json");
152
+ const manifest = withLocalOverrides(JSON.parse(readFileSync(manifestPath, "utf-8")) as Record<string, unknown>, packs);
153
+ writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
154
+ copyPacks(args.localPackages, dir, packs);
155
+ }
135
156
  const none = (ids: string[]) => (ids.length > 0 ? ids.join(", ") : "none");
136
157
  const report = await finishApp(
137
158
  dir,
@@ -141,7 +162,7 @@ async function create(args: CreateArgs, rawArgs: string[], relaunch: Relaunch, g
141
162
  ...(answers.gitRemote !== undefined ? { remote: answers.gitRemote } : {}),
142
163
  commitMessage: [
143
164
  `Scaffold with mfw create ${cliVersion()}`,
144
- `Channels: ${none(answers.channels)}\nPlugins: ${none(answers.plugins)}`,
165
+ `Channels: ${none(answers.channels)}\nPlugins: ${none(answers.plugins)}${answers.auth !== undefined ? `\nAuth: ${answers.auth}` : ""}`,
145
166
  ],
146
167
  },
147
168
  run,
package/src/program.ts CHANGED
@@ -52,7 +52,7 @@ function buildProgram(handlers: ProgramHandlers, result: { code: number }): Comm
52
52
  .command("create")
53
53
  .summary("writes a new Mercury app")
54
54
  .description(
55
- "Writes a new Mercury app into <folder>, which has to be missing or empty (its own name is turned into kebab case). Without options it asks for the app name, the assistant's name and role, and which channels and tool plugins to include; then it writes mercury.config.ts for that selection, the persona (persona/identity.md, persona/tone.md), the service and REPL entrypoints, a Dockerfile, a compose file with Qdrant, and an env example listing every variable the app reads. Then it runs bun install, and creates a git repository on main with a first commit, adding the origin when given (nothing is pushed).",
55
+ "Writes a new Mercury app into <folder>, which has to be missing or empty (its own name is turned into kebab case). Without options it asks for the app name, the assistant's name and role, and which channels (with the HTTP channel's auth provider) and tool plugins to include; then it writes mercury.config.ts for that selection, the persona (persona/identity.md, persona/tone.md), the service and REPL entrypoints, a Dockerfile, a compose file with Qdrant, and an env example listing every variable the app reads. Then it runs bun install, and creates a git repository on main with a first commit, adding the origin when given (nothing is pushed).",
56
56
  )
57
57
  .argument("<folder>", "where to write the app")
58
58
  .option("--name <name>", "app name, as in package.json (default: the folder's name)")
@@ -63,7 +63,15 @@ function buildProgram(handlers: ProgramHandlers, result: { code: number }): Comm
63
63
  `comma-separated: ${CATALOG.filter((e) => e.kind === "channel").map((e) => e.id).join(", ")}`,
64
64
  )
65
65
  .option("--plugins <ids>", `comma-separated: ${CATALOG.filter((e) => e.kind === "tool").map((e) => e.id).join(", ")}`)
66
+ .option(
67
+ "--auth <id>",
68
+ `the HTTP channel's auth provider, required with it: ${CATALOG.filter((e) => e.kind === "auth").map((e) => e.id).join(", ")}`,
69
+ )
66
70
  .option("--git-remote <url>", "the repository's origin, taken as typed (default: none)")
71
+ .option(
72
+ "--local-packages <folder>",
73
+ "install the packages packed in <folder> (bun pm pack) instead of the registry's, taking their versions from the tarballs, as mfw local-packages does",
74
+ )
67
75
  .option("--no-install", "don't run bun install")
68
76
  .option("--no-git", "don't create the git repository")
69
77
  .option("-y, --yes", "don't ask: use the flags and the defaults")
@@ -75,7 +83,7 @@ its latest on the registry (https://registry.npmjs.org, or MFW_REGISTRY).
75
83
 
76
84
  Examples:
77
85
  mfw create my-agent
78
- mfw create my-agent --assistant-name Hermes --channels http --plugins jira --yes`,
86
+ mfw create my-agent --assistant-name Hermes --channels http --auth oidc --plugins jira --yes`,
79
87
  )
80
88
  .action(async (folder: string, opts: CreateOptions) => {
81
89
  result.code = await handlers.create(toCreateArgs(folder, opts));