@mercury-fw/cli 0.38.0 → 0.39.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,34 @@
1
1
  # @mercury-fw/cli
2
2
 
3
+ ## 0.39.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 881835c: A CLI's login is set up inside the app's container, no longer carried in the env file.
8
+
9
+ - A plugin declares in `mercury.cliCredentials` the commands that set its CLI's login up (`setup`, required), check it (`check`) and log an identity out (`logout`), each a binary followed by its arguments.
10
+ - `mfw credentials setup <plugin>` runs the declared setup in a one-off container on your terminal, so the CLI writes its login straight onto the credentials volume; `mfw credentials check <plugin>` runs the declared check.
11
+ - `mfw credentials reset <plugin> [--user <key>]` runs the declared logout, of the identity Mercury runs as or of one person, instead of deleting the CLI's folder.
12
+ - `mfw credentials set` is removed, and the core no longer unpacks `*_CONFIG_TAR_B64` variables at startup: it reports a declared login that isn't set up yet, and still links a login kept outside `~/.config` to the volume.
13
+ - A command the model writes with `--user` is refused: which person a CLI acts as is never the model's to pick.
14
+ - `cliUserId` (`@mercury-fw/utils`) maps a user key to the id a CLI knows a person by.
15
+ - plugin-jira and plugin-bitbucket pin their CLIs at 2.1.0, which keep the service identity and each person apart and refuse the old login folder. After updating, remove the plugin's `*_CONFIG_TAR_B64` line from the env file and run `mfw credentials setup` once; on Jira, Mercury now runs as an Atlassian Service Account.
16
+ - plugin-atlassian-admin keeps its CLI at 0.1.2, whose `init` doesn't prompt for the organization key: until it does, write the key from `mfw shell` with `atlassian-admin init --api-key <KEY> --org-id <ORG_ID>` (the plugin's README says so).
17
+
18
+ ### Patch Changes
19
+
20
+ - Updated dependencies [881835c]
21
+ - @mercury-fw/core@0.39.0
22
+ - @mercury-fw/utils@0.39.0
23
+
24
+ ## 0.38.1
25
+
26
+ ### Patch Changes
27
+
28
+ - 972a17f: - `mfw local-packages` and `mfw create --local-packages` name each tarball in `.packs/` after its content, so a package repacked at the same version is installed anew instead of kept from Bun's cache or refused by the lockfile's integrity check
29
+ - @mercury-fw/core@0.38.1
30
+ - @mercury-fw/utils@0.38.1
31
+
3
32
  ## 0.38.0
4
33
 
5
34
  ### Minor Changes
package/README.md CHANGED
@@ -17,8 +17,9 @@
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
+ - [`mfw credentials setup <plugin>`](#mfw-credentials-setup-plugin)
21
+ - [`mfw credentials check <plugin>`](#mfw-credentials-check-plugin)
22
+ - [`mfw credentials reset <plugin> [--user <key>]`](#mfw-credentials-reset-plugin---user-key)
22
23
  - [`mfw google-chat set-key <key-file> [--subscription <name>]`](#mfw-google-chat-set-key-key-file---subscription-name)
23
24
  - [`mfw local-packages <folder>` / `mfw local-packages --off`](#mfw-local-packages-folder--mfw-local-packages---off)
24
25
  - [`mfw e2e [tests...] [--repeat N]`](#mfw-e2e-tests---repeat-n)
@@ -185,24 +186,44 @@ mfw reset wiki
185
186
 
186
187
  Useful for clearing out test data; the other layer isn't touched.
187
188
 
188
- ### `mfw credentials set <plugin> [--from <dir>] [--print]`
189
+ ### `mfw credentials setup <plugin>`
189
190
 
190
- 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.
191
+ Sets up a plugin's CLI 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`, with the commands that set the login up, check it and log an identity out, each a binary on the container's PATH followed by its arguments:
191
192
 
192
- 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.
193
+ ```json
194
+ "mercury": {
195
+ "cliCredentials": {
196
+ "folder": "jira-cli",
197
+ "setup": ["jira", "init"],
198
+ "check": ["jira", "doctor"],
199
+ "logout": ["jira", "auth", "logout"]
200
+ }
201
+ }
202
+ ```
203
+
204
+ `folder` is under `~/.config`, the usual place; `{ "path": ".aws" }` declares one anywhere else under the home. `setup` is required, `check` and `logout` aren't. `<plugin>` names the declaration by the plugin's package or by its folder; a name the app doesn't have is an error listing the ones it has.
205
+
206
+ `setup` runs the declared setup in a one-off container of the app (`docker compose run --rm --no-deps mercury jira init`) on your terminal, so the CLI asks you for what it needs and writes the login straight onto the credentials volume, mounted on `~/.config`. A login declared elsewhere in the home lives on the volume under `~/.config/mercury-home`, and its usual place is linked there first (the app makes the same link at every start). What the CLI writes back while running, like a refreshed token or a person's login, stays on the volume across redeploys. This sets up the identity Mercury itself runs as; people log in on their own, through Mercury. Nothing goes in the env file, and nothing is carried between machines: each environment sets up its own. 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.
207
+
208
+ ```bash
209
+ mfw credentials setup @mercury-fw/plugin-jira
210
+ ```
211
+
212
+ ### `mfw credentials check <plugin>`
213
+
214
+ Runs the check the plugin declares (the CLI's `doctor`, for the first-party plugins) in a one-off container, and exits with its code.
193
215
 
194
216
  ```bash
195
- mfw credentials set jira-cli
196
- mfw credentials set @mercury-fw/plugin-bitbucket --from ~/work/bitbucket-login
197
- mfw credentials set jira-cli --print
217
+ mfw credentials check jira-cli
198
218
  ```
199
219
 
200
- ### `mfw credentials reset <plugin>`
220
+ ### `mfw credentials reset <plugin> [--user <key>]`
201
221
 
202
- 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`).
222
+ Runs the logout the plugin declares in a one-off container, after you type the name it's declared by (its folder). Without `--user` it logs out the identity Mercury runs as, which then needs `mfw credentials setup` again; with `--user` it logs out one person, by their user key (`<provider>:<id>`), who logs in again through Mercury the next time they need it. The CLI only forgets the login: the tokens aren't revoked at the service.
203
223
 
204
224
  ```bash
205
225
  mfw credentials reset jira-cli
226
+ mfw credentials reset jira-cli --user oidc:312345678901234567
206
227
  ```
207
228
 
208
229
  ### `mfw google-chat set-key <key-file> [--subscription <name>]`
@@ -222,7 +243,7 @@ Makes the app install `@mercury-fw/*` packages from local tarballs instead of th
222
243
 
223
244
  Overrides, and not the dependencies themselves, because a packed package names the packages it depends on by version, and the registry has those versions too: only an override sends them to the tarballs as well (a packed `@mercury-fw/core` depends on `@mercury-fw/plugin-types`, for example). The Dockerfile `mfw create` writes copies `.packs/` before installing, so the image gets the same packages (an app created before 0.31.0 needs `COPY --chown=mercury:mercury .pack[s] ./.packs/` added after the line copying `package.json`); `.gitignore` leaves it out of the repository.
224
245
 
225
- Run it again after packing anew: it replaces the tarballs and the overrides of the previous run, and leaves alone the overrides you wrote yourself. `--off` takes the app back to the registry: the overrides it wrote and `.packs/` go, then `bun install`. After either, `mfw start` rebuilds the image with the packages now installed.
246
+ Run it again after packing anew: it replaces the tarballs and the overrides of the previous run, and leaves alone the overrides you wrote yourself. In `.packs/` each tarball is named after its content (`mercury-fw-core-0.38.0-1a2b3c4d.tgz`): Bun doesn't look again at a tarball whose override didn't change, it keeps what its lockfile and its cache hold, so a repack at the same version needs a new name to be installed. `--off` takes the app back to the registry: the overrides it wrote and `.packs/` go, then `bun install`. After either, `mfw start` rebuilds the image with the packages now installed.
226
247
 
227
248
  ```bash
228
249
  mfw local-packages ../mercury-fw/apps/testbed/.packs
@@ -56,17 +56,19 @@ 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 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
- credentialsSet: (plugin: string, { from, print }: {
63
- from?: string;
64
- print: boolean;
59
+ /** Runs the plugin's declared setup in a one-off container on the user's
60
+ * terminal (without `-T`, compose attaches a TTY whenever stdin is one),
61
+ * so the CLI asks what it needs and writes its login straight onto the
62
+ * credentials volume. */
63
+ credentialsSetup: (plugin: string) => Promise<number>;
64
+ /** Runs the plugin's declared check in a one-off container. */
65
+ credentialsCheck: (plugin: string) => Promise<number>;
66
+ /** Runs the plugin's declared logout, of the service identity or of the
67
+ * person whose user key is `user`, once the user types the declared name.
68
+ * A wrong answer runs nothing. */
69
+ credentialsReset: (plugin: string, { user }: {
70
+ user?: string;
65
71
  }) => Promise<number>;
66
- /** Deletes the plugin's CLI folder from the credentials volume once the
67
- * user types the folder's name, so its variable is unpacked again at the
68
- * next start. A wrong answer deletes nothing. */
69
- credentialsReset: (plugin: string) => Promise<number>;
70
72
  /** Writes the Google Chat channel's service account key (the JSON file
71
73
  * at `keyFile`) into the app's env file, and the Pub/Sub subscription when
72
74
  * given. Everything is checked before anything is written; the key is
@@ -1,8 +1,3 @@
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
1
  /** Sets `name=value` in the env file at `file`. Every line that assigns
7
2
  * `name` (`export name=` and spaces around `=` included) gives way to one
8
3
  * line, where the first was; with none, it's appended. Every other line stays
@@ -4,7 +4,10 @@
4
4
  * which the image copies before `bun install`. Overrides, and not the
5
5
  * dependencies themselves, because a packed package names its siblings by
6
6
  * version, which the registry has too: only an override sends those
7
- * transitive dependencies to the tarballs as well.
7
+ * transitive dependencies to the tarballs as well. Each tarball is named
8
+ * after its content there: Bun takes a `file:` tarball whose spec didn't
9
+ * change for the one in its lockfile, so a repack at the same version needs
10
+ * another name to be installed.
8
11
  */
9
12
  /** The tarballs' folder inside the app, as the Dockerfile copies it. */
10
13
  export declare const LOCAL_PACKS_DIR = ".packs";
@@ -20,8 +23,10 @@ export declare function withLocalOverrides(pkg: Manifest, packs: Array<{
20
23
  /** `pkg` without the overrides this command writes; without `overrides` at
21
24
  * all when none of the user's own is left. */
22
25
  export declare function withoutLocalOverrides(pkg: Manifest): Manifest;
23
- /** A local tarball: its file name in the folder, and the package it holds. */
26
+ /** A local tarball: its file name in the folder (`source`), its name in the
27
+ * app's `.packs/` (`file`), and the package it holds. */
24
28
  export type Pack = {
29
+ source: string;
25
30
  file: string;
26
31
  name: string;
27
32
  version: string;
@@ -33,9 +38,10 @@ export declare function packageOf(tarball: string): Promise<{
33
38
  version: string;
34
39
  }>;
35
40
  /** The tarballs in `from`, in file-name order, each with the package it
36
- * holds, for an app in `appDir`. Throws when `from` doesn't exist, holds no
37
- * tarball, or is the app's own `.packs/` (which the copy replaces). */
41
+ * holds and its name after its content, for an app in `appDir`. Throws when
42
+ * `from` doesn't exist, holds no tarball, or is the app's own `.packs/`
43
+ * (which the copy replaces). */
38
44
  export declare function readPacks(from: string, appDir: string): Promise<Pack[]>;
39
- /** Copies `packs` from `from` into the app's `.packs/`, in place of whatever was there. */
45
+ /** Copies `packs` from `from` into the app's `.packs/` under their content names, in place of whatever was there. */
40
46
  export declare function copyPacks(from: string, appDir: string, packs: Pack[]): void;
41
47
  export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mercury-fw/cli",
3
- "version": "0.38.0",
3
+ "version": "0.39.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -41,8 +41,8 @@
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.38.0",
45
- "@mercury-fw/utils": "0.38.0",
44
+ "@mercury-fw/core": "0.39.0",
45
+ "@mercury-fw/utils": "0.39.0",
46
46
  "commander": "^15.0.0"
47
47
  },
48
48
  "devDependencies": {
@@ -50,10 +50,10 @@
50
50
  "@mercury-fw/channel-http": "0.4.0",
51
51
  "@mercury-fw/auth-oidc": "0.1.0",
52
52
  "@mercury-fw/auth-static": "0.1.0",
53
- "@mercury-fw/formatter": "0.38.0",
54
- "@mercury-fw/plugin-atlassian-admin": "0.1.2",
55
- "@mercury-fw/plugin-bitbucket": "0.1.2",
56
- "@mercury-fw/plugin-jira": "0.4.3",
53
+ "@mercury-fw/formatter": "0.39.0",
54
+ "@mercury-fw/plugin-atlassian-admin": "0.2.0",
55
+ "@mercury-fw/plugin-bitbucket": "0.2.0",
56
+ "@mercury-fw/plugin-jira": "0.5.0",
57
57
  "@mercury-fw/typescript-config": "*",
58
58
  "@types/bun": "^1.4.2",
59
59
  "typescript": "^6.0.3"
@@ -7,9 +7,9 @@
7
7
  */
8
8
  import { chmodSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
9
9
  import { homedir } from "node:os";
10
- import { basename, join, relative, resolve } from "node:path";
11
- import { appCliCredentials, volumePath, type CliCredentials } from "@mercury-fw/utils";
12
- import { packCredentials, readServiceAccountKey, setEnvVar } from "./credentials.ts";
10
+ import { join, relative, resolve } from "node:path";
11
+ import { appCliCredentials, cliUserId, volumePath, type CliCredentials } from "@mercury-fw/utils";
12
+ import { readServiceAccountKey, setEnvVar } from "./credentials.ts";
13
13
  import type { App } from "./find-app.ts";
14
14
  import { copyPacks, LOCAL_PACKS_DIR, readPacks, withLocalOverrides, withoutLocalOverrides } from "./local-packages.ts";
15
15
  import { findTests, loadTest } from "../e2e/load.ts";
@@ -128,38 +128,43 @@ export function appCommands(app: App, deps: AppDeps) {
128
128
  }
129
129
  return 0;
130
130
  },
131
- /** Packs the plugin's CLI login folder (`from`, by default where the
132
- * plugin declares it under the home) into its credentials variable,
133
- * written into the app's env file, or printed with `print`. */
134
- credentialsSet: async (plugin: string, { from, print }: { from?: string; print: boolean }) => {
135
- const { name, path, variable } = credentialsOf(app, plugin);
136
- const source = resolve(from ?? join(deps.home, path));
137
- const value = await packCredentials(source, basename(path));
138
- if (print) {
139
- deps.print(`${variable}=${value}`);
140
- return 0;
141
- }
142
- const envFile = join(app.dir, ".env");
143
- setEnvVar(envFile, variable, value);
144
- deps.print(`${variable} set in ${envFile}, from ${source}.`);
145
- deps.print(
146
- `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.`,
147
- );
148
- return 0;
131
+ /** Runs the plugin's declared setup in a one-off container on the user's
132
+ * terminal (without `-T`, compose attaches a TTY whenever stdin is one),
133
+ * so the CLI asks what it needs and writes its login straight onto the
134
+ * credentials volume. */
135
+ credentialsSetup: async (plugin: string) => {
136
+ const declared = credentialsOf(app, plugin);
137
+ const code = await deps.run([...COMPOSE, "run", "--rm", "--no-deps", SERVICE, ...inContainer(declared, declared.setup)], {
138
+ cwd: app.dir,
139
+ });
140
+ if (code === 0 && declared.check !== undefined) deps.print(`Next: mfw credentials check ${declared.package}`);
141
+ return code;
149
142
  },
150
- /** Deletes the plugin's CLI folder from the credentials volume once the
151
- * user types the folder's name, so its variable is unpacked again at the
152
- * next start. A wrong answer deletes nothing. */
153
- credentialsReset: async (plugin: string) => {
154
- const { name, path } = credentialsOf(app, plugin);
155
- const answer = await deps.ask(
156
- `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: `,
157
- );
158
- if (answer.trim() !== name) {
159
- deps.print("Not confirmed: nothing deleted.");
143
+ /** Runs the plugin's declared check in a one-off container. */
144
+ credentialsCheck: async (plugin: string) => {
145
+ const declared = credentialsOf(app, plugin);
146
+ if (declared.check === undefined) throw new Error(`${declared.package} declares no command to check its CLI's login.`);
147
+ return deps.run([...COMPOSE, "run", "--rm", "--no-deps", "-T", SERVICE, ...inContainer(declared, declared.check)], {
148
+ cwd: app.dir,
149
+ });
150
+ },
151
+ /** Runs the plugin's declared logout, of the service identity or of the
152
+ * person whose user key is `user`, once the user types the declared name.
153
+ * A wrong answer runs nothing. */
154
+ credentialsReset: async (plugin: string, { user }: { user?: string }) => {
155
+ const declared = credentialsOf(app, plugin);
156
+ if (declared.logout === undefined) throw new Error(`${declared.package} declares no command to log its CLI out.`);
157
+ const who =
158
+ user === undefined
159
+ ? `This logs the service identity of ${declared.package}'s CLI out: commands that run as it fail until mfw credentials setup ${declared.package}.`
160
+ : `This logs ${user} out of ${declared.package}'s CLI: they log in again the next time they need it.`;
161
+ const answer = await deps.ask(`${who} Type ${declared.name} to confirm: `);
162
+ if (answer.trim() !== declared.name) {
163
+ deps.print("Not confirmed: nobody logged out.");
160
164
  return 1;
161
165
  }
162
- return stopped(SERVICE, [[...COMPOSE, "run", "--rm", "--no-deps", "-T", SERVICE, "rm", "-rf", `/home/mercury/${volumePath(path)}`]]);
166
+ const argv = user === undefined ? declared.logout : [...declared.logout, "--user", cliUserId(user)];
167
+ return deps.run([...COMPOSE, "run", "--rm", "--no-deps", "-T", SERVICE, ...inContainer(declared, argv)], { cwd: app.dir });
163
168
  },
164
169
  /** Writes the Google Chat channel's service account key (the JSON file
165
170
  * at `keyFile`) into the app's env file, and the Pub/Sub subscription when
@@ -276,6 +281,35 @@ export function appCommands(app: App, deps: AppDeps) {
276
281
  }
277
282
  }
278
283
 
284
+ /** Links `$2` (the home path) to `$1` (its place on the volume) the way the
285
+ * core does at startup, never replacing something else at `$2`, then runs the
286
+ * rest of the arguments. */
287
+ const LINK_THEN_RUN = [
288
+ 'mkdir -p "$1" "$(dirname "$2")" || exit 1',
289
+ 'if [ -L "$2" ] && [ "$(readlink "$2")" = "$1" ]; then :',
290
+ 'elif [ -e "$2" ] || [ -L "$2" ]; then echo "$2 is already there and is not a link to the credentials volume" >&2; exit 1',
291
+ 'else ln -s "$1" "$2" || exit 1; fi',
292
+ 'shift 2',
293
+ 'exec "$@"',
294
+ ].join("\n");
295
+
296
+ /** `argv` as the app's container runs it for `declared`: as it is for a
297
+ * login under ~/.config, the volume's mount; for one kept elsewhere in the
298
+ * home, after linking that path to the volume, as the core does at startup,
299
+ * since a one-off container doesn't start the app. */
300
+ function inContainer(declared: CliCredentials, argv: string[]): string[] {
301
+ if (volumePath(declared.path) === declared.path) return argv;
302
+ return [
303
+ "sh",
304
+ "-c",
305
+ LINK_THEN_RUN,
306
+ "sh",
307
+ `/home/mercury/${volumePath(declared.path)}`,
308
+ `/home/mercury/${declared.path}`,
309
+ ...argv,
310
+ ];
311
+ }
312
+
279
313
  /** The CLI credentials `plugin` names, by package or by folder, among those
280
314
  * the app's dependencies declare; throws naming the ones it has otherwise,
281
315
  * with the dependencies it couldn't read. */
@@ -1,54 +1,9 @@
1
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 core unpacks into `~/.config` on the volume at startup), and
5
- * writing that variable into the app's env file. Also reading a service
6
- * account key file, for `mfw google-chat set-key`.
2
+ * What `mfw google-chat set-key` needs: reading a service account key file,
3
+ * and writing a variable into the app's env file.
7
4
  */
8
- import {
9
- chmodSync,
10
- existsSync,
11
- mkdtempSync,
12
- readFileSync,
13
- realpathSync,
14
- renameSync,
15
- rmSync,
16
- statSync,
17
- symlinkSync,
18
- writeFileSync,
19
- } from "node:fs";
20
- import { tmpdir } from "node:os";
21
- import { dirname, join, resolve } from "node:path";
22
-
23
- /** `from` packed as `<folder>/…` into a base64 tar.gz on one line. The real
24
- * folder behind `from` is archived through a symlink named `folder` and
25
- * dereferenced (`-h`): whatever `from` is called, and even when it's itself a
26
- * symlink (dotfiles), the archive holds the files under the CLI's folder name. */
27
- export async function packCredentials(from: string, folder: string): Promise<string> {
28
- const source = resolve(from);
29
- if (!existsSync(source) || !statSync(source).isDirectory()) {
30
- throw new Error(`No folder at ${source}`);
31
- }
32
- const staging = mkdtempSync(join(tmpdir(), "mfw-credentials-"));
33
- symlinkSync(realpathSync(source), join(staging, folder));
34
- try {
35
- // COPYFILE_DISABLE: macOS's tar would otherwise add ._ files for metadata.
36
- const proc = Bun.spawn(["tar", "-czh", "--no-xattrs", "-f", "-", "-C", staging, folder], {
37
- env: { ...process.env, COPYFILE_DISABLE: "1" },
38
- stdout: "pipe",
39
- stderr: "pipe",
40
- });
41
- const [archive, stderr, code] = await Promise.all([
42
- new Response(proc.stdout).arrayBuffer(),
43
- new Response(proc.stderr).text(),
44
- proc.exited,
45
- ]);
46
- if (code !== 0) throw new Error(`tar failed packing ${source}: ${stderr.trim()}`);
47
- return Buffer.from(archive).toString("base64");
48
- } finally {
49
- rmSync(staging, { recursive: true, force: true });
50
- }
51
- }
5
+ import { chmodSync, existsSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
6
+ import { dirname, join } from "node:path";
52
7
 
53
8
  /** Sets `name=value` in the env file at `file`. Every line that assigns
54
9
  * `name` (`export name=` and spaces around `=` included) gives way to one
@@ -4,10 +4,14 @@
4
4
  * which the image copies before `bun install`. Overrides, and not the
5
5
  * dependencies themselves, because a packed package names its siblings by
6
6
  * version, which the registry has too: only an override sends those
7
- * transitive dependencies to the tarballs as well.
7
+ * transitive dependencies to the tarballs as well. Each tarball is named
8
+ * after its content there: Bun takes a `file:` tarball whose spec didn't
9
+ * change for the one in its lockfile, so a repack at the same version needs
10
+ * another name to be installed.
8
11
  */
9
12
 
10
- import { cpSync, existsSync, mkdirSync, readdirSync, rmSync } from "node:fs";
13
+ import { createHash } from "node:crypto";
14
+ import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync } from "node:fs";
11
15
  import { join, resolve } from "node:path";
12
16
 
13
17
  /** The tarballs' folder inside the app, as the Dockerfile copies it. */
@@ -38,8 +42,15 @@ export function withoutLocalOverrides(pkg: Manifest): Manifest {
38
42
  return Object.keys(own).length > 0 ? { ...rest, overrides: own } : rest;
39
43
  }
40
44
 
41
- /** A local tarball: its file name in the folder, and the package it holds. */
42
- export type Pack = { file: string; name: string; version: string };
45
+ /** A local tarball: its file name in the folder (`source`), its name in the
46
+ * app's `.packs/` (`file`), and the package it holds. */
47
+ export type Pack = { source: string; file: string; name: string; version: string };
48
+
49
+ /** `file`'s name with the first 8 hex digits of `tarball`'s sha256 before `.tgz`. */
50
+ function contentName(file: string, tarball: string): string {
51
+ const hash = createHash("sha256").update(readFileSync(tarball)).digest("hex").slice(0, 8);
52
+ return `${file.slice(0, -".tgz".length)}-${hash}.tgz`;
53
+ }
43
54
 
44
55
  /** The package name and version in a `bun pm pack` tarball, read from its
45
56
  * `package/package.json`. */
@@ -58,8 +69,9 @@ export async function packageOf(tarball: string): Promise<{ name: string; versio
58
69
  }
59
70
 
60
71
  /** The tarballs in `from`, in file-name order, each with the package it
61
- * holds, for an app in `appDir`. Throws when `from` doesn't exist, holds no
62
- * tarball, or is the app's own `.packs/` (which the copy replaces). */
72
+ * holds and its name after its content, for an app in `appDir`. Throws when
73
+ * `from` doesn't exist, holds no tarball, or is the app's own `.packs/`
74
+ * (which the copy replaces). */
63
75
  export async function readPacks(from: string, appDir: string): Promise<Pack[]> {
64
76
  const source = resolve(from);
65
77
  if (source === join(resolve(appDir), LOCAL_PACKS_DIR)) {
@@ -68,13 +80,15 @@ export async function readPacks(from: string, appDir: string): Promise<Pack[]> {
68
80
  if (!existsSync(source)) throw new Error(`${source} doesn't exist.`);
69
81
  const files = readdirSync(source).filter((f) => f.endsWith(".tgz")).sort();
70
82
  if (files.length === 0) throw new Error(`No .tgz in ${source}: pack the packages there first (bun pm pack).`);
71
- return Promise.all(files.map(async (file) => ({ file, ...(await packageOf(join(source, file))) })));
83
+ return Promise.all(
84
+ files.map(async (file) => ({ source: file, file: contentName(file, join(source, file)), ...(await packageOf(join(source, file))) })),
85
+ );
72
86
  }
73
87
 
74
- /** Copies `packs` from `from` into the app's `.packs/`, in place of whatever was there. */
88
+ /** Copies `packs` from `from` into the app's `.packs/` under their content names, in place of whatever was there. */
75
89
  export function copyPacks(from: string, appDir: string, packs: Pack[]): void {
76
90
  const target = join(appDir, LOCAL_PACKS_DIR);
77
91
  rmSync(target, { recursive: true, force: true });
78
92
  mkdirSync(target);
79
- for (const { file } of packs) cpSync(join(resolve(from), file), join(target, file));
93
+ for (const { source, file } of packs) cpSync(join(resolve(from), source), join(target, file));
80
94
  }
package/src/program.ts CHANGED
@@ -228,30 +228,35 @@ Examples:
228
228
  .command("credentials")
229
229
  .summary("the login of a plugin's CLI")
230
230
  .description(
231
- "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.",
231
+ "For a tool plugin whose CLI keeps its login in a folder (under ~/.config, or elsewhere in the home), and declares it with the commands that set it up (mercury.cliCredentials in its package.json): the commands run in a one-off container of the app, so the CLI writes its login straight onto the credentials volume, where it stays across redeploys. Every environment (development, production) sets up its own.",
232
232
  )
233
233
  .helpCommand(false)
234
234
  .addHelpText("after", INSIDE_AN_APP);
235
235
  credentials
236
- .command("set")
237
- .summary("packs a CLI's login folder into the env file")
236
+ .command("setup")
237
+ .summary("sets up a CLI's login in the app's container")
238
238
  .description(
239
- "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.",
239
+ "Runs the setup the plugin declares for its CLI's service identity (the identity Mercury acts as), in a one-off container on your terminal: the CLI asks for what it needs, and its login lands on the credentials volume. People log in on their own later, through Mercury.",
240
240
  )
241
241
  .argument("<plugin>", "the plugin's package (@mercury-fw/plugin-jira) or its CLI's folder as declared (jira-cli)")
242
- .option("--from <dir>", "the CLI's login folder, when it isn't where the plugin declares it")
243
- .option("--print", "print the line to paste elsewhere, and leave the env file alone")
244
- .action(async (plugin: string, opts: { from?: string; print?: boolean }) =>
245
- inApp((app) => app.credentialsSet(plugin, { ...(opts.from === undefined ? {} : { from: opts.from }), print: opts.print ?? false }))(),
246
- );
242
+ .action(async (plugin: string) => inApp((app) => app.credentialsSetup(plugin))());
243
+ credentials
244
+ .command("check")
245
+ .summary("checks a CLI's login")
246
+ .description("Runs the check the plugin declares for its CLI's login (its doctor, for the first-party plugins), in a one-off container.")
247
+ .argument("<plugin>", "the plugin's package or its CLI's folder")
248
+ .action(async (plugin: string) => inApp((app) => app.credentialsCheck(plugin))());
247
249
  credentials
248
250
  .command("reset")
249
- .summary("clears a CLI's folder from the volume")
251
+ .summary("logs an identity out of a CLI")
250
252
  .description(
251
- "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.",
253
+ "Runs the logout the plugin declares for its CLI, after you type the name it's declared by: of the service identity, which then needs mfw credentials setup again, or with --user of one person, who logs in again through Mercury the next time they need it. Local only: the tokens aren't revoked at the service.",
252
254
  )
253
255
  .argument("<plugin>", "the plugin's package or its CLI's folder")
254
- .action(async (plugin: string) => inApp((app) => app.credentialsReset(plugin))());
256
+ .option("--user <key>", "the person to log out, by their user key (<provider>:<id>, e.g. oidc:312345678901234567)")
257
+ .action(async (plugin: string, opts: { user?: string }) =>
258
+ inApp((app) => app.credentialsReset(plugin, opts.user === undefined ? {} : { user: opts.user }))(),
259
+ );
255
260
 
256
261
  const googleChat = program
257
262
  .command("google-chat")
package/src/render.ts CHANGED
@@ -371,18 +371,19 @@ The HTTP channel listens on \`http://<host>:4100\`, the port in \`HTTP_SURFACE_P
371
371
  const CREDENTIALS_SECTION = `
372
372
  ## CLI credentials
373
373
 
374
- 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:
374
+ 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, the plugin declares that folder and the commands that set it up (\`mercury.cliCredentials\` in its \`package.json\`: a folder under \`~/.config\`, or a path anywhere under the home), and the login is set up inside the app's container, on your terminal:
375
375
 
376
376
  \`\`\`bash
377
- mfw credentials set <plugin>
377
+ mfw credentials setup <plugin>
378
+ mfw credentials check <plugin>
378
379
  \`\`\`
379
380
 
380
- \`<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.
381
+ \`<plugin>\` is the plugin's package or its CLI's folder; a name the app doesn't have lists the ones it has. \`setup\` runs the CLI's own setup in a one-off container, so it asks you for what it needs and writes the login straight 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). What the CLI writes back afterwards, like a refreshed token, stays on the volume across redeploys. Nothing goes in \`.env\`, and nothing is carried from one machine to another: every environment sets up its own, development with its credentials, production with its own.
381
382
 
382
- 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:
383
+ To log an identity out, the one Mercury runs as or, with \`--user\`, one person:
383
384
 
384
385
  \`\`\`bash
385
- mfw credentials reset <plugin>
386
+ mfw credentials reset <plugin> [--user <key>]
386
387
  \`\`\`
387
388
 
388
389
  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.