@mercury-fw/cli 0.29.4 → 0.31.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 +28 -0
- package/README.md +92 -2
- package/dist/src/app/commands.d.ts +13 -0
- package/dist/src/app/local-packages.d.ts +26 -0
- package/dist/src/args.d.ts +9 -0
- package/dist/src/e2e/define.d.ts +73 -0
- package/dist/src/e2e/dump.d.ts +24 -0
- package/dist/src/e2e/expect.d.ts +12 -0
- package/dist/src/e2e/load.d.ts +9 -0
- package/dist/src/e2e/runner.d.ts +42 -0
- package/dist/src/e2e/session.d.ts +17 -0
- package/dist/src/finish.d.ts +52 -0
- package/dist/src/main.d.ts +3 -1
- package/dist/src/wizard.d.ts +3 -1
- package/package.json +9 -4
- package/src/app/commands.ts +85 -2
- package/src/app/local-packages.ts +51 -0
- package/src/args.ts +12 -1
- package/src/e2e/define.ts +71 -0
- package/src/e2e/dump.ts +48 -0
- package/src/e2e/expect.ts +59 -0
- package/src/e2e/load.ts +40 -0
- package/src/e2e/runner.ts +153 -0
- package/src/e2e/session.ts +104 -0
- package/src/finish.ts +144 -0
- package/src/main.ts +35 -15
- package/src/program.ts +40 -8
- package/src/wizard.ts +15 -2
- package/template/Dockerfile.tpl +4 -1
- package/template/gitignore.tpl +2 -0
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The REPL session `mfw e2e` drives: the app's REPL as a child process (in
|
|
3
|
+
* the app's container, through `docker compose run`), fed one turn at a time.
|
|
4
|
+
* A turn writes its line and then `/dump <file>` together: the REPL handles
|
|
5
|
+
* one line after the other, so the dump runs once the turn is over, and its
|
|
6
|
+
* "wrote … to <file>" line is the turn's end, unlike the prompt, which an
|
|
7
|
+
* answer's own text could look like. The dump is read from the host side of
|
|
8
|
+
* the folder the REPL writes it into.
|
|
9
|
+
*/
|
|
10
|
+
import { readFileSync } from "node:fs";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import type { Session } from "./runner.ts";
|
|
13
|
+
|
|
14
|
+
export type ReplSessionOptions = {
|
|
15
|
+
/** The command that starts the REPL. */
|
|
16
|
+
argv: string[];
|
|
17
|
+
cwd: string;
|
|
18
|
+
/** Where the dumps are, on this side, and as the REPL sees the same folder. */
|
|
19
|
+
hostDir: string;
|
|
20
|
+
replDir: string;
|
|
21
|
+
/** Prefix of this session's dump files, unique among sessions sharing the folder. */
|
|
22
|
+
name: string;
|
|
23
|
+
/** How long one turn may take. */
|
|
24
|
+
timeoutMs: number;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/** Starts the REPL and returns the session over it once the REPL is ready
|
|
28
|
+
* (its first prompt is out), so a turn's time is only the turn's; throws
|
|
29
|
+
* when the REPL exits or doesn't get there in time. */
|
|
30
|
+
export async function openReplSession(opts: ReplSessionOptions): Promise<Session> {
|
|
31
|
+
const proc = Bun.spawn(opts.argv, { cwd: opts.cwd, stdin: "pipe", stdout: "pipe", stderr: "pipe" });
|
|
32
|
+
let stdout = "";
|
|
33
|
+
let stderr = "";
|
|
34
|
+
let exited: number | undefined;
|
|
35
|
+
/** Wakes whoever waits for more output or for the exit. */
|
|
36
|
+
let notify = () => {};
|
|
37
|
+
const pump = async (stream: ReadableStream<Uint8Array>, add: (text: string) => void) => {
|
|
38
|
+
const decoder = new TextDecoder();
|
|
39
|
+
for await (const chunk of stream) {
|
|
40
|
+
add(decoder.decode(chunk, { stream: true }));
|
|
41
|
+
notify();
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
void pump(proc.stdout, (t) => (stdout += t));
|
|
45
|
+
void pump(proc.stderr, (t) => (stderr += t));
|
|
46
|
+
void proc.exited.then((code) => {
|
|
47
|
+
exited = code;
|
|
48
|
+
notify();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
/** Waits until `done` holds on the output so far; throws when the REPL exits or time runs out. */
|
|
52
|
+
const waitFor = async <T>(done: () => T | undefined): Promise<T> => {
|
|
53
|
+
const deadline = Date.now() + opts.timeoutMs;
|
|
54
|
+
for (;;) {
|
|
55
|
+
const result = done();
|
|
56
|
+
if (result !== undefined) return result;
|
|
57
|
+
if (exited !== undefined) throw new Error(`the REPL exited with code ${exited}: ${stderr.trim().split("\n").slice(-3).join(" ")}`);
|
|
58
|
+
const left = deadline - Date.now();
|
|
59
|
+
if (left <= 0) throw new Error(`no reply within ${opts.timeoutMs / 1000} s`);
|
|
60
|
+
await new Promise<void>((resolve) => {
|
|
61
|
+
const timer = setTimeout(resolve, left);
|
|
62
|
+
notify = () => {
|
|
63
|
+
clearTimeout(timer);
|
|
64
|
+
resolve();
|
|
65
|
+
};
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
// What the REPL prints while starting isn't part of any turn. One that
|
|
71
|
+
// doesn't get there is stopped: a docker compose run container otherwise
|
|
72
|
+
// keeps running.
|
|
73
|
+
try {
|
|
74
|
+
await waitFor(() => (/> $/.test(stdout) ? true : undefined));
|
|
75
|
+
} catch (err) {
|
|
76
|
+
proc.kill();
|
|
77
|
+
throw err;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
let turns = 0;
|
|
81
|
+
return {
|
|
82
|
+
turn: async (line) => {
|
|
83
|
+
turns++;
|
|
84
|
+
const file = `${opts.name}-turn-${turns}.json`;
|
|
85
|
+
const marker = `to ${opts.replDir}/${file}`;
|
|
86
|
+
const from = stdout.length;
|
|
87
|
+
proc.stdin.write(`${line}\n/dump ${opts.replDir}/${file}\n`);
|
|
88
|
+
proc.stdin.flush();
|
|
89
|
+
return waitFor(() => {
|
|
90
|
+
const at = stdout.indexOf(marker, from);
|
|
91
|
+
if (at === -1) return undefined;
|
|
92
|
+
// What the turn printed: everything before the dump's own line.
|
|
93
|
+
const output = stdout.slice(from, stdout.lastIndexOf("wrote ", at));
|
|
94
|
+
return { dump: JSON.parse(readFileSync(join(opts.hostDir, file), "utf-8")) as unknown, output };
|
|
95
|
+
});
|
|
96
|
+
},
|
|
97
|
+
close: async () => {
|
|
98
|
+
if (exited !== undefined) return;
|
|
99
|
+
proc.stdin.end();
|
|
100
|
+
const done = await Promise.race([proc.exited, new Promise((r) => setTimeout(() => r("timeout"), 10_000))]);
|
|
101
|
+
if (done === "timeout") proc.kill();
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
}
|
package/src/finish.ts
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What `mfw create` does once the app's files are written: `bun install`, then
|
|
3
|
+
* a git repository on `main` with a first commit and, when given, its origin
|
|
4
|
+
* (never pushed: that stays the user's). Every step is best effort: a failure
|
|
5
|
+
* never undoes what came before, and the report says what happened, which
|
|
6
|
+
* `finishMessage` turns into the command's last words, next steps included.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** Runs `argv` in `cwd`; resolves with its exit code and its output (stdout
|
|
10
|
+
* and stderr together), or with an empty output when `live` sends it straight
|
|
11
|
+
* to the terminal. A missing binary resolves with a non-zero code. */
|
|
12
|
+
export type Run = (argv: string[], cwd: string, opts?: { live?: boolean }) => Promise<{ code: number; output: string }>;
|
|
13
|
+
|
|
14
|
+
/** Why git is skipped for an app created inside another repository: on
|
|
15
|
+
* purpose, so there's nothing to finish by hand. */
|
|
16
|
+
export const INSIDE_A_REPOSITORY = "the folder is already inside a git repository";
|
|
17
|
+
|
|
18
|
+
/** What to do after writing: `remote` is taken as typed. */
|
|
19
|
+
export type FinishOptions = {
|
|
20
|
+
install: boolean;
|
|
21
|
+
git: boolean;
|
|
22
|
+
remote?: string;
|
|
23
|
+
/** The first commit's message, one `-m` per paragraph. */
|
|
24
|
+
commitMessage: string[];
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/** A step that ran (`done`), wasn't asked for (`off`), was skipped with a
|
|
28
|
+
* reason, or failed with the command's output. */
|
|
29
|
+
type Outcome = "done" | "off" | { failed: string };
|
|
30
|
+
|
|
31
|
+
export type FinishReport = {
|
|
32
|
+
install: Outcome;
|
|
33
|
+
git: Outcome | { skipped: string };
|
|
34
|
+
remote: Outcome;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/** The real runner: a child process with its output captured. */
|
|
38
|
+
export const spawnRun: Run = async (argv, cwd, opts) => {
|
|
39
|
+
try {
|
|
40
|
+
if (opts?.live) {
|
|
41
|
+
const proc = Bun.spawn(argv, { cwd, stdin: "ignore", stdout: "inherit", stderr: "inherit" });
|
|
42
|
+
return { code: await proc.exited, output: "" };
|
|
43
|
+
}
|
|
44
|
+
const proc = Bun.spawn(argv, { cwd, stdin: "ignore", stdout: "pipe", stderr: "pipe" });
|
|
45
|
+
const [stdout, stderr, code] = await Promise.all([
|
|
46
|
+
new Response(proc.stdout).text(),
|
|
47
|
+
new Response(proc.stderr).text(),
|
|
48
|
+
proc.exited,
|
|
49
|
+
]);
|
|
50
|
+
return { code, output: stdout + stderr };
|
|
51
|
+
} catch (err) {
|
|
52
|
+
return { code: 127, output: err instanceof Error ? err.message : String(err) };
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/** Runs the steps after writing the app in `dir`; see the file's comment. */
|
|
57
|
+
export async function finishApp(dir: string, opts: FinishOptions, run: Run): Promise<FinishReport> {
|
|
58
|
+
const report: FinishReport = { install: "off", git: "off", remote: "off" };
|
|
59
|
+
if (opts.install) {
|
|
60
|
+
// Live: an install can take a while, and its progress is worth seeing.
|
|
61
|
+
const installed = await run(["bun", "install"], dir, { live: true });
|
|
62
|
+
report.install = installed.code === 0 ? "done" : { failed: `exit code ${installed.code}` };
|
|
63
|
+
}
|
|
64
|
+
if (!opts.git) return report;
|
|
65
|
+
|
|
66
|
+
const skipped = await whyNoGit(dir, run);
|
|
67
|
+
if (skipped !== undefined) {
|
|
68
|
+
report.git = { skipped };
|
|
69
|
+
return report;
|
|
70
|
+
}
|
|
71
|
+
const commit = opts.commitMessage.flatMap((paragraph) => ["-m", paragraph]);
|
|
72
|
+
const steps: [label: string, argv: string[]][] = [
|
|
73
|
+
["git init", ["git", "init", "-q", "-b", "main"]],
|
|
74
|
+
["git add -A", ["git", "add", "-A"]],
|
|
75
|
+
["git commit", ["git", "commit", "-q", ...commit]],
|
|
76
|
+
];
|
|
77
|
+
for (const [label, argv] of steps) {
|
|
78
|
+
const step = await run(argv, dir);
|
|
79
|
+
if (step.code !== 0) {
|
|
80
|
+
report.git = { failed: `${label}: ${step.output.trim()}` };
|
|
81
|
+
return report;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
report.git = "done";
|
|
85
|
+
|
|
86
|
+
if (opts.remote !== undefined) {
|
|
87
|
+
const added = await run(["git", "remote", "add", "origin", opts.remote], dir);
|
|
88
|
+
report.remote = added.code === 0 ? "done" : { failed: added.output.trim() };
|
|
89
|
+
}
|
|
90
|
+
return report;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Why the repository can't be created in `dir`, or undefined when it can.
|
|
94
|
+
* Whether git can commit (an identity, hooks, signing) is git's to say: the
|
|
95
|
+
* commit runs, and its refusal is reported as it is. */
|
|
96
|
+
async function whyNoGit(dir: string, run: Run): Promise<string | undefined> {
|
|
97
|
+
if ((await run(["git", "--version"], dir)).code !== 0) return "git isn't installed";
|
|
98
|
+
// An app created inside a monorepo belongs to that repository, not a nested one.
|
|
99
|
+
if ((await run(["git", "rev-parse", "--is-inside-work-tree"], dir)).code === 0) return INSIDE_A_REPOSITORY;
|
|
100
|
+
return undefined;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** `outcome` as a status line's value, or undefined for a step that was off. */
|
|
104
|
+
function statusOf(outcome: FinishReport[keyof FinishReport], done: string): string | undefined {
|
|
105
|
+
if (outcome === "off") return undefined;
|
|
106
|
+
if (outcome === "done") return done;
|
|
107
|
+
if ("skipped" in outcome) return `skipped, ${outcome.skipped}`;
|
|
108
|
+
return `failed (${outcome.failed})`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** The command's closing output: where the app is, what the steps after
|
|
112
|
+
* writing did, and what's left to run. */
|
|
113
|
+
export function finishMessage(app: { name: string; dir: string; remote?: string; report: FinishReport }): string {
|
|
114
|
+
const { report } = app;
|
|
115
|
+
const insideRepository = typeof report.git === "object" && "skipped" in report.git && report.git.skipped === INSIDE_A_REPOSITORY;
|
|
116
|
+
// A repository that git didn't finish (not installed, a step refused) is
|
|
117
|
+
// finished by hand; one skipped on purpose isn't.
|
|
118
|
+
const byHand = report.git !== "done" && report.git !== "off" && !insideRepository;
|
|
119
|
+
const status = [
|
|
120
|
+
["bun install", statusOf(report.install, "done")],
|
|
121
|
+
["git", statusOf(report.git, "first commit on main")],
|
|
122
|
+
["origin", insideRepository && app.remote !== undefined ? "not added, the app is in that repository" : statusOf(report.remote, app.remote ?? "")],
|
|
123
|
+
].flatMap(([step, value]) => (value === undefined ? [] : [` ${step}: ${value}\n`]));
|
|
124
|
+
|
|
125
|
+
const next = [`cd ${app.dir}`];
|
|
126
|
+
if (report.install !== "done") next.push("bun install");
|
|
127
|
+
if (byHand) {
|
|
128
|
+
next.push("git init -b main", "git add -A", 'git commit -m "Scaffold with mfw create"');
|
|
129
|
+
if (app.remote !== undefined) next.push(`git remote add origin ${app.remote}`);
|
|
130
|
+
} else if (report.git === "done") {
|
|
131
|
+
if (report.install !== "done") next.push('git add bun.lock && git commit -m "Add bun.lock"');
|
|
132
|
+
if (app.remote !== undefined && report.remote !== "done") next.push(`git remote add origin ${app.remote}`);
|
|
133
|
+
}
|
|
134
|
+
next.push("cp .env.example .env # then fill it in", "mfw start # bunx mfw start, without a global mfw");
|
|
135
|
+
if (report.git === "done" && report.remote === "done") next.push("git push -u origin main");
|
|
136
|
+
|
|
137
|
+
return `Created ${app.name} in ${app.dir}
|
|
138
|
+
${status.join("")}
|
|
139
|
+
Next:
|
|
140
|
+
${next.map((line) => ` ${line}\n`).join("")}
|
|
141
|
+
Optional, to have mfw everywhere:
|
|
142
|
+
bun add -g @mercury-fw/cli
|
|
143
|
+
`;
|
|
144
|
+
}
|
package/src/main.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The `mfw` command. `create <folder>` writes a new Mercury app from the
|
|
3
3
|
* template, asking what to put in it (or taking the answers from flags with
|
|
4
|
-
* `--yes`)
|
|
4
|
+
* `--yes`), then installs it and commits it to a new repository (`finish.ts`). The other
|
|
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
|
*/
|
|
@@ -16,6 +16,7 @@ import { appVersions, cliVersion, newerCli, registryFrom } from "./versions.ts";
|
|
|
16
16
|
import { appCommands, terminalDeps, type AppDeps } from "./app/commands.ts";
|
|
17
17
|
import { findApp, type App } from "./app/find-app.ts";
|
|
18
18
|
import { askAnswers, DEFAULT_ASSISTANT_NAME, DEFAULT_ROLE, type Answers } from "./wizard.ts";
|
|
19
|
+
import { finishApp, finishMessage, spawnRun, type Run } from "./finish.ts";
|
|
19
20
|
import { targetError, writeApp } from "./write.ts";
|
|
20
21
|
|
|
21
22
|
/** The answers taken from the flags alone, defaults for the rest. */
|
|
@@ -26,6 +27,7 @@ function answersFromFlags(args: CreateArgs, defaultName: string): Answers {
|
|
|
26
27
|
role: args.role ?? DEFAULT_ROLE,
|
|
27
28
|
channels: args.channels ?? [],
|
|
28
29
|
plugins: args.plugins ?? [],
|
|
30
|
+
...(args.gitRemote !== undefined ? { gitRemote: args.gitRemote } : {}),
|
|
29
31
|
};
|
|
30
32
|
}
|
|
31
33
|
|
|
@@ -90,9 +92,18 @@ async function relaunchIfStale(rawArgs: string[], relaunch: Relaunch, globalInst
|
|
|
90
92
|
return relaunch(["bunx", cli, "create", ...rawArgs], env);
|
|
91
93
|
}
|
|
92
94
|
|
|
95
|
+
/** What's wrong with `--git-remote`, or undefined. It's taken as typed, but
|
|
96
|
+
* one starting with "-" would reach git as an option. */
|
|
97
|
+
function remoteError(args: CreateArgs): string | undefined {
|
|
98
|
+
if (args.gitRemote === undefined) return undefined;
|
|
99
|
+
if (!args.git) return "--git-remote needs the repository: drop --no-git";
|
|
100
|
+
if (args.gitRemote.startsWith("-")) return `--git-remote "${args.gitRemote}" isn't a remote`;
|
|
101
|
+
return undefined;
|
|
102
|
+
}
|
|
103
|
+
|
|
93
104
|
/** `mfw create`: returns the exit code. `rawArgs` are the arguments after
|
|
94
105
|
* `create` as typed, for a relaunch. */
|
|
95
|
-
async function create(args: CreateArgs, rawArgs: string[], relaunch: Relaunch, globalInstall: boolean): Promise<number> {
|
|
106
|
+
async function create(args: CreateArgs, rawArgs: string[], relaunch: Relaunch, globalInstall: boolean, run: Run): Promise<number> {
|
|
96
107
|
const relaunched = await relaunchIfStale(rawArgs, relaunch, globalInstall);
|
|
97
108
|
if (relaunched !== undefined) return relaunched;
|
|
98
109
|
// The folder is created in kebab case, only its own name: the parent path is
|
|
@@ -105,7 +116,10 @@ async function create(args: CreateArgs, rawArgs: string[], relaunch: Relaunch, g
|
|
|
105
116
|
const dir = join(dirname(typed), folder);
|
|
106
117
|
// What the command line already settles is checked before any question, so
|
|
107
118
|
// the wizard is never answered for nothing.
|
|
108
|
-
const early =
|
|
119
|
+
const early =
|
|
120
|
+
targetError(dir) ??
|
|
121
|
+
selectionError(args.channels ?? [], args.plugins ?? []) ??
|
|
122
|
+
remoteError(args);
|
|
109
123
|
if (early !== undefined) {
|
|
110
124
|
throw new Error(early);
|
|
111
125
|
}
|
|
@@ -118,16 +132,21 @@ async function create(args: CreateArgs, rawArgs: string[], relaunch: Relaunch, g
|
|
|
118
132
|
).map((e) => e.package);
|
|
119
133
|
const versions = await appVersions(chosen, { registry: registryFrom(process.env.MFW_REGISTRY) });
|
|
120
134
|
writeApp(dir, renderApp({ ...answers, versions }));
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
135
|
+
const none = (ids: string[]) => (ids.length > 0 ? ids.join(", ") : "none");
|
|
136
|
+
const report = await finishApp(
|
|
137
|
+
dir,
|
|
138
|
+
{
|
|
139
|
+
install: args.install,
|
|
140
|
+
git: args.git,
|
|
141
|
+
...(answers.gitRemote !== undefined ? { remote: answers.gitRemote } : {}),
|
|
142
|
+
commitMessage: [
|
|
143
|
+
`Scaffold with mfw create ${cliVersion()}`,
|
|
144
|
+
`Channels: ${none(answers.channels)}\nPlugins: ${none(answers.plugins)}`,
|
|
145
|
+
],
|
|
146
|
+
},
|
|
147
|
+
run,
|
|
148
|
+
);
|
|
149
|
+
process.stdout.write(finishMessage({ name: answers.name, dir, remote: answers.gitRemote, report }));
|
|
131
150
|
return 0;
|
|
132
151
|
}
|
|
133
152
|
|
|
@@ -203,13 +222,14 @@ export async function main(
|
|
|
203
222
|
deps,
|
|
204
223
|
relaunch = spawnRelaunch,
|
|
205
224
|
globalInstall = isGlobalInstall(),
|
|
206
|
-
|
|
225
|
+
run = spawnRun,
|
|
226
|
+
}: { cwd?: string; deps?: AppDeps; relaunch?: Relaunch; globalInstall?: boolean; run?: Run } = {},
|
|
207
227
|
): Promise<number> {
|
|
208
228
|
const handedOver = await handOverToAppCli(argv, cwd, relaunch);
|
|
209
229
|
if (handedOver !== undefined) return handedOver;
|
|
210
230
|
const rawCreateArgs = argv.slice(argv.indexOf("create") + 1);
|
|
211
231
|
return runProgram(argv, {
|
|
212
|
-
create: (args) => create(args, rawCreateArgs, relaunch, globalInstall),
|
|
232
|
+
create: (args) => create(args, rawCreateArgs, relaunch, globalInstall, run),
|
|
213
233
|
upgrade: () => upgrade(relaunch),
|
|
214
234
|
app: () => appCommands(findApp(cwd), deps ?? terminalDeps()),
|
|
215
235
|
});
|
package/src/program.ts
CHANGED
|
@@ -24,12 +24,14 @@ export type ProgramHandlers = {
|
|
|
24
24
|
app: () => AppCommands;
|
|
25
25
|
};
|
|
26
26
|
|
|
27
|
-
/**
|
|
28
|
-
function positiveInt(
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
27
|
+
/** A parser for `flag`'s value: a positive whole number, kept as typed. */
|
|
28
|
+
function positiveInt(flag: string): (value: string) => string {
|
|
29
|
+
return (value) => {
|
|
30
|
+
if (!/^\d+$/.test(value) || Number(value) < 1) {
|
|
31
|
+
throw new InvalidArgumentError(`${flag} takes a positive whole number (got "${value}").`);
|
|
32
|
+
}
|
|
33
|
+
return value;
|
|
34
|
+
};
|
|
33
35
|
}
|
|
34
36
|
|
|
35
37
|
/** The help's closing paragraph for the commands that run inside an app. */
|
|
@@ -50,7 +52,7 @@ function buildProgram(handlers: ProgramHandlers, result: { code: number }): Comm
|
|
|
50
52
|
.command("create")
|
|
51
53
|
.summary("writes a new Mercury app")
|
|
52
54
|
.description(
|
|
53
|
-
"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.
|
|
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).",
|
|
54
56
|
)
|
|
55
57
|
.argument("<folder>", "where to write the app")
|
|
56
58
|
.option("--name <name>", "app name, as in package.json (default: the folder's name)")
|
|
@@ -61,6 +63,9 @@ function buildProgram(handlers: ProgramHandlers, result: { code: number }): Comm
|
|
|
61
63
|
`comma-separated: ${CATALOG.filter((e) => e.kind === "channel").map((e) => e.id).join(", ")}`,
|
|
62
64
|
)
|
|
63
65
|
.option("--plugins <ids>", `comma-separated: ${CATALOG.filter((e) => e.kind === "tool").map((e) => e.id).join(", ")}`)
|
|
66
|
+
.option("--git-remote <url>", "the repository's origin, taken as typed (default: none)")
|
|
67
|
+
.option("--no-install", "don't run bun install")
|
|
68
|
+
.option("--no-git", "don't create the git repository")
|
|
64
69
|
.option("-y, --yes", "don't ask: use the flags and the defaults")
|
|
65
70
|
.addHelpText(
|
|
66
71
|
"after",
|
|
@@ -196,7 +201,7 @@ Examples:
|
|
|
196
201
|
"Prints a collection's points, each as its id and one line per payload field: newest first where the collection has a timestamp index, in Qdrant's own order otherwise.",
|
|
197
202
|
)
|
|
198
203
|
.argument("<collection>", "as mfw memory list prints it")
|
|
199
|
-
.option("--limit <n>", "how many points (default: 20)", positiveInt)
|
|
204
|
+
.option("--limit <n>", "how many points (default: 20)", positiveInt("--limit"))
|
|
200
205
|
.action(async (collection: string, opts: { limit?: string }) =>
|
|
201
206
|
inApp((app) => app.memory(["read", collection, ...(opts.limit === undefined ? [] : ["--limit", opts.limit])]))(),
|
|
202
207
|
);
|
|
@@ -264,6 +269,33 @@ Examples:
|
|
|
264
269
|
)(),
|
|
265
270
|
);
|
|
266
271
|
|
|
272
|
+
program
|
|
273
|
+
.command("local-packages")
|
|
274
|
+
.summary("installs @mercury-fw packages from local tarballs")
|
|
275
|
+
.description(
|
|
276
|
+
"Makes the app install the packages in <folder>'s tarballs (bun pm pack) instead of the registry's: copies them into .packs/ (the image copies it too), points each package at its tarball with overrides in package.json, so packages that depend on it get it as well, and runs bun install. Run it again after packing anew. --off removes them and installs from the registry.",
|
|
277
|
+
)
|
|
278
|
+
.argument("[folder]", "the folder holding the .tgz files")
|
|
279
|
+
.option("--off", "go back to the registry's packages")
|
|
280
|
+
.addHelpText("after", `\nExamples:\n mfw local-packages ../mercury-fw/apps/testbed/.packs\n mfw local-packages --off${INSIDE_AN_APP}`)
|
|
281
|
+
.action(async (folder: string | undefined, opts: { off?: boolean }) => {
|
|
282
|
+
if ((folder === undefined) === (opts.off !== true)) throw new Error("local-packages takes a folder of tarballs, or --off");
|
|
283
|
+
await inApp((app) => (folder === undefined ? app.localPackagesOff() : app.localPackages(folder)))();
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
program
|
|
287
|
+
.command("e2e")
|
|
288
|
+
.summary("runs the app's end-to-end tests against its model")
|
|
289
|
+
.description(
|
|
290
|
+
"Runs e2e tests: each case's turns go to the app's real model through its REPL (in the container, like mfw repl), and its checks run on the tool calls each turn made and the answer it gave. Without files, every e2e/*.e2e.ts in the app. Prints every check, exits 1 when a case didn't pass enough runs, and keeps the turns and checks in e2e/results/<time>/. The app must be built and its env file filled in, as for mfw repl.",
|
|
291
|
+
)
|
|
292
|
+
.argument("[tests...]", "test files (default: e2e/*.e2e.ts)")
|
|
293
|
+
.option("--repeat <n>", "runs of each case, overriding its own repeat", positiveInt("--repeat"))
|
|
294
|
+
.addHelpText("after", `\nExamples:\n mfw e2e\n mfw e2e e2e/jira.e2e.ts --repeat 3${INSIDE_AN_APP}`)
|
|
295
|
+
.action(async (tests: string[], opts: { repeat?: string }) =>
|
|
296
|
+
inApp((app) => app.e2e(tests, opts.repeat === undefined ? {} : { repeat: Number(opts.repeat) }))(),
|
|
297
|
+
);
|
|
298
|
+
|
|
267
299
|
return program;
|
|
268
300
|
}
|
|
269
301
|
|
package/src/wizard.ts
CHANGED
|
@@ -9,13 +9,15 @@ import { CATALOG, type CatalogEntry } from "./catalog.ts";
|
|
|
9
9
|
import { appNameError } from "./render.ts";
|
|
10
10
|
import type { CreateArgs } from "./args.ts";
|
|
11
11
|
|
|
12
|
-
/** The answers `renderApp` needs, apart from the package versions
|
|
12
|
+
/** The answers `renderApp` needs, apart from the package versions, and the
|
|
13
|
+
* repository's origin (none when absent). */
|
|
13
14
|
export type Answers = {
|
|
14
15
|
name: string;
|
|
15
16
|
assistantName: string;
|
|
16
17
|
role: string;
|
|
17
18
|
channels: string[];
|
|
18
19
|
plugins: string[];
|
|
20
|
+
gitRemote?: string;
|
|
19
21
|
};
|
|
20
22
|
|
|
21
23
|
export const DEFAULT_ASSISTANT_NAME = "Mercury";
|
|
@@ -67,6 +69,16 @@ export async function askAnswers(args: CreateArgs, dir: string): Promise<Answers
|
|
|
67
69
|
if (channels === undefined) return cancelled();
|
|
68
70
|
const plugins = await pick("tool", "Tool plugins (space to select)", args.plugins ?? []);
|
|
69
71
|
if (plugins === undefined) return cancelled();
|
|
72
|
+
// Asked only when there will be a repository and the flag didn't say.
|
|
73
|
+
let gitRemote = args.gitRemote;
|
|
74
|
+
if (args.git && gitRemote === undefined) {
|
|
75
|
+
const typed = await p.text({
|
|
76
|
+
message: "Git remote for origin (empty: none, add it later)",
|
|
77
|
+
validate: (v) => (v?.trim().startsWith("-") ? "That's not a remote: git would read it as an option" : undefined),
|
|
78
|
+
});
|
|
79
|
+
if (p.isCancel(typed)) return cancelled();
|
|
80
|
+
if (typed && typed.trim()) gitRemote = typed.trim();
|
|
81
|
+
}
|
|
70
82
|
|
|
71
83
|
p.note(
|
|
72
84
|
[
|
|
@@ -74,12 +86,13 @@ export async function askAnswers(args: CreateArgs, dir: string): Promise<Answers
|
|
|
74
86
|
`Assistant: You are ${assistantName}, ${role}.`,
|
|
75
87
|
`Channels: ${channels.length > 0 ? channels.join(", ") : "none"}`,
|
|
76
88
|
`Tool plugins: ${plugins.length > 0 ? plugins.join(", ") : "none"}`,
|
|
89
|
+
...(args.git ? [`Origin: ${gitRemote ?? "none"}`] : []),
|
|
77
90
|
].join("\n"),
|
|
78
91
|
"Summary",
|
|
79
92
|
);
|
|
80
93
|
const ok = await p.confirm({ message: `Create it in ${dir}?` });
|
|
81
94
|
if (p.isCancel(ok) || !ok) return cancelled();
|
|
82
|
-
return { name, assistantName, role, channels, plugins };
|
|
95
|
+
return { name, assistantName, role, channels, plugins, ...(gitRemote !== undefined ? { gitRemote } : {}) };
|
|
83
96
|
}
|
|
84
97
|
|
|
85
98
|
function cancelled(): undefined {
|
package/template/Dockerfile.tpl
CHANGED
|
@@ -12,8 +12,11 @@ RUN groupadd -r mercury && useradd -r -g mercury mercury
|
|
|
12
12
|
WORKDIR /app
|
|
13
13
|
|
|
14
14
|
# Each tool plugin downloads its pinned CLI in its postinstall (allowed by
|
|
15
|
-
# package.json's trustedDependencies); the binaries go on PATH.
|
|
15
|
+
# package.json's trustedDependencies); the binaries go on PATH. .packs/ holds
|
|
16
|
+
# local tarballs when the app installs them (mfw local-packages), and is
|
|
17
|
+
# skipped when it isn't there.
|
|
16
18
|
COPY --chown=mercury:mercury package.json bun.lock* ./
|
|
19
|
+
COPY --chown=mercury:mercury .pack[s] ./.packs/
|
|
17
20
|
RUN bun install --production && chown -R mercury:mercury node_modules
|
|
18
21
|
RUN find /app/node_modules -path '*/@mercury-fw/*/bin/*' -type f -exec ln -sf {} /usr/local/bin/ \;
|
|
19
22
|
|