@mercury-fw/utils 0.38.1 → 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,20 @@
1
1
  # @mercury-fw/utils
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
+
3
18
  ## 0.38.1
4
19
 
5
20
  ## 0.38.0
package/README.md CHANGED
@@ -1,5 +1,5 @@
1
1
  # @mercury-fw/utils
2
2
 
3
- Helpers shared by [Mercury](https://github.com/lucabro81/mercury-fw) packages, today the download of a plugin's pinned CLI binary that the CLI-based plugins run when they install, and reading the CLI login folder a plugin declares (`mercury.cliCredentials`). You don't depend on it directly.
3
+ Helpers shared by [Mercury](https://github.com/lucabro81/mercury-fw) packages, today the download of a plugin's pinned CLI binary that the CLI-based plugins run when they install, reading the CLI login a plugin declares (`mercury.cliCredentials`: where it lives and the commands that set it up), and the id a CLI knows a person by. You don't depend on it directly.
4
4
 
5
5
  MIT
@@ -1,42 +1,56 @@
1
1
  /**
2
- * The CLI credentials folder a plugin declares in its own package.json
2
+ * The CLI login a plugin declares in its own package.json
3
3
  * (`mercury.cliCredentials`): where the plugin's CLI keeps its login and reads
4
4
  * it back at runtime, either `{ folder }` under `~/.config` (the usual place)
5
- * or `{ path }` anywhere under the home. The app's env file carries it packed
6
- * into a variable (`mfw credentials set` writes it), and the core unpacks it
7
- * onto the credentials volume at startup. Declared by the plugin rather than
8
- * listed anywhere central, so a plugin from any author gets the same
9
- * mechanism; read as data, so both `mfw` on the host and the core in the
10
- * container find it without running the plugin's code.
5
+ * or `{ path }` anywhere under the home, and the commands that set it up,
6
+ * check it and log an identity out. `mfw credentials` runs those commands
7
+ * inside the app's container, so the CLI writes its login straight onto the
8
+ * credentials volume, and the core links a `{ path }` login to the volume at
9
+ * startup. Declared by the plugin rather than listed anywhere central, so a
10
+ * plugin from any author gets the same mechanism; read as data, so both `mfw`
11
+ * on the host and the core in the container find it without running the
12
+ * plugin's code.
11
13
  */
14
+ import { createHash } from "node:crypto";
12
15
  import { readFileSync, existsSync } from "node:fs";
13
16
  import { join } from "node:path";
14
17
 
15
18
  /** What a plugin declares: `name` as it was declared (the folder, or the
16
- * path), which `mfw credentials` takes and the variable is named after, and
17
- * `path` relative to the home. */
18
- export type DeclaredCredentials = { name: string; path: string };
19
+ * path), which `mfw credentials` takes; `path` relative to the home; and the
20
+ * commands, each a binary on the container's PATH and its arguments: `setup`
21
+ * sets up the service identity, `check` reports on the login, `logout`
22
+ * removes one identity's login (the service's, or a person's with `--user`). */
23
+ export type DeclaredCredentials = {
24
+ name: string;
25
+ path: string;
26
+ setup: string[];
27
+ check?: string[];
28
+ logout?: string[];
29
+ };
19
30
 
20
- /** One dependency's declaration, with the env variable that carries it. */
21
- export type CliCredentials = DeclaredCredentials & { package: string; variable: string };
31
+ /** One dependency's declaration. */
32
+ export type CliCredentials = DeclaredCredentials & { package: string };
22
33
 
23
34
  /** What an app's dependencies declare: the usable declarations, and one line
24
- * per dependency left out (not installed, malformed, sharing a variable). */
35
+ * per dependency left out (not installed, malformed, clashing with another). */
25
36
  export type AppCliCredentials = { declared: CliCredentials[]; problems: string[] };
26
37
 
27
38
  /** A single folder name: no separators, no `.`/`..`, no leading dot. */
28
39
  const FOLDER = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
29
40
 
30
- /** One segment of a home-relative path, never starting with a dash (the last
31
- * one is a tar member name, not an option); `.` and `..` are refused apart. */
41
+ /** One segment of a home-relative path, never starting with a dash; `.` and
42
+ * `..` are refused apart. */
32
43
  const SEGMENT = /^[A-Za-z0-9._][A-Za-z0-9._-]*$/;
33
44
 
45
+ /** A binary found on the PATH: a bare name, never a path or an option. */
46
+ const BINARY = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
47
+
34
48
  /** The credentials volume is mounted on `~/.config`; a login declared
35
49
  * anywhere else in the home lives on it under this folder. */
36
50
  const ELSEWHERE = ".config/mercury-home";
37
51
 
38
52
  const INVALID =
39
- "invalid mercury.cliCredentials in package.json: expected { folder } with a single folder name under ~/.config, or { path } relative to the home and outside ~/.config";
53
+ "invalid mercury.cliCredentials in package.json: expected { folder } with a single folder name under ~/.config, or { path } relative to the home and outside ~/.config, with setup (and optionally check, logout) as a binary name followed by its arguments, e.g. [\"jira\", \"init\"]";
40
54
 
41
55
  /** Whether `path` is a usable home-relative path for a login: inside the
42
56
  * home and outside `~/.config`, the volume, where a login is a `folder`. */
@@ -46,6 +60,16 @@ function isHomePath(path: string): boolean {
46
60
  return path !== ".config" && !path.startsWith(".config/");
47
61
  }
48
62
 
63
+ /** Whether `value` is a command: a binary name, then non-empty arguments. */
64
+ function isCommand(value: unknown): value is string[] {
65
+ return (
66
+ Array.isArray(value) &&
67
+ value.length > 0 &&
68
+ value.every((a) => typeof a === "string" && a !== "") &&
69
+ BINARY.test(value[0] as string)
70
+ );
71
+ }
72
+
49
73
  /**
50
74
  * What `mercury.cliCredentials` of a package.json-shaped object declares, or
51
75
  * undefined when the package declares nothing. A malformed declaration throws:
@@ -61,12 +85,16 @@ export function readCliCredentials(pkg: unknown): DeclaredCredentials | undefine
61
85
  if (!("cliCredentials" in mercury)) return undefined;
62
86
  const declared = (mercury as { cliCredentials?: unknown }).cliCredentials;
63
87
  if (typeof declared !== "object" || declared === null) throw new Error(INVALID);
64
- const { folder, path } = declared as { folder?: unknown; path?: unknown };
88
+ const { folder, path, setup, check, logout } = declared as Record<string, unknown>;
89
+ if (!isCommand(setup) || (check !== undefined && !isCommand(check)) || (logout !== undefined && !isCommand(logout))) {
90
+ throw new Error(INVALID);
91
+ }
92
+ const commands = { setup, ...(check === undefined ? {} : { check }), ...(logout === undefined ? {} : { logout }) };
65
93
  if (folder !== undefined && path === undefined && typeof folder === "string" && FOLDER.test(folder)) {
66
- return { name: folder, path: `.config/${folder}` };
94
+ return { name: folder, path: `.config/${folder}`, ...commands };
67
95
  }
68
96
  if (path !== undefined && folder === undefined && typeof path === "string" && isHomePath(path)) {
69
- return { name: path, path };
97
+ return { name: path, path, ...commands };
70
98
  }
71
99
  throw new Error(INVALID);
72
100
  }
@@ -79,21 +107,36 @@ export function volumePath(path: string): string {
79
107
  return path.startsWith(".config/") ? path : `${ELSEWHERE}/${path}`;
80
108
  }
81
109
 
82
- /** The env variable carrying a declared login packed, named after what was
83
- * declared: `jira-cli` → `JIRA_CLI_CONFIG_TAR_B64`, `.aws` → `AWS_CONFIG_TAR_B64`. */
84
- export function credentialsVariable(name: string): string {
85
- return `${name.toUpperCase().replace(/[^A-Z0-9]/g, "_").replace(/^_+/, "")}_CONFIG_TAR_B64`;
110
+ /** What the CLIs accept as `--user <id>`. */
111
+ const CLI_USER_ID = /^[a-z0-9][a-z0-9._:-]{0,63}$/;
112
+
113
+ /** A provider name kept readable in front of a hashed id. */
114
+ const PROVIDER = /^[a-z0-9][a-z0-9._-]{0,30}$/;
115
+
116
+ /**
117
+ * The id a CLI knows a person by (`--user <id>`), from Mercury's user key
118
+ * (`<provider>:<id>`): the key itself when the CLIs accept it, as they do
119
+ * `static:alice` or an OIDC key with a numeric subject, so a person's folder
120
+ * stays readable; otherwise the provider and the first 32 hex digits of the
121
+ * key's SHA-256, since an id with capitals, `/` or more than 64 characters is
122
+ * refused, and lowering capitals would merge two people.
123
+ */
124
+ export function cliUserId(key: string): string {
125
+ if (CLI_USER_ID.test(key)) return key;
126
+ const provider = key.slice(0, Math.max(key.indexOf(":"), 0));
127
+ const hash = createHash("sha256").update(key).digest("hex").slice(0, 32);
128
+ return `${PROVIDER.test(provider) ? provider : "user"}:${hash}`;
86
129
  }
87
130
 
88
131
  /**
89
132
  * The declarations of the app's dependencies (its package.json `dependencies`,
90
133
  * read from its `node_modules`), in the manifest's order. A dependency that
91
- * isn't installed or declares a malformed folder is left out with a problem
92
- * line, and so are all the ones whose declarations map to the same variable (one
93
- * variable can't carry two logins) or whose paths are one inside the other
94
- * (one would be linked inside the other's copy on the volume): one bad plugin
95
- * never costs the others their login. Throws only when the app's own
96
- * package.json can't be read.
134
+ * isn't installed or declares a malformed login is left out with a problem
135
+ * line, and so are all the ones declaring the same login (each would set it up
136
+ * and log it out for the other) or logins one inside the other (one would be
137
+ * linked inside the other's copy on the volume): one bad plugin never costs
138
+ * the others their login. Throws only when the app's own package.json can't be
139
+ * read.
97
140
  */
98
141
  export function appCliCredentials(appDir: string): AppCliCredentials {
99
142
  const manifest = JSON.parse(readFileSync(join(appDir, "package.json"), "utf-8")) as {
@@ -109,20 +152,18 @@ export function appCliCredentials(appDir: string): AppCliCredentials {
109
152
  }
110
153
  try {
111
154
  const declared = readCliCredentials(JSON.parse(readFileSync(path, "utf-8")));
112
- if (declared !== undefined) found.push({ package: name, ...declared, variable: credentialsVariable(declared.name) });
155
+ if (declared !== undefined) found.push({ package: name, ...declared });
113
156
  } catch (err) {
114
157
  problems.push(`${name}: ${err instanceof Error ? err.message : String(err)}`);
115
158
  }
116
159
  }
117
160
  const excluded = new Set<CliCredentials>();
118
161
  for (const c of found) {
119
- const sharing = found.filter((o) => o.variable === c.variable);
162
+ const sharing = found.filter((o) => o.path === c.path);
120
163
  if (sharing.length === 1) continue;
121
164
  sharing.forEach((o) => excluded.add(o));
122
165
  if (sharing[0] === c) {
123
- problems.push(
124
- `${sharing.map((o) => o.package).join(" and ")} declare CLI credentials (${sharing.map((o) => o.name).join(", ")}) carried by the same variable ${c.variable}: neither is used`,
125
- );
166
+ problems.push(`${sharing.map((o) => o.package).join(" and ")} declare the same CLI credentials (${c.name}): neither is used`);
126
167
  }
127
168
  }
128
169
  const inside = (outer: string, inner: string) => inner.startsWith(`${outer}/`);
@@ -1,17 +1,21 @@
1
1
  /** What a plugin declares: `name` as it was declared (the folder, or the
2
- * path), which `mfw credentials` takes and the variable is named after, and
3
- * `path` relative to the home. */
2
+ * path), which `mfw credentials` takes; `path` relative to the home; and the
3
+ * commands, each a binary on the container's PATH and its arguments: `setup`
4
+ * sets up the service identity, `check` reports on the login, `logout`
5
+ * removes one identity's login (the service's, or a person's with `--user`). */
4
6
  export type DeclaredCredentials = {
5
7
  name: string;
6
8
  path: string;
9
+ setup: string[];
10
+ check?: string[];
11
+ logout?: string[];
7
12
  };
8
- /** One dependency's declaration, with the env variable that carries it. */
13
+ /** One dependency's declaration. */
9
14
  export type CliCredentials = DeclaredCredentials & {
10
15
  package: string;
11
- variable: string;
12
16
  };
13
17
  /** What an app's dependencies declare: the usable declarations, and one line
14
- * per dependency left out (not installed, malformed, sharing a variable). */
18
+ * per dependency left out (not installed, malformed, clashing with another). */
15
19
  export type AppCliCredentials = {
16
20
  declared: CliCredentials[];
17
21
  problems: string[];
@@ -28,17 +32,23 @@ export declare function readCliCredentials(pkg: unknown): DeclaredCredentials |
28
32
  * volume's mount, a declared `folder`), under `~/.config/mercury-home`
29
33
  * otherwise, with the home path pointing there. */
30
34
  export declare function volumePath(path: string): string;
31
- /** The env variable carrying a declared login packed, named after what was
32
- * declared: `jira-cli` → `JIRA_CLI_CONFIG_TAR_B64`, `.aws` → `AWS_CONFIG_TAR_B64`. */
33
- export declare function credentialsVariable(name: string): string;
35
+ /**
36
+ * The id a CLI knows a person by (`--user <id>`), from Mercury's user key
37
+ * (`<provider>:<id>`): the key itself when the CLIs accept it, as they do
38
+ * `static:alice` or an OIDC key with a numeric subject, so a person's folder
39
+ * stays readable; otherwise the provider and the first 32 hex digits of the
40
+ * key's SHA-256, since an id with capitals, `/` or more than 64 characters is
41
+ * refused, and lowering capitals would merge two people.
42
+ */
43
+ export declare function cliUserId(key: string): string;
34
44
  /**
35
45
  * The declarations of the app's dependencies (its package.json `dependencies`,
36
46
  * read from its `node_modules`), in the manifest's order. A dependency that
37
- * isn't installed or declares a malformed folder is left out with a problem
38
- * line, and so are all the ones whose declarations map to the same variable (one
39
- * variable can't carry two logins) or whose paths are one inside the other
40
- * (one would be linked inside the other's copy on the volume): one bad plugin
41
- * never costs the others their login. Throws only when the app's own
42
- * package.json can't be read.
47
+ * isn't installed or declares a malformed login is left out with a problem
48
+ * line, and so are all the ones declaring the same login (each would set it up
49
+ * and log it out for the other) or logins one inside the other (one would be
50
+ * linked inside the other's copy on the volume): one bad plugin never costs
51
+ * the others their login. Throws only when the app's own package.json can't be
52
+ * read.
43
53
  */
44
54
  export declare function appCliCredentials(appDir: string): AppCliCredentials;
package/dist/index.d.ts CHANGED
@@ -5,4 +5,4 @@
5
5
  * as they're factored out.
6
6
  */
7
7
  export { downloadPinnedBinary, resolvePlatform, binaryAssetUrl, readPinnedBinary, type Platform, type PinnedBinary, } from "./cli-binary.ts";
8
- export { readCliCredentials, credentialsVariable, appCliCredentials, volumePath, type DeclaredCredentials, type CliCredentials, type AppCliCredentials, } from "./cli-credentials.ts";
8
+ export { readCliCredentials, appCliCredentials, volumePath, cliUserId, type DeclaredCredentials, type CliCredentials, type AppCliCredentials, } from "./cli-credentials.ts";
package/index.ts CHANGED
@@ -14,9 +14,9 @@ export {
14
14
  } from "./cli-binary.ts";
15
15
  export {
16
16
  readCliCredentials,
17
- credentialsVariable,
18
17
  appCliCredentials,
19
18
  volumePath,
19
+ cliUserId,
20
20
  type DeclaredCredentials,
21
21
  type CliCredentials,
22
22
  type AppCliCredentials,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mercury-fw/utils",
3
- "version": "0.38.1",
3
+ "version": "0.39.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",