@larkup/marketplace 0.1.1
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 +14 -0
- package/FUTURE_TODO.md +131 -0
- package/LICENSE +176 -0
- package/package.json +23 -0
- package/src/storage-provider.ts +147 -0
- package/src/tool-installer.ts +518 -0
- package/src/tool-loader.ts +100 -0
- package/src/tool-manifest.schema.ts +191 -0
- package/src/tool-registry.ts +306 -0
- package/src/types.ts +245 -0
- package/tsconfig.json +14 -0
|
@@ -0,0 +1,518 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { exec as execCb } from 'node:child_process';
|
|
4
|
+
import { promisify } from 'node:util';
|
|
5
|
+
import type {
|
|
6
|
+
InstalledTool,
|
|
7
|
+
InstalledToolsManifest,
|
|
8
|
+
InstallProgress,
|
|
9
|
+
ToolDescriptor,
|
|
10
|
+
DeploymentTarget,
|
|
11
|
+
ToolSource,
|
|
12
|
+
} from './types';
|
|
13
|
+
import { getToolById, invalidateRegistryCache } from './tool-registry';
|
|
14
|
+
|
|
15
|
+
const execAsync = promisify(execCb);
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Tool installer — manages installing / uninstalling marketplace tools.
|
|
19
|
+
*
|
|
20
|
+
* Architecture:
|
|
21
|
+
* ─────────────────────────────────────────────────────────────────────
|
|
22
|
+
* Tools are installed into an ISOLATED directory:
|
|
23
|
+
* `.larkup/tools/node_modules/@larkup/tool-*`
|
|
24
|
+
*
|
|
25
|
+
* This design:
|
|
26
|
+
* - Keeps the user's project package.json clean (no pollution)
|
|
27
|
+
* - Makes tools portable — copy `.larkup/tools/` to any machine
|
|
28
|
+
* - Enables per-agent tool sets (future: each agent has its own tools dir)
|
|
29
|
+
* - Works identically across local, Docker, and sandbox deployments
|
|
30
|
+
*
|
|
31
|
+
* The installer tracks state in `.larkup/tools/installed.json`.
|
|
32
|
+
* The loader resolves modules from the isolated node_modules.
|
|
33
|
+
* ─────────────────────────────────────────────────────────────────────
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
/* ------------------------------------------------------------------ */
|
|
37
|
+
/* Paths */
|
|
38
|
+
/* ------------------------------------------------------------------ */
|
|
39
|
+
|
|
40
|
+
/** Root directory for tool installations. */
|
|
41
|
+
function getToolsDir(): string {
|
|
42
|
+
return path.join(process.cwd(), '.larkup', 'tools');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Path to the installed tools manifest. */
|
|
46
|
+
function getManifestPath(): string {
|
|
47
|
+
return path.join(getToolsDir(), 'installed.json');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Path to the isolated node_modules for tools. */
|
|
51
|
+
export function getToolsNodeModulesDir(): string {
|
|
52
|
+
return path.join(getToolsDir(), 'node_modules');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/* ------------------------------------------------------------------ */
|
|
56
|
+
/* Manifest persistence */
|
|
57
|
+
/* ------------------------------------------------------------------ */
|
|
58
|
+
|
|
59
|
+
async function ensureDir() {
|
|
60
|
+
await fs.mkdir(getToolsDir(), { recursive: true });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function readManifest(): Promise<InstalledToolsManifest> {
|
|
64
|
+
try {
|
|
65
|
+
const raw = await fs.readFile(getManifestPath(), 'utf8');
|
|
66
|
+
const parsed = JSON.parse(raw);
|
|
67
|
+
// Migrate old manifests without downloadCounts
|
|
68
|
+
if (!parsed.downloadCounts) parsed.downloadCounts = {};
|
|
69
|
+
// Migrate old manifests: packagePath → packageName
|
|
70
|
+
for (const tool of parsed.tools ?? []) {
|
|
71
|
+
if (tool.packagePath && !tool.packageName) {
|
|
72
|
+
tool.packageName = tool.packagePath;
|
|
73
|
+
delete tool.packagePath;
|
|
74
|
+
}
|
|
75
|
+
if (!tool.source) tool.source = 'local';
|
|
76
|
+
if (!tool.resolvedPath) {
|
|
77
|
+
tool.resolvedPath = tool.packageName;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return parsed;
|
|
81
|
+
} catch {
|
|
82
|
+
return { tools: [], downloadCounts: {}, updatedAt: new Date().toISOString() };
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function writeManifest(manifest: InstalledToolsManifest) {
|
|
87
|
+
await ensureDir();
|
|
88
|
+
manifest.updatedAt = new Date().toISOString();
|
|
89
|
+
await fs.writeFile(getManifestPath(), JSON.stringify(manifest, null, 2), 'utf8');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/* ------------------------------------------------------------------ */
|
|
93
|
+
/* Public query API */
|
|
94
|
+
/* ------------------------------------------------------------------ */
|
|
95
|
+
|
|
96
|
+
export async function getInstalledTools(): Promise<InstalledTool[]> {
|
|
97
|
+
const manifest = await readManifest();
|
|
98
|
+
const tools = [...manifest.tools];
|
|
99
|
+
const bundledIds = (process.env.LARKUP_BUNDLED_TOOLS ?? '')
|
|
100
|
+
.split(',')
|
|
101
|
+
.map((id) => id.trim())
|
|
102
|
+
.filter(Boolean);
|
|
103
|
+
|
|
104
|
+
for (const toolId of bundledIds) {
|
|
105
|
+
if (tools.some((tool) => tool.id === toolId)) continue;
|
|
106
|
+
const descriptor = await getToolById(toolId);
|
|
107
|
+
if (!descriptor) continue;
|
|
108
|
+
tools.push({
|
|
109
|
+
id: toolId,
|
|
110
|
+
version: descriptor.version,
|
|
111
|
+
installedAt: 'bundled',
|
|
112
|
+
packageName: descriptor.packageName,
|
|
113
|
+
resolvedPath: descriptor.packageName,
|
|
114
|
+
source: 'local',
|
|
115
|
+
config: buildDefaultConfig(descriptor),
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return tools;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export async function isToolInstalled(toolId: string): Promise<boolean> {
|
|
123
|
+
const tools = await getInstalledTools();
|
|
124
|
+
return tools.some((t) => t.id === toolId);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export async function getInstalledTool(toolId: string): Promise<InstalledTool | undefined> {
|
|
128
|
+
const tools = await getInstalledTools();
|
|
129
|
+
return tools.find((t) => t.id === toolId);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Get download counts for all tools. */
|
|
133
|
+
export async function getDownloadCounts(): Promise<Record<string, number>> {
|
|
134
|
+
const manifest = await readManifest();
|
|
135
|
+
return manifest.downloadCounts;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/* ------------------------------------------------------------------ */
|
|
139
|
+
/* System dependency checks */
|
|
140
|
+
/* ------------------------------------------------------------------ */
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Check if system dependencies for a tool are available.
|
|
144
|
+
* Returns list of missing dependencies.
|
|
145
|
+
*/
|
|
146
|
+
export async function checkSystemDeps(toolId: string): Promise<string[]> {
|
|
147
|
+
const descriptor = await getToolById(toolId);
|
|
148
|
+
if (!descriptor?.systemDeps?.length) return [];
|
|
149
|
+
|
|
150
|
+
const missing: string[] = [];
|
|
151
|
+
for (const dep of descriptor.systemDeps) {
|
|
152
|
+
try {
|
|
153
|
+
await execAsync(`which ${dep}`);
|
|
154
|
+
} catch {
|
|
155
|
+
missing.push(dep);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return missing;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/* ------------------------------------------------------------------ */
|
|
162
|
+
/* Install execution — the core logic */
|
|
163
|
+
/* ------------------------------------------------------------------ */
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Detect the current deployment target from environment.
|
|
167
|
+
*/
|
|
168
|
+
function detectDeploymentTarget(): DeploymentTarget {
|
|
169
|
+
// Explicit override
|
|
170
|
+
const explicit = process.env.LARKUP_DEPLOYMENT_TARGET;
|
|
171
|
+
if (explicit && ['local', 'docker', 'serverless', 'sandbox'].includes(explicit)) {
|
|
172
|
+
return explicit as DeploymentTarget;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Auto-detect
|
|
176
|
+
if (process.env.VERCEL || process.env.AWS_LAMBDA_FUNCTION_NAME) return 'serverless';
|
|
177
|
+
if (process.env.DOCKER_CONTAINER || isDockerEnvironment()) return 'docker';
|
|
178
|
+
if (process.env.E2B_SANDBOX_ID || process.env.MODAL_TASK_ID) return 'sandbox';
|
|
179
|
+
|
|
180
|
+
return 'local';
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function isDockerEnvironment(): boolean {
|
|
184
|
+
try {
|
|
185
|
+
const fs = require('node:fs');
|
|
186
|
+
return fs.existsSync('/.dockerenv');
|
|
187
|
+
} catch {
|
|
188
|
+
return false;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Initialize the isolated tools directory with a package.json.
|
|
194
|
+
* This is needed for `npm install --prefix` to work correctly.
|
|
195
|
+
*/
|
|
196
|
+
async function ensureToolsPackageJson(): Promise<void> {
|
|
197
|
+
const toolsDir = getToolsDir();
|
|
198
|
+
const pkgPath = path.join(toolsDir, 'package.json');
|
|
199
|
+
|
|
200
|
+
try {
|
|
201
|
+
await fs.access(pkgPath);
|
|
202
|
+
} catch {
|
|
203
|
+
await ensureDir();
|
|
204
|
+
await fs.writeFile(
|
|
205
|
+
pkgPath,
|
|
206
|
+
JSON.stringify(
|
|
207
|
+
{
|
|
208
|
+
name: 'larkup-tools',
|
|
209
|
+
version: '1.0.0',
|
|
210
|
+
private: true,
|
|
211
|
+
description: 'Isolated directory for installed Larkup marketplace tools.',
|
|
212
|
+
},
|
|
213
|
+
null,
|
|
214
|
+
2,
|
|
215
|
+
),
|
|
216
|
+
'utf8',
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Execute the actual package install into the isolated tools directory.
|
|
223
|
+
*/
|
|
224
|
+
async function execInstall(
|
|
225
|
+
packageName: string,
|
|
226
|
+
version: string,
|
|
227
|
+
target: DeploymentTarget,
|
|
228
|
+
onProgress?: (message: string) => void,
|
|
229
|
+
): Promise<string> {
|
|
230
|
+
const toolsDir = getToolsDir();
|
|
231
|
+
|
|
232
|
+
switch (target) {
|
|
233
|
+
case 'local':
|
|
234
|
+
case 'docker': {
|
|
235
|
+
// Initialize the isolated tools directory
|
|
236
|
+
await ensureToolsPackageJson();
|
|
237
|
+
|
|
238
|
+
const installCmd = `npm install ${packageName}@${version} --prefix "${toolsDir}" --save --no-audit --no-fund`;
|
|
239
|
+
onProgress?.(`Running: npm install ${packageName}@${version}`);
|
|
240
|
+
|
|
241
|
+
try {
|
|
242
|
+
const { stdout, stderr } = await execAsync(installCmd, {
|
|
243
|
+
cwd: toolsDir,
|
|
244
|
+
timeout: 120_000, // 2 minute timeout
|
|
245
|
+
env: { ...process.env, NODE_ENV: 'production' },
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
if (stderr && !stderr.includes('npm warn')) {
|
|
249
|
+
console.warn(`[marketplace] Install stderr: ${stderr}`);
|
|
250
|
+
}
|
|
251
|
+
} catch (err) {
|
|
252
|
+
const message = err instanceof Error ? err.message : 'Install command failed';
|
|
253
|
+
throw new Error(`Failed to install ${packageName}: ${message}`);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Resolve the actual module path
|
|
257
|
+
const resolvedPath = path.join(toolsDir, 'node_modules', packageName);
|
|
258
|
+
try {
|
|
259
|
+
await fs.access(resolvedPath);
|
|
260
|
+
} catch {
|
|
261
|
+
throw new Error(
|
|
262
|
+
`Package ${packageName} was installed but could not be found at ${resolvedPath}`,
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
return resolvedPath;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
case 'serverless': {
|
|
270
|
+
// Cannot install at runtime on serverless — queue for next deploy
|
|
271
|
+
onProgress?.('Serverless environment detected — queuing for next deploy');
|
|
272
|
+
const pendingPath = path.join(toolsDir, 'pending-tools.json');
|
|
273
|
+
let pending: { tools: { packageName: string; version: string }[] } = { tools: [] };
|
|
274
|
+
try {
|
|
275
|
+
const raw = await fs.readFile(pendingPath, 'utf8');
|
|
276
|
+
pending = JSON.parse(raw);
|
|
277
|
+
} catch {
|
|
278
|
+
// No pending file yet
|
|
279
|
+
}
|
|
280
|
+
pending.tools.push({ packageName, version });
|
|
281
|
+
await ensureDir();
|
|
282
|
+
await fs.writeFile(pendingPath, JSON.stringify(pending, null, 2), 'utf8');
|
|
283
|
+
|
|
284
|
+
// Return the expected path (will be available after redeploy)
|
|
285
|
+
return path.join(toolsDir, 'node_modules', packageName);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
case 'sandbox': {
|
|
289
|
+
// Install into the sandbox's filesystem
|
|
290
|
+
onProgress?.('Installing in sandbox environment');
|
|
291
|
+
await ensureToolsPackageJson();
|
|
292
|
+
const installCmd = `npm install ${packageName}@${version} --prefix "${toolsDir}" --save --no-audit --no-fund`;
|
|
293
|
+
await execAsync(installCmd, { cwd: toolsDir, timeout: 120_000 });
|
|
294
|
+
return path.join(toolsDir, 'node_modules', packageName);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
default:
|
|
298
|
+
throw new Error(`Unsupported deployment target: ${target}`);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Resolve a tool's manifest from the registry or Hub API.
|
|
304
|
+
*/
|
|
305
|
+
async function resolveManifest(toolId: string): Promise<ToolDescriptor> {
|
|
306
|
+
const descriptor = await getToolById(toolId);
|
|
307
|
+
if (descriptor) return descriptor;
|
|
308
|
+
throw new Error(`Unknown tool: ${toolId}. Not found in local registry or Hub.`);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Determine the install source based on the environment.
|
|
313
|
+
* In the monorepo (development), tools are already available as workspace packages.
|
|
314
|
+
* Outside the monorepo, tools are downloaded from npm.
|
|
315
|
+
*/
|
|
316
|
+
async function isWorkspaceTool(packageName: string): Promise<boolean> {
|
|
317
|
+
try {
|
|
318
|
+
// Check if this package exists in the pnpm workspace
|
|
319
|
+
const { stdout } = await execAsync(`pnpm ls -r --depth -1 --json 2>/dev/null || true`, {
|
|
320
|
+
cwd: process.cwd(),
|
|
321
|
+
timeout: 10_000,
|
|
322
|
+
});
|
|
323
|
+
const data = JSON.parse(stdout || '[]');
|
|
324
|
+
return Array.isArray(data) && data.some((pkg: any) => pkg.name === packageName);
|
|
325
|
+
} catch {
|
|
326
|
+
return false;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/* ------------------------------------------------------------------ */
|
|
331
|
+
/* Install / uninstall public API */
|
|
332
|
+
/* ------------------------------------------------------------------ */
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Install a marketplace tool.
|
|
336
|
+
*
|
|
337
|
+
* The install process:
|
|
338
|
+
* 1. Resolve the tool manifest (local → Hub → fallback)
|
|
339
|
+
* 2. Check system dependencies (ffmpeg, docker, etc.)
|
|
340
|
+
* 3. Determine install strategy:
|
|
341
|
+
* - Workspace tool (monorepo dev)? → resolve path, no download needed
|
|
342
|
+
* - Remote tool? → `npm install` into `.larkup/tools/node_modules/`
|
|
343
|
+
* 4. Record in `installed.json`
|
|
344
|
+
* 5. Notify Hub API (fire-and-forget install counter)
|
|
345
|
+
* 6. Refresh registry cache
|
|
346
|
+
*/
|
|
347
|
+
export async function installTool(
|
|
348
|
+
toolId: string,
|
|
349
|
+
onProgress?: (progress: InstallProgress) => void,
|
|
350
|
+
): Promise<void> {
|
|
351
|
+
const report = (stage: InstallProgress['stage'], percent: number, message: string) => {
|
|
352
|
+
onProgress?.({ toolId, stage, percent, message });
|
|
353
|
+
};
|
|
354
|
+
|
|
355
|
+
try {
|
|
356
|
+
// 1. Resolve manifest
|
|
357
|
+
report('checking-deps', 5, 'Resolving tool manifest…');
|
|
358
|
+
const descriptor = await resolveManifest(toolId);
|
|
359
|
+
|
|
360
|
+
if (descriptor.comingSoon) {
|
|
361
|
+
throw new Error(`${descriptor.name} is coming soon.`);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// 2. Check system dependencies
|
|
365
|
+
report('checking-deps', 15, 'Checking system dependencies…');
|
|
366
|
+
const missing = await checkSystemDeps(toolId);
|
|
367
|
+
if (missing.length > 0) {
|
|
368
|
+
const msg = `Missing system dependencies: ${missing.join(', ')}. Please install them first.`;
|
|
369
|
+
report('failed', 0, msg);
|
|
370
|
+
throw new Error(msg);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// 3. Determine install strategy
|
|
374
|
+
const isWorkspace = await isWorkspaceTool(descriptor.packageName);
|
|
375
|
+
const target = detectDeploymentTarget();
|
|
376
|
+
let resolvedPath: string;
|
|
377
|
+
let source: ToolSource;
|
|
378
|
+
|
|
379
|
+
if (isWorkspace) {
|
|
380
|
+
// Monorepo development — tool is already linked via workspace
|
|
381
|
+
report('downloading', 30, 'Resolving workspace package…');
|
|
382
|
+
try {
|
|
383
|
+
// Resolve the workspace package path
|
|
384
|
+
const modulePath = require.resolve(descriptor.packageName, {
|
|
385
|
+
paths: [process.cwd()],
|
|
386
|
+
});
|
|
387
|
+
resolvedPath = path.dirname(modulePath);
|
|
388
|
+
} catch {
|
|
389
|
+
// Fallback: try resolving from node_modules directly
|
|
390
|
+
resolvedPath = path.join(process.cwd(), 'node_modules', descriptor.packageName);
|
|
391
|
+
}
|
|
392
|
+
source = 'local';
|
|
393
|
+
report('installing', 60, 'Workspace package resolved.');
|
|
394
|
+
} else {
|
|
395
|
+
// Remote install — download from npm into isolated directory
|
|
396
|
+
report('downloading', 30, `Downloading ${descriptor.packageName}@${descriptor.version}…`);
|
|
397
|
+
resolvedPath = await execInstall(descriptor.packageName, descriptor.version, target, (msg) =>
|
|
398
|
+
report('downloading', 50, msg),
|
|
399
|
+
);
|
|
400
|
+
source = target === 'sandbox' ? 'sandbox' : 'registry';
|
|
401
|
+
report('installing', 70, 'Package installed.');
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// 4. Record in manifest
|
|
405
|
+
report('installing', 80, 'Registering tool…');
|
|
406
|
+
await ensureDir();
|
|
407
|
+
const manifest = await readManifest();
|
|
408
|
+
const existing = manifest.tools.findIndex((t) => t.id === toolId);
|
|
409
|
+
|
|
410
|
+
const entry: InstalledTool = {
|
|
411
|
+
id: toolId,
|
|
412
|
+
version: descriptor.version,
|
|
413
|
+
installedAt: new Date().toISOString(),
|
|
414
|
+
packageName: descriptor.packageName,
|
|
415
|
+
resolvedPath,
|
|
416
|
+
source,
|
|
417
|
+
config: buildDefaultConfig(descriptor),
|
|
418
|
+
};
|
|
419
|
+
|
|
420
|
+
if (existing >= 0) {
|
|
421
|
+
manifest.tools[existing] = entry;
|
|
422
|
+
} else {
|
|
423
|
+
manifest.tools.push(entry);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// Track download count
|
|
427
|
+
manifest.downloadCounts[toolId] = (manifest.downloadCounts[toolId] ?? 0) + 1;
|
|
428
|
+
|
|
429
|
+
await writeManifest(manifest);
|
|
430
|
+
|
|
431
|
+
// 5. Notify Hub (fire-and-forget)
|
|
432
|
+
notifyHubInstall(toolId).catch(() => {});
|
|
433
|
+
|
|
434
|
+
// 6. Refresh registry cache (new tool manifests may be discoverable)
|
|
435
|
+
invalidateRegistryCache();
|
|
436
|
+
|
|
437
|
+
// Done
|
|
438
|
+
report('completed', 100, 'Installed successfully.');
|
|
439
|
+
} catch (err) {
|
|
440
|
+
const message = err instanceof Error ? err.message : 'Installation failed.';
|
|
441
|
+
report('failed', 0, message);
|
|
442
|
+
throw err;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
export async function uninstallTool(toolId: string): Promise<void> {
|
|
447
|
+
const manifest = await readManifest();
|
|
448
|
+
const tool = manifest.tools.find((t) => t.id === toolId);
|
|
449
|
+
|
|
450
|
+
// Remove from manifest
|
|
451
|
+
manifest.tools = manifest.tools.filter((t) => t.id !== toolId);
|
|
452
|
+
await writeManifest(manifest);
|
|
453
|
+
|
|
454
|
+
// If it was installed from npm, remove from isolated node_modules
|
|
455
|
+
if (tool?.source === 'registry' && tool.packageName) {
|
|
456
|
+
try {
|
|
457
|
+
const toolsDir = getToolsDir();
|
|
458
|
+
await execAsync(`npm uninstall ${tool.packageName} --prefix "${toolsDir}"`, {
|
|
459
|
+
cwd: toolsDir,
|
|
460
|
+
timeout: 30_000,
|
|
461
|
+
});
|
|
462
|
+
} catch {
|
|
463
|
+
// Best-effort cleanup
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// Refresh registry cache
|
|
468
|
+
invalidateRegistryCache();
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
export async function updateToolConfig(toolId: string, config: Record<string, any>): Promise<void> {
|
|
472
|
+
const manifest = await readManifest();
|
|
473
|
+
const tool = manifest.tools.find((t) => t.id === toolId);
|
|
474
|
+
if (!tool) throw new Error(`Tool ${toolId} is not installed.`);
|
|
475
|
+
tool.config = { ...tool.config, ...config };
|
|
476
|
+
await writeManifest(manifest);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/* ------------------------------------------------------------------ */
|
|
480
|
+
/* Hub notification */
|
|
481
|
+
/* ------------------------------------------------------------------ */
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* Notify the Hub API that a tool was installed (increment counter).
|
|
485
|
+
* This is fire-and-forget — never blocks the install flow.
|
|
486
|
+
*/
|
|
487
|
+
async function notifyHubInstall(toolId: string): Promise<void> {
|
|
488
|
+
const hubUrl = process.env.LARKUP_HUB_URL ?? 'https://hub.larkup.de';
|
|
489
|
+
try {
|
|
490
|
+
const controller = new AbortController();
|
|
491
|
+
const timeout = setTimeout(() => controller.abort(), 3000);
|
|
492
|
+
|
|
493
|
+
await fetch(`${hubUrl}/v1/tools/${toolId}/installed`, {
|
|
494
|
+
method: 'POST',
|
|
495
|
+
signal: controller.signal,
|
|
496
|
+
headers: { 'Content-Type': 'application/json' },
|
|
497
|
+
});
|
|
498
|
+
clearTimeout(timeout);
|
|
499
|
+
} catch {
|
|
500
|
+
// Silently fail — not critical
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/* ------------------------------------------------------------------ */
|
|
505
|
+
/* Helpers */
|
|
506
|
+
/* ------------------------------------------------------------------ */
|
|
507
|
+
|
|
508
|
+
function buildDefaultConfig(descriptor: {
|
|
509
|
+
configSchema?: { key: string; defaultValue?: string }[];
|
|
510
|
+
}): Record<string, any> {
|
|
511
|
+
const config: Record<string, any> = {};
|
|
512
|
+
for (const field of descriptor.configSchema ?? []) {
|
|
513
|
+
if (field.defaultValue !== undefined) {
|
|
514
|
+
config[field.key] = field.defaultValue;
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
return config;
|
|
518
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import type { InstalledTool } from './types';
|
|
3
|
+
import { getInstalledTool } from './tool-installer';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Dynamic tool loader — loads an installed tool's module at runtime.
|
|
7
|
+
*
|
|
8
|
+
* Resolution strategy:
|
|
9
|
+
* ─────────────────────────────────────────────────────────────────────
|
|
10
|
+
* 1. Check if the tool is recorded in `installed.json`
|
|
11
|
+
* 2. Use the `resolvedPath` from the install record:
|
|
12
|
+
* - For `local` (workspace): points to the monorepo package
|
|
13
|
+
* - For `registry` (npm): points to `.larkup/tools/node_modules/...`
|
|
14
|
+
* - For `sandbox`: points to the sandbox's tool directory
|
|
15
|
+
* 3. Dynamic `import()` the resolved path
|
|
16
|
+
* 4. Cache the module for the lifetime of the process
|
|
17
|
+
* ─────────────────────────────────────────────────────────────────────
|
|
18
|
+
*
|
|
19
|
+
* Heavy dependencies (ffmpeg bindings, whisper, pdf-lib, etc.) are
|
|
20
|
+
* only loaded when the tool is actually invoked via this loader.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const moduleCache = new Map<string, any>();
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Load a tool's exported API. Returns `null` if the tool is not installed.
|
|
27
|
+
*
|
|
28
|
+
* The returned object is whatever the tool's entry point exports —
|
|
29
|
+
* each tool defines its own public surface.
|
|
30
|
+
*/
|
|
31
|
+
export async function loadTool<T = any>(toolId: string): Promise<T | null> {
|
|
32
|
+
// Check cache first
|
|
33
|
+
if (moduleCache.has(toolId)) return moduleCache.get(toolId) as T;
|
|
34
|
+
|
|
35
|
+
const installed = await getInstalledTool(toolId);
|
|
36
|
+
if (!installed) return null;
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
// Determine what to import
|
|
40
|
+
const importPath = resolveImportPath(installed);
|
|
41
|
+
const mod = await import(/* webpackIgnore: true */ importPath);
|
|
42
|
+
moduleCache.set(toolId, mod);
|
|
43
|
+
return mod as T;
|
|
44
|
+
} catch (err) {
|
|
45
|
+
console.error(`[marketplace] Failed to load tool "${toolId}":`, err);
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Resolve the import path for a tool based on its install source.
|
|
52
|
+
*
|
|
53
|
+
* - `local` (workspace): import by package name (Node resolves via workspace symlink)
|
|
54
|
+
* - `registry` (npm): import from the isolated node_modules via absolute path
|
|
55
|
+
* - `sandbox`: import from the sandbox tool path
|
|
56
|
+
*/
|
|
57
|
+
function resolveImportPath(installed: InstalledTool): string {
|
|
58
|
+
switch (installed.source) {
|
|
59
|
+
case 'local':
|
|
60
|
+
// In monorepo, the package name resolves via pnpm workspace linking
|
|
61
|
+
return installed.packageName;
|
|
62
|
+
|
|
63
|
+
case 'registry':
|
|
64
|
+
case 'sandbox':
|
|
65
|
+
// Use the absolute resolved path from the isolated install
|
|
66
|
+
return installed.resolvedPath;
|
|
67
|
+
|
|
68
|
+
default:
|
|
69
|
+
// Fallback: try package name (backwards compat)
|
|
70
|
+
return installed.packageName;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Check if a tool is loaded and available for use.
|
|
76
|
+
*/
|
|
77
|
+
export function isToolLoaded(toolId: string): boolean {
|
|
78
|
+
return moduleCache.has(toolId);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Evict a tool from the module cache (e.g. after uninstall).
|
|
83
|
+
*/
|
|
84
|
+
export function unloadTool(toolId: string): void {
|
|
85
|
+
moduleCache.delete(toolId);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Check whether a specific capability is available (i.e. a tool
|
|
90
|
+
* providing that capability is installed).
|
|
91
|
+
*/
|
|
92
|
+
export async function hasCapability(capability: string): Promise<boolean> {
|
|
93
|
+
const { getToolsWithCapability } = await import('./tool-registry');
|
|
94
|
+
const tools = await getToolsWithCapability(capability);
|
|
95
|
+
for (const t of tools) {
|
|
96
|
+
const installed = await getInstalledTool(t.id);
|
|
97
|
+
if (installed) return true;
|
|
98
|
+
}
|
|
99
|
+
return false;
|
|
100
|
+
}
|