@mercury-fw/core 0.32.0 → 0.33.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,25 @@
1
1
  # @mercury-fw/core
2
2
 
3
+ ## 0.33.0
4
+
5
+ ### Minor Changes
6
+
7
+ - a493d9b: - A plugin whose CLI keeps its login in a folder declares it in its `package.json` (`mercury.cliCredentials`): `{ folder }` under `~/.config`, the default, or `{ path }` anywhere else under the home. Any plugin's CLI gets the login mechanism, not only the first-party ones.
8
+ - The core unpacks each declared login from its env variable onto the credentials volume (`~/.config`) at startup, only when the folder isn't there yet, for the service and the REPL alike; a folder declared elsewhere in the home lives on the volume under `~/.config/mercury-home`, linked from its usual place. It warns about a declared folder with neither the folder nor the variable.
9
+ - `mfw credentials set|reset <plugin>` names the plugin by its package or its CLI's folder (`@mercury-fw/plugin-jira` or `jira-cli`), read from the app's installed plugins; the short name (`jira`) is no longer accepted, and reset asks for the folder's name.
10
+ - `mfw create` no longer writes `docker-entrypoint.sh` or the credentials variables in the env example, and always mounts the `cli-credentials` volume; an existing app's entrypoint keeps working alongside.
11
+ - The generated README explains how a plugin's CLI gets its login without listing plugins.
12
+ - jira, bitbucket and atlassian-admin declare their CLI's login folder; their READMEs point to `mfw credentials set`.
13
+
14
+ ### Patch Changes
15
+
16
+ - Updated dependencies [a493d9b]
17
+ - @mercury-fw/utils@0.33.0
18
+ - @mercury-fw/plugin-types@0.33.0
19
+ - @mercury-fw/channel-types@0.33.0
20
+ - @mercury-fw/cli-engine@0.33.0
21
+ - @mercury-fw/confirm-engine@0.33.0
22
+
3
23
  ## 0.32.0
4
24
 
5
25
  ### Minor Changes
@@ -0,0 +1,13 @@
1
+ /** Where to read the declarations and where to unpack them. */
2
+ export interface MaterializeOptions {
3
+ /** The app's folder (its package.json and node_modules). */
4
+ appDir: string;
5
+ /** The home the CLIs run with; its `.config` is the credentials volume. */
6
+ homeDir: string;
7
+ env: Record<string, string | undefined>;
8
+ log: (msg: string) => void;
9
+ }
10
+ /** Unpacks every declared login that's missing from the volume and has its
11
+ * variable set, and links one declared outside `~/.config`; warns about one
12
+ * that has neither, and logs each dependency it couldn't read. */
13
+ export declare function materializeCliCredentials({ appDir, homeDir, env, log }: MaterializeOptions): Promise<void>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mercury-fw/core",
3
- "version": "0.32.0",
3
+ "version": "0.33.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -31,10 +31,11 @@
31
31
  "test": "bun test"
32
32
  },
33
33
  "dependencies": {
34
- "@mercury-fw/channel-types": "0.32.0",
35
- "@mercury-fw/cli-engine": "0.32.0",
36
- "@mercury-fw/confirm-engine": "0.32.0",
37
- "@mercury-fw/plugin-types": "0.32.0",
34
+ "@mercury-fw/channel-types": "0.33.0",
35
+ "@mercury-fw/cli-engine": "0.33.0",
36
+ "@mercury-fw/confirm-engine": "0.33.0",
37
+ "@mercury-fw/plugin-types": "0.33.0",
38
+ "@mercury-fw/utils": "0.33.0",
38
39
  "@qdrant/js-client-rest": "^1.19.0",
39
40
  "ai": "^7.0.126",
40
41
  "ai-sdk-ollama": "^4.4.0",
package/src/compose.ts CHANGED
@@ -21,6 +21,7 @@ import { createConfirmationStore, createStageConfirmation, tryConfirm, resolveCo
21
21
  import { createDisplayStore } from "./tools/display-store.ts";
22
22
  import { createPresentTool } from "./tools/present-tool.ts";
23
23
  import { loadPlugins } from "./plugins/plugin-loader.ts";
24
+ import { materializeCliCredentials } from "./credentials/materialize.ts";
24
25
  import type { MercuryConfig } from "./config/define-config.ts";
25
26
  import { createSessionHistory, type SessionHistory, type Message } from "./session/history.ts";
26
27
  import { createSummarizer } from "./session/summarizer.ts";
@@ -66,6 +67,7 @@ import { listWikiFilesInRoots, readWikiFile, readWikiFileInRoots, readIndexFile
66
67
  import { runRawTriagePass, runIndexAndOrphanPass, runContradictionCheckPass } from "./wiki/self-review-runner.ts";
67
68
  import { startSelfReviewCron } from "./cron/self-review-cron.ts";
68
69
  import { resolve as resolvePath } from "node:path";
70
+ import { homedir } from "node:os";
69
71
  import type { Tool } from "ai";
70
72
  import { startAdminServer } from "./admin/server.ts";
71
73
  // The HTTP surface's read routes (4b) reuse the admin panel's per-domain
@@ -148,6 +150,15 @@ export async function composeMercury(config: MercuryConfig): Promise<ComposedApp
148
150
  // that fails (an invalid allowlist, missing configuration) degrades only itself.
149
151
  const plugins = config.plugins;
150
152
 
153
+ // A plugin's CLI that keeps its login in a folder finds it in place before
154
+ // the plugin loads: unpacked from the env file on a fresh credentials volume.
155
+ await materializeCliCredentials({
156
+ appDir: process.cwd(),
157
+ homeDir: homedir(),
158
+ env: process.env,
159
+ log: (msg) => console.error(msg),
160
+ });
161
+
151
162
  const loadedPlugins = await loadPlugins(plugins, {
152
163
  model,
153
164
  env: process.env,
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Unpacks each plugin-declared CLI credentials variable onto the config folder
3
+ * at startup. A plugin whose CLI keeps its login in a folder declares it in its
4
+ * package.json (`mercury.cliCredentials`, read by `@mercury-fw/utils`); the
5
+ * app's env file carries that folder packed into a variable (`mfw credentials
6
+ * set` writes it). In the container `~/.config` is the credentials volume, so
7
+ * this unpacks only while a CLI's folder isn't there yet: what the CLI writes
8
+ * back afterwards, like a refreshed token, stays across redeploys and an older
9
+ * variable never overwrites it. A login declared elsewhere in the home lives
10
+ * on the volume too (`volumePath`), and the home path is made a link to it at
11
+ * every start, since the rest of the home is the image's and starts over each
12
+ * time. Called by `composeMercury` before the plugins load, so the service and
13
+ * the REPL both get it. Never throws: a CLI without its login degrades only
14
+ * that plugin's calls. It works on the home it's given, so an app run outside
15
+ * its container (against the Docker-first rule) would unpack into the real
16
+ * home: only with a credentials variable set and the folder missing there.
17
+ */
18
+ import { existsSync, lstatSync, mkdirSync, mkdtempSync, readlinkSync, renameSync, rmSync, symlinkSync } from "node:fs";
19
+ import { basename, dirname, join } from "node:path";
20
+ import { appCliCredentials, volumePath, type CliCredentials } from "@mercury-fw/utils";
21
+
22
+ /** Where to read the declarations and where to unpack them. */
23
+ export interface MaterializeOptions {
24
+ /** The app's folder (its package.json and node_modules). */
25
+ appDir: string;
26
+ /** The home the CLIs run with; its `.config` is the credentials volume. */
27
+ homeDir: string;
28
+ env: Record<string, string | undefined>;
29
+ log: (msg: string) => void;
30
+ }
31
+
32
+ /** Unpacks every declared login that's missing from the volume and has its
33
+ * variable set, and links one declared outside `~/.config`; warns about one
34
+ * that has neither, and logs each dependency it couldn't read. */
35
+ export async function materializeCliCredentials({ appDir, homeDir, env, log }: MaterializeOptions): Promise<void> {
36
+ let declared: CliCredentials[];
37
+ try {
38
+ const read = appCliCredentials(appDir);
39
+ for (const problem of read.problems) log(`CLI credentials: ${problem}`);
40
+ declared = read.declared;
41
+ } catch (err) {
42
+ log(`CLI credentials not unpacked: ${err instanceof Error ? err.message : String(err)}`);
43
+ return;
44
+ }
45
+ for (const c of declared) {
46
+ const target = join(homeDir, volumePath(c.path));
47
+ if (!existsSync(target)) {
48
+ const value = env[c.variable];
49
+ if (value === undefined || value === "") {
50
+ log(
51
+ `${c.package}: no login for its CLI (no ${c.name} folder, ${c.variable} not set): run mfw credentials set ${c.name}`,
52
+ );
53
+ continue;
54
+ }
55
+ try {
56
+ await unpack(value, basename(c.path), target);
57
+ } catch (err) {
58
+ log(`${c.package}: could not unpack ${c.variable} into ${c.name}: ${err instanceof Error ? err.message : String(err)}`);
59
+ continue;
60
+ }
61
+ }
62
+ const link = join(homeDir, c.path);
63
+ if (link === target) continue;
64
+ try {
65
+ linkTo(target, link, (msg) => log(`${c.package}: ${msg}`));
66
+ } catch (err) {
67
+ log(`${c.package}: could not link ${link} to the credentials volume: ${err instanceof Error ? err.message : String(err)}`);
68
+ }
69
+ }
70
+ }
71
+
72
+ /** Makes `link` a symlink to `target` unless it already is one; anything else
73
+ * at `link` is never replaced, only reported. */
74
+ function linkTo(target: string, link: string, log: (msg: string) => void): void {
75
+ const existing = lstatSync(link, { throwIfNoEntry: false });
76
+ if (existing === undefined) {
77
+ mkdirSync(dirname(link), { recursive: true });
78
+ symlinkSync(target, link);
79
+ } else if (!existing.isSymbolicLink() || readlinkSync(link) !== target) {
80
+ log(`${link} is already there and isn't a link to the credentials volume: the CLI won't find its login`);
81
+ }
82
+ }
83
+
84
+ /** Extracts only `member` from the base64 tar.gz into a staging folder next to
85
+ * `target`, then moves it to `target`: a failed extraction leaves nothing
86
+ * behind that would count as present on the next start. */
87
+ async function unpack(value: string, member: string, target: string): Promise<void> {
88
+ mkdirSync(dirname(target), { recursive: true });
89
+ const staging = mkdtempSync(join(dirname(target), `.${member}-`));
90
+ try {
91
+ const proc = Bun.spawn(["tar", "-xzf", "-", "-C", staging, member], {
92
+ stdin: Buffer.from(value, "base64"),
93
+ stdout: "ignore",
94
+ stderr: "pipe",
95
+ });
96
+ const [code, stderr] = await Promise.all([proc.exited, new Response(proc.stderr).text()]);
97
+ if (code !== 0 || !existsSync(join(staging, member))) {
98
+ throw new Error(stderr.trim() || `tar exited with ${code}`);
99
+ }
100
+ renameSync(join(staging, member), target);
101
+ } finally {
102
+ rmSync(staging, { recursive: true, force: true });
103
+ }
104
+ }