@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 +29 -0
- package/README.md +32 -11
- package/dist/src/app/commands.d.ts +12 -10
- package/dist/src/app/credentials.d.ts +0 -5
- package/dist/src/app/local-packages.d.ts +11 -5
- package/package.json +7 -7
- package/src/app/commands.ts +66 -32
- package/src/app/credentials.ts +4 -49
- package/src/app/local-packages.ts +23 -9
- package/src/program.ts +17 -12
- package/src/render.ts +6 -5
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
|
|
21
|
-
- [`mfw credentials
|
|
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
|
|
189
|
+
### `mfw credentials setup <plugin>`
|
|
189
190
|
|
|
190
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
/**
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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,
|
|
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
|
|
37
|
-
* tarball, or is the app's own `.packs/`
|
|
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
|
|
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.
|
|
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.
|
|
45
|
-
"@mercury-fw/utils": "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.
|
|
54
|
-
"@mercury-fw/plugin-atlassian-admin": "0.
|
|
55
|
-
"@mercury-fw/plugin-bitbucket": "0.
|
|
56
|
-
"@mercury-fw/plugin-jira": "0.
|
|
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"
|
package/src/app/commands.ts
CHANGED
|
@@ -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 {
|
|
11
|
-
import { appCliCredentials, volumePath, type CliCredentials } from "@mercury-fw/utils";
|
|
12
|
-
import {
|
|
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
|
-
/**
|
|
132
|
-
*
|
|
133
|
-
*
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
const
|
|
137
|
-
const
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
/**
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
|
|
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. */
|
package/src/app/credentials.ts
CHANGED
|
@@ -1,54 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
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
|
-
|
|
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 {
|
|
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,
|
|
42
|
-
|
|
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
|
|
62
|
-
* tarball, or is the app's own `.packs/`
|
|
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(
|
|
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
|
|
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),
|
|
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
|
|
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("
|
|
237
|
-
.summary("
|
|
236
|
+
.command("setup")
|
|
237
|
+
.summary("sets up a CLI's login in the app's container")
|
|
238
238
|
.description(
|
|
239
|
-
"
|
|
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
|
-
.
|
|
243
|
-
|
|
244
|
-
.
|
|
245
|
-
|
|
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("
|
|
251
|
+
.summary("logs an identity out of a CLI")
|
|
250
252
|
.description(
|
|
251
|
-
"
|
|
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
|
-
.
|
|
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,
|
|
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
|
|
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
|
|
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
|
-
|
|
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.
|