@mercury-fw/cli 0.32.0 → 0.33.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 +17 -0
- package/README.md +7 -7
- package/dist/src/app/commands.d.ts +4 -4
- package/dist/src/catalog.d.ts +0 -9
- package/package.json +7 -6
- package/src/app/commands.ts +24 -28
- package/src/app/credentials.ts +1 -1
- package/src/catalog.ts +0 -10
- package/src/program.ts +8 -8
- package/src/render.ts +25 -89
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,22 @@
|
|
|
1
1
|
# @mercury-fw/cli
|
|
2
2
|
|
|
3
|
+
## 0.33.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- a493d9b: - A plugin whose CLI keeps its login in a folder declares it in its `package.json` (`mercury.cliCredentials`): `{ folder }` under `~/.config`, the default, or `{ path }` anywhere else under the home. Any plugin's CLI gets the login mechanism, not only the first-party ones.
|
|
8
|
+
- The core unpacks each declared login from its env variable onto the credentials volume (`~/.config`) at startup, only when the folder isn't there yet, for the service and the REPL alike; a folder declared elsewhere in the home lives on the volume under `~/.config/mercury-home`, linked from its usual place. It warns about a declared folder with neither the folder nor the variable.
|
|
9
|
+
- `mfw credentials set|reset <plugin>` names the plugin by its package or its CLI's folder (`@mercury-fw/plugin-jira` or `jira-cli`), read from the app's installed plugins; the short name (`jira`) is no longer accepted, and reset asks for the folder's name.
|
|
10
|
+
- `mfw create` no longer writes `docker-entrypoint.sh` or the credentials variables in the env example, and always mounts the `cli-credentials` volume; an existing app's entrypoint keeps working alongside.
|
|
11
|
+
- The generated README explains how a plugin's CLI gets its login without listing plugins.
|
|
12
|
+
- jira, bitbucket and atlassian-admin declare their CLI's login folder; their READMEs point to `mfw credentials set`.
|
|
13
|
+
|
|
14
|
+
### Patch Changes
|
|
15
|
+
|
|
16
|
+
- Updated dependencies [a493d9b]
|
|
17
|
+
- @mercury-fw/utils@0.33.0
|
|
18
|
+
- @mercury-fw/core@0.33.0
|
|
19
|
+
|
|
3
20
|
## 0.32.0
|
|
4
21
|
|
|
5
22
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -185,22 +185,22 @@ Useful for clearing out test data; the other layer isn't touched.
|
|
|
185
185
|
|
|
186
186
|
### `mfw credentials set <plugin> [--from <dir>] [--print]`
|
|
187
187
|
|
|
188
|
-
Hands a
|
|
188
|
+
Hands a plugin's CLI its login, for a plugin whose CLI keeps it in a folder under the home and reads it from there at runtime. The plugin declares that folder in its `package.json` (`mercury.cliCredentials`: `{ "folder": "jira-cli" }` for `~/.config/jira-cli`, the usual place, or `{ "path": ".aws" }` for anywhere else under the home), and `<plugin>` names it by the plugin's package or by what it declares; a name the app doesn't have is an error listing the ones it has. Log in with the CLI on your machine first, then this packs the folder (where the plugin declares it, or `--from` when it lives elsewhere on your machine) into a base64 tar.gz and writes it into the app's `.env` as a variable named after the declaration (`jira-cli` goes in `JIRA_CLI_CONFIG_TAR_B64`, `.aws` in `AWS_CONFIG_TAR_B64`), 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.
|
|
189
189
|
|
|
190
|
-
When the
|
|
190
|
+
When the app starts, it 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. The volume is mounted on `~/.config`; a folder declared elsewhere in the home lives on it under `~/.config/mercury-home`, and the app makes its usual place a link to it at every start. A CLI that authenticates any other way isn't covered by this, and neither is one that deletes its own folder and makes it again, since that replaces the link.
|
|
191
191
|
|
|
192
192
|
```bash
|
|
193
|
-
mfw credentials set jira
|
|
194
|
-
mfw credentials set bitbucket --from ~/work/bitbucket-login
|
|
195
|
-
mfw credentials set jira --print
|
|
193
|
+
mfw credentials set jira-cli
|
|
194
|
+
mfw credentials set @mercury-fw/plugin-bitbucket --from ~/work/bitbucket-login
|
|
195
|
+
mfw credentials set jira-cli --print
|
|
196
196
|
```
|
|
197
197
|
|
|
198
198
|
### `mfw credentials reset <plugin>`
|
|
199
199
|
|
|
200
|
-
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
|
|
200
|
+
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 app never touches an existing folder. It asks you to type the folder'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`).
|
|
201
201
|
|
|
202
202
|
```bash
|
|
203
|
-
mfw credentials reset jira
|
|
203
|
+
mfw credentials reset jira-cli
|
|
204
204
|
```
|
|
205
205
|
|
|
206
206
|
### `mfw google-chat set-key <key-file> [--subscription <name>]`
|
|
@@ -56,15 +56,15 @@ export declare function appCommands(app: App, deps: AppDeps): {
|
|
|
56
56
|
* brings its service back up on an empty volume. The volume's real name
|
|
57
57
|
* comes from the compose file, and a wrong answer deletes nothing. */
|
|
58
58
|
reset: (target: ResetTarget) => Promise<number>;
|
|
59
|
-
/** Packs the plugin's CLI
|
|
60
|
-
*
|
|
61
|
-
* app's env file, or printed with `print`. */
|
|
59
|
+
/** Packs the plugin's CLI login folder (`from`, by default where the
|
|
60
|
+
* plugin declares it under the home) into its credentials variable,
|
|
61
|
+
* written into the app's env file, or printed with `print`. */
|
|
62
62
|
credentialsSet: (plugin: string, { from, print }: {
|
|
63
63
|
from?: string;
|
|
64
64
|
print: boolean;
|
|
65
65
|
}) => Promise<number>;
|
|
66
66
|
/** Deletes the plugin's CLI folder from the credentials volume once the
|
|
67
|
-
* user types the
|
|
67
|
+
* user types the folder's name, so its variable is unpacked again at the
|
|
68
68
|
* next start. A wrong answer deletes nothing. */
|
|
69
69
|
credentialsReset: (plugin: string) => Promise<number>;
|
|
70
70
|
/** Writes the Google Chat channel's service account key (the JSON file
|
package/dist/src/catalog.d.ts
CHANGED
|
@@ -21,19 +21,10 @@ export type CatalogEntry = {
|
|
|
21
21
|
exportName: string;
|
|
22
22
|
env: EnvVar[];
|
|
23
23
|
formatter?: FormatterExample;
|
|
24
|
-
credentials?: CliCredentials;
|
|
25
24
|
/** Dependencies whose install scripts Bun must run for this entry (Bun runs
|
|
26
25
|
* none it isn't told to trust), on top of a tool plugin's own package. */
|
|
27
26
|
trusts?: string[];
|
|
28
27
|
};
|
|
29
|
-
/** Where a tool plugin's CLI keeps its login: `folder` under the CLI user's
|
|
30
|
-
* `~/.config`, and the env variable that carries that folder into the
|
|
31
|
-
* container (a base64 tar.gz with the folder at its root), materialized on
|
|
32
|
-
* the credentials volume the first time the folder isn't there. */
|
|
33
|
-
export type CliCredentials = {
|
|
34
|
-
folder: string;
|
|
35
|
-
variable: string;
|
|
36
|
-
};
|
|
37
28
|
/** Starting formatter rules for a plugin that hands the user lists: without a
|
|
38
29
|
* rule a kind of list isn't shown, so the scaffolded config wraps the plugin
|
|
39
30
|
* with these. `displaysType` is the type the plugin exports for its kinds,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mercury-fw/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.33.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -41,16 +41,17 @@
|
|
|
41
41
|
"comment:shape": "The Mercury CLI (`mfw`). `mfw create <dir>` writes a new Mercury app from the template. The framework packages move in lockstep with it, so a new app gets them at the CLI's own version; plugins and channels at the registry's latest. The plugin and channel packages are devDependencies only for the catalog test. `main(argv)` is exported for create-mercury-agent.",
|
|
42
42
|
"dependencies": {
|
|
43
43
|
"@clack/prompts": "^1.8.1",
|
|
44
|
-
"@mercury-fw/core": "0.
|
|
44
|
+
"@mercury-fw/core": "0.33.0",
|
|
45
|
+
"@mercury-fw/utils": "0.33.0",
|
|
45
46
|
"commander": "^15.0.0"
|
|
46
47
|
},
|
|
47
48
|
"devDependencies": {
|
|
48
49
|
"@mercury-fw/channel-google-chat": "0.1.3",
|
|
49
50
|
"@mercury-fw/channel-http": "0.1.1",
|
|
50
|
-
"@mercury-fw/formatter": "0.
|
|
51
|
-
"@mercury-fw/plugin-atlassian-admin": "0.1.
|
|
52
|
-
"@mercury-fw/plugin-bitbucket": "0.1.
|
|
53
|
-
"@mercury-fw/plugin-jira": "0.4.
|
|
51
|
+
"@mercury-fw/formatter": "0.33.0",
|
|
52
|
+
"@mercury-fw/plugin-atlassian-admin": "0.1.2",
|
|
53
|
+
"@mercury-fw/plugin-bitbucket": "0.1.2",
|
|
54
|
+
"@mercury-fw/plugin-jira": "0.4.3",
|
|
54
55
|
"@mercury-fw/typescript-config": "*",
|
|
55
56
|
"@types/bun": "^1.4.2",
|
|
56
57
|
"typescript": "^6.0.3"
|
package/src/app/commands.ts
CHANGED
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { chmodSync, cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
9
9
|
import { homedir } from "node:os";
|
|
10
|
-
import { join, relative, resolve } from "node:path";
|
|
11
|
-
import {
|
|
10
|
+
import { basename, join, relative, resolve } from "node:path";
|
|
11
|
+
import { appCliCredentials, volumePath, type CliCredentials } from "@mercury-fw/utils";
|
|
12
12
|
import { packCredentials, readServiceAccountKey, setEnvVar } from "./credentials.ts";
|
|
13
13
|
import type { App } from "./find-app.ts";
|
|
14
14
|
import { LOCAL_PACKS_DIR, packageNameOf, withLocalOverrides, withoutLocalOverrides } from "./local-packages.ts";
|
|
@@ -127,13 +127,13 @@ export function appCommands(app: App, deps: AppDeps) {
|
|
|
127
127
|
}
|
|
128
128
|
return 0;
|
|
129
129
|
},
|
|
130
|
-
/** Packs the plugin's CLI
|
|
131
|
-
*
|
|
132
|
-
* app's env file, or printed with `print`. */
|
|
130
|
+
/** Packs the plugin's CLI login folder (`from`, by default where the
|
|
131
|
+
* plugin declares it under the home) into its credentials variable,
|
|
132
|
+
* written into the app's env file, or printed with `print`. */
|
|
133
133
|
credentialsSet: async (plugin: string, { from, print }: { from?: string; print: boolean }) => {
|
|
134
|
-
const {
|
|
135
|
-
const source = resolve(from ?? join(deps.home,
|
|
136
|
-
const value = await packCredentials(source,
|
|
134
|
+
const { name, path, variable } = credentialsOf(app, plugin);
|
|
135
|
+
const source = resolve(from ?? join(deps.home, path));
|
|
136
|
+
const value = await packCredentials(source, basename(path));
|
|
137
137
|
if (print) {
|
|
138
138
|
deps.print(`${variable}=${value}`);
|
|
139
139
|
return 0;
|
|
@@ -142,23 +142,23 @@ export function appCommands(app: App, deps: AppDeps) {
|
|
|
142
142
|
setEnvVar(envFile, variable, value);
|
|
143
143
|
deps.print(`${variable} set in ${envFile}, from ${source}.`);
|
|
144
144
|
deps.print(
|
|
145
|
-
`The app unpacks it at its next start, if the volume has no ${
|
|
145
|
+
`The app unpacks it at its next start, if the volume has no ${name} folder yet; if it has one, run mfw credentials reset ${name} first.`,
|
|
146
146
|
);
|
|
147
147
|
return 0;
|
|
148
148
|
},
|
|
149
149
|
/** Deletes the plugin's CLI folder from the credentials volume once the
|
|
150
|
-
* user types the
|
|
150
|
+
* user types the folder's name, so its variable is unpacked again at the
|
|
151
151
|
* next start. A wrong answer deletes nothing. */
|
|
152
152
|
credentialsReset: async (plugin: string) => {
|
|
153
|
-
const {
|
|
153
|
+
const { name, path } = credentialsOf(app, plugin);
|
|
154
154
|
const answer = await deps.ask(
|
|
155
|
-
`This deletes ${
|
|
155
|
+
`This deletes the ${name} folder from the app's credentials volume, and any token the CLI refreshed since it was unpacked. Type the folder's name (${name}) to confirm: `,
|
|
156
156
|
);
|
|
157
|
-
if (answer.trim() !==
|
|
157
|
+
if (answer.trim() !== name) {
|
|
158
158
|
deps.print("Not confirmed: nothing deleted.");
|
|
159
159
|
return 1;
|
|
160
160
|
}
|
|
161
|
-
return stopped(SERVICE, [[...COMPOSE, "run", "--rm", "--no-deps", "-T", SERVICE, "rm", "-rf", `/home/mercury
|
|
161
|
+
return stopped(SERVICE, [[...COMPOSE, "run", "--rm", "--no-deps", "-T", SERVICE, "rm", "-rf", `/home/mercury/${volumePath(path)}`]]);
|
|
162
162
|
},
|
|
163
163
|
/** Writes the Google Chat channel's service account key (the JSON file
|
|
164
164
|
* at `keyFile`) into the app's env file, and the Pub/Sub subscription when
|
|
@@ -274,22 +274,18 @@ export function appCommands(app: App, deps: AppDeps) {
|
|
|
274
274
|
}
|
|
275
275
|
}
|
|
276
276
|
|
|
277
|
-
/** The credentials
|
|
278
|
-
* throws naming the ones it has otherwise
|
|
277
|
+
/** The CLI credentials `plugin` names, by package or by folder, among those
|
|
278
|
+
* the app's dependencies declare; throws naming the ones it has otherwise,
|
|
279
|
+
* with the dependencies it couldn't read. */
|
|
279
280
|
function credentialsOf(app: App, plugin: string): CliCredentials {
|
|
280
|
-
const
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
const entry = have.find((e) => e.id === plugin);
|
|
287
|
-
if (entry?.credentials === undefined) {
|
|
288
|
-
throw new Error(
|
|
289
|
-
`${app.name} has no "${plugin}" tool plugin with CLI credentials. It has: ${have.map((e) => e.id).join(", ") || "none"}.`,
|
|
290
|
-
);
|
|
281
|
+
const { declared, problems } = appCliCredentials(app.dir);
|
|
282
|
+
const found = declared.find((c) => c.package === plugin || c.name === plugin);
|
|
283
|
+
if (found === undefined) {
|
|
284
|
+
const have = declared.map((c) => `${c.package} (${c.name})`).join(", ") || "none";
|
|
285
|
+
const unread = problems.length > 0 ? ` Left out: ${problems.join("; ")}.` : "";
|
|
286
|
+
throw new Error(`${app.name} has no CLI credentials named "${plugin}". It has: ${have}.${unread}`);
|
|
291
287
|
}
|
|
292
|
-
return
|
|
288
|
+
return found;
|
|
293
289
|
}
|
|
294
290
|
|
|
295
291
|
/** The real deps: docker on the user's terminal, questions on `input`
|
package/src/app/credentials.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The two halves of `mfw credentials set`: packing a CLI's config folder into
|
|
3
3
|
* its credentials variable (a base64 tar.gz with the folder at its root, what
|
|
4
|
-
* the
|
|
4
|
+
* the core unpacks into `~/.config` on the volume at startup), and
|
|
5
5
|
* writing that variable into the app's env file. Also reading a service
|
|
6
6
|
* account key file, for `mfw google-chat set-key`.
|
|
7
7
|
*/
|
package/src/catalog.ts
CHANGED
|
@@ -19,18 +19,11 @@ export type CatalogEntry = {
|
|
|
19
19
|
exportName: string;
|
|
20
20
|
env: EnvVar[];
|
|
21
21
|
formatter?: FormatterExample;
|
|
22
|
-
credentials?: CliCredentials;
|
|
23
22
|
/** Dependencies whose install scripts Bun must run for this entry (Bun runs
|
|
24
23
|
* none it isn't told to trust), on top of a tool plugin's own package. */
|
|
25
24
|
trusts?: string[];
|
|
26
25
|
};
|
|
27
26
|
|
|
28
|
-
/** Where a tool plugin's CLI keeps its login: `folder` under the CLI user's
|
|
29
|
-
* `~/.config`, and the env variable that carries that folder into the
|
|
30
|
-
* container (a base64 tar.gz with the folder at its root), materialized on
|
|
31
|
-
* the credentials volume the first time the folder isn't there. */
|
|
32
|
-
export type CliCredentials = { folder: string; variable: string };
|
|
33
|
-
|
|
34
27
|
/** Starting formatter rules for a plugin that hands the user lists: without a
|
|
35
28
|
* rule a kind of list isn't shown, so the scaffolded config wraps the plugin
|
|
36
29
|
* with these. `displaysType` is the type the plugin exports for its kinds,
|
|
@@ -71,7 +64,6 @@ export const CATALOG: CatalogEntry[] = [
|
|
|
71
64
|
kind: "tool",
|
|
72
65
|
package: "@mercury-fw/plugin-jira",
|
|
73
66
|
exportName: "jiraPlugin",
|
|
74
|
-
credentials: { folder: "jira-cli", variable: "JIRA_CLI_CONFIG_TAR_B64" },
|
|
75
67
|
env: [{ name: "JIRA_SITE_URL", comment: "Required: the Jira site the issue links point to (https://<site>.atlassian.net)" }],
|
|
76
68
|
formatter: {
|
|
77
69
|
displaysType: "JiraDisplays",
|
|
@@ -90,7 +82,6 @@ export const CATALOG: CatalogEntry[] = [
|
|
|
90
82
|
kind: "tool",
|
|
91
83
|
package: "@mercury-fw/plugin-bitbucket",
|
|
92
84
|
exportName: "bitbucketPlugin",
|
|
93
|
-
credentials: { folder: "bitbucket-cli", variable: "BITBUCKET_CLI_CONFIG_TAR_B64" },
|
|
94
85
|
env: [],
|
|
95
86
|
},
|
|
96
87
|
{
|
|
@@ -98,7 +89,6 @@ export const CATALOG: CatalogEntry[] = [
|
|
|
98
89
|
kind: "tool",
|
|
99
90
|
package: "@mercury-fw/plugin-atlassian-admin",
|
|
100
91
|
exportName: "atlassianAdminPlugin",
|
|
101
|
-
credentials: { folder: "atlassian-admin-cli", variable: "ATLASSIAN_ADMIN_CLI_CONFIG_TAR_B64" },
|
|
102
92
|
env: [],
|
|
103
93
|
},
|
|
104
94
|
];
|
package/src/program.ts
CHANGED
|
@@ -218,20 +218,20 @@ Examples:
|
|
|
218
218
|
|
|
219
219
|
const credentials = program
|
|
220
220
|
.command("credentials")
|
|
221
|
-
.summary("the
|
|
221
|
+
.summary("the login of a plugin's CLI")
|
|
222
222
|
.description(
|
|
223
|
-
"
|
|
223
|
+
"For a tool plugin whose CLI keeps its login in a folder (under ~/.config, or elsewhere in the home), and declares it (mercury.cliCredentials in its package.json): the folder travels in a variable of the env file, and the app unpacks it onto the credentials volume at the first start without that folder. What the CLI refreshes afterwards stays on the volume.",
|
|
224
224
|
)
|
|
225
225
|
.helpCommand(false)
|
|
226
226
|
.addHelpText("after", INSIDE_AN_APP);
|
|
227
227
|
credentials
|
|
228
228
|
.command("set")
|
|
229
|
-
.summary("packs a CLI's
|
|
229
|
+
.summary("packs a CLI's login folder into the env file")
|
|
230
230
|
.description(
|
|
231
|
-
"Packs the plugin's CLI
|
|
231
|
+
"Packs the plugin's CLI login folder (where the plugin declares it, ~/.config/<folder> or another path under the home, unless --from says otherwise) and writes it as the plugin's variable in the app's env file, replacing an older value. The value is never printed, unless --print asks for the line instead.",
|
|
232
232
|
)
|
|
233
|
-
.argument("<plugin>", "
|
|
234
|
-
.option("--from <dir>", "the CLI's
|
|
233
|
+
.argument("<plugin>", "the plugin's package (@mercury-fw/plugin-jira) or its CLI's folder as declared (jira-cli)")
|
|
234
|
+
.option("--from <dir>", "the CLI's login folder, when it isn't where the plugin declares it")
|
|
235
235
|
.option("--print", "print the line to paste elsewhere, and leave the env file alone")
|
|
236
236
|
.action(async (plugin: string, opts: { from?: string; print?: boolean }) =>
|
|
237
237
|
inApp((app) => app.credentialsSet(plugin, { ...(opts.from === undefined ? {} : { from: opts.from }), print: opts.print ?? false }))(),
|
|
@@ -240,9 +240,9 @@ Examples:
|
|
|
240
240
|
.command("reset")
|
|
241
241
|
.summary("clears a CLI's folder from the volume")
|
|
242
242
|
.description(
|
|
243
|
-
"Deletes the plugin's CLI folder from the credentials volume, after you type the
|
|
243
|
+
"Deletes the plugin's CLI folder from the credentials volume, after you type the folder's name, so its variable is unpacked again at the next start: what to run after correcting the variable. Any token the CLI refreshed on the volume goes with it.",
|
|
244
244
|
)
|
|
245
|
-
.argument("<plugin>", "
|
|
245
|
+
.argument("<plugin>", "the plugin's package or its CLI's folder")
|
|
246
246
|
.action(async (plugin: string) => inApp((app) => app.credentialsReset(plugin))());
|
|
247
247
|
|
|
248
248
|
const googleChat = program
|
package/src/render.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* written in catalog order, whatever order they were chosen in.
|
|
9
9
|
*/
|
|
10
10
|
import { DEFAULT_PERSONA_TONE } from "@mercury-fw/core";
|
|
11
|
-
import { CATALOG, type CatalogEntry, type
|
|
11
|
+
import { CATALOG, type CatalogEntry, type EnvVar } from "./catalog.ts";
|
|
12
12
|
import indexTs from "../template/src/index.ts.tpl" with { type: "text" };
|
|
13
13
|
import replTs from "../template/src/repl.ts.tpl" with { type: "text" };
|
|
14
14
|
import markdownDts from "../template/markdown.d.ts.tpl" with { type: "text" };
|
|
@@ -67,9 +67,9 @@ export function renderApp(input: RenderInput): Map<string, string> {
|
|
|
67
67
|
[".dockerignore", dockerignore],
|
|
68
68
|
[".env.example", renderEnv(channels, tools)],
|
|
69
69
|
[".gitignore", gitignore],
|
|
70
|
-
["Dockerfile",
|
|
70
|
+
["Dockerfile", dockerfile],
|
|
71
71
|
["README.md", renderReadme(input.name, channels, tools)],
|
|
72
|
-
["docker-compose.yml", renderCompose(input.name,
|
|
72
|
+
["docker-compose.yml", renderCompose(input.name, channels.some((c) => c.id === "http"))],
|
|
73
73
|
["markdown.d.ts", markdownDts],
|
|
74
74
|
["mercury.config.ts", renderConfig(channels, tools)],
|
|
75
75
|
["package.json", renderPackageJson(input.name, channels, tools, input.versions)],
|
|
@@ -80,60 +80,9 @@ export function renderApp(input: RenderInput): Map<string, string> {
|
|
|
80
80
|
["src/index.ts", indexTs],
|
|
81
81
|
["src/repl.ts", replTs],
|
|
82
82
|
["tsconfig.json", tsconfigJson],
|
|
83
|
-
...(withCredentials(tools).length > 0
|
|
84
|
-
? [["docker-entrypoint.sh", renderEntrypoint(withCredentials(tools))] as [string, string]]
|
|
85
|
-
: []),
|
|
86
83
|
]);
|
|
87
84
|
}
|
|
88
85
|
|
|
89
|
-
/** The chosen tool plugins whose CLI needs credentials, with them. */
|
|
90
|
-
function withCredentials(tools: CatalogEntry[]): Array<CatalogEntry & { credentials: CliCredentials }> {
|
|
91
|
-
return tools.filter((t): t is CatalogEntry & { credentials: CliCredentials } => t.credentials !== undefined);
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
/** The template's Dockerfile; with CLI credentials to materialize, the service
|
|
95
|
-
* starts through `docker-entrypoint.sh` instead of directly. */
|
|
96
|
-
function renderDockerfile(tools: CatalogEntry[]): string {
|
|
97
|
-
if (withCredentials(tools).length === 0) return dockerfile;
|
|
98
|
-
const cmd = 'CMD ["bun", "src/index.ts"]\n';
|
|
99
|
-
if (!dockerfile.endsWith(cmd)) throw new Error("Dockerfile.tpl no longer ends with the service's CMD");
|
|
100
|
-
return `${dockerfile.slice(0, -cmd.length)}# Materializes the tool plugins' CLI credentials on the first start, then
|
|
101
|
-
# starts the service.
|
|
102
|
-
COPY --chown=mercury:mercury --chmod=755 docker-entrypoint.sh ./
|
|
103
|
-
CMD ["./docker-entrypoint.sh"]
|
|
104
|
-
`;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
/** \`docker-entrypoint.sh\`: each plugin's credentials variable materialized
|
|
108
|
-
* into its CLI's folder on the volume, only while that folder isn't there,
|
|
109
|
-
* then the service. */
|
|
110
|
-
function renderEntrypoint(tools: Array<CatalogEntry & { credentials: CliCredentials }>): string {
|
|
111
|
-
const lines = tools.map((t) => `materialize ${t.credentials.folder} ${t.credentials.variable}`).join("\n");
|
|
112
|
-
return `#!/usr/bin/env bash
|
|
113
|
-
# Starts the service, first materializing each tool plugin's CLI credentials
|
|
114
|
-
# onto the cli-credentials volume: its variable in the env file is the CLI's
|
|
115
|
-
# config folder as a base64 tar.gz (mfw credentials set <plugin> writes
|
|
116
|
-
# it). Only when that CLI's folder isn't on the volume yet: what a CLI writes
|
|
117
|
-
# back while running, like a refreshed token, stays there across redeploys,
|
|
118
|
-
# and an older value in the env file never overwrites it. mfw credentials
|
|
119
|
-
# reset <plugin> clears one folder so its variable is materialized again.
|
|
120
|
-
set -euo pipefail
|
|
121
|
-
|
|
122
|
-
materialize() {
|
|
123
|
-
local folder="$1"
|
|
124
|
-
local variable="$2"
|
|
125
|
-
local value="\${!variable:-}"
|
|
126
|
-
if [[ -n "$value" && ! -d "/home/mercury/.config/$folder" ]]; then
|
|
127
|
-
echo "$value" | base64 -d | tar xzf - -C /home/mercury/.config
|
|
128
|
-
fi
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
${lines}
|
|
132
|
-
|
|
133
|
-
exec bun src/index.ts
|
|
134
|
-
`;
|
|
135
|
-
}
|
|
136
|
-
|
|
137
86
|
/** Why `name` can't be an app name, or undefined when it can. Shared with the
|
|
138
87
|
* wizard, which checks the name as it's typed. */
|
|
139
88
|
export function appNameError(name: string): string | undefined {
|
|
@@ -291,31 +240,18 @@ function renderEnv(channels: CatalogEntry[], tools: CatalogEntry[]): string {
|
|
|
291
240
|
const block = (vars: EnvVar[]) => vars.map((v) => `# ${v.comment}\n${v.name}=${v.value ?? ""}`).join("\n");
|
|
292
241
|
const sections = [block(CORE_ENV)];
|
|
293
242
|
for (const entry of [...channels, ...tools]) {
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
vars.push({
|
|
297
|
-
name: entry.credentials.variable,
|
|
298
|
-
comment: `${entry.credentials.folder}'s config folder, packed: mfw credentials set ${entry.id} writes it; materialized on the credentials volume at the first start without that folder`,
|
|
299
|
-
});
|
|
300
|
-
}
|
|
301
|
-
if (vars.length > 0) {
|
|
302
|
-
sections.push(`# --- ${entry.id}\n${block(vars)}`);
|
|
243
|
+
if (entry.env.length > 0) {
|
|
244
|
+
sections.push(`# --- ${entry.id}\n${block(entry.env)}`);
|
|
303
245
|
}
|
|
304
246
|
}
|
|
305
247
|
return `${sections.join("\n\n")}\n`;
|
|
306
248
|
}
|
|
307
249
|
|
|
308
250
|
/** `docker-compose.yml`: the app and Qdrant, with named volumes prefixed by the
|
|
309
|
-
* app's name
|
|
310
|
-
* the HTTP surface's port published on the host
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
? [
|
|
314
|
-
" # The tool plugins' CLI credentials, on a volume so what a CLI writes back",
|
|
315
|
-
" # (refreshed tokens) survives a redeploy. docker-entrypoint.sh fills it: see README.md.",
|
|
316
|
-
" - cli-credentials:/home/mercury/.config",
|
|
317
|
-
]
|
|
318
|
-
: [];
|
|
251
|
+
* app's name, the CLI credentials one included whatever was chosen (a plugin
|
|
252
|
+
* with a CLI can come later); the HTTP surface's port published on the host
|
|
253
|
+
* only with the HTTP channel. */
|
|
254
|
+
function renderCompose(name: string, hasHttp: boolean): string {
|
|
319
255
|
const httpPort = hasHttp
|
|
320
256
|
? [
|
|
321
257
|
" # The HTTP surface, on the host: no authentication, keep it off the public network.",
|
|
@@ -323,7 +259,6 @@ function renderCompose(name: string, hasTools: boolean, hasHttp: boolean): strin
|
|
|
323
259
|
' - "${HTTP_SURFACE_PORT:-4100}:${HTTP_SURFACE_PORT:-4100}"',
|
|
324
260
|
]
|
|
325
261
|
: [];
|
|
326
|
-
const credentialsVolume = hasTools ? [" cli-credentials:", ` name: ${name}_cli-credentials`] : [];
|
|
327
262
|
return [
|
|
328
263
|
"services:",
|
|
329
264
|
" mercury:",
|
|
@@ -335,7 +270,9 @@ function renderCompose(name: string, hasTools: boolean, hasHttp: boolean): strin
|
|
|
335
270
|
" required: false",
|
|
336
271
|
" volumes:",
|
|
337
272
|
" - wiki-vault:/app/wiki-vault",
|
|
338
|
-
|
|
273
|
+
" # The login of a plugin's CLI, on a volume so what the CLI writes back",
|
|
274
|
+
" # (refreshed tokens) survives a redeploy: see README.md.",
|
|
275
|
+
" - cli-credentials:/home/mercury/.config",
|
|
339
276
|
...httpPort,
|
|
340
277
|
" extra_hosts:",
|
|
341
278
|
' - "host.docker.internal:host-gateway"',
|
|
@@ -352,7 +289,8 @@ function renderCompose(name: string, hasTools: boolean, hasHttp: boolean): strin
|
|
|
352
289
|
` name: ${name}_wiki-vault`,
|
|
353
290
|
" qdrant-data:",
|
|
354
291
|
` name: ${name}_qdrant-data`,
|
|
355
|
-
|
|
292
|
+
" cli-credentials:",
|
|
293
|
+
` name: ${name}_cli-credentials`,
|
|
356
294
|
"",
|
|
357
295
|
].join("\n");
|
|
358
296
|
}
|
|
@@ -392,7 +330,7 @@ mfw repl
|
|
|
392
330
|
\`\`\`
|
|
393
331
|
|
|
394
332
|
\`bun install\` here gives your editor, \`bun run typecheck\` and the app's own \`mfw\` (the one a global \`mfw\` runs inside the app) the packages (tool plugins download their CLI binary as they install); the image installs its own copy when it builds. \`mfw start\` builds the image and starts the app with Qdrant in the background, \`mfw repl\` opens a terminal conversation with the assistant. \`mfw --help\` lists the rest: stopping and restarting, logs, a shell in the container, the wiki and the memory, and resetting them.
|
|
395
|
-
${renderHttpSection(channels)}${
|
|
333
|
+
${renderHttpSection(channels)}${CREDENTIALS_SECTION}`;
|
|
396
334
|
}
|
|
397
335
|
|
|
398
336
|
/** The README section on the HTTP surface, for an app with the HTTP channel;
|
|
@@ -406,27 +344,25 @@ The HTTP channel listens on \`http://<host>:4100\`, the port in \`HTTP_SURFACE_P
|
|
|
406
344
|
`;
|
|
407
345
|
}
|
|
408
346
|
|
|
409
|
-
/** The README section on CLI credentials
|
|
410
|
-
*
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
const first = tools[0] as CatalogEntry & { credentials: CliCredentials };
|
|
414
|
-
const list = tools.map((t) => `\`${t.id}\` (\`~/.config/${t.credentials.folder}\`)`).join(", ");
|
|
415
|
-
return `
|
|
347
|
+
/** The README section on CLI credentials: how a plugin whose CLI keeps its
|
|
348
|
+
* login in a folder gets it into the container. Generic on purpose: a CLI is
|
|
349
|
+
* a feature of some plugins, from any author, not something to list here. */
|
|
350
|
+
const CREDENTIALS_SECTION = `
|
|
416
351
|
## CLI credentials
|
|
417
352
|
|
|
418
|
-
|
|
353
|
+
Some tool plugins run a CLI. When that CLI keeps its login in a folder under your home and reads it at runtime to authenticate, this is how the folder gets into the container: log in with the CLI on your machine first (its own README says how), then hand the folder to the app:
|
|
419
354
|
|
|
420
355
|
\`\`\`bash
|
|
421
|
-
mfw credentials set
|
|
356
|
+
mfw credentials set <plugin>
|
|
422
357
|
\`\`\`
|
|
423
358
|
|
|
424
|
-
It packs the folder into its variable in \`.env\` (\`--from <folder>\` if it isn't where the CLI usually keeps it, \`--print\` to get the line to paste on another host instead). When the
|
|
359
|
+
\`<plugin>\` is the plugin's package or its CLI's folder, which the plugin declares (\`mercury.cliCredentials\` in its \`package.json\`: a folder under \`~/.config\`, or a path anywhere under the home); \`mfw credentials set\` with a name the app doesn't have lists the ones it has. It packs the folder into its variable in \`.env\` (\`--from <folder>\` if it isn't where the CLI usually keeps it, \`--print\` to get the line to paste on another host instead). When the app starts, it unpacks the variable onto the \`cli-credentials\` volume (mounted on \`~/.config\`: a folder declared elsewhere in the home lives under \`~/.config/mercury-home\`, and its usual place links there), but only if that CLI's folder isn't there yet: what the CLI writes back afterwards, like a refreshed token, stays on the volume across redeploys, and the older value in \`.env\` never overwrites it.
|
|
425
360
|
|
|
426
361
|
That same rule means a corrected variable does nothing while the old folder is on the volume. Clear it, and the next start unpacks the variable again:
|
|
427
362
|
|
|
428
363
|
\`\`\`bash
|
|
429
|
-
mfw credentials reset
|
|
364
|
+
mfw credentials reset <plugin>
|
|
430
365
|
\`\`\`
|
|
366
|
+
|
|
367
|
+
A CLI that authenticates any other way isn't covered by this, and nothing guarantees it works in Mercury; neither does one that deletes its own folder and makes it again, since that replaces the link.
|
|
431
368
|
`;
|
|
432
|
-
}
|