@portel/photon 1.4.1 → 1.5.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/README.md +287 -1160
- package/dist/auto-ui/beam.d.ts +9 -0
- package/dist/auto-ui/beam.d.ts.map +1 -0
- package/dist/auto-ui/beam.js +2381 -0
- package/dist/auto-ui/beam.js.map +1 -0
- package/dist/auto-ui/components/card.d.ts +13 -0
- package/dist/auto-ui/components/card.d.ts.map +1 -0
- package/dist/auto-ui/components/card.js +64 -0
- package/dist/auto-ui/components/card.js.map +1 -0
- package/dist/auto-ui/components/form.d.ts +15 -0
- package/dist/auto-ui/components/form.d.ts.map +1 -0
- package/dist/auto-ui/components/form.js +72 -0
- package/dist/auto-ui/components/form.js.map +1 -0
- package/dist/auto-ui/components/list.d.ts +13 -0
- package/dist/auto-ui/components/list.d.ts.map +1 -0
- package/dist/auto-ui/components/list.js +58 -0
- package/dist/auto-ui/components/list.js.map +1 -0
- package/dist/auto-ui/components/progress.d.ts +18 -0
- package/dist/auto-ui/components/progress.d.ts.map +1 -0
- package/dist/auto-ui/components/progress.js +125 -0
- package/dist/auto-ui/components/progress.js.map +1 -0
- package/dist/auto-ui/components/table.d.ts +13 -0
- package/dist/auto-ui/components/table.d.ts.map +1 -0
- package/dist/auto-ui/components/table.js +82 -0
- package/dist/auto-ui/components/table.js.map +1 -0
- package/dist/auto-ui/components/tree.d.ts +13 -0
- package/dist/auto-ui/components/tree.d.ts.map +1 -0
- package/dist/auto-ui/components/tree.js +61 -0
- package/dist/auto-ui/components/tree.js.map +1 -0
- package/dist/auto-ui/daemon-tools.d.ts +45 -0
- package/dist/auto-ui/daemon-tools.d.ts.map +1 -0
- package/dist/auto-ui/daemon-tools.js +580 -0
- package/dist/auto-ui/daemon-tools.js.map +1 -0
- package/dist/auto-ui/design-system/index.d.ts +21 -0
- package/dist/auto-ui/design-system/index.d.ts.map +1 -0
- package/dist/auto-ui/design-system/index.js +27 -0
- package/dist/auto-ui/design-system/index.js.map +1 -0
- package/dist/auto-ui/design-system/tokens.d.ts +9 -0
- package/dist/auto-ui/design-system/tokens.d.ts.map +1 -0
- package/dist/auto-ui/design-system/tokens.js +27 -0
- package/dist/auto-ui/design-system/tokens.js.map +1 -0
- package/dist/auto-ui/design-system/transaction-ui.d.ts +70 -0
- package/dist/auto-ui/design-system/transaction-ui.d.ts.map +1 -0
- package/dist/auto-ui/design-system/transaction-ui.js +982 -0
- package/dist/auto-ui/design-system/transaction-ui.js.map +1 -0
- package/dist/auto-ui/frontend/index.html +84 -0
- package/dist/auto-ui/index.d.ts +21 -0
- package/dist/auto-ui/index.d.ts.map +1 -0
- package/dist/auto-ui/index.js +25 -0
- package/dist/auto-ui/index.js.map +1 -0
- package/dist/auto-ui/openapi-generator.d.ts +71 -0
- package/dist/auto-ui/openapi-generator.d.ts.map +1 -0
- package/dist/auto-ui/openapi-generator.js +223 -0
- package/dist/auto-ui/openapi-generator.js.map +1 -0
- package/dist/auto-ui/photon-bridge.d.ts +159 -0
- package/dist/auto-ui/photon-bridge.d.ts.map +1 -0
- package/dist/auto-ui/photon-bridge.js +262 -0
- package/dist/auto-ui/photon-bridge.js.map +1 -0
- package/dist/auto-ui/photon-host.d.ts +113 -0
- package/dist/auto-ui/photon-host.d.ts.map +1 -0
- package/dist/auto-ui/photon-host.js +284 -0
- package/dist/auto-ui/photon-host.js.map +1 -0
- package/dist/auto-ui/platform-compat.d.ts +71 -0
- package/dist/auto-ui/platform-compat.d.ts.map +1 -0
- package/dist/auto-ui/platform-compat.js +574 -0
- package/dist/auto-ui/platform-compat.js.map +1 -0
- package/dist/auto-ui/playground-html.d.ts +15 -0
- package/dist/auto-ui/playground-html.d.ts.map +1 -0
- package/dist/auto-ui/playground-html.js +1113 -0
- package/dist/auto-ui/playground-html.js.map +1 -0
- package/dist/auto-ui/playground-server.d.ts +7 -0
- package/dist/auto-ui/playground-server.d.ts.map +1 -0
- package/dist/auto-ui/playground-server.js +840 -0
- package/dist/auto-ui/playground-server.js.map +1 -0
- package/dist/auto-ui/registry.d.ts +13 -0
- package/dist/auto-ui/registry.d.ts.map +1 -0
- package/dist/auto-ui/registry.js +62 -0
- package/dist/auto-ui/registry.js.map +1 -0
- package/dist/auto-ui/renderer.d.ts +14 -0
- package/dist/auto-ui/renderer.d.ts.map +1 -0
- package/dist/auto-ui/renderer.js +88 -0
- package/dist/auto-ui/renderer.js.map +1 -0
- package/dist/auto-ui/rendering/components.d.ts +29 -0
- package/dist/auto-ui/rendering/components.d.ts.map +1 -0
- package/dist/auto-ui/rendering/components.js +773 -0
- package/dist/auto-ui/rendering/components.js.map +1 -0
- package/dist/auto-ui/rendering/field-analyzer.d.ts +48 -0
- package/dist/auto-ui/rendering/field-analyzer.d.ts.map +1 -0
- package/dist/auto-ui/rendering/field-analyzer.js +270 -0
- package/dist/auto-ui/rendering/field-analyzer.js.map +1 -0
- package/dist/auto-ui/rendering/field-renderers.d.ts +64 -0
- package/dist/auto-ui/rendering/field-renderers.d.ts.map +1 -0
- package/dist/auto-ui/rendering/field-renderers.js +317 -0
- package/dist/auto-ui/rendering/field-renderers.js.map +1 -0
- package/dist/auto-ui/rendering/index.d.ts +28 -0
- package/dist/auto-ui/rendering/index.d.ts.map +1 -0
- package/dist/auto-ui/rendering/index.js +60 -0
- package/dist/auto-ui/rendering/index.js.map +1 -0
- package/dist/auto-ui/rendering/layout-selector.d.ts +48 -0
- package/dist/auto-ui/rendering/layout-selector.d.ts.map +1 -0
- package/dist/auto-ui/rendering/layout-selector.js +352 -0
- package/dist/auto-ui/rendering/layout-selector.js.map +1 -0
- package/dist/auto-ui/rendering/template-engine.d.ts +41 -0
- package/dist/auto-ui/rendering/template-engine.d.ts.map +1 -0
- package/dist/auto-ui/rendering/template-engine.js +238 -0
- package/dist/auto-ui/rendering/template-engine.js.map +1 -0
- package/dist/auto-ui/streamable-http-transport.d.ts +79 -0
- package/dist/auto-ui/streamable-http-transport.d.ts.map +1 -0
- package/dist/auto-ui/streamable-http-transport.js +1314 -0
- package/dist/auto-ui/streamable-http-transport.js.map +1 -0
- package/dist/auto-ui/types.d.ts +310 -0
- package/dist/auto-ui/types.d.ts.map +1 -0
- package/dist/auto-ui/types.js +71 -0
- package/dist/auto-ui/types.js.map +1 -0
- package/dist/beam.bundle.js +13506 -0
- package/dist/beam.bundle.js.map +7 -0
- package/dist/claude-code-plugin.d.ts.map +1 -1
- package/dist/claude-code-plugin.js +30 -30
- package/dist/claude-code-plugin.js.map +1 -1
- package/dist/cli/commands/info.d.ts +11 -0
- package/dist/cli/commands/info.d.ts.map +1 -0
- package/dist/cli/commands/info.js +313 -0
- package/dist/cli/commands/info.js.map +1 -0
- package/dist/cli/commands/marketplace.d.ts +11 -0
- package/dist/cli/commands/marketplace.d.ts.map +1 -0
- package/dist/cli/commands/marketplace.js +198 -0
- package/dist/cli/commands/marketplace.js.map +1 -0
- package/dist/cli/commands/package-app.d.ts +9 -0
- package/dist/cli/commands/package-app.d.ts.map +1 -0
- package/dist/cli/commands/package-app.js +191 -0
- package/dist/cli/commands/package-app.js.map +1 -0
- package/dist/cli/commands/package.d.ts +11 -0
- package/dist/cli/commands/package.d.ts.map +1 -0
- package/dist/cli/commands/package.js +573 -0
- package/dist/cli/commands/package.js.map +1 -0
- package/dist/cli-alias.d.ts.map +1 -1
- package/dist/cli-alias.js +30 -28
- package/dist/cli-alias.js.map +1 -1
- package/dist/cli-formatter.d.ts +8 -24
- package/dist/cli-formatter.d.ts.map +1 -1
- package/dist/cli-formatter.js +8 -325
- package/dist/cli-formatter.js.map +1 -1
- package/dist/cli.d.ts +15 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +1157 -1132
- package/dist/cli.js.map +1 -1
- package/dist/daemon/client.d.ts +79 -0
- package/dist/daemon/client.d.ts.map +1 -1
- package/dist/daemon/client.js +532 -8
- package/dist/daemon/client.js.map +1 -1
- package/dist/daemon/manager.d.ts +46 -12
- package/dist/daemon/manager.d.ts.map +1 -1
- package/dist/daemon/manager.js +102 -61
- package/dist/daemon/manager.js.map +1 -1
- package/dist/daemon/protocol.d.ts +62 -6
- package/dist/daemon/protocol.d.ts.map +1 -1
- package/dist/daemon/protocol.js +76 -1
- package/dist/daemon/protocol.js.map +1 -1
- package/dist/daemon/server.d.ts +6 -6
- package/dist/daemon/server.js +743 -133
- package/dist/daemon/server.js.map +1 -1
- package/dist/daemon/session-manager.d.ts +8 -1
- package/dist/daemon/session-manager.d.ts.map +1 -1
- package/dist/daemon/session-manager.js +32 -9
- package/dist/daemon/session-manager.js.map +1 -1
- package/dist/deploy/cloudflare.d.ts +12 -0
- package/dist/deploy/cloudflare.d.ts.map +1 -0
- package/dist/deploy/cloudflare.js +216 -0
- package/dist/deploy/cloudflare.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/loader.d.ts +168 -21
- package/dist/loader.d.ts.map +1 -1
- package/dist/loader.js +1120 -318
- package/dist/loader.js.map +1 -1
- package/dist/markdown-utils.d.ts +8 -0
- package/dist/markdown-utils.d.ts.map +1 -0
- package/dist/markdown-utils.js +63 -0
- package/dist/markdown-utils.js.map +1 -0
- package/dist/marketplace-manager.d.ts +10 -0
- package/dist/marketplace-manager.d.ts.map +1 -1
- package/dist/marketplace-manager.js +112 -28
- package/dist/marketplace-manager.js.map +1 -1
- package/dist/mcp-client.d.ts +9 -0
- package/dist/mcp-client.d.ts.map +1 -0
- package/dist/mcp-client.js +11 -0
- package/dist/mcp-client.js.map +1 -0
- package/dist/mcp-elicitation.d.ts +32 -0
- package/dist/mcp-elicitation.d.ts.map +1 -0
- package/dist/mcp-elicitation.js +26 -0
- package/dist/mcp-elicitation.js.map +1 -0
- package/dist/path-resolver.d.ts +9 -12
- package/dist/path-resolver.d.ts.map +1 -1
- package/dist/path-resolver.js +13 -43
- package/dist/path-resolver.js.map +1 -1
- package/dist/photon-cli-runner.d.ts.map +1 -1
- package/dist/photon-cli-runner.js +202 -77
- package/dist/photon-cli-runner.js.map +1 -1
- package/dist/photon-doc-extractor.d.ts +88 -0
- package/dist/photon-doc-extractor.d.ts.map +1 -1
- package/dist/photon-doc-extractor.js +536 -27
- package/dist/photon-doc-extractor.js.map +1 -1
- package/dist/photons/maker.photon.d.ts +182 -0
- package/dist/photons/maker.photon.d.ts.map +1 -0
- package/dist/photons/maker.photon.js +504 -0
- package/dist/photons/maker.photon.js.map +1 -0
- package/dist/photons/maker.photon.ts +626 -0
- package/dist/photons/marketplace.photon.d.ts +110 -0
- package/dist/photons/marketplace.photon.d.ts.map +1 -0
- package/dist/photons/marketplace.photon.js +260 -0
- package/dist/photons/marketplace.photon.js.map +1 -0
- package/dist/photons/marketplace.photon.ts +378 -0
- package/dist/photons/tunnel.photon.d.ts +80 -0
- package/dist/photons/tunnel.photon.d.ts.map +1 -0
- package/dist/photons/tunnel.photon.js +269 -0
- package/dist/photons/tunnel.photon.js.map +1 -0
- package/dist/photons/tunnel.photon.ts +345 -0
- package/dist/security-scanner.d.ts.map +1 -1
- package/dist/security-scanner.js +18 -15
- package/dist/security-scanner.js.map +1 -1
- package/dist/serv/auth/jwt.d.ts +89 -0
- package/dist/serv/auth/jwt.d.ts.map +1 -0
- package/dist/serv/auth/jwt.js +239 -0
- package/dist/serv/auth/jwt.js.map +1 -0
- package/dist/serv/auth/oauth.d.ts +117 -0
- package/dist/serv/auth/oauth.d.ts.map +1 -0
- package/dist/serv/auth/oauth.js +395 -0
- package/dist/serv/auth/oauth.js.map +1 -0
- package/dist/serv/auth/well-known.d.ts +60 -0
- package/dist/serv/auth/well-known.d.ts.map +1 -0
- package/dist/serv/auth/well-known.js +154 -0
- package/dist/serv/auth/well-known.js.map +1 -0
- package/dist/serv/db/d1-client.d.ts +65 -0
- package/dist/serv/db/d1-client.d.ts.map +1 -0
- package/dist/serv/db/d1-client.js +137 -0
- package/dist/serv/db/d1-client.js.map +1 -0
- package/dist/serv/db/d1-stores.d.ts +62 -0
- package/dist/serv/db/d1-stores.d.ts.map +1 -0
- package/dist/serv/db/d1-stores.js +307 -0
- package/dist/serv/db/d1-stores.js.map +1 -0
- package/dist/serv/index.d.ts +114 -0
- package/dist/serv/index.d.ts.map +1 -0
- package/dist/serv/index.js +172 -0
- package/dist/serv/index.js.map +1 -0
- package/dist/serv/local.d.ts +118 -0
- package/dist/serv/local.d.ts.map +1 -0
- package/dist/serv/local.js +392 -0
- package/dist/serv/local.js.map +1 -0
- package/dist/serv/middleware/auth.d.ts +66 -0
- package/dist/serv/middleware/auth.d.ts.map +1 -0
- package/dist/serv/middleware/auth.js +178 -0
- package/dist/serv/middleware/auth.js.map +1 -0
- package/dist/serv/middleware/tenant.d.ts +94 -0
- package/dist/serv/middleware/tenant.d.ts.map +1 -0
- package/dist/serv/middleware/tenant.js +152 -0
- package/dist/serv/middleware/tenant.js.map +1 -0
- package/dist/serv/runtime/executor.d.ts +76 -0
- package/dist/serv/runtime/executor.d.ts.map +1 -0
- package/dist/serv/runtime/executor.js +105 -0
- package/dist/serv/runtime/executor.js.map +1 -0
- package/dist/serv/runtime/index.d.ts +8 -0
- package/dist/serv/runtime/index.d.ts.map +1 -0
- package/dist/serv/runtime/index.js +10 -0
- package/dist/serv/runtime/index.js.map +1 -0
- package/dist/serv/runtime/oauth-context.d.ts +121 -0
- package/dist/serv/runtime/oauth-context.d.ts.map +1 -0
- package/dist/serv/runtime/oauth-context.js +153 -0
- package/dist/serv/runtime/oauth-context.js.map +1 -0
- package/dist/serv/session/kv-store.d.ts +54 -0
- package/dist/serv/session/kv-store.d.ts.map +1 -0
- package/dist/serv/session/kv-store.js +149 -0
- package/dist/serv/session/kv-store.js.map +1 -0
- package/dist/serv/session/store.d.ts +113 -0
- package/dist/serv/session/store.d.ts.map +1 -0
- package/dist/serv/session/store.js +284 -0
- package/dist/serv/session/store.js.map +1 -0
- package/dist/serv/types/index.d.ts +147 -0
- package/dist/serv/types/index.d.ts.map +1 -0
- package/dist/serv/types/index.js +8 -0
- package/dist/serv/types/index.js.map +1 -0
- package/dist/serv/vault/token-vault.d.ts +102 -0
- package/dist/serv/vault/token-vault.d.ts.map +1 -0
- package/dist/serv/vault/token-vault.js +177 -0
- package/dist/serv/vault/token-vault.js.map +1 -0
- package/dist/server.d.ts +173 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +1622 -86
- package/dist/server.js.map +1 -1
- package/dist/shared/cli-sections.d.ts +6 -0
- package/dist/shared/cli-sections.d.ts.map +1 -0
- package/dist/shared/cli-sections.js +16 -0
- package/dist/shared/cli-sections.js.map +1 -0
- package/dist/shared/cli-utils.d.ts +81 -0
- package/dist/shared/cli-utils.d.ts.map +1 -0
- package/dist/shared/cli-utils.js +174 -0
- package/dist/shared/cli-utils.js.map +1 -0
- package/dist/shared/config-docs.d.ts +6 -0
- package/dist/shared/config-docs.d.ts.map +1 -0
- package/dist/shared/config-docs.js +6 -0
- package/dist/shared/config-docs.js.map +1 -0
- package/dist/shared/error-handler.d.ts +128 -0
- package/dist/shared/error-handler.d.ts.map +1 -0
- package/dist/shared/error-handler.js +342 -0
- package/dist/shared/error-handler.js.map +1 -0
- package/dist/shared/logger.d.ts +42 -0
- package/dist/shared/logger.d.ts.map +1 -0
- package/dist/shared/logger.js +123 -0
- package/dist/shared/logger.js.map +1 -0
- package/dist/shared/performance.d.ts +65 -0
- package/dist/shared/performance.d.ts.map +1 -0
- package/dist/shared/performance.js +136 -0
- package/dist/shared/performance.js.map +1 -0
- package/dist/shared/task-runner.d.ts +2 -0
- package/dist/shared/task-runner.d.ts.map +1 -0
- package/dist/shared/task-runner.js +16 -0
- package/dist/shared/task-runner.js.map +1 -0
- package/dist/shared/validation.d.ts +6 -0
- package/dist/shared/validation.d.ts.map +1 -0
- package/dist/shared/validation.js +6 -0
- package/dist/shared/validation.js.map +1 -0
- package/dist/shared-utils.d.ts +63 -0
- package/dist/shared-utils.d.ts.map +1 -0
- package/dist/shared-utils.js +123 -0
- package/dist/shared-utils.js.map +1 -0
- package/dist/template-manager.d.ts +23 -2
- package/dist/template-manager.d.ts.map +1 -1
- package/dist/template-manager.js +175 -86
- package/dist/template-manager.js.map +1 -1
- package/dist/test-client.d.ts.map +1 -1
- package/dist/test-client.js +10 -8
- package/dist/test-client.js.map +1 -1
- package/dist/test-runner.d.ts +52 -0
- package/dist/test-runner.d.ts.map +1 -0
- package/dist/test-runner.js +785 -0
- package/dist/test-runner.js.map +1 -0
- package/dist/testing.d.ts +103 -0
- package/dist/testing.d.ts.map +1 -0
- package/dist/testing.js +163 -0
- package/dist/testing.js.map +1 -0
- package/dist/version-checker.d.ts.map +1 -1
- package/dist/version-checker.js +2 -2
- package/dist/version-checker.js.map +1 -1
- package/dist/version.d.ts +2 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +5 -0
- package/dist/version.js.map +1 -0
- package/dist/watcher.d.ts +6 -3
- package/dist/watcher.d.ts.map +1 -1
- package/dist/watcher.js +49 -10
- package/dist/watcher.js.map +1 -1
- package/package.json +47 -7
- package/templates/cloudflare/worker.ts.template +381 -0
- package/templates/cloudflare/wrangler.toml.template +9 -0
- package/dist/base.d.ts +0 -58
- package/dist/base.d.ts.map +0 -1
- package/dist/base.js +0 -92
- package/dist/base.js.map +0 -1
- package/dist/dependency-manager.d.ts +0 -49
- package/dist/dependency-manager.d.ts.map +0 -1
- package/dist/dependency-manager.js +0 -165
- package/dist/dependency-manager.js.map +0 -1
- package/dist/registry-manager.d.ts +0 -76
- package/dist/registry-manager.d.ts.map +0 -1
- package/dist/registry-manager.js +0 -220
- package/dist/registry-manager.js.map +0 -1
- package/dist/schema-extractor.d.ts +0 -110
- package/dist/schema-extractor.d.ts.map +0 -1
- package/dist/schema-extractor.js +0 -727
- package/dist/schema-extractor.js.map +0 -1
- package/dist/test-marketplace-sources.d.ts +0 -5
- package/dist/test-marketplace-sources.d.ts.map +0 -1
- package/dist/test-marketplace-sources.js +0 -53
- package/dist/test-marketplace-sources.js.map +0 -1
- package/dist/types.d.ts +0 -109
- package/dist/types.d.ts.map +0 -1
- package/dist/types.js +0 -12
- package/dist/types.js.map +0 -1
|
@@ -0,0 +1,2381 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Photon Beam - Interactive Control Panel
|
|
3
|
+
*
|
|
4
|
+
* A unified UI to interact with all your photons.
|
|
5
|
+
* Uses MCP Streamable HTTP (POST + SSE) for real-time communication.
|
|
6
|
+
* Version: 2.0.0 (SSE Architecture)
|
|
7
|
+
*/
|
|
8
|
+
import * as http from 'http';
|
|
9
|
+
import * as fs from 'fs/promises';
|
|
10
|
+
import { existsSync, lstatSync, realpathSync, watch } from 'fs';
|
|
11
|
+
import * as path from 'path';
|
|
12
|
+
import * as os from 'os';
|
|
13
|
+
import { spawn } from 'child_process';
|
|
14
|
+
import { fileURLToPath } from 'url';
|
|
15
|
+
import { createHash } from 'crypto';
|
|
16
|
+
/**
|
|
17
|
+
* Generate a unique ID for a photon based on its path.
|
|
18
|
+
* This ensures photons with the same name from different paths are distinguishable.
|
|
19
|
+
* Returns first 12 chars of SHA-256 hash for brevity while maintaining uniqueness.
|
|
20
|
+
*/
|
|
21
|
+
function generatePhotonId(photonPath) {
|
|
22
|
+
return createHash('sha256').update(photonPath).digest('hex').slice(0, 12);
|
|
23
|
+
}
|
|
24
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
25
|
+
const __dirname = path.dirname(__filename);
|
|
26
|
+
// WebSocket removed - now using MCP Streamable HTTP (SSE) only
|
|
27
|
+
import { listPhotonMCPs, resolvePhotonPath } from '../path-resolver.js';
|
|
28
|
+
import { PhotonLoader } from '../loader.js';
|
|
29
|
+
import { logger, createLogger } from '../shared/logger.js';
|
|
30
|
+
import { toEnvVarName } from '../shared/config-docs.js';
|
|
31
|
+
import { MarketplaceManager } from '../marketplace-manager.js';
|
|
32
|
+
import { PhotonDocExtractor } from '../photon-doc-extractor.js';
|
|
33
|
+
import { TemplateManager } from '../template-manager.js';
|
|
34
|
+
import { subscribeChannel, pingDaemon } from '../daemon/client.js';
|
|
35
|
+
import { SchemaExtractor, } from '@portel/photon-core';
|
|
36
|
+
import { generateOpenAPISpec } from './openapi-generator.js';
|
|
37
|
+
import { handleStreamableHTTP, broadcastNotification, broadcastToBeam, sendToSession, } from './streamable-http-transport.js';
|
|
38
|
+
import { getBundledPhotonPath, BEAM_BUNDLED_PHOTONS } from '../shared-utils.js';
|
|
39
|
+
// Config file path
|
|
40
|
+
const CONFIG_FILE = path.join(os.homedir(), '.photon', 'config.json');
|
|
41
|
+
/**
|
|
42
|
+
* Migrate old flat config to new nested structure
|
|
43
|
+
*/
|
|
44
|
+
function migrateConfig(config) {
|
|
45
|
+
// Already new format
|
|
46
|
+
if (config.photons !== undefined || config.mcpServers !== undefined) {
|
|
47
|
+
return {
|
|
48
|
+
photons: config.photons || {},
|
|
49
|
+
mcpServers: config.mcpServers || {},
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
// Old flat format → migrate all keys under photons
|
|
53
|
+
console.error('📦 Migrating config.json to new nested format...');
|
|
54
|
+
return {
|
|
55
|
+
photons: { ...config },
|
|
56
|
+
mcpServers: {},
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
async function loadConfig() {
|
|
60
|
+
try {
|
|
61
|
+
const data = await fs.readFile(CONFIG_FILE, 'utf-8');
|
|
62
|
+
const raw = JSON.parse(data);
|
|
63
|
+
const migrated = migrateConfig(raw);
|
|
64
|
+
// Save back if migration occurred (structure changed)
|
|
65
|
+
if (!raw.photons && Object.keys(raw).length > 0) {
|
|
66
|
+
await saveConfig(migrated);
|
|
67
|
+
console.error('✅ Config migrated successfully');
|
|
68
|
+
}
|
|
69
|
+
return migrated;
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return { photons: {}, mcpServers: {} };
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
async function saveConfig(config) {
|
|
76
|
+
const dir = path.dirname(CONFIG_FILE);
|
|
77
|
+
await fs.mkdir(dir, { recursive: true });
|
|
78
|
+
await fs.writeFile(CONFIG_FILE, JSON.stringify(config, null, 2));
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Extract class-level metadata (description, icon) from JSDoc comments
|
|
82
|
+
*/
|
|
83
|
+
function extractClassMetadataFromSource(content) {
|
|
84
|
+
try {
|
|
85
|
+
// Find class-level JSDoc (immediately before class, or first JSDoc in file)
|
|
86
|
+
const classDocRegex = /\/\*\*([\s\S]*?)\*\/\s*\n?(?:export\s+)?(?:default\s+)?class\s+\w+/;
|
|
87
|
+
const match = content.match(classDocRegex) || content.match(/^\/\*\*([\s\S]*?)\*\//);
|
|
88
|
+
if (!match) {
|
|
89
|
+
return {};
|
|
90
|
+
}
|
|
91
|
+
const docContent = match[1];
|
|
92
|
+
const metadata = {};
|
|
93
|
+
// Extract @icon
|
|
94
|
+
const iconMatch = docContent.match(/@icon\s+(\S+)/);
|
|
95
|
+
if (iconMatch) {
|
|
96
|
+
metadata.icon = iconMatch[1];
|
|
97
|
+
}
|
|
98
|
+
// Extract @internal (presence indicates internal photon)
|
|
99
|
+
if (/@internal\b/.test(docContent)) {
|
|
100
|
+
metadata.internal = true;
|
|
101
|
+
}
|
|
102
|
+
// Extract @version
|
|
103
|
+
const versionMatch = docContent.match(/@version\s+(\S+)/);
|
|
104
|
+
if (versionMatch) {
|
|
105
|
+
metadata.version = versionMatch[1];
|
|
106
|
+
}
|
|
107
|
+
// Extract @author
|
|
108
|
+
const authorMatch = docContent.match(/@author\s+([^\n@]+)/);
|
|
109
|
+
if (authorMatch) {
|
|
110
|
+
metadata.author = authorMatch[1].trim();
|
|
111
|
+
}
|
|
112
|
+
// Extract @description or first line of doc (not starting with @)
|
|
113
|
+
const descMatch = docContent.match(/@description\s+([^\n@]+)/);
|
|
114
|
+
if (descMatch) {
|
|
115
|
+
metadata.description = descMatch[1].trim();
|
|
116
|
+
}
|
|
117
|
+
else {
|
|
118
|
+
// Get first non-empty line that's not a tag
|
|
119
|
+
const lines = docContent
|
|
120
|
+
.split('\n')
|
|
121
|
+
.map((l) => l.replace(/^\s*\*\s?/, '').trim())
|
|
122
|
+
.filter((l) => l && !l.startsWith('@'));
|
|
123
|
+
if (lines.length > 0) {
|
|
124
|
+
metadata.description = lines[0];
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return metadata;
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
return {};
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Extract @visibility annotations from method-level JSDoc and apply to methods
|
|
135
|
+
* @visibility model,app → ['model', 'app']
|
|
136
|
+
*/
|
|
137
|
+
function applyMethodVisibility(source, methods) {
|
|
138
|
+
const regex = /\/\*\*[\s\S]*?@visibility\s+([\w,\s]+)[\s\S]*?\*\/\s*(?:async\s+)?\*?\s*(\w+)/g;
|
|
139
|
+
let match;
|
|
140
|
+
while ((match = regex.exec(source)) !== null) {
|
|
141
|
+
const [, visibilityStr, methodName] = match;
|
|
142
|
+
const method = methods.find((m) => m.name === methodName);
|
|
143
|
+
if (method) {
|
|
144
|
+
method.visibility = visibilityStr
|
|
145
|
+
.split(',')
|
|
146
|
+
.map((v) => v.trim())
|
|
147
|
+
.filter((v) => v === 'model' || v === 'app');
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Extract @csp annotations from class-level JSDoc
|
|
153
|
+
* @csp connect domain1,domain2
|
|
154
|
+
* @csp resource cdn.example.com
|
|
155
|
+
*/
|
|
156
|
+
function extractCspFromSource(source) {
|
|
157
|
+
const result = {};
|
|
158
|
+
// Match class-level JSDoc with @csp tags
|
|
159
|
+
const classDocRegex = /\/\*\*([\s\S]*?)\*\/\s*\n?(?:export\s+)?(?:default\s+)?class\s+(\w+)/g;
|
|
160
|
+
let classMatch;
|
|
161
|
+
while ((classMatch = classDocRegex.exec(source)) !== null) {
|
|
162
|
+
const docContent = classMatch[1];
|
|
163
|
+
const csp = {};
|
|
164
|
+
let hasCsp = false;
|
|
165
|
+
const cspRegex = /@csp\s+(connect|resource|frame|base-uri)\s+([^\n@]+)/g;
|
|
166
|
+
let cspMatch;
|
|
167
|
+
while ((cspMatch = cspRegex.exec(docContent)) !== null) {
|
|
168
|
+
hasCsp = true;
|
|
169
|
+
const directive = cspMatch[1].trim();
|
|
170
|
+
const domains = cspMatch[2]
|
|
171
|
+
.trim()
|
|
172
|
+
.split(/[,\s]+/)
|
|
173
|
+
.filter(Boolean);
|
|
174
|
+
const key = directive === 'base-uri' ? 'baseUriDomains' : `${directive}Domains`;
|
|
175
|
+
csp[key] = (csp[key] || []).concat(domains);
|
|
176
|
+
}
|
|
177
|
+
if (hasCsp) {
|
|
178
|
+
result['__class__'] = csp;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return result;
|
|
182
|
+
}
|
|
183
|
+
export async function startBeam(rawWorkingDir, port) {
|
|
184
|
+
const workingDir = path.resolve(rawWorkingDir);
|
|
185
|
+
// Initialize marketplace manager for photon discovery and installation
|
|
186
|
+
const marketplace = new MarketplaceManager();
|
|
187
|
+
await marketplace.initialize();
|
|
188
|
+
// Auto-update stale caches in background
|
|
189
|
+
marketplace.autoUpdateStaleCaches().catch(() => { });
|
|
190
|
+
// Discover all photons (user photons + bundled photons)
|
|
191
|
+
const userPhotonList = await listPhotonMCPs(workingDir);
|
|
192
|
+
// Add bundled photons with their paths
|
|
193
|
+
const bundledPhotonPaths = new Map();
|
|
194
|
+
for (const name of BEAM_BUNDLED_PHOTONS) {
|
|
195
|
+
const bundledPath = getBundledPhotonPath(name, __dirname, BEAM_BUNDLED_PHOTONS);
|
|
196
|
+
if (bundledPath) {
|
|
197
|
+
bundledPhotonPaths.set(name, bundledPath);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
// Combine: user photons first, then bundled photons (avoid duplicates)
|
|
201
|
+
const photonList = [...userPhotonList];
|
|
202
|
+
for (const name of BEAM_BUNDLED_PHOTONS) {
|
|
203
|
+
if (!photonList.includes(name) && bundledPhotonPaths.has(name)) {
|
|
204
|
+
photonList.push(name);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
if (photonList.length === 0) {
|
|
208
|
+
logger.info('No photons found - showing management UI');
|
|
209
|
+
}
|
|
210
|
+
// Load saved config and apply to env
|
|
211
|
+
const savedConfig = await loadConfig();
|
|
212
|
+
// Extract metadata for all photons
|
|
213
|
+
const photons = [];
|
|
214
|
+
const photonMCPs = new Map(); // Store full MCP objects
|
|
215
|
+
// Use PhotonLoader with error-only logger to reduce verbosity
|
|
216
|
+
// Beam handles config errors gracefully via UI forms, but we still want to see actual errors
|
|
217
|
+
const errorOnlyLogger = createLogger({ level: 'error' });
|
|
218
|
+
const loader = new PhotonLoader(false, errorOnlyLogger);
|
|
219
|
+
// Counts updated after photon loading
|
|
220
|
+
let configuredCount = 0;
|
|
221
|
+
let unconfiguredCount = 0;
|
|
222
|
+
// Check for placeholder defaults or localhost URLs (which need local services running)
|
|
223
|
+
const isPlaceholderOrLocalDefault = (value) => {
|
|
224
|
+
if (value.includes('<') || value.includes('your-'))
|
|
225
|
+
return true;
|
|
226
|
+
if (value.includes('localhost') || value.includes('127.0.0.1'))
|
|
227
|
+
return true;
|
|
228
|
+
return false;
|
|
229
|
+
};
|
|
230
|
+
// Helper: load a single photon, returning the info to push into photons[]
|
|
231
|
+
async function loadSinglePhoton(name) {
|
|
232
|
+
const photonPath = bundledPhotonPaths.get(name) || (await resolvePhotonPath(name, workingDir));
|
|
233
|
+
if (!photonPath)
|
|
234
|
+
return null;
|
|
235
|
+
// Apply saved config to environment before loading
|
|
236
|
+
if (savedConfig.photons[name]) {
|
|
237
|
+
for (const [key, value] of Object.entries(savedConfig.photons[name])) {
|
|
238
|
+
process.env[key] = value;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
// Read source once — used for constructor params, schema extraction, and class metadata
|
|
242
|
+
const extractor = new SchemaExtractor();
|
|
243
|
+
let constructorParams = [];
|
|
244
|
+
let templatePath;
|
|
245
|
+
let source;
|
|
246
|
+
let isInternal;
|
|
247
|
+
try {
|
|
248
|
+
source = await fs.readFile(photonPath, 'utf-8');
|
|
249
|
+
}
|
|
250
|
+
catch {
|
|
251
|
+
// Can't read source
|
|
252
|
+
}
|
|
253
|
+
// Extract @internal early — outside try/catch so it's always available
|
|
254
|
+
if (source && /@internal\b/.test(source)) {
|
|
255
|
+
isInternal = true;
|
|
256
|
+
}
|
|
257
|
+
try {
|
|
258
|
+
if (source) {
|
|
259
|
+
const params = extractor.extractConstructorParams(source);
|
|
260
|
+
constructorParams = params
|
|
261
|
+
.filter((p) => p.isPrimitive)
|
|
262
|
+
.map((p) => ({
|
|
263
|
+
name: p.name,
|
|
264
|
+
envVar: toEnvVarName(name, p.name),
|
|
265
|
+
type: p.type,
|
|
266
|
+
isOptional: p.isOptional,
|
|
267
|
+
hasDefault: p.hasDefault,
|
|
268
|
+
defaultValue: p.defaultValue,
|
|
269
|
+
}));
|
|
270
|
+
// Extract @ui template path from class-level JSDoc
|
|
271
|
+
const classJsdocMatch = source.match(/\/\*\*[\s\S]*?\*\/\s*(?=export\s+default\s+class)/)
|
|
272
|
+
|| source.match(/^\/\*\*([\s\S]*?)\*\//);
|
|
273
|
+
if (classJsdocMatch) {
|
|
274
|
+
const uiMatch = classJsdocMatch[0].match(/@ui\s+([^\s*]+)/);
|
|
275
|
+
if (uiMatch) {
|
|
276
|
+
templatePath = uiMatch[1];
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
catch {
|
|
282
|
+
// Can't extract params, try to load anyway
|
|
283
|
+
}
|
|
284
|
+
// Check if any required params are missing from environment
|
|
285
|
+
const missingRequired = constructorParams.filter((p) => !p.isOptional && !p.hasDefault && !process.env[p.envVar]);
|
|
286
|
+
const hasPlaceholderDefaults = constructorParams.some((p) => p.hasDefault &&
|
|
287
|
+
typeof p.defaultValue === 'string' &&
|
|
288
|
+
isPlaceholderOrLocalDefault(p.defaultValue));
|
|
289
|
+
const needsConfig = missingRequired.length > 0 ||
|
|
290
|
+
(hasPlaceholderDefaults &&
|
|
291
|
+
constructorParams.some((p) => p.hasDefault &&
|
|
292
|
+
typeof p.defaultValue === 'string' &&
|
|
293
|
+
isPlaceholderOrLocalDefault(p.defaultValue) &&
|
|
294
|
+
!process.env[p.envVar]));
|
|
295
|
+
if (needsConfig && constructorParams.length > 0) {
|
|
296
|
+
return {
|
|
297
|
+
id: generatePhotonId(photonPath),
|
|
298
|
+
name,
|
|
299
|
+
path: photonPath,
|
|
300
|
+
configured: false,
|
|
301
|
+
internal: isInternal,
|
|
302
|
+
requiredParams: constructorParams,
|
|
303
|
+
errorMessage: missingRequired.length > 0
|
|
304
|
+
? `Missing required: ${missingRequired.map((p) => p.name).join(', ')}`
|
|
305
|
+
: 'Has placeholder values that need configuration',
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
// All params satisfied, try to load with timeout
|
|
309
|
+
try {
|
|
310
|
+
const loadPromise = loader.loadFile(photonPath);
|
|
311
|
+
const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error('Loading timeout (10s)')), 10000));
|
|
312
|
+
const mcp = (await Promise.race([loadPromise, timeoutPromise]));
|
|
313
|
+
const instance = mcp.instance;
|
|
314
|
+
if (!instance) {
|
|
315
|
+
return null;
|
|
316
|
+
}
|
|
317
|
+
photonMCPs.set(name, mcp);
|
|
318
|
+
// Extract schema for UI — reuse source read from above
|
|
319
|
+
const schemaSource = source || (await fs.readFile(photonPath, 'utf-8'));
|
|
320
|
+
const { tools: schemas, templates } = extractor.extractAllFromSource(schemaSource);
|
|
321
|
+
mcp.schemas = schemas;
|
|
322
|
+
// Get UI assets for linking
|
|
323
|
+
const uiAssets = mcp.assets?.ui || [];
|
|
324
|
+
// Filter out lifecycle methods
|
|
325
|
+
const lifecycleMethods = ['onInitialize', 'onShutdown', 'constructor'];
|
|
326
|
+
const methods = schemas
|
|
327
|
+
.filter((schema) => !lifecycleMethods.includes(schema.name))
|
|
328
|
+
.map((schema) => {
|
|
329
|
+
const linkedAsset = uiAssets.find((ui) => ui.linkedTool === schema.name);
|
|
330
|
+
return {
|
|
331
|
+
name: schema.name,
|
|
332
|
+
description: schema.description || '',
|
|
333
|
+
params: schema.inputSchema || { type: 'object', properties: {}, required: [] },
|
|
334
|
+
returns: { type: 'object' },
|
|
335
|
+
autorun: schema.autorun || false,
|
|
336
|
+
outputFormat: schema.outputFormat,
|
|
337
|
+
layoutHints: schema.layoutHints,
|
|
338
|
+
buttonLabel: schema.buttonLabel,
|
|
339
|
+
icon: schema.icon,
|
|
340
|
+
linkedUi: linkedAsset?.id,
|
|
341
|
+
...(schema.isStatic ? { isStatic: true } : {}),
|
|
342
|
+
};
|
|
343
|
+
});
|
|
344
|
+
// Add templates as methods with isTemplate flag and markdown output format
|
|
345
|
+
templates.forEach((template) => {
|
|
346
|
+
if (!lifecycleMethods.includes(template.name)) {
|
|
347
|
+
methods.push({
|
|
348
|
+
name: template.name,
|
|
349
|
+
description: template.description || '',
|
|
350
|
+
params: template.inputSchema || { type: 'object', properties: {}, required: [] },
|
|
351
|
+
returns: { type: 'object' },
|
|
352
|
+
isTemplate: true,
|
|
353
|
+
outputFormat: 'markdown',
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
});
|
|
357
|
+
// Apply @visibility annotations from source to methods
|
|
358
|
+
applyMethodVisibility(schemaSource, methods);
|
|
359
|
+
// Check if this is an App (has main() method with @ui)
|
|
360
|
+
const mainMethod = methods.find((m) => m.name === 'main' && m.linkedUi);
|
|
361
|
+
// Extract class-level metadata — reuse source already read
|
|
362
|
+
const classMetadata = extractClassMetadataFromSource(schemaSource);
|
|
363
|
+
// Extract class-level @csp metadata and apply to all UI assets
|
|
364
|
+
const cspData = extractCspFromSource(schemaSource);
|
|
365
|
+
if (cspData['__class__'] && mcp.assets?.ui) {
|
|
366
|
+
for (const uiAsset of mcp.assets.ui) {
|
|
367
|
+
uiAsset.csp = cspData['__class__'];
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
// Count resources and prompts
|
|
371
|
+
const resourceCount = mcp.assets?.resources?.length || 0;
|
|
372
|
+
const promptCount = templates.length;
|
|
373
|
+
// Read install metadata for marketplace-installed photons
|
|
374
|
+
let installSource;
|
|
375
|
+
let metaVersion = classMetadata.version;
|
|
376
|
+
let metaAuthor = classMetadata.author;
|
|
377
|
+
try {
|
|
378
|
+
const { readLocalMetadata } = await import('../marketplace-manager.js');
|
|
379
|
+
const localMeta = await readLocalMetadata();
|
|
380
|
+
const installMeta = localMeta.photons[`${name}.photon.ts`];
|
|
381
|
+
if (installMeta) {
|
|
382
|
+
installSource = {
|
|
383
|
+
marketplace: installMeta.marketplace,
|
|
384
|
+
installedAt: installMeta.installedAt,
|
|
385
|
+
};
|
|
386
|
+
if (!metaVersion && installMeta.version) {
|
|
387
|
+
metaVersion = installMeta.version;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
catch {
|
|
392
|
+
// No install metadata - that's fine
|
|
393
|
+
}
|
|
394
|
+
return {
|
|
395
|
+
id: generatePhotonId(photonPath),
|
|
396
|
+
name,
|
|
397
|
+
path: photonPath,
|
|
398
|
+
configured: true,
|
|
399
|
+
methods,
|
|
400
|
+
templatePath,
|
|
401
|
+
isApp: !!mainMethod,
|
|
402
|
+
appEntry: mainMethod,
|
|
403
|
+
assets: mcp.assets,
|
|
404
|
+
description: classMetadata.description || mcp.description || `${name} MCP`,
|
|
405
|
+
icon: classMetadata.icon,
|
|
406
|
+
internal: isInternal || classMetadata.internal,
|
|
407
|
+
version: metaVersion,
|
|
408
|
+
author: metaAuthor,
|
|
409
|
+
resourceCount,
|
|
410
|
+
promptCount,
|
|
411
|
+
installSource,
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
catch (error) {
|
|
415
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
416
|
+
if (constructorParams.length > 0) {
|
|
417
|
+
return {
|
|
418
|
+
id: generatePhotonId(photonPath),
|
|
419
|
+
name,
|
|
420
|
+
path: photonPath,
|
|
421
|
+
configured: false,
|
|
422
|
+
internal: isInternal,
|
|
423
|
+
requiredParams: constructorParams,
|
|
424
|
+
errorMessage: errorMsg.slice(0, 200),
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
return null;
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
const channelSubscriptions = new Map();
|
|
431
|
+
const EVENT_BUFFER_SIZE = 30; // Keep last 30 events per channel
|
|
432
|
+
const channelEventBuffers = new Map();
|
|
433
|
+
// Store an event in the channel buffer
|
|
434
|
+
function bufferEvent(channel, method, params) {
|
|
435
|
+
let buffer = channelEventBuffers.get(channel);
|
|
436
|
+
if (!buffer) {
|
|
437
|
+
buffer = { events: [], nextId: 1 };
|
|
438
|
+
channelEventBuffers.set(channel, buffer);
|
|
439
|
+
}
|
|
440
|
+
const eventId = buffer.nextId++;
|
|
441
|
+
const event = {
|
|
442
|
+
id: eventId,
|
|
443
|
+
method,
|
|
444
|
+
params,
|
|
445
|
+
timestamp: Date.now(),
|
|
446
|
+
};
|
|
447
|
+
buffer.events.push(event);
|
|
448
|
+
// Keep only last N events (circular buffer)
|
|
449
|
+
if (buffer.events.length > EVENT_BUFFER_SIZE) {
|
|
450
|
+
buffer.events.shift();
|
|
451
|
+
}
|
|
452
|
+
return eventId;
|
|
453
|
+
}
|
|
454
|
+
// Replay missed events to a specific session, or signal refresh needed
|
|
455
|
+
function replayEventsToSession(sessionId, channel, lastEventId) {
|
|
456
|
+
const buffer = channelEventBuffers.get(channel);
|
|
457
|
+
// No buffer = no events ever sent on this channel
|
|
458
|
+
if (!buffer || buffer.events.length === 0) {
|
|
459
|
+
return { replayed: 0, refreshNeeded: false };
|
|
460
|
+
}
|
|
461
|
+
// No lastEventId = client is fresh, no replay needed
|
|
462
|
+
if (lastEventId === undefined) {
|
|
463
|
+
return { replayed: 0, refreshNeeded: false };
|
|
464
|
+
}
|
|
465
|
+
const oldestEvent = buffer.events[0];
|
|
466
|
+
// If lastEventId is older than our oldest buffered event, signal refresh needed
|
|
467
|
+
if (lastEventId < oldestEvent.id) {
|
|
468
|
+
sendToSession(sessionId, 'photon/refresh-needed', { channel });
|
|
469
|
+
logger.info(`📡 Replay: ${channel} - lastEventId ${lastEventId} too old (oldest: ${oldestEvent.id}), refresh needed`);
|
|
470
|
+
return { replayed: 0, refreshNeeded: true };
|
|
471
|
+
}
|
|
472
|
+
// Find events to replay (all events after lastEventId)
|
|
473
|
+
const eventsToReplay = buffer.events.filter((e) => e.id > lastEventId);
|
|
474
|
+
if (eventsToReplay.length === 0) {
|
|
475
|
+
return { replayed: 0, refreshNeeded: false };
|
|
476
|
+
}
|
|
477
|
+
// Replay each missed event to this session
|
|
478
|
+
for (const event of eventsToReplay) {
|
|
479
|
+
sendToSession(sessionId, event.method, { ...event.params, _eventId: event.id });
|
|
480
|
+
}
|
|
481
|
+
logger.info(`📡 Replay: ${channel} - replayed ${eventsToReplay.length} events (${lastEventId + 1} to ${buffer.nextId - 1})`);
|
|
482
|
+
return { replayed: eventsToReplay.length, refreshNeeded: false };
|
|
483
|
+
}
|
|
484
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
485
|
+
// Subscribe to a channel (increment ref count, actually subscribe if first)
|
|
486
|
+
// Channel format: {photonId}:{itemId} (e.g., "a3f2b1c4d5e6:photon")
|
|
487
|
+
async function subscribeToChannel(channel) {
|
|
488
|
+
const existing = channelSubscriptions.get(channel);
|
|
489
|
+
if (existing) {
|
|
490
|
+
existing.refCount++;
|
|
491
|
+
logger.debug(`Channel ${channel} ref count: ${existing.refCount}`);
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
// First subscriber - actually subscribe to daemon
|
|
495
|
+
const subscription = { refCount: 1, unsubscribe: null };
|
|
496
|
+
channelSubscriptions.set(channel, subscription);
|
|
497
|
+
try {
|
|
498
|
+
// Extract photonId and itemId from channel (e.g., "a3f2b1:photon" -> photonId, itemId)
|
|
499
|
+
const [photonId, itemId] = channel.split(':');
|
|
500
|
+
// Look up photon name from ID
|
|
501
|
+
const photon = photons.find((p) => p.id === photonId);
|
|
502
|
+
if (!photon) {
|
|
503
|
+
logger.warn(`Cannot subscribe to ${channel}: unknown photon ID ${photonId}`);
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
const photonName = photon.name;
|
|
507
|
+
// Daemon uses photonName:itemId as channel (not photonId)
|
|
508
|
+
const daemonChannel = `${photonName}:${itemId}`;
|
|
509
|
+
const isRunning = await pingDaemon(photonName);
|
|
510
|
+
if (isRunning) {
|
|
511
|
+
const unsubscribe = await subscribeChannel(photonName, daemonChannel, (message) => {
|
|
512
|
+
// Forward channel messages as events with delta
|
|
513
|
+
// Include both photonId (for client) and photonName (for display)
|
|
514
|
+
const params = {
|
|
515
|
+
photonId,
|
|
516
|
+
photon: photonName,
|
|
517
|
+
channel: daemonChannel,
|
|
518
|
+
event: message?.event,
|
|
519
|
+
data: message?.data || message,
|
|
520
|
+
};
|
|
521
|
+
// Buffer event for replay on reconnect
|
|
522
|
+
const eventId = bufferEvent(channel, 'photon/channel-event', params);
|
|
523
|
+
broadcastToBeam('photon/channel-event', { ...params, _eventId: eventId });
|
|
524
|
+
});
|
|
525
|
+
subscription.unsubscribe = unsubscribe;
|
|
526
|
+
logger.info(`📡 Subscribed to ${daemonChannel} (id: ${photonId}, ref: 1)`);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
catch {
|
|
530
|
+
// Daemon not running - that's fine, in-process events still work
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
// Unsubscribe from a channel (decrement ref count, actually unsubscribe if last)
|
|
534
|
+
function unsubscribeFromChannel(channel) {
|
|
535
|
+
const subscription = channelSubscriptions.get(channel);
|
|
536
|
+
if (!subscription)
|
|
537
|
+
return;
|
|
538
|
+
subscription.refCount--;
|
|
539
|
+
logger.debug(`Channel ${channel} ref count: ${subscription.refCount}`);
|
|
540
|
+
if (subscription.refCount <= 0) {
|
|
541
|
+
// Last subscriber - actually unsubscribe
|
|
542
|
+
if (subscription.unsubscribe) {
|
|
543
|
+
subscription.unsubscribe();
|
|
544
|
+
logger.info(`📡 Unsubscribed from ${channel}`);
|
|
545
|
+
}
|
|
546
|
+
channelSubscriptions.delete(channel);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
// Track what each session is viewing for cleanup on disconnect
|
|
550
|
+
// Uses photonId (hash) for unique identification across servers
|
|
551
|
+
const sessionViewState = new Map();
|
|
552
|
+
// Called when a client starts viewing a board (from MCP notification)
|
|
553
|
+
// photonId: hash of photon path (unique across servers)
|
|
554
|
+
// itemId: whatever the photon uses to identify the item (e.g., board name)
|
|
555
|
+
// lastEventId: optional - if provided, replay missed events or signal refresh needed
|
|
556
|
+
function onClientViewingBoard(sessionId, photonId, itemId, lastEventId) {
|
|
557
|
+
const prevState = sessionViewState.get(sessionId);
|
|
558
|
+
// Unsubscribe from previous item if different
|
|
559
|
+
if (prevState?.itemId && (prevState.photonId !== photonId || prevState.itemId !== itemId)) {
|
|
560
|
+
const prevChannel = `${prevState.photonId}:${prevState.itemId}`;
|
|
561
|
+
unsubscribeFromChannel(prevChannel);
|
|
562
|
+
}
|
|
563
|
+
// Subscribe to new item
|
|
564
|
+
const channel = `${photonId}:${itemId}`;
|
|
565
|
+
sessionViewState.set(sessionId, { photonId, itemId });
|
|
566
|
+
subscribeToChannel(channel);
|
|
567
|
+
// Replay missed events if lastEventId is provided
|
|
568
|
+
if (lastEventId !== undefined) {
|
|
569
|
+
replayEventsToSession(sessionId, channel, lastEventId);
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
// Called when a client disconnects
|
|
573
|
+
function onClientDisconnect(sessionId) {
|
|
574
|
+
const state = sessionViewState.get(sessionId);
|
|
575
|
+
if (state?.photonId && state?.itemId) {
|
|
576
|
+
const channel = `${state.photonId}:${state.itemId}`;
|
|
577
|
+
unsubscribeFromChannel(channel);
|
|
578
|
+
}
|
|
579
|
+
sessionViewState.delete(sessionId);
|
|
580
|
+
}
|
|
581
|
+
const subscriptionManager = {
|
|
582
|
+
onClientViewingBoard,
|
|
583
|
+
onClientDisconnect,
|
|
584
|
+
};
|
|
585
|
+
// UI asset loader for MCP resources/read
|
|
586
|
+
const loadUIAsset = async (photonName, uiId) => {
|
|
587
|
+
const photon = photons.find((p) => p.name === photonName);
|
|
588
|
+
if (!photon || !photon.configured)
|
|
589
|
+
return null;
|
|
590
|
+
const photonDir = path.dirname(photon.path);
|
|
591
|
+
const asset = photon.assets?.ui?.find((u) => u.id === uiId);
|
|
592
|
+
let uiPath;
|
|
593
|
+
if (asset?.resolvedPath) {
|
|
594
|
+
uiPath = asset.resolvedPath;
|
|
595
|
+
}
|
|
596
|
+
else {
|
|
597
|
+
uiPath = path.join(photonDir, photonName, 'ui', `${uiId}.html`);
|
|
598
|
+
}
|
|
599
|
+
try {
|
|
600
|
+
return await fs.readFile(uiPath, 'utf-8');
|
|
601
|
+
}
|
|
602
|
+
catch {
|
|
603
|
+
return null; // UI asset not found
|
|
604
|
+
}
|
|
605
|
+
};
|
|
606
|
+
// Create HTTP server
|
|
607
|
+
const server = http.createServer(async (req, res) => {
|
|
608
|
+
const url = new URL(req.url || '/', `http://${req.headers.host}`);
|
|
609
|
+
// ══════════════════════════════════════════════════════════════════════════
|
|
610
|
+
// MCP Streamable HTTP Transport (standard MCP clients like Claude Desktop)
|
|
611
|
+
// Endpoint: /mcp (POST for requests, GET for SSE notifications)
|
|
612
|
+
// ══════════════════════════════════════════════════════════════════════════
|
|
613
|
+
if (url.pathname === '/mcp') {
|
|
614
|
+
const handled = await handleStreamableHTTP(req, res, {
|
|
615
|
+
photons, // Pass all photons including unconfigured for configurationSchema
|
|
616
|
+
photonMCPs,
|
|
617
|
+
loadUIAsset,
|
|
618
|
+
configurePhoton: async (photonName, config) => {
|
|
619
|
+
return configurePhotonViaMCP(photonName, config, photons, photonMCPs, loader, savedConfig);
|
|
620
|
+
},
|
|
621
|
+
reloadPhoton: async (photonName) => {
|
|
622
|
+
return reloadPhotonViaMCP(photonName, photons, photonMCPs, loader, savedConfig, broadcastPhotonChange);
|
|
623
|
+
},
|
|
624
|
+
removePhoton: async (photonName) => {
|
|
625
|
+
return removePhotonViaMCP(photonName, photons, photonMCPs, savedConfig, broadcastPhotonChange);
|
|
626
|
+
},
|
|
627
|
+
updateMetadata: async (photonName, methodName, metadata) => {
|
|
628
|
+
return updateMetadataViaMCP(photonName, methodName, metadata, photons);
|
|
629
|
+
},
|
|
630
|
+
generatePhotonHelp: async (photonName) => {
|
|
631
|
+
return generatePhotonHelpMarkdown(photonName, photons);
|
|
632
|
+
},
|
|
633
|
+
loader, // Pass loader for proper execution context (this.emit() support)
|
|
634
|
+
subscriptionManager, // For on-demand channel subscriptions
|
|
635
|
+
broadcast: (message) => {
|
|
636
|
+
const msg = message;
|
|
637
|
+
// Forward JSON-RPC notifications (progress, status, etc.)
|
|
638
|
+
if (msg.jsonrpc === '2.0' && msg.method) {
|
|
639
|
+
broadcastNotification(msg.method, msg.params || {});
|
|
640
|
+
}
|
|
641
|
+
// Forward channel events (task-moved, task-updated, etc.) with delta
|
|
642
|
+
else if (msg.type === 'channel-event') {
|
|
643
|
+
const params = {
|
|
644
|
+
photon: msg.photon,
|
|
645
|
+
channel: msg.channel,
|
|
646
|
+
event: msg.event,
|
|
647
|
+
data: msg.data,
|
|
648
|
+
};
|
|
649
|
+
// Buffer event for replay - find photonId from name for consistent channel key
|
|
650
|
+
const photon = photons.find((p) => p.name === msg.photon);
|
|
651
|
+
if (photon && msg.channel) {
|
|
652
|
+
const [, itemId] = msg.channel.split(':');
|
|
653
|
+
const bufferChannel = `${photon.id}:${itemId}`;
|
|
654
|
+
const eventId = bufferEvent(bufferChannel, 'photon/channel-event', {
|
|
655
|
+
...params,
|
|
656
|
+
photonId: photon.id,
|
|
657
|
+
});
|
|
658
|
+
broadcastToBeam('photon/channel-event', {
|
|
659
|
+
...params,
|
|
660
|
+
photonId: photon.id,
|
|
661
|
+
_eventId: eventId,
|
|
662
|
+
});
|
|
663
|
+
}
|
|
664
|
+
else {
|
|
665
|
+
broadcastToBeam('photon/channel-event', params);
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
// Forward board-update for backwards compatibility
|
|
669
|
+
else if (msg.type === 'board-update') {
|
|
670
|
+
broadcastToBeam('photon/board-update', {
|
|
671
|
+
photon: msg.photon,
|
|
672
|
+
board: msg.board,
|
|
673
|
+
});
|
|
674
|
+
}
|
|
675
|
+
},
|
|
676
|
+
});
|
|
677
|
+
if (handled)
|
|
678
|
+
return;
|
|
679
|
+
}
|
|
680
|
+
// Serve static frontend bundle
|
|
681
|
+
if (url.pathname === '/beam.bundle.js') {
|
|
682
|
+
try {
|
|
683
|
+
const bundlePath = path.join(__dirname, '../../dist/beam.bundle.js');
|
|
684
|
+
const content = await fs.readFile(bundlePath, 'utf-8');
|
|
685
|
+
res.writeHead(200, { 'Content-Type': 'text/javascript' });
|
|
686
|
+
res.end(content);
|
|
687
|
+
}
|
|
688
|
+
catch {
|
|
689
|
+
res.writeHead(404);
|
|
690
|
+
res.end('Bundle not found. Run npm run build:beam first.');
|
|
691
|
+
}
|
|
692
|
+
return;
|
|
693
|
+
}
|
|
694
|
+
// Default route: Serve Lit App
|
|
695
|
+
if (url.pathname === '/' || !url.pathname.startsWith('/api')) {
|
|
696
|
+
try {
|
|
697
|
+
const indexPath = path.join(__dirname, 'frontend/index.html');
|
|
698
|
+
const content = await fs.readFile(indexPath, 'utf-8');
|
|
699
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
700
|
+
res.end(content);
|
|
701
|
+
}
|
|
702
|
+
catch (err) {
|
|
703
|
+
res.writeHead(500);
|
|
704
|
+
res.end('Error serving UI: ' + String(err));
|
|
705
|
+
}
|
|
706
|
+
return;
|
|
707
|
+
}
|
|
708
|
+
// File browser API
|
|
709
|
+
if (url.pathname === '/api/browse') {
|
|
710
|
+
res.setHeader('Content-Type', 'application/json');
|
|
711
|
+
const dirPath = url.searchParams.get('path') || workingDir;
|
|
712
|
+
const root = url.searchParams.get('root');
|
|
713
|
+
try {
|
|
714
|
+
const resolved = path.resolve(dirPath);
|
|
715
|
+
// Validate path is within root (if specified)
|
|
716
|
+
if (root) {
|
|
717
|
+
const resolvedRoot = path.resolve(root);
|
|
718
|
+
if (!resolved.startsWith(resolvedRoot)) {
|
|
719
|
+
res.writeHead(403);
|
|
720
|
+
res.end(JSON.stringify({ error: 'Access denied: outside allowed directory' }));
|
|
721
|
+
return;
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
const stat = await fs.stat(resolved);
|
|
725
|
+
if (!stat.isDirectory()) {
|
|
726
|
+
res.writeHead(400);
|
|
727
|
+
res.end(JSON.stringify({ error: 'Not a directory' }));
|
|
728
|
+
return;
|
|
729
|
+
}
|
|
730
|
+
const entries = await fs.readdir(resolved, { withFileTypes: true });
|
|
731
|
+
const items = entries
|
|
732
|
+
.filter((e) => !e.name.startsWith('.') || e.name === '.photon')
|
|
733
|
+
.map((e) => ({
|
|
734
|
+
name: e.name,
|
|
735
|
+
path: path.join(resolved, e.name),
|
|
736
|
+
isDirectory: e.isDirectory(),
|
|
737
|
+
}))
|
|
738
|
+
.sort((a, b) => {
|
|
739
|
+
if (a.isDirectory !== b.isDirectory)
|
|
740
|
+
return a.isDirectory ? -1 : 1;
|
|
741
|
+
return a.name.localeCompare(b.name);
|
|
742
|
+
});
|
|
743
|
+
res.writeHead(200);
|
|
744
|
+
res.end(JSON.stringify({
|
|
745
|
+
path: resolved,
|
|
746
|
+
parent: path.dirname(resolved),
|
|
747
|
+
root: root ? path.resolve(root) : null,
|
|
748
|
+
items,
|
|
749
|
+
}));
|
|
750
|
+
}
|
|
751
|
+
catch {
|
|
752
|
+
res.writeHead(500);
|
|
753
|
+
res.end(JSON.stringify({ error: 'Failed to read directory' }));
|
|
754
|
+
}
|
|
755
|
+
return;
|
|
756
|
+
}
|
|
757
|
+
// Serve a local file (for relative image paths in markdown previews, etc.)
|
|
758
|
+
if (url.pathname === '/api/local-file') {
|
|
759
|
+
const filePath = url.searchParams.get('path');
|
|
760
|
+
if (!filePath) {
|
|
761
|
+
res.writeHead(400);
|
|
762
|
+
res.end('Missing path parameter');
|
|
763
|
+
return;
|
|
764
|
+
}
|
|
765
|
+
const resolved = path.resolve(filePath);
|
|
766
|
+
try {
|
|
767
|
+
const fileStat = await fs.stat(resolved);
|
|
768
|
+
if (!fileStat.isFile()) {
|
|
769
|
+
res.writeHead(400);
|
|
770
|
+
res.end('Not a file');
|
|
771
|
+
return;
|
|
772
|
+
}
|
|
773
|
+
// Determine MIME type from extension
|
|
774
|
+
const ext = path.extname(resolved).toLowerCase();
|
|
775
|
+
const mimeTypes = {
|
|
776
|
+
'.png': 'image/png',
|
|
777
|
+
'.jpg': 'image/jpeg',
|
|
778
|
+
'.jpeg': 'image/jpeg',
|
|
779
|
+
'.gif': 'image/gif',
|
|
780
|
+
'.svg': 'image/svg+xml',
|
|
781
|
+
'.webp': 'image/webp',
|
|
782
|
+
'.ico': 'image/x-icon',
|
|
783
|
+
'.bmp': 'image/bmp',
|
|
784
|
+
'.avif': 'image/avif',
|
|
785
|
+
'.pdf': 'application/pdf',
|
|
786
|
+
};
|
|
787
|
+
const contentType = mimeTypes[ext] || 'application/octet-stream';
|
|
788
|
+
const data = await fs.readFile(resolved);
|
|
789
|
+
res.writeHead(200, {
|
|
790
|
+
'Content-Type': contentType,
|
|
791
|
+
'Content-Length': data.length,
|
|
792
|
+
'Cache-Control': 'public, max-age=300',
|
|
793
|
+
});
|
|
794
|
+
res.end(data);
|
|
795
|
+
}
|
|
796
|
+
catch {
|
|
797
|
+
res.writeHead(404);
|
|
798
|
+
res.end('File not found');
|
|
799
|
+
}
|
|
800
|
+
return;
|
|
801
|
+
}
|
|
802
|
+
// Get photon's workdir (if applicable)
|
|
803
|
+
if (url.pathname === '/api/photon-workdir') {
|
|
804
|
+
res.setHeader('Content-Type', 'application/json');
|
|
805
|
+
const photonName = url.searchParams.get('name');
|
|
806
|
+
// If no photon name provided, just return the default working directory
|
|
807
|
+
if (!photonName) {
|
|
808
|
+
res.writeHead(200);
|
|
809
|
+
res.end(JSON.stringify({
|
|
810
|
+
defaultWorkdir: workingDir,
|
|
811
|
+
}));
|
|
812
|
+
return;
|
|
813
|
+
}
|
|
814
|
+
const photon = photons.find((p) => p.name === photonName);
|
|
815
|
+
if (!photon) {
|
|
816
|
+
res.writeHead(404);
|
|
817
|
+
res.end(JSON.stringify({ error: 'Photon not found' }));
|
|
818
|
+
return;
|
|
819
|
+
}
|
|
820
|
+
// For filesystem photon, use BEAM's working directory
|
|
821
|
+
// This ensures the file browser shows the same files BEAM is managing
|
|
822
|
+
let photonWorkdir = null;
|
|
823
|
+
if (photonName === 'filesystem') {
|
|
824
|
+
photonWorkdir = workingDir;
|
|
825
|
+
}
|
|
826
|
+
res.writeHead(200);
|
|
827
|
+
res.end(JSON.stringify({
|
|
828
|
+
name: photonName,
|
|
829
|
+
workdir: photonWorkdir,
|
|
830
|
+
defaultWorkdir: workingDir,
|
|
831
|
+
}));
|
|
832
|
+
return;
|
|
833
|
+
}
|
|
834
|
+
// Serve UI templates for custom UI rendering
|
|
835
|
+
if (url.pathname === '/api/ui') {
|
|
836
|
+
const photonName = url.searchParams.get('photon');
|
|
837
|
+
const uiId = url.searchParams.get('id');
|
|
838
|
+
if (!photonName || !uiId) {
|
|
839
|
+
res.writeHead(400);
|
|
840
|
+
res.end(JSON.stringify({ error: 'Missing photon or id parameter' }));
|
|
841
|
+
return;
|
|
842
|
+
}
|
|
843
|
+
const photon = photons.find((p) => p.name === photonName);
|
|
844
|
+
if (!photon) {
|
|
845
|
+
res.writeHead(404);
|
|
846
|
+
res.end(JSON.stringify({ error: 'Photon not found' }));
|
|
847
|
+
return;
|
|
848
|
+
}
|
|
849
|
+
// UI templates are in <photon-dir>/<photon-name>/ui/<id>.html
|
|
850
|
+
const photonDir = path.dirname(photon.path);
|
|
851
|
+
// Try to use resolved path from assets if available (respects JSDoc)
|
|
852
|
+
const asset = photon.assets?.ui?.find((u) => u.id === uiId);
|
|
853
|
+
let uiPath;
|
|
854
|
+
if (asset && asset.resolvedPath) {
|
|
855
|
+
uiPath = asset.resolvedPath;
|
|
856
|
+
}
|
|
857
|
+
else {
|
|
858
|
+
uiPath = path.join(photonDir, photonName, 'ui', `${uiId}.html`);
|
|
859
|
+
}
|
|
860
|
+
try {
|
|
861
|
+
const uiContent = await fs.readFile(uiPath, 'utf-8');
|
|
862
|
+
res.setHeader('Content-Type', 'text/html');
|
|
863
|
+
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
|
864
|
+
res.writeHead(200);
|
|
865
|
+
res.end(uiContent);
|
|
866
|
+
}
|
|
867
|
+
catch {
|
|
868
|
+
res.writeHead(404);
|
|
869
|
+
res.end(JSON.stringify({ error: `UI template not found: ${uiId}` }));
|
|
870
|
+
}
|
|
871
|
+
return;
|
|
872
|
+
}
|
|
873
|
+
// Serve @ui template files (class-level custom UI)
|
|
874
|
+
if (url.pathname === '/api/template') {
|
|
875
|
+
const photonName = url.searchParams.get('photon');
|
|
876
|
+
const templatePathParam = url.searchParams.get('path');
|
|
877
|
+
if (!photonName) {
|
|
878
|
+
res.writeHead(400);
|
|
879
|
+
res.end(JSON.stringify({ error: 'Missing photon parameter' }));
|
|
880
|
+
return;
|
|
881
|
+
}
|
|
882
|
+
const photon = photons.find((p) => p.name === photonName);
|
|
883
|
+
if (!photon || !photon.configured) {
|
|
884
|
+
res.writeHead(404);
|
|
885
|
+
res.end(JSON.stringify({ error: 'Photon not found or not configured' }));
|
|
886
|
+
return;
|
|
887
|
+
}
|
|
888
|
+
// Use provided path or photon's templatePath
|
|
889
|
+
const templateFile = templatePathParam || photon.templatePath;
|
|
890
|
+
if (!templateFile) {
|
|
891
|
+
res.writeHead(400);
|
|
892
|
+
res.end(JSON.stringify({ error: 'No template path specified' }));
|
|
893
|
+
return;
|
|
894
|
+
}
|
|
895
|
+
// Resolve template path relative to photon's directory
|
|
896
|
+
const photonDir = path.dirname(photon.path);
|
|
897
|
+
const fullTemplatePath = path.isAbsolute(templateFile)
|
|
898
|
+
? templateFile
|
|
899
|
+
: path.join(photonDir, templateFile);
|
|
900
|
+
try {
|
|
901
|
+
const templateContent = await fs.readFile(fullTemplatePath, 'utf-8');
|
|
902
|
+
res.setHeader('Content-Type', 'text/html');
|
|
903
|
+
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
|
904
|
+
res.writeHead(200);
|
|
905
|
+
res.end(templateContent);
|
|
906
|
+
}
|
|
907
|
+
catch {
|
|
908
|
+
res.writeHead(404);
|
|
909
|
+
res.end(JSON.stringify({ error: `Template not found: ${templateFile}` }));
|
|
910
|
+
}
|
|
911
|
+
return;
|
|
912
|
+
}
|
|
913
|
+
// PWA Manifest - Auto-generated for any photon
|
|
914
|
+
if (url.pathname === '/api/pwa/manifest.json') {
|
|
915
|
+
const photonName = url.searchParams.get('photon');
|
|
916
|
+
if (!photonName) {
|
|
917
|
+
res.writeHead(400);
|
|
918
|
+
res.end(JSON.stringify({ error: 'Missing photon parameter' }));
|
|
919
|
+
return;
|
|
920
|
+
}
|
|
921
|
+
const photon = photons.find((p) => p.name === photonName);
|
|
922
|
+
const displayName = photon?.name || photonName;
|
|
923
|
+
const description = photon?.description || `${displayName} - Photon App`;
|
|
924
|
+
const manifest = {
|
|
925
|
+
name: displayName,
|
|
926
|
+
short_name: displayName,
|
|
927
|
+
description,
|
|
928
|
+
start_url: `/api/pwa/app?photon=${encodeURIComponent(photonName)}`,
|
|
929
|
+
display: 'standalone',
|
|
930
|
+
background_color: '#1a1a1a',
|
|
931
|
+
theme_color: '#1a1a1a',
|
|
932
|
+
orientation: 'any',
|
|
933
|
+
icons: [
|
|
934
|
+
{
|
|
935
|
+
src: `/api/pwa/icon.svg?photon=${encodeURIComponent(photonName)}`,
|
|
936
|
+
sizes: 'any',
|
|
937
|
+
type: 'image/svg+xml',
|
|
938
|
+
purpose: 'any',
|
|
939
|
+
},
|
|
940
|
+
],
|
|
941
|
+
categories: ['developer', 'utilities'],
|
|
942
|
+
};
|
|
943
|
+
res.setHeader('Content-Type', 'application/manifest+json');
|
|
944
|
+
res.writeHead(200);
|
|
945
|
+
res.end(JSON.stringify(manifest, null, 2));
|
|
946
|
+
return;
|
|
947
|
+
}
|
|
948
|
+
// PWA Icon - Auto-generated SVG from photon emoji
|
|
949
|
+
if (url.pathname === '/api/pwa/icon.svg') {
|
|
950
|
+
const photonName = url.searchParams.get('photon');
|
|
951
|
+
const photon = photons.find((p) => p.name === photonName);
|
|
952
|
+
const emoji = photon?.icon || '📦';
|
|
953
|
+
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
|
954
|
+
<rect width="100" height="100" rx="20" fill="#1a1a1a"/>
|
|
955
|
+
<text x="50" y="50" font-size="50" text-anchor="middle" dominant-baseline="central">${emoji}</text>
|
|
956
|
+
</svg>`;
|
|
957
|
+
res.setHeader('Content-Type', 'image/svg+xml');
|
|
958
|
+
res.writeHead(200);
|
|
959
|
+
res.end(svg);
|
|
960
|
+
return;
|
|
961
|
+
}
|
|
962
|
+
// PWA App Entry - Serves the photon UI with PWA tags injected
|
|
963
|
+
if (url.pathname === '/api/pwa/app') {
|
|
964
|
+
const photonName = url.searchParams.get('photon');
|
|
965
|
+
if (!photonName) {
|
|
966
|
+
res.writeHead(400);
|
|
967
|
+
res.end('Missing photon parameter');
|
|
968
|
+
return;
|
|
969
|
+
}
|
|
970
|
+
const photon = photons.find((p) => p.name === photonName);
|
|
971
|
+
if (!photon) {
|
|
972
|
+
res.writeHead(404);
|
|
973
|
+
res.end(`Photon not found: ${photonName}`);
|
|
974
|
+
return;
|
|
975
|
+
}
|
|
976
|
+
const displayName = photon.name;
|
|
977
|
+
const emoji = photon?.icon || '📦';
|
|
978
|
+
const uiAssets = photon.assets?.ui || [];
|
|
979
|
+
const asset = uiAssets.find((u) => u.linkedTool === 'main') || uiAssets[0];
|
|
980
|
+
const uiId = asset?.id || 'main';
|
|
981
|
+
// PWA Host page - embeds photon UI in iframe, handles postMessage
|
|
982
|
+
const pwaHost = `<!DOCTYPE html>
|
|
983
|
+
<html lang="en">
|
|
984
|
+
<head>
|
|
985
|
+
<meta charset="UTF-8">
|
|
986
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
987
|
+
<title>${emoji} ${displayName}</title>
|
|
988
|
+
<link rel="manifest" href="/api/pwa/manifest.json?photon=${encodeURIComponent(photonName)}">
|
|
989
|
+
<meta name="theme-color" content="#1a1a1a">
|
|
990
|
+
<meta name="mobile-web-app-capable" content="yes">
|
|
991
|
+
<meta name="apple-mobile-web-app-capable" content="yes">
|
|
992
|
+
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
|
993
|
+
<meta name="apple-mobile-web-app-title" content="${displayName}">
|
|
994
|
+
<link rel="apple-touch-icon" href="/api/pwa/icon.svg?photon=${encodeURIComponent(photonName)}">
|
|
995
|
+
<style>
|
|
996
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
997
|
+
html, body { min-height: 100%; background: #1a1a1a; font-family: system-ui, sans-serif; color: #e5e5e5; }
|
|
998
|
+
.app-container { display: flex; flex-direction: column; min-height: 100vh; }
|
|
999
|
+
.app-frame { flex: 1; min-height: 80vh; }
|
|
1000
|
+
iframe { width: 100%; height: 100%; min-height: 80vh; border: none; }
|
|
1001
|
+
|
|
1002
|
+
.offline {
|
|
1003
|
+
display: none;
|
|
1004
|
+
height: 100vh;
|
|
1005
|
+
flex-direction: column;
|
|
1006
|
+
align-items: center;
|
|
1007
|
+
justify-content: center;
|
|
1008
|
+
color: #888;
|
|
1009
|
+
text-align: center;
|
|
1010
|
+
padding: 40px;
|
|
1011
|
+
}
|
|
1012
|
+
.offline.show { display: flex; }
|
|
1013
|
+
.offline h1 { font-size: 48px; margin-bottom: 20px; }
|
|
1014
|
+
.offline p { font-size: 18px; margin-bottom: 30px; max-width: 400px; line-height: 1.6; }
|
|
1015
|
+
.offline code {
|
|
1016
|
+
background: #2a2a2a;
|
|
1017
|
+
padding: 12px 24px;
|
|
1018
|
+
border-radius: 8px;
|
|
1019
|
+
font-size: 16px;
|
|
1020
|
+
color: #4ade80;
|
|
1021
|
+
font-family: monospace;
|
|
1022
|
+
}
|
|
1023
|
+
.offline .retry {
|
|
1024
|
+
margin-top: 20px;
|
|
1025
|
+
padding: 10px 20px;
|
|
1026
|
+
background: #333;
|
|
1027
|
+
border: none;
|
|
1028
|
+
border-radius: 6px;
|
|
1029
|
+
color: #fff;
|
|
1030
|
+
cursor: pointer;
|
|
1031
|
+
font-size: 14px;
|
|
1032
|
+
}
|
|
1033
|
+
.offline .retry:hover { background: #444; }
|
|
1034
|
+
</style>
|
|
1035
|
+
</head>
|
|
1036
|
+
<body>
|
|
1037
|
+
<div id="offline" class="offline">
|
|
1038
|
+
<h1>${emoji}</h1>
|
|
1039
|
+
<p>Server is not running. Start Photon to use ${displayName}:</p>
|
|
1040
|
+
<code>photon</code>
|
|
1041
|
+
<button class="retry" onclick="location.reload()">Retry</button>
|
|
1042
|
+
</div>
|
|
1043
|
+
|
|
1044
|
+
<div class="app-container" id="app-container" style="display:none">
|
|
1045
|
+
<iframe id="app"></iframe>
|
|
1046
|
+
</div>
|
|
1047
|
+
|
|
1048
|
+
<script>
|
|
1049
|
+
const iframe = document.getElementById('app');
|
|
1050
|
+
const offline = document.getElementById('offline');
|
|
1051
|
+
const appContainer = document.getElementById('app-container');
|
|
1052
|
+
const photonName = '${photonName}';
|
|
1053
|
+
const uiId = '${uiId}';
|
|
1054
|
+
|
|
1055
|
+
// Load UI with platform bridge injected
|
|
1056
|
+
async function loadApp() {
|
|
1057
|
+
try {
|
|
1058
|
+
// Fetch UI template and platform bridge
|
|
1059
|
+
const [uiRes, bridgeRes] = await Promise.all([
|
|
1060
|
+
fetch('/api/ui?photon=' + encodeURIComponent(photonName) + '&id=' + encodeURIComponent(uiId)),
|
|
1061
|
+
fetch('/api/platform-bridge?photon=' + encodeURIComponent(photonName) + '&method=' + encodeURIComponent(uiId) + '&theme=dark')
|
|
1062
|
+
]);
|
|
1063
|
+
|
|
1064
|
+
if (!uiRes.ok) {
|
|
1065
|
+
offline.classList.add('show');
|
|
1066
|
+
return;
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
let html = await uiRes.text();
|
|
1070
|
+
const bridge = bridgeRes.ok ? await bridgeRes.text() : '';
|
|
1071
|
+
|
|
1072
|
+
// Inject platform bridge before </head>
|
|
1073
|
+
html = html.replace('</head>', bridge + '</head>');
|
|
1074
|
+
|
|
1075
|
+
// Create blob URL and load in iframe
|
|
1076
|
+
const blob = new Blob([html], { type: 'text/html' });
|
|
1077
|
+
iframe.src = URL.createObjectURL(blob);
|
|
1078
|
+
appContainer.style.display = 'flex';
|
|
1079
|
+
initBridge();
|
|
1080
|
+
} catch (e) {
|
|
1081
|
+
offline.classList.add('show');
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
function initBridge() {
|
|
1086
|
+
// Listen for messages from iframe
|
|
1087
|
+
window.addEventListener('message', async (e) => {
|
|
1088
|
+
const msg = e.data;
|
|
1089
|
+
if (!msg || typeof msg !== 'object') return;
|
|
1090
|
+
|
|
1091
|
+
// Handle JSON-RPC tools/call from iframe
|
|
1092
|
+
if (msg.jsonrpc === '2.0' && msg.method === 'tools/call' && msg.id != null) {
|
|
1093
|
+
const { name: toolName, arguments: toolArgs } = msg.params || {};
|
|
1094
|
+
try {
|
|
1095
|
+
const res = await fetch('/api/invoke', {
|
|
1096
|
+
method: 'POST',
|
|
1097
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1098
|
+
body: JSON.stringify({ photon: photonName, method: toolName, args: toolArgs || {} }),
|
|
1099
|
+
signal: AbortSignal.timeout(60000), // 60s for method calls
|
|
1100
|
+
});
|
|
1101
|
+
const data = await res.json();
|
|
1102
|
+
iframe.contentWindow.postMessage({
|
|
1103
|
+
jsonrpc: '2.0',
|
|
1104
|
+
id: msg.id,
|
|
1105
|
+
result: data.error ? undefined : (data.result !== undefined ? data.result : data),
|
|
1106
|
+
error: data.error ? { code: -32000, message: data.error } : undefined,
|
|
1107
|
+
}, '*');
|
|
1108
|
+
} catch (err) {
|
|
1109
|
+
iframe.contentWindow.postMessage({
|
|
1110
|
+
jsonrpc: '2.0',
|
|
1111
|
+
id: msg.id,
|
|
1112
|
+
error: { code: -32000, message: err.message },
|
|
1113
|
+
}, '*');
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
});
|
|
1117
|
+
|
|
1118
|
+
// Send init message to iframe once loaded
|
|
1119
|
+
iframe.onload = () => {
|
|
1120
|
+
iframe.contentWindow.postMessage({
|
|
1121
|
+
type: 'photon:init',
|
|
1122
|
+
context: { photon: photonName, theme: 'dark', displayMode: 'fullscreen' }
|
|
1123
|
+
}, '*');
|
|
1124
|
+
};
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
loadApp();
|
|
1128
|
+
</script>
|
|
1129
|
+
</body>
|
|
1130
|
+
</html>`;
|
|
1131
|
+
res.setHeader('Content-Type', 'text/html');
|
|
1132
|
+
res.writeHead(200);
|
|
1133
|
+
res.end(pwaHost);
|
|
1134
|
+
return;
|
|
1135
|
+
}
|
|
1136
|
+
// Invoke API: Direct HTTP endpoint for method invocation (used by PWA)
|
|
1137
|
+
if (url.pathname === '/api/invoke' && req.method === 'POST') {
|
|
1138
|
+
let body = '';
|
|
1139
|
+
req.on('data', (chunk) => (body += chunk));
|
|
1140
|
+
req.on('end', async () => {
|
|
1141
|
+
try {
|
|
1142
|
+
const { photon: photonName, method, args } = JSON.parse(body);
|
|
1143
|
+
if (!photonName || !method) {
|
|
1144
|
+
res.writeHead(400);
|
|
1145
|
+
res.end(JSON.stringify({ error: 'Missing photon or method' }));
|
|
1146
|
+
return;
|
|
1147
|
+
}
|
|
1148
|
+
const mcp = photonMCPs.get(photonName);
|
|
1149
|
+
if (!mcp || !mcp.instance) {
|
|
1150
|
+
res.writeHead(404);
|
|
1151
|
+
res.end(JSON.stringify({ error: `Photon not found: ${photonName}` }));
|
|
1152
|
+
return;
|
|
1153
|
+
}
|
|
1154
|
+
if (typeof mcp.instance[method] !== 'function') {
|
|
1155
|
+
res.writeHead(404);
|
|
1156
|
+
res.end(JSON.stringify({ error: `Method not found: ${method}` }));
|
|
1157
|
+
return;
|
|
1158
|
+
}
|
|
1159
|
+
const result = await mcp.instance[method](args || {});
|
|
1160
|
+
res.setHeader('Content-Type', 'application/json');
|
|
1161
|
+
res.writeHead(200);
|
|
1162
|
+
res.end(JSON.stringify({ result }));
|
|
1163
|
+
}
|
|
1164
|
+
catch (err) {
|
|
1165
|
+
res.setHeader('Content-Type', 'application/json');
|
|
1166
|
+
res.writeHead(500);
|
|
1167
|
+
res.end(JSON.stringify({ error: err.message || String(err) }));
|
|
1168
|
+
}
|
|
1169
|
+
});
|
|
1170
|
+
return;
|
|
1171
|
+
}
|
|
1172
|
+
// Platform Bridge API: Generate platform compatibility script
|
|
1173
|
+
if (url.pathname === '/api/platform-bridge') {
|
|
1174
|
+
const theme = (url.searchParams.get('theme') || 'dark');
|
|
1175
|
+
const photonName = url.searchParams.get('photon') || '';
|
|
1176
|
+
const methodName = url.searchParams.get('method') || '';
|
|
1177
|
+
const { generatePlatformBridgeScript } = await import('./platform-compat.js');
|
|
1178
|
+
const script = generatePlatformBridgeScript({
|
|
1179
|
+
theme,
|
|
1180
|
+
locale: 'en-US',
|
|
1181
|
+
displayMode: 'inline',
|
|
1182
|
+
photon: photonName,
|
|
1183
|
+
method: methodName,
|
|
1184
|
+
hostName: 'beam',
|
|
1185
|
+
hostVersion: '1.5.0',
|
|
1186
|
+
});
|
|
1187
|
+
res.setHeader('Content-Type', 'text/html');
|
|
1188
|
+
res.writeHead(200);
|
|
1189
|
+
res.end(script);
|
|
1190
|
+
return;
|
|
1191
|
+
}
|
|
1192
|
+
// Diagnostics endpoint: server health and photon status
|
|
1193
|
+
if (url.pathname === '/api/diagnostics') {
|
|
1194
|
+
res.setHeader('Content-Type', 'application/json');
|
|
1195
|
+
try {
|
|
1196
|
+
const { PHOTON_VERSION } = await import('../version.js');
|
|
1197
|
+
const sources = marketplace.getAll();
|
|
1198
|
+
const photonStatus = photons.map((p) => ({
|
|
1199
|
+
name: p.name,
|
|
1200
|
+
status: p.configured ? 'loaded' : 'unconfigured',
|
|
1201
|
+
methods: p.configured ? p.methods.length : 0,
|
|
1202
|
+
error: !p.configured ? p.errorMessage : undefined,
|
|
1203
|
+
}));
|
|
1204
|
+
res.writeHead(200);
|
|
1205
|
+
res.end(JSON.stringify({
|
|
1206
|
+
nodeVersion: process.version,
|
|
1207
|
+
photonVersion: PHOTON_VERSION,
|
|
1208
|
+
workingDir,
|
|
1209
|
+
uptime: process.uptime(),
|
|
1210
|
+
photonCount: photons.length,
|
|
1211
|
+
configuredCount: photons.filter((p) => p.configured).length,
|
|
1212
|
+
unconfiguredCount: photons.filter((p) => !p.configured).length,
|
|
1213
|
+
marketplaceSources: sources.filter((s) => s.enabled).length,
|
|
1214
|
+
photons: photonStatus,
|
|
1215
|
+
}));
|
|
1216
|
+
}
|
|
1217
|
+
catch {
|
|
1218
|
+
res.writeHead(500);
|
|
1219
|
+
res.end(JSON.stringify({ error: 'Failed to generate diagnostics' }));
|
|
1220
|
+
}
|
|
1221
|
+
return;
|
|
1222
|
+
}
|
|
1223
|
+
// MCP Config Export endpoint: generate Claude Desktop config snippet
|
|
1224
|
+
if (url.pathname === '/api/export/mcp-config') {
|
|
1225
|
+
res.setHeader('Content-Type', 'application/json');
|
|
1226
|
+
const photonName = url.searchParams.get('photon');
|
|
1227
|
+
if (!photonName) {
|
|
1228
|
+
res.writeHead(400);
|
|
1229
|
+
res.end(JSON.stringify({ error: 'Missing photon query parameter' }));
|
|
1230
|
+
return;
|
|
1231
|
+
}
|
|
1232
|
+
const photon = photons.find((p) => p.name === photonName);
|
|
1233
|
+
if (!photon) {
|
|
1234
|
+
res.writeHead(404);
|
|
1235
|
+
res.end(JSON.stringify({ error: `Photon '${photonName}' not found` }));
|
|
1236
|
+
return;
|
|
1237
|
+
}
|
|
1238
|
+
res.writeHead(200);
|
|
1239
|
+
res.end(JSON.stringify({
|
|
1240
|
+
mcpServers: {
|
|
1241
|
+
[`photon-${photonName}`]: {
|
|
1242
|
+
command: 'npx',
|
|
1243
|
+
args: ['-y', '@portel/photon', 'mcp', photonName],
|
|
1244
|
+
},
|
|
1245
|
+
},
|
|
1246
|
+
}, null, 2));
|
|
1247
|
+
return;
|
|
1248
|
+
}
|
|
1249
|
+
// OpenAPI Specification endpoint
|
|
1250
|
+
// Serves auto-generated OpenAPI 3.1 spec from loaded photons
|
|
1251
|
+
if (url.pathname === '/api/openapi.json') {
|
|
1252
|
+
res.setHeader('Content-Type', 'application/json');
|
|
1253
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
1254
|
+
try {
|
|
1255
|
+
const serverUrl = `http://${req.headers.host || 'localhost:' + port}`;
|
|
1256
|
+
const spec = generateOpenAPISpec(photons, serverUrl);
|
|
1257
|
+
res.writeHead(200);
|
|
1258
|
+
res.end(JSON.stringify(spec, null, 2));
|
|
1259
|
+
}
|
|
1260
|
+
catch (err) {
|
|
1261
|
+
res.writeHead(500);
|
|
1262
|
+
res.end(JSON.stringify({ error: 'Failed to generate OpenAPI spec' }));
|
|
1263
|
+
}
|
|
1264
|
+
return;
|
|
1265
|
+
}
|
|
1266
|
+
// Marketplace API: Search photons
|
|
1267
|
+
if (url.pathname === '/api/marketplace/search') {
|
|
1268
|
+
res.setHeader('Content-Type', 'application/json');
|
|
1269
|
+
const query = url.searchParams.get('q') || '';
|
|
1270
|
+
try {
|
|
1271
|
+
const results = await marketplace.search(query);
|
|
1272
|
+
const photonList = [];
|
|
1273
|
+
for (const [name, sources] of results) {
|
|
1274
|
+
const source = sources[0]; // Use first source
|
|
1275
|
+
photonList.push({
|
|
1276
|
+
name,
|
|
1277
|
+
description: source.metadata?.description || '',
|
|
1278
|
+
version: source.metadata?.version || '',
|
|
1279
|
+
author: source.metadata?.author || '',
|
|
1280
|
+
tags: source.metadata?.tags || [],
|
|
1281
|
+
marketplace: source.marketplace.name,
|
|
1282
|
+
installed: photonMCPs.has(name),
|
|
1283
|
+
});
|
|
1284
|
+
}
|
|
1285
|
+
res.writeHead(200);
|
|
1286
|
+
res.end(JSON.stringify({ photons: photonList }));
|
|
1287
|
+
}
|
|
1288
|
+
catch {
|
|
1289
|
+
res.writeHead(500);
|
|
1290
|
+
res.end(JSON.stringify({ error: 'Search failed' }));
|
|
1291
|
+
}
|
|
1292
|
+
return;
|
|
1293
|
+
}
|
|
1294
|
+
// Marketplace API: List all available photons
|
|
1295
|
+
if (url.pathname === '/api/marketplace/list') {
|
|
1296
|
+
res.setHeader('Content-Type', 'application/json');
|
|
1297
|
+
try {
|
|
1298
|
+
const allPhotons = await marketplace.getAllPhotons();
|
|
1299
|
+
const photonList = [];
|
|
1300
|
+
for (const [name, { metadata, marketplace: mp }] of allPhotons) {
|
|
1301
|
+
photonList.push({
|
|
1302
|
+
name,
|
|
1303
|
+
description: metadata.description || '',
|
|
1304
|
+
version: metadata.version || '',
|
|
1305
|
+
author: metadata.author || '',
|
|
1306
|
+
tags: metadata.tags || [],
|
|
1307
|
+
marketplace: mp.name,
|
|
1308
|
+
icon: metadata.icon,
|
|
1309
|
+
internal: metadata.internal,
|
|
1310
|
+
installed: photonMCPs.has(name),
|
|
1311
|
+
});
|
|
1312
|
+
}
|
|
1313
|
+
res.writeHead(200);
|
|
1314
|
+
res.end(JSON.stringify({ photons: photonList }));
|
|
1315
|
+
}
|
|
1316
|
+
catch {
|
|
1317
|
+
res.writeHead(500);
|
|
1318
|
+
res.end(JSON.stringify({ error: 'Failed to list photons' }));
|
|
1319
|
+
}
|
|
1320
|
+
return;
|
|
1321
|
+
}
|
|
1322
|
+
// Marketplace API: Add/install a photon
|
|
1323
|
+
if (url.pathname === '/api/marketplace/add' && req.method === 'POST') {
|
|
1324
|
+
res.setHeader('Content-Type', 'application/json');
|
|
1325
|
+
let body = '';
|
|
1326
|
+
req.on('data', (chunk) => {
|
|
1327
|
+
body += chunk;
|
|
1328
|
+
});
|
|
1329
|
+
req.on('end', async () => {
|
|
1330
|
+
try {
|
|
1331
|
+
const { name } = JSON.parse(body);
|
|
1332
|
+
if (!name) {
|
|
1333
|
+
res.writeHead(400);
|
|
1334
|
+
res.end(JSON.stringify({ error: 'Missing photon name' }));
|
|
1335
|
+
return;
|
|
1336
|
+
}
|
|
1337
|
+
// Fetch the photon from marketplace
|
|
1338
|
+
const result = await marketplace.fetchMCP(name);
|
|
1339
|
+
if (!result) {
|
|
1340
|
+
res.writeHead(404);
|
|
1341
|
+
res.end(JSON.stringify({ error: `Photon '${name}' not found in marketplace` }));
|
|
1342
|
+
return;
|
|
1343
|
+
}
|
|
1344
|
+
// Write to working directory
|
|
1345
|
+
const targetPath = path.join(workingDir, `${name}.photon.ts`);
|
|
1346
|
+
await fs.writeFile(targetPath, result.content, 'utf-8');
|
|
1347
|
+
// Save metadata if available
|
|
1348
|
+
if (result.metadata) {
|
|
1349
|
+
const hash = (await import('../marketplace-manager.js')).calculateHash(result.content);
|
|
1350
|
+
await marketplace.savePhotonMetadata(`${name}.photon.ts`, result.marketplace, result.metadata, hash);
|
|
1351
|
+
}
|
|
1352
|
+
res.writeHead(200);
|
|
1353
|
+
res.end(JSON.stringify({
|
|
1354
|
+
success: true,
|
|
1355
|
+
name,
|
|
1356
|
+
path: targetPath,
|
|
1357
|
+
version: result.metadata?.version,
|
|
1358
|
+
}));
|
|
1359
|
+
// Broadcast to connected clients to reload photon list
|
|
1360
|
+
broadcastPhotonChange();
|
|
1361
|
+
}
|
|
1362
|
+
catch {
|
|
1363
|
+
res.writeHead(500);
|
|
1364
|
+
res.end(JSON.stringify({ error: 'Failed to add photon' }));
|
|
1365
|
+
}
|
|
1366
|
+
});
|
|
1367
|
+
return;
|
|
1368
|
+
}
|
|
1369
|
+
// Marketplace API: Get all marketplace sources
|
|
1370
|
+
if (url.pathname === '/api/marketplace/sources') {
|
|
1371
|
+
res.setHeader('Content-Type', 'application/json');
|
|
1372
|
+
try {
|
|
1373
|
+
const sources = marketplace.getAll();
|
|
1374
|
+
const sourcesWithCounts = await Promise.all(sources.map(async (source) => {
|
|
1375
|
+
// Get photon count from cached manifest
|
|
1376
|
+
const manifest = await marketplace.getCachedManifest(source.name);
|
|
1377
|
+
return {
|
|
1378
|
+
name: source.name,
|
|
1379
|
+
repo: source.repo,
|
|
1380
|
+
source: source.source,
|
|
1381
|
+
sourceType: source.sourceType,
|
|
1382
|
+
enabled: source.enabled,
|
|
1383
|
+
photonCount: manifest?.photons?.length || 0,
|
|
1384
|
+
lastUpdated: source.lastUpdated,
|
|
1385
|
+
};
|
|
1386
|
+
}));
|
|
1387
|
+
res.writeHead(200);
|
|
1388
|
+
res.end(JSON.stringify({ sources: sourcesWithCounts }));
|
|
1389
|
+
}
|
|
1390
|
+
catch {
|
|
1391
|
+
res.writeHead(500);
|
|
1392
|
+
res.end(JSON.stringify({ error: 'Failed to get marketplace sources' }));
|
|
1393
|
+
}
|
|
1394
|
+
return;
|
|
1395
|
+
}
|
|
1396
|
+
// Marketplace API: Add a new marketplace source
|
|
1397
|
+
if (url.pathname === '/api/marketplace/sources/add' && req.method === 'POST') {
|
|
1398
|
+
res.setHeader('Content-Type', 'application/json');
|
|
1399
|
+
let body = '';
|
|
1400
|
+
req.on('data', (chunk) => {
|
|
1401
|
+
body += chunk;
|
|
1402
|
+
});
|
|
1403
|
+
req.on('end', async () => {
|
|
1404
|
+
try {
|
|
1405
|
+
const { source } = JSON.parse(body);
|
|
1406
|
+
if (!source) {
|
|
1407
|
+
res.writeHead(400);
|
|
1408
|
+
res.end(JSON.stringify({ error: 'Missing source parameter' }));
|
|
1409
|
+
return;
|
|
1410
|
+
}
|
|
1411
|
+
const result = await marketplace.add(source);
|
|
1412
|
+
// Update cache for the new marketplace
|
|
1413
|
+
if (result.added) {
|
|
1414
|
+
await marketplace.updateMarketplaceCache(result.marketplace.name);
|
|
1415
|
+
}
|
|
1416
|
+
res.writeHead(200);
|
|
1417
|
+
res.end(JSON.stringify({
|
|
1418
|
+
success: true,
|
|
1419
|
+
name: result.marketplace.name,
|
|
1420
|
+
added: result.added,
|
|
1421
|
+
}));
|
|
1422
|
+
}
|
|
1423
|
+
catch (err) {
|
|
1424
|
+
res.writeHead(400);
|
|
1425
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
1426
|
+
}
|
|
1427
|
+
});
|
|
1428
|
+
return;
|
|
1429
|
+
}
|
|
1430
|
+
// Marketplace API: Remove a marketplace source
|
|
1431
|
+
if (url.pathname === '/api/marketplace/sources/remove' && req.method === 'POST') {
|
|
1432
|
+
res.setHeader('Content-Type', 'application/json');
|
|
1433
|
+
let body = '';
|
|
1434
|
+
req.on('data', (chunk) => {
|
|
1435
|
+
body += chunk;
|
|
1436
|
+
});
|
|
1437
|
+
req.on('end', async () => {
|
|
1438
|
+
try {
|
|
1439
|
+
const { name } = JSON.parse(body);
|
|
1440
|
+
if (!name) {
|
|
1441
|
+
res.writeHead(400);
|
|
1442
|
+
res.end(JSON.stringify({ error: 'Missing name parameter' }));
|
|
1443
|
+
return;
|
|
1444
|
+
}
|
|
1445
|
+
const removed = await marketplace.remove(name);
|
|
1446
|
+
if (!removed) {
|
|
1447
|
+
res.writeHead(404);
|
|
1448
|
+
res.end(JSON.stringify({ error: `Marketplace '${name}' not found` }));
|
|
1449
|
+
return;
|
|
1450
|
+
}
|
|
1451
|
+
res.writeHead(200);
|
|
1452
|
+
res.end(JSON.stringify({ success: true }));
|
|
1453
|
+
}
|
|
1454
|
+
catch (err) {
|
|
1455
|
+
res.writeHead(400);
|
|
1456
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
1457
|
+
}
|
|
1458
|
+
});
|
|
1459
|
+
return;
|
|
1460
|
+
}
|
|
1461
|
+
// Marketplace API: Toggle marketplace enabled/disabled
|
|
1462
|
+
if (url.pathname === '/api/marketplace/sources/toggle' && req.method === 'POST') {
|
|
1463
|
+
res.setHeader('Content-Type', 'application/json');
|
|
1464
|
+
let body = '';
|
|
1465
|
+
req.on('data', (chunk) => {
|
|
1466
|
+
body += chunk;
|
|
1467
|
+
});
|
|
1468
|
+
req.on('end', async () => {
|
|
1469
|
+
try {
|
|
1470
|
+
const { name, enabled } = JSON.parse(body);
|
|
1471
|
+
if (!name || typeof enabled !== 'boolean') {
|
|
1472
|
+
res.writeHead(400);
|
|
1473
|
+
res.end(JSON.stringify({ error: 'Missing name or enabled parameter' }));
|
|
1474
|
+
return;
|
|
1475
|
+
}
|
|
1476
|
+
const success = await marketplace.setEnabled(name, enabled);
|
|
1477
|
+
if (!success) {
|
|
1478
|
+
res.writeHead(404);
|
|
1479
|
+
res.end(JSON.stringify({ error: `Marketplace '${name}' not found` }));
|
|
1480
|
+
return;
|
|
1481
|
+
}
|
|
1482
|
+
res.writeHead(200);
|
|
1483
|
+
res.end(JSON.stringify({ success: true }));
|
|
1484
|
+
}
|
|
1485
|
+
catch (err) {
|
|
1486
|
+
res.writeHead(500);
|
|
1487
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
1488
|
+
}
|
|
1489
|
+
});
|
|
1490
|
+
return;
|
|
1491
|
+
}
|
|
1492
|
+
// Marketplace API: Refresh marketplace cache
|
|
1493
|
+
if (url.pathname === '/api/marketplace/refresh' && req.method === 'POST') {
|
|
1494
|
+
res.setHeader('Content-Type', 'application/json');
|
|
1495
|
+
let body = '';
|
|
1496
|
+
req.on('data', (chunk) => {
|
|
1497
|
+
body += chunk;
|
|
1498
|
+
});
|
|
1499
|
+
req.on('end', async () => {
|
|
1500
|
+
try {
|
|
1501
|
+
const { name } = JSON.parse(body || '{}');
|
|
1502
|
+
if (name) {
|
|
1503
|
+
// Refresh specific marketplace
|
|
1504
|
+
const success = await marketplace.updateMarketplaceCache(name);
|
|
1505
|
+
res.writeHead(200);
|
|
1506
|
+
res.end(JSON.stringify({ success, updated: success ? [name] : [] }));
|
|
1507
|
+
}
|
|
1508
|
+
else {
|
|
1509
|
+
// Refresh all enabled marketplaces
|
|
1510
|
+
const results = await marketplace.updateAllCaches();
|
|
1511
|
+
const updated = Array.from(results.entries())
|
|
1512
|
+
.filter(([, success]) => success)
|
|
1513
|
+
.map(([name]) => name);
|
|
1514
|
+
res.writeHead(200);
|
|
1515
|
+
res.end(JSON.stringify({ success: true, updated }));
|
|
1516
|
+
}
|
|
1517
|
+
}
|
|
1518
|
+
catch (err) {
|
|
1519
|
+
res.writeHead(500);
|
|
1520
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
1521
|
+
}
|
|
1522
|
+
});
|
|
1523
|
+
return;
|
|
1524
|
+
}
|
|
1525
|
+
// Marketplace API: Check for available updates
|
|
1526
|
+
if (url.pathname === '/api/marketplace/updates') {
|
|
1527
|
+
res.setHeader('Content-Type', 'application/json');
|
|
1528
|
+
try {
|
|
1529
|
+
const { readLocalMetadata } = await import('../marketplace-manager.js');
|
|
1530
|
+
const localMetadata = await readLocalMetadata();
|
|
1531
|
+
const updates = [];
|
|
1532
|
+
// Check each installed photon for updates
|
|
1533
|
+
for (const [fileName, installMeta] of Object.entries(localMetadata.photons)) {
|
|
1534
|
+
const photonName = fileName.replace(/\.photon\.ts$/, '');
|
|
1535
|
+
const latestInfo = await marketplace.getPhotonMetadata(photonName);
|
|
1536
|
+
if (latestInfo && latestInfo.metadata.version !== installMeta.version) {
|
|
1537
|
+
updates.push({
|
|
1538
|
+
name: photonName,
|
|
1539
|
+
fileName,
|
|
1540
|
+
currentVersion: installMeta.version,
|
|
1541
|
+
latestVersion: latestInfo.metadata.version,
|
|
1542
|
+
marketplace: latestInfo.marketplace.name,
|
|
1543
|
+
});
|
|
1544
|
+
}
|
|
1545
|
+
}
|
|
1546
|
+
res.writeHead(200);
|
|
1547
|
+
res.end(JSON.stringify({ updates }));
|
|
1548
|
+
}
|
|
1549
|
+
catch {
|
|
1550
|
+
res.writeHead(500);
|
|
1551
|
+
res.end(JSON.stringify({ error: 'Failed to check for updates' }));
|
|
1552
|
+
}
|
|
1553
|
+
return;
|
|
1554
|
+
}
|
|
1555
|
+
// Test API: Run a single test
|
|
1556
|
+
// Supports modes: 'direct' (call instance method), 'mcp' (call via executeTool), 'cli' (spawn subprocess)
|
|
1557
|
+
if (url.pathname === '/api/test/run' && req.method === 'POST') {
|
|
1558
|
+
let body = '';
|
|
1559
|
+
req.on('data', (chunk) => (body += chunk));
|
|
1560
|
+
req.on('end', async () => {
|
|
1561
|
+
res.setHeader('Content-Type', 'application/json');
|
|
1562
|
+
try {
|
|
1563
|
+
const { photon: photonName, test: testName, mode = 'direct' } = JSON.parse(body);
|
|
1564
|
+
// Find the photon
|
|
1565
|
+
const photon = photons.find((p) => p.name === photonName);
|
|
1566
|
+
if (!photon) {
|
|
1567
|
+
res.writeHead(404);
|
|
1568
|
+
res.end(JSON.stringify({ passed: false, error: 'Photon not found', mode }));
|
|
1569
|
+
return;
|
|
1570
|
+
}
|
|
1571
|
+
// Get the MCP instance
|
|
1572
|
+
const mcp = photonMCPs.get(photonName);
|
|
1573
|
+
if (!mcp || !mcp.instance) {
|
|
1574
|
+
res.writeHead(404);
|
|
1575
|
+
res.end(JSON.stringify({ passed: false, error: 'Photon not loaded', mode }));
|
|
1576
|
+
return;
|
|
1577
|
+
}
|
|
1578
|
+
// Run the test method
|
|
1579
|
+
const start = Date.now();
|
|
1580
|
+
try {
|
|
1581
|
+
let result;
|
|
1582
|
+
if (mode === 'mcp') {
|
|
1583
|
+
// MCP mode: use executeTool to simulate MCP protocol
|
|
1584
|
+
// This tests the full tool execution path
|
|
1585
|
+
result = await loader.executeTool(mcp, testName, {}, {});
|
|
1586
|
+
}
|
|
1587
|
+
else if (mode === 'cli') {
|
|
1588
|
+
// CLI mode: spawn subprocess to test CLI interface
|
|
1589
|
+
const cliPath = path.resolve(__dirname, '..', 'cli.js');
|
|
1590
|
+
const args = ['cli', photonName, testName, '--json', '--dir', workingDir];
|
|
1591
|
+
result = await new Promise((resolve) => {
|
|
1592
|
+
const proc = spawn('node', [cliPath, ...args], {
|
|
1593
|
+
cwd: workingDir,
|
|
1594
|
+
timeout: 30000,
|
|
1595
|
+
env: { ...process.env },
|
|
1596
|
+
});
|
|
1597
|
+
let stdout = '';
|
|
1598
|
+
let stderr = '';
|
|
1599
|
+
proc.stdout.on('data', (data) => (stdout += data.toString()));
|
|
1600
|
+
proc.stderr.on('data', (data) => (stderr += data.toString()));
|
|
1601
|
+
proc.on('close', (code) => {
|
|
1602
|
+
const output = stdout.trim() || stderr.trim();
|
|
1603
|
+
const hasOutput = output.length > 0;
|
|
1604
|
+
const infraErrors = [
|
|
1605
|
+
'Photon not found',
|
|
1606
|
+
'command not found',
|
|
1607
|
+
'Cannot find module',
|
|
1608
|
+
'ENOENT',
|
|
1609
|
+
];
|
|
1610
|
+
const isInfraError = infraErrors.some((e) => (stdout + stderr).includes(e));
|
|
1611
|
+
if (hasOutput && !isInfraError) {
|
|
1612
|
+
// CLI interface worked - transport successful
|
|
1613
|
+
resolve({ passed: true, message: 'CLI interface test passed' });
|
|
1614
|
+
}
|
|
1615
|
+
else if (isInfraError) {
|
|
1616
|
+
resolve({ passed: false, error: `CLI infrastructure error: ${output}` });
|
|
1617
|
+
}
|
|
1618
|
+
else {
|
|
1619
|
+
resolve({
|
|
1620
|
+
passed: false,
|
|
1621
|
+
error: `CLI test failed with code ${code}: no output`,
|
|
1622
|
+
});
|
|
1623
|
+
}
|
|
1624
|
+
});
|
|
1625
|
+
proc.on('error', (err) => {
|
|
1626
|
+
resolve({ passed: false, error: `CLI spawn error: ${err.message}` });
|
|
1627
|
+
});
|
|
1628
|
+
});
|
|
1629
|
+
}
|
|
1630
|
+
else {
|
|
1631
|
+
// Direct mode: call instance method directly
|
|
1632
|
+
result = await mcp.instance[testName]();
|
|
1633
|
+
}
|
|
1634
|
+
const duration = Date.now() - start;
|
|
1635
|
+
// Check result
|
|
1636
|
+
if (result && typeof result === 'object') {
|
|
1637
|
+
if (result.skipped === true) {
|
|
1638
|
+
res.writeHead(200);
|
|
1639
|
+
res.end(JSON.stringify({
|
|
1640
|
+
passed: true,
|
|
1641
|
+
skipped: true,
|
|
1642
|
+
message: result.reason || 'Skipped',
|
|
1643
|
+
duration,
|
|
1644
|
+
mode,
|
|
1645
|
+
}));
|
|
1646
|
+
}
|
|
1647
|
+
else if (result.passed === false) {
|
|
1648
|
+
res.writeHead(200);
|
|
1649
|
+
res.end(JSON.stringify({
|
|
1650
|
+
passed: false,
|
|
1651
|
+
error: result.error || result.message || 'Test failed',
|
|
1652
|
+
duration,
|
|
1653
|
+
mode,
|
|
1654
|
+
}));
|
|
1655
|
+
}
|
|
1656
|
+
else {
|
|
1657
|
+
res.writeHead(200);
|
|
1658
|
+
res.end(JSON.stringify({
|
|
1659
|
+
passed: true,
|
|
1660
|
+
message: result?.message,
|
|
1661
|
+
duration,
|
|
1662
|
+
mode,
|
|
1663
|
+
}));
|
|
1664
|
+
}
|
|
1665
|
+
}
|
|
1666
|
+
else {
|
|
1667
|
+
res.writeHead(200);
|
|
1668
|
+
res.end(JSON.stringify({
|
|
1669
|
+
passed: true,
|
|
1670
|
+
duration,
|
|
1671
|
+
mode,
|
|
1672
|
+
}));
|
|
1673
|
+
}
|
|
1674
|
+
}
|
|
1675
|
+
catch (testError) {
|
|
1676
|
+
const duration = Date.now() - start;
|
|
1677
|
+
res.writeHead(200);
|
|
1678
|
+
res.end(JSON.stringify({
|
|
1679
|
+
passed: false,
|
|
1680
|
+
error: testError.message || String(testError),
|
|
1681
|
+
duration,
|
|
1682
|
+
mode,
|
|
1683
|
+
}));
|
|
1684
|
+
}
|
|
1685
|
+
}
|
|
1686
|
+
catch {
|
|
1687
|
+
res.writeHead(400);
|
|
1688
|
+
res.end(JSON.stringify({ passed: false, error: 'Invalid request' }));
|
|
1689
|
+
}
|
|
1690
|
+
});
|
|
1691
|
+
return;
|
|
1692
|
+
}
|
|
1693
|
+
res.writeHead(404);
|
|
1694
|
+
res.end('Not Found');
|
|
1695
|
+
});
|
|
1696
|
+
// Broadcast photon changes to all connected clients via MCP SSE
|
|
1697
|
+
const broadcastPhotonChange = () => {
|
|
1698
|
+
// MCP Streamable HTTP clients (SSE) get tools/list_changed notification
|
|
1699
|
+
broadcastNotification('notifications/tools/list_changed');
|
|
1700
|
+
// Beam SSE clients get full photons list
|
|
1701
|
+
broadcastToBeam('beam/photons', { photons });
|
|
1702
|
+
};
|
|
1703
|
+
// File watcher for hot reload
|
|
1704
|
+
const watchers = [];
|
|
1705
|
+
const pendingReloads = new Map();
|
|
1706
|
+
// Determine which photon a file change belongs to
|
|
1707
|
+
const getPhotonForPath = (changedPath) => {
|
|
1708
|
+
const relativePath = path.relative(workingDir, changedPath);
|
|
1709
|
+
const parts = relativePath.split(path.sep);
|
|
1710
|
+
// Direct .photon.ts file change
|
|
1711
|
+
if (relativePath.endsWith('.photon.ts')) {
|
|
1712
|
+
return path.basename(relativePath, '.photon.ts');
|
|
1713
|
+
}
|
|
1714
|
+
// Asset folder change - first segment is the photon name
|
|
1715
|
+
if (parts.length > 1) {
|
|
1716
|
+
const folderName = parts[0];
|
|
1717
|
+
// Check if corresponding .photon.ts exists
|
|
1718
|
+
const photon = photons.find((p) => p.name === folderName);
|
|
1719
|
+
if (photon) {
|
|
1720
|
+
return folderName;
|
|
1721
|
+
}
|
|
1722
|
+
}
|
|
1723
|
+
return null;
|
|
1724
|
+
};
|
|
1725
|
+
// Handle file change with debounce
|
|
1726
|
+
const handleFileChange = async (photonName) => {
|
|
1727
|
+
// Clear any pending reload for this photon
|
|
1728
|
+
const pending = pendingReloads.get(photonName);
|
|
1729
|
+
if (pending)
|
|
1730
|
+
clearTimeout(pending);
|
|
1731
|
+
// Debounce - wait 100ms for batch saves
|
|
1732
|
+
pendingReloads.set(photonName, setTimeout(async () => {
|
|
1733
|
+
pendingReloads.delete(photonName);
|
|
1734
|
+
const photonIndex = photons.findIndex((p) => p.name === photonName);
|
|
1735
|
+
const isNewPhoton = photonIndex === -1;
|
|
1736
|
+
const photonPath = isNewPhoton
|
|
1737
|
+
? path.join(workingDir, `${photonName}.photon.ts`)
|
|
1738
|
+
: photons[photonIndex].path;
|
|
1739
|
+
// Handle file deletion - if file no longer exists and photon is in list, remove it
|
|
1740
|
+
if (!isNewPhoton && photonPath && !existsSync(photonPath)) {
|
|
1741
|
+
logger.info(`🗑️ Photon file deleted: ${photonName}`);
|
|
1742
|
+
photons.splice(photonIndex, 1);
|
|
1743
|
+
photonMCPs.delete(photonName);
|
|
1744
|
+
// Also remove from saved config
|
|
1745
|
+
if (savedConfig.photons[photonName]) {
|
|
1746
|
+
delete savedConfig.photons[photonName];
|
|
1747
|
+
await saveConfig(savedConfig);
|
|
1748
|
+
}
|
|
1749
|
+
broadcastPhotonChange();
|
|
1750
|
+
broadcastToBeam('beam/photon-removed', { name: photonName });
|
|
1751
|
+
return;
|
|
1752
|
+
}
|
|
1753
|
+
logger.info(isNewPhoton
|
|
1754
|
+
? `✨ New photon detected: ${photonName}`
|
|
1755
|
+
: `🔄 File change detected, reloading ${photonName}...`);
|
|
1756
|
+
// For new photons, check if configuration is needed first
|
|
1757
|
+
if (isNewPhoton) {
|
|
1758
|
+
const extractor = new SchemaExtractor();
|
|
1759
|
+
let constructorParams = [];
|
|
1760
|
+
try {
|
|
1761
|
+
const source = await fs.readFile(photonPath, 'utf-8');
|
|
1762
|
+
const params = extractor.extractConstructorParams(source);
|
|
1763
|
+
constructorParams = params
|
|
1764
|
+
.filter((p) => p.isPrimitive)
|
|
1765
|
+
.map((p) => ({
|
|
1766
|
+
name: p.name,
|
|
1767
|
+
envVar: toEnvVarName(photonName, p.name),
|
|
1768
|
+
type: p.type,
|
|
1769
|
+
isOptional: p.isOptional,
|
|
1770
|
+
hasDefault: p.hasDefault,
|
|
1771
|
+
defaultValue: p.defaultValue,
|
|
1772
|
+
}));
|
|
1773
|
+
}
|
|
1774
|
+
catch {
|
|
1775
|
+
// Can't extract params, try to load anyway
|
|
1776
|
+
}
|
|
1777
|
+
// Check if any required params are missing
|
|
1778
|
+
const missingRequired = constructorParams.filter((p) => !p.isOptional && !p.hasDefault && !process.env[p.envVar]);
|
|
1779
|
+
if (missingRequired.length > 0 && constructorParams.length > 0) {
|
|
1780
|
+
// Add as unconfigured photon
|
|
1781
|
+
const unconfiguredPhoton = {
|
|
1782
|
+
id: generatePhotonId(photonPath),
|
|
1783
|
+
name: photonName,
|
|
1784
|
+
path: photonPath,
|
|
1785
|
+
configured: false,
|
|
1786
|
+
requiredParams: constructorParams,
|
|
1787
|
+
errorMessage: `Missing required: ${missingRequired.map((p) => p.name).join(', ')}`,
|
|
1788
|
+
};
|
|
1789
|
+
photons.push(unconfiguredPhoton);
|
|
1790
|
+
broadcastPhotonChange();
|
|
1791
|
+
logger.info(`⚙️ ${photonName} added (needs configuration)`);
|
|
1792
|
+
return;
|
|
1793
|
+
}
|
|
1794
|
+
}
|
|
1795
|
+
try {
|
|
1796
|
+
// Load or reload the photon
|
|
1797
|
+
const mcp = isNewPhoton
|
|
1798
|
+
? await loader.loadFile(photonPath)
|
|
1799
|
+
: await loader.reloadFile(photonPath);
|
|
1800
|
+
if (!mcp.instance)
|
|
1801
|
+
throw new Error('Failed to create instance');
|
|
1802
|
+
photonMCPs.set(photonName, mcp);
|
|
1803
|
+
// Re-extract schema - use extractAllFromSource to get both tools and templates
|
|
1804
|
+
const extractor = new SchemaExtractor();
|
|
1805
|
+
const reloadSource = await fs.readFile(photonPath, 'utf-8');
|
|
1806
|
+
const { tools: schemas, templates } = extractor.extractAllFromSource(reloadSource);
|
|
1807
|
+
mcp.schemas = schemas; // Store schemas for result rendering
|
|
1808
|
+
const lifecycleMethods = ['onInitialize', 'onShutdown', 'constructor'];
|
|
1809
|
+
const uiAssets = mcp.assets?.ui || [];
|
|
1810
|
+
const methods = schemas
|
|
1811
|
+
.filter((schema) => !lifecycleMethods.includes(schema.name))
|
|
1812
|
+
.map((schema) => {
|
|
1813
|
+
const linkedAsset = uiAssets.find((ui) => ui.linkedTool === schema.name);
|
|
1814
|
+
return {
|
|
1815
|
+
name: schema.name,
|
|
1816
|
+
description: schema.description || '',
|
|
1817
|
+
params: schema.inputSchema || { type: 'object', properties: {}, required: [] },
|
|
1818
|
+
returns: { type: 'object' },
|
|
1819
|
+
autorun: schema.autorun || false,
|
|
1820
|
+
outputFormat: schema.outputFormat,
|
|
1821
|
+
layoutHints: schema.layoutHints,
|
|
1822
|
+
buttonLabel: schema.buttonLabel,
|
|
1823
|
+
icon: schema.icon,
|
|
1824
|
+
linkedUi: linkedAsset?.id,
|
|
1825
|
+
};
|
|
1826
|
+
});
|
|
1827
|
+
// Add templates as methods
|
|
1828
|
+
templates.forEach((template) => {
|
|
1829
|
+
if (!lifecycleMethods.includes(template.name)) {
|
|
1830
|
+
methods.push({
|
|
1831
|
+
name: template.name,
|
|
1832
|
+
description: template.description || '',
|
|
1833
|
+
params: template.inputSchema || { type: 'object', properties: {}, required: [] },
|
|
1834
|
+
returns: { type: 'object' },
|
|
1835
|
+
isTemplate: true,
|
|
1836
|
+
outputFormat: 'markdown',
|
|
1837
|
+
});
|
|
1838
|
+
}
|
|
1839
|
+
});
|
|
1840
|
+
// Apply @visibility annotations
|
|
1841
|
+
applyMethodVisibility(reloadSource, methods);
|
|
1842
|
+
// Check if this is an App (has main() method with @ui)
|
|
1843
|
+
const mainMethod = methods.find((m) => m.name === 'main' && m.linkedUi);
|
|
1844
|
+
// Extract class metadata from source
|
|
1845
|
+
const reloadClassMeta = extractClassMetadataFromSource(reloadSource);
|
|
1846
|
+
const reloadedPhoton = {
|
|
1847
|
+
id: generatePhotonId(photonPath),
|
|
1848
|
+
name: photonName,
|
|
1849
|
+
path: photonPath,
|
|
1850
|
+
configured: true,
|
|
1851
|
+
methods,
|
|
1852
|
+
isApp: !!mainMethod,
|
|
1853
|
+
appEntry: mainMethod,
|
|
1854
|
+
description: reloadClassMeta.description,
|
|
1855
|
+
icon: reloadClassMeta.icon,
|
|
1856
|
+
internal: reloadClassMeta.internal || (/@internal\b/.test(reloadSource) || undefined),
|
|
1857
|
+
};
|
|
1858
|
+
if (isNewPhoton) {
|
|
1859
|
+
photons.push(reloadedPhoton);
|
|
1860
|
+
broadcastPhotonChange();
|
|
1861
|
+
logger.info(`✅ ${photonName} added`);
|
|
1862
|
+
}
|
|
1863
|
+
else {
|
|
1864
|
+
photons[photonIndex] = reloadedPhoton;
|
|
1865
|
+
logger.info(`📡 Broadcasting hot-reload for ${photonName}`);
|
|
1866
|
+
broadcastToBeam('beam/hot-reload', { photon: reloadedPhoton });
|
|
1867
|
+
broadcastPhotonChange();
|
|
1868
|
+
logger.info(`✅ ${photonName} hot reloaded`);
|
|
1869
|
+
}
|
|
1870
|
+
}
|
|
1871
|
+
catch (error) {
|
|
1872
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
1873
|
+
// For new photons that fail to load, add as unconfigured
|
|
1874
|
+
if (isNewPhoton) {
|
|
1875
|
+
const extractor = new SchemaExtractor();
|
|
1876
|
+
let constructorParams = [];
|
|
1877
|
+
try {
|
|
1878
|
+
const source = await fs.readFile(photonPath, 'utf-8');
|
|
1879
|
+
const params = extractor.extractConstructorParams(source);
|
|
1880
|
+
constructorParams = params
|
|
1881
|
+
.filter((p) => p.isPrimitive)
|
|
1882
|
+
.map((p) => ({
|
|
1883
|
+
name: p.name,
|
|
1884
|
+
envVar: toEnvVarName(photonName, p.name),
|
|
1885
|
+
type: p.type,
|
|
1886
|
+
isOptional: p.isOptional,
|
|
1887
|
+
hasDefault: p.hasDefault,
|
|
1888
|
+
defaultValue: p.defaultValue,
|
|
1889
|
+
}));
|
|
1890
|
+
}
|
|
1891
|
+
catch {
|
|
1892
|
+
// Ignore extraction errors
|
|
1893
|
+
}
|
|
1894
|
+
if (constructorParams.length > 0) {
|
|
1895
|
+
const unconfiguredPhoton = {
|
|
1896
|
+
id: generatePhotonId(photonPath),
|
|
1897
|
+
name: photonName,
|
|
1898
|
+
path: photonPath,
|
|
1899
|
+
configured: false,
|
|
1900
|
+
requiredParams: constructorParams,
|
|
1901
|
+
errorMessage: errorMsg.slice(0, 200),
|
|
1902
|
+
};
|
|
1903
|
+
photons.push(unconfiguredPhoton);
|
|
1904
|
+
broadcastPhotonChange();
|
|
1905
|
+
logger.info(`⚙️ ${photonName} added (needs configuration)`);
|
|
1906
|
+
return;
|
|
1907
|
+
}
|
|
1908
|
+
}
|
|
1909
|
+
logger.error(`Hot reload failed for ${photonName}: ${errorMsg}`);
|
|
1910
|
+
broadcastToBeam('beam/error', {
|
|
1911
|
+
type: 'hot-reload-error',
|
|
1912
|
+
photon: photonName,
|
|
1913
|
+
message: errorMsg.slice(0, 200),
|
|
1914
|
+
});
|
|
1915
|
+
}
|
|
1916
|
+
}, 100));
|
|
1917
|
+
};
|
|
1918
|
+
// Watch working directory recursively
|
|
1919
|
+
try {
|
|
1920
|
+
const watcher = watch(workingDir, { recursive: true }, (eventType, filename) => {
|
|
1921
|
+
if (!filename)
|
|
1922
|
+
return;
|
|
1923
|
+
const fullPath = path.join(workingDir, filename);
|
|
1924
|
+
logger.debug(`📂 File event: ${eventType} ${filename}`);
|
|
1925
|
+
const photonName = getPhotonForPath(fullPath);
|
|
1926
|
+
if (photonName) {
|
|
1927
|
+
logger.info(`📁 Change detected: ${filename} → ${photonName}`);
|
|
1928
|
+
handleFileChange(photonName);
|
|
1929
|
+
}
|
|
1930
|
+
});
|
|
1931
|
+
// Handle watcher errors (e.g., EMFILE: too many open files)
|
|
1932
|
+
watcher.on('error', (err) => {
|
|
1933
|
+
logger.warn(`File watcher error (continuing without hot-reload): ${err.message}`);
|
|
1934
|
+
try {
|
|
1935
|
+
watcher.close();
|
|
1936
|
+
}
|
|
1937
|
+
catch {
|
|
1938
|
+
// Ignore close errors
|
|
1939
|
+
}
|
|
1940
|
+
});
|
|
1941
|
+
watchers.push(watcher);
|
|
1942
|
+
logger.info(`👀 Watching for changes in ${workingDir}`);
|
|
1943
|
+
}
|
|
1944
|
+
catch (error) {
|
|
1945
|
+
logger.warn(`File watching not available: ${error}`);
|
|
1946
|
+
}
|
|
1947
|
+
// Symlinked and bundled photon watchers are set up after photon loading (see below)
|
|
1948
|
+
// Bind to 0.0.0.0 for tunnel access, with port fallback
|
|
1949
|
+
// Start server BEFORE loading photons so the UI is immediately reachable
|
|
1950
|
+
const maxPortAttempts = 10;
|
|
1951
|
+
let currentPort = port;
|
|
1952
|
+
await new Promise((resolve) => {
|
|
1953
|
+
const tryListen = () => {
|
|
1954
|
+
server.once('error', (err) => {
|
|
1955
|
+
if (err.code === 'EADDRINUSE' && currentPort < port + maxPortAttempts) {
|
|
1956
|
+
currentPort++;
|
|
1957
|
+
console.error(`⚠️ Port ${currentPort - 1} is in use, trying ${currentPort}...`);
|
|
1958
|
+
tryListen();
|
|
1959
|
+
}
|
|
1960
|
+
else if (err.code === 'EADDRINUSE') {
|
|
1961
|
+
console.error(`\n❌ No available port found (tried ${port}-${currentPort}). Exiting.\n`);
|
|
1962
|
+
process.exit(1);
|
|
1963
|
+
}
|
|
1964
|
+
else {
|
|
1965
|
+
console.error(`\n❌ Server error: ${err.message}\n`);
|
|
1966
|
+
process.exit(1);
|
|
1967
|
+
}
|
|
1968
|
+
});
|
|
1969
|
+
server.listen(currentPort, '0.0.0.0', () => {
|
|
1970
|
+
process.env.BEAM_PORT = String(currentPort);
|
|
1971
|
+
const url = `http://localhost:${currentPort}`;
|
|
1972
|
+
console.log(`\n⚡ Photon Beam → ${url} (loading photons...)\n`);
|
|
1973
|
+
resolve();
|
|
1974
|
+
});
|
|
1975
|
+
};
|
|
1976
|
+
tryListen();
|
|
1977
|
+
});
|
|
1978
|
+
// Load photons in parallel batches (server is already listening)
|
|
1979
|
+
const LOAD_CONCURRENCY = 4;
|
|
1980
|
+
for (let i = 0; i < photonList.length; i += LOAD_CONCURRENCY) {
|
|
1981
|
+
const batch = photonList.slice(i, i + LOAD_CONCURRENCY);
|
|
1982
|
+
const results = await Promise.allSettled(batch.map((name) => loadSinglePhoton(name)));
|
|
1983
|
+
for (const result of results) {
|
|
1984
|
+
if (result.status === 'fulfilled' && result.value) {
|
|
1985
|
+
photons.push(result.value);
|
|
1986
|
+
}
|
|
1987
|
+
}
|
|
1988
|
+
}
|
|
1989
|
+
configuredCount = photons.filter((p) => p.configured).length;
|
|
1990
|
+
unconfiguredCount = photons.filter((p) => !p.configured).length;
|
|
1991
|
+
const status = unconfiguredCount > 0
|
|
1992
|
+
? `${configuredCount} ready, ${unconfiguredCount} need setup`
|
|
1993
|
+
: `${configuredCount} photon${configuredCount !== 1 ? 's' : ''} ready`;
|
|
1994
|
+
console.log(`⚡ Photon Beam ready (${status})`);
|
|
1995
|
+
// Notify connected clients that photon list is now available
|
|
1996
|
+
broadcastPhotonChange();
|
|
1997
|
+
// Set up file watchers for symlinked and bundled photon assets (now that photons are loaded)
|
|
1998
|
+
for (const photon of photons) {
|
|
1999
|
+
if (!photon.path) {
|
|
2000
|
+
logger.debug(`⏭️ Skipping ${photon.name}: no path`);
|
|
2001
|
+
continue;
|
|
2002
|
+
}
|
|
2003
|
+
try {
|
|
2004
|
+
const stat = lstatSync(photon.path);
|
|
2005
|
+
if (stat.isSymbolicLink()) {
|
|
2006
|
+
const realPath = realpathSync(photon.path);
|
|
2007
|
+
const realDir = path.dirname(realPath);
|
|
2008
|
+
const assetFolder = path.join(realDir, photon.name);
|
|
2009
|
+
if (existsSync(assetFolder)) {
|
|
2010
|
+
const assetWatcher = watch(assetFolder, { recursive: true }, (eventType, filename) => {
|
|
2011
|
+
if (filename) {
|
|
2012
|
+
if (filename.endsWith('.json') ||
|
|
2013
|
+
filename.startsWith('boards/') ||
|
|
2014
|
+
filename === 'data.json') {
|
|
2015
|
+
logger.debug(`⏭️ Ignoring data file change: ${photon.name}/${filename}`);
|
|
2016
|
+
return;
|
|
2017
|
+
}
|
|
2018
|
+
logger.info(`📁 Asset change detected: ${photon.name}/${filename}`);
|
|
2019
|
+
handleFileChange(photon.name);
|
|
2020
|
+
}
|
|
2021
|
+
});
|
|
2022
|
+
assetWatcher.on('error', (err) => {
|
|
2023
|
+
logger.warn(`Watcher error for ${photon.name}/: ${err.message}`);
|
|
2024
|
+
});
|
|
2025
|
+
watchers.push(assetWatcher);
|
|
2026
|
+
logger.info(`👀 Watching ${photon.name}/ (symlinked → ${assetFolder})`);
|
|
2027
|
+
}
|
|
2028
|
+
else {
|
|
2029
|
+
logger.debug(`⏭️ Skipping ${photon.name}: asset folder not found at ${assetFolder}`);
|
|
2030
|
+
}
|
|
2031
|
+
}
|
|
2032
|
+
else {
|
|
2033
|
+
logger.debug(`⏭️ Skipping ${photon.name}: not a symlink`);
|
|
2034
|
+
}
|
|
2035
|
+
}
|
|
2036
|
+
catch (err) {
|
|
2037
|
+
logger.debug(`⏭️ Skipping ${photon.name}: ${err instanceof Error ? err.message : err}`);
|
|
2038
|
+
}
|
|
2039
|
+
}
|
|
2040
|
+
// Watch bundled photon asset folders
|
|
2041
|
+
for (const [photonName, photonPath] of bundledPhotonPaths) {
|
|
2042
|
+
const photonDir = path.dirname(photonPath);
|
|
2043
|
+
const isInWorkingDir = photonDir.startsWith(workingDir);
|
|
2044
|
+
if (isInWorkingDir) {
|
|
2045
|
+
const assetFolder = path.join(photonDir, photonName);
|
|
2046
|
+
if (existsSync(assetFolder)) {
|
|
2047
|
+
logger.info(`👀 Watching ${photonName}/ via main watcher`);
|
|
2048
|
+
}
|
|
2049
|
+
continue;
|
|
2050
|
+
}
|
|
2051
|
+
try {
|
|
2052
|
+
const photonWatcher = watch(photonPath, (eventType) => {
|
|
2053
|
+
if (eventType === 'change') {
|
|
2054
|
+
handleFileChange(photonName);
|
|
2055
|
+
}
|
|
2056
|
+
});
|
|
2057
|
+
photonWatcher.on('error', () => { });
|
|
2058
|
+
watchers.push(photonWatcher);
|
|
2059
|
+
}
|
|
2060
|
+
catch {
|
|
2061
|
+
// Ignore errors
|
|
2062
|
+
}
|
|
2063
|
+
const assetFolder = path.join(photonDir, photonName);
|
|
2064
|
+
try {
|
|
2065
|
+
const assetWatcher = watch(assetFolder, { recursive: true }, (eventType, filename) => {
|
|
2066
|
+
if (filename) {
|
|
2067
|
+
if (filename.endsWith('.json') ||
|
|
2068
|
+
filename.startsWith('boards/') ||
|
|
2069
|
+
filename === 'data.json') {
|
|
2070
|
+
logger.debug(`⏭️ Ignoring data file change: ${photonName}/${filename}`);
|
|
2071
|
+
return;
|
|
2072
|
+
}
|
|
2073
|
+
logger.info(`📁 Asset change detected: ${photonName}/${filename}`);
|
|
2074
|
+
handleFileChange(photonName);
|
|
2075
|
+
}
|
|
2076
|
+
});
|
|
2077
|
+
assetWatcher.on('error', () => { });
|
|
2078
|
+
watchers.push(assetWatcher);
|
|
2079
|
+
logger.info(`👀 Watching ${photonName}/ for asset changes`);
|
|
2080
|
+
}
|
|
2081
|
+
catch {
|
|
2082
|
+
// Asset folder doesn't exist or can't be watched - that's okay
|
|
2083
|
+
}
|
|
2084
|
+
}
|
|
2085
|
+
}
|
|
2086
|
+
/**
|
|
2087
|
+
* Configure a photon via MCP
|
|
2088
|
+
*/
|
|
2089
|
+
async function configurePhotonViaMCP(photonName, config, photons, photonMCPs, loader, savedConfig) {
|
|
2090
|
+
// Find the unconfigured photon
|
|
2091
|
+
const photonIndex = photons.findIndex((p) => p.name === photonName && !p.configured);
|
|
2092
|
+
if (photonIndex === -1) {
|
|
2093
|
+
return { success: false, error: `Photon not found or already configured: ${photonName}` };
|
|
2094
|
+
}
|
|
2095
|
+
const unconfiguredPhoton = photons[photonIndex];
|
|
2096
|
+
// Apply config to environment
|
|
2097
|
+
for (const [key, value] of Object.entries(config)) {
|
|
2098
|
+
process.env[key] = String(value);
|
|
2099
|
+
}
|
|
2100
|
+
// Save config to file
|
|
2101
|
+
savedConfig.photons[photonName] = config;
|
|
2102
|
+
await saveConfig(savedConfig);
|
|
2103
|
+
// Try to reload the photon
|
|
2104
|
+
try {
|
|
2105
|
+
const mcp = await loader.loadFile(unconfiguredPhoton.path);
|
|
2106
|
+
const instance = mcp.instance;
|
|
2107
|
+
if (!instance) {
|
|
2108
|
+
throw new Error('Failed to create instance');
|
|
2109
|
+
}
|
|
2110
|
+
photonMCPs.set(photonName, mcp);
|
|
2111
|
+
// Extract schema for UI
|
|
2112
|
+
const extractor = new SchemaExtractor();
|
|
2113
|
+
const configSource = await fs.readFile(unconfiguredPhoton.path, 'utf-8');
|
|
2114
|
+
const { tools: schemas, templates } = extractor.extractAllFromSource(configSource);
|
|
2115
|
+
mcp.schemas = schemas;
|
|
2116
|
+
// Get UI assets for linking
|
|
2117
|
+
const uiAssets = mcp.assets?.ui || [];
|
|
2118
|
+
const lifecycleMethods = ['onInitialize', 'onShutdown', 'constructor'];
|
|
2119
|
+
const methods = schemas
|
|
2120
|
+
.filter((schema) => !lifecycleMethods.includes(schema.name))
|
|
2121
|
+
.map((schema) => {
|
|
2122
|
+
const linkedAsset = uiAssets.find((ui) => ui.linkedTool === schema.name);
|
|
2123
|
+
return {
|
|
2124
|
+
name: schema.name,
|
|
2125
|
+
description: schema.description || '',
|
|
2126
|
+
params: schema.inputSchema || { type: 'object', properties: {}, required: [] },
|
|
2127
|
+
returns: { type: 'object' },
|
|
2128
|
+
autorun: schema.autorun || false,
|
|
2129
|
+
outputFormat: schema.outputFormat,
|
|
2130
|
+
layoutHints: schema.layoutHints,
|
|
2131
|
+
buttonLabel: schema.buttonLabel,
|
|
2132
|
+
icon: schema.icon,
|
|
2133
|
+
linkedUi: linkedAsset?.id,
|
|
2134
|
+
};
|
|
2135
|
+
});
|
|
2136
|
+
// Add templates as methods
|
|
2137
|
+
templates.forEach((template) => {
|
|
2138
|
+
if (!lifecycleMethods.includes(template.name)) {
|
|
2139
|
+
methods.push({
|
|
2140
|
+
name: template.name,
|
|
2141
|
+
description: template.description || '',
|
|
2142
|
+
params: template.inputSchema || { type: 'object', properties: {}, required: [] },
|
|
2143
|
+
returns: { type: 'object' },
|
|
2144
|
+
isTemplate: true,
|
|
2145
|
+
outputFormat: 'markdown',
|
|
2146
|
+
});
|
|
2147
|
+
}
|
|
2148
|
+
});
|
|
2149
|
+
// Apply @visibility annotations
|
|
2150
|
+
applyMethodVisibility(configSource, methods);
|
|
2151
|
+
// Check if this is an App
|
|
2152
|
+
const mainMethod = methods.find((m) => m.name === 'main' && m.linkedUi);
|
|
2153
|
+
const isApp = !!mainMethod;
|
|
2154
|
+
// Replace unconfigured photon with configured one
|
|
2155
|
+
const configuredPhoton = {
|
|
2156
|
+
id: generatePhotonId(unconfiguredPhoton.path),
|
|
2157
|
+
name: photonName,
|
|
2158
|
+
path: unconfiguredPhoton.path,
|
|
2159
|
+
configured: true,
|
|
2160
|
+
methods,
|
|
2161
|
+
isApp,
|
|
2162
|
+
appEntry: mainMethod,
|
|
2163
|
+
assets: mcp.assets,
|
|
2164
|
+
};
|
|
2165
|
+
photons[photonIndex] = configuredPhoton;
|
|
2166
|
+
logger.info(`✅ ${photonName} configured via MCP`);
|
|
2167
|
+
// Notify connected MCP clients about tools list change
|
|
2168
|
+
broadcastNotification('notifications/tools/list_changed', {});
|
|
2169
|
+
broadcastToBeam('beam/configured', { photon: configuredPhoton });
|
|
2170
|
+
return { success: true };
|
|
2171
|
+
}
|
|
2172
|
+
catch (error) {
|
|
2173
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
2174
|
+
logger.error(`Failed to configure ${photonName} via MCP: ${errorMsg}`);
|
|
2175
|
+
return { success: false, error: errorMsg };
|
|
2176
|
+
}
|
|
2177
|
+
}
|
|
2178
|
+
/**
|
|
2179
|
+
* Reload a photon via MCP
|
|
2180
|
+
*/
|
|
2181
|
+
async function reloadPhotonViaMCP(photonName, photons, photonMCPs, loader, savedConfig, broadcastChange) {
|
|
2182
|
+
// Find the photon
|
|
2183
|
+
const photonIndex = photons.findIndex((p) => p.name === photonName);
|
|
2184
|
+
if (photonIndex === -1) {
|
|
2185
|
+
return { success: false, error: `Photon not found: ${photonName}` };
|
|
2186
|
+
}
|
|
2187
|
+
const photon = photons[photonIndex];
|
|
2188
|
+
const photonPath = photon.path;
|
|
2189
|
+
// Get saved config for this photon
|
|
2190
|
+
const config = savedConfig.photons[photonName] || {};
|
|
2191
|
+
// Apply config to environment
|
|
2192
|
+
for (const [key, value] of Object.entries(config)) {
|
|
2193
|
+
process.env[key] = value;
|
|
2194
|
+
}
|
|
2195
|
+
try {
|
|
2196
|
+
// Reload the photon (clears compiled cache for hot reload)
|
|
2197
|
+
const mcp = await loader.reloadFile(photonPath);
|
|
2198
|
+
const instance = mcp.instance;
|
|
2199
|
+
if (!instance) {
|
|
2200
|
+
throw new Error('Failed to create instance');
|
|
2201
|
+
}
|
|
2202
|
+
photonMCPs.set(photonName, mcp);
|
|
2203
|
+
// Extract schema for UI
|
|
2204
|
+
const extractor = new SchemaExtractor();
|
|
2205
|
+
const reloadSrc = await fs.readFile(photonPath, 'utf-8');
|
|
2206
|
+
const { tools: schemas, templates } = extractor.extractAllFromSource(reloadSrc);
|
|
2207
|
+
mcp.schemas = schemas;
|
|
2208
|
+
const lifecycleMethods = ['onInitialize', 'onShutdown', 'constructor'];
|
|
2209
|
+
const uiAssets = mcp.assets?.ui || [];
|
|
2210
|
+
const methods = schemas
|
|
2211
|
+
.filter((schema) => !lifecycleMethods.includes(schema.name))
|
|
2212
|
+
.map((schema) => {
|
|
2213
|
+
const linkedAsset = uiAssets.find((ui) => ui.linkedTool === schema.name);
|
|
2214
|
+
return {
|
|
2215
|
+
name: schema.name,
|
|
2216
|
+
description: schema.description || '',
|
|
2217
|
+
params: schema.inputSchema || { type: 'object', properties: {}, required: [] },
|
|
2218
|
+
returns: { type: 'object' },
|
|
2219
|
+
autorun: schema.autorun || false,
|
|
2220
|
+
outputFormat: schema.outputFormat,
|
|
2221
|
+
layoutHints: schema.layoutHints,
|
|
2222
|
+
buttonLabel: schema.buttonLabel,
|
|
2223
|
+
icon: schema.icon,
|
|
2224
|
+
linkedUi: linkedAsset?.id,
|
|
2225
|
+
};
|
|
2226
|
+
});
|
|
2227
|
+
// Add templates as methods
|
|
2228
|
+
templates.forEach((template) => {
|
|
2229
|
+
if (!lifecycleMethods.includes(template.name)) {
|
|
2230
|
+
methods.push({
|
|
2231
|
+
name: template.name,
|
|
2232
|
+
description: template.description || '',
|
|
2233
|
+
params: template.inputSchema || { type: 'object', properties: {}, required: [] },
|
|
2234
|
+
returns: { type: 'object' },
|
|
2235
|
+
isTemplate: true,
|
|
2236
|
+
outputFormat: 'markdown',
|
|
2237
|
+
});
|
|
2238
|
+
}
|
|
2239
|
+
});
|
|
2240
|
+
// Apply @visibility annotations
|
|
2241
|
+
applyMethodVisibility(reloadSrc, methods);
|
|
2242
|
+
// Check if this is an App
|
|
2243
|
+
const mainMethod = methods.find((m) => m.name === 'main' && m.linkedUi);
|
|
2244
|
+
// Extract class metadata from source
|
|
2245
|
+
const reloadClassMeta = extractClassMetadataFromSource(reloadSrc);
|
|
2246
|
+
// Update photon info
|
|
2247
|
+
const reloadedPhoton = {
|
|
2248
|
+
id: generatePhotonId(photonPath),
|
|
2249
|
+
name: photonName,
|
|
2250
|
+
path: photonPath,
|
|
2251
|
+
configured: true,
|
|
2252
|
+
methods,
|
|
2253
|
+
isApp: !!mainMethod,
|
|
2254
|
+
appEntry: mainMethod,
|
|
2255
|
+
description: reloadClassMeta.description,
|
|
2256
|
+
icon: reloadClassMeta.icon,
|
|
2257
|
+
internal: reloadClassMeta.internal || (/@internal\b/.test(reloadSrc) || undefined),
|
|
2258
|
+
};
|
|
2259
|
+
photons[photonIndex] = reloadedPhoton;
|
|
2260
|
+
logger.info(`🔄 ${photonName} reloaded via MCP`);
|
|
2261
|
+
// Notify clients about the change
|
|
2262
|
+
broadcastChange();
|
|
2263
|
+
return { success: true, photon: reloadedPhoton };
|
|
2264
|
+
}
|
|
2265
|
+
catch (error) {
|
|
2266
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
2267
|
+
logger.error(`Failed to reload ${photonName} via MCP: ${errorMsg}`);
|
|
2268
|
+
return { success: false, error: errorMsg };
|
|
2269
|
+
}
|
|
2270
|
+
}
|
|
2271
|
+
/**
|
|
2272
|
+
* Remove a photon via MCP
|
|
2273
|
+
*/
|
|
2274
|
+
async function removePhotonViaMCP(photonName, photons, photonMCPs, savedConfig, broadcastChange) {
|
|
2275
|
+
// Find and remove the photon
|
|
2276
|
+
const photonIndex = photons.findIndex((p) => p.name === photonName);
|
|
2277
|
+
if (photonIndex === -1) {
|
|
2278
|
+
return { success: false, error: `Photon not found: ${photonName}` };
|
|
2279
|
+
}
|
|
2280
|
+
// Remove from arrays and maps
|
|
2281
|
+
photons.splice(photonIndex, 1);
|
|
2282
|
+
photonMCPs.delete(photonName);
|
|
2283
|
+
// Remove saved config
|
|
2284
|
+
if (savedConfig.photons[photonName]) {
|
|
2285
|
+
delete savedConfig.photons[photonName];
|
|
2286
|
+
await saveConfig(savedConfig);
|
|
2287
|
+
}
|
|
2288
|
+
logger.info(`🗑️ ${photonName} removed via MCP`);
|
|
2289
|
+
// Notify clients about the change
|
|
2290
|
+
broadcastChange();
|
|
2291
|
+
return { success: true };
|
|
2292
|
+
}
|
|
2293
|
+
/**
|
|
2294
|
+
* Update photon or method metadata via MCP
|
|
2295
|
+
*/
|
|
2296
|
+
async function updateMetadataViaMCP(photonName, methodName, metadata, photons) {
|
|
2297
|
+
// Find the photon
|
|
2298
|
+
const photonIndex = photons.findIndex((p) => p.name === photonName);
|
|
2299
|
+
if (photonIndex === -1) {
|
|
2300
|
+
return { success: false, error: `Photon not found: ${photonName}` };
|
|
2301
|
+
}
|
|
2302
|
+
const photon = photons[photonIndex];
|
|
2303
|
+
if (methodName) {
|
|
2304
|
+
// Update method metadata
|
|
2305
|
+
if (!photon.configured || !photon.methods) {
|
|
2306
|
+
return { success: false, error: 'Photon is not configured or has no methods' };
|
|
2307
|
+
}
|
|
2308
|
+
const method = photon.methods.find((m) => m.name === methodName);
|
|
2309
|
+
if (!method) {
|
|
2310
|
+
return { success: false, error: `Method not found: ${methodName}` };
|
|
2311
|
+
}
|
|
2312
|
+
// Update method metadata
|
|
2313
|
+
if (metadata.description !== undefined) {
|
|
2314
|
+
method.description = metadata.description;
|
|
2315
|
+
}
|
|
2316
|
+
if (metadata.icon !== undefined) {
|
|
2317
|
+
method.icon = metadata.icon;
|
|
2318
|
+
}
|
|
2319
|
+
logger.info(`📝 Updated metadata for ${photonName}/${methodName}`);
|
|
2320
|
+
}
|
|
2321
|
+
else {
|
|
2322
|
+
// Update photon metadata
|
|
2323
|
+
if (metadata.description !== undefined) {
|
|
2324
|
+
photon.description = metadata.description;
|
|
2325
|
+
}
|
|
2326
|
+
if (metadata.icon !== undefined) {
|
|
2327
|
+
photon.icon = metadata.icon;
|
|
2328
|
+
}
|
|
2329
|
+
logger.info(`📝 Updated metadata for ${photonName}`);
|
|
2330
|
+
}
|
|
2331
|
+
return { success: true };
|
|
2332
|
+
}
|
|
2333
|
+
/**
|
|
2334
|
+
* Generate rich help markdown for a photon using PhotonDocExtractor + TemplateManager.
|
|
2335
|
+
* Checks for an existing .md file first; generates and saves one if missing.
|
|
2336
|
+
*/
|
|
2337
|
+
async function generatePhotonHelpMarkdown(photonName, photons) {
|
|
2338
|
+
const photon = photons.find((p) => p.name === photonName);
|
|
2339
|
+
if (!photon) {
|
|
2340
|
+
throw new Error(`Photon not found: ${photonName}`);
|
|
2341
|
+
}
|
|
2342
|
+
if (!photon.path) {
|
|
2343
|
+
throw new Error(`Photon path not available: ${photonName}`);
|
|
2344
|
+
}
|
|
2345
|
+
const sourceDir = path.dirname(photon.path);
|
|
2346
|
+
const mdPath = path.join(sourceDir, `${photonName}.md`);
|
|
2347
|
+
// Check if .md file already exists and is newer than the photon source
|
|
2348
|
+
try {
|
|
2349
|
+
const [mdStat, srcStat] = await Promise.all([
|
|
2350
|
+
fs.stat(mdPath),
|
|
2351
|
+
fs.stat(photon.path),
|
|
2352
|
+
]);
|
|
2353
|
+
if (mdStat.mtimeMs >= srcStat.mtimeMs) {
|
|
2354
|
+
const existing = await fs.readFile(mdPath, 'utf-8');
|
|
2355
|
+
if (existing.trim()) {
|
|
2356
|
+
return existing;
|
|
2357
|
+
}
|
|
2358
|
+
}
|
|
2359
|
+
}
|
|
2360
|
+
catch {
|
|
2361
|
+
// .md doesn't exist or stat failed - regenerate
|
|
2362
|
+
}
|
|
2363
|
+
// Extract metadata and render template
|
|
2364
|
+
const extractor = new PhotonDocExtractor(photon.path);
|
|
2365
|
+
const metadata = await extractor.extractFullMetadata();
|
|
2366
|
+
// Use TemplateManager to render the photon.md template
|
|
2367
|
+
const templateMgr = new TemplateManager(sourceDir);
|
|
2368
|
+
await templateMgr.ensureTemplates();
|
|
2369
|
+
const markdown = await templateMgr.renderTemplate('photon.md', metadata);
|
|
2370
|
+
// Try to save the generated .md file for future use
|
|
2371
|
+
try {
|
|
2372
|
+
await fs.writeFile(mdPath, markdown, 'utf-8');
|
|
2373
|
+
logger.info(`📄 Generated help doc: ${mdPath}`);
|
|
2374
|
+
}
|
|
2375
|
+
catch {
|
|
2376
|
+
// Write may fail for bundled/read-only photons - that's fine
|
|
2377
|
+
logger.debug(`Could not save help doc to ${mdPath} (read-only?)`);
|
|
2378
|
+
}
|
|
2379
|
+
return markdown;
|
|
2380
|
+
}
|
|
2381
|
+
//# sourceMappingURL=beam.js.map
|