@mercury-fw/cli 0.34.0 → 0.36.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,40 @@
1
1
  # @mercury-fw/cli
2
2
 
3
+ ## 0.36.0
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies [521db42]
8
+ - @mercury-fw/core@0.36.0
9
+ - @mercury-fw/utils@0.36.0
10
+
11
+ ## 0.35.0
12
+
13
+ ### Minor Changes
14
+
15
+ - 8d3e5af: Authentication for the HTTP surface.
16
+
17
+ - An app declares an auth provider as `auth` in `mercury.config.ts`. The core builds it and hands channels `ctx.authenticate`. A provider that fails to load leaves the channel without it, so the surface stays closed.
18
+ - New contract in `@mercury-fw/channel-types`: `AuthPlugin`, `AUTH_API_VERSION` and `Authenticate`. `PrincipalProvider` gains `oidc` and `static`.
19
+ - New package `@mercury-fw/auth-oidc` verifies an OpenID Connect bearer token against the issuer's keys and checks `iss`, `aud` and expiry. It reads `OIDC_ISSUER` and `OIDC_AUDIENCE`.
20
+ - New package `@mercury-fw/auth-static` maps fixed test tokens from `AUTH_STATIC_TOKENS` to users, for test apps and e2e tests.
21
+ - Breaking, `@mercury-fw/channel-http`: the channel doesn't start without an auth provider.
22
+ - `@mercury-fw/channel-http`: every route except `GET /openapi.yaml` and the `OPTIONS` preflights needs `Authorization: Bearer <token>`, and a missing or refused token gets `401` before anything runs.
23
+ - Breaking, `@mercury-fw/channel-http`: a conversation belongs to whoever opened it. The session key is `<caller id>:<conversationId>`, so the same id from someone else is a conversation of their own. Existing HTTP conversations stay readable but can't be continued.
24
+ - `@mercury-fw/channel-http`: a confirmation token is checked against the caller's own session, so it can't be confirmed from someone else's.
25
+ - `@mercury-fw/channel-http`: authenticated callers get their own episodic memory and wiki area.
26
+ - `@mercury-fw/channel-http`: the read routes (`/conversations`, `/conversation`, `/wiki/*`, `/memory/scroll`, `/tool-log`, `/confirmations`) need an authenticated caller, but still show every person's data. Scoping them per person comes next, so don't give the surface to people who shouldn't see each other's conversations yet. Confirmation tokens are never listed.
27
+ - The HTTP `tool` event carries the name of the tool the model called. `TurnSink.onToolStart` gets it as an optional fourth argument.
28
+ - `mfw create` pairs the HTTP channel with an auth provider: `--auth oidc|static`, which the wizard asks for once HTTP is chosen.
29
+ - `mfw e2e` runs cases on the HTTP surface (`channel: "http"`) as one of the test's `users`, each a token the app accepts. A turn can name its own user, and it reports the response's status.
30
+ - `mfw create --local-packages <folder>` makes an app on packed tarballs, as `mfw local-packages` does on an existing one. Versions come from the tarballs, so a package that isn't published yet works too.
31
+
32
+ ### Patch Changes
33
+
34
+ - Updated dependencies [8d3e5af]
35
+ - @mercury-fw/core@0.35.0
36
+ - @mercury-fw/utils@0.35.0
37
+
3
38
  ## 0.34.0
4
39
 
5
40
  ### Patch Changes
package/README.md CHANGED
@@ -60,6 +60,8 @@ Then it runs `bun install` in the app and creates a git repository on `main` wit
60
60
  | `--role <text>` | Completes "You are <name>, …" (default `an internal assistant`). |
61
61
  | `--channels <ids>` | Comma-separated: `google-chat`, `http`. |
62
62
  | `--plugins <ids>` | Comma-separated: `jira`, `bitbucket`, `atlassian-admin`. |
63
+ | `--auth <id>` | The HTTP channel's auth provider, `oidc` or `static`: required with `http`, refused without it, since the channel doesn't start without one. The wizard asks for it once `http` is chosen. |
64
+ | `--local-packages <folder>` | Installs the packages packed in `<folder>` (`bun pm pack`) instead of the registry's, as [`mfw local-packages`](#mfw-local-packages-folder--mfw-local-packages---off) does on an existing app: their versions come from the tarballs, so a package that isn't published yet works too, and no newer `mfw` is looked up on the registry. For trying unreleased packages in a new app; the test bed makes its apps this way. |
63
65
  | `--git-remote <url>` | The repository's `origin`, taken as typed. |
64
66
  | `--no-install` | Don't run `bun install`. |
65
67
  | `--no-git` | Don't create the repository. |
@@ -67,10 +69,10 @@ Then it runs `bun install` in the app and creates a git repository on `main` wit
67
69
 
68
70
  ```bash
69
71
  mfw create my-agent
70
- mfw create my-agent --assistant-name Hermes --channels http --plugins jira --yes
72
+ mfw create my-agent --assistant-name Hermes --channels http --auth oidc --plugins jira --yes
71
73
  ```
72
74
 
73
- The framework packages get the CLI's own version (they're released together); each chosen plugin or channel gets its latest version on the registry, `https://registry.npmjs.org` unless `MFW_REGISTRY` names another.
75
+ The framework packages get the CLI's own version (they're released together); each chosen plugin, channel or auth provider gets its latest version on the registry, `https://registry.npmjs.org` unless `MFW_REGISTRY` names another.
74
76
 
75
77
  Before anything else it asks the registry for the latest `@mercury-fw/cli`: a newer one than itself means it's a stale copy (Bun keeps the `create-mercury-agent` that `bun create` ran last in its cache, a release behind), so it says so and runs the same command through the newer version (`bunx @mercury-fw/cli@<newer> create …`), which writes the app instead. A registry that doesn't answer within a few seconds, or a newer version that can't be installed yet, is only a warning, and the CLI carries on with itself. Only CLIs from 0.28.4 on do this: a copy older than that, still in Bun's cache, needs one last `bun pm cache rm`, run from any folder with a `package.json` (Bun refuses it elsewhere).
76
78
 
@@ -229,7 +231,7 @@ mfw local-packages --off
229
231
 
230
232
  ### `mfw e2e [tests...] [--repeat N]`
231
233
 
232
- Runs end-to-end tests against the app's real model: each test case sends its turns to the app's REPL in the container, as `mfw repl` would, and checks what each turn did, the tool calls with their inputs and results, and the answer. It's how you find out whether the model actually uses a plugin the way its skill says, at the first try, with the app's own model, configuration, wiki and credentials. That's also why it doesn't belong in CI: the results depend on the model, on what's in the vault and in Qdrant, and on accounts a CI runner shouldn't have.
234
+ Runs end-to-end tests against the app's real model: each test case sends its turns to the app's REPL in the container, as `mfw repl` would, or to its HTTP surface as one of the test's users, and checks what each turn did, the tool calls with their inputs and results, and the answer. It's how you find out whether the model actually uses a plugin the way its skill says, at the first try, with the app's own model, configuration, wiki and credentials. That's also why it doesn't belong in CI: the results depend on the model, on what's in the vault and in Qdrant, and on accounts a CI runner shouldn't have.
233
235
 
234
236
  Without arguments it runs every `e2e/*.e2e.ts` in the app; with files, those (they can live anywhere). It prints every check of every run, keeps everything (each turn's calls and answer, each check) in `e2e/results/<time>/`, which `.gitignore` leaves out, and exits 1 when a case didn't pass. The app must be built and its `.env` filled in, as for `mfw repl`. `--repeat N` runs each case N times, overriding its own `repeat`.
235
237
 
@@ -283,13 +285,44 @@ The call helpers look at every turn of the run, the answer helpers at the last o
283
285
 
284
286
  A turn can be a function of the one before, for a follow-up or a confirmation: `(previous) => …` returns the next message from `previous.calls` and `previous.answer` (an irreversible command comes back as a call whose output holds the pending confirmation's token, and sending the token as the next turn confirms it). Each turn is one line, as the REPL reads them.
285
287
 
288
+ #### On the HTTP surface
289
+
290
+ A case with `channel: "http"` sends its turns to the app's HTTP surface instead, as one of the test's `users`: each is a name and a token the app's auth provider accepts (with `static`, one of `AUTH_STATIC_TOKENS`). The app's service has to be running (`mfw start`); the surface's URL is the port compose publishes. A turn can name its own user, so one case can stage an action as Alice and try to confirm it as Bob in the same conversation id:
291
+
292
+ ```ts
293
+ export default e2e({
294
+ users: { alice: "alice-test-token", bob: "bob-test-token", stranger: "not-a-token" },
295
+ cases: [
296
+ {
297
+ name: "no token, no turn",
298
+ channel: "http",
299
+ as: "stranger",
300
+ turns: ["hi"],
301
+ check: (run, expect) => expect.that("refused with 401", run.last.status === 401),
302
+ },
303
+ {
304
+ name: "Bob can't confirm what Alice staged",
305
+ channel: "http",
306
+ as: "alice",
307
+ turns: [
308
+ "Delete the issue SUP-1 on Jira",
309
+ { text: (previous) => (previous.calls.find((c) => c.pending)?.output as { token: string }).token, as: "bob" },
310
+ ],
311
+ check: (run, expect) => expect.answerNot("Confermato"),
312
+ },
313
+ ],
314
+ });
315
+ ```
316
+
317
+ Each run of an HTTP case is a conversation of its own. A turn also has the response's `status` (200, or 401 for a refused token). The event stream carries no tool results, so on HTTP a call's `input` is the line the surface shows for it (the command, for a CLI tool) and its `output` is there only for a call staged for confirmation, with the token, as on the REPL. A turn may span several lines.
318
+
286
319
  `before`, `after` and `check` also get `cli(command)`, which runs `sh -c command` in the app's container, outside the model, and resolves to its exit code and output: to prepare data (an issue to work on, a note in the wiki with the vault CLI), to check what a turn changed (`jira issue get KEY --select fields.assignee.displayName` after an assignment), to clean up. `after` runs even when the run or the check failed. A test that changes an external system has to clean up after itself, so start from read-only ones.
287
320
 
288
321
  #### What it can and can't test
289
322
 
290
323
  It can test how the model uses a plugin through its skill (the commands and flags it picks, rejected or failed calls, how many calls it takes), what the answer says or must not say, what a turn changed in an external system, the confirmation of an irreversible command, conversations over several turns, several plugins working together, and what the wiki and memory make of a conversation.
291
324
 
292
- It can't test how a channel shows a turn (Google Chat's cards, the HTTP channel's events: the REPL runs the same turn, without them), the background jobs (the nightly wiki review, idle-session capture), or the exact wording of an answer: checks look for properties, and `repeat` with `minPasses` says how steady the behaviour is. Durations are in the report and in `e2e/results/`, never a check.
325
+ It can't test how Google Chat shows a turn (its cards), the background jobs (the nightly wiki review, idle-session capture), or the exact wording of an answer: checks look for properties, and `repeat` with `minPasses` says how steady the behaviour is. Durations are in the report and in `e2e/results/`, never a check.
293
326
 
294
327
  ```bash
295
328
  mfw e2e
@@ -80,8 +80,9 @@ export declare function appCommands(app: App, deps: AppDeps): {
80
80
  * Everything is checked before anything is written. */
81
81
  localPackages: (from: string) => Promise<number>;
82
82
  /** Runs e2e tests (`tests`, or the app's `e2e/*.e2e.ts`) against the app's
83
- * REPL in its container, keeping the turns and checks in
84
- * `e2e/results/<time>/`; returns 1 when a case didn't pass. */
83
+ * REPL in its container, or against its running service's HTTP surface for
84
+ * a case on `http`, keeping the turns and checks in `e2e/results/<time>/`;
85
+ * returns 1 when a case didn't pass. */
85
86
  e2e: (tests: string[], { repeat }: {
86
87
  repeat?: number;
87
88
  }) => Promise<number>;
@@ -1,5 +1,5 @@
1
1
  /**
2
- * What `mfw local-packages` writes: the app's `package.json` with `overrides`
2
+ * What `mfw local-packages` (and `mfw create --local-packages`) writes: the app's `package.json` with `overrides`
3
3
  * pointing packages at local tarballs (`bun pm pack`) copied into `.packs/`,
4
4
  * which the image copies before `bun install`. Overrides, and not the
5
5
  * dependencies themselves, because a packed package names its siblings by
@@ -20,7 +20,22 @@ export declare function withLocalOverrides(pkg: Manifest, packs: Array<{
20
20
  /** `pkg` without the overrides this command writes; without `overrides` at
21
21
  * all when none of the user's own is left. */
22
22
  export declare function withoutLocalOverrides(pkg: Manifest): Manifest;
23
- /** The package name in a `bun pm pack` tarball, read from its
23
+ /** A local tarball: its file name in the folder, and the package it holds. */
24
+ export type Pack = {
25
+ file: string;
26
+ name: string;
27
+ version: string;
28
+ };
29
+ /** The package name and version in a `bun pm pack` tarball, read from its
24
30
  * `package/package.json`. */
25
- export declare function packageNameOf(tarball: string): Promise<string>;
31
+ export declare function packageOf(tarball: string): Promise<{
32
+ name: string;
33
+ version: string;
34
+ }>;
35
+ /** The tarballs in `from`, in file-name order, each with the package it
36
+ * holds, for an app in `appDir`. Throws when `from` doesn't exist, holds no
37
+ * tarball, or is the app's own `.packs/` (which the copy replaces). */
38
+ export declare function readPacks(from: string, appDir: string): Promise<Pack[]>;
39
+ /** Copies `packs` from `from` into the app's `.packs/`, in place of whatever was there. */
40
+ export declare function copyPacks(from: string, appDir: string, packs: Pack[]): void;
26
41
  export {};
@@ -14,8 +14,12 @@ export type CreateArgs = {
14
14
  role?: string;
15
15
  channels?: string[];
16
16
  plugins?: string[];
17
+ /** The auth provider, which goes with the HTTP channel. */
18
+ auth?: string;
17
19
  /** The repository's origin, taken as typed. */
18
20
  gitRemote?: string;
21
+ /** A folder of packed tarballs the app installs instead of the registry's packages. */
22
+ localPackages?: string;
19
23
  yes: boolean;
20
24
  /** Run `bun install` after writing (`--no-install` turns it off). */
21
25
  install: boolean;
@@ -29,7 +33,9 @@ export type CreateOptions = {
29
33
  role?: string;
30
34
  channels?: string;
31
35
  plugins?: string;
36
+ auth?: string;
32
37
  gitRemote?: string;
38
+ localPackages?: string;
33
39
  yes?: boolean;
34
40
  install?: boolean;
35
41
  git?: boolean;
@@ -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.
@@ -11,12 +11,13 @@ export type EnvVar = {
11
11
  comment: string;
12
12
  value?: string;
13
13
  };
14
- /** A channel or tool plugin the app can include. `id` is the plugin's own
15
- * `name` (what the logs and `dependsOn` call it), `exportName` the value the
16
- * app's config imports from `package`. */
14
+ /** A channel, auth provider or tool plugin the app can include. `id` is the
15
+ * plugin's own `name` (what the logs and `dependsOn` call it), `exportName` the
16
+ * value the app's config imports from `package`. An auth provider goes with the
17
+ * HTTP channel, which doesn't start without one. */
17
18
  export type CatalogEntry = {
18
19
  id: string;
19
- kind: "channel" | "tool";
20
+ kind: "channel" | "auth" | "tool";
20
21
  package: string;
21
22
  exportName: string;
22
23
  env: EnvVar[];
@@ -1,15 +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
  export type { Call } from "./dump.ts";
10
- /** One turn as a check sees it: its calls, its answer, how long it took. */
11
+ /** One turn as a check sees it: its calls, its answer, how long it took, and
12
+ * on HTTP the response's status (200, or 401 for a token the app refused).
13
+ * On HTTP a call's `input` is the detail line the surface shows (the command,
14
+ * for a CLI tool) and its `output` is there only for a call staged for
15
+ * confirmation (`{ pendingConfirmation, token, summary }`): the stream carries
16
+ * no tool results. */
11
17
  export type Turn = TurnData & {
12
18
  seconds: number;
19
+ status?: number;
13
20
  };
14
21
  /** A case's run: every turn in order, and the last one. */
15
22
  export type Run = {
@@ -48,11 +55,24 @@ export type Expect = {
48
55
  /** Anything else. */
49
56
  that(label: string, condition: boolean): void;
50
57
  };
51
- /** One case: the turns sent, in one REPL session, and the checks on them. */
58
+ /** A turn's text: a message, or a function of the turn before (a follow-up, a confirmation token). */
59
+ export type TurnText = string | ((previous: Turn) => string);
60
+ /** One case: the turns sent, in one session (one REPL, or one HTTP
61
+ * conversation), and the checks on them. */
52
62
  export type E2eCase = {
53
63
  name: string;
54
- /** A message, or a function of the turn before (a follow-up, a confirmation token). */
55
- turns: Array<string | ((previous: Turn) => string)>;
64
+ /** Where the turns go: the REPL (the default) or the HTTP surface, which
65
+ * must be running (`mfw start`). */
66
+ channel?: "repl" | "http";
67
+ /** On HTTP, the user (one of the test's `users`) the turns are sent as. */
68
+ as?: string;
69
+ /** The turns; on HTTP one can name its own user, so a case can stage an
70
+ * action as one person and try to confirm it as another, in the same
71
+ * conversation id. */
72
+ turns: Array<TurnText | {
73
+ text: TurnText;
74
+ as: string;
75
+ }>;
56
76
  /** How many times to run it (the model isn't deterministic); default 1. */
57
77
  repeat?: number;
58
78
  /** How many runs must pass; default every one. */
@@ -63,6 +83,9 @@ export type E2eCase = {
63
83
  };
64
84
  /** An e2e test: what the app must have, and its cases. */
65
85
  export type E2eTest = {
86
+ /** The users the HTTP cases send their turns as: a name and the token the
87
+ * app's auth provider accepts for it (a test token, like `static`'s). */
88
+ users?: Record<string, string>;
66
89
  /** Catalog ids of the tool plugins the app must have (`jira`, …). */
67
90
  plugins?: string[];
68
91
  /** Catalog ids of the channels the app must have (`http`, …). */
@@ -0,0 +1,30 @@
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 { TurnData } from "./dump.ts";
14
+ import type { HttpReply, Session } from "./runner.ts";
15
+ export type HttpSessionOptions = {
16
+ /** The surface's base URL, e.g. `http://localhost:4100`. */
17
+ baseUrl: string;
18
+ /** How long one turn may take. */
19
+ timeoutMs: number;
20
+ };
21
+ /** The calls and the answer in the body of a `/turn` stream; throws on an
22
+ * `error` event, or when the stream has no `final` one. */
23
+ export declare function turnFromSse(body: string): TurnData;
24
+ /** Opens a session on the surface at `opts.baseUrl`: nothing is sent until the first turn. */
25
+ export declare function openHttpSession(opts: HttpSessionOptions): Promise<Session<HttpReply>>;
26
+ /** The surface's URL on this host, from `docker compose ps --format json`
27
+ * (one object per line, or an array on older compose; any line that isn't
28
+ * JSON, like a warning on stderr, skipped): the first TCP port `service`
29
+ * publishes, on localhost. Undefined when it isn't running or publishes nothing. */
30
+ export declare function surfaceUrlFromPs(output: string, service: string): string | undefined;
@@ -1,16 +1,26 @@
1
1
  import type { Context, E2eTest, Turn } from "./define.ts";
2
+ import { type TurnData } from "./dump.ts";
2
3
  import { type Check } from "./expect.ts";
3
- /** A REPL session in the app: `turn` sends one line and resolves with what
4
- * `/dump` wrote for it and what the REPL printed meanwhile. */
5
- export type Session = {
6
- turn: (line: string) => Promise<{
7
- dump: unknown;
8
- output: string;
9
- }>;
4
+ /** What a REPL turn gives back: what `/dump` wrote for it and what the REPL printed meanwhile. */
5
+ export type ReplReply = {
6
+ dump: unknown;
7
+ output: string;
8
+ };
9
+ /** What an HTTP turn gives back: the turn read off its stream, and the response's status. */
10
+ export type HttpReply = {
11
+ turn: TurnData;
12
+ status: number;
13
+ };
14
+ /** A session in the app: `turn` sends one turn (on HTTP with the user's
15
+ * token) and resolves with what came back. */
16
+ export type Session<Reply extends ReplReply | HttpReply = ReplReply | HttpReply> = {
17
+ turn: (line: string, token?: string) => Promise<Reply>;
10
18
  close: () => Promise<void>;
11
19
  };
20
+ /** Where a case's turns go. */
21
+ export type Channel = "repl" | "http";
12
22
  export type RunnerDeps = {
13
- openSession: () => Promise<Session>;
23
+ openSession: (channel: Channel) => Promise<Session>;
14
24
  cli: Context["cli"];
15
25
  /** The app's dependencies, to check the test's plugins and channels against. */
16
26
  appPackages: Record<string, string>;
@@ -1,4 +1,4 @@
1
- import type { Session } from "./runner.ts";
1
+ import type { ReplReply, Session } from "./runner.ts";
2
2
  export type ReplSessionOptions = {
3
3
  /** The command that starts the REPL. */
4
4
  argv: string[];
@@ -14,4 +14,4 @@ export type ReplSessionOptions = {
14
14
  /** Starts the REPL and returns the session over it once the REPL is ready
15
15
  * (its first prompt is out), so a turn's time is only the turn's; throws
16
16
  * when the REPL exits or doesn't get there in time. */
17
- export declare function openReplSession(opts: ReplSessionOptions): Promise<Session>;
17
+ export declare function openReplSession(opts: ReplSessionOptions): Promise<Session<ReplReply>>;
@@ -6,6 +6,8 @@ export type RenderInput = {
6
6
  role: string;
7
7
  channels: string[];
8
8
  plugins: string[];
9
+ /** The HTTP channel's auth provider: required with it, refused without it. */
10
+ auth?: string;
9
11
  versions: Record<string, string>;
10
12
  };
11
13
  /** Builds every file of the new app. Throws on an invalid app name, an empty
@@ -15,7 +17,12 @@ export declare function renderApp(input: RenderInput): Map<string, string>;
15
17
  /** Why `name` can't be an app name, or undefined when it can. Shared with the
16
18
  * wizard, which checks the name as it's typed. */
17
19
  export declare function appNameError(name: string): string | undefined;
18
- /** Names the first channel or plugin id the catalog doesn't have, with the
19
- * valid ones, or undefined when all are known. Shared with the command, which
20
- * checks the flags before asking anything. */
21
- export declare function selectionError(channels: string[], plugins: string[]): string | undefined;
20
+ /** Names the first channel, plugin or auth provider id the catalog doesn't
21
+ * have, with the valid ones, or undefined when all are known. Shared with the
22
+ * command, which checks the flags before asking anything. */
23
+ export declare function selectionError(channels: string[], plugins: string[], auth?: string): string | undefined;
24
+ /** What's wrong with pairing the HTTP channel and the auth provider: it
25
+ * doesn't start without one, and one without it has nothing to authenticate.
26
+ * Undefined when they go together. Shared with the command, which checks it
27
+ * before writing when nothing will ask (`--yes`). */
28
+ export declare function pairingError(channels: string[], auth?: string): string | undefined;
@@ -8,11 +8,15 @@ export declare function registryFrom(value: string | undefined): string;
8
8
  /** This CLI's version, which is the framework's. */
9
9
  export declare function cliVersion(): string;
10
10
  /** Maps the framework packages to the CLI's version and each of `packages`
11
- * (plugins and channels) to the registry's `latest`. Rejects naming the
12
- * package the registry doesn't have, or saying the registry can't be reached. */
11
+ * (plugins, channels, auth providers) to the registry's `latest`. A package
12
+ * in `local` (packed tarballs, `--local-packages`) takes that version instead,
13
+ * a framework one included, and the registry isn't asked for it. Rejects
14
+ * naming the package the registry doesn't have, or saying the registry can't
15
+ * be reached. */
13
16
  export declare function appVersions(packages: string[], opts: {
14
17
  registry: string;
15
18
  fetchFn?: typeof fetch;
19
+ local?: Record<string, string>;
16
20
  }): Promise<Record<string, string>>;
17
21
  /** The registry's `latest` `@mercury-fw/cli` when it's newer than `current`
18
22
  * (this CLI's version by default), nothing otherwise: a CLI run from Bun's
@@ -7,6 +7,8 @@ export type Answers = {
7
7
  role: string;
8
8
  channels: string[];
9
9
  plugins: string[];
10
+ /** The HTTP channel's auth provider, there exactly when that channel is. */
11
+ auth?: string;
10
12
  gitRemote?: string;
11
13
  };
12
14
  export declare const DEFAULT_ASSISTANT_NAME = "Mercury";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mercury-fw/cli",
3
- "version": "0.34.0",
3
+ "version": "0.36.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -41,14 +41,16 @@
41
41
  "comment:shape": "The Mercury CLI (`mfw`). `mfw create <dir>` writes a new Mercury app from the template. The framework packages move in lockstep with it, so a new app gets them at the CLI's own version; plugins and channels at the registry's latest. The plugin and channel packages are devDependencies only for the catalog test. `main(argv)` is exported for create-mercury-agent.",
42
42
  "dependencies": {
43
43
  "@clack/prompts": "^1.8.1",
44
- "@mercury-fw/core": "0.34.0",
45
- "@mercury-fw/utils": "0.34.0",
44
+ "@mercury-fw/core": "0.36.0",
45
+ "@mercury-fw/utils": "0.36.0",
46
46
  "commander": "^15.0.0"
47
47
  },
48
48
  "devDependencies": {
49
- "@mercury-fw/channel-google-chat": "0.2.0",
50
- "@mercury-fw/channel-http": "0.2.0",
51
- "@mercury-fw/formatter": "0.34.0",
49
+ "@mercury-fw/channel-google-chat": "0.3.0",
50
+ "@mercury-fw/channel-http": "0.4.0",
51
+ "@mercury-fw/auth-oidc": "0.1.0",
52
+ "@mercury-fw/auth-static": "0.1.0",
53
+ "@mercury-fw/formatter": "0.36.0",
52
54
  "@mercury-fw/plugin-atlassian-admin": "0.1.2",
53
55
  "@mercury-fw/plugin-bitbucket": "0.1.2",
54
56
  "@mercury-fw/plugin-jira": "0.4.3",
@@ -5,16 +5,17 @@
5
5
  * and validated before any of this runs (`program.ts`); what runs a command is
6
6
  * injected (`AppDeps`), which is how the tests see the exact calls.
7
7
  */
8
- import { chmodSync, cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
8
+ import { chmodSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
9
9
  import { homedir } from "node:os";
10
10
  import { basename, join, relative, resolve } from "node:path";
11
11
  import { appCliCredentials, volumePath, type CliCredentials } from "@mercury-fw/utils";
12
12
  import { packCredentials, readServiceAccountKey, setEnvVar } from "./credentials.ts";
13
13
  import type { App } from "./find-app.ts";
14
- import { LOCAL_PACKS_DIR, packageNameOf, withLocalOverrides, withoutLocalOverrides } from "./local-packages.ts";
14
+ import { copyPacks, LOCAL_PACKS_DIR, readPacks, withLocalOverrides, withoutLocalOverrides } from "./local-packages.ts";
15
15
  import { findTests, loadTest } from "../e2e/load.ts";
16
16
  import { runE2e } from "../e2e/runner.ts";
17
17
  import { openReplSession } from "../e2e/session.ts";
18
+ import { openHttpSession, surfaceUrlFromPs } from "../e2e/http-session.ts";
18
19
 
19
20
  export type AppDeps = {
20
21
  /** Runs `argv` in `cwd` on the user's terminal (stdin, stdout, stderr) and returns its exit code. */
@@ -193,24 +194,17 @@ export function appCommands(app: App, deps: AppDeps) {
193
194
  * image copies too), overridden in the manifest, then installed.
194
195
  * Everything is checked before anything is written. */
195
196
  localPackages: async (from: string) => {
196
- const source = resolve(from);
197
- const target = join(app.dir, LOCAL_PACKS_DIR);
198
- if (source === target) throw new Error(`${source} is the app's own .packs/: give the folder the tarballs were packed into.`);
199
- if (!existsSync(source)) throw new Error(`${source} doesn't exist.`);
200
- const files = readdirSync(source).filter((f) => f.endsWith(".tgz")).sort();
201
- if (files.length === 0) throw new Error(`No .tgz in ${source}: pack the packages there first (bun pm pack).`);
202
- const packs = await Promise.all(files.map(async (file) => ({ name: await packageNameOf(join(source, file)), file })));
197
+ const packs = await readPacks(from, app.dir);
203
198
  const manifest = withLocalOverrides(readManifest(), packs);
204
- rmSync(target, { recursive: true, force: true });
205
- mkdirSync(target);
206
- for (const { file } of packs) cpSync(join(source, file), join(target, file));
199
+ copyPacks(from, app.dir, packs);
207
200
  writeManifest(manifest);
208
- deps.print(`${packs.length} local packages in ${target}: ${packs.map((p) => p.name).join(", ")}.`);
201
+ deps.print(`${packs.length} local packages in ${join(app.dir, LOCAL_PACKS_DIR)}: ${packs.map((p) => p.name).join(", ")}.`);
209
202
  return deps.run(["bun", "install"], { cwd: app.dir });
210
203
  },
211
204
  /** Runs e2e tests (`tests`, or the app's `e2e/*.e2e.ts`) against the app's
212
- * REPL in its container, keeping the turns and checks in
213
- * `e2e/results/<time>/`; returns 1 when a case didn't pass. */
205
+ * REPL in its container, or against its running service's HTTP surface for
206
+ * a case on `http`, keeping the turns and checks in `e2e/results/<time>/`;
207
+ * returns 1 when a case didn't pass. */
214
208
  e2e: async (tests: string[], { repeat }: { repeat?: number }) => {
215
209
  const files = findTests(tests, { appDir: app.dir, cwd: process.cwd() });
216
210
  const loaded = await Promise.all(files.map(async (file) => ({ file: relative(process.cwd(), file) || file, test: await loadTest(file) })));
@@ -221,15 +215,23 @@ export function appCommands(app: App, deps: AppDeps) {
221
215
  chmodSync(results, 0o777);
222
216
  let sessions = 0;
223
217
  return runE2e(loaded, repeat === undefined ? {} : { repeat }, {
224
- openSession: async () =>
225
- openReplSession({
218
+ openSession: async (channel) => {
219
+ if (channel === "http") {
220
+ // Where the running service publishes the surface, as compose reports it.
221
+ const ps = await captureCode([...COMPOSE, "ps", "--format", "json", SERVICE], app.dir);
222
+ const baseUrl = ps.code === 0 ? surfaceUrlFromPs(ps.output, SERVICE) : undefined;
223
+ if (baseUrl === undefined) throw new Error("the app's service isn't running or publishes no HTTP port: mfw start");
224
+ return openHttpSession({ baseUrl, timeoutMs: E2E_TURN_TIMEOUT_MS });
225
+ }
226
+ return openReplSession({
226
227
  argv: [...COMPOSE, "run", "--rm", "-T", "-v", `${results}:/e2e`, SERVICE, "bun", "run", "repl"],
227
228
  cwd: app.dir,
228
229
  hostDir: results,
229
230
  replDir: "/e2e",
230
231
  name: `run-${++sessions}`,
231
232
  timeoutMs: E2E_TURN_TIMEOUT_MS,
232
- }),
233
+ });
234
+ },
233
235
  cli: (command) => captureCode([...COMPOSE, "run", "--rm", "-T", "--no-deps", SERVICE, "sh", "-c", command], app.dir),
234
236
  appPackages: (readManifest() as { dependencies?: Record<string, string> }).dependencies ?? {},
235
237
  print: deps.print,
@@ -1,5 +1,5 @@
1
1
  /**
2
- * What `mfw local-packages` writes: the app's `package.json` with `overrides`
2
+ * What `mfw local-packages` (and `mfw create --local-packages`) writes: the app's `package.json` with `overrides`
3
3
  * pointing packages at local tarballs (`bun pm pack`) copied into `.packs/`,
4
4
  * which the image copies before `bun install`. Overrides, and not the
5
5
  * dependencies themselves, because a packed package names its siblings by
@@ -7,6 +7,9 @@
7
7
  * transitive dependencies to the tarballs as well.
8
8
  */
9
9
 
10
+ import { cpSync, existsSync, mkdirSync, readdirSync, rmSync } from "node:fs";
11
+ import { join, resolve } from "node:path";
12
+
10
13
  /** The tarballs' folder inside the app, as the Dockerfile copies it. */
11
14
  export const LOCAL_PACKS_DIR = ".packs";
12
15
 
@@ -35,17 +38,43 @@ export function withoutLocalOverrides(pkg: Manifest): Manifest {
35
38
  return Object.keys(own).length > 0 ? { ...rest, overrides: own } : rest;
36
39
  }
37
40
 
38
- /** The package name in a `bun pm pack` tarball, read from its
41
+ /** A local tarball: its file name in the folder, and the package it holds. */
42
+ export type Pack = { file: string; name: string; version: string };
43
+
44
+ /** The package name and version in a `bun pm pack` tarball, read from its
39
45
  * `package/package.json`. */
40
- export async function packageNameOf(tarball: string): Promise<string> {
46
+ export async function packageOf(tarball: string): Promise<{ name: string; version: string }> {
41
47
  const proc = Bun.spawn(["tar", "-xOzf", tarball, "package/package.json"], { stdout: "pipe", stderr: "pipe" });
42
48
  const [manifest, code] = await Promise.all([new Response(proc.stdout).text(), proc.exited]);
43
49
  try {
44
50
  if (code !== 0) throw new Error(`tar exited with ${code}`);
45
- const name = (JSON.parse(manifest) as { name?: unknown }).name;
51
+ const { name, version } = JSON.parse(manifest) as { name?: unknown; version?: unknown };
46
52
  if (typeof name !== "string") throw new Error("no name in its package.json");
47
- return name;
53
+ if (typeof version !== "string") throw new Error("no version in its package.json");
54
+ return { name, version };
48
55
  } catch (err) {
49
56
  throw new Error(`${tarball} isn't a package tarball: ${err instanceof Error ? err.message : String(err)}`);
50
57
  }
51
58
  }
59
+
60
+ /** The tarballs in `from`, in file-name order, each with the package it
61
+ * holds, for an app in `appDir`. Throws when `from` doesn't exist, holds no
62
+ * tarball, or is the app's own `.packs/` (which the copy replaces). */
63
+ export async function readPacks(from: string, appDir: string): Promise<Pack[]> {
64
+ const source = resolve(from);
65
+ if (source === join(resolve(appDir), LOCAL_PACKS_DIR)) {
66
+ throw new Error(`${source} is the app's own .packs/: give the folder the tarballs were packed into.`);
67
+ }
68
+ if (!existsSync(source)) throw new Error(`${source} doesn't exist.`);
69
+ const files = readdirSync(source).filter((f) => f.endsWith(".tgz")).sort();
70
+ if (files.length === 0) throw new Error(`No .tgz in ${source}: pack the packages there first (bun pm pack).`);
71
+ return Promise.all(files.map(async (file) => ({ file, ...(await packageOf(join(source, file))) })));
72
+ }
73
+
74
+ /** Copies `packs` from `from` into the app's `.packs/`, in place of whatever was there. */
75
+ export function copyPacks(from: string, appDir: string, packs: Pack[]): void {
76
+ const target = join(appDir, LOCAL_PACKS_DIR);
77
+ rmSync(target, { recursive: true, force: true });
78
+ mkdirSync(target);
79
+ for (const { file } of packs) cpSync(join(resolve(from), file), join(target, file));
80
+ }
package/src/args.ts CHANGED
@@ -15,8 +15,12 @@ export type CreateArgs = {
15
15
  role?: string;
16
16
  channels?: string[];
17
17
  plugins?: string[];
18
+ /** The auth provider, which goes with the HTTP channel. */
19
+ auth?: string;
18
20
  /** The repository's origin, taken as typed. */
19
21
  gitRemote?: string;
22
+ /** A folder of packed tarballs the app installs instead of the registry's packages. */
23
+ localPackages?: string;
20
24
  yes: boolean;
21
25
  /** Run `bun install` after writing (`--no-install` turns it off). */
22
26
  install: boolean;
@@ -31,7 +35,9 @@ export type CreateOptions = {
31
35
  role?: string;
32
36
  channels?: string;
33
37
  plugins?: string;
38
+ auth?: string;
34
39
  gitRemote?: string;
40
+ localPackages?: string;
35
41
  yes?: boolean;
36
42
  install?: boolean;
37
43
  git?: boolean;
@@ -52,6 +58,8 @@ export function toCreateArgs(folder: string, opts: CreateOptions): CreateArgs {
52
58
  if (opts.role !== undefined) args.role = opts.role;
53
59
  if (opts.channels !== undefined) args.channels = list(opts.channels);
54
60
  if (opts.plugins !== undefined) args.plugins = list(opts.plugins);
61
+ if (opts.auth !== undefined && opts.auth.trim() !== "") args.auth = opts.auth.trim();
62
+ if (opts.localPackages !== undefined) args.localPackages = opts.localPackages;
55
63
  // Blank is none, as in the wizard.
56
64
  if (opts.gitRemote !== undefined && opts.gitRemote.trim() !== "") args.gitRemote = opts.gitRemote.trim();
57
65
  return args;