@mercury-fw/utils 0.38.1 → 0.40.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,36 @@
1
1
  # @mercury-fw/utils
2
2
 
3
+ ## 0.40.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 0739f77: A plugin acts as the person Mercury is talking to, who logs in to the service through Mercury.
8
+
9
+ - Plugin contract version 4: a plugin declares `actsAs: "person" | "mercury"`, and one acting as the person contributes a `login`; its tools get the turn's `person` and `requireLogin` in their context.
10
+ - A person is offered only the plugins acting as the person: their prompt fragments, skills and tools. A plugin acting as Mercury, or declaring nothing, is offered on the terminal only until people can be allowed to make Mercury act as itself; the startup log names it.
11
+ - `createCliTool` adds `--user` with the person's id to every command, staged confirmations included, and when the CLI exits with code 3 (not logged in) returns their login instead. `createCliPersonLogin` builds a plugin's `login` on a CLI's two-step remote login. A failed `runCli` carries `exitCode`.
12
+ - Channel contract version 4: channels get `logins` (`accept` a callback URL, `complete` a login) and `detectLoginRequired`.
13
+ - The HTTP channel, with `HTTP_SURFACE_PUBLIC_URL` set, streams a `login` event with the link to open and takes the person back at `GET /login/callback`, public and protected by the single-use state.
14
+ - `mercury.cliCredentials` can declare `userSetup`, run by `mfw credentials setup <plugin> --user-app`: the app people log in through, set up without logging anyone in.
15
+ - plugin-jira and plugin-bitbucket act as the person, on CLIs 2.3.0: set up their people's app with `--user-app` and register `<HTTP_SURFACE_PUBLIC_URL>/login/callback` on it. Jira's skill now says `currentUser()` is the person.
16
+ - plugin-atlassian-admin acts as Mercury, on CLI 0.2.0, whose `init` asks for the key without echoing it: until permissions exist it's available on the terminal only.
17
+ - A third-party plugin needs `apiVersion: 4` to load, and `actsAs: "person"` with a `login` to be offered to people.
18
+
19
+ ## 0.39.0
20
+
21
+ ### Minor Changes
22
+
23
+ - 881835c: A CLI's login is set up inside the app's container, no longer carried in the env file.
24
+
25
+ - 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.
26
+ - `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.
27
+ - `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.
28
+ - `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.
29
+ - A command the model writes with `--user` is refused: which person a CLI acts as is never the model's to pick.
30
+ - `cliUserId` (`@mercury-fw/utils`) maps a user key to the id a CLI knows a person by.
31
+ - 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.
32
+ - 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).
33
+
3
34
  ## 0.38.1
4
35
 
5
36
  ## 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,58 @@
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, `userSetup` the app people log in through
22
+ * (no person logged in), `check` reports on the login, `logout` removes one
23
+ * identity's login (the service's, or a person's with `--user`). */
24
+ export type DeclaredCredentials = {
25
+ name: string;
26
+ path: string;
27
+ setup: string[];
28
+ userSetup?: string[];
29
+ check?: string[];
30
+ logout?: string[];
31
+ };
19
32
 
20
- /** One dependency's declaration, with the env variable that carries it. */
21
- export type CliCredentials = DeclaredCredentials & { package: string; variable: string };
33
+ /** One dependency's declaration. */
34
+ export type CliCredentials = DeclaredCredentials & { package: string };
22
35
 
23
36
  /** What an app's dependencies declare: the usable declarations, and one line
24
- * per dependency left out (not installed, malformed, sharing a variable). */
37
+ * per dependency left out (not installed, malformed, clashing with another). */
25
38
  export type AppCliCredentials = { declared: CliCredentials[]; problems: string[] };
26
39
 
27
40
  /** A single folder name: no separators, no `.`/`..`, no leading dot. */
28
41
  const FOLDER = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
29
42
 
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. */
43
+ /** One segment of a home-relative path, never starting with a dash; `.` and
44
+ * `..` are refused apart. */
32
45
  const SEGMENT = /^[A-Za-z0-9._][A-Za-z0-9._-]*$/;
33
46
 
47
+ /** A binary found on the PATH: a bare name, never a path or an option. */
48
+ const BINARY = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
49
+
34
50
  /** The credentials volume is mounted on `~/.config`; a login declared
35
51
  * anywhere else in the home lives on it under this folder. */
36
52
  const ELSEWHERE = ".config/mercury-home";
37
53
 
38
54
  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";
55
+ "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 userSetup, check, logout) as a binary name followed by its arguments, e.g. [\"jira\", \"init\"]";
40
56
 
41
57
  /** Whether `path` is a usable home-relative path for a login: inside the
42
58
  * home and outside `~/.config`, the volume, where a login is a `folder`. */
@@ -46,6 +62,16 @@ function isHomePath(path: string): boolean {
46
62
  return path !== ".config" && !path.startsWith(".config/");
47
63
  }
48
64
 
65
+ /** Whether `value` is a command: a binary name, then non-empty arguments. */
66
+ function isCommand(value: unknown): value is string[] {
67
+ return (
68
+ Array.isArray(value) &&
69
+ value.length > 0 &&
70
+ value.every((a) => typeof a === "string" && a !== "") &&
71
+ BINARY.test(value[0] as string)
72
+ );
73
+ }
74
+
49
75
  /**
50
76
  * What `mercury.cliCredentials` of a package.json-shaped object declares, or
51
77
  * undefined when the package declares nothing. A malformed declaration throws:
@@ -61,12 +87,20 @@ export function readCliCredentials(pkg: unknown): DeclaredCredentials | undefine
61
87
  if (!("cliCredentials" in mercury)) return undefined;
62
88
  const declared = (mercury as { cliCredentials?: unknown }).cliCredentials;
63
89
  if (typeof declared !== "object" || declared === null) throw new Error(INVALID);
64
- const { folder, path } = declared as { folder?: unknown; path?: unknown };
90
+ const { folder, path, setup, userSetup, check, logout } = declared as Record<string, unknown>;
91
+ const optional = { userSetup, check, logout };
92
+ if (!isCommand(setup) || Object.values(optional).some((c) => c !== undefined && !isCommand(c))) {
93
+ throw new Error(INVALID);
94
+ }
95
+ const commands: Pick<DeclaredCredentials, "setup" | "userSetup" | "check" | "logout"> = { setup };
96
+ for (const [field, command] of Object.entries(optional) as [keyof typeof optional, string[] | undefined][]) {
97
+ if (command !== undefined) commands[field] = command;
98
+ }
65
99
  if (folder !== undefined && path === undefined && typeof folder === "string" && FOLDER.test(folder)) {
66
- return { name: folder, path: `.config/${folder}` };
100
+ return { name: folder, path: `.config/${folder}`, ...commands };
67
101
  }
68
102
  if (path !== undefined && folder === undefined && typeof path === "string" && isHomePath(path)) {
69
- return { name: path, path };
103
+ return { name: path, path, ...commands };
70
104
  }
71
105
  throw new Error(INVALID);
72
106
  }
@@ -79,21 +113,36 @@ export function volumePath(path: string): string {
79
113
  return path.startsWith(".config/") ? path : `${ELSEWHERE}/${path}`;
80
114
  }
81
115
 
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`;
116
+ /** What the CLIs accept as `--user <id>`. */
117
+ const CLI_USER_ID = /^[a-z0-9][a-z0-9._:-]{0,63}$/;
118
+
119
+ /** A provider name kept readable in front of a hashed id. */
120
+ const PROVIDER = /^[a-z0-9][a-z0-9._-]{0,30}$/;
121
+
122
+ /**
123
+ * The id a CLI knows a person by (`--user <id>`), from Mercury's user key
124
+ * (`<provider>:<id>`): the key itself when the CLIs accept it, as they do
125
+ * `static:alice` or an OIDC key with a numeric subject, so a person's folder
126
+ * stays readable; otherwise the provider and the first 32 hex digits of the
127
+ * key's SHA-256, since an id with capitals, `/` or more than 64 characters is
128
+ * refused, and lowering capitals would merge two people.
129
+ */
130
+ export function cliUserId(key: string): string {
131
+ if (CLI_USER_ID.test(key)) return key;
132
+ const provider = key.slice(0, Math.max(key.indexOf(":"), 0));
133
+ const hash = createHash("sha256").update(key).digest("hex").slice(0, 32);
134
+ return `${PROVIDER.test(provider) ? provider : "user"}:${hash}`;
86
135
  }
87
136
 
88
137
  /**
89
138
  * The declarations of the app's dependencies (its package.json `dependencies`,
90
139
  * 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.
140
+ * isn't installed or declares a malformed login is left out with a problem
141
+ * line, and so are all the ones declaring the same login (each would set it up
142
+ * and log it out for the other) or logins one inside the other (one would be
143
+ * linked inside the other's copy on the volume): one bad plugin never costs
144
+ * the others their login. Throws only when the app's own package.json can't be
145
+ * read.
97
146
  */
98
147
  export function appCliCredentials(appDir: string): AppCliCredentials {
99
148
  const manifest = JSON.parse(readFileSync(join(appDir, "package.json"), "utf-8")) as {
@@ -109,20 +158,18 @@ export function appCliCredentials(appDir: string): AppCliCredentials {
109
158
  }
110
159
  try {
111
160
  const declared = readCliCredentials(JSON.parse(readFileSync(path, "utf-8")));
112
- if (declared !== undefined) found.push({ package: name, ...declared, variable: credentialsVariable(declared.name) });
161
+ if (declared !== undefined) found.push({ package: name, ...declared });
113
162
  } catch (err) {
114
163
  problems.push(`${name}: ${err instanceof Error ? err.message : String(err)}`);
115
164
  }
116
165
  }
117
166
  const excluded = new Set<CliCredentials>();
118
167
  for (const c of found) {
119
- const sharing = found.filter((o) => o.variable === c.variable);
168
+ const sharing = found.filter((o) => o.path === c.path);
120
169
  if (sharing.length === 1) continue;
121
170
  sharing.forEach((o) => excluded.add(o));
122
171
  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
- );
172
+ problems.push(`${sharing.map((o) => o.package).join(" and ")} declare the same CLI credentials (${c.name}): neither is used`);
126
173
  }
127
174
  }
128
175
  const inside = (outer: string, inner: string) => inner.startsWith(`${outer}/`);
@@ -1,17 +1,23 @@
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, `userSetup` the app people log in through
5
+ * (no person logged in), `check` reports on the login, `logout` removes one
6
+ * identity's login (the service's, or a person's with `--user`). */
4
7
  export type DeclaredCredentials = {
5
8
  name: string;
6
9
  path: string;
10
+ setup: string[];
11
+ userSetup?: string[];
12
+ check?: string[];
13
+ logout?: string[];
7
14
  };
8
- /** One dependency's declaration, with the env variable that carries it. */
15
+ /** One dependency's declaration. */
9
16
  export type CliCredentials = DeclaredCredentials & {
10
17
  package: string;
11
- variable: string;
12
18
  };
13
19
  /** What an app's dependencies declare: the usable declarations, and one line
14
- * per dependency left out (not installed, malformed, sharing a variable). */
20
+ * per dependency left out (not installed, malformed, clashing with another). */
15
21
  export type AppCliCredentials = {
16
22
  declared: CliCredentials[];
17
23
  problems: string[];
@@ -28,17 +34,23 @@ export declare function readCliCredentials(pkg: unknown): DeclaredCredentials |
28
34
  * volume's mount, a declared `folder`), under `~/.config/mercury-home`
29
35
  * otherwise, with the home path pointing there. */
30
36
  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;
37
+ /**
38
+ * The id a CLI knows a person by (`--user <id>`), from Mercury's user key
39
+ * (`<provider>:<id>`): the key itself when the CLIs accept it, as they do
40
+ * `static:alice` or an OIDC key with a numeric subject, so a person's folder
41
+ * stays readable; otherwise the provider and the first 32 hex digits of the
42
+ * key's SHA-256, since an id with capitals, `/` or more than 64 characters is
43
+ * refused, and lowering capitals would merge two people.
44
+ */
45
+ export declare function cliUserId(key: string): string;
34
46
  /**
35
47
  * The declarations of the app's dependencies (its package.json `dependencies`,
36
48
  * 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.
49
+ * isn't installed or declares a malformed login is left out with a problem
50
+ * line, and so are all the ones declaring the same login (each would set it up
51
+ * and log it out for the other) or logins one inside the other (one would be
52
+ * linked inside the other's copy on the volume): one bad plugin never costs
53
+ * the others their login. Throws only when the app's own package.json can't be
54
+ * read.
43
55
  */
44
56
  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.40.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",