@mercury-fw/cli 0.33.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/CHANGELOG.md +35 -0
- package/README.md +37 -4
- package/dist/src/app/commands.d.ts +3 -2
- package/dist/src/app/local-packages.d.ts +18 -3
- package/dist/src/args.d.ts +6 -0
- package/dist/src/catalog.d.ts +6 -5
- package/dist/src/e2e/define.d.ts +30 -7
- package/dist/src/e2e/http-session.d.ts +30 -0
- package/dist/src/e2e/runner.d.ts +18 -8
- package/dist/src/e2e/session.d.ts +2 -2
- package/dist/src/render.d.ts +11 -4
- package/dist/src/versions.d.ts +6 -2
- package/dist/src/wizard.d.ts +2 -0
- package/package.json +8 -6
- package/src/app/commands.ts +20 -18
- package/src/app/local-packages.ts +34 -5
- package/src/args.ts +8 -0
- package/src/catalog.ts +29 -6
- package/src/e2e/define.ts +28 -8
- package/src/e2e/http-session.ts +122 -0
- package/src/e2e/runner.ts +63 -23
- package/src/e2e/session.ts +2 -2
- package/src/main.ts +31 -10
- package/src/program.ts +10 -2
- package/src/render.ts +52 -31
- package/src/versions.ts +13 -5
- package/src/wizard.ts +29 -2
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
|
|
13
|
-
* `name` (what the logs and `dependsOn` call it), `exportName` 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
|
|
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
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
49
|
-
|
|
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
|
|
4
|
-
*
|
|
5
|
-
* every
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
-
/**
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
...
|
|
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(
|
|
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
|
-
|
|
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
|
|
121
|
-
const line = typeof
|
|
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
|
|
160
|
+
const reply = await session.turn(line, token);
|
|
125
161
|
const seconds = (deps.now() - start) / 1000;
|
|
126
|
-
|
|
127
|
-
|
|
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 {
|
package/src/e2e/session.ts
CHANGED
|
@@ -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
|
-
|
|
108
|
-
|
|
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
|
-
|
|
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, {
|
|
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));
|