@portel/photon 1.9.0 → 1.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (294) hide show
  1. package/README.md +163 -210
  2. package/dist/async/dedup-map.d.ts +40 -0
  3. package/dist/async/dedup-map.d.ts.map +1 -0
  4. package/dist/async/dedup-map.js +80 -0
  5. package/dist/async/dedup-map.js.map +1 -0
  6. package/dist/async/index.d.ts +11 -0
  7. package/dist/async/index.d.ts.map +1 -0
  8. package/dist/async/index.js +11 -0
  9. package/dist/async/index.js.map +1 -0
  10. package/dist/async/loading-gate.d.ts +27 -0
  11. package/dist/async/loading-gate.d.ts.map +1 -0
  12. package/dist/async/loading-gate.js +48 -0
  13. package/dist/async/loading-gate.js.map +1 -0
  14. package/dist/async/with-timeout.d.ts +6 -0
  15. package/dist/async/with-timeout.d.ts.map +1 -0
  16. package/dist/async/with-timeout.js +17 -0
  17. package/dist/async/with-timeout.js.map +1 -0
  18. package/dist/auto-ui/beam/class-metadata.d.ts +52 -0
  19. package/dist/auto-ui/beam/class-metadata.d.ts.map +1 -0
  20. package/dist/auto-ui/beam/class-metadata.js +133 -0
  21. package/dist/auto-ui/beam/class-metadata.js.map +1 -0
  22. package/dist/auto-ui/beam/config.d.ts +13 -0
  23. package/dist/auto-ui/beam/config.d.ts.map +1 -0
  24. package/dist/auto-ui/beam/config.js +52 -0
  25. package/dist/auto-ui/beam/config.js.map +1 -0
  26. package/dist/auto-ui/beam/external-mcp.d.ts +37 -0
  27. package/dist/auto-ui/beam/external-mcp.d.ts.map +1 -0
  28. package/dist/auto-ui/beam/external-mcp.js +311 -0
  29. package/dist/auto-ui/beam/external-mcp.js.map +1 -0
  30. package/dist/auto-ui/beam/photon-management.d.ts +51 -0
  31. package/dist/auto-ui/beam/photon-management.d.ts.map +1 -0
  32. package/dist/auto-ui/beam/photon-management.js +310 -0
  33. package/dist/auto-ui/beam/photon-management.js.map +1 -0
  34. package/dist/auto-ui/beam/routes/api-browse.d.ts +17 -0
  35. package/dist/auto-ui/beam/routes/api-browse.d.ts.map +1 -0
  36. package/dist/auto-ui/beam/routes/api-browse.js +531 -0
  37. package/dist/auto-ui/beam/routes/api-browse.js.map +1 -0
  38. package/dist/auto-ui/beam/routes/api-config.d.ts +9 -0
  39. package/dist/auto-ui/beam/routes/api-config.d.ts.map +1 -0
  40. package/dist/auto-ui/beam/routes/api-config.js +494 -0
  41. package/dist/auto-ui/beam/routes/api-config.js.map +1 -0
  42. package/dist/auto-ui/beam/routes/api-marketplace.d.ts +8 -0
  43. package/dist/auto-ui/beam/routes/api-marketplace.d.ts.map +1 -0
  44. package/dist/auto-ui/beam/routes/api-marketplace.js +490 -0
  45. package/dist/auto-ui/beam/routes/api-marketplace.js.map +1 -0
  46. package/dist/auto-ui/beam/startup.d.ts +41 -0
  47. package/dist/auto-ui/beam/startup.d.ts.map +1 -0
  48. package/dist/auto-ui/beam/startup.js +98 -0
  49. package/dist/auto-ui/beam/startup.js.map +1 -0
  50. package/dist/auto-ui/beam/subscription.d.ts +35 -0
  51. package/dist/auto-ui/beam/subscription.d.ts.map +1 -0
  52. package/dist/auto-ui/beam/subscription.js +151 -0
  53. package/dist/auto-ui/beam/subscription.js.map +1 -0
  54. package/dist/auto-ui/beam/types.d.ts +103 -0
  55. package/dist/auto-ui/beam/types.d.ts.map +1 -0
  56. package/dist/auto-ui/beam/types.js +8 -0
  57. package/dist/auto-ui/beam/types.js.map +1 -0
  58. package/dist/auto-ui/beam.d.ts +2 -0
  59. package/dist/auto-ui/beam.d.ts.map +1 -1
  60. package/dist/auto-ui/beam.js +729 -2596
  61. package/dist/auto-ui/beam.js.map +1 -1
  62. package/dist/auto-ui/bridge/index.d.ts.map +1 -1
  63. package/dist/auto-ui/bridge/index.js +10 -2
  64. package/dist/auto-ui/bridge/index.js.map +1 -1
  65. package/dist/auto-ui/components/card.d.ts.map +1 -1
  66. package/dist/auto-ui/components/card.js +3 -1
  67. package/dist/auto-ui/components/card.js.map +1 -1
  68. package/dist/auto-ui/components/progress.d.ts.map +1 -1
  69. package/dist/auto-ui/components/progress.js.map +1 -1
  70. package/dist/auto-ui/daemon-tools.d.ts +1 -1
  71. package/dist/auto-ui/daemon-tools.d.ts.map +1 -1
  72. package/dist/auto-ui/daemon-tools.js +4 -3
  73. package/dist/auto-ui/daemon-tools.js.map +1 -1
  74. package/dist/auto-ui/photon-bridge.d.ts +6 -2
  75. package/dist/auto-ui/photon-bridge.d.ts.map +1 -1
  76. package/dist/auto-ui/photon-bridge.js +20 -8
  77. package/dist/auto-ui/photon-bridge.js.map +1 -1
  78. package/dist/auto-ui/platform-compat.d.ts.map +1 -1
  79. package/dist/auto-ui/platform-compat.js +4 -0
  80. package/dist/auto-ui/platform-compat.js.map +1 -1
  81. package/dist/auto-ui/streamable-http-transport.d.ts +4 -2
  82. package/dist/auto-ui/streamable-http-transport.d.ts.map +1 -1
  83. package/dist/auto-ui/streamable-http-transport.js +120 -30
  84. package/dist/auto-ui/streamable-http-transport.js.map +1 -1
  85. package/dist/auto-ui/types.d.ts +4 -2
  86. package/dist/auto-ui/types.d.ts.map +1 -1
  87. package/dist/auto-ui/types.js.map +1 -1
  88. package/dist/beam.bundle.js +8225 -3999
  89. package/dist/beam.bundle.js.map +4 -4
  90. package/dist/cli/commands/alias.d.ts +14 -0
  91. package/dist/cli/commands/alias.d.ts.map +1 -0
  92. package/dist/cli/commands/alias.js +41 -0
  93. package/dist/cli/commands/alias.js.map +1 -0
  94. package/dist/cli/commands/audit.d.ts +9 -0
  95. package/dist/cli/commands/audit.d.ts.map +1 -0
  96. package/dist/cli/commands/audit.js +377 -0
  97. package/dist/cli/commands/audit.js.map +1 -0
  98. package/dist/cli/commands/beam.d.ts +20 -0
  99. package/dist/cli/commands/beam.d.ts.map +1 -0
  100. package/dist/cli/commands/beam.js +256 -0
  101. package/dist/cli/commands/beam.js.map +1 -0
  102. package/dist/cli/commands/config.d.ts +14 -0
  103. package/dist/cli/commands/config.d.ts.map +1 -0
  104. package/dist/cli/commands/config.js +165 -0
  105. package/dist/cli/commands/config.js.map +1 -0
  106. package/dist/cli/commands/daemon.d.ts +11 -0
  107. package/dist/cli/commands/daemon.d.ts.map +1 -0
  108. package/dist/cli/commands/daemon.js +108 -0
  109. package/dist/cli/commands/daemon.js.map +1 -0
  110. package/dist/cli/commands/doctor.d.ts +14 -0
  111. package/dist/cli/commands/doctor.d.ts.map +1 -0
  112. package/dist/cli/commands/doctor.js +257 -0
  113. package/dist/cli/commands/doctor.js.map +1 -0
  114. package/dist/cli/commands/host.d.ts +11 -0
  115. package/dist/cli/commands/host.d.ts.map +1 -0
  116. package/dist/cli/commands/host.js +96 -0
  117. package/dist/cli/commands/host.js.map +1 -0
  118. package/dist/cli/commands/info.d.ts +1 -1
  119. package/dist/cli/commands/info.d.ts.map +1 -1
  120. package/dist/cli/commands/info.js +16 -15
  121. package/dist/cli/commands/info.js.map +1 -1
  122. package/dist/cli/commands/init.d.ts +20 -0
  123. package/dist/cli/commands/init.d.ts.map +1 -0
  124. package/dist/cli/commands/init.js +774 -0
  125. package/dist/cli/commands/init.js.map +1 -0
  126. package/dist/cli/commands/maker.d.ts +12 -0
  127. package/dist/cli/commands/maker.d.ts.map +1 -0
  128. package/dist/cli/commands/maker.js +605 -0
  129. package/dist/cli/commands/maker.js.map +1 -0
  130. package/dist/cli/commands/mcp.d.ts +27 -0
  131. package/dist/cli/commands/mcp.d.ts.map +1 -0
  132. package/dist/cli/commands/mcp.js +390 -0
  133. package/dist/cli/commands/mcp.js.map +1 -0
  134. package/dist/cli/commands/package-app.d.ts +1 -1
  135. package/dist/cli/commands/package-app.d.ts.map +1 -1
  136. package/dist/cli/commands/package-app.js +5 -4
  137. package/dist/cli/commands/package-app.js.map +1 -1
  138. package/dist/cli/commands/package.d.ts +1 -1
  139. package/dist/cli/commands/package.d.ts.map +1 -1
  140. package/dist/cli/commands/package.js +134 -32
  141. package/dist/cli/commands/package.js.map +1 -1
  142. package/dist/cli/commands/run.d.ts +34 -0
  143. package/dist/cli/commands/run.d.ts.map +1 -0
  144. package/dist/cli/commands/run.js +334 -0
  145. package/dist/cli/commands/run.js.map +1 -0
  146. package/dist/cli/commands/search.d.ts +11 -0
  147. package/dist/cli/commands/search.d.ts.map +1 -0
  148. package/dist/cli/commands/search.js +60 -0
  149. package/dist/cli/commands/search.js.map +1 -0
  150. package/dist/cli/commands/serve.d.ts +11 -0
  151. package/dist/cli/commands/serve.d.ts.map +1 -0
  152. package/dist/cli/commands/serve.js +138 -0
  153. package/dist/cli/commands/serve.js.map +1 -0
  154. package/dist/cli/commands/test.d.ts +14 -0
  155. package/dist/cli/commands/test.d.ts.map +1 -0
  156. package/dist/cli/commands/test.js +51 -0
  157. package/dist/cli/commands/test.js.map +1 -0
  158. package/dist/cli/commands/update.d.ts +11 -0
  159. package/dist/cli/commands/update.d.ts.map +1 -0
  160. package/dist/cli/commands/update.js +72 -0
  161. package/dist/cli/commands/update.js.map +1 -0
  162. package/dist/cli/index.d.ts +14 -0
  163. package/dist/cli/index.d.ts.map +1 -0
  164. package/dist/cli/index.js +139 -0
  165. package/dist/cli/index.js.map +1 -0
  166. package/dist/cli-alias.js +2 -2
  167. package/dist/cli-alias.js.map +1 -1
  168. package/dist/cli.d.ts +3 -16
  169. package/dist/cli.d.ts.map +1 -1
  170. package/dist/cli.js +4 -2725
  171. package/dist/cli.js.map +1 -1
  172. package/dist/context-store.d.ts +13 -12
  173. package/dist/context-store.d.ts.map +1 -1
  174. package/dist/context-store.js +47 -23
  175. package/dist/context-store.js.map +1 -1
  176. package/dist/context.d.ts +35 -0
  177. package/dist/context.d.ts.map +1 -0
  178. package/dist/context.js +38 -0
  179. package/dist/context.js.map +1 -0
  180. package/dist/daemon/client.d.ts +25 -13
  181. package/dist/daemon/client.d.ts.map +1 -1
  182. package/dist/daemon/client.js +183 -135
  183. package/dist/daemon/client.js.map +1 -1
  184. package/dist/daemon/manager.d.ts +58 -26
  185. package/dist/daemon/manager.d.ts.map +1 -1
  186. package/dist/daemon/manager.js +348 -157
  187. package/dist/daemon/manager.js.map +1 -1
  188. package/dist/daemon/protocol.d.ts +9 -3
  189. package/dist/daemon/protocol.d.ts.map +1 -1
  190. package/dist/daemon/protocol.js +2 -0
  191. package/dist/daemon/protocol.js.map +1 -1
  192. package/dist/daemon/server.js +850 -200
  193. package/dist/daemon/server.js.map +1 -1
  194. package/dist/daemon/session-manager.d.ts +16 -2
  195. package/dist/daemon/session-manager.d.ts.map +1 -1
  196. package/dist/daemon/session-manager.js +65 -7
  197. package/dist/daemon/session-manager.js.map +1 -1
  198. package/dist/daemon/state-machine.d.ts +22 -0
  199. package/dist/daemon/state-machine.d.ts.map +1 -0
  200. package/dist/daemon/state-machine.js +48 -0
  201. package/dist/daemon/state-machine.js.map +1 -0
  202. package/dist/deploy/cloudflare.d.ts.map +1 -1
  203. package/dist/deploy/cloudflare.js +5 -5
  204. package/dist/deploy/cloudflare.js.map +1 -1
  205. package/dist/loader.d.ts +65 -7
  206. package/dist/loader.d.ts.map +1 -1
  207. package/dist/loader.js +587 -63
  208. package/dist/loader.js.map +1 -1
  209. package/dist/marketplace-manager.d.ts +84 -12
  210. package/dist/marketplace-manager.d.ts.map +1 -1
  211. package/dist/marketplace-manager.js +470 -26
  212. package/dist/marketplace-manager.js.map +1 -1
  213. package/dist/path-resolver.d.ts +3 -1
  214. package/dist/path-resolver.d.ts.map +1 -1
  215. package/dist/path-resolver.js +4 -3
  216. package/dist/path-resolver.js.map +1 -1
  217. package/dist/photon-cli-runner.d.ts +1 -1
  218. package/dist/photon-cli-runner.d.ts.map +1 -1
  219. package/dist/photon-cli-runner.js +34 -44
  220. package/dist/photon-cli-runner.js.map +1 -1
  221. package/dist/photon-doc-extractor.d.ts +1 -0
  222. package/dist/photon-doc-extractor.d.ts.map +1 -1
  223. package/dist/photon-doc-extractor.js +33 -12
  224. package/dist/photon-doc-extractor.js.map +1 -1
  225. package/dist/photons/maker.photon.d.ts.map +1 -1
  226. package/dist/photons/maker.photon.js +4 -4
  227. package/dist/photons/maker.photon.js.map +1 -1
  228. package/dist/photons/maker.photon.ts +4 -3
  229. package/dist/photons/marketplace.photon.d.ts.map +1 -1
  230. package/dist/photons/marketplace.photon.js +10 -27
  231. package/dist/photons/marketplace.photon.js.map +1 -1
  232. package/dist/photons/marketplace.photon.ts +14 -33
  233. package/dist/photons/tunnel.photon.d.ts.map +1 -1
  234. package/dist/photons/tunnel.photon.js +4 -8
  235. package/dist/photons/tunnel.photon.js.map +1 -1
  236. package/dist/photons/tunnel.photon.ts +4 -7
  237. package/dist/serv/session/kv-store.d.ts +1 -1
  238. package/dist/serv/session/kv-store.d.ts.map +1 -1
  239. package/dist/serv/session/store.d.ts.map +1 -1
  240. package/dist/serv/session/store.js +16 -14
  241. package/dist/serv/session/store.js.map +1 -1
  242. package/dist/serv/vault/token-vault.js +1 -1
  243. package/dist/serv/vault/token-vault.js.map +1 -1
  244. package/dist/server.d.ts +34 -12
  245. package/dist/server.d.ts.map +1 -1
  246. package/dist/server.js +364 -313
  247. package/dist/server.js.map +1 -1
  248. package/dist/shared/audit.d.ts +30 -0
  249. package/dist/shared/audit.d.ts.map +1 -0
  250. package/dist/shared/audit.js +89 -0
  251. package/dist/shared/audit.js.map +1 -0
  252. package/dist/shared/cli-sections.d.ts +0 -4
  253. package/dist/shared/cli-sections.d.ts.map +1 -1
  254. package/dist/shared/cli-sections.js +0 -6
  255. package/dist/shared/cli-sections.js.map +1 -1
  256. package/dist/shared/cli-utils.d.ts +2 -56
  257. package/dist/shared/cli-utils.d.ts.map +1 -1
  258. package/dist/shared/cli-utils.js +1 -87
  259. package/dist/shared/cli-utils.js.map +1 -1
  260. package/dist/shared/error-handler.d.ts +6 -72
  261. package/dist/shared/error-handler.d.ts.map +1 -1
  262. package/dist/shared/error-handler.js +22 -213
  263. package/dist/shared/error-handler.js.map +1 -1
  264. package/dist/shared/security.d.ts +0 -9
  265. package/dist/shared/security.d.ts.map +1 -1
  266. package/dist/shared/security.js +0 -30
  267. package/dist/shared/security.js.map +1 -1
  268. package/dist/shared-utils.d.ts +0 -26
  269. package/dist/shared-utils.d.ts.map +1 -1
  270. package/dist/shared-utils.js +0 -44
  271. package/dist/shared-utils.js.map +1 -1
  272. package/dist/shell-completions.d.ts +1 -1
  273. package/dist/shell-completions.d.ts.map +1 -1
  274. package/dist/shell-completions.js +5 -5
  275. package/dist/shell-completions.js.map +1 -1
  276. package/dist/template-manager.d.ts.map +1 -1
  277. package/dist/template-manager.js +14 -1
  278. package/dist/template-manager.js.map +1 -1
  279. package/dist/test-runner.d.ts +0 -12
  280. package/dist/test-runner.d.ts.map +1 -1
  281. package/dist/test-runner.js +4 -39
  282. package/dist/test-runner.js.map +1 -1
  283. package/dist/testing.d.ts +1 -1
  284. package/dist/testing.d.ts.map +1 -1
  285. package/dist/testing.js +2 -2
  286. package/dist/testing.js.map +1 -1
  287. package/dist/version-checker.d.ts +4 -4
  288. package/dist/version-checker.d.ts.map +1 -1
  289. package/dist/version-checker.js +33 -4
  290. package/dist/version-checker.js.map +1 -1
  291. package/dist/watcher.d.ts.map +1 -1
  292. package/dist/watcher.js +14 -12
  293. package/dist/watcher.js.map +1 -1
  294. package/package.json +24 -17
@@ -9,9 +9,10 @@ import * as crypto from 'crypto';
9
9
  import { createLogger } from './shared/logger.js';
10
10
  import { getErrorMessage } from './shared/error-handler.js';
11
11
  import { verifyContentHash, validateAssetPath, isPathWithin } from './shared/security.js';
12
+ import { getDefaultContext } from './context.js';
12
13
  // Timeout for marketplace fetch requests
13
14
  const FETCH_TIMEOUT_MS = 10 * 1000;
14
- const CONFIG_DIR = path.join(os.homedir(), '.photon');
15
+ const CONFIG_DIR = getDefaultContext().baseDir;
15
16
  const CONFIG_FILE = path.join(CONFIG_DIR, 'marketplaces.json');
16
17
  const CACHE_DIR = path.join(CONFIG_DIR, '.cache', 'marketplaces');
17
18
  const METADATA_FILE = path.join(CONFIG_DIR, '.metadata.json');
@@ -49,6 +50,28 @@ export function calculateHash(content) {
49
50
  const hash = crypto.createHash('sha256').update(content).digest('hex');
50
51
  return `sha256:${hash}`;
51
52
  }
53
+ /**
54
+ * Calculate combined hash of a photon source file + its declared assets.
55
+ * This ensures asset-only changes (e.g. board.html update) are detected.
56
+ */
57
+ export async function calculatePhotonHash(sourceFilePath, assets, baseDir) {
58
+ const hasher = crypto.createHash('sha256');
59
+ // Always include the source file
60
+ hasher.update(await fs.readFile(sourceFilePath, 'utf-8'));
61
+ // Include each asset file in sorted order for determinism
62
+ if (assets && assets.length > 0 && baseDir) {
63
+ for (const asset of [...assets].sort()) {
64
+ const assetPath = path.join(baseDir, asset);
65
+ try {
66
+ hasher.update(await fs.readFile(assetPath));
67
+ }
68
+ catch {
69
+ // Asset missing — skip (will be caught by validation)
70
+ }
71
+ }
72
+ }
73
+ return `sha256:${hasher.digest('hex')}`;
74
+ }
52
75
  /**
53
76
  * Read local installation metadata
54
77
  */
@@ -65,24 +88,26 @@ export async function readLocalMetadata() {
65
88
  }
66
89
  return { photons: {} };
67
90
  }
68
- /**
69
- * Write local installation metadata
70
- */
71
- export async function writeLocalMetadata(metadata) {
72
- await fs.mkdir(CONFIG_DIR, { recursive: true });
73
- await fs.writeFile(METADATA_FILE, JSON.stringify(metadata, null, 2), 'utf-8');
74
- }
75
91
  export class MarketplaceManager {
76
92
  config = { marketplaces: [] };
77
93
  logger;
78
- constructor(logger) {
94
+ configDir;
95
+ configFile;
96
+ cacheDir;
97
+ metadataFile;
98
+ constructor(logger, baseDir) {
79
99
  this.logger = logger ?? createLogger({ component: 'marketplace-manager', minimal: true });
100
+ const dir = baseDir || CONFIG_DIR;
101
+ this.configDir = dir;
102
+ this.configFile = path.join(dir, 'marketplaces.json');
103
+ this.cacheDir = path.join(dir, '.cache', 'marketplaces');
104
+ this.metadataFile = path.join(dir, '.metadata.json');
80
105
  }
81
106
  async initialize() {
82
- await fs.mkdir(CONFIG_DIR, { recursive: true });
83
- await fs.mkdir(CACHE_DIR, { recursive: true });
84
- if (existsSync(CONFIG_FILE)) {
85
- const data = await fs.readFile(CONFIG_FILE, 'utf-8');
107
+ await fs.mkdir(this.configDir, { recursive: true });
108
+ await fs.mkdir(this.cacheDir, { recursive: true });
109
+ if (existsSync(this.configFile)) {
110
+ const data = await fs.readFile(this.configFile, 'utf-8');
86
111
  try {
87
112
  this.config = JSON.parse(data);
88
113
  }
@@ -103,7 +128,7 @@ export class MarketplaceManager {
103
128
  }
104
129
  }
105
130
  async save() {
106
- await fs.writeFile(CONFIG_FILE, JSON.stringify(this.config, null, 2), 'utf-8');
131
+ await fs.writeFile(this.configFile, JSON.stringify(this.config, null, 2), 'utf-8');
107
132
  }
108
133
  /**
109
134
  * Get all marketplaces
@@ -325,7 +350,7 @@ export class MarketplaceManager {
325
350
  * Get cache file path for marketplace
326
351
  */
327
352
  getCacheFile(marketplaceName) {
328
- return path.join(CACHE_DIR, `${marketplaceName}.json`);
353
+ return path.join(this.cacheDir, `${marketplaceName}.json`);
329
354
  }
330
355
  /**
331
356
  * Fetch photons.json manifest from various sources
@@ -563,11 +588,30 @@ export class MarketplaceManager {
563
588
  }
564
589
  const metadata = manifest?.photons.find((p) => p.name === mcpName);
565
590
  // Security: verify content hash if metadata provides one
566
- if (metadata?.hash && marketplace.sourceType !== 'local') {
567
- const expectedHash = metadata.hash.replace(/^sha256:/, '');
591
+ // Prefer contentHash (source-only) for download verification; fall back to hash
592
+ let verifyHash = metadata?.contentHash || metadata?.hash;
593
+ if (verifyHash && marketplace.sourceType !== 'local') {
594
+ const expectedHash = verifyHash.replace(/^sha256:/, '');
568
595
  if (!verifyContentHash(content, expectedHash)) {
569
- this.logger.warn(`Content hash mismatch for ${mcpName}skipping`);
570
- continue;
596
+ // Hash mismatch may mean local cache is stale refresh and retry once
597
+ this.logger.info(`Content hash mismatch for ${mcpName} — refreshing manifest cache`);
598
+ const updated = await this.updateMarketplaceCache(marketplace.name);
599
+ if (updated) {
600
+ const freshManifest = await this.getCachedManifest(marketplace.name);
601
+ const freshMeta = freshManifest?.photons.find((p) => p.name === mcpName);
602
+ verifyHash = freshMeta?.contentHash || freshMeta?.hash;
603
+ if (verifyHash) {
604
+ const freshHash = verifyHash.replace(/^sha256:/, '');
605
+ if (!verifyContentHash(content, freshHash)) {
606
+ this.logger.warn(`Content hash mismatch for ${mcpName} after cache refresh — skipping`);
607
+ continue;
608
+ }
609
+ }
610
+ }
611
+ else {
612
+ this.logger.warn(`Content hash mismatch for ${mcpName} — skipping`);
613
+ continue;
614
+ }
571
615
  }
572
616
  }
573
617
  return { content, marketplace, metadata };
@@ -783,29 +827,92 @@ export class MarketplaceManager {
783
827
  }
784
828
  return [];
785
829
  }
830
+ /**
831
+ * Install a photon and its assets to the working directory.
832
+ *
833
+ * This is the canonical installation path — used by both CLI and Beam UI.
834
+ * Handles writing the .photon.ts file, saving metadata, and downloading
835
+ * all declared asset files (UI templates, etc.) with path-traversal protection.
836
+ *
837
+ * @param result - The result from fetchMCP()
838
+ * @param name - Photon name (used for filename)
839
+ * @param workingDir - Directory to install into (e.g., ~/.photon)
840
+ * @returns Installed file path and list of asset paths written
841
+ */
842
+ async installPhoton(result, name, workingDir) {
843
+ // Inject @forkedFrom tag if not already present
844
+ let content = result.content;
845
+ if (!content.includes('@forkedFrom')) {
846
+ const origin = `${result.marketplace.repo}#${name}`;
847
+ // Insert before the first closing */ of the file-level docblock
848
+ content = content.replace(/(\s*\*\/)/, `\n * @forkedFrom ${origin}$1`);
849
+ }
850
+ // Write the .photon.ts file
851
+ await fs.mkdir(workingDir, { recursive: true });
852
+ const photonPath = path.join(workingDir, `${name}.photon.ts`);
853
+ await fs.writeFile(photonPath, content, 'utf-8');
854
+ const assetsInstalled = [];
855
+ if (result.metadata) {
856
+ // Download and save all declared assets first (before hashing)
857
+ if (result.metadata.assets && result.metadata.assets.length > 0) {
858
+ const assets = await this.fetchAssets(result.marketplace, result.metadata.assets);
859
+ for (const [assetPath, content] of assets) {
860
+ const safePath = validateAssetPath(assetPath);
861
+ const assetTarget = path.join(workingDir, safePath);
862
+ if (!isPathWithin(assetTarget, workingDir))
863
+ continue;
864
+ await fs.mkdir(path.dirname(assetTarget), { recursive: true });
865
+ await fs.writeFile(assetTarget, content, 'utf-8');
866
+ assetsInstalled.push(assetPath);
867
+ }
868
+ }
869
+ // Save install metadata — use combined hash (source+assets) for update detection
870
+ // The manifest's `hash` field is the combined hash; fall back to content-only hash
871
+ const combinedHash = result.metadata.hash || calculateHash(result.content);
872
+ await this.savePhotonMetadata(`${name}.photon.ts`, result.marketplace, result.metadata, combinedHash);
873
+ }
874
+ return { photonPath, assetsInstalled };
875
+ }
786
876
  /**
787
877
  * Save installation metadata for a Photon
788
878
  */
789
- async savePhotonMetadata(fileName, marketplace, metadata, contentHash) {
790
- const localMetadata = await readLocalMetadata();
879
+ async savePhotonMetadata(fileName, marketplace, metadata, combinedHash) {
880
+ const localMetadata = await this.readMetadata();
791
881
  localMetadata.photons[fileName] = {
792
882
  marketplace: marketplace.name,
793
883
  marketplaceRepo: marketplace.repo,
794
884
  version: metadata.version,
795
- originalHash: metadata.hash || contentHash,
885
+ originalHash: combinedHash,
796
886
  installedAt: new Date().toISOString(),
797
887
  };
798
- await writeLocalMetadata(localMetadata);
888
+ await this.writeMetadata(localMetadata);
799
889
  }
800
890
  /**
801
891
  * Get local installation metadata for a Photon
802
892
  */
803
893
  async getPhotonInstallMetadata(fileName) {
804
- const localMetadata = await readLocalMetadata();
894
+ const localMetadata = await this.readMetadata();
805
895
  return localMetadata.photons[fileName] || null;
806
896
  }
897
+ async readMetadata() {
898
+ try {
899
+ if (existsSync(this.metadataFile)) {
900
+ const data = await fs.readFile(this.metadataFile, 'utf-8');
901
+ return JSON.parse(data);
902
+ }
903
+ }
904
+ catch {
905
+ /* corrupted — start fresh */
906
+ }
907
+ return { photons: {} };
908
+ }
909
+ async writeMetadata(metadata) {
910
+ await fs.mkdir(this.configDir, { recursive: true });
911
+ await fs.writeFile(this.metadataFile, JSON.stringify(metadata, null, 2), 'utf-8');
912
+ }
807
913
  /**
808
914
  * Check if a Photon file has been modified since installation
915
+ * Compares combined hash (source + assets) against stored originalHash
809
916
  */
810
917
  async isPhotonModified(filePath, fileName) {
811
918
  const metadata = await this.getPhotonInstallMetadata(fileName);
@@ -813,8 +920,31 @@ export class MarketplaceManager {
813
920
  return false; // No metadata, can't determine
814
921
  }
815
922
  try {
816
- const currentHash = await calculateFileHash(filePath);
817
- return currentHash !== metadata.originalHash;
923
+ // Strip @forkedFrom from source (injected during install, not in manifest)
924
+ const rawContent = await fs.readFile(filePath, 'utf-8');
925
+ const strippedContent = rawContent.replace(/\n\s*\*\s*@forkedFrom\s+[^\n]+/, '');
926
+ // Write stripped content to temp for hashing, then compute combined hash
927
+ // For efficiency, hash inline: source (stripped) + assets
928
+ const workingDir = path.dirname(filePath);
929
+ const photonName = fileName.replace(/\.photon\.ts$/, '');
930
+ // Look up assets from manifest
931
+ const photonMeta = await this.getPhotonMetadata(photonName);
932
+ const assets = photonMeta?.metadata.assets;
933
+ const hasher = crypto.createHash('sha256');
934
+ hasher.update(strippedContent);
935
+ if (assets && assets.length > 0) {
936
+ for (const asset of [...assets].sort()) {
937
+ const assetPath = path.join(workingDir, asset);
938
+ try {
939
+ hasher.update(await fs.readFile(assetPath));
940
+ }
941
+ catch {
942
+ // Asset missing — skip
943
+ }
944
+ }
945
+ }
946
+ const hash = `sha256:${hasher.digest('hex')}`;
947
+ return hash !== metadata.originalHash;
818
948
  }
819
949
  catch {
820
950
  return false; // file unreadable → not modified
@@ -947,6 +1077,320 @@ export class MarketplaceManager {
947
1077
  recommendation,
948
1078
  };
949
1079
  }
1080
+ /**
1081
+ * Get marketplace sources suitable as fork targets
1082
+ */
1083
+ async getForkTargets() {
1084
+ const all = this.getAll();
1085
+ return all
1086
+ .filter((m) => m.sourceType === 'github' && m.repo)
1087
+ .map((m) => ({ name: m.name, repo: m.repo, sourceType: m.sourceType }));
1088
+ }
1089
+ /**
1090
+ * Fork a photon — remove marketplace tracking, optionally push to a target repo.
1091
+ * Shared logic used by both CLI and Beam.
1092
+ */
1093
+ async forkPhoton(name, workingDir, options) {
1094
+ const fileName = `${name}.photon.ts`;
1095
+ const filePath = path.join(workingDir, fileName);
1096
+ // Check file exists
1097
+ if (!existsSync(filePath)) {
1098
+ return { success: false, message: `Photon not found: ${name}` };
1099
+ }
1100
+ // Read install metadata
1101
+ const localMetadata = await readLocalMetadata();
1102
+ const installMeta = localMetadata.photons[fileName];
1103
+ if (!installMeta) {
1104
+ return {
1105
+ success: true,
1106
+ message: `${name} is already a local photon (no marketplace tracking)`,
1107
+ };
1108
+ }
1109
+ // Check @forkedFrom tag
1110
+ const content = await fs.readFile(filePath, 'utf-8');
1111
+ const hasForkedFrom = content.includes('@forkedFrom');
1112
+ // Handle target repo push if specified
1113
+ if (options?.targetRepo || options?.createRepo) {
1114
+ const { execSync } = await import('child_process');
1115
+ // Check gh CLI
1116
+ try {
1117
+ execSync('gh --version', { stdio: 'pipe' });
1118
+ }
1119
+ catch {
1120
+ return {
1121
+ success: false,
1122
+ message: 'GitHub CLI (gh) is required but not installed',
1123
+ };
1124
+ }
1125
+ if (options.createRepo) {
1126
+ // Create new repo and push
1127
+ try {
1128
+ execSync(`gh repo create ${options.createRepo} --public --confirm`, { stdio: 'pipe' });
1129
+ }
1130
+ catch {
1131
+ // Repo may already exist
1132
+ }
1133
+ const targetRepo = options.createRepo;
1134
+ const tmpDir = path.join(os.tmpdir(), `photon-fork-${Date.now()}`);
1135
+ try {
1136
+ execSync(`gh repo clone ${targetRepo} "${tmpDir}" -- --depth=1`, {
1137
+ stdio: 'pipe',
1138
+ });
1139
+ await fs.copyFile(filePath, path.join(tmpDir, fileName));
1140
+ // Copy assets
1141
+ const photonMeta = await this.getPhotonMetadata(name);
1142
+ if (photonMeta?.metadata.assets) {
1143
+ for (const asset of photonMeta.metadata.assets) {
1144
+ const srcAsset = path.join(workingDir, asset);
1145
+ if (existsSync(srcAsset)) {
1146
+ const dstAsset = path.join(tmpDir, asset);
1147
+ await fs.mkdir(path.dirname(dstAsset), { recursive: true });
1148
+ await fs.copyFile(srcAsset, dstAsset);
1149
+ }
1150
+ }
1151
+ }
1152
+ execSync(`cd "${tmpDir}" && git add -A && git commit -m "fork: ${name} photon" && git push origin`, { stdio: 'pipe' });
1153
+ await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => { });
1154
+ }
1155
+ catch (e) {
1156
+ await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => { });
1157
+ return {
1158
+ success: false,
1159
+ message: `Failed to push to new repo: ${e.message}`,
1160
+ };
1161
+ }
1162
+ }
1163
+ else if (options.targetRepo) {
1164
+ // Push to existing repo
1165
+ const tmpDir = path.join(os.tmpdir(), `photon-fork-${Date.now()}`);
1166
+ try {
1167
+ execSync(`gh repo clone ${options.targetRepo} "${tmpDir}" -- --depth=1`, {
1168
+ stdio: 'pipe',
1169
+ });
1170
+ await fs.copyFile(filePath, path.join(tmpDir, fileName));
1171
+ // Copy assets
1172
+ const photonMeta = await this.getPhotonMetadata(name);
1173
+ if (photonMeta?.metadata.assets) {
1174
+ for (const asset of photonMeta.metadata.assets) {
1175
+ const srcAsset = path.join(workingDir, asset);
1176
+ if (existsSync(srcAsset)) {
1177
+ const dstAsset = path.join(tmpDir, asset);
1178
+ await fs.mkdir(path.dirname(dstAsset), { recursive: true });
1179
+ await fs.copyFile(srcAsset, dstAsset);
1180
+ }
1181
+ }
1182
+ }
1183
+ execSync(`cd "${tmpDir}" && git add -A && git commit -m "fork: ${name} photon" && git push origin`, { stdio: 'pipe' });
1184
+ await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => { });
1185
+ }
1186
+ catch (e) {
1187
+ await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => { });
1188
+ return {
1189
+ success: false,
1190
+ message: `Failed to push to ${options.targetRepo}: ${e.message}`,
1191
+ };
1192
+ }
1193
+ }
1194
+ }
1195
+ // Remove marketplace tracking
1196
+ delete localMetadata.photons[fileName];
1197
+ await this.writeMetadata(localMetadata);
1198
+ const parts = [];
1199
+ parts.push(`${name} is now your own`);
1200
+ if (hasForkedFrom) {
1201
+ parts.push('Origin preserved as @forkedFrom tag');
1202
+ }
1203
+ parts.push('Marketplace update tracking removed');
1204
+ return { success: true, message: parts.join('. ') };
1205
+ }
1206
+ /**
1207
+ * Contribute a photon back upstream via PR.
1208
+ * Shared logic used by both CLI and Beam.
1209
+ */
1210
+ async contributePhoton(name, workingDir, options) {
1211
+ const { execSync } = await import('child_process');
1212
+ const fileName = `${name}.photon.ts`;
1213
+ const filePath = path.join(workingDir, fileName);
1214
+ // Check file exists
1215
+ if (!existsSync(filePath)) {
1216
+ return { success: false, message: `Photon not found: ${name}` };
1217
+ }
1218
+ // Check gh CLI
1219
+ try {
1220
+ execSync('gh --version', { stdio: 'pipe' });
1221
+ }
1222
+ catch {
1223
+ return {
1224
+ success: false,
1225
+ message: 'GitHub CLI (gh) is required. Install: https://cli.github.com/',
1226
+ };
1227
+ }
1228
+ // Check gh auth
1229
+ try {
1230
+ execSync('gh auth status', { stdio: 'pipe' });
1231
+ }
1232
+ catch {
1233
+ return {
1234
+ success: false,
1235
+ message: 'GitHub CLI is not authenticated. Run: gh auth login',
1236
+ };
1237
+ }
1238
+ // Read install metadata
1239
+ const localMetadata = await readLocalMetadata();
1240
+ const installMeta = localMetadata.photons[fileName];
1241
+ if (!installMeta) {
1242
+ return {
1243
+ success: false,
1244
+ message: `${name} was not installed from a marketplace`,
1245
+ };
1246
+ }
1247
+ if (!installMeta.marketplaceRepo) {
1248
+ return {
1249
+ success: false,
1250
+ message: `No upstream repository found for ${name}`,
1251
+ };
1252
+ }
1253
+ // Check if actually modified
1254
+ const modified = await this.isPhotonModified(filePath, fileName);
1255
+ if (!modified) {
1256
+ return {
1257
+ success: true,
1258
+ message: `${name} has not been modified — nothing to contribute`,
1259
+ };
1260
+ }
1261
+ const repo = installMeta.marketplaceRepo;
1262
+ const branchName = options?.branch || `contribute/${name}-${Date.now()}`;
1263
+ if (options?.dryRun) {
1264
+ return {
1265
+ success: true,
1266
+ message: [
1267
+ `[dry-run] Would:`,
1268
+ ` 1. Fork ${repo}`,
1269
+ ` 2. Clone fork to temp directory`,
1270
+ ` 3. Copy modified ${fileName} (and assets)`,
1271
+ ` 4. Create branch ${branchName}`,
1272
+ ` 5. Commit and push`,
1273
+ ` 6. Create PR to ${repo}`,
1274
+ ].join('\n'),
1275
+ };
1276
+ }
1277
+ // Fork the repo
1278
+ try {
1279
+ execSync(`gh repo fork ${repo} --clone=false`, { stdio: 'pipe' });
1280
+ }
1281
+ catch {
1282
+ // Fork may already exist
1283
+ }
1284
+ // Get fork name
1285
+ const forkJson = execSync('gh api user', { encoding: 'utf-8' });
1286
+ const ghUser = JSON.parse(forkJson).login;
1287
+ const repoName = repo.split('/')[1];
1288
+ const forkRepo = `${ghUser}/${repoName}`;
1289
+ // Clone to temp dir
1290
+ const tmpDir = path.join(os.tmpdir(), `photon-contribute-${Date.now()}`);
1291
+ try {
1292
+ execSync(`gh repo clone ${forkRepo} "${tmpDir}" -- --depth=1`, {
1293
+ stdio: 'pipe',
1294
+ });
1295
+ // Copy modified photon file
1296
+ await fs.copyFile(filePath, path.join(tmpDir, fileName));
1297
+ // Copy assets
1298
+ const photonMeta = await this.getPhotonMetadata(name);
1299
+ if (photonMeta?.metadata.assets) {
1300
+ for (const asset of photonMeta.metadata.assets) {
1301
+ const srcAsset = path.join(workingDir, asset);
1302
+ if (existsSync(srcAsset)) {
1303
+ const dstAsset = path.join(tmpDir, asset);
1304
+ await fs.mkdir(path.dirname(dstAsset), { recursive: true });
1305
+ await fs.copyFile(srcAsset, dstAsset);
1306
+ }
1307
+ }
1308
+ }
1309
+ // Create branch, commit, push
1310
+ execSync(`cd "${tmpDir}" && git checkout -b "${branchName}" && git add -A && git commit -m "improve: update ${name} photon" && git push origin "${branchName}"`, { stdio: 'pipe' });
1311
+ // Create PR
1312
+ const prOutput = execSync(`cd "${tmpDir}" && gh pr create --repo "${repo}" --title "Improve ${name} photon" --body "Contributed improvements to ${name} photon via Photon marketplace."`, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
1313
+ // Cleanup temp dir
1314
+ await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => { });
1315
+ const prUrl = prOutput.trim();
1316
+ return {
1317
+ success: true,
1318
+ prUrl,
1319
+ message: `Pull request created: ${prUrl}`,
1320
+ };
1321
+ }
1322
+ catch (e) {
1323
+ await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => { });
1324
+ return {
1325
+ success: false,
1326
+ message: `Contribute failed: ${e.message}`,
1327
+ };
1328
+ }
1329
+ }
1330
+ /**
1331
+ * Resolve and install a photon from a GitHub shorthand reference.
1332
+ * Format: owner/repo (photon name = repo name)
1333
+ * or owner/repo/photon-name
1334
+ *
1335
+ * - Adds the marketplace to config (idempotent)
1336
+ * - Fetches and installs the photon to workingDir
1337
+ * - If already installed, skips fetch
1338
+ * - Returns the resolved photon name
1339
+ */
1340
+ async fetchAndInstallFromRef(ref, workingDir) {
1341
+ const parts = ref.split('/');
1342
+ let owner, repo, photonName;
1343
+ if (parts.length === 2) {
1344
+ [owner, repo] = parts;
1345
+ photonName = repo;
1346
+ }
1347
+ else if (parts.length === 3) {
1348
+ [owner, repo, photonName] = parts;
1349
+ }
1350
+ else {
1351
+ throw new Error(`Invalid photon reference: ${ref}. Expected owner/repo or owner/repo/photon-name`);
1352
+ }
1353
+ const photonFile = path.join(workingDir, `${photonName}.photon.ts`);
1354
+ // Already installed — skip fetch
1355
+ if (existsSync(photonFile)) {
1356
+ return { photonName, alreadyInstalled: true };
1357
+ }
1358
+ // Add marketplace (idempotent — skips if already added)
1359
+ const repoShorthand = `${owner}/${repo}`;
1360
+ const { marketplace: marketplaceInfo } = await this.add(repoShorthand);
1361
+ // Fetch photon content directly from GitHub raw
1362
+ const rawUrl = `https://raw.githubusercontent.com/${owner}/${repo}/main/${photonName}.photon.ts`;
1363
+ let content;
1364
+ try {
1365
+ const response = await fetch(rawUrl, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
1366
+ if (!response.ok) {
1367
+ throw new Error(`HTTP ${response.status}`);
1368
+ }
1369
+ content = await response.text();
1370
+ }
1371
+ catch (err) {
1372
+ throw new Error(`Could not fetch photon '${photonName}' from ${repoShorthand}: ${err instanceof Error ? err.message : String(err)}`);
1373
+ }
1374
+ // Try to get manifest metadata (best-effort)
1375
+ const marketplace = this.get(marketplaceInfo.name);
1376
+ if (!marketplace)
1377
+ throw new Error(`Marketplace not found after add: ${marketplaceInfo.name}`);
1378
+ await this.updateMarketplaceCache(marketplace.name).catch(() => {
1379
+ /* non-fatal */
1380
+ });
1381
+ const manifest = await this.getCachedManifest(marketplace.name);
1382
+ const metadata = manifest?.photons.find((p) => p.name === photonName);
1383
+ // When no manifest exists, use a minimal synthetic metadata so that
1384
+ // .metadata.json is always written (enables update detection via hash comparison)
1385
+ const effectiveMetadata = metadata ?? {
1386
+ name: photonName,
1387
+ version: 'unknown',
1388
+ description: '',
1389
+ source: rawUrl,
1390
+ };
1391
+ await this.installPhoton({ content, marketplace, metadata: effectiveMetadata }, photonName, workingDir);
1392
+ return { photonName, alreadyInstalled: false };
1393
+ }
950
1394
  /**
951
1395
  * Compare two semver versions
952
1396
  * Returns: positive if v1 > v2, negative if v1 < v2, 0 if equal