@mercury-fw/cli 0.25.1 → 0.27.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,37 @@
1
1
  # @mercury-fw/cli
2
2
 
3
+ ## 0.27.0
4
+
5
+ ### Minor Changes
6
+
7
+ - d2a6be5: - A scaffolded app with tool plugins gets a `docker-entrypoint.sh`: at the first start without a CLI's config folder on the credentials volume, it unpacks that CLI's variable from the env file there, then starts the service. What the CLI refreshes afterwards stays on the volume.
8
+ - The scaffolded env example lists each tool plugin's credentials variable, and the README explains the flow.
9
+ - `mfw credentials set <plugin>` packs a CLI's config folder (`~/.config/<cli>`, or `--from <dir>`) into its variable in the app's env file, never printing it; `--print` prints the line to paste elsewhere.
10
+ - `mfw credentials reset <plugin>` clears a CLI's folder from the credentials volume after you type the plugin's name, so a corrected variable is unpacked at the next start.
11
+
12
+ ### Patch Changes
13
+
14
+ - @mercury-fw/core@0.27.0
15
+
16
+ ## 0.26.0
17
+
18
+ ### Minor Changes
19
+
20
+ - 8b92abb: - `mfw` operates an app from inside its folder: `start`, `stop` and `restart` (with `--no-cache`), `logs`, `repl`, `shell`, all as `docker compose` calls.
21
+ - `mfw vault` maintains the wiki vault (`list`, `read`, `grep`, `write-curated`, `write-raw`) in a one-off container.
22
+ - `mfw memory list` and `mfw memory read <collection>` read the memory on Qdrant, newest first where the collection has a timestamp index.
23
+ - `mfw reset memory` and `mfw reset wiki` delete a memory volume after you type the app's name, then bring the service back up empty; after a memory reset a running app is restarted, so it sets up its collections again.
24
+ - The command line is declared with commander: `--help` at every level, a suggestion for a mistyped command, and every argument checked before anything runs.
25
+ - A scaffolded app lists `@mercury-fw/cli` among its devDependencies, at the framework's version, and its README runs it through `bunx mfw`.
26
+ - A scaffolded app's `@types/bun` and `typescript` use caret ranges instead of exact versions.
27
+ - The core ships a read-only memory CLI (`src/memory/memory-cli.ts`) next to the vault one, which is what `mfw memory` runs.
28
+ - The vault CLI says so when asked to read a note that doesn't exist, instead of printing a stack trace.
29
+
30
+ ### Patch Changes
31
+
32
+ - Updated dependencies [8b92abb]
33
+ - @mercury-fw/core@0.26.0
34
+
3
35
  ## 0.25.1
4
36
 
5
37
  ### Patch Changes
package/README.md CHANGED
@@ -1,18 +1,39 @@
1
1
  # @mercury-fw/cli
2
2
 
3
- `mfw`, the command-line tool of [Mercury](https://github.com/lucabro81/mercury-fw). For now it has one command, `mfw create`, which scaffolds a new app; operating an app (starting it, the REPL, memory maintenance) moves here next.
3
+ `mfw`, the command-line tool of [Mercury](https://github.com/lucabro81/mercury-fw): it creates an app, then runs it. Every command except `create` works from inside an app, meaning its folder or any folder under it (the one holding `mercury.config.ts`), and wraps the `docker compose` calls that app needs, so you don't have to remember them; each section below says which calls those are. New commands get documented here as they're added.
4
4
 
5
- ```bash
6
- bunx @mercury-fw/cli create my-agent
7
- ```
5
+ ## Table of contents
6
+
7
+ - [Getting it](#getting-it)
8
+ - [Usage](#usage)
9
+ - [`mfw create <folder>`](#mfw-create-folder)
10
+ - [`mfw start [--no-cache]`](#mfw-start---no-cache)
11
+ - [`mfw stop`](#mfw-stop)
12
+ - [`mfw restart [--no-cache]`](#mfw-restart---no-cache)
13
+ - [`mfw logs [service]`](#mfw-logs-service)
14
+ - [`mfw repl`](#mfw-repl)
15
+ - [`mfw shell`](#mfw-shell)
16
+ - [`mfw vault <command>`](#mfw-vault-command)
17
+ - [`mfw memory list`](#mfw-memory-list)
18
+ - [`mfw memory read <collection> [--limit N]`](#mfw-memory-read-collection---limit-n)
19
+ - [`mfw reset <memory|wiki>`](#mfw-reset-memorywiki)
20
+ - [`mfw credentials set <plugin> [--from <dir>] [--print]`](#mfw-credentials-set-plugin---from-dir---print)
21
+ - [`mfw credentials reset <plugin>`](#mfw-credentials-reset-plugin)
22
+ - [Help](#help)
8
23
 
9
- or, the same thing under the `create` convention:
24
+ ## Getting it
25
+
26
+ A new app comes from `bun create mercury-agent`, which is `mfw create` under the `create` convention:
10
27
 
11
28
  ```bash
12
29
  bun create mercury-agent my-agent
13
30
  ```
14
31
 
15
- ## `mfw create <folder>`
32
+ The app it writes lists `@mercury-fw/cli` among its devDependencies, at the same version as the framework it depends on, so after `bun install` in the app every other command runs as `bunx mfw <command>`, with no global install.
33
+
34
+ ## Usage
35
+
36
+ ### `mfw create <folder>`
16
37
 
17
38
  Writes a new Mercury app into `<folder>`, which has to be missing or empty; the folder's own name is turned into kebab case (`My Agent` becomes `my-agent`, the path above it stays as typed). Without flags it asks:
18
39
 
@@ -32,9 +53,143 @@ It writes the `mercury.config.ts` for that selection, the persona, the service a
32
53
  | `-y`, `--yes` | No questions: the flags, and the defaults for the rest. |
33
54
 
34
55
  ```bash
35
- mfw create my-agent --assistant-name Hermes --channels http --plugins jira --yes
56
+ bun create mercury-agent my-agent
57
+ bunx @mercury-fw/cli create my-agent --assistant-name Hermes --channels http --plugins jira --yes
36
58
  ```
37
59
 
38
60
  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.
39
61
 
62
+ ### `mfw start [--no-cache]`
63
+
64
+ Builds the app's image and starts the app and Qdrant in the background (`docker compose up -d --build`). Docker's cache means only what changed gets rebuilt, and a running container is recreated only if its image or configuration changed, so it's also the command to run after changing a dependency, the Dockerfile or `.env`. `--no-cache` rebuilds everything from scratch (`docker compose build --no-cache`, then `up -d`), which is what refetches a tool plugin's CLI binary when a new release is out: a normal build keeps the cached one.
65
+
66
+ ```bash
67
+ bunx mfw start
68
+ bunx mfw start --no-cache
69
+ ```
70
+
71
+ ### `mfw stop`
72
+
73
+ Stops the app and Qdrant and removes their containers (`docker compose down`). The volumes stay, so memory, the wiki and the CLI credentials are all there on the next start.
74
+
75
+ ```bash
76
+ bunx mfw stop
77
+ ```
78
+
79
+ ### `mfw restart [--no-cache]`
80
+
81
+ Like `start`, but recreates the containers even when nothing changed (`--force-recreate`): a clean restart, for a process that got stuck. A changed `.env` doesn't need it, `start` already recreates what the change touches. `--no-cache` as in `start`.
82
+
83
+ ```bash
84
+ bunx mfw restart
85
+ bunx mfw restart --no-cache
86
+ ```
87
+
88
+ ### `mfw logs [service]`
89
+
90
+ Follows the logs of every service, interleaved, or only of the one you name, `mercury` or `qdrant` (`docker compose logs -f`). One service at most. `Ctrl+C` stops following, the app keeps running.
91
+
92
+ ```bash
93
+ bunx mfw logs
94
+ bunx mfw logs mercury
95
+ ```
96
+
97
+ ### `mfw repl`
98
+
99
+ Opens the dev REPL, a conversation with the assistant in the terminal, in a one-off container (`docker compose run --rm mercury bun run repl`). The REPL has no user identity by design, so what you try there never lands in anyone's memory; ending it (`Ctrl+D`) removes the one-off container and leaves a running app alone.
100
+
101
+ ```bash
102
+ bunx mfw repl
103
+ ```
104
+
105
+ ### `mfw shell`
106
+
107
+ Opens a shell in the app's container: the running one if the app is up (`docker compose exec mercury bash`), a one-off one otherwise (`docker compose run --rm mercury bash`). The tool plugins' CLIs are on `PATH` there with their credentials, so it's where you check that a command works before blaming the model.
108
+
109
+ ```bash
110
+ bunx mfw shell
111
+ ```
112
+
113
+ ### `mfw vault <command>`
114
+
115
+ Maintains the wiki vault, which lives on a Docker volume and not in the app's folder, so every command runs in a one-off container on that volume (`docker compose run --rm -T mercury bun …`). Paths are vault-relative, the way `list` prints them, `curated/` or `raw/` included.
116
+
117
+ | Command | |
118
+ |---|---|
119
+ | `list` | Every note. |
120
+ | `read <path>` | One note (a path that isn't one says so, exit 1). |
121
+ | `grep <pattern>` | Every line matching `<pattern>`, a regular expression, as `path:line:text`. A pattern starting with `-` goes after `--` (`mfw vault grep -- -h`). |
122
+ | `write-curated <path> [--author NAME]` | Writes a curated note, the body read from stdin. |
123
+ | `write-raw <path>` | Writes raw material for the nightly review to triage, the body read from stdin. |
124
+
125
+ ```bash
126
+ bunx mfw vault list
127
+ bunx mfw vault read curated/standards/jira-fields.md
128
+ bunx mfw vault grep "story points"
129
+ cat note.md | bunx mfw vault write-curated curated/standards/new-note.md --author luca
130
+ ```
131
+
132
+ There's no command writing inferred notes on purpose: those are the agent's own, written only by its consolidation.
133
+
134
+ ### `mfw memory list`
135
+
136
+ Lists the collections of the memory on Qdrant with how many points each holds, in a one-off container that reaches Qdrant the way the app does. Read-only.
137
+
138
+ ```bash
139
+ bunx mfw memory list
140
+ ```
141
+
142
+ ```
143
+ episodic_memory 25 points
144
+ semantic_facts 20 points
145
+ tool_corrections 37 points
146
+ verbatim_archive 0 points
147
+ ```
148
+
149
+ ### `mfw memory read <collection> [--limit N]`
150
+
151
+ Prints a collection's points, each as its id followed by one line per payload field. Newest first where the collection has a timestamp index (episodic memory and the verbatim archive do); in Qdrant's own order otherwise, and it says so. `--limit` defaults to 20. Read-only.
152
+
153
+ ```bash
154
+ bunx mfw memory read episodic_memory
155
+ bunx mfw memory read semantic_facts --limit 5
156
+ ```
157
+
158
+ ### `mfw reset <memory|wiki>`
159
+
160
+ Deletes for good what the assistant remembers: `memory` is every collection on Qdrant, `wiki` the whole vault. It reads the volume's real name from the compose file, tells you which one is about to go, and asks you to type the app's name (the `name` in `package.json`); anything else, an empty answer, a `y` or closing the input included, deletes nothing and exits 1. Once confirmed it stops the service using the volume, removes its container and the volume, and starts the service again on an empty one (`docker compose stop`, `rm -f`, `docker volume rm`, `up -d`). After `memory`, a running app is restarted too (`docker compose restart mercury`), since it sets up its collections only when it starts.
161
+
162
+ If a step fails once the service is stopped (the volume still in use by a one-off container, say), it stops there and says the service is down: `bunx mfw start` brings it back.
163
+
164
+ ```bash
165
+ bunx mfw reset memory
166
+ bunx mfw reset wiki
167
+ ```
168
+
169
+ Useful for clearing out test data; the other layer isn't touched.
170
+
171
+ ### `mfw credentials set <plugin> [--from <dir>] [--print]`
172
+
173
+ Hands a tool plugin's CLI its login. Every such CLI keeps it in a config folder of its own (`jira-cli`, `bitbucket-cli`, `atlassian-admin-cli`); log in with the CLI on your machine first, then this packs that folder (`~/.config/<cli>`, or `--from` when it lives elsewhere) into a base64 tar.gz and writes it as the plugin's variable in the app's `.env` (`JIRA_CLI_CONFIG_TAR_B64` and so on), replacing an older value and leaving the other lines alone. The value is never printed; `--print` prints the whole line instead and leaves `.env` alone, for pasting it into another host's.
174
+
175
+ When the container starts, the app's `docker-entrypoint.sh` unpacks the variable onto the credentials volume, but only if that CLI's folder isn't there yet: what the CLI writes back while running, like a refreshed token, stays on the volume across redeploys, and an older value in `.env` never overwrites it. `<plugin>` has to be a tool plugin the app depends on.
176
+
177
+ ```bash
178
+ bunx mfw credentials set jira
179
+ bunx mfw credentials set bitbucket --from ~/work/bitbucket-login
180
+ bunx mfw credentials set jira --print
181
+ ```
182
+
183
+ ### `mfw credentials reset <plugin>`
184
+
185
+ Deletes the plugin's CLI folder from the credentials volume, so the variable in `.env` is unpacked again at the next start: what to run after correcting a variable whose folder is already on the volume, since the entrypoint never touches an existing folder. It asks you to type the plugin's name first, because a token the CLI refreshed on the volume goes too (and with a CLI that rotates its refresh token, the one in `.env` may no longer work). Once confirmed it stops the app, removes the folder in a one-off container of the app's own image, and starts the app again (`docker compose stop mercury`, `run --rm --no-deps -T mercury rm -rf …`, `up -d mercury`).
186
+
187
+ ```bash
188
+ bunx mfw credentials reset jira
189
+ ```
190
+
191
+ ## Help
192
+
193
+ `mfw --help` lists every command, and `--help` after any of them describes it, down to the subcommands (`mfw vault write-curated --help`). A mistyped command gets a suggestion (`mfw strat` → "Did you mean start?"). Every argument is checked before anything runs: a wrong one exits 1 saying why, with no container started.
194
+
40
195
  MIT
@@ -0,0 +1,76 @@
1
+ import type { App } from "./find-app.ts";
2
+ export type AppDeps = {
3
+ /** Runs `argv` in `cwd` on the user's terminal (stdin, stdout, stderr) and returns its exit code. */
4
+ run: (argv: string[], opts: {
5
+ cwd: string;
6
+ }) => Promise<number>;
7
+ /** Runs `argv` in `cwd` and returns its stdout; throws when it fails. */
8
+ capture: (argv: string[], opts: {
9
+ cwd: string;
10
+ }) => Promise<string>;
11
+ /** Asks the user `question` and returns the answer as typed. */
12
+ ask: (question: string) => Promise<string>;
13
+ /** Tells the user something. */
14
+ print: (line: string) => void;
15
+ /** The user's home folder, where a CLI keeps its config (`~/.config/<cli>`). */
16
+ home: string;
17
+ };
18
+ /** The core's maintenance CLIs, from the container's working directory (the
19
+ * app's folder, where node_modules/@mercury-fw/core is). A path and not a bin:
20
+ * a monorepo image installs before copying the sources, and Bun doesn't link a
21
+ * bin whose file isn't there yet. The core moves in lockstep with this CLI. */
22
+ export declare const VAULT_CLI = "node_modules/@mercury-fw/core/src/wiki/vault-cli.ts";
23
+ export declare const MEMORY_CLI = "node_modules/@mercury-fw/core/src/memory/memory-cli.ts";
24
+ /** What `mfw reset` can wipe: the compose service using the volume, the
25
+ * volume's key in the compose file, and how to say what's lost. */
26
+ export declare const RESET_TARGETS: {
27
+ readonly memory: {
28
+ readonly service: "qdrant";
29
+ readonly volume: "qdrant-data";
30
+ readonly what: "Layer-3 memory (every Qdrant collection)";
31
+ };
32
+ readonly wiki: {
33
+ readonly service: "mercury";
34
+ readonly volume: "wiki-vault";
35
+ readonly what: "the wiki vault (every note)";
36
+ };
37
+ };
38
+ export type ResetTarget = keyof typeof RESET_TARGETS;
39
+ /** The commands bound to `app`, each returning its exit code. */
40
+ export declare function appCommands(app: App, deps: AppDeps): {
41
+ start: ({ noCache }: {
42
+ noCache: boolean;
43
+ }) => Promise<number>;
44
+ restart: ({ noCache }: {
45
+ noCache: boolean;
46
+ }) => Promise<number>;
47
+ stop: () => Promise<number>;
48
+ logs: (service?: string) => Promise<number>;
49
+ repl: () => Promise<number>;
50
+ shell: () => Promise<number>;
51
+ /** `args` is the vault CLI's own command line (`list`, `read <path>`, …). */
52
+ vault: (args: string[]) => Promise<number>;
53
+ /** `args` is the memory CLI's own command line (`list`, `read <collection>`, …). */
54
+ memory: (args: string[]) => Promise<number>;
55
+ /** Deletes `target`'s volume once the user types the app's name, then
56
+ * brings its service back up on an empty volume. The volume's real name
57
+ * comes from the compose file, and a wrong answer deletes nothing. */
58
+ reset: (target: ResetTarget) => Promise<number>;
59
+ /** Packs the plugin's CLI config folder (`from`, by default
60
+ * `~/.config/<folder>`) into its credentials variable, written into the
61
+ * app's env file, or printed with `print`. */
62
+ credentialsSet: (plugin: string, { from, print }: {
63
+ from?: string;
64
+ print: boolean;
65
+ }) => Promise<number>;
66
+ /** Deletes the plugin's CLI folder from the credentials volume once the
67
+ * user types the plugin's name, so its variable is unpacked again at the
68
+ * next start. A wrong answer deletes nothing. */
69
+ credentialsReset: (plugin: string) => Promise<number>;
70
+ };
71
+ /** The real deps: docker on the user's terminal, questions on `input`
72
+ * (stdin by default). */
73
+ export declare function terminalDeps({ input, output, }?: {
74
+ input?: NodeJS.ReadableStream;
75
+ output?: NodeJS.WritableStream;
76
+ }): AppDeps;
@@ -0,0 +1,12 @@
1
+ /** `from` packed as `<folder>/…` into a base64 tar.gz on one line. The real
2
+ * folder behind `from` is archived through a symlink named `folder` and
3
+ * dereferenced (`-h`): whatever `from` is called, and even when it's itself a
4
+ * symlink (dotfiles), the archive holds the files under the CLI's folder name. */
5
+ export declare function packCredentials(from: string, folder: string): Promise<string>;
6
+ /** Sets `name=value` in the env file at `file`. Every line that assigns
7
+ * `name` (`export name=` and spaces around `=` included) gives way to one
8
+ * line, where the first was; with none, it's appended. Every other line stays
9
+ * as it was. The file is replaced whole through a temporary file next to it,
10
+ * keeping its permissions, so a failed write never leaves it half written; a
11
+ * missing file is created readable by its owner only. */
12
+ export declare function setEnvVar(file: string, name: string, value: string): void;
@@ -0,0 +1,6 @@
1
+ export type App = {
2
+ dir: string;
3
+ name: string;
4
+ };
5
+ /** The app containing `from`; throws when there's none, or when its manifest has no name. */
6
+ export declare function findApp(from: string): App;
@@ -1,3 +1,10 @@
1
+ /**
2
+ * `mfw create`'s answers as the command line gives them: the target folder
3
+ * plus what can be given as flags, either to skip the wizard (`--yes`) or to
4
+ * pre-fill it. The command line itself is parsed in `program.ts`; no
5
+ * validation of the answers here: `renderApp` owns that, so the wizard and the
6
+ * flags go through the same checks.
7
+ */
1
8
  /** What the command line says. An answer left out is asked by the wizard, or
2
9
  * takes its default with `yes`. */
3
10
  export type CreateArgs = {
@@ -9,6 +16,14 @@ export type CreateArgs = {
9
16
  plugins?: string[];
10
17
  yes: boolean;
11
18
  };
12
- /** Parses the arguments that follow `create`. Throws on a missing or extra
13
- * folder and on an unknown flag. */
14
- export declare function parseCreateArgs(argv: string[]): CreateArgs;
19
+ /** `create`'s options as commander hands them over. */
20
+ export type CreateOptions = {
21
+ name?: string;
22
+ assistantName?: string;
23
+ role?: string;
24
+ channels?: string;
25
+ plugins?: string;
26
+ yes?: boolean;
27
+ };
28
+ /** The answers in `folder` and `opts`, with only what was actually given. */
29
+ export declare function toCreateArgs(folder: string, opts: CreateOptions): CreateArgs;
@@ -21,6 +21,15 @@ export type CatalogEntry = {
21
21
  exportName: string;
22
22
  env: EnvVar[];
23
23
  formatter?: FormatterExample;
24
+ credentials?: CliCredentials;
25
+ };
26
+ /** Where a tool plugin's CLI keeps its login: `folder` under the CLI user's
27
+ * `~/.config`, and the env variable that carries that folder into the
28
+ * container (a base64 tar.gz with the folder at its root), materialized on
29
+ * the credentials volume the first time the folder isn't there. */
30
+ export type CliCredentials = {
31
+ folder: string;
32
+ variable: string;
24
33
  };
25
34
  /** Starting formatter rules for a plugin that hands the user lists: without a
26
35
  * rule a kind of list isn't shown, so the scaffolded config wraps the plugin
@@ -1,4 +1,9 @@
1
+ import { type AppDeps } from "./app/commands.ts";
1
2
  /** Runs `mfw` with `argv` (the arguments after the command name) and returns
2
3
  * the exit code, printing to stdout/stderr. `bin.ts` and `create-mercury-agent`
3
- * both call it. */
4
- export declare function main(argv: string[]): Promise<number>;
4
+ * both call it; the app commands look for the app from `cwd` and run docker
5
+ * through `deps`. */
6
+ export declare function main(argv: string[], { cwd, deps }?: {
7
+ cwd?: string;
8
+ deps?: AppDeps;
9
+ }): Promise<number>;
@@ -0,0 +1,22 @@
1
+ /**
2
+ * The `mfw` command line, declared with commander: every command, its
3
+ * arguments and options, and the help for each level (`mfw --help`,
4
+ * `mfw vault --help`, `mfw vault write-curated --help`). Parsing and
5
+ * validation happen here, before anything runs; what a command does lives in
6
+ * `create` (passed in) and `app/commands.ts`.
7
+ */
8
+ import { type OutputConfiguration } from "commander";
9
+ import { type CreateArgs } from "./args.ts";
10
+ import { appCommands } from "./app/commands.ts";
11
+ /** The commands that operate an app, bound to it. */
12
+ export type AppCommands = ReturnType<typeof appCommands>;
13
+ export type ProgramHandlers = {
14
+ /** `mfw create`; returns the exit code. */
15
+ create: (args: CreateArgs) => Promise<number>;
16
+ /** The app the app commands act on; throws outside one. */
17
+ app: () => AppCommands;
18
+ };
19
+ /** Runs `mfw` on `argv` (the arguments after the command name) and returns
20
+ * the exit code. Commander's own messages (help, errors) go to `output`, and
21
+ * so does the message of an error a command throws. */
22
+ export declare function runProgram(argv: string[], handlers: ProgramHandlers, output?: OutputConfiguration): Promise<number>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mercury-fw/cli",
3
- "version": "0.25.1",
3
+ "version": "0.27.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -36,12 +36,13 @@
36
36
  "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.",
37
37
  "dependencies": {
38
38
  "@clack/prompts": "^1.8.1",
39
- "@mercury-fw/core": "0.25.1"
39
+ "@mercury-fw/core": "0.27.0",
40
+ "commander": "^15.0.0"
40
41
  },
41
42
  "devDependencies": {
42
43
  "@mercury-fw/channel-google-chat": "0.1.0",
43
44
  "@mercury-fw/channel-http": "0.1.0",
44
- "@mercury-fw/formatter": "0.25.1",
45
+ "@mercury-fw/formatter": "0.27.0",
45
46
  "@mercury-fw/plugin-atlassian-admin": "0.1.0",
46
47
  "@mercury-fw/plugin-bitbucket": "0.1.0",
47
48
  "@mercury-fw/plugin-jira": "0.1.0",
@@ -0,0 +1,214 @@
1
+ /**
2
+ * The commands that operate an app (`mfw start`, `mfw vault`, …): each one is
3
+ * a short sequence of `docker compose` calls run from the app's folder, so the
4
+ * docker details live here and not in every app. The command line is parsed
5
+ * and validated before any of this runs (`program.ts`); what runs a command is
6
+ * injected (`AppDeps`), which is how the tests see the exact calls.
7
+ */
8
+ import { readFileSync } from "node:fs";
9
+ import { homedir } from "node:os";
10
+ import { join, resolve } from "node:path";
11
+ import { CATALOG, type CliCredentials } from "../catalog.ts";
12
+ import { packCredentials, setEnvVar } from "./credentials.ts";
13
+ import type { App } from "./find-app.ts";
14
+
15
+ export type AppDeps = {
16
+ /** Runs `argv` in `cwd` on the user's terminal (stdin, stdout, stderr) and returns its exit code. */
17
+ run: (argv: string[], opts: { cwd: string }) => Promise<number>;
18
+ /** Runs `argv` in `cwd` and returns its stdout; throws when it fails. */
19
+ capture: (argv: string[], opts: { cwd: string }) => Promise<string>;
20
+ /** Asks the user `question` and returns the answer as typed. */
21
+ ask: (question: string) => Promise<string>;
22
+ /** Tells the user something. */
23
+ print: (line: string) => void;
24
+ /** The user's home folder, where a CLI keeps its config (`~/.config/<cli>`). */
25
+ home: string;
26
+ };
27
+
28
+ const COMPOSE = ["docker", "compose"];
29
+
30
+ /** The app's service, the one the image builds. */
31
+ const SERVICE = "mercury";
32
+
33
+ /** The core's maintenance CLIs, from the container's working directory (the
34
+ * app's folder, where node_modules/@mercury-fw/core is). A path and not a bin:
35
+ * a monorepo image installs before copying the sources, and Bun doesn't link a
36
+ * bin whose file isn't there yet. The core moves in lockstep with this CLI. */
37
+ export const VAULT_CLI = "node_modules/@mercury-fw/core/src/wiki/vault-cli.ts";
38
+ export const MEMORY_CLI = "node_modules/@mercury-fw/core/src/memory/memory-cli.ts";
39
+
40
+ /** What `mfw reset` can wipe: the compose service using the volume, the
41
+ * volume's key in the compose file, and how to say what's lost. */
42
+ export const RESET_TARGETS = {
43
+ memory: { service: "qdrant", volume: "qdrant-data", what: "Layer-3 memory (every Qdrant collection)" },
44
+ wiki: { service: SERVICE, volume: "wiki-vault", what: "the wiki vault (every note)" },
45
+ } as const;
46
+ export type ResetTarget = keyof typeof RESET_TARGETS;
47
+
48
+ /** The compose calls that build and start the app; `recreate` restarts containers even when nothing changed. */
49
+ function startCalls(noCache: boolean, recreate: boolean): string[][] {
50
+ const up = [...COMPOSE, "up", "-d", ...(noCache ? [] : ["--build"]), ...(recreate ? ["--force-recreate"] : [])];
51
+ return noCache ? [[...COMPOSE, "build", "--no-cache"], up] : [up];
52
+ }
53
+
54
+ /** The commands bound to `app`, each returning its exit code. */
55
+ export function appCommands(app: App, deps: AppDeps) {
56
+ /** Runs `calls` in order from the app's folder, stopping at the first that fails; returns its exit code, 0 if none did. */
57
+ const runAll = async (calls: string[][]): Promise<number> => {
58
+ for (const argv of calls) {
59
+ const code = await deps.run(argv, { cwd: app.dir });
60
+ if (code !== 0) return code;
61
+ }
62
+ return 0;
63
+ };
64
+ const oneOff = (argv: string[]) => runAll([[...COMPOSE, "run", "--rm", "-T", SERVICE, ...argv]]);
65
+ /** The compose services running right now. */
66
+ const running = async (): Promise<string[]> =>
67
+ (await deps.capture([...COMPOSE, "ps", "--status", "running", "--services"], { cwd: app.dir }))
68
+ .split("\n")
69
+ .map((s) => s.trim());
70
+
71
+ return {
72
+ start: ({ noCache }: { noCache: boolean }) => runAll(startCalls(noCache, false)),
73
+ restart: ({ noCache }: { noCache: boolean }) => runAll(startCalls(noCache, true)),
74
+ stop: () => runAll([[...COMPOSE, "down"]]),
75
+ logs: (service?: string) => runAll([[...COMPOSE, "logs", "-f", ...(service === undefined ? [] : [service])]]),
76
+ repl: () => runAll([[...COMPOSE, "run", "--rm", SERVICE, "bun", "run", "repl"]]),
77
+ shell: async () => {
78
+ const shell = (await running()).includes(SERVICE) ? ["exec", SERVICE, "bash"] : ["run", "--rm", SERVICE, "bash"];
79
+ return runAll([[...COMPOSE, ...shell]]);
80
+ },
81
+ /** `args` is the vault CLI's own command line (`list`, `read <path>`, …). */
82
+ vault: (args: string[]) => oneOff(["bun", VAULT_CLI, ...args]),
83
+ /** `args` is the memory CLI's own command line (`list`, `read <collection>`, …). */
84
+ memory: (args: string[]) => oneOff(["bun", MEMORY_CLI, ...args]),
85
+ /** Deletes `target`'s volume once the user types the app's name, then
86
+ * brings its service back up on an empty volume. The volume's real name
87
+ * comes from the compose file, and a wrong answer deletes nothing. */
88
+ reset: async (target: ResetTarget) => {
89
+ const { service, volume: key, what } = RESET_TARGETS[target];
90
+ const config = JSON.parse(
91
+ await deps.capture([...COMPOSE, "config", "--no-interpolate", "--format", "json"], { cwd: app.dir }),
92
+ ) as { volumes?: Record<string, { name?: string }> };
93
+ const volume = config.volumes?.[key]?.name;
94
+ if (volume === undefined) {
95
+ throw new Error(`The compose file has no "${key}" volume to reset`);
96
+ }
97
+ const answer = await deps.ask(`This deletes ${what} for good: volume ${volume}. Type the app's name (${app.name}) to confirm: `);
98
+ if (answer.trim() !== app.name) {
99
+ deps.print("Not confirmed: nothing deleted.");
100
+ return 1;
101
+ }
102
+ const code = await stopped(service, [
103
+ [...COMPOSE, "rm", "-f", service],
104
+ ["docker", "volume", "rm", volume],
105
+ ]);
106
+ if (code !== 0) return code;
107
+ // A running app sets up its Qdrant collections only when it starts.
108
+ if (target === "memory" && (await running()).includes(SERVICE)) {
109
+ return runAll([[...COMPOSE, "restart", SERVICE]]);
110
+ }
111
+ return 0;
112
+ },
113
+ /** Packs the plugin's CLI config folder (`from`, by default
114
+ * `~/.config/<folder>`) into its credentials variable, written into the
115
+ * app's env file, or printed with `print`. */
116
+ credentialsSet: async (plugin: string, { from, print }: { from?: string; print: boolean }) => {
117
+ const { folder, variable } = credentialsOf(app, plugin);
118
+ const source = resolve(from ?? join(deps.home, ".config", folder));
119
+ const value = await packCredentials(source, folder);
120
+ if (print) {
121
+ deps.print(`${variable}=${value}`);
122
+ return 0;
123
+ }
124
+ const envFile = join(app.dir, ".env");
125
+ setEnvVar(envFile, variable, value);
126
+ deps.print(`${variable} set in ${envFile}, from ${source}.`);
127
+ deps.print(
128
+ `The app unpacks it at its next start, if the volume has no ${folder} folder yet; if it has one, run bunx mfw credentials reset ${plugin} first.`,
129
+ );
130
+ return 0;
131
+ },
132
+ /** Deletes the plugin's CLI folder from the credentials volume once the
133
+ * user types the plugin's name, so its variable is unpacked again at the
134
+ * next start. A wrong answer deletes nothing. */
135
+ credentialsReset: async (plugin: string) => {
136
+ const { folder } = credentialsOf(app, plugin);
137
+ const answer = await deps.ask(
138
+ `This deletes ${folder}'s folder from the app's credentials volume, and any token the CLI refreshed since it was unpacked. Type the plugin's name (${plugin}) to confirm: `,
139
+ );
140
+ if (answer.trim() !== plugin) {
141
+ deps.print("Not confirmed: nothing deleted.");
142
+ return 1;
143
+ }
144
+ return stopped(SERVICE, [[...COMPOSE, "run", "--rm", "--no-deps", "-T", SERVICE, "rm", "-rf", `/home/mercury/.config/${folder}`]]);
145
+ },
146
+ };
147
+
148
+ /** Stops `service`, runs `steps`, starts it again; stops at the first
149
+ * failure and, once the service is stopped, says it's down and how to bring
150
+ * it back. Returns the exit code. */
151
+ async function stopped(service: string, steps: string[][]): Promise<number> {
152
+ const all = [[...COMPOSE, "stop", service], ...steps, [...COMPOSE, "up", "-d", service]];
153
+ for (const [i, argv] of all.entries()) {
154
+ const code = await deps.run(argv, { cwd: app.dir });
155
+ if (code === 0) continue;
156
+ if (i > 0) deps.print(`The ${service} service was stopped and not restarted: bunx mfw start brings it back.`);
157
+ return code;
158
+ }
159
+ return 0;
160
+ }
161
+ }
162
+
163
+ /** The credentials of the tool plugin `plugin`, which `app` must depend on;
164
+ * throws naming the ones it has otherwise. */
165
+ function credentialsOf(app: App, plugin: string): CliCredentials {
166
+ const manifest = JSON.parse(readFileSync(join(app.dir, "package.json"), "utf-8")) as {
167
+ dependencies?: Record<string, string>;
168
+ };
169
+ const have = CATALOG.filter(
170
+ (e) => e.kind === "tool" && e.credentials !== undefined && manifest.dependencies?.[e.package] !== undefined,
171
+ );
172
+ const entry = have.find((e) => e.id === plugin);
173
+ if (entry?.credentials === undefined) {
174
+ throw new Error(
175
+ `${app.name} has no "${plugin}" tool plugin with CLI credentials. It has: ${have.map((e) => e.id).join(", ") || "none"}.`,
176
+ );
177
+ }
178
+ return entry.credentials;
179
+ }
180
+
181
+ /** The real deps: docker on the user's terminal, questions on `input`
182
+ * (stdin by default). */
183
+ export function terminalDeps({
184
+ input = process.stdin,
185
+ output = process.stdout,
186
+ }: { input?: NodeJS.ReadableStream; output?: NodeJS.WritableStream } = {}): AppDeps {
187
+ return {
188
+ run: async (argv, { cwd }) => {
189
+ const proc = Bun.spawn(argv, { cwd, stdin: "inherit", stdout: "inherit", stderr: "inherit" });
190
+ return await proc.exited;
191
+ },
192
+ capture: async (argv, { cwd }) => {
193
+ const proc = Bun.spawn(argv, { cwd, stdin: "ignore", stdout: "pipe", stderr: "inherit" });
194
+ const [out, code] = await Promise.all([new Response(proc.stdout).text(), proc.exited]);
195
+ if (code !== 0) throw new Error(`${argv.join(" ")} failed (exit ${code})`);
196
+ return out;
197
+ },
198
+ // Input closing before a line (Ctrl+D, `< /dev/null`) is an empty answer,
199
+ // never a question left waiting.
200
+ ask: async (question) => {
201
+ const { createInterface } = await import("node:readline");
202
+ const rl = createInterface({ input, output });
203
+ return await new Promise<string>((resolve) => {
204
+ rl.once("close", () => resolve(""));
205
+ rl.question(question, (answer) => {
206
+ resolve(answer);
207
+ rl.close();
208
+ });
209
+ });
210
+ },
211
+ print: (line) => console.log(line),
212
+ home: homedir(),
213
+ };
214
+ }