@mercury-fw/cli 0.26.0 → 0.27.1

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,25 @@
1
1
  # @mercury-fw/cli
2
2
 
3
+ ## 0.27.1
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies [2c0e020]
8
+ - @mercury-fw/core@0.27.1
9
+
10
+ ## 0.27.0
11
+
12
+ ### Minor Changes
13
+
14
+ - 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.
15
+ - The scaffolded env example lists each tool plugin's credentials variable, and the README explains the flow.
16
+ - `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.
17
+ - `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.
18
+
19
+ ### Patch Changes
20
+
21
+ - @mercury-fw/core@0.27.0
22
+
3
23
  ## 0.26.0
4
24
 
5
25
  ### Minor Changes
package/README.md CHANGED
@@ -17,6 +17,8 @@
17
17
  - [`mfw memory list`](#mfw-memory-list)
18
18
  - [`mfw memory read <collection> [--limit N]`](#mfw-memory-read-collection---limit-n)
19
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)
20
22
  - [Help](#help)
21
23
 
22
24
  ## Getting it
@@ -166,6 +168,26 @@ bunx mfw reset wiki
166
168
 
167
169
  Useful for clearing out test data; the other layer isn't touched.
168
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
+
169
191
  ## Help
170
192
 
171
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.
@@ -1,10 +1,3 @@
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
1
  import type { App } from "./find-app.ts";
9
2
  export type AppDeps = {
10
3
  /** Runs `argv` in `cwd` on the user's terminal (stdin, stdout, stderr) and returns its exit code. */
@@ -19,6 +12,8 @@ export type AppDeps = {
19
12
  ask: (question: string) => Promise<string>;
20
13
  /** Tells the user something. */
21
14
  print: (line: string) => void;
15
+ /** The user's home folder, where a CLI keeps its config (`~/.config/<cli>`). */
16
+ home: string;
22
17
  };
23
18
  /** The core's maintenance CLIs, from the container's working directory (the
24
19
  * app's folder, where node_modules/@mercury-fw/core is). A path and not a bin:
@@ -61,6 +56,17 @@ export declare function appCommands(app: App, deps: AppDeps): {
61
56
  * brings its service back up on an empty volume. The volume's real name
62
57
  * comes from the compose file, and a wrong answer deletes nothing. */
63
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>;
64
70
  };
65
71
  /** The real deps: docker on the user's terminal, questions on `input`
66
72
  * (stdin by default). */
@@ -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;
@@ -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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mercury-fw/cli",
3
- "version": "0.26.0",
3
+ "version": "0.27.1",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -36,13 +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.26.0",
39
+ "@mercury-fw/core": "0.27.1",
40
40
  "commander": "^15.0.0"
41
41
  },
42
42
  "devDependencies": {
43
43
  "@mercury-fw/channel-google-chat": "0.1.0",
44
44
  "@mercury-fw/channel-http": "0.1.0",
45
- "@mercury-fw/formatter": "0.26.0",
45
+ "@mercury-fw/formatter": "0.27.1",
46
46
  "@mercury-fw/plugin-atlassian-admin": "0.1.0",
47
47
  "@mercury-fw/plugin-bitbucket": "0.1.0",
48
48
  "@mercury-fw/plugin-jira": "0.1.0",
@@ -5,6 +5,11 @@
5
5
  * and validated before any of this runs (`program.ts`); what runs a command is
6
6
  * injected (`AppDeps`), which is how the tests see the exact calls.
7
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";
8
13
  import type { App } from "./find-app.ts";
9
14
 
10
15
  export type AppDeps = {
@@ -16,6 +21,8 @@ export type AppDeps = {
16
21
  ask: (question: string) => Promise<string>;
17
22
  /** Tells the user something. */
18
23
  print: (line: string) => void;
24
+ /** The user's home folder, where a CLI keeps its config (`~/.config/<cli>`). */
25
+ home: string;
19
26
  };
20
27
 
21
28
  const COMPOSE = ["docker", "compose"];
@@ -92,25 +99,83 @@ export function appCommands(app: App, deps: AppDeps) {
92
99
  deps.print("Not confirmed: nothing deleted.");
93
100
  return 1;
94
101
  }
95
- const steps = [
96
- [...COMPOSE, "stop", service],
102
+ const code = await stopped(service, [
97
103
  [...COMPOSE, "rm", "-f", service],
98
104
  ["docker", "volume", "rm", volume],
99
- [...COMPOSE, "up", "-d", service],
100
- ];
101
- for (const [i, argv] of steps.entries()) {
102
- const code = await deps.run(argv, { cwd: app.dir });
103
- if (code === 0) continue;
104
- if (i > 0) deps.print(`The ${service} service was stopped and not restarted: bunx mfw start brings it back.`);
105
- return code;
106
- }
105
+ ]);
106
+ if (code !== 0) return code;
107
107
  // A running app sets up its Qdrant collections only when it starts.
108
108
  if (target === "memory" && (await running()).includes(SERVICE)) {
109
109
  return runAll([[...COMPOSE, "restart", SERVICE]]);
110
110
  }
111
111
  return 0;
112
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>;
113
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;
114
179
  }
115
180
 
116
181
  /** The real deps: docker on the user's terminal, questions on `input`
@@ -144,5 +209,6 @@ export function terminalDeps({
144
209
  });
145
210
  },
146
211
  print: (line) => console.log(line),
212
+ home: homedir(),
147
213
  };
148
214
  }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * The two halves of `mfw credentials set`: packing a CLI's config folder into
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
5
+ * writing that variable into the app's env file.
6
+ */
7
+ import {
8
+ chmodSync,
9
+ existsSync,
10
+ mkdtempSync,
11
+ readFileSync,
12
+ realpathSync,
13
+ renameSync,
14
+ rmSync,
15
+ statSync,
16
+ symlinkSync,
17
+ writeFileSync,
18
+ } from "node:fs";
19
+ import { tmpdir } from "node:os";
20
+ import { dirname, join, resolve } from "node:path";
21
+
22
+ /** `from` packed as `<folder>/…` into a base64 tar.gz on one line. The real
23
+ * folder behind `from` is archived through a symlink named `folder` and
24
+ * dereferenced (`-h`): whatever `from` is called, and even when it's itself a
25
+ * symlink (dotfiles), the archive holds the files under the CLI's folder name. */
26
+ export async function packCredentials(from: string, folder: string): Promise<string> {
27
+ const source = resolve(from);
28
+ if (!existsSync(source) || !statSync(source).isDirectory()) {
29
+ throw new Error(`No folder at ${source}`);
30
+ }
31
+ const staging = mkdtempSync(join(tmpdir(), "mfw-credentials-"));
32
+ symlinkSync(realpathSync(source), join(staging, folder));
33
+ try {
34
+ // COPYFILE_DISABLE: macOS's tar would otherwise add ._ files for metadata.
35
+ const proc = Bun.spawn(["tar", "-czh", "--no-xattrs", "-f", "-", "-C", staging, folder], {
36
+ env: { ...process.env, COPYFILE_DISABLE: "1" },
37
+ stdout: "pipe",
38
+ stderr: "pipe",
39
+ });
40
+ const [archive, stderr, code] = await Promise.all([
41
+ new Response(proc.stdout).arrayBuffer(),
42
+ new Response(proc.stderr).text(),
43
+ proc.exited,
44
+ ]);
45
+ if (code !== 0) throw new Error(`tar failed packing ${source}: ${stderr.trim()}`);
46
+ return Buffer.from(archive).toString("base64");
47
+ } finally {
48
+ rmSync(staging, { recursive: true, force: true });
49
+ }
50
+ }
51
+
52
+ /** Sets `name=value` in the env file at `file`. Every line that assigns
53
+ * `name` (`export name=` and spaces around `=` included) gives way to one
54
+ * line, where the first was; with none, it's appended. Every other line stays
55
+ * as it was. The file is replaced whole through a temporary file next to it,
56
+ * keeping its permissions, so a failed write never leaves it half written; a
57
+ * missing file is created readable by its owner only. */
58
+ export function setEnvVar(file: string, name: string, value: string): void {
59
+ const line = `${name}=${value}`;
60
+ if (!existsSync(file)) {
61
+ writeFileSync(file, `${line}\n`, { mode: 0o600 });
62
+ return;
63
+ }
64
+ const text = readFileSync(file, "utf-8");
65
+ const assigns = new RegExp(`^\\s*(export\\s+)?${name}\\s*=`);
66
+ const lines = text.split("\n");
67
+ const first = lines.findIndex((l) => assigns.test(l));
68
+ let next: string;
69
+ if (first === -1) {
70
+ next = `${text}${text === "" || text.endsWith("\n") ? "" : "\n"}${line}\n`;
71
+ } else {
72
+ next = lines
73
+ .flatMap((l, i) => (i === first ? [line] : assigns.test(l) ? [] : [l]))
74
+ .join("\n");
75
+ }
76
+ const temporary = join(dirname(file), `.${name}.${process.pid}.tmp`);
77
+ const mode = statSync(file).mode & 0o777;
78
+ writeFileSync(temporary, next, { mode });
79
+ // The umask may have narrowed `mode` on creation.
80
+ chmodSync(temporary, mode);
81
+ renameSync(temporary, file);
82
+ }
package/src/catalog.ts CHANGED
@@ -19,8 +19,15 @@ export type CatalogEntry = {
19
19
  exportName: string;
20
20
  env: EnvVar[];
21
21
  formatter?: FormatterExample;
22
+ credentials?: CliCredentials;
22
23
  };
23
24
 
25
+ /** Where a tool plugin's CLI keeps its login: `folder` under the CLI user's
26
+ * `~/.config`, and the env variable that carries that folder into the
27
+ * container (a base64 tar.gz with the folder at its root), materialized on
28
+ * the credentials volume the first time the folder isn't there. */
29
+ export type CliCredentials = { folder: string; variable: string };
30
+
24
31
  /** Starting formatter rules for a plugin that hands the user lists: without a
25
32
  * rule a kind of list isn't shown, so the scaffolded config wraps the plugin
26
33
  * with these. `displaysType` is the type the plugin exports for its kinds,
@@ -58,6 +65,7 @@ export const CATALOG: CatalogEntry[] = [
58
65
  kind: "tool",
59
66
  package: "@mercury-fw/plugin-jira",
60
67
  exportName: "jiraPlugin",
68
+ credentials: { folder: "jira-cli", variable: "JIRA_CLI_CONFIG_TAR_B64" },
61
69
  env: [{ name: "JIRA_SITE_URL", comment: "Jira site the issue links point to (https://<site>.atlassian.net)" }],
62
70
  formatter: {
63
71
  displaysType: "JiraDisplays",
@@ -76,6 +84,7 @@ export const CATALOG: CatalogEntry[] = [
76
84
  kind: "tool",
77
85
  package: "@mercury-fw/plugin-bitbucket",
78
86
  exportName: "bitbucketPlugin",
87
+ credentials: { folder: "bitbucket-cli", variable: "BITBUCKET_CLI_CONFIG_TAR_B64" },
79
88
  env: [],
80
89
  },
81
90
  {
@@ -83,6 +92,7 @@ export const CATALOG: CatalogEntry[] = [
83
92
  kind: "tool",
84
93
  package: "@mercury-fw/plugin-atlassian-admin",
85
94
  exportName: "atlassianAdminPlugin",
95
+ credentials: { folder: "atlassian-admin-cli", variable: "ATLASSIAN_ADMIN_CLI_CONFIG_TAR_B64" },
86
96
  env: [],
87
97
  },
88
98
  ];
package/src/program.ts CHANGED
@@ -199,6 +199,35 @@ Examples:
199
199
  .addHelpText("after", INSIDE_AN_APP)
200
200
  .action(async (target: ResetTarget) => inApp((app) => app.reset(target))());
201
201
 
202
+ const credentials = program
203
+ .command("credentials")
204
+ .summary("the tool plugins' CLI credentials")
205
+ .description(
206
+ "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.",
207
+ )
208
+ .helpCommand(false)
209
+ .addHelpText("after", INSIDE_AN_APP);
210
+ credentials
211
+ .command("set")
212
+ .summary("packs a CLI's config folder into the env file")
213
+ .description(
214
+ "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.",
215
+ )
216
+ .argument("<plugin>", "a tool plugin of the app: jira, bitbucket, atlassian-admin")
217
+ .option("--from <dir>", "the CLI's config folder, when it isn't ~/.config/<cli>")
218
+ .option("--print", "print the line to paste elsewhere, and leave the env file alone")
219
+ .action(async (plugin: string, opts: { from?: string; print?: boolean }) =>
220
+ inApp((app) => app.credentialsSet(plugin, { ...(opts.from === undefined ? {} : { from: opts.from }), print: opts.print ?? false }))(),
221
+ );
222
+ credentials
223
+ .command("reset")
224
+ .summary("clears a CLI's folder from the volume")
225
+ .description(
226
+ "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.",
227
+ )
228
+ .argument("<plugin>", "a tool plugin of the app")
229
+ .action(async (plugin: string) => inApp((app) => app.credentialsReset(plugin))());
230
+
202
231
  return program;
203
232
  }
204
233
 
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 EnvVar } from "./catalog.ts";
11
+ import { CATALOG, type CatalogEntry, type CliCredentials, 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" };
@@ -68,7 +68,7 @@ export function renderApp(input: RenderInput): Map<string, string> {
68
68
  [".dockerignore", dockerignore],
69
69
  [".env.example", renderEnv(channels, tools)],
70
70
  [".gitignore", gitignore],
71
- ["Dockerfile", dockerfile],
71
+ ["Dockerfile", renderDockerfile(tools)],
72
72
  ["README.md", renderReadme(input.name, channels, tools)],
73
73
  ["docker-compose.yml", renderCompose(input.name, tools.length > 0)],
74
74
  ["markdown.d.ts", markdownDts],
@@ -81,9 +81,60 @@ export function renderApp(input: RenderInput): Map<string, string> {
81
81
  ["src/index.ts", indexTs],
82
82
  ["src/repl.ts", replTs],
83
83
  ["tsconfig.json", tsconfigJson],
84
+ ...(withCredentials(tools).length > 0
85
+ ? [["docker-entrypoint.sh", renderEntrypoint(withCredentials(tools))] as [string, string]]
86
+ : []),
84
87
  ]);
85
88
  }
86
89
 
90
+ /** The chosen tool plugins whose CLI needs credentials, with them. */
91
+ function withCredentials(tools: CatalogEntry[]): Array<CatalogEntry & { credentials: CliCredentials }> {
92
+ return tools.filter((t): t is CatalogEntry & { credentials: CliCredentials } => t.credentials !== undefined);
93
+ }
94
+
95
+ /** The template's Dockerfile; with CLI credentials to materialize, the service
96
+ * starts through `docker-entrypoint.sh` instead of directly. */
97
+ function renderDockerfile(tools: CatalogEntry[]): string {
98
+ if (withCredentials(tools).length === 0) return dockerfile;
99
+ const cmd = 'CMD ["bun", "src/index.ts"]\n';
100
+ if (!dockerfile.endsWith(cmd)) throw new Error("Dockerfile.tpl no longer ends with the service's CMD");
101
+ return `${dockerfile.slice(0, -cmd.length)}# Materializes the tool plugins' CLI credentials on the first start, then
102
+ # starts the service.
103
+ COPY --chown=mercury:mercury --chmod=755 docker-entrypoint.sh ./
104
+ CMD ["./docker-entrypoint.sh"]
105
+ `;
106
+ }
107
+
108
+ /** \`docker-entrypoint.sh\`: each plugin's credentials variable materialized
109
+ * into its CLI's folder on the volume, only while that folder isn't there,
110
+ * then the service. */
111
+ function renderEntrypoint(tools: Array<CatalogEntry & { credentials: CliCredentials }>): string {
112
+ const lines = tools.map((t) => `materialize ${t.credentials.folder} ${t.credentials.variable}`).join("\n");
113
+ return `#!/usr/bin/env bash
114
+ # Starts the service, first materializing each tool plugin's CLI credentials
115
+ # onto the cli-credentials volume: its variable in the env file is the CLI's
116
+ # config folder as a base64 tar.gz (bunx mfw credentials set <plugin> writes
117
+ # it). Only when that CLI's folder isn't on the volume yet: what a CLI writes
118
+ # back while running, like a refreshed token, stays there across redeploys,
119
+ # and an older value in the env file never overwrites it. bunx mfw credentials
120
+ # reset <plugin> clears one folder so its variable is materialized again.
121
+ set -euo pipefail
122
+
123
+ materialize() {
124
+ local folder="$1"
125
+ local variable="$2"
126
+ local value="\${!variable:-}"
127
+ if [[ -n "$value" && ! -d "/home/mercury/.config/$folder" ]]; then
128
+ echo "$value" | base64 -d | tar xzf - -C /home/mercury/.config
129
+ fi
130
+ }
131
+
132
+ ${lines}
133
+
134
+ exec bun src/index.ts
135
+ `;
136
+ }
137
+
87
138
  /** Why `name` can't be an app name, or undefined when it can. Shared with the
88
139
  * wizard, which checks the name as it's typed. */
89
140
  export function appNameError(name: string): string | undefined {
@@ -251,8 +302,15 @@ function renderEnv(channels: CatalogEntry[], tools: CatalogEntry[]): string {
251
302
  );
252
303
  }
253
304
  for (const entry of [...channels, ...tools]) {
254
- if (entry.env.length > 0) {
255
- sections.push(`# --- ${entry.id}\n${block(entry.env)}`);
305
+ const vars = [...entry.env];
306
+ if (entry.credentials !== undefined) {
307
+ vars.push({
308
+ name: entry.credentials.variable,
309
+ comment: `${entry.credentials.folder}'s config folder, packed: bunx mfw credentials set ${entry.id} writes it; materialized on the credentials volume at the first start without that folder`,
310
+ });
311
+ }
312
+ if (vars.length > 0) {
313
+ sections.push(`# --- ${entry.id}\n${block(vars)}`);
256
314
  }
257
315
  }
258
316
  return `${sections.join("\n\n")}\n`;
@@ -264,7 +322,7 @@ function renderCompose(name: string, hasTools: boolean): string {
264
322
  const credentialsMount = hasTools
265
323
  ? [
266
324
  " # The tool plugins' CLI credentials, on a volume so what a CLI writes back",
267
- " # (refreshed tokens) survives a redeploy. It starts empty: see README.md.",
325
+ " # (refreshed tokens) survives a redeploy. docker-entrypoint.sh fills it: see README.md.",
268
326
  " - cli-credentials:/home/mercury/.config",
269
327
  ]
270
328
  : [];
@@ -328,12 +386,30 @@ bunx mfw repl
328
386
  \`\`\`
329
387
 
330
388
  \`bun install\` here gives your editor, \`bun run typecheck\` and \`mfw\` the packages (tool plugins download their CLI binary as they install); the image installs its own copy when it builds. \`bunx mfw start\` builds the image and starts the app with Qdrant in the background, \`bunx mfw repl\` opens a terminal conversation with the assistant. \`bunx mfw --help\` lists the rest: stopping and restarting, logs, a shell in the container, the wiki and the memory, and resetting them.
331
- ${tools.length > 0 ? CREDENTIALS_SECTION : ""}`;
389
+ ${renderCredentialsSection(withCredentials(tools))}`;
332
390
  }
333
391
 
334
- /** The README section on CLI credentials, for an app with tool plugins. */
335
- const CREDENTIALS_SECTION = `
392
+ /** The README section on CLI credentials, for an app with tool plugins;
393
+ * nothing without one. */
394
+ function renderCredentialsSection(tools: Array<CatalogEntry & { credentials: CliCredentials }>): string {
395
+ if (tools.length === 0) return "";
396
+ const first = tools[0] as CatalogEntry & { credentials: CliCredentials };
397
+ const list = tools.map((t) => `\`${t.id}\` (\`~/.config/${t.credentials.folder}\`)`).join(", ");
398
+ return `
336
399
  ## CLI credentials
337
400
 
338
- Each tool plugin runs its own CLI, and each CLI keeps its login under \`/home/mercury/.config\` in the container, on the \`cli-credentials\` volume. The volume starts empty: nothing in this app provisions it yet, so authenticate each CLI once inside the container (\`docker compose run --rm mercury <cli> --help\` lists its auth commands) or copy its config folder into the volume. What the CLIs write back afterwards, like refreshed tokens, stays on the volume across redeploys.
401
+ 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:
402
+
403
+ \`\`\`bash
404
+ bunx mfw credentials set ${first.id}
405
+ \`\`\`
406
+
407
+ 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.
408
+
409
+ 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:
410
+
411
+ \`\`\`bash
412
+ bunx mfw credentials reset ${first.id}
413
+ \`\`\`
339
414
  `;
415
+ }