mercury-agent 0.4.7 → 0.4.8
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/docs/auth/dashboard.md +28 -28
- package/examples/extensions/voice-synth/index.ts +94 -94
- package/package.json +1 -1
- package/src/adapters/whatsapp.ts +635 -632
- package/src/agent/model-capabilities.ts +231 -231
- package/src/bridges/discord.ts +178 -178
- package/src/bridges/slack.ts +179 -179
- package/src/bridges/teams.ts +162 -162
- package/src/bridges/telegram.ts +579 -579
- package/src/cli/mercury.ts +2536 -2536
- package/src/cli/whatsapp-auth.ts +263 -260
- package/src/config.ts +316 -316
- package/src/core/permissions.ts +196 -196
- package/src/core/router.ts +191 -191
- package/src/core/routes/chat.ts +175 -175
- package/src/core/routes/dashboard.ts +2491 -2491
- package/src/core/routes/messages.ts +37 -37
- package/src/core/routes/mutes.ts +95 -95
- package/src/core/routes/roles.ts +135 -135
- package/src/core/runtime.ts +1140 -1140
- package/src/core/task-scheduler.ts +139 -139
- package/src/extensions/catalog.ts +117 -117
- package/src/extensions/hooks.ts +161 -161
- package/src/extensions/installer.ts +306 -306
- package/src/extensions/loader.ts +271 -271
- package/src/extensions/permission-guard.ts +52 -52
- package/src/server.ts +391 -391
- package/src/storage/db.ts +1625 -1625
- package/src/storage/pi-auth.ts +95 -95
- package/src/tts/azure.ts +52 -52
- package/src/tts/synthesize.ts +133 -133
|
@@ -1,306 +1,306 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Install/remove Mercury extensions from the host (CLI + dashboard).
|
|
3
|
-
* Uses the same layout as `mercury add` / `mercury remove`.
|
|
4
|
-
*/
|
|
5
|
-
import { spawnSync } from "node:child_process";
|
|
6
|
-
import {
|
|
7
|
-
cpSync,
|
|
8
|
-
existsSync,
|
|
9
|
-
mkdirSync,
|
|
10
|
-
readFileSync,
|
|
11
|
-
renameSync,
|
|
12
|
-
rmSync,
|
|
13
|
-
} from "node:fs";
|
|
14
|
-
import { dirname, join } from "node:path";
|
|
15
|
-
import type { Logger } from "../logger.js";
|
|
16
|
-
import type { ExtensionCatalogEntry } from "./catalog.js";
|
|
17
|
-
import { RESERVED_EXTENSION_NAMES } from "./reserved.js";
|
|
18
|
-
|
|
19
|
-
const VALID_EXT_NAME_RE = /^[a-z0-9][a-z0-9-]*$/;
|
|
20
|
-
|
|
21
|
-
function loadEnvFile(envPath: string): Record<string, string> {
|
|
22
|
-
const content = readFileSync(envPath, "utf-8");
|
|
23
|
-
const vars: Record<string, string> = {};
|
|
24
|
-
for (const line of content.split("\n")) {
|
|
25
|
-
const trimmed = line.trim();
|
|
26
|
-
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
27
|
-
const match = trimmed.match(/^([^=]+)=(.*)$/);
|
|
28
|
-
if (match) {
|
|
29
|
-
vars[match[1]] = match[2];
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
return vars;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/** Resolve MERCURY_DATA_DIR from project `.env` (default `.mercury`). */
|
|
36
|
-
export function getProjectDataDir(cwd: string): string {
|
|
37
|
-
const envPath = join(cwd, ".env");
|
|
38
|
-
if (existsSync(envPath)) {
|
|
39
|
-
const envVars = loadEnvFile(envPath);
|
|
40
|
-
if (envVars.MERCURY_DATA_DIR) return envVars.MERCURY_DATA_DIR;
|
|
41
|
-
}
|
|
42
|
-
return ".mercury";
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
export function getUserExtensionsDir(cwd: string): string {
|
|
46
|
-
return join(cwd, getProjectDataDir(cwd), "extensions");
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
export function getGlobalDir(cwd: string): string {
|
|
50
|
-
const envPath = join(cwd, ".env");
|
|
51
|
-
if (existsSync(envPath)) {
|
|
52
|
-
const envVars = loadEnvFile(envPath);
|
|
53
|
-
if (envVars.MERCURY_GLOBAL_DIR) return envVars.MERCURY_GLOBAL_DIR;
|
|
54
|
-
}
|
|
55
|
-
return join(cwd, getProjectDataDir(cwd), "global");
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/** Path to `examples/extensions/<sourceDir>` inside the mercury-agent package. */
|
|
59
|
-
export function resolveExamplesExtensionDir(
|
|
60
|
-
packageRoot: string,
|
|
61
|
-
sourceDir: string,
|
|
62
|
-
): string {
|
|
63
|
-
return join(packageRoot, "examples", "extensions", sourceDir);
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
export type ExtensionInstallResult =
|
|
67
|
-
| { ok: true }
|
|
68
|
-
| { ok: false; error: string };
|
|
69
|
-
|
|
70
|
-
function validateForInstall(
|
|
71
|
-
destName: string,
|
|
72
|
-
sourceDir: string,
|
|
73
|
-
extensionsDir: string,
|
|
74
|
-
): string | null {
|
|
75
|
-
if (!VALID_EXT_NAME_RE.test(destName)) {
|
|
76
|
-
return `Invalid extension name "${destName}" (lowercase letters, digits, hyphens only).`;
|
|
77
|
-
}
|
|
78
|
-
if (RESERVED_EXTENSION_NAMES.has(destName)) {
|
|
79
|
-
return `"${destName}" is a reserved built-in name.`;
|
|
80
|
-
}
|
|
81
|
-
if (!existsSync(join(sourceDir, "index.ts"))) {
|
|
82
|
-
return "Extension source has no index.ts.";
|
|
83
|
-
}
|
|
84
|
-
if (existsSync(join(extensionsDir, destName))) {
|
|
85
|
-
return `Extension "${destName}" is already installed. Remove it first.`;
|
|
86
|
-
}
|
|
87
|
-
return null;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
/** Validate that `index.ts` exists and default-exports a function (for CLI doctor). */
|
|
91
|
-
export async function checkExtensionIndexLoads(
|
|
92
|
-
extDir: string,
|
|
93
|
-
logicalName: string,
|
|
94
|
-
): Promise<string | null> {
|
|
95
|
-
const indexPath = join(extDir, "index.ts");
|
|
96
|
-
try {
|
|
97
|
-
const mod = await import(indexPath);
|
|
98
|
-
if (typeof mod.default !== "function") {
|
|
99
|
-
return `${logicalName}/index.ts must export a default function`;
|
|
100
|
-
}
|
|
101
|
-
} catch (err) {
|
|
102
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
103
|
-
return `Failed to load ${logicalName}/index.ts: ${msg}`;
|
|
104
|
-
}
|
|
105
|
-
return null;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
function installSkillIfPresent(
|
|
109
|
-
extDir: string,
|
|
110
|
-
name: string,
|
|
111
|
-
cwd: string,
|
|
112
|
-
): void {
|
|
113
|
-
const skillDir = join(extDir, "skill");
|
|
114
|
-
if (!existsSync(join(skillDir, "SKILL.md"))) return;
|
|
115
|
-
|
|
116
|
-
const globalDir = getGlobalDir(cwd);
|
|
117
|
-
const dst = join(globalDir, "skills", name);
|
|
118
|
-
mkdirSync(dirname(dst), { recursive: true });
|
|
119
|
-
rmSync(dst, { recursive: true, force: true });
|
|
120
|
-
cpSync(skillDir, dst, { recursive: true });
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
/**
|
|
124
|
-
* Copy an extension from a local directory into `.mercury/extensions/<destName>/`.
|
|
125
|
-
*/
|
|
126
|
-
export async function installExtensionFromDirectory(options: {
|
|
127
|
-
cwd: string;
|
|
128
|
-
sourceDir: string;
|
|
129
|
-
destName: string;
|
|
130
|
-
}): Promise<ExtensionInstallResult> {
|
|
131
|
-
const { cwd, sourceDir, destName } = options;
|
|
132
|
-
const extensionsDir = getUserExtensionsDir(cwd);
|
|
133
|
-
mkdirSync(extensionsDir, { recursive: true });
|
|
134
|
-
|
|
135
|
-
const err = validateForInstall(destName, sourceDir, extensionsDir);
|
|
136
|
-
if (err) return { ok: false, error: err };
|
|
137
|
-
|
|
138
|
-
const loadErr = await checkExtensionIndexLoads(sourceDir, destName);
|
|
139
|
-
if (loadErr) return { ok: false, error: loadErr };
|
|
140
|
-
|
|
141
|
-
const destDir = join(extensionsDir, destName);
|
|
142
|
-
try {
|
|
143
|
-
cpSync(sourceDir, destDir, { recursive: true });
|
|
144
|
-
|
|
145
|
-
if (existsSync(join(destDir, "package.json"))) {
|
|
146
|
-
const installResult = spawnSync("bun", ["install"], {
|
|
147
|
-
stdio: "pipe",
|
|
148
|
-
encoding: "utf-8",
|
|
149
|
-
cwd: destDir,
|
|
150
|
-
});
|
|
151
|
-
if (installResult.status !== 0) {
|
|
152
|
-
const stderr = installResult.stderr?.toString?.() ?? "";
|
|
153
|
-
rmSync(destDir, { recursive: true, force: true });
|
|
154
|
-
return {
|
|
155
|
-
ok: false,
|
|
156
|
-
error: `bun install failed in extension: ${stderr || "unknown error"}`,
|
|
157
|
-
};
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
installSkillIfPresent(destDir, destName, cwd);
|
|
162
|
-
return { ok: true };
|
|
163
|
-
} catch (e) {
|
|
164
|
-
if (existsSync(destDir)) {
|
|
165
|
-
rmSync(destDir, { recursive: true, force: true });
|
|
166
|
-
}
|
|
167
|
-
const msg = e instanceof Error ? e.message : String(e);
|
|
168
|
-
return { ok: false, error: msg };
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
export function removeInstalledExtension(options: {
|
|
173
|
-
cwd: string;
|
|
174
|
-
name: string;
|
|
175
|
-
}): ExtensionInstallResult {
|
|
176
|
-
const { cwd, name } = options;
|
|
177
|
-
if (!VALID_EXT_NAME_RE.test(name)) {
|
|
178
|
-
return { ok: false, error: `Invalid extension name "${name}".` };
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
const extensionsDir = getUserExtensionsDir(cwd);
|
|
182
|
-
const extDir = join(extensionsDir, name);
|
|
183
|
-
|
|
184
|
-
if (!existsSync(extDir)) {
|
|
185
|
-
return { ok: false, error: `Extension "${name}" is not installed.` };
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
const globalDir = getGlobalDir(cwd);
|
|
189
|
-
const skillDst = join(globalDir, "skills", name);
|
|
190
|
-
if (existsSync(skillDst)) {
|
|
191
|
-
rmSync(skillDst, { recursive: true, force: true });
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
rmSync(extDir, { recursive: true, force: true });
|
|
195
|
-
return { ok: true };
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
/**
|
|
199
|
-
* On startup, re-copy any installed catalog extension whose bundled source
|
|
200
|
-
* differs from the installed copy. Triggered when a new image ships a patched
|
|
201
|
-
* extension (e.g. MERCURY_BROWSER_SESSIONS fix) but running agents still have
|
|
202
|
-
* the old installed copy on their data volume.
|
|
203
|
-
*
|
|
204
|
-
* Uses an atomic temp-dir swap so a failed copy never leaves an extension
|
|
205
|
-
* partially installed or fully deleted.
|
|
206
|
-
*/
|
|
207
|
-
export async function syncBundledCatalogExtensions(options: {
|
|
208
|
-
packageRoot: string;
|
|
209
|
-
extensionsDir: string;
|
|
210
|
-
globalDir: string;
|
|
211
|
-
catalog: ExtensionCatalogEntry[];
|
|
212
|
-
logger: Logger;
|
|
213
|
-
}): Promise<void> {
|
|
214
|
-
const { packageRoot, extensionsDir, globalDir, catalog, logger } = options;
|
|
215
|
-
if (!existsSync(extensionsDir)) return;
|
|
216
|
-
|
|
217
|
-
for (const entry of catalog) {
|
|
218
|
-
const installedDir = join(extensionsDir, entry.name);
|
|
219
|
-
if (!existsSync(installedDir)) continue;
|
|
220
|
-
|
|
221
|
-
const bundledDir = resolveExamplesExtensionDir(
|
|
222
|
-
packageRoot,
|
|
223
|
-
entry.sourceDir,
|
|
224
|
-
);
|
|
225
|
-
if (!existsSync(bundledDir)) continue;
|
|
226
|
-
|
|
227
|
-
const bundledIndex = join(bundledDir, "index.ts");
|
|
228
|
-
if (!existsSync(bundledIndex)) continue;
|
|
229
|
-
|
|
230
|
-
let needsUpdate = false;
|
|
231
|
-
try {
|
|
232
|
-
for (const file of ["index.ts", "package.json"]) {
|
|
233
|
-
const bundledFile = join(bundledDir, file);
|
|
234
|
-
const installedFile = join(installedDir, file);
|
|
235
|
-
if (!existsSync(bundledFile)) continue;
|
|
236
|
-
const bundledContent = readFileSync(bundledFile, "utf-8");
|
|
237
|
-
const installedContent = existsSync(installedFile)
|
|
238
|
-
? readFileSync(installedFile, "utf-8")
|
|
239
|
-
: null;
|
|
240
|
-
if (bundledContent !== installedContent) {
|
|
241
|
-
needsUpdate = true;
|
|
242
|
-
break;
|
|
243
|
-
}
|
|
244
|
-
}
|
|
245
|
-
} catch {
|
|
246
|
-
needsUpdate = true;
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
if (!needsUpdate) continue;
|
|
250
|
-
|
|
251
|
-
logger.info("Bundled extension source updated — reinstalling", {
|
|
252
|
-
name: entry.name,
|
|
253
|
-
});
|
|
254
|
-
|
|
255
|
-
// Atomic swap: copy to a temp sibling dir first, then replace.
|
|
256
|
-
// This ensures a copy failure never leaves the extension deleted.
|
|
257
|
-
const tmpDir = `${installedDir}.tmp`;
|
|
258
|
-
rmSync(tmpDir, { recursive: true, force: true });
|
|
259
|
-
try {
|
|
260
|
-
cpSync(bundledDir, tmpDir, { recursive: true });
|
|
261
|
-
|
|
262
|
-
if (existsSync(join(tmpDir, "package.json"))) {
|
|
263
|
-
const installResult = spawnSync("bun", ["install"], {
|
|
264
|
-
stdio: "pipe",
|
|
265
|
-
encoding: "utf-8",
|
|
266
|
-
cwd: tmpDir,
|
|
267
|
-
});
|
|
268
|
-
if (installResult.status !== 0) {
|
|
269
|
-
// bun install failed — discard the temp dir; keep the old install.
|
|
270
|
-
rmSync(tmpDir, { recursive: true, force: true });
|
|
271
|
-
logger.warn(
|
|
272
|
-
"Extension sync skipped: bun install failed in bundled source",
|
|
273
|
-
{
|
|
274
|
-
name: entry.name,
|
|
275
|
-
stderr: installResult.stderr?.toString?.() ?? "",
|
|
276
|
-
},
|
|
277
|
-
);
|
|
278
|
-
continue;
|
|
279
|
-
}
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
// Swap: remove old, rename temp into place.
|
|
283
|
-
rmSync(installedDir, { recursive: true, force: true });
|
|
284
|
-
renameSync(tmpDir, installedDir);
|
|
285
|
-
|
|
286
|
-
// Sync skill dir to global skills.
|
|
287
|
-
const skillDir = join(installedDir, "skill");
|
|
288
|
-
if (existsSync(join(skillDir, "SKILL.md"))) {
|
|
289
|
-
const dst = join(globalDir, "skills", entry.name);
|
|
290
|
-
mkdirSync(dirname(dst), { recursive: true });
|
|
291
|
-
rmSync(dst, { recursive: true, force: true });
|
|
292
|
-
cpSync(skillDir, dst, { recursive: true });
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
logger.info("Extension reinstalled from bundled source", {
|
|
296
|
-
name: entry.name,
|
|
297
|
-
});
|
|
298
|
-
} catch (e) {
|
|
299
|
-
rmSync(tmpDir, { recursive: true, force: true });
|
|
300
|
-
logger.warn("Failed to reinstall extension from bundled source", {
|
|
301
|
-
name: entry.name,
|
|
302
|
-
error: e instanceof Error ? e.message : String(e),
|
|
303
|
-
});
|
|
304
|
-
}
|
|
305
|
-
}
|
|
306
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Install/remove Mercury extensions from the host (CLI + dashboard).
|
|
3
|
+
* Uses the same layout as `mercury add` / `mercury remove`.
|
|
4
|
+
*/
|
|
5
|
+
import { spawnSync } from "node:child_process";
|
|
6
|
+
import {
|
|
7
|
+
cpSync,
|
|
8
|
+
existsSync,
|
|
9
|
+
mkdirSync,
|
|
10
|
+
readFileSync,
|
|
11
|
+
renameSync,
|
|
12
|
+
rmSync,
|
|
13
|
+
} from "node:fs";
|
|
14
|
+
import { dirname, join } from "node:path";
|
|
15
|
+
import type { Logger } from "../logger.js";
|
|
16
|
+
import type { ExtensionCatalogEntry } from "./catalog.js";
|
|
17
|
+
import { RESERVED_EXTENSION_NAMES } from "./reserved.js";
|
|
18
|
+
|
|
19
|
+
const VALID_EXT_NAME_RE = /^[a-z0-9][a-z0-9-]*$/;
|
|
20
|
+
|
|
21
|
+
function loadEnvFile(envPath: string): Record<string, string> {
|
|
22
|
+
const content = readFileSync(envPath, "utf-8");
|
|
23
|
+
const vars: Record<string, string> = {};
|
|
24
|
+
for (const line of content.split("\n")) {
|
|
25
|
+
const trimmed = line.trim();
|
|
26
|
+
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
27
|
+
const match = trimmed.match(/^([^=]+)=(.*)$/);
|
|
28
|
+
if (match) {
|
|
29
|
+
vars[match[1]] = match[2];
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return vars;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Resolve MERCURY_DATA_DIR from project `.env` (default `.mercury`). */
|
|
36
|
+
export function getProjectDataDir(cwd: string): string {
|
|
37
|
+
const envPath = join(cwd, ".env");
|
|
38
|
+
if (existsSync(envPath)) {
|
|
39
|
+
const envVars = loadEnvFile(envPath);
|
|
40
|
+
if (envVars.MERCURY_DATA_DIR) return envVars.MERCURY_DATA_DIR;
|
|
41
|
+
}
|
|
42
|
+
return ".mercury";
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function getUserExtensionsDir(cwd: string): string {
|
|
46
|
+
return join(cwd, getProjectDataDir(cwd), "extensions");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function getGlobalDir(cwd: string): string {
|
|
50
|
+
const envPath = join(cwd, ".env");
|
|
51
|
+
if (existsSync(envPath)) {
|
|
52
|
+
const envVars = loadEnvFile(envPath);
|
|
53
|
+
if (envVars.MERCURY_GLOBAL_DIR) return envVars.MERCURY_GLOBAL_DIR;
|
|
54
|
+
}
|
|
55
|
+
return join(cwd, getProjectDataDir(cwd), "global");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Path to `examples/extensions/<sourceDir>` inside the mercury-agent package. */
|
|
59
|
+
export function resolveExamplesExtensionDir(
|
|
60
|
+
packageRoot: string,
|
|
61
|
+
sourceDir: string,
|
|
62
|
+
): string {
|
|
63
|
+
return join(packageRoot, "examples", "extensions", sourceDir);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export type ExtensionInstallResult =
|
|
67
|
+
| { ok: true }
|
|
68
|
+
| { ok: false; error: string };
|
|
69
|
+
|
|
70
|
+
function validateForInstall(
|
|
71
|
+
destName: string,
|
|
72
|
+
sourceDir: string,
|
|
73
|
+
extensionsDir: string,
|
|
74
|
+
): string | null {
|
|
75
|
+
if (!VALID_EXT_NAME_RE.test(destName)) {
|
|
76
|
+
return `Invalid extension name "${destName}" (lowercase letters, digits, hyphens only).`;
|
|
77
|
+
}
|
|
78
|
+
if (RESERVED_EXTENSION_NAMES.has(destName)) {
|
|
79
|
+
return `"${destName}" is a reserved built-in name.`;
|
|
80
|
+
}
|
|
81
|
+
if (!existsSync(join(sourceDir, "index.ts"))) {
|
|
82
|
+
return "Extension source has no index.ts.";
|
|
83
|
+
}
|
|
84
|
+
if (existsSync(join(extensionsDir, destName))) {
|
|
85
|
+
return `Extension "${destName}" is already installed. Remove it first.`;
|
|
86
|
+
}
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Validate that `index.ts` exists and default-exports a function (for CLI doctor). */
|
|
91
|
+
export async function checkExtensionIndexLoads(
|
|
92
|
+
extDir: string,
|
|
93
|
+
logicalName: string,
|
|
94
|
+
): Promise<string | null> {
|
|
95
|
+
const indexPath = join(extDir, "index.ts");
|
|
96
|
+
try {
|
|
97
|
+
const mod = await import(indexPath);
|
|
98
|
+
if (typeof mod.default !== "function") {
|
|
99
|
+
return `${logicalName}/index.ts must export a default function`;
|
|
100
|
+
}
|
|
101
|
+
} catch (err) {
|
|
102
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
103
|
+
return `Failed to load ${logicalName}/index.ts: ${msg}`;
|
|
104
|
+
}
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function installSkillIfPresent(
|
|
109
|
+
extDir: string,
|
|
110
|
+
name: string,
|
|
111
|
+
cwd: string,
|
|
112
|
+
): void {
|
|
113
|
+
const skillDir = join(extDir, "skill");
|
|
114
|
+
if (!existsSync(join(skillDir, "SKILL.md"))) return;
|
|
115
|
+
|
|
116
|
+
const globalDir = getGlobalDir(cwd);
|
|
117
|
+
const dst = join(globalDir, "skills", name);
|
|
118
|
+
mkdirSync(dirname(dst), { recursive: true });
|
|
119
|
+
rmSync(dst, { recursive: true, force: true });
|
|
120
|
+
cpSync(skillDir, dst, { recursive: true });
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Copy an extension from a local directory into `.mercury/extensions/<destName>/`.
|
|
125
|
+
*/
|
|
126
|
+
export async function installExtensionFromDirectory(options: {
|
|
127
|
+
cwd: string;
|
|
128
|
+
sourceDir: string;
|
|
129
|
+
destName: string;
|
|
130
|
+
}): Promise<ExtensionInstallResult> {
|
|
131
|
+
const { cwd, sourceDir, destName } = options;
|
|
132
|
+
const extensionsDir = getUserExtensionsDir(cwd);
|
|
133
|
+
mkdirSync(extensionsDir, { recursive: true });
|
|
134
|
+
|
|
135
|
+
const err = validateForInstall(destName, sourceDir, extensionsDir);
|
|
136
|
+
if (err) return { ok: false, error: err };
|
|
137
|
+
|
|
138
|
+
const loadErr = await checkExtensionIndexLoads(sourceDir, destName);
|
|
139
|
+
if (loadErr) return { ok: false, error: loadErr };
|
|
140
|
+
|
|
141
|
+
const destDir = join(extensionsDir, destName);
|
|
142
|
+
try {
|
|
143
|
+
cpSync(sourceDir, destDir, { recursive: true });
|
|
144
|
+
|
|
145
|
+
if (existsSync(join(destDir, "package.json"))) {
|
|
146
|
+
const installResult = spawnSync("bun", ["install"], {
|
|
147
|
+
stdio: "pipe",
|
|
148
|
+
encoding: "utf-8",
|
|
149
|
+
cwd: destDir,
|
|
150
|
+
});
|
|
151
|
+
if (installResult.status !== 0) {
|
|
152
|
+
const stderr = installResult.stderr?.toString?.() ?? "";
|
|
153
|
+
rmSync(destDir, { recursive: true, force: true });
|
|
154
|
+
return {
|
|
155
|
+
ok: false,
|
|
156
|
+
error: `bun install failed in extension: ${stderr || "unknown error"}`,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
installSkillIfPresent(destDir, destName, cwd);
|
|
162
|
+
return { ok: true };
|
|
163
|
+
} catch (e) {
|
|
164
|
+
if (existsSync(destDir)) {
|
|
165
|
+
rmSync(destDir, { recursive: true, force: true });
|
|
166
|
+
}
|
|
167
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
168
|
+
return { ok: false, error: msg };
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function removeInstalledExtension(options: {
|
|
173
|
+
cwd: string;
|
|
174
|
+
name: string;
|
|
175
|
+
}): ExtensionInstallResult {
|
|
176
|
+
const { cwd, name } = options;
|
|
177
|
+
if (!VALID_EXT_NAME_RE.test(name)) {
|
|
178
|
+
return { ok: false, error: `Invalid extension name "${name}".` };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const extensionsDir = getUserExtensionsDir(cwd);
|
|
182
|
+
const extDir = join(extensionsDir, name);
|
|
183
|
+
|
|
184
|
+
if (!existsSync(extDir)) {
|
|
185
|
+
return { ok: false, error: `Extension "${name}" is not installed.` };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const globalDir = getGlobalDir(cwd);
|
|
189
|
+
const skillDst = join(globalDir, "skills", name);
|
|
190
|
+
if (existsSync(skillDst)) {
|
|
191
|
+
rmSync(skillDst, { recursive: true, force: true });
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
rmSync(extDir, { recursive: true, force: true });
|
|
195
|
+
return { ok: true };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* On startup, re-copy any installed catalog extension whose bundled source
|
|
200
|
+
* differs from the installed copy. Triggered when a new image ships a patched
|
|
201
|
+
* extension (e.g. MERCURY_BROWSER_SESSIONS fix) but running agents still have
|
|
202
|
+
* the old installed copy on their data volume.
|
|
203
|
+
*
|
|
204
|
+
* Uses an atomic temp-dir swap so a failed copy never leaves an extension
|
|
205
|
+
* partially installed or fully deleted.
|
|
206
|
+
*/
|
|
207
|
+
export async function syncBundledCatalogExtensions(options: {
|
|
208
|
+
packageRoot: string;
|
|
209
|
+
extensionsDir: string;
|
|
210
|
+
globalDir: string;
|
|
211
|
+
catalog: ExtensionCatalogEntry[];
|
|
212
|
+
logger: Logger;
|
|
213
|
+
}): Promise<void> {
|
|
214
|
+
const { packageRoot, extensionsDir, globalDir, catalog, logger } = options;
|
|
215
|
+
if (!existsSync(extensionsDir)) return;
|
|
216
|
+
|
|
217
|
+
for (const entry of catalog) {
|
|
218
|
+
const installedDir = join(extensionsDir, entry.name);
|
|
219
|
+
if (!existsSync(installedDir)) continue;
|
|
220
|
+
|
|
221
|
+
const bundledDir = resolveExamplesExtensionDir(
|
|
222
|
+
packageRoot,
|
|
223
|
+
entry.sourceDir,
|
|
224
|
+
);
|
|
225
|
+
if (!existsSync(bundledDir)) continue;
|
|
226
|
+
|
|
227
|
+
const bundledIndex = join(bundledDir, "index.ts");
|
|
228
|
+
if (!existsSync(bundledIndex)) continue;
|
|
229
|
+
|
|
230
|
+
let needsUpdate = false;
|
|
231
|
+
try {
|
|
232
|
+
for (const file of ["index.ts", "package.json"]) {
|
|
233
|
+
const bundledFile = join(bundledDir, file);
|
|
234
|
+
const installedFile = join(installedDir, file);
|
|
235
|
+
if (!existsSync(bundledFile)) continue;
|
|
236
|
+
const bundledContent = readFileSync(bundledFile, "utf-8");
|
|
237
|
+
const installedContent = existsSync(installedFile)
|
|
238
|
+
? readFileSync(installedFile, "utf-8")
|
|
239
|
+
: null;
|
|
240
|
+
if (bundledContent !== installedContent) {
|
|
241
|
+
needsUpdate = true;
|
|
242
|
+
break;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
} catch {
|
|
246
|
+
needsUpdate = true;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (!needsUpdate) continue;
|
|
250
|
+
|
|
251
|
+
logger.info("Bundled extension source updated — reinstalling", {
|
|
252
|
+
name: entry.name,
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
// Atomic swap: copy to a temp sibling dir first, then replace.
|
|
256
|
+
// This ensures a copy failure never leaves the extension deleted.
|
|
257
|
+
const tmpDir = `${installedDir}.tmp`;
|
|
258
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
259
|
+
try {
|
|
260
|
+
cpSync(bundledDir, tmpDir, { recursive: true });
|
|
261
|
+
|
|
262
|
+
if (existsSync(join(tmpDir, "package.json"))) {
|
|
263
|
+
const installResult = spawnSync("bun", ["install"], {
|
|
264
|
+
stdio: "pipe",
|
|
265
|
+
encoding: "utf-8",
|
|
266
|
+
cwd: tmpDir,
|
|
267
|
+
});
|
|
268
|
+
if (installResult.status !== 0) {
|
|
269
|
+
// bun install failed — discard the temp dir; keep the old install.
|
|
270
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
271
|
+
logger.warn(
|
|
272
|
+
"Extension sync skipped: bun install failed in bundled source",
|
|
273
|
+
{
|
|
274
|
+
name: entry.name,
|
|
275
|
+
stderr: installResult.stderr?.toString?.() ?? "",
|
|
276
|
+
},
|
|
277
|
+
);
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Swap: remove old, rename temp into place.
|
|
283
|
+
rmSync(installedDir, { recursive: true, force: true });
|
|
284
|
+
renameSync(tmpDir, installedDir);
|
|
285
|
+
|
|
286
|
+
// Sync skill dir to global skills.
|
|
287
|
+
const skillDir = join(installedDir, "skill");
|
|
288
|
+
if (existsSync(join(skillDir, "SKILL.md"))) {
|
|
289
|
+
const dst = join(globalDir, "skills", entry.name);
|
|
290
|
+
mkdirSync(dirname(dst), { recursive: true });
|
|
291
|
+
rmSync(dst, { recursive: true, force: true });
|
|
292
|
+
cpSync(skillDir, dst, { recursive: true });
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
logger.info("Extension reinstalled from bundled source", {
|
|
296
|
+
name: entry.name,
|
|
297
|
+
});
|
|
298
|
+
} catch (e) {
|
|
299
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
300
|
+
logger.warn("Failed to reinstall extension from bundled source", {
|
|
301
|
+
name: entry.name,
|
|
302
|
+
error: e instanceof Error ? e.message : String(e),
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}
|