@synap-core/cli 1.8.0 → 1.11.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 (372) hide show
  1. package/dist/commands/agent-chat.d.ts +47 -0
  2. package/dist/commands/agent-chat.js +250 -0
  3. package/dist/commands/agent-chat.js.map +1 -0
  4. package/dist/commands/agent-run.js +3 -4
  5. package/dist/commands/agent-run.js.map +1 -1
  6. package/dist/commands/agents.js +85 -46
  7. package/dist/commands/agents.js.map +1 -1
  8. package/dist/commands/automation.js +2 -1
  9. package/dist/commands/automation.js.map +1 -1
  10. package/dist/commands/bridge-setup.d.ts +66 -0
  11. package/dist/commands/bridge-setup.js +1112 -0
  12. package/dist/commands/bridge-setup.js.map +1 -0
  13. package/dist/commands/cap-connections.d.ts +28 -0
  14. package/dist/commands/cap-connections.js +224 -0
  15. package/dist/commands/cap-connections.js.map +1 -0
  16. package/dist/commands/capability.d.ts +89 -0
  17. package/dist/commands/capability.js +1594 -0
  18. package/dist/commands/capability.js.map +1 -0
  19. package/dist/commands/centrality.d.ts +20 -0
  20. package/dist/commands/centrality.js +95 -0
  21. package/dist/commands/centrality.js.map +1 -0
  22. package/dist/commands/connect.d.ts +2 -0
  23. package/dist/commands/connect.js +16 -35
  24. package/dist/commands/connect.js.map +1 -1
  25. package/dist/commands/context.js +6 -7
  26. package/dist/commands/context.js.map +1 -1
  27. package/dist/commands/data-extra.d.ts +1 -0
  28. package/dist/commands/data-extra.js +41 -14
  29. package/dist/commands/data-extra.js.map +1 -1
  30. package/dist/commands/data.d.ts +4 -4
  31. package/dist/commands/data.js +254 -187
  32. package/dist/commands/data.js.map +1 -1
  33. package/dist/commands/diagnose.d.ts +20 -0
  34. package/dist/commands/diagnose.js +186 -0
  35. package/dist/commands/diagnose.js.map +1 -0
  36. package/dist/commands/digest.d.ts +17 -0
  37. package/dist/commands/digest.js +122 -0
  38. package/dist/commands/digest.js.map +1 -0
  39. package/dist/commands/discover.d.ts +2 -0
  40. package/dist/commands/discover.js +25 -8
  41. package/dist/commands/discover.js.map +1 -1
  42. package/dist/commands/doc.d.ts +14 -0
  43. package/dist/commands/doc.js +61 -4
  44. package/dist/commands/doc.js.map +1 -1
  45. package/dist/commands/doctor.d.ts +21 -0
  46. package/dist/commands/doctor.js +189 -0
  47. package/dist/commands/doctor.js.map +1 -0
  48. package/dist/commands/events.js +5 -2
  49. package/dist/commands/events.js.map +1 -1
  50. package/dist/commands/explain.d.ts +1 -1
  51. package/dist/commands/explain.js +18 -15
  52. package/dist/commands/explain.js.map +1 -1
  53. package/dist/commands/facet.d.ts +29 -0
  54. package/dist/commands/facet.js +113 -0
  55. package/dist/commands/facet.js.map +1 -0
  56. package/dist/commands/graph.js +7 -4
  57. package/dist/commands/graph.js.map +1 -1
  58. package/dist/commands/import.d.ts +38 -0
  59. package/dist/commands/import.js +471 -0
  60. package/dist/commands/import.js.map +1 -0
  61. package/dist/commands/init.js +35 -16
  62. package/dist/commands/init.js.map +1 -1
  63. package/dist/commands/keys.js +3 -4
  64. package/dist/commands/keys.js.map +1 -1
  65. package/dist/commands/knowledge.d.ts +8 -0
  66. package/dist/commands/knowledge.js +308 -33
  67. package/dist/commands/knowledge.js.map +1 -1
  68. package/dist/commands/launch.d.ts +50 -0
  69. package/dist/commands/launch.js +790 -0
  70. package/dist/commands/launch.js.map +1 -0
  71. package/dist/commands/lens.d.ts +45 -0
  72. package/dist/commands/lens.js +386 -0
  73. package/dist/commands/lens.js.map +1 -0
  74. package/dist/commands/market-authoring.d.ts +45 -0
  75. package/dist/commands/market-authoring.js +225 -0
  76. package/dist/commands/market-authoring.js.map +1 -0
  77. package/dist/commands/market.d.ts +224 -0
  78. package/dist/commands/market.js +1643 -0
  79. package/dist/commands/market.js.map +1 -0
  80. package/dist/commands/mcp.d.ts +64 -0
  81. package/dist/commands/mcp.js +265 -0
  82. package/dist/commands/mcp.js.map +1 -0
  83. package/dist/commands/observe.js +14 -11
  84. package/dist/commands/observe.js.map +1 -1
  85. package/dist/commands/open.d.ts +21 -4
  86. package/dist/commands/open.js +45 -4
  87. package/dist/commands/open.js.map +1 -1
  88. package/dist/commands/pod.d.ts +100 -0
  89. package/dist/commands/pod.js +359 -0
  90. package/dist/commands/pod.js.map +1 -0
  91. package/dist/commands/pods.d.ts +15 -0
  92. package/dist/commands/pods.js +233 -17
  93. package/dist/commands/pods.js.map +1 -1
  94. package/dist/commands/proposals-actions.js +43 -3
  95. package/dist/commands/proposals-actions.js.map +1 -1
  96. package/dist/commands/purge.d.ts +23 -0
  97. package/dist/commands/purge.js +148 -0
  98. package/dist/commands/purge.js.map +1 -0
  99. package/dist/commands/raycast.d.ts +15 -0
  100. package/dist/commands/raycast.js +153 -0
  101. package/dist/commands/raycast.js.map +1 -0
  102. package/dist/commands/sessions.js +47 -22
  103. package/dist/commands/sessions.js.map +1 -1
  104. package/dist/commands/skill-manage.d.ts +21 -0
  105. package/dist/commands/skill-manage.js +195 -0
  106. package/dist/commands/skill-manage.js.map +1 -0
  107. package/dist/commands/skills-commands.js +2 -1
  108. package/dist/commands/skills-commands.js.map +1 -1
  109. package/dist/commands/skills.js +7 -4
  110. package/dist/commands/skills.js.map +1 -1
  111. package/dist/commands/status.d.ts +3 -1
  112. package/dist/commands/status.js +166 -14
  113. package/dist/commands/status.js.map +1 -1
  114. package/dist/commands/statusline.d.ts +24 -0
  115. package/dist/commands/statusline.js +450 -0
  116. package/dist/commands/statusline.js.map +1 -0
  117. package/dist/commands/subscribe.js +2 -2
  118. package/dist/commands/subscribe.js.map +1 -1
  119. package/dist/commands/template.d.ts +42 -0
  120. package/dist/commands/template.js +191 -0
  121. package/dist/commands/template.js.map +1 -0
  122. package/dist/commands/tools.d.ts +30 -0
  123. package/dist/commands/tools.js +315 -0
  124. package/dist/commands/tools.js.map +1 -0
  125. package/dist/commands/upload.d.ts +30 -0
  126. package/dist/commands/upload.js +155 -0
  127. package/dist/commands/upload.js.map +1 -0
  128. package/dist/commands/versions.d.ts +27 -0
  129. package/dist/commands/versions.js +277 -0
  130. package/dist/commands/versions.js.map +1 -0
  131. package/dist/commands/view.js +3 -2
  132. package/dist/commands/view.js.map +1 -1
  133. package/dist/commands/whoami.d.ts +21 -0
  134. package/dist/commands/whoami.js +129 -0
  135. package/dist/commands/whoami.js.map +1 -0
  136. package/dist/index.js +894 -60
  137. package/dist/index.js.map +1 -1
  138. package/dist/lib/approval-poll.d.ts +42 -0
  139. package/dist/lib/approval-poll.js +52 -0
  140. package/dist/lib/approval-poll.js.map +1 -0
  141. package/dist/lib/auth.d.ts +29 -6
  142. package/dist/lib/auth.js +105 -94
  143. package/dist/lib/auth.js.map +1 -1
  144. package/dist/lib/bundle-version.d.ts +11 -0
  145. package/dist/lib/bundle-version.js +33 -0
  146. package/dist/lib/bundle-version.js.map +1 -0
  147. package/dist/lib/capture-lane.d.ts +30 -12
  148. package/dist/lib/capture-lane.js +73 -14
  149. package/dist/lib/capture-lane.js.map +1 -1
  150. package/dist/lib/capture-structure.d.ts +123 -0
  151. package/dist/lib/capture-structure.js +57 -0
  152. package/dist/lib/capture-structure.js.map +1 -0
  153. package/dist/lib/cp-packages.d.ts +177 -0
  154. package/dist/lib/cp-packages.js +389 -0
  155. package/dist/lib/cp-packages.js.map +1 -0
  156. package/dist/lib/describe-lens.d.ts +46 -0
  157. package/dist/lib/describe-lens.js +33 -0
  158. package/dist/lib/describe-lens.js.map +1 -0
  159. package/dist/lib/hub-client.d.ts +91 -1
  160. package/dist/lib/hub-client.js +565 -22
  161. package/dist/lib/hub-client.js.map +1 -1
  162. package/dist/lib/id.d.ts +23 -0
  163. package/dist/lib/id.js +35 -0
  164. package/dist/lib/id.js.map +1 -0
  165. package/dist/lib/installed.d.ts +51 -0
  166. package/dist/lib/installed.js +70 -0
  167. package/dist/lib/installed.js.map +1 -0
  168. package/dist/lib/next-steps.d.ts +61 -0
  169. package/dist/lib/next-steps.js +146 -0
  170. package/dist/lib/next-steps.js.map +1 -0
  171. package/dist/lib/pod.d.ts +144 -22
  172. package/dist/lib/pod.js +302 -64
  173. package/dist/lib/pod.js.map +1 -1
  174. package/dist/lib/project-ref.d.ts +95 -0
  175. package/dist/lib/project-ref.js +144 -0
  176. package/dist/lib/project-ref.js.map +1 -0
  177. package/dist/lib/project.d.ts +35 -0
  178. package/dist/lib/project.js +39 -0
  179. package/dist/lib/project.js.map +1 -0
  180. package/dist/lib/session-lens.d.ts +33 -0
  181. package/dist/lib/session-lens.js +76 -0
  182. package/dist/lib/session-lens.js.map +1 -0
  183. package/dist/lib/skill-import.d.ts +40 -0
  184. package/dist/lib/skill-import.js +203 -0
  185. package/dist/lib/skill-import.js.map +1 -0
  186. package/dist/lib/skills-installer.d.ts +17 -6
  187. package/dist/lib/skills-installer.js +133 -19
  188. package/dist/lib/skills-installer.js.map +1 -1
  189. package/dist/lib/suite.d.ts +28 -0
  190. package/dist/lib/suite.js +29 -0
  191. package/dist/lib/suite.js.map +1 -0
  192. package/dist/lib/superwhisper-import.d.ts +40 -0
  193. package/dist/lib/superwhisper-import.js +389 -0
  194. package/dist/lib/superwhisper-import.js.map +1 -0
  195. package/dist/lib/targets.d.ts +49 -1
  196. package/dist/lib/targets.js +420 -288
  197. package/dist/lib/targets.js.map +1 -1
  198. package/dist/lib/template-file.d.ts +74 -0
  199. package/dist/lib/template-file.js +140 -0
  200. package/dist/lib/template-file.js.map +1 -0
  201. package/dist/lib/unwrapList.d.ts +1 -0
  202. package/dist/lib/unwrapList.js +20 -0
  203. package/dist/lib/unwrapList.js.map +1 -0
  204. package/dist/utils/logger.d.ts +2 -0
  205. package/dist/utils/logger.js +10 -2
  206. package/dist/utils/logger.js.map +1 -1
  207. package/node_modules/@synap/hub-rest-client/dist/index.cjs +414 -34
  208. package/node_modules/@synap/hub-rest-client/dist/index.d.cts +762 -22
  209. package/node_modules/@synap/hub-rest-client/dist/index.d.ts +762 -22
  210. package/node_modules/@synap/hub-rest-client/dist/index.js +414 -34
  211. package/node_modules/@synap/hub-rest-client/package.json +1 -1
  212. package/node_modules/@synap/hub-rest-client/src/client.ts +525 -37
  213. package/node_modules/@synap/hub-rest-client/src/index.ts +42 -0
  214. package/node_modules/@synap/hub-rest-client/src/setup.ts +57 -1
  215. package/node_modules/@synap/hub-rest-client/src/types.ts +697 -37
  216. package/package.json +11 -5
  217. package/skills/agent-os/SKILL.md +218 -0
  218. package/skills/manifest.json +5 -0
  219. package/skills/onboard/SKILL.md +193 -0
  220. package/skills/synap/SKILL.md +1284 -58
  221. package/skills/synap/_order.txt +37 -0
  222. package/skills/synap/_skill.yaml +24 -0
  223. package/skills/synap/authentication.md +10 -0
  224. package/skills/synap/automations.md +264 -0
  225. package/skills/synap/capabilities.md +107 -0
  226. package/skills/synap/capture.md +50 -142
  227. package/skills/synap/cli-operations.md +107 -0
  228. package/skills/synap/common-mistakes.md +13 -0
  229. package/skills/synap/content-os.md +83 -0
  230. package/skills/synap/creative-loop.md +43 -0
  231. package/skills/synap/crm.md +117 -0
  232. package/skills/synap/diagnostics.md +35 -0
  233. package/skills/synap/digesting-a-channel.md +63 -0
  234. package/skills/synap/document-embeds.md +123 -0
  235. package/skills/synap/escalation-ladder.md +71 -0
  236. package/skills/synap/focus-sessions.md +47 -0
  237. package/skills/synap/governance-rules.md +64 -0
  238. package/skills/synap/governance.md +6 -6
  239. package/skills/synap/graph-gardening.md +21 -0
  240. package/skills/synap/inline-patterns.md +38 -0
  241. package/skills/synap/lenses.md +38 -0
  242. package/skills/synap/linking-principle.md +39 -0
  243. package/skills/synap/linking.md +1 -0
  244. package/skills/synap/mental-model.md +45 -0
  245. package/skills/synap/more.md +7 -0
  246. package/skills/synap/multi-entity-capture.md +12 -0
  247. package/skills/synap/next-action.md +37 -0
  248. package/skills/synap/north-star.md +17 -0
  249. package/skills/synap/operating-mode-rules.md +45 -0
  250. package/skills/synap/operating-mode.md +9 -0
  251. package/skills/synap/quick-reference.md +67 -0
  252. package/skills/synap/reading.md +43 -0
  253. package/skills/synap/recap.md +21 -0
  254. package/skills/synap/reflexes.md +46 -0
  255. package/skills/synap/response-style.md +21 -0
  256. package/skills/synap/scope.md +13 -0
  257. package/skills/synap/showing.md +17 -0
  258. package/skills/synap/user-model.md +25 -0
  259. package/skills/synap/viewframe-cells.md +290 -0
  260. package/skills/synap/work-flow.md +113 -0
  261. package/skills/synap/worked-examples.md +74 -0
  262. package/skills/synap/workspace-design.md +45 -0
  263. package/skills/synap/workspace-edges.md +49 -0
  264. package/skills/synap/writes.md +213 -0
  265. package/skills/synap-app/SKILL.md +319 -0
  266. package/skills/synap-app/_order.txt +5 -0
  267. package/skills/synap-app/_skill.yaml +20 -0
  268. package/skills/synap-app/creating-the-base-app.md +64 -0
  269. package/skills/synap-app/governance.md +42 -0
  270. package/skills/synap-app/install-commands.md +55 -0
  271. package/skills/synap-app/overview.md +50 -0
  272. package/skills/synap-app/using-the-sdk.md +73 -0
  273. package/skills/synap-market/SKILL.md +335 -0
  274. package/skills/synap-market/_order.txt +5 -0
  275. package/skills/synap-market/_skill.yaml +24 -0
  276. package/skills/synap-market/author-and-publish.md +75 -0
  277. package/skills/synap-market/discover-and-fetch.md +59 -0
  278. package/skills/synap-market/governance-and-catalog.md +56 -0
  279. package/skills/synap-market/install.md +55 -0
  280. package/skills/synap-market/overview.md +51 -0
  281. package/skills/synap-schema/SKILL.md +97 -6
  282. package/skills/synap-schema/_order.txt +13 -0
  283. package/skills/synap-schema/_skill.yaml +22 -0
  284. package/skills/synap-schema/before-you-touch.md +14 -0
  285. package/skills/synap-schema/common-mistakes.md +9 -0
  286. package/skills/synap-schema/creating-profiles.md +17 -0
  287. package/skills/synap-schema/discover-profiles.md +24 -0
  288. package/skills/synap-schema/entity-scope.md +8 -0
  289. package/skills/synap-schema/extend-vs-create.md +31 -0
  290. package/skills/synap-schema/more.md +6 -0
  291. package/skills/synap-schema/overlay-properties.md +22 -0
  292. package/skills/synap-schema/profiles.md +3 -0
  293. package/skills/synap-schema/property-defs.md +29 -0
  294. package/skills/synap-schema/read-before-write.md +50 -0
  295. package/skills/synap-schema/relations.md +18 -0
  296. package/skills/synap-schema/worked-example.md +30 -0
  297. package/skills/synap-ui/SKILL.md +26 -2
  298. package/skills/synap-ui/_order.txt +13 -0
  299. package/skills/synap-ui/_skill.yaml +23 -0
  300. package/skills/synap-ui/ai-companion.md +27 -0
  301. package/skills/synap-ui/arranging-bento.md +8 -0
  302. package/skills/synap-ui/before-you-build.md +20 -0
  303. package/skills/synap-ui/bento-layouts.md +35 -0
  304. package/skills/synap-ui/common-mistakes.md +10 -0
  305. package/skills/synap-ui/creating-views.md +26 -0
  306. package/skills/synap-ui/four-builds.md +24 -0
  307. package/skills/synap-ui/more.md +7 -0
  308. package/skills/synap-ui/proposing-vs-extending.md +11 -0
  309. package/skills/synap-ui/view-list.md +24 -0
  310. package/skills/synap-ui/views.md +3 -0
  311. package/skills/synap-ui/worked-example.md +18 -0
  312. package/skills/synap-ui/workspaces.md +47 -0
  313. package/templates/automations/bookmark-enrichment.automation.json +44 -0
  314. package/templates/automations/mail-feed.automation.json +86 -0
  315. package/templates/automations/url-bookmark-capture.automation.json +70 -0
  316. package/templates/skills/README.md +21 -0
  317. package/templates/skills/authoring-automations/SKILL.md +95 -0
  318. package/templates/skills/authoring-automations/reference/node-matrix.md +49 -0
  319. package/templates/skills/authoring-capabilities/SKILL.md +93 -0
  320. package/templates/skills/authoring-capabilities/reference/template-anatomy.md +70 -0
  321. package/templates/skills/authoring-playbooks/SKILL.md +102 -0
  322. package/templates/skills/authoring-playbooks/reference/stages-contract.md +35 -0
  323. package/templates/skills/authoring-playbooks/reference/worked-example.md +64 -0
  324. package/templates/skills/bookmark-enrichment/SKILL.md +40 -0
  325. package/templates/skills/coordinating-agents/SKILL.md +32 -0
  326. package/templates/skills/coordinating-branches/SKILL.md +27 -0
  327. package/templates/skills/mapping-the-server/SKILL.md +54 -0
  328. package/templates/skills/reading-client-progress/SKILL.md +65 -0
  329. package/templates/skills/routing-to-channels/SKILL.md +54 -0
  330. package/templates/skills/stellar-scf-grants-advisor/ASSOCIATE-CHEATSHEET.md +133 -0
  331. package/templates/skills/stellar-scf-grants-advisor/ASSOCIATE-ONBOARDING-GUIDE.md +761 -0
  332. package/templates/skills/stellar-scf-grants-advisor/CLIENT-FAQ.md +181 -0
  333. package/templates/skills/stellar-scf-grants-advisor/PARTNER-HANDOVER.md +130 -0
  334. package/templates/skills/stellar-scf-grants-advisor/SKILL.md +102 -0
  335. package/templates/skills/stellar-scf-grants-advisor/examples/README.md +43 -0
  336. package/templates/skills/stellar-scf-grants-advisor/examples/chat-summary-templates.md +158 -0
  337. package/templates/skills/stellar-scf-grants-advisor/examples/corpus-comparable-rows.md +136 -0
  338. package/templates/skills/stellar-scf-grants-advisor/examples/q10-team-rewrites.md +109 -0
  339. package/templates/skills/stellar-scf-grants-advisor/examples/q2-description-rewrites.md +88 -0
  340. package/templates/skills/stellar-scf-grants-advisor/examples/q6-integration-rewrites.md +107 -0
  341. package/templates/skills/stellar-scf-grants-advisor/examples/tranche-structures.md +176 -0
  342. package/templates/skills/stellar-scf-grants-advisor/reference/01-methodology.md +96 -0
  343. package/templates/skills/stellar-scf-grants-advisor/reference/02-scoring-framework.md +199 -0
  344. package/templates/skills/stellar-scf-grants-advisor/reference/03-rejection-patterns.md +241 -0
  345. package/templates/skills/stellar-scf-grants-advisor/reference/04-winner-patterns.md +120 -0
  346. package/templates/skills/stellar-scf-grants-advisor/reference/05-comeback-patterns.md +92 -0
  347. package/templates/skills/stellar-scf-grants-advisor/reference/06-budget-benchmarks.md +125 -0
  348. package/templates/skills/stellar-scf-grants-advisor/reference/07-integration-list.md +147 -0
  349. package/templates/skills/stellar-scf-grants-advisor/reference/08-abstract-fields.md +258 -0
  350. package/templates/skills/stellar-scf-grants-advisor/reference/09-build-fields.md +259 -0
  351. package/templates/skills/stellar-scf-grants-advisor/reference/10-corpus-winners.md +135 -0
  352. package/templates/skills/stellar-scf-grants-advisor/reference/11-corpus-rejects.md +267 -0
  353. package/templates/skills/stellar-scf-grants-advisor/reference/12-client-communication.md +131 -0
  354. package/templates/skills/stellar-scf-grants-advisor/reference/13-excel-output-spec.md +199 -0
  355. package/templates/skills/stellar-scf-grants-advisor/reference/14-worked-examples.md +160 -0
  356. package/templates/skills/stellar-scf-grants-advisor/templates/abstract-review-builder.py +253 -0
  357. package/templates/skills/stellar-scf-grants-advisor/templates/build-review-builder.py +213 -0
  358. package/dist/commands/connectors.d.ts +0 -31
  359. package/dist/commands/connectors.js +0 -276
  360. package/dist/commands/connectors.js.map +0 -1
  361. package/dist/commands/finish.d.ts +0 -25
  362. package/dist/commands/finish.js +0 -274
  363. package/dist/commands/finish.js.map +0 -1
  364. package/dist/commands/openclaw.d.ts +0 -40
  365. package/dist/commands/openclaw.js +0 -1148
  366. package/dist/commands/openclaw.js.map +0 -1
  367. package/dist/commands/security-audit.d.ts +0 -12
  368. package/dist/commands/security-audit.js +0 -100
  369. package/dist/commands/security-audit.js.map +0 -1
  370. package/dist/lib/hardening.d.ts +0 -18
  371. package/dist/lib/hardening.js +0 -214
  372. package/dist/lib/hardening.js.map +0 -1
@@ -0,0 +1,1643 @@
1
+ /**
2
+ * `synap market` — discover + install ANY package type.
3
+ * =====================================================
4
+ *
5
+ * `synap launch` is workspace-first: it turns an empty pod into a company from
6
+ * BUNDLED workspace templates (offline). `market` is the superset browser — it
7
+ * reaches EVERY package type the control-plane registry carries
8
+ * (capability / skill / workflow / view / cell / workspace), public or private,
9
+ * over the shared catalog door (`assembleCatalog` + the CP `/api/packages`
10
+ * transport). Suites (`enterprise-os`, `the-arch`) headline via the `suite` tag,
11
+ * not a hardcoded slug.
12
+ *
13
+ * synap market [--search q] [--type t] [--list] [--json] — browse
14
+ * synap market install <slug> — install
15
+ * synap market update [slug] [--yes] [--json] — check/apply drift
16
+ *
17
+ * INSTALL SCOPE: WORKSPACE-type packages install via `/packages/apply` (the
18
+ * same door `launch` uses), which provisions a workspace from the definition.
19
+ * Non-workspace kinds (capability / automation / cell / template) install via
20
+ * the pod's kind-agnostic `market.install` verb on `/capabilities/execute`,
21
+ * which resolves the definition from `cp_catalog_cache`. Types the verb doesn't
22
+ * handle fall back to a route-to-browser message.
23
+ */
24
+ import ora from "ora";
25
+ import chalk from "chalk";
26
+ import { assembleCatalog, computeTemplateUpdates, isHashVersion, listPublicTemplates, listWorkspaceTemplates, toPackageDefinition, layerTemplateGraph, bundledEdgesOf, groupByDomain, } from "@synap-core/workspace-templates";
27
+ import { log } from "../utils/logger.js";
28
+ import { resolveHubConfig, hubPost, renderHubError, HubError } from "../lib/hub-client.js";
29
+ import { fetchProjects } from "../lib/project.js";
30
+ import { writeGovernance } from "../lib/capture-lane.js";
31
+ import { renderNextSteps, FLOW } from "../lib/next-steps.js";
32
+ import { getStoredToken, isTokenLocallyExpired } from "../lib/auth.js";
33
+ import { fetchPublicBrowseRows, fetchMyRows, fetchAvailableSlugs, fetchPackageDefinition, } from "../lib/cp-packages.js";
34
+ import { fetchInstalledSlugs, fetchInstalledTemplates } from "../lib/installed.js";
35
+ import { isSuite } from "../lib/suite.js";
36
+ import { capabilityAdd } from "./capability.js";
37
+ import { bundledTemplatesVersion } from "../lib/bundle-version.js";
38
+ import { HubNetworkError } from "../lib/hub-client.js";
39
+ // Same longer budget launch uses — an apply writes profiles/views/entities.
40
+ const APPLY_TIMEOUT_MS = 120_000;
41
+ /** `--timeout <seconds>` → ms, falling back to `APPLY_TIMEOUT_MS` when unset/invalid. */
42
+ function parseApplyTimeoutMs(raw) {
43
+ const secs = raw ? parseInt(raw, 10) : NaN;
44
+ return Number.isFinite(secs) && secs > 0 ? secs * 1000 : APPLY_TIMEOUT_MS;
45
+ }
46
+ /**
47
+ * Assemble the market catalog: bundled workspace templates ∪ public CP browse
48
+ * (all types) ∪ the caller's own packages (when logged in), through the shared
49
+ * `assembleCatalog` door. Unlike `launch`, PUBLIC CP rows are fetched even when
50
+ * logged out — the bundle only carries workspaces, so all-types discovery needs
51
+ * the CP browse route (which needs no auth).
52
+ */
53
+ export async function buildMarketCatalog(filters) {
54
+ const creds = getStoredToken();
55
+ const loggedIn = !!creds && !isTokenLocallyExpired(creds);
56
+ let reachedCp = true;
57
+ const [publicRows, mineRows, availableSlugs, installed] = await Promise.all([
58
+ fetchPublicBrowseRows(filters).catch(() => {
59
+ reachedCp = false;
60
+ return [];
61
+ }),
62
+ loggedIn && creds
63
+ ? fetchMyRows(creds.token, filters).catch(() => [])
64
+ : Promise.resolve([]),
65
+ loggedIn && creds
66
+ ? fetchAvailableSlugs(creds.token, filters).catch(() => null)
67
+ : Promise.resolve(null),
68
+ fetchInstalledSlugs(),
69
+ ]);
70
+ const merged = assembleCatalog({
71
+ // Logged in → include PRIVATE bundled templates (e.g. the `the-arch` suite),
72
+ // matching the "private templates unlocked" promise. Logged out → public
73
+ // bundle only. (mergeCatalog derives `isPrivate` from each template's
74
+ // `meta.isPublic === false`, so private bundled entries stay badged.)
75
+ bundled: loggedIn ? listWorkspaceTemplates() : listPublicTemplates(),
76
+ remoteRows: [...publicRows, ...mineRows],
77
+ bundleVersion: bundledTemplatesVersion(),
78
+ remoteStatus: reachedCp ? "ok" : "unreachable",
79
+ });
80
+ const tierBySlug = new Map();
81
+ const lockedSlugs = new Set();
82
+ for (const r of publicRows) {
83
+ if (r.requiredTier != null || r.pricingModel != null) {
84
+ tierBySlug.set(r.slug, {
85
+ requiredTier: r.requiredTier ?? null,
86
+ pricingModel: r.pricingModel ?? null,
87
+ });
88
+ }
89
+ if (availableSlugs && r.requiredTier && !availableSlugs.has(r.slug)) {
90
+ lockedSlugs.add(r.slug);
91
+ }
92
+ }
93
+ const bundledSlugs = new Set(listWorkspaceTemplates().map((t) => t.meta.slug));
94
+ // Raw CP-row versions, bypassing the merge winner (see `remoteVersionBySlug`
95
+ // doc above). `/mine` rows are the caller's own — they win a slug collision
96
+ // with a public browse row (matches `assembleCatalog`'s own precedence).
97
+ const remoteVersionBySlug = new Map();
98
+ for (const r of publicRows)
99
+ if (r.version)
100
+ remoteVersionBySlug.set(r.slug, r.version);
101
+ for (const r of mineRows)
102
+ if (r.version)
103
+ remoteVersionBySlug.set(r.slug, r.version);
104
+ return {
105
+ entries: merged.entries,
106
+ tierBySlug,
107
+ lockedSlugs,
108
+ installed,
109
+ bundledSlugs,
110
+ loggedIn,
111
+ reachedCp,
112
+ remoteVersionBySlug,
113
+ };
114
+ }
115
+ /** The package type of an entry — bundled templates are always `workspace`. */
116
+ function typeOf(entry) {
117
+ return entry.category ?? "workspace";
118
+ }
119
+ /**
120
+ * Map a CP package `category` (what {@link typeOf} returns) → the pod
121
+ * `market.install` verb's `kind` vocabulary, or `null` when the verb can't
122
+ * install that type. The CP's live PACKAGE_TYPES and the pod's cache `kind`
123
+ * diverge (see cp-catalog-sync.ts): the CP's `workflow` is the pod's
124
+ * `automation`; the CP's `workspace` is the pod's `template` — but a workspace
125
+ * installs through the dedicated `/packages/apply` create path, so it never
126
+ * routes through here. `automation`/`template` are also accepted verbatim so a
127
+ * row already speaking the pod's cache vocabulary still resolves.
128
+ */
129
+ export function marketInstallKind(type) {
130
+ switch (type) {
131
+ case "capability":
132
+ return "capability";
133
+ case "cell":
134
+ return "cell";
135
+ case "workflow":
136
+ case "automation":
137
+ return "automation";
138
+ case "template":
139
+ return "template";
140
+ default:
141
+ return null;
142
+ }
143
+ }
144
+ /**
145
+ * Client-side filter. The CP browse route filters REMOTE rows server-side, but
146
+ * `assembleCatalog` always adds the BUNDLED (all-`workspace`) templates — so a
147
+ * `--type capability` would otherwise leak bundled workspaces. Applied to the
148
+ * merged list so both sources obey the same filter.
149
+ */
150
+ function matchesFilters(entry, filters) {
151
+ if (!filters)
152
+ return true;
153
+ if (filters.category && typeOf(entry) !== filters.category)
154
+ return false;
155
+ if (filters.search) {
156
+ const q = filters.search.toLowerCase();
157
+ const hay = `${entry.slug} ${entry.name} ${entry.description ?? ""}`.toLowerCase();
158
+ if (!hay.includes(q))
159
+ return false;
160
+ }
161
+ return true;
162
+ }
163
+ function clip(s, n) {
164
+ return s.length <= n ? s : s.slice(0, n - 1).trimEnd() + "…";
165
+ }
166
+ /**
167
+ * Print an honest per-dependency seed summary — no more silent "✓ ready" when
168
+ * a dependency's capabilities/playbooks failed to seed. Prefers the top-level
169
+ * `seedSummary` rollup when present (pre-aggregated by the backend); falls
170
+ * back to re-deriving from the per-dependency `dependencies[]` array on older
171
+ * pod builds. No-op when there's nothing to report.
172
+ */
173
+ function printSeedOutcomes(dependencies, seedSummary) {
174
+ if (seedSummary) {
175
+ if (seedSummary.attempted === 0)
176
+ return;
177
+ if (seedSummary.failed.length === 0) {
178
+ log.success(`${seedSummary.seeded}/${seedSummary.attempted} capabilities seeded`);
179
+ return;
180
+ }
181
+ log.warn(`${seedSummary.seeded}/${seedSummary.attempted} capabilities seeded`);
182
+ for (const f of seedSummary.failed) {
183
+ log.hint(`${f.slug}: failed${f.error ? ` (${f.error})` : ""}`);
184
+ }
185
+ return;
186
+ }
187
+ if (!dependencies?.length)
188
+ return;
189
+ const withLayers = dependencies.filter((d) => d.seedOutcome && d.seedOutcome.status !== "no-layers");
190
+ if (withLayers.length === 0)
191
+ return;
192
+ const seeded = withLayers.filter((d) => d.seedOutcome.status === "seeded");
193
+ const failed = withLayers.filter((d) => d.seedOutcome.status === "failed");
194
+ if (failed.length === 0) {
195
+ log.success(`${seeded.length}/${withLayers.length} capabilities seeded`);
196
+ return;
197
+ }
198
+ log.warn(`${seeded.length}/${withLayers.length} capabilities seeded`);
199
+ for (const d of failed) {
200
+ log.hint(`${d.slug}: failed${d.seedOutcome.error ? ` (${d.seedOutcome.error})` : ""}`);
201
+ }
202
+ }
203
+ // ── Apply-outcome honesty — shared by `marketInstall` + `applyOnePackage` ───
204
+ // Both hit the same `/packages/apply` door and must render the SAME honest
205
+ // verdict from its response: a partial capability-seed failure, a version
206
+ // stamp that silently didn't take, or a compose-overlay result are never a
207
+ // plain "✓ success" — in the human text OR in `--json`.
208
+ /** True when at least one dependency's capability seed genuinely failed. */
209
+ function hasSeedFailure(dependencies, seedSummary) {
210
+ if (seedSummary)
211
+ return seedSummary.failed.length > 0;
212
+ return !!dependencies?.some((d) => d.seedOutcome?.status === "failed");
213
+ }
214
+ /**
215
+ * A version was sent for a template the pod had never stamped before, yet the
216
+ * response either says nothing changed or omits the honest-outcome fields
217
+ * entirely — that combination means the stamp/reconcile didn't actually take,
218
+ * not that the install was genuinely a no-op (the observed `market install
219
+ * the-arch` → "already up to date" bug).
220
+ */
221
+ function detectStampContradiction(ws, seedSummary, remoteVersionSent, wasUnstamped) {
222
+ if (!remoteVersionSent || !wasUnstamped)
223
+ return false;
224
+ return ws?.outcome === "unchanged" || (ws?.outcome === undefined && seedSummary === undefined);
225
+ }
226
+ /**
227
+ * Classify one `/packages/apply` response into the honest verdict both
228
+ * `marketInstall` and `applyOnePackage` print (and both put in `--json`).
229
+ * `wasUnstamped` = the slug was already installed with no recorded version
230
+ * BEFORE this call (only `marketInstall` can detect this today — see its
231
+ * `fetchInstalledTemplates` call; `applyOnePackage` only ever re-applies
232
+ * already-versioned slugs, so it always passes `false`).
233
+ */
234
+ export function classifyApplyResult(entry, ws, dependencies, seedSummary, ctx) {
235
+ const seedFailed = hasSeedFailure(dependencies, seedSummary);
236
+ if (detectStampContradiction(ws, seedSummary, ctx.remoteVersionSent, ctx.wasUnstamped)) {
237
+ return {
238
+ status: "stamp-contradiction",
239
+ warnings: [
240
+ `${entry.name} didn't get stamped — your pod may be running an older version that doesn't support this yet (redeploy the pod backend).`,
241
+ ],
242
+ };
243
+ }
244
+ if (ws?.outcome === "created")
245
+ return { status: seedFailed ? "installed-with-failures" : "installed", warnings: [] };
246
+ if (ws?.outcome === "reconciled")
247
+ return { status: seedFailed ? "updated-with-failures" : "updated", warnings: [] };
248
+ if (ws?.outcome === "unchanged")
249
+ return { status: "unchanged", warnings: [] };
250
+ if (ws?.status === "composed") {
251
+ const warnings = [
252
+ `${entry.name} is a compose-overlay — it rides its base workspace and isn't independently updatable; update the base workspace to update this too.`,
253
+ ];
254
+ return { status: seedFailed ? "composed-with-failures" : "composed", warnings };
255
+ }
256
+ if (ws?.status === "created")
257
+ return { status: "legacy-unknown", warnings: [] };
258
+ return { status: "no-workspace", warnings: [] };
259
+ }
260
+ /** Render a `classifyApplyResult` verdict — the ONE honest-text door for both `marketInstall` and `applyOnePackage`. */
261
+ export function printApplyVerdict(entry, verdict) {
262
+ switch (verdict.status) {
263
+ case "installed":
264
+ log.success(`Installed ${entry.name}.`);
265
+ return;
266
+ case "installed-with-failures":
267
+ log.warn(`Installed ${entry.name} — completed with capability failures (see below).`);
268
+ return;
269
+ case "updated":
270
+ log.success(`Updated ${entry.name}.`);
271
+ return;
272
+ case "updated-with-failures":
273
+ log.warn(`Updated ${entry.name} — completed with capability failures (see below).`);
274
+ return;
275
+ case "unchanged":
276
+ log.info(`• ${entry.name} is already up to date.`);
277
+ return;
278
+ case "composed":
279
+ log.success(`${entry.name} — layered onto its base workspace.`);
280
+ log.hint(verdict.warnings[0]);
281
+ return;
282
+ case "composed-with-failures":
283
+ log.warn(`${entry.name} — layered onto its base workspace, with capability failures (see below).`);
284
+ log.hint(verdict.warnings[0]);
285
+ return;
286
+ case "stamp-contradiction":
287
+ log.warn(verdict.warnings[0]);
288
+ return;
289
+ case "legacy-unknown":
290
+ log.success(`${entry.name} ready.`);
291
+ log.hint("This pod's /packages/apply doesn't distinguish reconciled-vs-unchanged yet — check synap orient if you expected new content.");
292
+ return;
293
+ case "no-workspace":
294
+ log.warn(`${entry.name} — pod returned no workspace.`);
295
+ return;
296
+ }
297
+ }
298
+ // ── `synap market` / `synap market --list` — discovery ───────────────────────
299
+ export async function market(opts) {
300
+ const filters = opts.search || opts.type ? { search: opts.search, category: opts.type } : undefined;
301
+ const cat = await buildMarketCatalog(filters);
302
+ const entries = cat.entries.filter((e) => matchesFilters(e, filters));
303
+ if (opts.json) {
304
+ console.log(JSON.stringify({
305
+ entries: entries.map((e) => {
306
+ const tier = cat.tierBySlug.get(e.slug);
307
+ return {
308
+ slug: e.slug,
309
+ name: e.name,
310
+ description: e.description,
311
+ type: typeOf(e),
312
+ domain: e.domain ?? null,
313
+ tags: e.tags,
314
+ suite: isSuite(e),
315
+ overlay: isOverlay(e),
316
+ ridesBase: composeBaseOf(e.slug),
317
+ bundles: bundleCountOf(e.slug),
318
+ isPrivate: e.isPrivate,
319
+ installed: cat.installed.has(e.slug),
320
+ requiredTier: tier?.requiredTier ?? null,
321
+ pricingModel: tier?.pricingModel ?? null,
322
+ locked: cat.lockedSlugs.has(e.slug),
323
+ source: e.source,
324
+ };
325
+ }),
326
+ loggedIn: cat.loggedIn,
327
+ reachedCp: cat.reachedCp,
328
+ nextSteps: FLOW.afterMarketList(),
329
+ }, null, 2));
330
+ return;
331
+ }
332
+ if (entries.length === 0) {
333
+ log.warn("No packages match.");
334
+ if (!cat.reachedCp)
335
+ log.dim("Couldn't reach the control plane — only bundled workspaces are shown.");
336
+ log.dim("Try 'synap market' with no filter, or 'synap login' to see your private packages.");
337
+ return;
338
+ }
339
+ renderCatalog(entries, cat);
340
+ }
341
+ /**
342
+ * The kind of composition a workspace template participates in, derived from its
343
+ * bundled dependency edges — NOT hardcoded. A `compose` edge = an OVERLAY that
344
+ * rides a base workspace (installing it bare is meaningless); a `require` edge
345
+ * on a suite = a workspace it BUNDLES. Non-bundled (private/CP-only) rows return
346
+ * `null` base and 0 bundles, which the renderer degrades gracefully.
347
+ */
348
+ function composeBaseOf(slug) {
349
+ for (const dep of bundledEdgesOf(slug)) {
350
+ if (dep.relation === "compose")
351
+ return dep.slug;
352
+ }
353
+ return null;
354
+ }
355
+ function bundleCountOf(slug) {
356
+ return bundledEdgesOf(slug).filter((d) => d.relation === "require").length;
357
+ }
358
+ function isOverlay(entry) {
359
+ return typeOf(entry) === "workspace" && !isSuite(entry) && composeBaseOf(entry.slug) !== null;
360
+ }
361
+ /**
362
+ * Section-aware catalog renderer. Three axes carry distinct signal, so each gets
363
+ * its OWN visual channel instead of one flat colored word (the old list):
364
+ * KIND → the SECTION (Suites / Workspaces-by-domain / Overlays / other types)
365
+ * STATE → a leading ✓ glyph (installed)
366
+ * ACCESS → a dim right-hand tag (private / tier / locked)
367
+ * Name reads first (you scan it); the slug is the dim type-token (you type it).
368
+ */
369
+ function renderCatalog(entries, cat) {
370
+ const width = process.stdout.columns ?? 100;
371
+ const NAME_W = 26;
372
+ const SLUG_W = 20;
373
+ // Resolve a base slug to its display name so an overlay names the WORKSPACE it
374
+ // extends, not its raw slug (e.g. "→ Brand Library", not "→ brand-library").
375
+ const nameBySlug = new Map(entries.map((e) => [e.slug, e.name]));
376
+ const accessTag = (entry) => {
377
+ const parts = [];
378
+ if (entry.isPrivate)
379
+ parts.push(chalk.yellow("private"));
380
+ const tier = cat.tierBySlug.get(entry.slug);
381
+ if (tier?.requiredTier)
382
+ parts.push(chalk.cyan(tier.requiredTier));
383
+ else if (tier?.pricingModel && tier.pricingModel !== "free")
384
+ parts.push(chalk.cyan(tier.pricingModel));
385
+ if (cat.lockedSlugs.has(entry.slug))
386
+ parts.push(chalk.red("locked"));
387
+ return parts.length ? " " + parts.join(" ") : "";
388
+ };
389
+ // One item = one line: glyph · bold name · default-weight slug (the token you
390
+ // TYPE — never dimmed) · dim detail · dim access.
391
+ const row = (entry, detail) => {
392
+ const glyph = cat.installed.has(entry.slug) ? chalk.green("✓") : " ";
393
+ const name = clip(entry.name, NAME_W).padEnd(NAME_W);
394
+ const slug = clip(entry.slug, SLUG_W).padEnd(SLUG_W);
395
+ const access = accessTag(entry);
396
+ const head = ` ${glyph} ${chalk.bold(name)} ${slug}`;
397
+ // Budget the detail to the remaining terminal width (never wrap/overflow).
398
+ const detailText = detail ?? entry.description ?? "";
399
+ const used = 2 + 1 + 1 + NAME_W + 1 + SLUG_W + 1;
400
+ const room = Math.max(12, width - used - stripLen(access) - 1);
401
+ console.log(head + " " + chalk.dim(clip(detailText, room)) + access);
402
+ };
403
+ // An overlay's identity IS "Name → Base Workspace" — the base it extends must
404
+ // read as part of what it IS, not be buried in the description. So it gets its
405
+ // own row shape: a combined, coloured identity column, then slug, then blurb.
406
+ const IDENT_W = 40;
407
+ const overlayRow = (entry) => {
408
+ const glyph = cat.installed.has(entry.slug) ? chalk.green("✓") : " ";
409
+ const baseName = clip(nameBySlug.get(composeBaseOf(entry.slug) ?? "") ?? composeBaseOf(entry.slug) ?? "", IDENT_W - 22 - 3 // leave room for "name → " inside IDENT_W
410
+ );
411
+ const nameText = clip(entry.name, 22);
412
+ const identityVisible = `${nameText} → ${baseName}`;
413
+ const identity = chalk.bold(nameText) + chalk.dim(" → ") + chalk.cyan(baseName);
414
+ const pad = " ".repeat(Math.max(1, IDENT_W - identityVisible.length));
415
+ const slug = clip(entry.slug, SLUG_W).padEnd(SLUG_W);
416
+ const access = accessTag(entry);
417
+ const used = 2 + 1 + 1 + IDENT_W + 1 + SLUG_W + 1;
418
+ const room = Math.max(10, width - used - stripLen(access) - 1);
419
+ console.log(` ${glyph} ${identity}${pad} ${slug} ` +
420
+ chalk.dim(clip(entry.description ?? "", room)) +
421
+ access);
422
+ };
423
+ // ── Suites — the headline: one install = a whole operating core ────────────
424
+ const suites = entries.filter((e) => isSuite(e));
425
+ if (suites.length > 0) {
426
+ log.heading(" Suites · a whole operating core in one install");
427
+ log.blank();
428
+ for (const e of suites) {
429
+ const n = bundleCountOf(e.slug);
430
+ row(e, n > 0 ? `bundles ${n} workspace${n === 1 ? "" : "s"}` : e.description ?? "");
431
+ }
432
+ log.blank();
433
+ }
434
+ // ── Workspaces — standalone installs, grouped by authored DOMAIN ───────────
435
+ const workspaces = entries.filter((e) => typeOf(e) === "workspace" && !isSuite(e) && !isOverlay(e));
436
+ if (workspaces.length > 0) {
437
+ log.heading(" Workspaces");
438
+ for (const group of groupByDomain(workspaces, (e) => e.domain)) {
439
+ log.blank();
440
+ console.log(" " + chalk.underline(group.label));
441
+ for (const e of group.items)
442
+ row(e);
443
+ }
444
+ log.blank();
445
+ }
446
+ // ── Add-ons — each rides a workspace you already have (named in-line) ───────
447
+ const overlays = entries.filter((e) => isOverlay(e));
448
+ if (overlays.length > 0) {
449
+ log.heading(" Add-ons · each extends the workspace named after the →");
450
+ log.blank();
451
+ for (const e of overlays)
452
+ overlayRow(e);
453
+ log.blank();
454
+ }
455
+ // ── Other package types (capability / skill / view / cell) by type ─────────
456
+ const other = entries.filter((e) => typeOf(e) !== "workspace");
457
+ if (other.length > 0) {
458
+ const order = ["capability", "skill", "workflow", "view", "cell"];
459
+ const byType = new Map();
460
+ for (const e of other) {
461
+ const t = typeOf(e);
462
+ (byType.get(t) ?? byType.set(t, []).get(t)).push(e);
463
+ }
464
+ const types = [...byType.keys()].sort((a, b) => (order.indexOf(a) + 1 || 99) - (order.indexOf(b) + 1 || 99));
465
+ for (const t of types) {
466
+ log.heading(` ${t.charAt(0).toUpperCase() + t.slice(1)}`);
467
+ log.blank();
468
+ for (const e of byType.get(t))
469
+ row(e);
470
+ log.blank();
471
+ }
472
+ }
473
+ // Footer: a scannable count line + progressive-disclosure hints.
474
+ const installedN = entries.filter((e) => cat.installed.has(e.slug)).length;
475
+ log.dim(`${installedN} installed · ${entries.length - installedN} available` +
476
+ (suites.length ? ` · ${suites.length} suite${suites.length === 1 ? "" : "s"}` : ""));
477
+ if (!cat.loggedIn)
478
+ log.dim("Log in to see your private packages: synap login");
479
+ renderNextSteps(FLOW.afterMarketList());
480
+ }
481
+ /** Visible length of a string with chalk color codes stripped — for width math. */
482
+ function stripLen(s) {
483
+ // eslint-disable-next-line no-control-regex
484
+ return s.replace(/\[[0-9;]*m/g, "").length;
485
+ }
486
+ // ── Optional --project link ──────────────────────────────────────────────────
487
+ /**
488
+ * Installs are POD-WIDE. A template provisions WORKSPACES (pod-level) and their
489
+ * contents are workspace-level — so linking the install to a project would be
490
+ * superfluous. `--project` is a pure OPT-IN to ALSO tag the seeded entities to a
491
+ * project; it is never prompted, defaulted, or taken from a pin. Returns the
492
+ * resolved project (validated) or undefined for the pod-wide default; exits on
493
+ * an unknown explicit id.
494
+ */
495
+ async function resolveOptionalProject(cfg, explicitId) {
496
+ if (!explicitId)
497
+ return undefined;
498
+ const match = (await fetchProjects(cfg)).find((p) => p.id === explicitId);
499
+ if (!match) {
500
+ log.error(`No project "${explicitId}" found on this pod.`);
501
+ log.hint("List projects: synap orient");
502
+ process.exit(1);
503
+ }
504
+ return { projectId: match.id, name: match.name };
505
+ }
506
+ /** Human render of a preflight report — scannable summary + per-issue reasons. */
507
+ function renderPreflightReport(entry, r) {
508
+ // Hard structural failure — the definition itself won't install.
509
+ if (r.validationErrors.length > 0) {
510
+ log.error(`${entry.name} — ${r.validationErrors.length} structural error${r.validationErrors.length === 1 ? "" : "s"} (won't install):`);
511
+ for (const e of r.validationErrors)
512
+ log.hint(e);
513
+ return;
514
+ }
515
+ const p = r.profiles;
516
+ // Every issue class, not just profile conflicts — the verdict and the printed
517
+ // warnings must agree (`r.ok` excludes advisory deferred/scopeConflicts, so
518
+ // "cleanly" alone could sit above a ⚠ line; count them all here).
519
+ const issueCounts = [];
520
+ if (p.conflicts.length)
521
+ issueCounts.push(`${p.conflicts.length} profile conflict${p.conflicts.length === 1 ? "" : "s"}`);
522
+ if (p.deferred.length)
523
+ issueCounts.push(`${p.deferred.length} duplicate${p.deferred.length === 1 ? "" : "s"}`);
524
+ if (p.scopeConflicts.length)
525
+ issueCounts.push(`${p.scopeConflicts.length} scope note${p.scopeConflicts.length === 1 ? "" : "s"}`);
526
+ if (r.entityLinks.unresolved.length)
527
+ issueCounts.push(`${r.entityLinks.unresolved.length} dropped link${r.entityLinks.unresolved.length === 1 ? "" : "s"}`);
528
+ if (r.views.wouldOrphan.length)
529
+ issueCounts.push(`${r.views.wouldOrphan.length} orphaned view${r.views.wouldOrphan.length === 1 ? "" : "s"}`);
530
+ const blocking = !r.ok; // conflicts / dropped links / orphaned views
531
+ const hasAny = issueCounts.length > 0;
532
+ // Verdict — never says "clean" if any warning will print below.
533
+ if (!hasAny)
534
+ log.success(`Preflight for ${entry.name}: no conflicts — safe to install.`);
535
+ else if (blocking)
536
+ log.warn(`Preflight for ${entry.name}: would install with issues — review before installing.`);
537
+ else
538
+ log.warn(`Preflight for ${entry.name}: installs, with ${issueCounts.length} advisory note${issueCounts.length === 1 ? "" : "s"} — review below.`);
539
+ log.dim(`Profiles: ${p.create.length} to create · ${p.reused.length} reused`);
540
+ if (hasAny)
541
+ log.dim(`Issues: ${issueCounts.join(" · ")}`);
542
+ // Warnings are the PAYLOAD of a dry-run — print them BEFORE the (potentially
543
+ // long) create/reuse list so they never scroll off-screen.
544
+ // The load-bearing signal for an AI author: a declared role/kind that clashes
545
+ // with an existing pod profile is SKIPPED — never created, never mutated.
546
+ for (const c of p.conflicts)
547
+ log.warn(`${c.slug}: declared ${c.declaredKind}, pod has ${c.existingKind} → SKIP (not created)`);
548
+ for (const d of p.deferred)
549
+ log.warn(`${d.slug}: same slug exists but not promotable (${d.reason}) → a duplicate would be created`);
550
+ for (const sc of p.scopeConflicts)
551
+ log.warn(`${sc.slug}: declared scope ${sc.declaredScope}, pod has ${sc.existingScope} → reused as-is (declared scope not applied)`);
552
+ for (const link of r.entityLinks.unresolved)
553
+ log.warn(`link ${link}: an endpoint profile is a conflict → relation dropped`);
554
+ for (const v of r.views.wouldOrphan)
555
+ log.warn(`view "${v}": its scope profile is a conflict → view would have no scope`);
556
+ // One actionable hint for the whole conflict/link/view class — the remedy the
557
+ // per-line messages don't give.
558
+ if (p.conflicts.length || r.entityLinks.unresolved.length || r.views.wouldOrphan.length)
559
+ log.hint("Conflicts are skipped, never merged — rename the template's profile to a new slug, or install as-is to reuse the pod's existing one.");
560
+ for (const slug of p.create)
561
+ log.dim(`+ ${slug} (new profile)`);
562
+ for (const slug of p.reused)
563
+ log.dim(`↺ ${slug} (reuses existing pod profile)`);
564
+ if (!hasAny)
565
+ log.hint("Run without --dry-run to install.");
566
+ }
567
+ // ── `synap market install <slug>` ───────────────────────────────────────────
568
+ export async function marketInstall(slug, opts) {
569
+ const applyTimeoutMs = parseApplyTimeoutMs(opts.timeout);
570
+ // `installedTemplates` (not just `cat.installed`'s slug set) is fetched here
571
+ // too — it carries per-slug VERSION, which is what lets the stamp-
572
+ // contradiction check below tell "genuinely unchanged" apart from "the pod
573
+ // silently failed to stamp/reconcile a previously-unstamped install".
574
+ const [cat, installedTemplates] = await Promise.all([buildMarketCatalog(), fetchInstalledTemplates()]);
575
+ const entry = cat.entries.find((e) => e.slug === slug);
576
+ if (!entry) {
577
+ log.error(`No package "${slug}" found.`);
578
+ log.hint("Browse what's available: synap market --list");
579
+ process.exit(1);
580
+ }
581
+ const type = typeOf(entry);
582
+ // A CAPABILITY installs through the SAME door as `cap add` — one code path, so
583
+ // the two commands can never diverge. That door resolves the template by key
584
+ // (so opt-in / syncByDefault:false caps like unipile-linkedin work — the
585
+ // params-less market.install verb below cannot) AND prompts for any required
586
+ // credential inline (a key), which a server-side verb can never do. `cap add`
587
+ // is now a thin alias of this. `--pod` is honoured (capabilityAdd resolves the
588
+ // active pod itself); `--json` prompts can't render, so a credentialed cap
589
+ // still prompts — run it interactively.
590
+ if (type === "capability") {
591
+ await capabilityAdd(slug, {});
592
+ return;
593
+ }
594
+ // Other non-workspace packages install through the pod's ONE kind-agnostic
595
+ // door — the `market.install` verb on `/capabilities/execute`. (The WORKSPACE
596
+ // path below uses `/packages/apply`, which provisions a workspace, and is
597
+ // left untouched.) The verb resolves the definition from `cp_catalog_cache`
598
+ // by (kind, slug), tier-gates, and — for the CLI's OPERATOR hub key (no
599
+ // agentUserId) — installs directly; an agent call would file a proposal.
600
+ if (type !== "workspace") {
601
+ const kind = marketInstallKind(type);
602
+ if (!kind) {
603
+ log.warn(`"${entry.name}" is a ${type} package — the CLI can't install this type yet.`);
604
+ log.hint("Install it from the browser marketplace (Marketplace → find it → Install).");
605
+ return;
606
+ }
607
+ let cfg;
608
+ try {
609
+ cfg = await resolveHubConfig(opts);
610
+ }
611
+ catch (e) {
612
+ renderHubError(e);
613
+ process.exit(1);
614
+ }
615
+ const s = opts.json ? null : ora(`Installing ${entry.name}…`).start();
616
+ let res;
617
+ try {
618
+ // Pod-wide install: no workspaceId (a capability/cell/template lands on
619
+ // the pod). `market.install` is a WRITE builtin — an operator run
620
+ // owner-bypasses the gate and runs it in-process.
621
+ res = (await hubPost("/capabilities/execute", { verbId: "market.install", parameters: { slug, kind } }, cfg, applyTimeoutMs));
622
+ }
623
+ catch (e) {
624
+ s?.stop();
625
+ // 404 = this pod predates the seeded `market.install` verb (older deploy).
626
+ if (e instanceof HubError && e.status === 404) {
627
+ log.error(`This pod doesn't expose the market.install verb yet.`);
628
+ log.hint("It needs a newer pod deploy — meanwhile install from the browser marketplace.");
629
+ process.exit(1);
630
+ }
631
+ // The verb throws NOT_FOUND (surfaced as a 500) when the CP kind hasn't
632
+ // synced into `cp_catalog_cache` yet — say so actionably, not opaquely.
633
+ if (e instanceof HubError && /catalog cache/i.test(e.message)) {
634
+ log.error(`"${entry.name}" isn't in the pod's marketplace cache yet.`);
635
+ log.hint("The pod syncs the catalog every ~10 min — retry shortly, or install from the browser marketplace.");
636
+ process.exit(1);
637
+ }
638
+ log.error(`${entry.name} failed`);
639
+ renderHubError(e);
640
+ process.exit(1);
641
+ }
642
+ s?.stop();
643
+ // The execute door wraps the verb's return as { status:"run", result: <MarketInstallOutcome> }.
644
+ // The outcome is { status:"installed", result } (operator) or { status:"proposed", proposalId, reviewUrl }.
645
+ const outcome = (res.result ?? res);
646
+ const status = String(outcome.status ?? "installed");
647
+ if (opts.json) {
648
+ console.log(JSON.stringify({ slug, kind, outcome: status, result: outcome }, null, 2));
649
+ return;
650
+ }
651
+ if (status === "proposed") {
652
+ const proposalId = outcome.proposalId ? String(outcome.proposalId) : undefined;
653
+ log.info(`${entry.name} — proposed (under review, not live yet).`);
654
+ if (proposalId)
655
+ log.hint(`Approve: synap proposals approve ${proposalId}`);
656
+ return;
657
+ }
658
+ log.success(`Installed ${entry.name}.`);
659
+ return;
660
+ }
661
+ // Tier gate: don't silently attempt an ungrantable install.
662
+ if (cat.lockedSlugs.has(slug)) {
663
+ const tier = cat.tierBySlug.get(slug)?.requiredTier;
664
+ log.warn(`"${entry.name}" requires the ${tier ?? "a higher"} tier — your account can't install it yet.`);
665
+ log.hint("Upgrade your plan, then retry. Manage it from the browser billing settings.");
666
+ return;
667
+ }
668
+ let cfg;
669
+ try {
670
+ cfg = await resolveHubConfig(opts);
671
+ }
672
+ catch (e) {
673
+ renderHubError(e);
674
+ process.exit(1);
675
+ }
676
+ // ── Install target: POD-WIDE. ────────────────────────────────────────────
677
+ // A template just provisions its workspace(s) pod-wide — no project needed.
678
+ // `--project` is an optional add-on to also tag the seeded entities to a
679
+ // project (never prompted, never from a pin). Default = pod-wide.
680
+ const project = await resolveOptionalProject(cfg, opts.project);
681
+ const projectId = project?.projectId;
682
+ const projectName = project?.name;
683
+ if (!opts.json && project) {
684
+ log.info(`Also linking seeded entities to project ${chalk.bold(project.name)}.`);
685
+ }
686
+ // Build the install payload — bundled (offline, byte-identical to the CP
687
+ // definition) when available, else the authed CP definition.
688
+ const cpToken = getStoredToken()?.token;
689
+ let pkg;
690
+ if (cat.bundledSlugs.has(slug)) {
691
+ pkg = toPackageDefinition(slug);
692
+ }
693
+ else {
694
+ const def = await fetchPackageDefinition(slug, cpToken);
695
+ if (!def) {
696
+ log.error(`Couldn't fetch "${entry.name}"'s definition from the control plane.`);
697
+ log.hint(cat.loggedIn ? "" : "It may be private — try: synap login");
698
+ process.exit(1);
699
+ }
700
+ pkg = def;
701
+ }
702
+ // Stamp the version the catalog resolved for this slug — including the
703
+ // authed `/mine` version for private templates — so the pod records the
704
+ // installed baseline and later `market update` drift checks work.
705
+ const remoteVersion = cat.remoteVersionBySlug.get(slug);
706
+ if (remoteVersion)
707
+ pkg._meta = { ...(pkg._meta ?? {}), version: remoteVersion };
708
+ // ── --dry-run: write-free preflight of the CREATE path. ───────────────────
709
+ // Runs the pod's REAL resolver (write-free) so a template author sees
710
+ // profileKind conflicts and other resolution failures BEFORE installing.
711
+ // (Previews the create path only; `--onto` reconcile-preview is not modeled.)
712
+ if (opts.dryRun) {
713
+ const s = opts.json ? null : ora(`Checking ${entry.name}…`).start();
714
+ try {
715
+ const report = (await hubPost("/packages/preflight", pkg, cfg, applyTimeoutMs));
716
+ s?.stop();
717
+ if (opts.json) {
718
+ console.log(JSON.stringify({ slug, ...report }, null, 2));
719
+ return;
720
+ }
721
+ renderPreflightReport(entry, report);
722
+ return;
723
+ }
724
+ catch (e) {
725
+ s?.stop();
726
+ log.error(`${entry.name} preflight failed`);
727
+ renderHubError(e);
728
+ process.exit(1);
729
+ }
730
+ }
731
+ // Pre-apply state — was this slug already installed with NO version stamp?
732
+ // Feeds `classifyApplyResult`'s stamp-contradiction check below.
733
+ const preInstalled = installedTemplates.find((t) => t.slug === slug);
734
+ const wasUnstamped = !!preInstalled && !preInstalled.version;
735
+ const s = opts.json ? null : ora(`Installing ${entry.name}…`).start();
736
+ try {
737
+ // projectId links the workspace's seed entities to the project
738
+ // (belongs_to_project) — exactly what `launch` does. Undefined = pod-wide.
739
+ // targetWorkspaceId (--onto) reconciles the template ADDITIVELY onto an
740
+ // existing workspace instead of creating a new one.
741
+ const res = (await hubPost("/packages/apply", { ...pkg, projectId, targetWorkspaceId: opts.onto }, cfg, applyTimeoutMs));
742
+ if (writeGovernance(res) === "proposed") {
743
+ const proposalId = res.proposalId ? String(res.proposalId) : undefined;
744
+ const steps = FLOW.afterMarketInstall({ slug, proposed: true, projectName });
745
+ s?.stop();
746
+ if (opts.json) {
747
+ console.log(JSON.stringify({ slug, outcome: "proposed", proposalId, projectId, nextSteps: steps }, null, 2));
748
+ return;
749
+ }
750
+ log.info(`${entry.name} — proposed (under review, not live yet).`);
751
+ if (proposalId)
752
+ log.hint(`Approve: synap proposals approve ${proposalId}`);
753
+ renderNextSteps(steps);
754
+ return;
755
+ }
756
+ const ws = res.workspace;
757
+ const dependencies = res.dependencies;
758
+ const seedSummary = res.seedSummary;
759
+ const steps = FLOW.afterMarketInstall({ slug, proposed: false, projectName });
760
+ s?.stop();
761
+ const verdict = classifyApplyResult(entry, ws, dependencies, seedSummary, {
762
+ remoteVersionSent: remoteVersion,
763
+ wasUnstamped,
764
+ });
765
+ if (opts.json) {
766
+ console.log(JSON.stringify({
767
+ slug,
768
+ outcome: ws?.outcome ?? ws?.status ?? "unknown",
769
+ status: verdict.status,
770
+ warnings: verdict.warnings,
771
+ workspace: ws,
772
+ dependencies,
773
+ seedSummary,
774
+ projectId,
775
+ onto: opts.onto,
776
+ nextSteps: steps,
777
+ }, null, 2));
778
+ return;
779
+ }
780
+ // `--onto` reconciles onto an EXISTING workspace — the honest verdict
781
+ // reads "reconciled onto <workspace>", not the create-flavored default
782
+ // text `printApplyVerdict` would otherwise print for the same outcomes.
783
+ if (opts.onto) {
784
+ const wsLabel = ws?.workspaceId ? chalk.bold(ws.workspaceId) : chalk.bold(opts.onto);
785
+ switch (verdict.status) {
786
+ case "installed":
787
+ case "updated":
788
+ log.success(`Reconciled ${entry.name} onto ${wsLabel}.`);
789
+ break;
790
+ case "installed-with-failures":
791
+ case "updated-with-failures":
792
+ log.warn(`Reconciled ${entry.name} onto ${wsLabel} — completed with capability failures (see below).`);
793
+ break;
794
+ case "unchanged":
795
+ log.info(`• ${entry.name} is already up to date on ${wsLabel}.`);
796
+ break;
797
+ default:
798
+ printApplyVerdict(entry, verdict);
799
+ }
800
+ }
801
+ else {
802
+ printApplyVerdict(entry, verdict);
803
+ }
804
+ printSeedOutcomes(dependencies, seedSummary);
805
+ if (ws?.outcome || ws?.status === "composed" || ws?.status === "created")
806
+ renderNextSteps(steps);
807
+ }
808
+ catch (e) {
809
+ s?.stop();
810
+ log.error(`${entry.name} failed`);
811
+ renderHubError(e);
812
+ process.exit(1);
813
+ }
814
+ }
815
+ /**
816
+ * Per-installed-slug drift check. Starts from the shared `computeTemplateUpdates`
817
+ * (handles bundled-only / private-only slugs via `bundleVersion`), then
818
+ * overrides `latestVersion`/`updateAvailable` with the CP row's OWN version for
819
+ * any slug in `remoteVersionBySlug` — the merge-winner bypass this command
820
+ * needs (see `MarketCatalog.remoteVersionBySlug`'s doc). A slug the pod never
821
+ * version-stamped is reported `noVersionInfo: true`, never silently "outdated".
822
+ */
823
+ export function computeUpdates(installedTemplates, cat) {
824
+ const withVersion = installedTemplates.filter((t) => !!t.version);
825
+ const base = computeTemplateUpdates(withVersion.map((t) => ({ slug: t.slug, version: t.version })), cat.entries, bundledTemplatesVersion());
826
+ const baseBySlug = new Map(base.map((b) => [b.slug, b]));
827
+ return installedTemplates.map((t) => {
828
+ if (!t.version) {
829
+ return {
830
+ slug: t.slug,
831
+ installedVersion: "",
832
+ latestVersion: cat.remoteVersionBySlug.get(t.slug),
833
+ updateAvailable: false,
834
+ noVersionInfo: true,
835
+ };
836
+ }
837
+ const b = baseBySlug.get(t.slug);
838
+ const remoteVersion = cat.remoteVersionBySlug.get(t.slug);
839
+ if (remoteVersion) {
840
+ return {
841
+ ...b,
842
+ latestVersion: remoteVersion,
843
+ updateAvailable: remoteVersion !== t.version,
844
+ noVersionInfo: false,
845
+ };
846
+ }
847
+ return { ...b, noVersionInfo: false };
848
+ });
849
+ }
850
+ /**
851
+ * Apply one package via the SAME door `marketInstall` uses
852
+ * (`POST /packages/apply`) — for an already-installed slug this re-runs the
853
+ * pod's idempotent-create path, which reconciles when the pod build supports
854
+ * it (see the `applyOnePackage` caveat below for what "created" means here).
855
+ */
856
+ async function applyOnePackage(entry, cat, cfg, opts, applyTimeoutMs = APPLY_TIMEOUT_MS) {
857
+ if (cat.lockedSlugs.has(entry.slug)) {
858
+ const tier = cat.tierBySlug.get(entry.slug)?.requiredTier;
859
+ if (!opts.json)
860
+ log.warn(`"${entry.name}" requires the ${tier ?? "a higher"} tier — skipping.`);
861
+ return { slug: entry.slug, outcome: "locked", status: "locked", warnings: [] };
862
+ }
863
+ const cpToken = getStoredToken()?.token;
864
+ let pkg;
865
+ if (cat.bundledSlugs.has(entry.slug)) {
866
+ pkg = toPackageDefinition(entry.slug);
867
+ }
868
+ else {
869
+ const def = await fetchPackageDefinition(entry.slug, cpToken);
870
+ if (!def) {
871
+ if (!opts.json) {
872
+ log.error(`Couldn't fetch "${entry.name}"'s definition from the control plane.`);
873
+ if (!cat.loggedIn)
874
+ log.hint("It may be private — try: synap login");
875
+ }
876
+ return { slug: entry.slug, outcome: "fetch-failed", status: "fetch-failed", warnings: [] };
877
+ }
878
+ pkg = def;
879
+ }
880
+ // Same version stamp as `marketInstall` — needed for private templates too,
881
+ // since `applyOnePackage` re-runs the apply door on every update.
882
+ const remoteVersion = cat.remoteVersionBySlug.get(entry.slug);
883
+ if (remoteVersion)
884
+ pkg._meta = { ...(pkg._meta ?? {}), version: remoteVersion };
885
+ const s = opts.json ? null : ora(`Updating ${entry.name}…`).start();
886
+ try {
887
+ const res = (await hubPost("/packages/apply", pkg, cfg, applyTimeoutMs));
888
+ s?.stop();
889
+ if (writeGovernance(res) === "proposed") {
890
+ const proposalId = res.proposalId ? String(res.proposalId) : undefined;
891
+ if (!opts.json) {
892
+ log.info(`${entry.name} — update proposed (under review, not live yet).`);
893
+ if (proposalId)
894
+ log.hint(`Approve: synap proposals approve ${proposalId}`);
895
+ }
896
+ return { slug: entry.slug, outcome: "proposed", status: "proposed", warnings: [] };
897
+ }
898
+ const ws = res.workspace;
899
+ const dependencies = res.dependencies;
900
+ const seedSummary = res.seedSummary;
901
+ // `wasUnstamped: false` — `applyOnePackage` is only ever reached (from
902
+ // `marketUpdate`) for a slug that already had `updateAvailable: true`,
903
+ // which by construction means it already carried a recorded version. The
904
+ // stamp-contradiction check is therefore a no-op here by design; it's
905
+ // `marketInstall`'s job to catch it on a first-time/unstamped install.
906
+ const verdict = classifyApplyResult(entry, ws, dependencies, seedSummary, {
907
+ remoteVersionSent: remoteVersion,
908
+ wasUnstamped: false,
909
+ });
910
+ if (!opts.json) {
911
+ printApplyVerdict(entry, verdict);
912
+ printSeedOutcomes(dependencies, seedSummary);
913
+ }
914
+ return {
915
+ slug: entry.slug,
916
+ outcome: ws?.outcome ?? ws?.status ?? "unknown",
917
+ status: verdict.status,
918
+ warnings: verdict.warnings,
919
+ dependencies,
920
+ seedSummary,
921
+ };
922
+ }
923
+ catch (e) {
924
+ s?.stop();
925
+ const timedOut = e instanceof HubNetworkError && e.code === "TIMEOUT";
926
+ if (!opts.json) {
927
+ log.error(`${entry.name} update failed`);
928
+ renderHubError(e);
929
+ }
930
+ return {
931
+ slug: entry.slug,
932
+ outcome: timedOut ? "timed-out" : "failed",
933
+ status: timedOut ? "timed-out" : "failed",
934
+ warnings: [],
935
+ error: e instanceof Error ? e.message : String(e),
936
+ };
937
+ }
938
+ }
939
+ export async function marketUpdate(slugsArg, opts) {
940
+ const applyTimeoutMs = parseApplyTimeoutMs(opts.timeout);
941
+ const [installedTemplates, cat] = await Promise.all([fetchInstalledTemplates(), buildMarketCatalog()]);
942
+ if (installedTemplates.length === 0) {
943
+ if (opts.json) {
944
+ console.log(JSON.stringify({ checks: [], loggedIn: cat.loggedIn }, null, 2));
945
+ return;
946
+ }
947
+ log.warn("No installed packages found on this pod.");
948
+ log.hint("Install one: synap market install <slug>");
949
+ return;
950
+ }
951
+ let checks = computeUpdates(installedTemplates, cat);
952
+ const entryBySlug = new Map(cat.entries.map((e) => [e.slug, e]));
953
+ const explicitSlugs = !!slugsArg && slugsArg.length > 0;
954
+ let unknownSlugs = [];
955
+ if (explicitSlugs) {
956
+ const known = new Set(checks.map((c) => c.slug));
957
+ unknownSlugs = slugsArg.filter((s) => !known.has(s));
958
+ const validSlugs = slugsArg.filter((s) => known.has(s));
959
+ for (const s of unknownSlugs) {
960
+ if (!opts.json)
961
+ log.error(`"${s}" is not installed on this pod — skipping.`);
962
+ }
963
+ if (validSlugs.length === 0) {
964
+ if (opts.json) {
965
+ console.log(JSON.stringify({ checks: [], loggedIn: cat.loggedIn, unknownSlugs, applied: [] }, null, 2));
966
+ return;
967
+ }
968
+ log.hint("List what's installed: synap market installed");
969
+ process.exit(1);
970
+ }
971
+ checks = checks.filter((c) => validSlugs.includes(c.slug));
972
+ }
973
+ const withUpdates = checks.filter((c) => c.updateAvailable);
974
+ const noVersionInfo = checks.filter((c) => c.noVersionInfo);
975
+ // Private templates resolve only via the authed CP path — an update check
976
+ // that found NOTHING while logged out is ambiguous (could be genuinely
977
+ // current, could be blind to private drift). Say so rather than staying
978
+ // silently quiet.
979
+ if (!cat.loggedIn && !opts.json) {
980
+ log.dim("Not logged in — private template updates aren't visible. Run: synap login");
981
+ }
982
+ // --dry-run is a NAMED alias for the "no --yes" preview path — forces the
983
+ // preview even when --yes or explicit slugs would otherwise trigger apply.
984
+ const shouldApply = !opts.dryRun && (explicitSlugs || opts.yes);
985
+ if (!opts.json) {
986
+ log.heading(" Installed packages");
987
+ log.blank();
988
+ for (const c of checks) {
989
+ const label = c.noVersionInfo
990
+ ? noVersionLabel(entryBySlug.get(c.slug)?.isPrivate ?? false, cat.loggedIn)
991
+ : c.updateAvailable
992
+ ? chalk.yellow(`update available `) + chalk.dim(c.installedVersion) + " → " + c.latestVersion
993
+ : chalk.green("up to date");
994
+ console.log(" " + chalk.cyan(c.slug.padEnd(28)) + label);
995
+ }
996
+ log.blank();
997
+ if (noVersionInfo.length > 0) {
998
+ log.dim(`${noVersionInfo.length} installed without a version stamp — can't check ${noVersionInfo.length === 1 ? "it" : "them"} for updates.`);
999
+ }
1000
+ }
1001
+ if (withUpdates.length === 0) {
1002
+ // "Nothing to update" is only honest when every installed row was
1003
+ // actually CHECKED. Rows the pod can't version-check are "couldn't be
1004
+ // checked", not silently folded into a clean ✓ — see rule #2 in this
1005
+ // file's header.
1006
+ if (opts.json) {
1007
+ console.log(JSON.stringify({
1008
+ checks,
1009
+ loggedIn: cat.loggedIn,
1010
+ unknownSlugs,
1011
+ applied: [],
1012
+ status: noVersionInfo.length > 0 ? "partial" : "clean",
1013
+ uncheckable: noVersionInfo.map((c) => c.slug),
1014
+ }, null, 2));
1015
+ }
1016
+ else if (noVersionInfo.length > 0) {
1017
+ const upToDate = checks.length - noVersionInfo.length;
1018
+ console.log(" " +
1019
+ chalk.yellow(`⚠ ${upToDate} up to date · ${noVersionInfo.length} couldn't be checked`));
1020
+ log.hint("Run 'synap market installed' for why — unstamped install (reinstall to fix) or a private template while logged out (synap login).");
1021
+ }
1022
+ else {
1023
+ log.success("Nothing to update.");
1024
+ }
1025
+ return;
1026
+ }
1027
+ if (!shouldApply) {
1028
+ const steps = FLOW.afterMarketUpdateCheck(withUpdates.map((c) => c.slug));
1029
+ if (opts.json) {
1030
+ console.log(JSON.stringify({ checks, loggedIn: cat.loggedIn, unknownSlugs, applied: [], nextSteps: steps }, null, 2));
1031
+ return;
1032
+ }
1033
+ log.info(`${withUpdates.length} package${withUpdates.length === 1 ? "" : "s"} have updates.`);
1034
+ renderNextSteps(steps);
1035
+ return;
1036
+ }
1037
+ let cfg;
1038
+ try {
1039
+ cfg = await resolveHubConfig(opts);
1040
+ }
1041
+ catch (e) {
1042
+ renderHubError(e);
1043
+ process.exit(1);
1044
+ }
1045
+ const results = [];
1046
+ for (const c of withUpdates) {
1047
+ const entry = cat.entries.find((e) => e.slug === c.slug);
1048
+ if (!entry) {
1049
+ if (!opts.json)
1050
+ log.warn(`"${c.slug}" is no longer in the catalog — skipping.`);
1051
+ results.push({ slug: c.slug, outcome: "not-in-catalog", status: "not-in-catalog", warnings: [] });
1052
+ continue;
1053
+ }
1054
+ results.push(await applyOnePackage(entry, cat, cfg, { json: opts.json }, applyTimeoutMs));
1055
+ }
1056
+ const summary = summarizeApplyResults(results);
1057
+ const steps = FLOW.afterMarketUpdateApply();
1058
+ if (opts.json) {
1059
+ console.log(JSON.stringify({ checks, loggedIn: cat.loggedIn, unknownSlugs, applied: results, summary, nextSteps: steps }, null, 2));
1060
+ return;
1061
+ }
1062
+ log.blank();
1063
+ console.log(" " + summary.line);
1064
+ renderNextSteps(steps);
1065
+ }
1066
+ /**
1067
+ * One-line rollup over a batch of `applyOnePackage` outcomes — computed from
1068
+ * the SAME per-item `status` (`classifyApplyResult`'s honest verdict) the
1069
+ * loop already collects, not a separate tally. `"installed"`/`"updated"`/
1070
+ * `"composed"`/`"legacy-unknown"` count as a clean update, `"unchanged"` as
1071
+ * already-current, the `*-with-failures` and `"stamp-contradiction"` statuses
1072
+ * get their OWN bucket — a partial capability-seed failure or a stamp that
1073
+ * silently didn't take is never folded into a clean "updated" count.
1074
+ * `"timed-out"` is broken out from the generic `"failed"` bucket; everything
1075
+ * else (locked/proposed/fetch-failed/not-in-catalog/no-workspace/unknown)
1076
+ * rolls into "failed".
1077
+ */
1078
+ function summarizeApplyResults(results) {
1079
+ let updated = 0;
1080
+ let unchanged = 0;
1081
+ let withFailures = 0;
1082
+ let timedOut = 0;
1083
+ let failed = 0;
1084
+ for (const r of results) {
1085
+ switch (r.status) {
1086
+ case "installed":
1087
+ case "updated":
1088
+ case "composed":
1089
+ case "legacy-unknown":
1090
+ updated++;
1091
+ break;
1092
+ case "unchanged":
1093
+ unchanged++;
1094
+ break;
1095
+ case "installed-with-failures":
1096
+ case "updated-with-failures":
1097
+ case "composed-with-failures":
1098
+ case "stamp-contradiction":
1099
+ withFailures++;
1100
+ break;
1101
+ case "timed-out":
1102
+ timedOut++;
1103
+ break;
1104
+ default:
1105
+ failed++;
1106
+ }
1107
+ }
1108
+ const parts = [`${updated} updated`, `${unchanged} already current`];
1109
+ if (withFailures > 0)
1110
+ parts.push(`${withFailures} completed with failures`);
1111
+ if (failed > 0)
1112
+ parts.push(`${failed} failed`);
1113
+ if (timedOut > 0)
1114
+ parts.push(`${timedOut} timed out`);
1115
+ const clean = withFailures === 0 && failed === 0 && timedOut === 0;
1116
+ const color = clean ? chalk.green : chalk.yellow;
1117
+ return {
1118
+ updated,
1119
+ unchanged,
1120
+ withFailures,
1121
+ failed,
1122
+ timedOut,
1123
+ line: color(`${clean ? "✓" : "⚠"} ${parts.join(" · ")}`),
1124
+ };
1125
+ }
1126
+ // ── `synap market installed [--json] [--outdated]` — pure-read inventory ────
1127
+ /** `"h-<hash>"` → `"h-<first 6 hash chars>"`. Semver strings pass through. */
1128
+ function truncateVersion(v) {
1129
+ return isHashVersion(v) ? `h-${v.slice(2, 8)}` : v;
1130
+ }
1131
+ /**
1132
+ * Honest `noVersionInfo` label — a PRIVATE template that's blind to updates
1133
+ * because the caller is logged out is a different (recoverable) situation
1134
+ * than a genuinely unstampable install: "reinstall" is the wrong fix when
1135
+ * the real fix is `synap login`.
1136
+ */
1137
+ function noVersionLabel(isPrivate, loggedIn) {
1138
+ return isPrivate && !loggedIn
1139
+ ? chalk.dim("connect to the Control Plane to check/update — run: synap login")
1140
+ : chalk.dim("version unknown — reinstall to enable update checks");
1141
+ }
1142
+ /**
1143
+ * Human status cell for one installed row — distinguishes hash drift (no
1144
+ * ordering, just "changed") from semver drift ("update available").
1145
+ */
1146
+ function installedStatusLabel(c, isPrivate, loggedIn) {
1147
+ if (c.noVersionInfo)
1148
+ return noVersionLabel(isPrivate, loggedIn);
1149
+ if (!c.updateAvailable)
1150
+ return chalk.green("up to date");
1151
+ const installed = truncateVersion(c.installedVersion);
1152
+ const latest = c.latestVersion ? truncateVersion(c.latestVersion) : "?";
1153
+ const hashPair = isHashVersion(c.installedVersion) || isHashVersion(c.latestVersion ?? "");
1154
+ const label = hashPair ? "content changed" : "update available";
1155
+ return chalk.yellow(`${label} `) + chalk.dim(installed) + " → " + latest;
1156
+ }
1157
+ // ── Composition engine — the ONE traversal door ─────────────────────────────
1158
+ //
1159
+ // Both `--tree` (nested view) and `--layers` (flat DAG view) are RENDERERS
1160
+ // over the shared `layerTemplateGraph` engine (`@synap-core/workspace-
1161
+ // templates`) — neither hand-walks `dependencies[]` itself anymore. The old
1162
+ // bespoke walker (`collectDescendantSlugs`/`buildDependencyNode`) rebuilt a
1163
+ // fresh node for every OCCURRENCE of a shared slug (`foundation` printed once
1164
+ // per branch that reached it) and was blind to a remote/private CP-only
1165
+ // package's `dependencies[]` (only bundled templates were ever consulted, so
1166
+ // such a package's subtree silently rendered empty — a live bug). The engine
1167
+ // fixes both: one node per slug at its longest-path layer, edges converging
1168
+ // on it, via an INJECTED `edgesOf` (see `buildEdgesOf` below) that falls back
1169
+ // to a remote catalog entry's fetched `definition.dependencies` when the slug
1170
+ // isn't in the bundle.
1171
+ /**
1172
+ * Synchronous `edgesOf` for `layerTemplateGraph`, backed by an async prefetch
1173
+ * BFS: bundled slugs resolve instantly via `bundledEdgesOf`; any slug NOT
1174
+ * bundled (a remote/private CP-only package) has its full definition fetched
1175
+ * via `fetchPackageDefinition` and its `.dependencies` cached. `seeds` is the
1176
+ * set of slugs to start the BFS from — pass every installed slug so the
1177
+ * resulting closure covers everything either renderer might touch.
1178
+ */
1179
+ async function buildEdgesOf(seeds, cat, cpToken) {
1180
+ const cache = new Map();
1181
+ const visited = new Set();
1182
+ let frontier = [...new Set(seeds)];
1183
+ while (frontier.length > 0) {
1184
+ const nextFrontier = new Set();
1185
+ await Promise.all(frontier.map(async (slug) => {
1186
+ if (visited.has(slug))
1187
+ return;
1188
+ visited.add(slug);
1189
+ let deps;
1190
+ if (cat.bundledSlugs.has(slug)) {
1191
+ deps = bundledEdgesOf(slug);
1192
+ }
1193
+ else {
1194
+ try {
1195
+ const def = await fetchPackageDefinition(slug, cpToken);
1196
+ deps = def?.dependencies ?? [];
1197
+ }
1198
+ catch {
1199
+ deps = [];
1200
+ }
1201
+ }
1202
+ cache.set(slug, deps);
1203
+ for (const d of deps)
1204
+ nextFrontier.add(d.slug);
1205
+ }));
1206
+ frontier = [...nextFrontier].filter((s) => !visited.has(s));
1207
+ }
1208
+ // Fallback covers a slug the BFS never seeded (shouldn't happen — every
1209
+ // `edgesOf` call the engine makes is reachable from `seeds`) — cheap and
1210
+ // safe either way since it's the same bundled-only lookup as the default.
1211
+ return (slug) => cache.get(slug) ?? bundledEdgesOf(slug);
1212
+ }
1213
+ /** `CompositionInstalledEntry[]` from the Hub's installed-templates rows — the shape both `--tree` and `--layers` feed the engine. */
1214
+ function toInstalledEntries(installedTemplates) {
1215
+ return installedTemplates.map((t) => ({
1216
+ slug: t.slug,
1217
+ workspaceId: t.workspaceId,
1218
+ workspaceName: t.workspaceName,
1219
+ version: t.version,
1220
+ }));
1221
+ }
1222
+ /**
1223
+ * The nested composition-tree view: one tree per installed TOP-LEVEL package
1224
+ * (a suite, or any installed package whose `edgesOf` resolves at least one
1225
+ * dependency — bundled OR remote). Everything else stays a flat leaf
1226
+ * (`leafRows`, rendered like a plain `market installed` row). This is the
1227
+ * SAME "top-level" rule the old walker used (kept verbatim for `--tree`'s
1228
+ * JSON/text backward compatibility — see `marketInstalled`'s before/after
1229
+ * diff check), just evaluated against the fixed `edgesOf` instead of the
1230
+ * bundled-only map.
1231
+ *
1232
+ * Nested `TreeNode`s are built by walking `graph.edges` (both `require` and
1233
+ * `compose` relations — the engine excludes compose SOURCES from `nodes`,
1234
+ * rule 2, but never from `edges`, so the nested view still shows them exactly
1235
+ * where the old walker did) and `shared` reads straight off each node's
1236
+ * `roots` (the engine's true reachability set), replacing the old walker's
1237
+ * bespoke reverse index.
1238
+ */
1239
+ export async function buildCompositionTrees(rows, cat, installedTemplates, workspaceCountBySlug) {
1240
+ const entryBySlug = new Map(cat.entries.map((e) => [e.slug, e]));
1241
+ const installedBySlug = new Map(installedTemplates.map((t) => [t.slug, t]));
1242
+ const checksBySlug = new Map(rows.map((c) => [c.slug, c]));
1243
+ const cpToken = getStoredToken()?.token;
1244
+ const installedSlugs = [...new Set(installedTemplates.map((t) => t.slug))];
1245
+ const edgesOf = await buildEdgesOf(installedSlugs, cat, cpToken);
1246
+ const topLevel = rows.filter((c) => {
1247
+ const entry = entryBySlug.get(c.slug);
1248
+ return (entry && isSuite(entry)) || edgesOf(c.slug).length > 0;
1249
+ });
1250
+ const topLevelSlugs = new Set(topLevel.map((c) => c.slug));
1251
+ const leafRows = rows.filter((c) => !topLevelSlugs.has(c.slug));
1252
+ const graph = layerTemplateGraph({
1253
+ roots: [...topLevelSlugs],
1254
+ edgesOf,
1255
+ nameOf: (slug) => entryBySlug.get(slug)?.name,
1256
+ installed: toInstalledEntries(installedTemplates),
1257
+ updates: rows,
1258
+ });
1259
+ function buildTreeNode(edge, currentTopLevelSlug, ancestors) {
1260
+ const slug = edge.to;
1261
+ const entry = entryBySlug.get(slug);
1262
+ const installedInfo = installedBySlug.get(slug);
1263
+ const node = graph.nodes.get(slug);
1264
+ // Exclude BOTH the walking root (matches the old walker's exclusion) AND
1265
+ // the node's own slug (a top-level package that's ALSO separately
1266
+ // installed reaches itself trivially in `node.roots` — rule 4 of Phase 4
1267
+ // — which the old bespoke `collectDescendantSlugs` never surfaced since
1268
+ // it only ever recorded genuine descendants, never a self-loop).
1269
+ const shared = (node?.roots ?? [])
1270
+ .filter((r) => r !== currentTopLevelSlug && r !== slug)
1271
+ .map((r) => entryBySlug.get(r)?.name ?? r);
1272
+ const children = [];
1273
+ if (!ancestors.has(slug)) {
1274
+ const nextAncestors = new Set(ancestors);
1275
+ nextAncestors.add(slug);
1276
+ for (const childEdge of graph.edges.filter((e) => e.from === slug)) {
1277
+ children.push(buildTreeNode(childEdge, currentTopLevelSlug, nextAncestors));
1278
+ }
1279
+ }
1280
+ const check = checksBySlug.get(slug);
1281
+ return {
1282
+ slug,
1283
+ name: entry?.name ?? slug,
1284
+ relation: edge.relation,
1285
+ depKind: edge.depKind,
1286
+ installed: !!installedInfo,
1287
+ workspaceId: installedInfo?.workspaceId,
1288
+ workspaceName: installedInfo?.workspaceName,
1289
+ installedVersion: check?.installedVersion,
1290
+ latestVersion: check?.latestVersion ?? null,
1291
+ updateAvailable: check?.updateAvailable ?? false,
1292
+ noVersionInfo: check ? check.noVersionInfo : !installedInfo,
1293
+ shared,
1294
+ children,
1295
+ };
1296
+ }
1297
+ const packages = topLevel.map((c) => {
1298
+ const entry = entryBySlug.get(c.slug);
1299
+ const children = graph.edges
1300
+ .filter((e) => e.from === c.slug)
1301
+ .map((edge) => buildTreeNode(edge, c.slug, new Set([c.slug])));
1302
+ return {
1303
+ slug: c.slug,
1304
+ name: entry?.name ?? c.slug,
1305
+ installedVersion: c.installedVersion,
1306
+ latestVersion: c.latestVersion ?? null,
1307
+ updateAvailable: c.updateAvailable,
1308
+ noVersionInfo: c.noVersionInfo,
1309
+ workspaceCount: workspaceCountBySlug.get(c.slug) ?? 0,
1310
+ isSuite: !!(entry && isSuite(entry)),
1311
+ children,
1312
+ };
1313
+ });
1314
+ return { packages, leafRows };
1315
+ }
1316
+ function nodeStatusLabel(node) {
1317
+ if (!node.installed)
1318
+ return chalk.dim("not installed");
1319
+ if (node.noVersionInfo)
1320
+ return chalk.dim("can't check");
1321
+ if (!node.updateAvailable)
1322
+ return chalk.green("up to date");
1323
+ const installed = node.installedVersion ? truncateVersion(node.installedVersion) : "?";
1324
+ const latest = node.latestVersion ? truncateVersion(node.latestVersion) : "?";
1325
+ return chalk.yellow("update available ") + chalk.dim(installed) + " → " + latest;
1326
+ }
1327
+ function printDependencyNode(node, prefix, isLast) {
1328
+ const branch = isLast ? "└─ " : "├─ ";
1329
+ const targetPlain = node.installed ? `→ ${node.workspaceName}` : "→ (not installed)";
1330
+ const target = node.installed ? `→ ${chalk.bold(node.workspaceName)}` : chalk.dim("→ (not installed)");
1331
+ const relationHint = node.relation === "compose" ? chalk.dim(" ↳ composes onto its base") : "";
1332
+ const sharedTag = node.shared.length ? chalk.magenta(` [shared: ${node.shared.join(", ")}]`) : "";
1333
+ const pad = (s, n) => s + " ".repeat(Math.max(1, n - s.length));
1334
+ console.log(prefix +
1335
+ chalk.dim(branch) +
1336
+ chalk.cyan(pad(node.slug, 20)) +
1337
+ pad(target, target.length + Math.max(0, 26 - targetPlain.length)) +
1338
+ nodeStatusLabel(node) +
1339
+ relationHint +
1340
+ sharedTag);
1341
+ const childPrefix = prefix + (isLast ? " " : chalk.dim("│ "));
1342
+ node.children.forEach((child, i) => printDependencyNode(child, childPrefix, i === node.children.length - 1));
1343
+ }
1344
+ export function printCompositionTree(pkg) {
1345
+ const suiteTag = pkg.isSuite ? " " + chalk.magenta("suite") : "";
1346
+ console.log(chalk.bold(pkg.name) +
1347
+ " " +
1348
+ chalk.cyan(`(${pkg.slug})`) +
1349
+ " " +
1350
+ chalk.dim(truncateVersion(pkg.installedVersion)) +
1351
+ " " +
1352
+ (pkg.noVersionInfo
1353
+ ? chalk.dim("can't check — reinstall")
1354
+ : pkg.updateAvailable
1355
+ ? chalk.yellow("update available")
1356
+ : chalk.green("up to date")) +
1357
+ suiteTag);
1358
+ pkg.children.forEach((child, i) => printDependencyNode(child, "", i === pkg.children.length - 1));
1359
+ log.blank();
1360
+ }
1361
+ // ── `synap market installed --layers` — flat DAG view ──────────────────────
1362
+ //
1363
+ // `--tree` nests (and, by design, reprints a shared base once per branch that
1364
+ // reaches it — see `TreeNode.shared`). `--layers` is the OTHER read of the
1365
+ // SAME engine: each installed/dependency slug appears EXACTLY ONCE, sunk to
1366
+ // its longest-path layer (roots at the top, bedrock at the bottom), with true
1367
+ // fan-in (`← N packages`) instead of a per-branch "shared" tag.
1368
+ /**
1369
+ * Full graph over every currently-installed package — the ROOTS are every
1370
+ * "top-level" installed slug (a suite, or one with its own resolved
1371
+ * dependencies — same rule `buildCompositionTrees` uses) PLUS any installed
1372
+ * slug left unreached by that walk (a standalone leaf with no dependents/
1373
+ * dependencies of its own). The second half matters here specifically:
1374
+ * `layerTemplateGraph` only ever surfaces nodes reachable from `roots` (its
1375
+ * contract — "roots always land at layer 0"), so a genuinely standalone
1376
+ * package would otherwise silently vanish from `--layers` instead of showing
1377
+ * up as its own one-node layer-0 entry.
1378
+ */
1379
+ async function buildInstallationGraph(installedTemplates, cat, checks) {
1380
+ const cpToken = getStoredToken()?.token;
1381
+ const entryBySlug = new Map(cat.entries.map((e) => [e.slug, e]));
1382
+ const installedSlugs = [...new Set(installedTemplates.map((t) => t.slug))];
1383
+ const edgesOf = await buildEdgesOf(installedSlugs, cat, cpToken);
1384
+ const primary = installedSlugs.filter((slug) => {
1385
+ const entry = entryBySlug.get(slug);
1386
+ return (entry && isSuite(entry)) || edgesOf(slug).length > 0;
1387
+ });
1388
+ const reached = new Set(primary);
1389
+ let frontier = [...primary];
1390
+ while (frontier.length > 0) {
1391
+ const next = [];
1392
+ for (const s of frontier) {
1393
+ for (const dep of edgesOf(s)) {
1394
+ if (!reached.has(dep.slug)) {
1395
+ reached.add(dep.slug);
1396
+ next.push(dep.slug);
1397
+ }
1398
+ }
1399
+ }
1400
+ frontier = next;
1401
+ }
1402
+ const leftover = installedSlugs.filter((s) => !reached.has(s));
1403
+ const roots = [...new Set([...primary, ...leftover])];
1404
+ return layerTemplateGraph({
1405
+ roots,
1406
+ edgesOf,
1407
+ nameOf: (slug) => entryBySlug.get(slug)?.name,
1408
+ installed: toInstalledEntries(installedTemplates),
1409
+ updates: checks,
1410
+ });
1411
+ }
1412
+ /** Status cell for one `--layers` row — same vocabulary as `installedStatusLabel`, adapted to a `CompositionNode` (which, unlike `UpdateCheck`, can be a ghost — not installed at all). */
1413
+ function layerNodeStatus(node, isPrivate, loggedIn) {
1414
+ if (!node.installed)
1415
+ return chalk.dim("not installed");
1416
+ if (node.noVersionInfo)
1417
+ return noVersionLabel(isPrivate, loggedIn);
1418
+ if (!node.updateAvailable)
1419
+ return chalk.green("up to date");
1420
+ const installed = node.installedVersion ? truncateVersion(node.installedVersion) : "?";
1421
+ const latest = node.latestVersion ? truncateVersion(node.latestVersion) : "?";
1422
+ return chalk.yellow("update available ") + chalk.dim(installed) + " → " + latest;
1423
+ }
1424
+ /**
1425
+ * Render the layered DAG bottom-up: bedrock (the highest layer index, most
1426
+ * depended-on) FIRST, roots (layer 0) LAST — the base of the pyramid printed
1427
+ * first felt like the clearer reading order for "what everything rests on".
1428
+ */
1429
+ function printLayersGraph(graph, cat) {
1430
+ const entryBySlug = new Map(cat.entries.map((e) => [e.slug, e]));
1431
+ log.heading(" Installed packages — composition layers (bedrock first, bottom-up)");
1432
+ log.blank();
1433
+ // Column widths computed ONCE over every node in the graph (not per layer
1434
+ // bucket) — otherwise name/slug columns re-align at each layer boundary
1435
+ // and read as ragged.
1436
+ const allNodes = [...graph.nodes.values()];
1437
+ const nameW = Math.max(4, ...allNodes.map((n) => n.name.length)) + 2;
1438
+ const slugW = Math.max(4, ...allNodes.map((n) => n.slug.length)) + 2;
1439
+ for (let i = graph.layers.length - 1; i >= 0; i--) {
1440
+ const bucket = graph.layers[i];
1441
+ if (!bucket || bucket.length === 0)
1442
+ continue;
1443
+ // Bedrock/Roots already say what they need to; a bare "Layer N" in
1444
+ // between doesn't need "— layer N" repeated after it.
1445
+ const label = i === graph.layers.length - 1 ? "Bedrock" : i === 0 ? "Roots" : `Layer ${i}`;
1446
+ console.log(" " + chalk.dim(label));
1447
+ for (const node of bucket) {
1448
+ const entry = entryBySlug.get(node.slug);
1449
+ const dim = !node.installed;
1450
+ const fanIn = node.dependents.length > 0
1451
+ ? " " + chalk.magenta(`used by ${node.dependents.length} package${node.dependents.length === 1 ? "" : "s"}`)
1452
+ : "";
1453
+ const overlays = node.overlays.length > 0
1454
+ ? " " + node.overlays.map((o) => chalk.cyan(`+ ${o.name} (overlay)`)).join(" ")
1455
+ : "";
1456
+ const priv = entry?.isPrivate ? " " + chalk.yellow("private") : "";
1457
+ const name = dim ? chalk.dim(node.name.padEnd(nameW)) : chalk.bold(node.name.padEnd(nameW));
1458
+ const slug = dim ? chalk.dim(node.slug.padEnd(slugW)) : chalk.cyan(node.slug.padEnd(slugW));
1459
+ console.log(" " +
1460
+ name +
1461
+ slug +
1462
+ layerNodeStatus(node, entry?.isPrivate ?? false, cat.loggedIn) +
1463
+ fanIn +
1464
+ overlays +
1465
+ priv);
1466
+ }
1467
+ log.blank();
1468
+ }
1469
+ if (graph.cycles.length > 0) {
1470
+ log.warn(`${graph.cycles.length} dependency cycle${graph.cycles.length === 1 ? "" : "s"} detected — surfaced, not hidden:`);
1471
+ for (const [from, to] of graph.cycles)
1472
+ log.hint(`${from} → ${to} (broken here to avoid an infinite loop)`);
1473
+ log.blank();
1474
+ }
1475
+ }
1476
+ /**
1477
+ * Pure-read inventory of what's installed on this pod — reuses
1478
+ * `buildMarketCatalog` + `fetchInstalledTemplates` + `computeUpdates`
1479
+ * (`market update`'s own drift check), never writes anything. Pairs with
1480
+ * `market update` to apply what this surfaces.
1481
+ */
1482
+ export async function marketInstalled(opts) {
1483
+ const [installedTemplates, cat] = await Promise.all([fetchInstalledTemplates(), buildMarketCatalog()]);
1484
+ if (installedTemplates.length === 0) {
1485
+ if (opts.json) {
1486
+ console.log(JSON.stringify({ rows: [], loggedIn: cat.loggedIn }, null, 2));
1487
+ return;
1488
+ }
1489
+ log.warn("No installed packages found on this pod.");
1490
+ log.hint("Browse what's available: synap market --list");
1491
+ return;
1492
+ }
1493
+ const checks = computeUpdates(installedTemplates, cat);
1494
+ // Multiple workspaces can install the same slug (a template composed onto
1495
+ // several bases, or the same additive pack in >1 workspace) — one row per
1496
+ // DISTINCT slug, with `workspaceCount` carrying the fan-out.
1497
+ const workspaceCountBySlug = new Map();
1498
+ for (const t of installedTemplates) {
1499
+ workspaceCountBySlug.set(t.slug, (workspaceCountBySlug.get(t.slug) ?? 0) + 1);
1500
+ }
1501
+ const seen = new Set();
1502
+ let rows = checks.filter((c) => {
1503
+ if (seen.has(c.slug))
1504
+ return false;
1505
+ seen.add(c.slug);
1506
+ return true;
1507
+ });
1508
+ if (opts.outdated)
1509
+ rows = rows.filter((c) => c.updateAvailable);
1510
+ const entryBySlug = new Map(cat.entries.map((e) => [e.slug, e]));
1511
+ if (opts.layers) {
1512
+ const graph = await buildInstallationGraph(installedTemplates, cat, checks);
1513
+ const steps = FLOW.afterMarketInstalledCheck(rows.filter((c) => c.updateAvailable).map((c) => c.slug));
1514
+ if (opts.json) {
1515
+ console.log(JSON.stringify({
1516
+ nodes: [...graph.nodes.values()],
1517
+ edges: graph.edges,
1518
+ layers: graph.layers.map((bucket) => bucket.map((n) => n.slug)),
1519
+ cycles: graph.cycles,
1520
+ loggedIn: cat.loggedIn,
1521
+ nextSteps: steps,
1522
+ }, null, 2));
1523
+ return;
1524
+ }
1525
+ if (graph.nodes.size === 0) {
1526
+ log.success("Nothing installed.");
1527
+ return;
1528
+ }
1529
+ printLayersGraph(graph, cat);
1530
+ if (!cat.loggedIn)
1531
+ log.dim("Not logged in — private template updates aren't visible. Run: synap login");
1532
+ renderNextSteps(steps);
1533
+ return;
1534
+ }
1535
+ if (opts.tree) {
1536
+ const { packages, leafRows } = await buildCompositionTrees(rows, cat, installedTemplates, workspaceCountBySlug);
1537
+ const steps = FLOW.afterMarketInstalledCheck(rows.filter((c) => c.updateAvailable).map((c) => c.slug));
1538
+ if (opts.json) {
1539
+ console.log(JSON.stringify({
1540
+ packages,
1541
+ leaves: leafRows.map((c) => {
1542
+ const entry = entryBySlug.get(c.slug);
1543
+ return {
1544
+ slug: c.slug,
1545
+ name: entry?.name ?? c.slug,
1546
+ installedVersion: c.installedVersion,
1547
+ latestVersion: c.latestVersion ?? null,
1548
+ updateAvailable: c.updateAvailable,
1549
+ noVersionInfo: c.noVersionInfo,
1550
+ isPrivate: entry?.isPrivate ?? false,
1551
+ workspaceCount: workspaceCountBySlug.get(c.slug) ?? 0,
1552
+ };
1553
+ }),
1554
+ loggedIn: cat.loggedIn,
1555
+ nextSteps: steps,
1556
+ }, null, 2));
1557
+ return;
1558
+ }
1559
+ if (packages.length === 0 && leafRows.length === 0) {
1560
+ log.success("Nothing installed.");
1561
+ return;
1562
+ }
1563
+ if (packages.length > 0) {
1564
+ log.heading(" Installed packages — composition graph");
1565
+ log.blank();
1566
+ packages.forEach(printCompositionTree);
1567
+ }
1568
+ if (leafRows.length > 0) {
1569
+ log.heading(" Other installed packages");
1570
+ log.blank();
1571
+ const nameW = Math.max(4, ...leafRows.map((c) => (entryBySlug.get(c.slug)?.name ?? c.slug).length)) + 2;
1572
+ const slugW = Math.max(4, ...leafRows.map((c) => c.slug.length)) + 2;
1573
+ for (const c of leafRows) {
1574
+ const entry = entryBySlug.get(c.slug);
1575
+ const name = entry?.name ?? c.slug;
1576
+ const priv = entry?.isPrivate ? chalk.yellow("private") : "";
1577
+ const wsCount = workspaceCountBySlug.get(c.slug) ?? 0;
1578
+ console.log(" " +
1579
+ chalk.bold(name.padEnd(nameW)) +
1580
+ chalk.cyan(c.slug.padEnd(slugW)) +
1581
+ installedStatusLabel(c, entry?.isPrivate ?? false, cat.loggedIn) +
1582
+ " " +
1583
+ priv +
1584
+ (priv ? " " : "") +
1585
+ chalk.dim(`${wsCount} workspace${wsCount === 1 ? "" : "s"}`));
1586
+ }
1587
+ log.blank();
1588
+ }
1589
+ if (!cat.loggedIn)
1590
+ log.dim("Not logged in — private template updates aren't visible. Run: synap login");
1591
+ renderNextSteps(steps);
1592
+ return;
1593
+ }
1594
+ if (opts.json) {
1595
+ const steps = FLOW.afterMarketInstalledCheck(rows.filter((c) => c.updateAvailable).map((c) => c.slug));
1596
+ console.log(JSON.stringify({
1597
+ rows: rows.map((c) => {
1598
+ const entry = entryBySlug.get(c.slug);
1599
+ return {
1600
+ slug: c.slug,
1601
+ name: entry?.name ?? c.slug,
1602
+ installedVersion: c.installedVersion,
1603
+ latestVersion: c.latestVersion ?? null,
1604
+ updateAvailable: c.updateAvailable,
1605
+ noVersionInfo: c.noVersionInfo,
1606
+ isPrivate: entry?.isPrivate ?? false,
1607
+ isHashVersion: isHashVersion(c.installedVersion) || isHashVersion(c.latestVersion ?? ""),
1608
+ workspaceCount: workspaceCountBySlug.get(c.slug) ?? 0,
1609
+ };
1610
+ }),
1611
+ loggedIn: cat.loggedIn,
1612
+ nextSteps: steps,
1613
+ }, null, 2));
1614
+ return;
1615
+ }
1616
+ if (rows.length === 0) {
1617
+ log.success(opts.outdated ? "Nothing outdated." : "Nothing installed.");
1618
+ return;
1619
+ }
1620
+ log.heading(" Installed packages");
1621
+ log.blank();
1622
+ const nameW = Math.max(4, ...rows.map((c) => (entryBySlug.get(c.slug)?.name ?? c.slug).length)) + 2;
1623
+ const slugW = Math.max(4, ...rows.map((c) => c.slug.length)) + 2;
1624
+ for (const c of rows) {
1625
+ const entry = entryBySlug.get(c.slug);
1626
+ const name = entry?.name ?? c.slug;
1627
+ const priv = entry?.isPrivate ? chalk.yellow("private") : "";
1628
+ const wsCount = workspaceCountBySlug.get(c.slug) ?? 0;
1629
+ console.log(" " +
1630
+ chalk.bold(name.padEnd(nameW)) +
1631
+ chalk.cyan(c.slug.padEnd(slugW)) +
1632
+ installedStatusLabel(c, entry?.isPrivate ?? false, cat.loggedIn) +
1633
+ " " +
1634
+ priv +
1635
+ (priv ? " " : "") +
1636
+ chalk.dim(`${wsCount} workspace${wsCount === 1 ? "" : "s"}`));
1637
+ }
1638
+ log.blank();
1639
+ if (!cat.loggedIn)
1640
+ log.dim("Not logged in — private template updates aren't visible. Run: synap login");
1641
+ renderNextSteps(FLOW.afterMarketInstalledCheck(rows.filter((c) => c.updateAvailable).map((c) => c.slug)));
1642
+ }
1643
+ //# sourceMappingURL=market.js.map