@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.
@@ -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
+ }