@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 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 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.
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 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.
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 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`).
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 config folder (`from`, by default
60
- * `~/.config/<folder>`) into its credentials variable, written into the
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 plugin's name, so its variable is unpacked again at 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
@@ -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.32.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.32.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.32.0",
51
- "@mercury-fw/plugin-atlassian-admin": "0.1.1",
52
- "@mercury-fw/plugin-bitbucket": "0.1.1",
53
- "@mercury-fw/plugin-jira": "0.4.2",
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"
@@ -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 { CATALOG, type CliCredentials } from "../catalog.ts";
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 config folder (`from`, by default
131
- * `~/.config/<folder>`) into its credentials variable, written into the
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 { folder, variable } = credentialsOf(app, plugin);
135
- const source = resolve(from ?? join(deps.home, ".config", folder));
136
- const value = await packCredentials(source, folder);
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 ${folder} folder yet; if it has one, run mfw credentials reset ${plugin} first.`,
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 plugin's name, so its variable is unpacked again at 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 { folder } = credentialsOf(app, plugin);
153
+ const { name, path } = credentialsOf(app, plugin);
154
154
  const answer = await deps.ask(
155
- `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: `,
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() !== plugin) {
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/.config/${folder}`]]);
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 of the tool plugin `plugin`, which `app` must depend on;
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 manifest = JSON.parse(readFileSync(join(app.dir, "package.json"), "utf-8")) as {
281
- dependencies?: Record<string, string>;
282
- };
283
- const have = CATALOG.filter(
284
- (e) => e.kind === "tool" && e.credentials !== undefined && manifest.dependencies?.[e.package] !== undefined,
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 entry.credentials;
288
+ return found;
293
289
  }
294
290
 
295
291
  /** The real deps: docker on the user's terminal, questions on `input`
@@ -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 app's `docker-entrypoint.sh` unpacks into `~/.config` on the volume), and
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 tool plugins' CLI credentials")
221
+ .summary("the login of a plugin's CLI")
222
222
  .description(
223
- "Hands a tool plugin's CLI its login: the CLI's config folder travels in a variable of the env file, and the container unpacks it onto the credentials volume at the first start without that folder. What the CLI refreshes afterwards stays on the volume.",
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 config folder into the env file")
229
+ .summary("packs a CLI's login folder into the env file")
230
230
  .description(
231
- "Packs the plugin's CLI config folder (~/.config/<cli> 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.",
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>", "a tool plugin of the app: jira, bitbucket, atlassian-admin")
234
- .option("--from <dir>", "the CLI's config folder, when it isn't ~/.config/<cli>")
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 plugin'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.",
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>", "a tool plugin of the app")
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 CliCredentials, type EnvVar } from "./catalog.ts";
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", renderDockerfile(tools)],
70
+ ["Dockerfile", dockerfile],
71
71
  ["README.md", renderReadme(input.name, channels, tools)],
72
- ["docker-compose.yml", renderCompose(input.name, tools.length > 0, channels.some((c) => c.id === "http"))],
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
- const vars = [...entry.env];
295
- if (entry.credentials !== undefined) {
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; the CLI credentials volume only when a tool plugin was chosen,
310
- * the HTTP surface's port published on the host only with the HTTP channel. */
311
- function renderCompose(name: string, hasTools: boolean, hasHttp: boolean): string {
312
- const credentialsMount = hasTools
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
- ...credentialsMount,
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
- ...credentialsVolume,
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)}${renderCredentialsSection(withCredentials(tools))}`;
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, for an app with tool plugins;
410
- * nothing without one. */
411
- function renderCredentialsSection(tools: Array<CatalogEntry & { credentials: CliCredentials }>): string {
412
- if (tools.length === 0) return "";
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
- Each tool plugin runs its own CLI, and each CLI keeps its login in a folder of its own: ${list}. Log in with the CLI on your machine first (its own README says how), then hand that folder to the app:
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 ${first.id}
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 container starts, \`docker-entrypoint.sh\` unpacks it onto the \`cli-credentials\` volume, 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.
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 ${first.id}
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
- }