@synap-core/cli 1.10.0 → 1.12.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +23 -198
- package/dist/build-info.json +6 -0
- package/dist/commands/agent-run.js +3 -2
- package/dist/commands/agent-run.js.map +1 -1
- package/dist/commands/agents.d.ts +33 -17
- package/dist/commands/agents.js +203 -237
- package/dist/commands/agents.js.map +1 -1
- package/dist/commands/app.d.ts +143 -0
- package/dist/commands/app.js +523 -0
- package/dist/commands/app.js.map +1 -0
- package/dist/commands/automate.d.ts +21 -0
- package/dist/commands/automate.js +42 -0
- package/dist/commands/automate.js.map +1 -0
- package/dist/commands/automation-schema.test.d.ts +16 -0
- package/dist/commands/automation-schema.test.js +77 -0
- package/dist/commands/automation-schema.test.js.map +1 -0
- package/dist/commands/automation.d.ts +12 -0
- package/dist/commands/automation.js +80 -35
- package/dist/commands/automation.js.map +1 -1
- package/dist/commands/bridge-setup.d.ts +14 -1
- package/dist/commands/bridge-setup.js +392 -128
- package/dist/commands/bridge-setup.js.map +1 -1
- package/dist/commands/cap-connections.d.ts +4 -4
- package/dist/commands/cap-connections.js +14 -14
- package/dist/commands/cap-connections.js.map +1 -1
- package/dist/commands/capability.d.ts +2 -0
- package/dist/commands/capability.js +297 -176
- package/dist/commands/capability.js.map +1 -1
- package/dist/commands/catalog.d.ts +275 -0
- package/dist/commands/catalog.js +481 -0
- package/dist/commands/catalog.js.map +1 -0
- package/dist/commands/cell.d.ts +155 -8
- package/dist/commands/cell.js +416 -40
- package/dist/commands/cell.js.map +1 -1
- package/dist/commands/centrality.js +3 -14
- package/dist/commands/centrality.js.map +1 -1
- package/dist/commands/connect.d.ts +38 -13
- package/dist/commands/connect.js +68 -167
- package/dist/commands/connect.js.map +1 -1
- package/dist/commands/connections.d.ts +3 -2
- package/dist/commands/connections.js +95 -25
- package/dist/commands/connections.js.map +1 -1
- package/dist/commands/context.js +9 -2
- package/dist/commands/context.js.map +1 -1
- package/dist/commands/data-extra.d.ts +34 -0
- package/dist/commands/data-extra.js +198 -25
- package/dist/commands/data-extra.js.map +1 -1
- package/dist/commands/data.d.ts +120 -2
- package/dist/commands/data.js +307 -24
- package/dist/commands/data.js.map +1 -1
- package/dist/commands/diagnose.d.ts +66 -4
- package/dist/commands/diagnose.js +329 -29
- package/dist/commands/diagnose.js.map +1 -1
- package/dist/commands/digest.js +2 -1
- package/dist/commands/digest.js.map +1 -1
- package/dist/commands/doc.d.ts +25 -4
- package/dist/commands/doc.js +100 -18
- package/dist/commands/doc.js.map +1 -1
- package/dist/commands/doctor.d.ts +130 -0
- package/dist/commands/doctor.js +814 -0
- package/dist/commands/doctor.js.map +1 -0
- package/dist/commands/events.js +3 -1
- package/dist/commands/events.js.map +1 -1
- package/dist/commands/explain.js +5 -5
- package/dist/commands/explain.js.map +1 -1
- package/dist/commands/graph.js +5 -3
- package/dist/commands/graph.js.map +1 -1
- package/dist/commands/import.d.ts +368 -13
- package/dist/commands/import.js +1096 -55
- package/dist/commands/import.js.map +1 -1
- package/dist/commands/init.d.ts +20 -9
- package/dist/commands/init.js +433 -973
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/keys.d.ts +1 -0
- package/dist/commands/keys.js +15 -2
- package/dist/commands/keys.js.map +1 -1
- package/dist/commands/knowledge.d.ts +28 -6
- package/dist/commands/knowledge.js +358 -73
- package/dist/commands/knowledge.js.map +1 -1
- package/dist/commands/launch.js +2 -2
- package/dist/commands/launch.js.map +1 -1
- package/dist/commands/lens.d.ts +37 -1
- package/dist/commands/lens.js +193 -72
- package/dist/commands/lens.js.map +1 -1
- package/dist/commands/market-authoring.d.ts +72 -0
- package/dist/commands/market-authoring.js +566 -0
- package/dist/commands/market-authoring.js.map +1 -0
- package/dist/commands/market-official.d.ts +58 -0
- package/dist/commands/market-official.js +84 -0
- package/dist/commands/market-official.js.map +1 -0
- package/dist/commands/market.d.ts +419 -20
- package/dist/commands/market.js +2124 -193
- package/dist/commands/market.js.map +1 -1
- package/dist/commands/mcp.d.ts +70 -13
- package/dist/commands/mcp.js +308 -40
- package/dist/commands/mcp.js.map +1 -1
- package/dist/commands/open.d.ts +39 -9
- package/dist/commands/open.js +80 -25
- package/dist/commands/open.js.map +1 -1
- package/dist/commands/playbook.d.ts +42 -0
- package/dist/commands/playbook.js +162 -0
- package/dist/commands/playbook.js.map +1 -0
- package/dist/commands/pod.js +1 -1
- package/dist/commands/pod.js.map +1 -1
- package/dist/commands/pods.d.ts +19 -0
- package/dist/commands/pods.js +220 -85
- package/dist/commands/pods.js.map +1 -1
- package/dist/commands/proposals-actions.d.ts +62 -0
- package/dist/commands/proposals-actions.js +146 -8
- package/dist/commands/proposals-actions.js.map +1 -1
- package/dist/commands/proposals-await.d.ts +68 -0
- package/dist/commands/proposals-await.js +248 -0
- package/dist/commands/proposals-await.js.map +1 -0
- package/dist/commands/proton-setup.d.ts +73 -0
- package/dist/commands/proton-setup.js +577 -0
- package/dist/commands/proton-setup.js.map +1 -0
- package/dist/commands/providers.d.ts +23 -0
- package/dist/commands/providers.js +126 -1
- package/dist/commands/providers.js.map +1 -1
- package/dist/commands/raycast.js +6 -2
- package/dist/commands/raycast.js.map +1 -1
- package/dist/commands/rule.d.ts +150 -0
- package/dist/commands/rule.js +447 -0
- package/dist/commands/rule.js.map +1 -0
- package/dist/commands/session-wait.d.ts +111 -0
- package/dist/commands/session-wait.js +209 -0
- package/dist/commands/session-wait.js.map +1 -0
- package/dist/commands/sessions.d.ts +52 -0
- package/dist/commands/sessions.js +479 -58
- package/dist/commands/sessions.js.map +1 -1
- package/dist/commands/skill-manage.d.ts +21 -0
- package/dist/commands/skill-manage.js +136 -19
- package/dist/commands/skill-manage.js.map +1 -1
- package/dist/commands/skills.js +19 -1
- package/dist/commands/skills.js.map +1 -1
- package/dist/commands/status.d.ts +1 -4
- package/dist/commands/status.js +5 -34
- package/dist/commands/status.js.map +1 -1
- package/dist/commands/statusline.backoff.test.d.ts +13 -0
- package/dist/commands/statusline.backoff.test.js +100 -0
- package/dist/commands/statusline.backoff.test.js.map +1 -0
- package/dist/commands/statusline.js +16 -9
- package/dist/commands/statusline.js.map +1 -1
- package/dist/commands/sync-status.d.ts +12 -0
- package/dist/commands/sync-status.js +125 -0
- package/dist/commands/sync-status.js.map +1 -0
- package/dist/commands/templates-retire.d.ts +34 -0
- package/dist/commands/templates-retire.js +192 -0
- package/dist/commands/templates-retire.js.map +1 -0
- package/dist/commands/tools.js +6 -21
- package/dist/commands/tools.js.map +1 -1
- package/dist/commands/tracks.d.ts +51 -0
- package/dist/commands/tracks.js +291 -0
- package/dist/commands/tracks.js.map +1 -0
- package/dist/commands/update.d.ts +3 -11
- package/dist/commands/update.js +5 -134
- package/dist/commands/update.js.map +1 -1
- package/dist/commands/upload.d.ts +68 -0
- package/dist/commands/upload.js +257 -0
- package/dist/commands/upload.js.map +1 -0
- package/dist/commands/vendor.d.ts +28 -0
- package/dist/commands/vendor.js +110 -0
- package/dist/commands/vendor.js.map +1 -0
- package/dist/commands/versions.js +6 -0
- package/dist/commands/versions.js.map +1 -1
- package/dist/commands/view.js +10 -5
- package/dist/commands/view.js.map +1 -1
- package/dist/commands/whoami.d.ts +23 -3
- package/dist/commands/whoami.js +169 -23
- package/dist/commands/whoami.js.map +1 -1
- package/dist/commands/worker.d.ts +122 -0
- package/dist/commands/worker.js +435 -0
- package/dist/commands/worker.js.map +1 -0
- package/dist/commands/worker.test.d.ts +1 -0
- package/dist/commands/worker.test.js +116 -0
- package/dist/commands/worker.test.js.map +1 -0
- package/dist/commands/workspace-ops.d.ts +35 -0
- package/dist/commands/workspace-ops.js +153 -0
- package/dist/commands/workspace-ops.js.map +1 -0
- package/dist/index.d.ts +6 -6
- package/dist/index.js +1009 -171
- package/dist/index.js.map +1 -1
- package/dist/lib/agent-connections.d.ts +31 -0
- package/dist/lib/agent-connections.js +33 -0
- package/dist/lib/agent-connections.js.map +1 -0
- package/dist/lib/agent-detect.d.ts +27 -0
- package/dist/lib/agent-detect.js +71 -0
- package/dist/lib/agent-detect.js.map +1 -0
- package/dist/lib/agent-door.d.ts +138 -0
- package/dist/lib/agent-door.js +426 -0
- package/dist/lib/agent-door.js.map +1 -0
- package/dist/lib/agents-config.d.ts +2 -0
- package/dist/lib/agents-config.js +4 -9
- package/dist/lib/agents-config.js.map +1 -1
- package/dist/lib/approval-poll.d.ts +31 -2
- package/dist/lib/approval-poll.js +20 -7
- package/dist/lib/approval-poll.js.map +1 -1
- package/dist/lib/atomic-file.d.ts +48 -0
- package/dist/lib/atomic-file.js +129 -0
- package/dist/lib/atomic-file.js.map +1 -0
- package/dist/lib/auth.d.ts +14 -9
- package/dist/lib/auth.js +44 -61
- package/dist/lib/auth.js.map +1 -1
- package/dist/lib/batch-approval.d.ts +85 -0
- package/dist/lib/batch-approval.js +170 -0
- package/dist/lib/batch-approval.js.map +1 -0
- package/dist/lib/capture-lane.d.ts +6 -5
- package/dist/lib/capture-lane.js +11 -8
- package/dist/lib/capture-lane.js.map +1 -1
- package/dist/lib/capture-structure.d.ts +342 -0
- package/dist/lib/capture-structure.js +295 -0
- package/dist/lib/capture-structure.js.map +1 -0
- package/dist/lib/catalog-templates.d.ts +59 -0
- package/dist/lib/catalog-templates.js +126 -0
- package/dist/lib/catalog-templates.js.map +1 -0
- package/dist/lib/cli-build.d.ts +19 -0
- package/dist/lib/cli-build.js +37 -0
- package/dist/lib/cli-build.js.map +1 -0
- package/dist/lib/conversation-headers.d.ts +18 -0
- package/dist/lib/conversation-headers.js +32 -0
- package/dist/lib/conversation-headers.js.map +1 -0
- package/dist/lib/cp-packages.d.ts +216 -4
- package/dist/lib/cp-packages.js +414 -11
- package/dist/lib/cp-packages.js.map +1 -1
- package/dist/lib/credential-class.d.ts +117 -0
- package/dist/lib/credential-class.js +116 -0
- package/dist/lib/credential-class.js.map +1 -0
- package/dist/lib/describe-lens.d.ts +35 -0
- package/dist/lib/describe-lens.js +40 -0
- package/dist/lib/describe-lens.js.map +1 -1
- package/dist/lib/device-connect.d.ts +125 -0
- package/dist/lib/device-connect.js +256 -0
- package/dist/lib/device-connect.js.map +1 -0
- package/dist/lib/directory-lens.d.ts +99 -0
- package/dist/lib/directory-lens.js +187 -0
- package/dist/lib/directory-lens.js.map +1 -0
- package/dist/lib/exporter-coverage.d.ts +71 -0
- package/dist/lib/exporter-coverage.js +97 -0
- package/dist/lib/exporter-coverage.js.map +1 -0
- package/dist/lib/hub-client.d.ts +43 -9
- package/dist/lib/hub-client.js +429 -65
- package/dist/lib/hub-client.js.map +1 -1
- package/dist/lib/id.d.ts +23 -0
- package/dist/lib/id.js +35 -0
- package/dist/lib/id.js.map +1 -0
- package/dist/lib/init-connect.d.ts +41 -0
- package/dist/lib/init-connect.js +75 -0
- package/dist/lib/init-connect.js.map +1 -0
- package/dist/lib/init-exit.d.ts +24 -0
- package/dist/lib/init-exit.js +41 -0
- package/dist/lib/init-exit.js.map +1 -0
- package/dist/lib/init-flow.d.ts +25 -0
- package/dist/lib/init-flow.js +40 -0
- package/dist/lib/init-flow.js.map +1 -0
- package/dist/lib/init-handoff.d.ts +16 -0
- package/dist/lib/init-handoff.js +19 -0
- package/dist/lib/init-handoff.js.map +1 -0
- package/dist/lib/init-output.d.ts +18 -0
- package/dist/lib/init-output.js +43 -0
- package/dist/lib/init-output.js.map +1 -0
- package/dist/lib/init-pod.d.ts +45 -0
- package/dist/lib/init-pod.js +36 -0
- package/dist/lib/init-pod.js.map +1 -0
- package/dist/lib/installed.d.ts +210 -11
- package/dist/lib/installed.js +234 -36
- package/dist/lib/installed.js.map +1 -1
- package/dist/lib/key-approval.d.ts +57 -0
- package/dist/lib/key-approval.js +117 -0
- package/dist/lib/key-approval.js.map +1 -0
- package/dist/lib/key-diagnosis.d.ts +56 -0
- package/dist/lib/key-diagnosis.js +227 -0
- package/dist/lib/key-diagnosis.js.map +1 -0
- package/dist/lib/key-doctor.d.ts +136 -0
- package/dist/lib/key-doctor.js +286 -0
- package/dist/lib/key-doctor.js.map +1 -0
- package/dist/lib/key-locations.d.ts +120 -0
- package/dist/lib/key-locations.js +326 -0
- package/dist/lib/key-locations.js.map +1 -0
- package/dist/lib/key-source.d.ts +66 -0
- package/dist/lib/key-source.js +106 -0
- package/dist/lib/key-source.js.map +1 -0
- package/dist/lib/key-state.d.ts +70 -0
- package/dist/lib/key-state.js +115 -0
- package/dist/lib/key-state.js.map +1 -0
- package/dist/lib/kind-package.d.ts +181 -0
- package/dist/lib/kind-package.js +679 -0
- package/dist/lib/kind-package.js.map +1 -0
- package/dist/lib/knowledge-title.d.ts +24 -0
- package/dist/lib/knowledge-title.js +46 -0
- package/dist/lib/knowledge-title.js.map +1 -0
- package/dist/lib/knowledge-title.test.d.ts +1 -0
- package/dist/lib/knowledge-title.test.js +34 -0
- package/dist/lib/knowledge-title.test.js.map +1 -0
- package/dist/lib/machine-instance.d.ts +64 -0
- package/dist/lib/machine-instance.js +142 -0
- package/dist/lib/machine-instance.js.map +1 -0
- package/dist/lib/mcp-bridge.d.ts +42 -0
- package/dist/lib/mcp-bridge.js +147 -0
- package/dist/lib/mcp-bridge.js.map +1 -0
- package/dist/lib/mcp-bridge.test.d.ts +1 -0
- package/dist/lib/mcp-bridge.test.js +90 -0
- package/dist/lib/mcp-bridge.test.js.map +1 -0
- package/dist/lib/mcp-call.d.ts +30 -0
- package/dist/lib/mcp-call.js +107 -0
- package/dist/lib/mcp-call.js.map +1 -0
- package/dist/lib/mcp-snippets.d.ts +53 -0
- package/dist/lib/mcp-snippets.js +82 -0
- package/dist/lib/mcp-snippets.js.map +1 -0
- package/dist/lib/next-steps.d.ts +8 -0
- package/dist/lib/next-steps.js +31 -1
- package/dist/lib/next-steps.js.map +1 -1
- package/dist/lib/package-wire.d.ts +179 -0
- package/dist/lib/package-wire.js +68 -0
- package/dist/lib/package-wire.js.map +1 -0
- package/dist/lib/pair-link.d.ts +38 -0
- package/dist/lib/pair-link.js +68 -0
- package/dist/lib/pair-link.js.map +1 -0
- package/dist/lib/pod-wide.d.ts +40 -0
- package/dist/lib/pod-wide.js +46 -0
- package/dist/lib/pod-wide.js.map +1 -0
- package/dist/lib/pod.d.ts +119 -115
- package/dist/lib/pod.js +206 -412
- package/dist/lib/pod.js.map +1 -1
- package/dist/lib/project-ref.d.ts +9 -3
- package/dist/lib/project-ref.js +10 -4
- package/dist/lib/project-ref.js.map +1 -1
- package/dist/lib/review-credential.d.ts +119 -0
- package/dist/lib/review-credential.js +147 -0
- package/dist/lib/review-credential.js.map +1 -0
- package/dist/lib/safe-open.d.ts +49 -0
- package/dist/lib/safe-open.js +119 -0
- package/dist/lib/safe-open.js.map +1 -0
- package/dist/lib/session-lens.d.ts +40 -1
- package/dist/lib/session-lens.js +54 -4
- package/dist/lib/session-lens.js.map +1 -1
- package/dist/lib/superwhisper-import.d.ts +8 -0
- package/dist/lib/superwhisper-import.js +14 -7
- package/dist/lib/superwhisper-import.js.map +1 -1
- package/dist/lib/superwhisper-title.test.d.ts +1 -0
- package/dist/lib/superwhisper-title.test.js +18 -0
- package/dist/lib/superwhisper-title.test.js.map +1 -0
- package/dist/lib/targets.d.ts +134 -10
- package/dist/lib/targets.js +962 -258
- package/dist/lib/targets.js.map +1 -1
- package/dist/lib/template-file.d.ts +78 -0
- package/dist/lib/template-file.js +140 -0
- package/dist/lib/template-file.js.map +1 -0
- package/dist/lib/template-lifecycle.d.ts +217 -0
- package/dist/lib/template-lifecycle.js +433 -0
- package/dist/lib/template-lifecycle.js.map +1 -0
- package/dist/lib/template-steps.d.ts +88 -0
- package/dist/lib/template-steps.js +118 -0
- package/dist/lib/template-steps.js.map +1 -0
- package/dist/lib/time-ago.d.ts +2 -0
- package/dist/lib/time-ago.js +15 -0
- package/dist/lib/time-ago.js.map +1 -0
- package/dist/utils/logger.js +5 -2
- package/dist/utils/logger.js.map +1 -1
- package/package.json +25 -18
- package/skills/README.md +12 -10
- package/skills/agent-os/SKILL.md +220 -99
- package/skills/manifest.json +2 -2
- package/skills/onboard/SKILL.md +61 -25
- package/skills/synap/README.md +5 -8
- package/skills/synap/SKILL.md +1185 -301
- package/skills/synap/_order.txt +7 -0
- package/skills/synap/_skill.yaml +1 -1
- package/skills/synap/automations.md +15 -0
- package/skills/synap/capture.md +54 -151
- package/skills/synap/cli-operations.md +24 -18
- package/skills/synap/common-mistakes.md +1 -0
- package/skills/synap/concepts.md +28 -0
- package/skills/synap/content-os.md +92 -0
- package/skills/synap/creative-loop.md +15 -10
- package/skills/synap/crm.md +21 -52
- package/skills/synap/digesting-a-channel.md +25 -0
- package/skills/synap/document-embeds.md +141 -47
- package/skills/synap/escalation-ladder.md +20 -10
- package/skills/synap/focus-sessions.md +47 -21
- package/skills/synap/from-intent.md +142 -0
- package/skills/synap/governance-rules.md +4 -4
- package/skills/synap/governance.md +10 -7
- package/skills/synap/hand-off.md +33 -0
- package/skills/synap/inline-patterns.md +15 -7
- package/skills/synap/lenses.md +33 -15
- package/skills/synap/linking.md +41 -40
- package/skills/synap/mcp-remote-servers.md +64 -0
- package/skills/synap/mental-model.md +24 -22
- package/skills/synap/multi-entity-capture.md +6 -4
- package/skills/synap/operating-mode-rules.md +1 -1
- package/skills/synap/processes.md +118 -0
- package/skills/synap/quick-reference.md +5 -1
- package/skills/synap/reading.md +3 -1
- package/skills/synap/reflexes.md +12 -42
- package/skills/synap/viewframe-cells.md +38 -5
- package/skills/synap/work-flow.md +1 -1
- package/skills/synap/work-model.md +96 -0
- package/skills/synap/worked-examples.md +21 -1
- package/skills/synap/workspace-design.md +14 -10
- package/skills/synap/writes.md +165 -35
- package/skills/synap/writing-titles.md +63 -0
- package/skills/synap-app/SKILL.md +319 -0
- package/skills/synap-app/_order.txt +5 -0
- package/skills/synap-app/_skill.yaml +20 -0
- package/skills/synap-app/creating-the-base-app.md +64 -0
- package/skills/synap-app/governance.md +42 -0
- package/skills/synap-app/install-commands.md +55 -0
- package/skills/synap-app/overview.md +50 -0
- package/skills/synap-app/using-the-sdk.md +73 -0
- package/skills/synap-design/SKILL.md +474 -0
- package/skills/synap-design/_order.txt +4 -0
- package/skills/synap-design/_skill.yaml +21 -0
- package/skills/synap-design/loop.md +134 -0
- package/skills/synap-design/overview.md +44 -0
- package/skills/synap-design/principles.md +130 -0
- package/skills/synap-design/system.md +133 -0
- package/skills/synap-market/SKILL.md +625 -0
- package/skills/synap-market/_order.txt +5 -0
- package/skills/synap-market/_skill.yaml +24 -0
- package/skills/synap-market/author-and-publish.md +212 -0
- package/skills/synap-market/discover-and-fetch.md +59 -0
- package/skills/synap-market/governance-and-catalog.md +63 -0
- package/skills/synap-market/install.md +133 -0
- package/skills/synap-market/overview.md +119 -0
- package/skills/synap-schema/SKILL.md +172 -10
- package/skills/synap-schema/_order.txt +2 -0
- package/skills/synap-schema/_skill.yaml +4 -2
- package/skills/synap-schema/common-mistakes.md +4 -2
- package/skills/synap-schema/discover-profiles.md +1 -1
- package/skills/synap-schema/entity-scope.md +15 -3
- package/skills/synap-schema/extend-first.md +29 -0
- package/skills/synap-schema/extend-vs-create.md +1 -1
- package/skills/synap-schema/intent-vocabulary-ssot.md +111 -0
- package/skills/synap-schema/property-types.md +1 -1
- package/skills/synap-schema/relations.md +1 -1
- package/skills/synap-ui/SKILL.md +19 -12
- package/skills/synap-ui/_skill.yaml +1 -1
- package/skills/synap-ui/ai-companion.md +1 -1
- package/skills/synap-ui/arranging-bento.md +2 -0
- package/skills/synap-ui/bento-layouts.md +10 -10
- package/skills/synap-ui/bento-recipes.md +313 -543
- package/skills/synap-ui/four-builds.md +5 -0
- package/skills/synap-ui/view-types.md +2 -2
- package/skills/synap-ui/widget-catalog.md +22 -278
- package/templates/automations/bookmark-enrichment.automation.json +290 -13
- package/templates/automations/url-bookmark-capture.automation.json +73 -11
- package/templates/skills/authoring-playbooks/SKILL.md +6 -3
- package/templates/skills/authoring-playbooks/reference/stages-contract.md +15 -0
- package/templates/skills/authoring-playbooks/reference/worked-example.md +3 -0
- package/dist/commands/infra.d.ts +0 -21
- package/dist/commands/infra.js +0 -262
- package/dist/commands/infra.js.map +0 -1
- package/dist/lib/browser-auth.d.ts +0 -35
- package/dist/lib/browser-auth.js +0 -170
- package/dist/lib/browser-auth.js.map +0 -1
- package/dist/lib/openclaw.d.ts +0 -32
- package/dist/lib/openclaw.js +0 -164
- package/dist/lib/openclaw.js.map +0 -1
- package/dist/lib/seed.d.ts +0 -13
- package/dist/lib/seed.js +0 -135
- package/dist/lib/seed.js.map +0 -1
package/dist/commands/market.js
CHANGED
|
@@ -14,28 +14,31 @@
|
|
|
14
14
|
* synap market install <slug> — install
|
|
15
15
|
* synap market update [slug] [--yes] [--json] — check/apply drift
|
|
16
16
|
*
|
|
17
|
-
* INSTALL SCOPE:
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
* generic CLI→pod install for standalone non-workspace packages is not wired yet.
|
|
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.
|
|
24
23
|
*/
|
|
25
24
|
import ora from "ora";
|
|
26
25
|
import chalk from "chalk";
|
|
27
|
-
import { assembleCatalog, computeTemplateUpdates, isHashVersion, listPublicTemplates, listWorkspaceTemplates, toPackageDefinition, layerTemplateGraph, bundledEdgesOf, } from "@synap-core/workspace-templates";
|
|
26
|
+
import { assembleCatalog, computeTemplateUpdates, isHashVersion, listPublicTemplates, listWorkspaceTemplates, getWorkspaceTemplate, mergeCatalog, toPackageDefinition, layerTemplateGraph, bundledEdgesOf, groupByDomain, } from "@synap-core/workspace-templates";
|
|
28
27
|
import { log } from "../utils/logger.js";
|
|
29
|
-
import { resolveHubConfig, hubPost, renderHubError } from "../lib/hub-client.js";
|
|
28
|
+
import { resolveHubConfig, hubPost, renderHubError, HubError } from "../lib/hub-client.js";
|
|
30
29
|
import { fetchProjects } from "../lib/project.js";
|
|
31
30
|
import { writeGovernance } from "../lib/capture-lane.js";
|
|
32
31
|
import { renderNextSteps, FLOW } from "../lib/next-steps.js";
|
|
33
32
|
import { getStoredToken, isTokenLocallyExpired } from "../lib/auth.js";
|
|
34
|
-
import
|
|
35
|
-
import {
|
|
33
|
+
import prompts from "prompts";
|
|
34
|
+
import { fetchPublicBrowseRows, fetchMyRows, fetchAvailableSlugs, fetchRetiredWorkspaceSlugs, fetchPackageDefinition, fetchPackageRecord, hydrateMineCategories, } from "../lib/cp-packages.js";
|
|
35
|
+
import { fetchInstalledSlugs, fetchInstalledTemplates, fetchInstalledInventory, fetchInstalledInventoryStrict, installedRowsToTemplates, fetchWorkspaceAttachments, fetchWorkspaceAttachmentsStrict, verifyStampLanded, INSTALLED_KINDS } from "../lib/installed.js";
|
|
36
36
|
import { isSuite } from "../lib/suite.js";
|
|
37
|
+
import { TEMPLATE_STEP_LABELS, classifyTemplateStep, proposalsDoor, stepFromApplyState, templateStepGlyph, templateStepPhrase, } from "../lib/template-steps.js";
|
|
38
|
+
import { capabilityAdd } from "./capability.js";
|
|
37
39
|
import { bundledTemplatesVersion } from "../lib/bundle-version.js";
|
|
38
40
|
import { HubNetworkError } from "../lib/hub-client.js";
|
|
41
|
+
import { parseDataChanges, dataChangesFromRefusal, consentNeedsPerson, dataChangeRefusalMessage, printDataChanges, parseAcceptList, decideConsent, consentRerunCommand, consentRefusalLines, lifecycleNotice, overlayNotice, overlayNeedsAttention, } from "../lib/template-lifecycle.js";
|
|
39
42
|
// Same longer budget launch uses — an apply writes profiles/views/entities.
|
|
40
43
|
const APPLY_TIMEOUT_MS = 120_000;
|
|
41
44
|
/** `--timeout <seconds>` → ms, falling back to `APPLY_TIMEOUT_MS` when unset/invalid. */
|
|
@@ -43,6 +46,12 @@ function parseApplyTimeoutMs(raw) {
|
|
|
43
46
|
const secs = raw ? parseInt(raw, 10) : NaN;
|
|
44
47
|
return Number.isFinite(secs) && secs > 0 ? secs * 1000 : APPLY_TIMEOUT_MS;
|
|
45
48
|
}
|
|
49
|
+
/** Drop workspace entries whose slug the CP retired. `null` (unknown) keeps every entry. */
|
|
50
|
+
export function dropRetiredWorkspaces(entries, retiredSlugs) {
|
|
51
|
+
if (!retiredSlugs || retiredSlugs.size === 0)
|
|
52
|
+
return entries;
|
|
53
|
+
return entries.filter((e) => !(typeOf(e) === "workspace" && retiredSlugs.has(e.slug)));
|
|
54
|
+
}
|
|
46
55
|
/**
|
|
47
56
|
* Assemble the market catalog: bundled workspace templates ∪ public CP browse
|
|
48
57
|
* (all types) ∪ the caller's own packages (when logged in), through the shared
|
|
@@ -54,7 +63,7 @@ export async function buildMarketCatalog(filters) {
|
|
|
54
63
|
const creds = getStoredToken();
|
|
55
64
|
const loggedIn = !!creds && !isTokenLocallyExpired(creds);
|
|
56
65
|
let reachedCp = true;
|
|
57
|
-
const [publicRows,
|
|
66
|
+
const [publicRows, rawMineRows, availableSlugs, installed, retiredSlugs] = await Promise.all([
|
|
58
67
|
fetchPublicBrowseRows(filters).catch(() => {
|
|
59
68
|
reachedCp = false;
|
|
60
69
|
return [];
|
|
@@ -66,14 +75,36 @@ export async function buildMarketCatalog(filters) {
|
|
|
66
75
|
? fetchAvailableSlugs(creds.token, filters).catch(() => null)
|
|
67
76
|
: Promise.resolve(null),
|
|
68
77
|
fetchInstalledSlugs(),
|
|
78
|
+
fetchRetiredWorkspaceSlugs(),
|
|
69
79
|
]);
|
|
80
|
+
// The `/mine` LIST PROJECTION omits `category` (CP `routes/packages.ts`), so
|
|
81
|
+
// the caller's own rows — the ONLY way a PRIVATE package can reach this
|
|
82
|
+
// catalog — arrive type-less. Untreated, `typeOf`'s display default calls
|
|
83
|
+
// every one of them a workspace: that is why a published cell package was
|
|
84
|
+
// invisible to `--list --type cell` and installed as an empty "New
|
|
85
|
+
// Workspace". Hydrate from the public rows we already have, else the authed
|
|
86
|
+
// single-package door. Best-effort: a row that can't be resolved keeps an
|
|
87
|
+
// ABSENT category (the install refusal is the floor under it).
|
|
88
|
+
const publicCategoryBySlug = new Map();
|
|
89
|
+
for (const r of publicRows)
|
|
90
|
+
publicCategoryBySlug.set(r.slug, r.category);
|
|
91
|
+
const mineRows = loggedIn && creds && rawMineRows.length > 0
|
|
92
|
+
? await hydrateMineCategories(rawMineRows, creds.token, publicCategoryBySlug).catch(() => rawMineRows)
|
|
93
|
+
: rawMineRows;
|
|
70
94
|
const merged = assembleCatalog({
|
|
71
95
|
// Logged in → include PRIVATE bundled templates (e.g. the `the-arch` suite),
|
|
72
96
|
// matching the "private templates unlocked" promise. Logged out → public
|
|
73
97
|
// bundle only. (mergeCatalog derives `isPrivate` from each template's
|
|
74
98
|
// `meta.isPublic === false`, so private bundled entries stay badged.)
|
|
75
99
|
bundled: loggedIn ? listWorkspaceTemplates() : listPublicTemplates(),
|
|
76
|
-
|
|
100
|
+
// `/mine` FIRST: `mergeCatalog` keeps the first remote row for a slug, so
|
|
101
|
+
// this makes the caller's OWN row win a collision with a stranger's public
|
|
102
|
+
// one. That matches the CP's own `GET /:slug` precedence (own row beats a
|
|
103
|
+
// public row) and `remoteVersionBySlug` below, and it is the only order
|
|
104
|
+
// under which the private flag and the hydrated category survive a slug a
|
|
105
|
+
// public package also claims. (The bundled-template squatting defense sits
|
|
106
|
+
// above both and is unaffected.)
|
|
107
|
+
remoteRows: [...mineRows, ...publicRows],
|
|
77
108
|
bundleVersion: bundledTemplatesVersion(),
|
|
78
109
|
remoteStatus: reachedCp ? "ok" : "unreachable",
|
|
79
110
|
});
|
|
@@ -102,7 +133,9 @@ export async function buildMarketCatalog(filters) {
|
|
|
102
133
|
if (r.version)
|
|
103
134
|
remoteVersionBySlug.set(r.slug, r.version);
|
|
104
135
|
return {
|
|
105
|
-
|
|
136
|
+
// A template the CP retired is never offered, listed as installable or
|
|
137
|
+
// suggested — even when the bundled copy this CLI ships still carries it.
|
|
138
|
+
entries: dropRetiredWorkspaces(merged.entries, retiredSlugs),
|
|
106
139
|
tierBySlug,
|
|
107
140
|
lockedSlugs,
|
|
108
141
|
installed,
|
|
@@ -112,10 +145,157 @@ export async function buildMarketCatalog(filters) {
|
|
|
112
145
|
remoteVersionBySlug,
|
|
113
146
|
};
|
|
114
147
|
}
|
|
115
|
-
/**
|
|
148
|
+
/**
|
|
149
|
+
* The human plural for a package kind, in the CLI's own listing.
|
|
150
|
+
*
|
|
151
|
+
* This was `t.charAt(0).toUpperCase() + t.slice(1)`, which `.claude/rules/
|
|
152
|
+
* vocabulary.md` forbids by name — and it showed: the listing printed "Cell"
|
|
153
|
+
* (the registry says **Card**) and "Workflow" (the registry says Automation).
|
|
154
|
+
*
|
|
155
|
+
* The real SSOT is `@synap-core/types/vocabulary`, but this package cannot
|
|
156
|
+
* resolve it — the CLI links only `@synap-core/workspace-templates`, and adding
|
|
157
|
+
* a cross-repo dependency is an install-topology change, not a labelling fix.
|
|
158
|
+
* So the table is local AND pinned: `test/kind-heading-vocabulary-parity.test.ts`
|
|
159
|
+
* source-scans the registry and fails if these words drift from it. That is the
|
|
160
|
+
* same shape as `market-install-kind-parity.test.ts`, which keeps
|
|
161
|
+
* `marketInstallKind` honest against the pod's zod enum across repos.
|
|
162
|
+
*/
|
|
163
|
+
const KIND_HEADINGS = {
|
|
164
|
+
capability: "Tools",
|
|
165
|
+
skill: "Tools",
|
|
166
|
+
// The CP publish vocabulary calls it `workflow`; the pod's install ledger
|
|
167
|
+
// (`GET /installed`) calls the same thing `automation`. Both tokens reach
|
|
168
|
+
// `kindHeading()` — `market --list` speaks CP, `market installed` speaks pod —
|
|
169
|
+
// so both are mapped, to the ONE plural the registry says.
|
|
170
|
+
workflow: "Rules",
|
|
171
|
+
automation: "Rules",
|
|
172
|
+
view: "Views",
|
|
173
|
+
cell: "Cards",
|
|
174
|
+
workspace: "Spaces",
|
|
175
|
+
};
|
|
176
|
+
/** Max width of the NAME column in `market installed` — see `displayName`. */
|
|
177
|
+
const NAME_COL_MAX = 44;
|
|
178
|
+
function kindHeading(kind) {
|
|
179
|
+
return KIND_HEADINGS[kind] ?? humanizeKindToken(kind);
|
|
180
|
+
}
|
|
181
|
+
/** Last resort for a kind the CP added and this CLI has not learned yet. */
|
|
182
|
+
function humanizeKindToken(kind) {
|
|
183
|
+
const words = kind.replace(/[-_]+/g, " ").trim();
|
|
184
|
+
return words ? words[0].toUpperCase() + words.slice(1) : kind;
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* The package type of an entry — bundled templates are always `workspace`.
|
|
188
|
+
*
|
|
189
|
+
* ⚠️ The `?? "workspace"` default is safe for DISPLAY and FILTERING (a bundled
|
|
190
|
+
* template genuinely has no category). It is NOT safe for INSTALL: a package
|
|
191
|
+
* whose category is merely UNKNOWN would install as a workspace. That is not
|
|
192
|
+
* hypothetical — on 2026-09-05 `synap market install probe-card-pack`, a
|
|
193
|
+
* freshly published CELL package, created an empty "New Workspace" and reported
|
|
194
|
+
* "✓ Installed". Install paths must use {@link declaredCategoryOf} and refuse
|
|
195
|
+
* an unknown, never fall through to this default.
|
|
196
|
+
*/
|
|
116
197
|
function typeOf(entry) {
|
|
117
198
|
return entry.category ?? "workspace";
|
|
118
199
|
}
|
|
200
|
+
/**
|
|
201
|
+
* The category the catalog ACTUALLY declared, or null when it said nothing.
|
|
202
|
+
*
|
|
203
|
+
* The distinction `typeOf` erases: "this is a workspace" and "I don't know what
|
|
204
|
+
* this is" are different facts, and only one of them may be acted on. Third
|
|
205
|
+
* instance in one day of a defaulted discriminator turning "I don't know" into
|
|
206
|
+
* a confident claim (`status ?? "installed"`, an absent `runResult.success`
|
|
207
|
+
* read as failure, and this).
|
|
208
|
+
*/
|
|
209
|
+
function declaredCategoryOf(entry) {
|
|
210
|
+
return entry.category ?? null;
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* The `kind` values the pod's `market.install` verb provisions.
|
|
214
|
+
*
|
|
215
|
+
* SSOT: the `kind` zod enum of `marketInstallParams` in
|
|
216
|
+
* `synap-backend/packages/api/src/services/capabilities/builtin-verbs.ts`
|
|
217
|
+
* (each arm implemented in `marketplace-install.ts`). This list is a
|
|
218
|
+
* hand-maintained copy — `test/market-install-kind.test.ts` pins it against a
|
|
219
|
+
* copy of that enum so a widening on the pod cannot silently leave the CLI
|
|
220
|
+
* refusing a kind the verb accepts (which is exactly how `view` and `skill`
|
|
221
|
+
* became uninstallable from the CLI).
|
|
222
|
+
*/
|
|
223
|
+
export const MARKET_INSTALL_KINDS = [
|
|
224
|
+
"capability",
|
|
225
|
+
"automation",
|
|
226
|
+
"template",
|
|
227
|
+
"cell",
|
|
228
|
+
"skill",
|
|
229
|
+
"view",
|
|
230
|
+
];
|
|
231
|
+
/**
|
|
232
|
+
* Map a CP package `category` (what {@link typeOf} returns) → the pod
|
|
233
|
+
* `market.install` verb's `kind` vocabulary, or `null` when the verb can't
|
|
234
|
+
* install that type. The CP's live PACKAGE_TYPES and the pod's cache `kind`
|
|
235
|
+
* diverge (see cp-catalog-sync.ts): the CP's `workflow` is the pod's
|
|
236
|
+
* `automation`; the CP's `workspace` is the pod's `template` — but a workspace
|
|
237
|
+
* installs through the dedicated `/packages/apply` create path, so it never
|
|
238
|
+
* routes through here. `automation`/`template` are also accepted verbatim so a
|
|
239
|
+
* row already speaking the pod's cache vocabulary still resolves.
|
|
240
|
+
*/
|
|
241
|
+
export function marketInstallKind(type) {
|
|
242
|
+
switch (type) {
|
|
243
|
+
case "capability":
|
|
244
|
+
return "capability";
|
|
245
|
+
case "cell":
|
|
246
|
+
return "cell";
|
|
247
|
+
case "workflow":
|
|
248
|
+
case "automation":
|
|
249
|
+
return "automation";
|
|
250
|
+
case "template":
|
|
251
|
+
return "template";
|
|
252
|
+
// The verb has always accepted these — the CLI returning null here is what
|
|
253
|
+
// made the published `view` packages uninstallable from the terminal.
|
|
254
|
+
case "skill":
|
|
255
|
+
return "skill";
|
|
256
|
+
case "view":
|
|
257
|
+
return "view";
|
|
258
|
+
default:
|
|
259
|
+
return null;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* Kinds whose `market.install` arm HARD-REQUIRES an acting workspace and 400s
|
|
264
|
+
* without one (`marketplace-install.ts`): an automation is added TO a workspace,
|
|
265
|
+
* and a view package needs a workspace to place its views in. Every other kind
|
|
266
|
+
* (capability / cell / skill) lands pod-wide — that branch is deliberate, so
|
|
267
|
+
* they must NOT be added here.
|
|
268
|
+
*/
|
|
269
|
+
export function marketInstallNeedsWorkspace(kind) {
|
|
270
|
+
return kind === "automation" || kind === "view";
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* Classify the PER-ITEM results of a kind:automation (workflow) install.
|
|
274
|
+
*
|
|
275
|
+
* The backend `market.install` automation case installs each automation under
|
|
276
|
+
* its own try/catch (per-item isolation), so the verb can report the overall
|
|
277
|
+
* install as `{status:"installed"}` while individual items are
|
|
278
|
+
* `{status:"error"}`. The CLI must inspect these before printing success — a
|
|
279
|
+
* bare "✓ Installed" is a lie when every item errored (the false-success bug).
|
|
280
|
+
*
|
|
281
|
+
* Returns null for any outcome that carries NO per-item array (cell, skill,
|
|
282
|
+
* workspace, …) so the caller falls through to the unchanged success line.
|
|
283
|
+
* Reads `automations[]` (the automation kind's field) or a generic `results[]`.
|
|
284
|
+
*/
|
|
285
|
+
export function summarizeItemInstall(installed) {
|
|
286
|
+
const raw = Array.isArray(installed.automations)
|
|
287
|
+
? installed.automations
|
|
288
|
+
: Array.isArray(installed.results)
|
|
289
|
+
? installed.results
|
|
290
|
+
: undefined;
|
|
291
|
+
if (!raw)
|
|
292
|
+
return null;
|
|
293
|
+
const items = raw;
|
|
294
|
+
const created = items.filter((i) => i.status === "created").length;
|
|
295
|
+
const reused = items.filter((i) => i.status === "reused").length;
|
|
296
|
+
const errored = items.filter((i) => i.status === "error");
|
|
297
|
+
return { created, reused, errored, okCount: created + reused };
|
|
298
|
+
}
|
|
119
299
|
/**
|
|
120
300
|
* Client-side filter. The CP browse route filters REMOTE rows server-side, but
|
|
121
301
|
* `assembleCatalog` always adds the BUNDLED (all-`workspace`) templates — so a
|
|
@@ -135,27 +315,20 @@ function matchesFilters(entry, filters) {
|
|
|
135
315
|
}
|
|
136
316
|
return true;
|
|
137
317
|
}
|
|
138
|
-
/** Status badges — suite (tag) / private / installed / tier / locked. */
|
|
139
|
-
function badges(entry, cat) {
|
|
140
|
-
const out = [];
|
|
141
|
-
if (isSuite(entry))
|
|
142
|
-
out.push(chalk.magenta("suite"));
|
|
143
|
-
if (entry.isPrivate)
|
|
144
|
-
out.push(chalk.yellow("private"));
|
|
145
|
-
if (cat.installed.has(entry.slug))
|
|
146
|
-
out.push(chalk.green("installed"));
|
|
147
|
-
const tier = cat.tierBySlug.get(entry.slug);
|
|
148
|
-
if (tier?.requiredTier)
|
|
149
|
-
out.push(chalk.cyan(tier.requiredTier));
|
|
150
|
-
else if (tier?.pricingModel && tier.pricingModel !== "free")
|
|
151
|
-
out.push(chalk.cyan(tier.pricingModel));
|
|
152
|
-
if (cat.lockedSlugs.has(entry.slug))
|
|
153
|
-
out.push(chalk.red("locked"));
|
|
154
|
-
return out;
|
|
155
|
-
}
|
|
156
318
|
function clip(s, n) {
|
|
157
319
|
return s.length <= n ? s : s.slice(0, n - 1).trimEnd() + "…";
|
|
158
320
|
}
|
|
321
|
+
/**
|
|
322
|
+
* True when the pod reported a FAILED install layer.
|
|
323
|
+
*
|
|
324
|
+
* Reads `status` explicitly rather than inferring failure from the absence of a
|
|
325
|
+
* success marker: an unrecognised status is not a failure, and an absent
|
|
326
|
+
* `layers` array is not a success. Both are simply "not a reported failure",
|
|
327
|
+
* which is what the pre-existing `seedFailed` path already covers.
|
|
328
|
+
*/
|
|
329
|
+
export function hasLayerFailure(layers) {
|
|
330
|
+
return (layers ?? []).some((l) => l?.status === "failed");
|
|
331
|
+
}
|
|
159
332
|
/**
|
|
160
333
|
* Print an honest per-dependency seed summary — no more silent "✓ ready" when
|
|
161
334
|
* a dependency's capabilities/playbooks failed to seed. Prefers the top-level
|
|
@@ -163,6 +336,23 @@ function clip(s, n) {
|
|
|
163
336
|
* back to re-deriving from the per-dependency `dependencies[]` array on older
|
|
164
337
|
* pod builds. No-op when there's nothing to report.
|
|
165
338
|
*/
|
|
339
|
+
/**
|
|
340
|
+
* Name the install layers the pod reported as FAILED.
|
|
341
|
+
*
|
|
342
|
+
* The verdict line already says "some capabilities failed"; without
|
|
343
|
+
* this the user is told something failed and never which thing — the same
|
|
344
|
+
* complaint that made `install-layers.ts` fall back from `name` to `key`.
|
|
345
|
+
* Silent when the pod sent no layers (an older pod) or none failed.
|
|
346
|
+
*/
|
|
347
|
+
export function printLayerOutcomes(layers) {
|
|
348
|
+
const failed = (layers ?? []).filter((l) => l?.status === "failed");
|
|
349
|
+
if (failed.length === 0)
|
|
350
|
+
return;
|
|
351
|
+
for (const l of failed) {
|
|
352
|
+
log.error(`${l.layer ?? "(unnamed layer)"} — ${l.message ?? "no detail reported"}`);
|
|
353
|
+
}
|
|
354
|
+
log.hint("The workspace itself was created. Re-run the install to retry the failed layer(s); nothing above is retried automatically.");
|
|
355
|
+
}
|
|
166
356
|
function printSeedOutcomes(dependencies, seedSummary) {
|
|
167
357
|
if (seedSummary) {
|
|
168
358
|
if (seedSummary.attempted === 0)
|
|
@@ -211,11 +401,61 @@ function hasSeedFailure(dependencies, seedSummary) {
|
|
|
211
401
|
* not that the install was genuinely a no-op (the observed `market install
|
|
212
402
|
* the-arch` → "already up to date" bug).
|
|
213
403
|
*/
|
|
214
|
-
function detectStampContradiction(ws, seedSummary, remoteVersionSent, wasUnstamped) {
|
|
215
|
-
if (!remoteVersionSent
|
|
404
|
+
function detectStampContradiction(ws, seedSummary, remoteVersionSent, wasUnstamped, stampVerified) {
|
|
405
|
+
if (!remoteVersionSent)
|
|
406
|
+
return false;
|
|
407
|
+
// GROUND TRUTH wins: a post-apply re-read of the workspace's stored stamp.
|
|
408
|
+
// `true` = it landed (never warn), `false` = reached the pod and it's still
|
|
409
|
+
// missing (warn — old pod, no stamp-on-reconcile). Only fall back to the
|
|
410
|
+
// proxy below when the re-read wasn't attempted/possible (`undefined`/`null`),
|
|
411
|
+
// because the proxy false-positives once the pod DOES stamp on an unchanged
|
|
412
|
+
// (content-identical) reconcile — `outcome` still comes back `"unchanged"`.
|
|
413
|
+
if (stampVerified === true)
|
|
414
|
+
return false;
|
|
415
|
+
if (stampVerified === false)
|
|
416
|
+
return true;
|
|
417
|
+
if (!wasUnstamped)
|
|
216
418
|
return false;
|
|
217
419
|
return ws?.outcome === "unchanged" || (ws?.outcome === undefined && seedSummary === undefined);
|
|
218
420
|
}
|
|
421
|
+
/**
|
|
422
|
+
* How far the pod says layer 2 got, read from the `/packages/apply` response
|
|
423
|
+
* (synap-backend `hub-protocol/rest/packages.ts`): a thrown layer 2 arrives as
|
|
424
|
+
* `layer2: {status: "failed"}`, an unconverged one as the `templateApply`
|
|
425
|
+
* record, and either way the pod's sentence as `templateApplyWarning`.
|
|
426
|
+
* `null` = the pod reported nothing unfinished (converged, or an older pod).
|
|
427
|
+
*/
|
|
428
|
+
export function podApplyState(res) {
|
|
429
|
+
const layer2 = res.layer2;
|
|
430
|
+
if (layer2?.status === "failed")
|
|
431
|
+
return "not-finished";
|
|
432
|
+
const record = res.templateApply;
|
|
433
|
+
if (record?.state === "awaiting-approval")
|
|
434
|
+
return "awaiting-approval";
|
|
435
|
+
if (record || heldVersionLine(res))
|
|
436
|
+
return "not-finished";
|
|
437
|
+
return null;
|
|
438
|
+
}
|
|
439
|
+
/**
|
|
440
|
+
* The verdict of one `/packages/apply` response — the ONE reader both
|
|
441
|
+
* `marketInstall` and `applyOnePackage` call. Layer 2's outcome comes from the
|
|
442
|
+
* pod's own state (`podApplyState`): an apply the pod says is unfinished is
|
|
443
|
+
* never "updated", and never a stamp contradiction (the pod held the version
|
|
444
|
+
* on purpose — redeploying fixes nothing).
|
|
445
|
+
*/
|
|
446
|
+
export function classifyApplyResponse(entry, res, ctx) {
|
|
447
|
+
const state = podApplyState(res);
|
|
448
|
+
if (state) {
|
|
449
|
+
const recorded = res.templateApply?.proposals;
|
|
450
|
+
const ids = Array.isArray(recorded) ? recorded.filter((p) => typeof p === "string") : [];
|
|
451
|
+
const next = heldVersionLine(res) ??
|
|
452
|
+
(state === "awaiting-approval"
|
|
453
|
+
? `Approve its proposals (${proposalsDoor(ids) ?? "synap proposals"}), then run synap templates update ${entry.slug}.`
|
|
454
|
+
: `Run synap templates update ${entry.slug} to finish it.`);
|
|
455
|
+
return { status: state, warnings: [next] };
|
|
456
|
+
}
|
|
457
|
+
return classifyApplyResult(entry, res.workspace, res.dependencies, res.seedSummary, ctx);
|
|
458
|
+
}
|
|
219
459
|
/**
|
|
220
460
|
* Classify one `/packages/apply` response into the honest verdict both
|
|
221
461
|
* `marketInstall` and `applyOnePackage` print (and both put in `--json`).
|
|
@@ -225,8 +465,11 @@ function detectStampContradiction(ws, seedSummary, remoteVersionSent, wasUnstamp
|
|
|
225
465
|
* already-versioned slugs, so it always passes `false`).
|
|
226
466
|
*/
|
|
227
467
|
export function classifyApplyResult(entry, ws, dependencies, seedSummary, ctx) {
|
|
228
|
-
|
|
229
|
-
|
|
468
|
+
// A post-workspace LAYER failure counts the same as a seed failure: the
|
|
469
|
+
// workspace exists, but part of what the package promised did not land.
|
|
470
|
+
const seedFailed = hasSeedFailure(dependencies, seedSummary) || hasLayerFailure(ws?.layers);
|
|
471
|
+
if (!ctx.stampHeld &&
|
|
472
|
+
detectStampContradiction(ws, seedSummary, ctx.remoteVersionSent, ctx.wasUnstamped, ctx.stampVerified)) {
|
|
230
473
|
return {
|
|
231
474
|
status: "stamp-contradiction",
|
|
232
475
|
warnings: [
|
|
@@ -241,8 +484,13 @@ export function classifyApplyResult(entry, ws, dependencies, seedSummary, ctx) {
|
|
|
241
484
|
if (ws?.outcome === "unchanged")
|
|
242
485
|
return { status: "unchanged", warnings: [] };
|
|
243
486
|
if (ws?.status === "composed") {
|
|
487
|
+
// Honest: a compose-overlay has no standalone workspace — it merges INTO
|
|
488
|
+
// its base. But it IS updatable: `market update <slug>` re-sends the
|
|
489
|
+
// package, the pod re-resolves the base and re-layers the latest content
|
|
490
|
+
// (idempotent, additive). So this agrees with `market update` listing the
|
|
491
|
+
// slug as a normal updatable row — don't tell the user it "isn't updatable".
|
|
244
492
|
const warnings = [
|
|
245
|
-
`${entry.name} is a compose-overlay — it
|
|
493
|
+
`${entry.name} is a compose-overlay — it layers onto its base workspace rather than creating its own. Pull its latest anytime with: synap market update ${entry.slug}`,
|
|
246
494
|
];
|
|
247
495
|
return { status: seedFailed ? "composed-with-failures" : "composed", warnings };
|
|
248
496
|
}
|
|
@@ -250,6 +498,25 @@ export function classifyApplyResult(entry, ws, dependencies, seedSummary, ctx) {
|
|
|
250
498
|
return { status: "legacy-unknown", warnings: [] };
|
|
251
499
|
return { status: "no-workspace", warnings: [] };
|
|
252
500
|
}
|
|
501
|
+
/** One plain line per deferred placement; `[]` when none (or an older pod). */
|
|
502
|
+
export function deferredPlacementLines(res) {
|
|
503
|
+
const deferred = res.actionPlacements?.deferred;
|
|
504
|
+
if (!Array.isArray(deferred))
|
|
505
|
+
return [];
|
|
506
|
+
return deferred
|
|
507
|
+
.filter((d) => d && typeof d.proposalId === "string")
|
|
508
|
+
.map((d) => `The "${d.label ?? d.ref}" button appears once you approve proposal ${d.proposalId} (synap open proposal ${d.proposalId}), then run synap market update again.`);
|
|
509
|
+
}
|
|
510
|
+
/**
|
|
511
|
+
* Why the pod did NOT record the template version — the pod's ONE sentence
|
|
512
|
+
* (`templateApplyWarning` on the `/packages/apply` response, synap-backend
|
|
513
|
+
* `templateApplyWarning`): the same words `synap templates` keeps showing under
|
|
514
|
+
* the space. Printed verbatim; `null` when the version was recorded (or an
|
|
515
|
+
* older pod that does not say).
|
|
516
|
+
*/
|
|
517
|
+
export function heldVersionLine(res) {
|
|
518
|
+
return typeof res.templateApplyWarning === "string" ? res.templateApplyWarning : null;
|
|
519
|
+
}
|
|
253
520
|
/** Render a `classifyApplyResult` verdict — the ONE honest-text door for both `marketInstall` and `applyOnePackage`. */
|
|
254
521
|
export function printApplyVerdict(entry, verdict) {
|
|
255
522
|
switch (verdict.status) {
|
|
@@ -257,13 +524,13 @@ export function printApplyVerdict(entry, verdict) {
|
|
|
257
524
|
log.success(`Installed ${entry.name}.`);
|
|
258
525
|
return;
|
|
259
526
|
case "installed-with-failures":
|
|
260
|
-
log.warn(`Installed ${entry.name}
|
|
527
|
+
log.warn(`Installed ${entry.name}, but ${templateStepPhrase("unfinished")}: some capabilities failed (see below).`);
|
|
261
528
|
return;
|
|
262
529
|
case "updated":
|
|
263
530
|
log.success(`Updated ${entry.name}.`);
|
|
264
531
|
return;
|
|
265
532
|
case "updated-with-failures":
|
|
266
|
-
log.warn(`Updated ${entry.name}
|
|
533
|
+
log.warn(`Updated ${entry.name}, but ${templateStepPhrase("unfinished")}: some capabilities failed (see below).`);
|
|
267
534
|
return;
|
|
268
535
|
case "unchanged":
|
|
269
536
|
log.info(`• ${entry.name} is already up to date.`);
|
|
@@ -273,7 +540,15 @@ export function printApplyVerdict(entry, verdict) {
|
|
|
273
540
|
log.hint(verdict.warnings[0]);
|
|
274
541
|
return;
|
|
275
542
|
case "composed-with-failures":
|
|
276
|
-
log.warn(`${entry.name} — layered onto its base workspace,
|
|
543
|
+
log.warn(`${entry.name} — layered onto its base workspace, but ${templateStepPhrase("unfinished")}: some capabilities failed (see below).`);
|
|
544
|
+
log.hint(verdict.warnings[0]);
|
|
545
|
+
return;
|
|
546
|
+
case "not-finished":
|
|
547
|
+
log.warn(`${entry.name} is in place, but ${templateStepPhrase("unfinished")}.`);
|
|
548
|
+
log.hint(verdict.warnings[0]);
|
|
549
|
+
return;
|
|
550
|
+
case "awaiting-approval":
|
|
551
|
+
log.info(`${entry.name} is in place; the rest waits on your approval.`);
|
|
277
552
|
log.hint(verdict.warnings[0]);
|
|
278
553
|
return;
|
|
279
554
|
case "stamp-contradiction":
|
|
@@ -302,8 +577,12 @@ export async function market(opts) {
|
|
|
302
577
|
name: e.name,
|
|
303
578
|
description: e.description,
|
|
304
579
|
type: typeOf(e),
|
|
580
|
+
domain: e.domain ?? null,
|
|
305
581
|
tags: e.tags,
|
|
306
582
|
suite: isSuite(e),
|
|
583
|
+
overlay: isOverlay(e),
|
|
584
|
+
ridesBase: composeBaseOf(e.slug),
|
|
585
|
+
bundles: bundleCountOf(e.slug),
|
|
307
586
|
isPrivate: e.isPrivate,
|
|
308
587
|
installed: cat.installed.has(e.slug),
|
|
309
588
|
requiredTier: tier?.requiredTier ?? null,
|
|
@@ -325,42 +604,153 @@ export async function market(opts) {
|
|
|
325
604
|
log.dim("Try 'synap market' with no filter, or 'synap login' to see your private packages.");
|
|
326
605
|
return;
|
|
327
606
|
}
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
607
|
+
renderCatalog(entries, cat);
|
|
608
|
+
}
|
|
609
|
+
/**
|
|
610
|
+
* The kind of composition a workspace template participates in, derived from its
|
|
611
|
+
* bundled dependency edges — NOT hardcoded. A `compose` edge = an OVERLAY that
|
|
612
|
+
* rides a base workspace (installing it bare is meaningless); a `require` edge
|
|
613
|
+
* on a suite = a workspace it BUNDLES. Non-bundled (private/CP-only) rows return
|
|
614
|
+
* `null` base and 0 bundles, which the renderer degrades gracefully.
|
|
615
|
+
*/
|
|
616
|
+
function composeBaseOf(slug) {
|
|
617
|
+
for (const dep of bundledEdgesOf(slug)) {
|
|
618
|
+
if (dep.relation === "compose")
|
|
619
|
+
return dep.slug;
|
|
620
|
+
}
|
|
621
|
+
return null;
|
|
622
|
+
}
|
|
623
|
+
function bundleCountOf(slug) {
|
|
624
|
+
return bundledEdgesOf(slug).filter((d) => d.relation === "require").length;
|
|
625
|
+
}
|
|
626
|
+
function isOverlay(entry) {
|
|
627
|
+
return typeOf(entry) === "workspace" && !isSuite(entry) && composeBaseOf(entry.slug) !== null;
|
|
628
|
+
}
|
|
629
|
+
/**
|
|
630
|
+
* Section-aware catalog renderer. Three axes carry distinct signal, so each gets
|
|
631
|
+
* its OWN visual channel instead of one flat colored word (the old list):
|
|
632
|
+
* KIND → the SECTION (Suites / Workspaces-by-domain / Overlays / other types)
|
|
633
|
+
* STATE → a leading ✓ glyph (installed)
|
|
634
|
+
* ACCESS → a dim right-hand tag (private / tier / locked)
|
|
635
|
+
* Name reads first (you scan it); the slug is the dim type-token (you type it).
|
|
636
|
+
*/
|
|
637
|
+
function renderCatalog(entries, cat) {
|
|
638
|
+
const width = process.stdout.columns ?? 100;
|
|
639
|
+
const NAME_W = 26;
|
|
640
|
+
const SLUG_W = 20;
|
|
641
|
+
// Resolve a base slug to its display name so an overlay names the WORKSPACE it
|
|
642
|
+
// extends, not its raw slug (e.g. "→ Brand Library", not "→ brand-library").
|
|
643
|
+
const nameBySlug = new Map(entries.map((e) => [e.slug, e.name]));
|
|
644
|
+
const accessTag = (entry) => {
|
|
645
|
+
const parts = [];
|
|
646
|
+
if (entry.isPrivate)
|
|
647
|
+
parts.push(chalk.yellow("private"));
|
|
648
|
+
const tier = cat.tierBySlug.get(entry.slug);
|
|
649
|
+
if (tier?.requiredTier)
|
|
650
|
+
parts.push(chalk.cyan(tier.requiredTier));
|
|
651
|
+
else if (tier?.pricingModel && tier.pricingModel !== "free")
|
|
652
|
+
parts.push(chalk.cyan(tier.pricingModel));
|
|
653
|
+
if (cat.lockedSlugs.has(entry.slug))
|
|
654
|
+
parts.push(chalk.red("locked"));
|
|
655
|
+
return parts.length ? " " + parts.join(" ") : "";
|
|
340
656
|
};
|
|
657
|
+
// One item = one line: glyph · bold name · default-weight slug (the token you
|
|
658
|
+
// TYPE — never dimmed) · dim detail · dim access.
|
|
659
|
+
const row = (entry, detail) => {
|
|
660
|
+
const glyph = cat.installed.has(entry.slug) ? chalk.green("✓") : " ";
|
|
661
|
+
const name = clip(entry.name, NAME_W).padEnd(NAME_W);
|
|
662
|
+
const slug = clip(entry.slug, SLUG_W).padEnd(SLUG_W);
|
|
663
|
+
const access = accessTag(entry);
|
|
664
|
+
const head = ` ${glyph} ${chalk.bold(name)} ${slug}`;
|
|
665
|
+
// Budget the detail to the remaining terminal width (never wrap/overflow).
|
|
666
|
+
const detailText = detail ?? entry.description ?? "";
|
|
667
|
+
const used = 2 + 1 + 1 + NAME_W + 1 + SLUG_W + 1;
|
|
668
|
+
const room = Math.max(12, width - used - stripLen(access) - 1);
|
|
669
|
+
console.log(head + " " + chalk.dim(clip(detailText, room)) + access);
|
|
670
|
+
};
|
|
671
|
+
// An overlay's identity IS "Name → Base Workspace" — the base it extends must
|
|
672
|
+
// read as part of what it IS, not be buried in the description. So it gets its
|
|
673
|
+
// own row shape: a combined, coloured identity column, then slug, then blurb.
|
|
674
|
+
const IDENT_W = 40;
|
|
675
|
+
const overlayRow = (entry) => {
|
|
676
|
+
const glyph = cat.installed.has(entry.slug) ? chalk.green("✓") : " ";
|
|
677
|
+
const baseName = clip(nameBySlug.get(composeBaseOf(entry.slug) ?? "") ?? composeBaseOf(entry.slug) ?? "", IDENT_W - 22 - 3 // leave room for "name → " inside IDENT_W
|
|
678
|
+
);
|
|
679
|
+
const nameText = clip(entry.name, 22);
|
|
680
|
+
const identityVisible = `${nameText} → ${baseName}`;
|
|
681
|
+
const identity = chalk.bold(nameText) + chalk.dim(" → ") + chalk.cyan(baseName);
|
|
682
|
+
const pad = " ".repeat(Math.max(1, IDENT_W - identityVisible.length));
|
|
683
|
+
const slug = clip(entry.slug, SLUG_W).padEnd(SLUG_W);
|
|
684
|
+
const access = accessTag(entry);
|
|
685
|
+
const used = 2 + 1 + 1 + IDENT_W + 1 + SLUG_W + 1;
|
|
686
|
+
const room = Math.max(10, width - used - stripLen(access) - 1);
|
|
687
|
+
console.log(` ${glyph} ${identity}${pad} ${slug} ` +
|
|
688
|
+
chalk.dim(clip(entry.description ?? "", room)) +
|
|
689
|
+
access);
|
|
690
|
+
};
|
|
691
|
+
// ── Suites — the headline: one install = a whole operating core ────────────
|
|
692
|
+
const suites = entries.filter((e) => isSuite(e));
|
|
341
693
|
if (suites.length > 0) {
|
|
342
|
-
log.heading(" Suites
|
|
694
|
+
log.heading(" Suites · a whole operating core in one install");
|
|
343
695
|
log.blank();
|
|
344
|
-
suites
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
696
|
+
for (const e of suites) {
|
|
697
|
+
const n = bundleCountOf(e.slug);
|
|
698
|
+
row(e, n > 0 ? `bundles ${n} workspace${n === 1 ? "" : "s"}` : e.description ?? "");
|
|
699
|
+
}
|
|
700
|
+
log.blank();
|
|
701
|
+
}
|
|
702
|
+
// ── Workspaces — standalone installs, grouped by authored DOMAIN ───────────
|
|
703
|
+
const workspaces = entries.filter((e) => typeOf(e) === "workspace" && !isSuite(e) && !isOverlay(e));
|
|
704
|
+
if (workspaces.length > 0) {
|
|
705
|
+
log.heading(" Workspaces");
|
|
706
|
+
for (const group of groupByDomain(workspaces, (e) => e.domain)) {
|
|
707
|
+
log.blank();
|
|
708
|
+
console.log(" " + chalk.underline(group.label));
|
|
709
|
+
for (const e of group.items)
|
|
710
|
+
row(e);
|
|
711
|
+
}
|
|
712
|
+
log.blank();
|
|
713
|
+
}
|
|
714
|
+
// ── Add-ons — each rides a workspace you already have (named in-line) ───────
|
|
715
|
+
const overlays = entries.filter((e) => isOverlay(e));
|
|
716
|
+
if (overlays.length > 0) {
|
|
717
|
+
log.heading(" Add-ons · each extends the workspace named after the →");
|
|
718
|
+
log.blank();
|
|
719
|
+
for (const e of overlays)
|
|
720
|
+
overlayRow(e);
|
|
356
721
|
log.blank();
|
|
357
|
-
byType.get(t).forEach(render);
|
|
358
722
|
}
|
|
359
|
-
|
|
723
|
+
// ── Other package types (capability / skill / view / cell) by type ─────────
|
|
724
|
+
const other = entries.filter((e) => typeOf(e) !== "workspace");
|
|
725
|
+
if (other.length > 0) {
|
|
726
|
+
const order = ["capability", "skill", "workflow", "view", "cell"];
|
|
727
|
+
const byType = new Map();
|
|
728
|
+
for (const e of other) {
|
|
729
|
+
const t = typeOf(e);
|
|
730
|
+
(byType.get(t) ?? byType.set(t, []).get(t)).push(e);
|
|
731
|
+
}
|
|
732
|
+
const types = [...byType.keys()].sort((a, b) => (order.indexOf(a) + 1 || 99) - (order.indexOf(b) + 1 || 99));
|
|
733
|
+
for (const t of types) {
|
|
734
|
+
log.heading(` ${kindHeading(t)}`);
|
|
735
|
+
log.blank();
|
|
736
|
+
for (const e of byType.get(t))
|
|
737
|
+
row(e);
|
|
738
|
+
log.blank();
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
// Footer: a scannable count line + progressive-disclosure hints.
|
|
742
|
+
const installedN = entries.filter((e) => cat.installed.has(e.slug)).length;
|
|
743
|
+
log.dim(`${installedN} installed · ${entries.length - installedN} available` +
|
|
744
|
+
(suites.length ? ` · ${suites.length} suite${suites.length === 1 ? "" : "s"}` : ""));
|
|
360
745
|
if (!cat.loggedIn)
|
|
361
746
|
log.dim("Log in to see your private packages: synap login");
|
|
362
747
|
renderNextSteps(FLOW.afterMarketList());
|
|
363
748
|
}
|
|
749
|
+
/** Visible length of a string with chalk color codes stripped — for width math. */
|
|
750
|
+
function stripLen(s) {
|
|
751
|
+
// eslint-disable-next-line no-control-regex
|
|
752
|
+
return s.replace(/\[[0-9;]*m/g, "").length;
|
|
753
|
+
}
|
|
364
754
|
// ── Optional --project link ──────────────────────────────────────────────────
|
|
365
755
|
/**
|
|
366
756
|
* Installs are POD-WIDE. A template provisions WORKSPACES (pod-level) and their
|
|
@@ -381,6 +771,67 @@ async function resolveOptionalProject(cfg, explicitId) {
|
|
|
381
771
|
}
|
|
382
772
|
return { projectId: match.id, name: match.name };
|
|
383
773
|
}
|
|
774
|
+
/** Human render of a preflight report — scannable summary + per-issue reasons. */
|
|
775
|
+
function renderPreflightReport(entry, r) {
|
|
776
|
+
// Hard structural failure — the definition itself won't install.
|
|
777
|
+
if (r.validationErrors.length > 0) {
|
|
778
|
+
log.error(`${entry.name} — ${r.validationErrors.length} structural error${r.validationErrors.length === 1 ? "" : "s"} (won't install):`);
|
|
779
|
+
for (const e of r.validationErrors)
|
|
780
|
+
log.hint(e);
|
|
781
|
+
return;
|
|
782
|
+
}
|
|
783
|
+
const p = r.profiles;
|
|
784
|
+
// Every issue class, not just profile conflicts — the verdict and the printed
|
|
785
|
+
// warnings must agree (`r.ok` excludes advisory deferred/scopeConflicts, so
|
|
786
|
+
// "cleanly" alone could sit above a ⚠ line; count them all here).
|
|
787
|
+
const issueCounts = [];
|
|
788
|
+
if (p.conflicts.length)
|
|
789
|
+
issueCounts.push(`${p.conflicts.length} profile conflict${p.conflicts.length === 1 ? "" : "s"}`);
|
|
790
|
+
if (p.deferred.length)
|
|
791
|
+
issueCounts.push(`${p.deferred.length} duplicate${p.deferred.length === 1 ? "" : "s"}`);
|
|
792
|
+
if (p.scopeConflicts.length)
|
|
793
|
+
issueCounts.push(`${p.scopeConflicts.length} scope note${p.scopeConflicts.length === 1 ? "" : "s"}`);
|
|
794
|
+
if (r.entityLinks.unresolved.length)
|
|
795
|
+
issueCounts.push(`${r.entityLinks.unresolved.length} dropped link${r.entityLinks.unresolved.length === 1 ? "" : "s"}`);
|
|
796
|
+
if (r.views.wouldOrphan.length)
|
|
797
|
+
issueCounts.push(`${r.views.wouldOrphan.length} orphaned view${r.views.wouldOrphan.length === 1 ? "" : "s"}`);
|
|
798
|
+
const blocking = !r.ok; // conflicts / dropped links / orphaned views
|
|
799
|
+
const hasAny = issueCounts.length > 0;
|
|
800
|
+
// Verdict — never says "clean" if any warning will print below.
|
|
801
|
+
if (!hasAny)
|
|
802
|
+
log.success(`Preflight for ${entry.name}: no conflicts — safe to install.`);
|
|
803
|
+
else if (blocking)
|
|
804
|
+
log.warn(`Preflight for ${entry.name}: would install with issues — review before installing.`);
|
|
805
|
+
else
|
|
806
|
+
log.warn(`Preflight for ${entry.name}: installs, with ${issueCounts.length} advisory note${issueCounts.length === 1 ? "" : "s"} — review below.`);
|
|
807
|
+
log.dim(`Profiles: ${p.create.length} to create · ${p.reused.length} reused`);
|
|
808
|
+
if (hasAny)
|
|
809
|
+
log.dim(`Issues: ${issueCounts.join(" · ")}`);
|
|
810
|
+
// Warnings are the PAYLOAD of a dry-run — print them BEFORE the (potentially
|
|
811
|
+
// long) create/reuse list so they never scroll off-screen.
|
|
812
|
+
// The load-bearing signal for an AI author: a declared role/kind that clashes
|
|
813
|
+
// with an existing pod profile is SKIPPED — never created, never mutated.
|
|
814
|
+
for (const c of p.conflicts)
|
|
815
|
+
log.warn(`${c.slug}: declared ${c.declaredKind}, pod has ${c.existingKind} → SKIP (not created)`);
|
|
816
|
+
for (const d of p.deferred)
|
|
817
|
+
log.warn(`${d.slug}: same slug exists but not promotable (${d.reason}) → a duplicate would be created`);
|
|
818
|
+
for (const sc of p.scopeConflicts)
|
|
819
|
+
log.warn(`${sc.slug}: declared scope ${sc.declaredScope}, pod has ${sc.existingScope} → reused as-is (declared scope not applied)`);
|
|
820
|
+
for (const link of r.entityLinks.unresolved)
|
|
821
|
+
log.warn(`link ${link}: an endpoint profile is a conflict → relation dropped`);
|
|
822
|
+
for (const v of r.views.wouldOrphan)
|
|
823
|
+
log.warn(`view "${v}": its scope profile is a conflict → view would have no scope`);
|
|
824
|
+
// One actionable hint for the whole conflict/link/view class — the remedy the
|
|
825
|
+
// per-line messages don't give.
|
|
826
|
+
if (p.conflicts.length || r.entityLinks.unresolved.length || r.views.wouldOrphan.length)
|
|
827
|
+
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.");
|
|
828
|
+
for (const slug of p.create)
|
|
829
|
+
log.dim(`+ ${slug} (new profile)`);
|
|
830
|
+
for (const slug of p.reused)
|
|
831
|
+
log.dim(`↺ ${slug} (reuses existing pod profile)`);
|
|
832
|
+
if (!hasAny)
|
|
833
|
+
log.hint("Run without --dry-run to install.");
|
|
834
|
+
}
|
|
384
835
|
// ── `synap market install <slug>` ───────────────────────────────────────────
|
|
385
836
|
export async function marketInstall(slug, opts) {
|
|
386
837
|
const applyTimeoutMs = parseApplyTimeoutMs(opts.timeout);
|
|
@@ -395,15 +846,208 @@ export async function marketInstall(slug, opts) {
|
|
|
395
846
|
log.hint("Browse what's available: synap market --list");
|
|
396
847
|
process.exit(1);
|
|
397
848
|
}
|
|
398
|
-
|
|
399
|
-
//
|
|
400
|
-
//
|
|
849
|
+
// Install must know what it is installing. `typeOf`'s display default would
|
|
850
|
+
// silently provision a WORKSPACE for anything the catalog could not classify
|
|
851
|
+
// — which is exactly how a published cell package became "New Workspace".
|
|
852
|
+
// Last chance before refusing: the catalog's `/mine` rows carry no category,
|
|
853
|
+
// and hydration there is bounded/best-effort. For THIS one slug we can ask
|
|
854
|
+
// the authed single-package door directly — the only door that can see a
|
|
855
|
+
// private package the caller owns. Still no defaulting: an unresolved
|
|
856
|
+
// category falls through to the refusal below.
|
|
857
|
+
let declared = declaredCategoryOf(entry);
|
|
858
|
+
if (!declared) {
|
|
859
|
+
const token = getStoredToken()?.token;
|
|
860
|
+
if (token) {
|
|
861
|
+
const rec = await fetchPackageRecord(slug, token);
|
|
862
|
+
declared = rec?.category ?? null;
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
if (!declared) {
|
|
866
|
+
log.error(`Can't install "${slug}": the catalog didn't say what kind of package it is.`);
|
|
867
|
+
log.hint("This usually means the package is PRIVATE and the public catalog can't see it. Run `synap login`, then retry — nothing was installed.");
|
|
868
|
+
process.exit(1);
|
|
869
|
+
}
|
|
870
|
+
const type = declared;
|
|
871
|
+
// `--dry-run` promises "writes nothing". Only the WORKSPACE path below can
|
|
872
|
+
// keep that promise: it has a write-free preflight (`/packages/preflight`).
|
|
873
|
+
// The `market.install` verb has no `dryRun` parameter (see
|
|
874
|
+
// `marketInstallParams` in the pod's `builtin-verbs.ts`), and `cap add`
|
|
875
|
+
// installs immediately — so for every other kind this flag used to be
|
|
876
|
+
// silently ignored and the package was INSTALLED FOR REAL. A safety flag that
|
|
877
|
+
// performs the action it promises to skip is worse than no flag, so refuse
|
|
878
|
+
// and say why rather than quietly doing the write.
|
|
879
|
+
if (opts.dryRun && type !== "workspace") {
|
|
880
|
+
log.error(`--dry-run isn't available for ${type} packages.`);
|
|
881
|
+
log.hint(`Only workspace packages have a write-free preflight. Nothing was installed. To install for real: synap market install ${slug}`);
|
|
882
|
+
process.exit(1);
|
|
883
|
+
}
|
|
884
|
+
// A CAPABILITY installs through the SAME door as `cap add` — one code path, so
|
|
885
|
+
// the two commands can never diverge. That door resolves the template by key
|
|
886
|
+
// (so opt-in / syncByDefault:false caps like unipile-linkedin work — the
|
|
887
|
+
// params-less market.install verb below cannot) AND prompts for any required
|
|
888
|
+
// credential inline (a key), which a server-side verb can never do. `cap add`
|
|
889
|
+
// is now a thin alias of this. `--pod` is honoured (capabilityAdd resolves the
|
|
890
|
+
// active pod itself); `--json` prompts can't render, so a credentialed cap
|
|
891
|
+
// still prompts — run it interactively.
|
|
892
|
+
if (type === "capability") {
|
|
893
|
+
await capabilityAdd(slug, {});
|
|
894
|
+
return;
|
|
895
|
+
}
|
|
896
|
+
// Other non-workspace packages install through the pod's ONE kind-agnostic
|
|
897
|
+
// door — the `market.install` verb on `/capabilities/execute`. (The WORKSPACE
|
|
898
|
+
// path below uses `/packages/apply`, which provisions a workspace, and is
|
|
899
|
+
// left untouched.) The verb resolves the definition from `cp_catalog_cache`
|
|
900
|
+
// by (kind, slug), tier-gates, and — for the CLI's OPERATOR hub key (no
|
|
901
|
+
// agentUserId) — installs directly; an agent call would file a proposal.
|
|
401
902
|
if (type !== "workspace") {
|
|
402
|
-
|
|
403
|
-
if (
|
|
404
|
-
log.
|
|
903
|
+
const kind = marketInstallKind(type);
|
|
904
|
+
if (!kind) {
|
|
905
|
+
log.warn(`"${entry.name}" is a ${type} package — the CLI can't install this type yet.`);
|
|
906
|
+
log.hint("Install it from the browser marketplace (Marketplace → find it → Install).");
|
|
907
|
+
return;
|
|
908
|
+
}
|
|
909
|
+
let cfg;
|
|
910
|
+
try {
|
|
911
|
+
cfg = await resolveHubConfig(opts);
|
|
912
|
+
}
|
|
913
|
+
catch (e) {
|
|
914
|
+
renderHubError(e);
|
|
915
|
+
process.exit(1);
|
|
916
|
+
}
|
|
917
|
+
// An `automation`/`workflow` install is added TO a workspace, and a `view`
|
|
918
|
+
// package needs a workspace to place its views in — the backend
|
|
919
|
+
// `market.install` verb hard-requires `workspaceId` for both
|
|
920
|
+
// (marketplace-install.ts) and 400s without it. Resolve it from the
|
|
921
|
+
// explicit `--workspace` flag, else the active lens (`synap use` / env),
|
|
922
|
+
// and fail with an actionable message rather than passing null downstream.
|
|
923
|
+
// Every OTHER kind (cell/capability/skill) lands pod-wide — leave
|
|
924
|
+
// workspaceId undefined so that deliberate pod-wide branch is preserved.
|
|
925
|
+
let workspaceId;
|
|
926
|
+
if (marketInstallNeedsWorkspace(kind)) {
|
|
927
|
+
workspaceId = opts.workspace ?? cfg.workspaceId;
|
|
928
|
+
if (!workspaceId) {
|
|
929
|
+
log.error(`Installing "${entry.name}" (a ${type}) needs an acting workspace to add it to.`);
|
|
930
|
+
log.hint("Pass one: synap market install " + slug + " --workspace <workspaceId>");
|
|
931
|
+
log.hint("Or set a default lens: synap use <workspace> (see: synap orient)");
|
|
932
|
+
process.exit(1);
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
const s = opts.json ? null : ora(`Installing ${entry.name}…`).start();
|
|
936
|
+
let res;
|
|
937
|
+
try {
|
|
938
|
+
// Pod-wide install by default: no workspaceId (a capability/cell lands on
|
|
939
|
+
// the pod). An automation/workflow carries the resolved `workspaceId`
|
|
940
|
+
// (required — see above). `market.install` is a WRITE builtin — an
|
|
941
|
+
// operator run owner-bypasses the gate and runs it in-process.
|
|
942
|
+
res = (await hubPost("/capabilities/execute", {
|
|
943
|
+
verbId: "market.install",
|
|
944
|
+
parameters: { slug, kind },
|
|
945
|
+
...(workspaceId ? { workspaceId } : {}),
|
|
946
|
+
}, cfg, applyTimeoutMs));
|
|
947
|
+
}
|
|
948
|
+
catch (e) {
|
|
949
|
+
s?.stop();
|
|
950
|
+
// 404 = this pod predates the seeded `market.install` verb (older deploy).
|
|
951
|
+
if (e instanceof HubError && e.status === 404) {
|
|
952
|
+
log.error(`This pod doesn't expose the market.install verb yet.`);
|
|
953
|
+
log.hint("It needs a newer pod deploy — meanwhile install from the browser marketplace.");
|
|
954
|
+
process.exit(1);
|
|
955
|
+
}
|
|
956
|
+
// The verb throws NOT_FOUND (surfaced as a 500) when the CP kind hasn't
|
|
957
|
+
// synced into `cp_catalog_cache` yet — say so actionably, not opaquely.
|
|
958
|
+
if (e instanceof HubError && /catalog cache/i.test(e.message)) {
|
|
959
|
+
log.error(`"${entry.name}" isn't in the pod's marketplace cache yet.`);
|
|
960
|
+
log.hint("The pod syncs the catalog every ~10 min — retry shortly, or install from the browser marketplace.");
|
|
961
|
+
process.exit(1);
|
|
962
|
+
}
|
|
963
|
+
log.error(`${entry.name} failed`);
|
|
964
|
+
renderHubError(e);
|
|
965
|
+
process.exit(1);
|
|
966
|
+
}
|
|
967
|
+
s?.stop();
|
|
968
|
+
// The execute door wraps the verb's return as { status:"run", result: <MarketInstallOutcome> }.
|
|
969
|
+
//
|
|
970
|
+
// ⚠️ DO NOT default this to "installed". A governed install comes back as a
|
|
971
|
+
// PROPOSAL, and the envelope's discriminator is `kind` — not `status`
|
|
972
|
+
// (`execute-capability.ts`: `{ kind:"proposed", proposalId, reviewUrl,
|
|
973
|
+
// ackState:"proposed" }`). Some hops flatten that to `{ proposed:true, … }`.
|
|
974
|
+
// Either way `outcome.status` is undefined, so `?? "installed"` printed a
|
|
975
|
+
// green "✓ Installed" for a write that had NOT happened — found by
|
|
976
|
+
// dogfooding `synap market install task-views-pack` on a live pod
|
|
977
|
+
// 2026-09-05, which returned `outcome:"installed"` alongside
|
|
978
|
+
// `proposed:true`. This is the same defaulted-discriminator class as the
|
|
979
|
+
// `changeType ?? "update"` bug that once titled every capability run
|
|
980
|
+
// "Update Capability".
|
|
981
|
+
//
|
|
982
|
+
// So: read every honest marker, and when none is present say so rather than
|
|
983
|
+
// assuming success.
|
|
984
|
+
const outcome = (res.result ?? res);
|
|
985
|
+
const isProposed = outcome.kind === "proposed" ||
|
|
986
|
+
outcome.status === "proposed" ||
|
|
987
|
+
outcome.ackState === "proposed" ||
|
|
988
|
+
outcome.proposed === true;
|
|
989
|
+
const status = isProposed
|
|
990
|
+
? "proposed"
|
|
991
|
+
: outcome.status
|
|
992
|
+
? String(outcome.status)
|
|
993
|
+
: outcome.ackState === "applied" || outcome.result !== undefined
|
|
994
|
+
? "installed"
|
|
995
|
+
: "unknown";
|
|
996
|
+
if (opts.json) {
|
|
997
|
+
console.log(JSON.stringify({ slug, kind, outcome: status, result: outcome }, null, 2));
|
|
998
|
+
return;
|
|
999
|
+
}
|
|
1000
|
+
if (status === "proposed") {
|
|
1001
|
+
const proposalId = outcome.proposalId ? String(outcome.proposalId) : undefined;
|
|
1002
|
+
const reviewUrl = outcome.reviewUrl ? String(outcome.reviewUrl) : undefined;
|
|
1003
|
+
log.info(`${entry.name} — proposed (under review, NOT installed yet).`);
|
|
1004
|
+
if (proposalId)
|
|
1005
|
+
log.hint(`Review and approve: synap open proposal ${proposalId}`);
|
|
1006
|
+
if (reviewUrl)
|
|
1007
|
+
log.hint(`Review: ${reviewUrl}`);
|
|
1008
|
+
return;
|
|
1009
|
+
}
|
|
1010
|
+
if (status === "unknown") {
|
|
1011
|
+
// The pod answered with a shape carrying no honest marker at all. Saying
|
|
1012
|
+
// "installed" here is exactly the lie this block exists to prevent.
|
|
1013
|
+
log.warn(`${entry.name} — the pod returned an unrecognised result; nothing is assumed installed. Check \`synap market installed\` before retrying.`);
|
|
1014
|
+
return;
|
|
405
1015
|
}
|
|
406
|
-
|
|
1016
|
+
// A kind:automation (workflow) install returns PER-ITEM results with
|
|
1017
|
+
// per-item isolation — each automation can be {status:"error"} while the
|
|
1018
|
+
// verb overall still reports {status:"installed"} (marketplace-install.ts
|
|
1019
|
+
// automation case). A bare "✓ Installed" here would lie when every item
|
|
1020
|
+
// errored (0 automations created). Inspect the per-item array and report
|
|
1021
|
+
// the real outcome: N created · N reused · N error, surfacing each error's
|
|
1022
|
+
// message, and exit non-zero if NOTHING was created. Other kinds carry no
|
|
1023
|
+
// such array — `summarizeItemInstall` returns null and we fall through to
|
|
1024
|
+
// the unchanged success line.
|
|
1025
|
+
const installed = (outcome.result ?? outcome);
|
|
1026
|
+
const summary = summarizeItemInstall(installed);
|
|
1027
|
+
if (summary) {
|
|
1028
|
+
const { created, reused, errored, okCount } = summary;
|
|
1029
|
+
const counts = `${created} created · ${reused} reused · ${errored.length} error`;
|
|
1030
|
+
if (errored.length > 0) {
|
|
1031
|
+
// Never print a bare success when any item failed.
|
|
1032
|
+
if (okCount === 0) {
|
|
1033
|
+
log.error(`${entry.name} — install failed (${counts}).`);
|
|
1034
|
+
}
|
|
1035
|
+
else {
|
|
1036
|
+
log.warn(`${entry.name} — installed partially (${counts}).`);
|
|
1037
|
+
}
|
|
1038
|
+
for (const e of errored) {
|
|
1039
|
+
log.error(`${e.name ?? "(unnamed)"} — ${e.message ?? "unknown error"}`);
|
|
1040
|
+
}
|
|
1041
|
+
// Total failure (nothing created or reused) exits non-zero so scripts
|
|
1042
|
+
// and users see the real outcome; a partial install stays exit 0.
|
|
1043
|
+
if (okCount === 0)
|
|
1044
|
+
process.exit(1);
|
|
1045
|
+
return;
|
|
1046
|
+
}
|
|
1047
|
+
log.success(`Installed ${entry.name} (${counts}).`);
|
|
1048
|
+
return;
|
|
1049
|
+
}
|
|
1050
|
+
log.success(`Installed ${entry.name}.`);
|
|
407
1051
|
return;
|
|
408
1052
|
}
|
|
409
1053
|
// Tier gate: don't silently attempt an ungrantable install.
|
|
@@ -434,25 +1078,40 @@ export async function marketInstall(slug, opts) {
|
|
|
434
1078
|
// Build the install payload — bundled (offline, byte-identical to the CP
|
|
435
1079
|
// definition) when available, else the authed CP definition.
|
|
436
1080
|
const cpToken = getStoredToken()?.token;
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
1081
|
+
// The definition + an HONEST version label (see `resolveInstallPayload`):
|
|
1082
|
+
// the catalog version only rides on the catalog's own definition.
|
|
1083
|
+
const payload = await resolveInstallPayload(slug, cat, cpToken);
|
|
1084
|
+
if (!payload) {
|
|
1085
|
+
log.error(`Couldn't fetch "${entry.name}"'s definition from the control plane.`);
|
|
1086
|
+
log.hint(cat.loggedIn ? "" : "It may be private — try: synap login");
|
|
1087
|
+
process.exit(1);
|
|
440
1088
|
}
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
1089
|
+
const pkg = payload.pkg;
|
|
1090
|
+
// Only a catalog-sourced install can predict the stamp the pod will record.
|
|
1091
|
+
const remoteVersion = payload.catalogVersion;
|
|
1092
|
+
// ── --dry-run: write-free preflight of the CREATE path. ───────────────────
|
|
1093
|
+
// Runs the pod's REAL resolver (write-free) so a template author sees
|
|
1094
|
+
// profileKind conflicts and other resolution failures BEFORE installing.
|
|
1095
|
+
// (Previews the create path only; `--onto` reconcile-preview is not modeled.)
|
|
1096
|
+
if (opts.dryRun) {
|
|
1097
|
+
const s = opts.json ? null : ora(`Checking ${entry.name}…`).start();
|
|
1098
|
+
try {
|
|
1099
|
+
const report = (await hubPost("/packages/preflight", pkg, cfg, applyTimeoutMs));
|
|
1100
|
+
s?.stop();
|
|
1101
|
+
if (opts.json) {
|
|
1102
|
+
console.log(JSON.stringify({ slug, ...report }, null, 2));
|
|
1103
|
+
return;
|
|
1104
|
+
}
|
|
1105
|
+
renderPreflightReport(entry, report);
|
|
1106
|
+
return;
|
|
1107
|
+
}
|
|
1108
|
+
catch (e) {
|
|
1109
|
+
s?.stop();
|
|
1110
|
+
log.error(`${entry.name} preflight failed`);
|
|
1111
|
+
renderHubError(e);
|
|
446
1112
|
process.exit(1);
|
|
447
1113
|
}
|
|
448
|
-
pkg = def;
|
|
449
1114
|
}
|
|
450
|
-
// Stamp the version the catalog resolved for this slug — including the
|
|
451
|
-
// authed `/mine` version for private templates — so the pod records the
|
|
452
|
-
// installed baseline and later `market update` drift checks work.
|
|
453
|
-
const remoteVersion = cat.remoteVersionBySlug.get(slug);
|
|
454
|
-
if (remoteVersion)
|
|
455
|
-
pkg._meta = { ...(pkg._meta ?? {}), version: remoteVersion };
|
|
456
1115
|
// Pre-apply state — was this slug already installed with NO version stamp?
|
|
457
1116
|
// Feeds `classifyApplyResult`'s stamp-contradiction check below.
|
|
458
1117
|
const preInstalled = installedTemplates.find((t) => t.slug === slug);
|
|
@@ -474,18 +1133,27 @@ export async function marketInstall(slug, opts) {
|
|
|
474
1133
|
}
|
|
475
1134
|
log.info(`${entry.name} — proposed (under review, not live yet).`);
|
|
476
1135
|
if (proposalId)
|
|
477
|
-
log.hint(`
|
|
1136
|
+
log.hint(`Review and approve: synap open proposal ${proposalId}`);
|
|
478
1137
|
renderNextSteps(steps);
|
|
479
1138
|
return;
|
|
480
1139
|
}
|
|
481
1140
|
const ws = res.workspace;
|
|
482
1141
|
const dependencies = res.dependencies;
|
|
483
1142
|
const seedSummary = res.seedSummary;
|
|
1143
|
+
// GROUND TRUTH: re-read the workspace's actual stored stamp instead of
|
|
1144
|
+
// guessing "did it stamp?" from the apply-response `outcome` (which reads
|
|
1145
|
+
// "unchanged" on a content-identical reconcile even when the stamp DID
|
|
1146
|
+
// land). Only worth a call when we sent a version and know the workspace.
|
|
1147
|
+
let stampVerified;
|
|
1148
|
+
if (remoteVersion && ws?.workspaceId) {
|
|
1149
|
+
stampVerified = await verifyStampLanded(ws.workspaceId, remoteVersion, cfg);
|
|
1150
|
+
}
|
|
484
1151
|
const steps = FLOW.afterMarketInstall({ slug, proposed: false, projectName });
|
|
485
1152
|
s?.stop();
|
|
486
|
-
const verdict =
|
|
1153
|
+
const verdict = classifyApplyResponse(entry, res, {
|
|
487
1154
|
remoteVersionSent: remoteVersion,
|
|
488
1155
|
wasUnstamped,
|
|
1156
|
+
stampVerified,
|
|
489
1157
|
});
|
|
490
1158
|
if (opts.json) {
|
|
491
1159
|
console.log(JSON.stringify({
|
|
@@ -514,7 +1182,7 @@ export async function marketInstall(slug, opts) {
|
|
|
514
1182
|
break;
|
|
515
1183
|
case "installed-with-failures":
|
|
516
1184
|
case "updated-with-failures":
|
|
517
|
-
log.warn(`Reconciled ${entry.name} onto ${wsLabel}
|
|
1185
|
+
log.warn(`Reconciled ${entry.name} onto ${wsLabel}, but ${templateStepPhrase("unfinished")}: some capabilities failed (see below).`);
|
|
518
1186
|
break;
|
|
519
1187
|
case "unchanged":
|
|
520
1188
|
log.info(`• ${entry.name} is already up to date on ${wsLabel}.`);
|
|
@@ -527,6 +1195,9 @@ export async function marketInstall(slug, opts) {
|
|
|
527
1195
|
printApplyVerdict(entry, verdict);
|
|
528
1196
|
}
|
|
529
1197
|
printSeedOutcomes(dependencies, seedSummary);
|
|
1198
|
+
printLayerOutcomes(ws?.layers);
|
|
1199
|
+
for (const line of deferredPlacementLines(res))
|
|
1200
|
+
log.info(line);
|
|
530
1201
|
if (ws?.outcome || ws?.status === "composed" || ws?.status === "created")
|
|
531
1202
|
renderNextSteps(steps);
|
|
532
1203
|
}
|
|
@@ -538,18 +1209,103 @@ export async function marketInstall(slug, opts) {
|
|
|
538
1209
|
}
|
|
539
1210
|
}
|
|
540
1211
|
/**
|
|
541
|
-
*
|
|
542
|
-
* (
|
|
543
|
-
*
|
|
544
|
-
*
|
|
545
|
-
*
|
|
546
|
-
*
|
|
1212
|
+
* Pick the install definition through the SAME winner rule the catalog uses
|
|
1213
|
+
* (`mergeCatalog`: an official CP row wins over this CLI's frozen bundle only
|
|
1214
|
+
* when its `definition.sourcePackage.version` is strictly newer), and label it
|
|
1215
|
+
* honestly: a catalog definition carries the catalog version; a bundled one
|
|
1216
|
+
* carries the bundle's own package version (`bundle@x.y.z`), never the
|
|
1217
|
+
* catalog's. The pod re-derives the stamp from what it applied either way.
|
|
1218
|
+
*
|
|
1219
|
+
* Returns `null` only for a non-bundled slug whose definition could not be
|
|
1220
|
+
* fetched (the caller reports it). A failed fetch for a BUNDLED slug falls back
|
|
1221
|
+
* to the bundle — the documented offline path — labelled as the bundle.
|
|
1222
|
+
*/
|
|
1223
|
+
export async function resolveInstallPayload(slug, cat, cpToken) {
|
|
1224
|
+
const catalogVersion = cat.remoteVersionBySlug.get(slug);
|
|
1225
|
+
if (!cat.bundledSlugs.has(slug)) {
|
|
1226
|
+
const def = await fetchPackageDefinition(slug, cpToken);
|
|
1227
|
+
if (!def)
|
|
1228
|
+
return null;
|
|
1229
|
+
return { pkg: stampVersion(def, slug, catalogVersion), from: "catalog", catalogVersion };
|
|
1230
|
+
}
|
|
1231
|
+
if (catalogVersion && cat.reachedCp) {
|
|
1232
|
+
const def = await fetchPackageDefinition(slug, cpToken).catch(() => null);
|
|
1233
|
+
const tpl = getWorkspaceTemplate(slug);
|
|
1234
|
+
if (def && tpl) {
|
|
1235
|
+
const winner = mergeCatalog({
|
|
1236
|
+
bundled: [tpl],
|
|
1237
|
+
remote: [{ slug, name: slug, definition: def }],
|
|
1238
|
+
bundleVersion: bundledTemplatesVersion(),
|
|
1239
|
+
}).entries.find((e) => e.slug === slug);
|
|
1240
|
+
if (winner?.source === "remote") {
|
|
1241
|
+
return { pkg: stampVersion(def, slug, catalogVersion), from: "catalog", catalogVersion };
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
const bundleVersion = bundledTemplatesVersion();
|
|
1246
|
+
const pkg = toPackageDefinition(slug);
|
|
1247
|
+
return { pkg: stampVersion(pkg, slug, bundleVersion ? `bundle@${bundleVersion}` : undefined), from: "bundle" };
|
|
1248
|
+
}
|
|
1249
|
+
/**
|
|
1250
|
+
* Label the payload with the slug it was resolved for and the version it came
|
|
1251
|
+
* from. The SLUG is load-bearing: the pod keys "which existing workspace is
|
|
1252
|
+
* this?" on `body._meta.slug`. The CP now stamps `_meta.slug` on every stored
|
|
1253
|
+
* and served definition (`synap-control-plane-api` `seeds/template-identity.ts`),
|
|
1254
|
+
* and the bundle's `toPackageDefinition` carries it too; setting it here keeps
|
|
1255
|
+
* it true against a CP that predates the stamp. Without it a governed apply's
|
|
1256
|
+
* approval matched nothing and CREATED a second "Content OS" (2026-10-06)
|
|
1257
|
+
* instead of updating.
|
|
1258
|
+
*/
|
|
1259
|
+
function stampVersion(pkg, slug, version) {
|
|
1260
|
+
const meta = pkg._meta ?? {};
|
|
1261
|
+
return { ...pkg, _meta: { ...meta, slug, ...(version ? { version } : {}) } };
|
|
1262
|
+
}
|
|
1263
|
+
export function updateDoorFor(kind) {
|
|
1264
|
+
switch (kind ?? "workspace") {
|
|
1265
|
+
case "workspace":
|
|
1266
|
+
return "packages-apply";
|
|
1267
|
+
case "view":
|
|
1268
|
+
case "skill":
|
|
1269
|
+
case "automation":
|
|
1270
|
+
return "configs-reconcile";
|
|
1271
|
+
default:
|
|
1272
|
+
return null;
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
1275
|
+
/**
|
|
1276
|
+
* Per-installed-slug drift check. SERVER-FIRST: when the pod runs the
|
|
1277
|
+
* server-side TemplateHealth code (Hub `/workspaces` returns `drifted`/
|
|
1278
|
+
* `latestVersion`), that verdict wins — the CLI stops re-deriving drift, so it
|
|
1279
|
+
* can't disagree with the pod (or the browser, which reads the same authority).
|
|
1280
|
+
*
|
|
1281
|
+
* FALLBACK (older pod, `drifted === undefined`): the legacy client derivation —
|
|
1282
|
+
* the shared `computeTemplateUpdates` (handles bundled-only / private-only slugs
|
|
1283
|
+
* via `bundleVersion`), then override `latestVersion`/`updateAvailable` with the
|
|
1284
|
+
* CP row's OWN version for any slug in `remoteVersionBySlug` (the merge-winner
|
|
1285
|
+
* bypass). A slug the pod never version-stamped is `noVersionInfo: true`, never
|
|
1286
|
+
* silently "outdated".
|
|
547
1287
|
*/
|
|
548
1288
|
export function computeUpdates(installedTemplates, cat) {
|
|
549
|
-
|
|
550
|
-
|
|
1289
|
+
// Only the fallback path needs the client-derived base — compute it for the
|
|
1290
|
+
// slugs the server didn't answer for, so a fully-modern pod does no extra work.
|
|
1291
|
+
const needsFallback = installedTemplates.filter((t) => t.drifted === undefined && !!t.version);
|
|
1292
|
+
const base = needsFallback.length
|
|
1293
|
+
? computeTemplateUpdates(needsFallback.map((t) => ({ slug: t.slug, version: t.version })), cat.entries, bundledTemplatesVersion())
|
|
1294
|
+
: [];
|
|
551
1295
|
const baseBySlug = new Map(base.map((b) => [b.slug, b]));
|
|
552
1296
|
return installedTemplates.map((t) => {
|
|
1297
|
+
// ── Server-side TemplateHealth wins ──
|
|
1298
|
+
if (t.drifted !== undefined) {
|
|
1299
|
+
return {
|
|
1300
|
+
slug: t.slug,
|
|
1301
|
+
installedVersion: t.version ?? "",
|
|
1302
|
+
latestVersion: t.latestVersion ?? undefined,
|
|
1303
|
+
updateAvailable: t.drifted,
|
|
1304
|
+
noVersionInfo: !t.version,
|
|
1305
|
+
kind: t.kind,
|
|
1306
|
+
};
|
|
1307
|
+
}
|
|
1308
|
+
// ── Client fallback (older pod) ──
|
|
553
1309
|
if (!t.version) {
|
|
554
1310
|
return {
|
|
555
1311
|
slug: t.slug,
|
|
@@ -557,6 +1313,7 @@ export function computeUpdates(installedTemplates, cat) {
|
|
|
557
1313
|
latestVersion: cat.remoteVersionBySlug.get(t.slug),
|
|
558
1314
|
updateAvailable: false,
|
|
559
1315
|
noVersionInfo: true,
|
|
1316
|
+
kind: t.kind,
|
|
560
1317
|
};
|
|
561
1318
|
}
|
|
562
1319
|
const b = baseBySlug.get(t.slug);
|
|
@@ -567,18 +1324,24 @@ export function computeUpdates(installedTemplates, cat) {
|
|
|
567
1324
|
latestVersion: remoteVersion,
|
|
568
1325
|
updateAvailable: remoteVersion !== t.version,
|
|
569
1326
|
noVersionInfo: false,
|
|
1327
|
+
kind: t.kind,
|
|
570
1328
|
};
|
|
571
1329
|
}
|
|
572
|
-
return { ...b, noVersionInfo: false };
|
|
1330
|
+
return { ...b, noVersionInfo: false, kind: t.kind };
|
|
573
1331
|
});
|
|
574
1332
|
}
|
|
1333
|
+
/** First check per slug, order preserved — an update is applied per PACKAGE, not per installed object. */
|
|
1334
|
+
export function dedupeBySlug(checks) {
|
|
1335
|
+
const seen = new Set();
|
|
1336
|
+
return checks.filter((c) => (seen.has(c.slug) ? false : (seen.add(c.slug), true)));
|
|
1337
|
+
}
|
|
575
1338
|
/**
|
|
576
1339
|
* Apply one package via the SAME door `marketInstall` uses
|
|
577
1340
|
* (`POST /packages/apply`) — for an already-installed slug this re-runs the
|
|
578
1341
|
* pod's idempotent-create path, which reconciles when the pod build supports
|
|
579
1342
|
* it (see the `applyOnePackage` caveat below for what "created" means here).
|
|
580
1343
|
*/
|
|
581
|
-
async function applyOnePackage(entry, cat, cfg, opts, applyTimeoutMs = APPLY_TIMEOUT_MS) {
|
|
1344
|
+
async function applyOnePackage(entry, cat, cfg, opts, applyTimeoutMs = APPLY_TIMEOUT_MS, extra = {}) {
|
|
582
1345
|
if (cat.lockedSlugs.has(entry.slug)) {
|
|
583
1346
|
const tier = cat.tierBySlug.get(entry.slug)?.requiredTier;
|
|
584
1347
|
if (!opts.json)
|
|
@@ -586,27 +1349,19 @@ async function applyOnePackage(entry, cat, cfg, opts, applyTimeoutMs = APPLY_TIM
|
|
|
586
1349
|
return { slug: entry.slug, outcome: "locked", status: "locked", warnings: [] };
|
|
587
1350
|
}
|
|
588
1351
|
const cpToken = getStoredToken()?.token;
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
if (!opts.json) {
|
|
597
|
-
log.error(`Couldn't fetch "${entry.name}"'s definition from the control plane.`);
|
|
598
|
-
if (!cat.loggedIn)
|
|
599
|
-
log.hint("It may be private — try: synap login");
|
|
600
|
-
}
|
|
601
|
-
return { slug: entry.slug, outcome: "fetch-failed", status: "fetch-failed", warnings: [] };
|
|
1352
|
+
// Same honest payload as `marketInstall` (see `resolveInstallPayload`).
|
|
1353
|
+
const payload = extra.payload !== undefined ? extra.payload : await resolveInstallPayload(entry.slug, cat, cpToken);
|
|
1354
|
+
if (!payload) {
|
|
1355
|
+
if (!opts.json) {
|
|
1356
|
+
log.error(`Couldn't fetch "${entry.name}"'s definition from the control plane.`);
|
|
1357
|
+
if (!cat.loggedIn)
|
|
1358
|
+
log.hint("It may be private — try: synap login");
|
|
602
1359
|
}
|
|
603
|
-
|
|
1360
|
+
return { slug: entry.slug, outcome: "fetch-failed", status: "fetch-failed", warnings: [] };
|
|
604
1361
|
}
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
if (remoteVersion)
|
|
609
|
-
pkg._meta = { ...(pkg._meta ?? {}), version: remoteVersion };
|
|
1362
|
+
const pkg = extra.acceptDataChanges && extra.acceptDataChanges.length > 0
|
|
1363
|
+
? { ...payload.pkg, acceptDataChanges: extra.acceptDataChanges }
|
|
1364
|
+
: payload.pkg;
|
|
610
1365
|
const s = opts.json ? null : ora(`Updating ${entry.name}…`).start();
|
|
611
1366
|
try {
|
|
612
1367
|
const res = (await hubPost("/packages/apply", pkg, cfg, applyTimeoutMs));
|
|
@@ -616,7 +1371,7 @@ async function applyOnePackage(entry, cat, cfg, opts, applyTimeoutMs = APPLY_TIM
|
|
|
616
1371
|
if (!opts.json) {
|
|
617
1372
|
log.info(`${entry.name} — update proposed (under review, not live yet).`);
|
|
618
1373
|
if (proposalId)
|
|
619
|
-
log.hint(`
|
|
1374
|
+
log.hint(`Review and approve: synap open proposal ${proposalId}`);
|
|
620
1375
|
}
|
|
621
1376
|
return { slug: entry.slug, outcome: "proposed", status: "proposed", warnings: [] };
|
|
622
1377
|
}
|
|
@@ -628,13 +1383,16 @@ async function applyOnePackage(entry, cat, cfg, opts, applyTimeoutMs = APPLY_TIM
|
|
|
628
1383
|
// which by construction means it already carried a recorded version. The
|
|
629
1384
|
// stamp-contradiction check is therefore a no-op here by design; it's
|
|
630
1385
|
// `marketInstall`'s job to catch it on a first-time/unstamped install.
|
|
631
|
-
const verdict =
|
|
632
|
-
remoteVersionSent:
|
|
1386
|
+
const verdict = classifyApplyResponse(entry, res, {
|
|
1387
|
+
remoteVersionSent: payload.catalogVersion,
|
|
633
1388
|
wasUnstamped: false,
|
|
634
1389
|
});
|
|
635
1390
|
if (!opts.json) {
|
|
636
1391
|
printApplyVerdict(entry, verdict);
|
|
637
1392
|
printSeedOutcomes(dependencies, seedSummary);
|
|
1393
|
+
printLayerOutcomes(ws?.layers);
|
|
1394
|
+
for (const line of deferredPlacementLines(res))
|
|
1395
|
+
log.info(line);
|
|
638
1396
|
}
|
|
639
1397
|
return {
|
|
640
1398
|
slug: entry.slug,
|
|
@@ -647,10 +1405,33 @@ async function applyOnePackage(entry, cat, cfg, opts, applyTimeoutMs = APPLY_TIM
|
|
|
647
1405
|
}
|
|
648
1406
|
catch (e) {
|
|
649
1407
|
s?.stop();
|
|
1408
|
+
// The pod refused: data changes are owed that the person has not agreed to.
|
|
1409
|
+
// Nothing was written. Show the list and the exact command that agrees.
|
|
1410
|
+
const owed = dataChangesFromRefusal(e);
|
|
1411
|
+
if (owed) {
|
|
1412
|
+
if (!opts.json) {
|
|
1413
|
+
const said = consentRefusalLines(entry.name, entry.slug, owed, consentNeedsPerson(e));
|
|
1414
|
+
log.warn(said.warn);
|
|
1415
|
+
printDataChanges(entry.slug, owed);
|
|
1416
|
+
for (const hint of said.hints)
|
|
1417
|
+
log.hint(hint);
|
|
1418
|
+
}
|
|
1419
|
+
return {
|
|
1420
|
+
slug: entry.slug,
|
|
1421
|
+
outcome: "needs-consent",
|
|
1422
|
+
status: "needs-consent",
|
|
1423
|
+
warnings: [],
|
|
1424
|
+
pendingDataChanges: owed,
|
|
1425
|
+
};
|
|
1426
|
+
}
|
|
650
1427
|
const timedOut = e instanceof HubNetworkError && e.code === "TIMEOUT";
|
|
1428
|
+
const refusal = dataChangeRefusalMessage(e);
|
|
651
1429
|
if (!opts.json) {
|
|
652
1430
|
log.error(`${entry.name} update failed`);
|
|
653
|
-
|
|
1431
|
+
if (refusal)
|
|
1432
|
+
log.warn(refusal);
|
|
1433
|
+
else
|
|
1434
|
+
renderHubError(e);
|
|
654
1435
|
}
|
|
655
1436
|
return {
|
|
656
1437
|
slug: entry.slug,
|
|
@@ -661,19 +1442,120 @@ async function applyOnePackage(entry, cat, cfg, opts, applyTimeoutMs = APPLY_TIM
|
|
|
661
1442
|
};
|
|
662
1443
|
}
|
|
663
1444
|
}
|
|
1445
|
+
/**
|
|
1446
|
+
* Ask the pod's write-free preview door (`POST /packages/preflight`, the same
|
|
1447
|
+
* body `/packages/apply` takes) which data changes an update would run.
|
|
1448
|
+
*/
|
|
1449
|
+
async function previewDataChanges(entry, cat, cfg) {
|
|
1450
|
+
const payload = await resolveInstallPayload(entry.slug, cat, getStoredToken()?.token).catch(() => null);
|
|
1451
|
+
if (!payload)
|
|
1452
|
+
return { payload: null, error: "its definition couldn't be fetched" };
|
|
1453
|
+
try {
|
|
1454
|
+
const res = await hubPost("/packages/preflight", payload.pkg, cfg);
|
|
1455
|
+
return { payload, changes: parseDataChanges(res) };
|
|
1456
|
+
}
|
|
1457
|
+
catch (e) {
|
|
1458
|
+
const status = e instanceof HubError ? `HTTP ${e.status}` : e instanceof Error ? e.message : String(e);
|
|
1459
|
+
return { payload, error: `the pod's preview failed: ${status}` };
|
|
1460
|
+
}
|
|
1461
|
+
}
|
|
1462
|
+
/** One slug's outcome out of the shared pod-wide config reconcile. */
|
|
1463
|
+
async function reconcileOutcome(c, report, opts) {
|
|
1464
|
+
let r;
|
|
1465
|
+
try {
|
|
1466
|
+
r = await report;
|
|
1467
|
+
}
|
|
1468
|
+
catch (e) {
|
|
1469
|
+
if (!opts.json) {
|
|
1470
|
+
log.error(`${c.slug} update failed`);
|
|
1471
|
+
renderHubError(e);
|
|
1472
|
+
}
|
|
1473
|
+
return { slug: c.slug, outcome: "failed", status: "failed", warnings: [], error: e instanceof Error ? e.message : String(e) };
|
|
1474
|
+
}
|
|
1475
|
+
// A report this CLI cannot read is a FAILED read — never "up to date".
|
|
1476
|
+
const lists = [r?.updated, r?.ownerOwnedSkipped, r?.skipped, r?.conflicts];
|
|
1477
|
+
if (!lists.some(Array.isArray) || lists.some((l) => l !== undefined && !Array.isArray(l))) {
|
|
1478
|
+
const error = "the pod's reconcile answered with a report this CLI can't read — not assuming it updated";
|
|
1479
|
+
if (!opts.json)
|
|
1480
|
+
log.error(`${c.slug} — ${error}`);
|
|
1481
|
+
return { slug: c.slug, outcome: "failed", status: "failed", warnings: [], error };
|
|
1482
|
+
}
|
|
1483
|
+
const mine = (rows) => (rows ?? []).filter((e) => e.packageSlug === c.slug);
|
|
1484
|
+
const conflicts = mine(r.conflicts);
|
|
1485
|
+
const owned = mine(r.ownerOwnedSkipped);
|
|
1486
|
+
const updated = mine(r.updated);
|
|
1487
|
+
const warnings = owned.map((e) => `${e.name}: kept your edits — ${e.reason}`);
|
|
1488
|
+
if (conflicts.length > 0) {
|
|
1489
|
+
const error = conflicts.map((e) => `${e.name}: ${e.reason}`).join("; ");
|
|
1490
|
+
if (!opts.json)
|
|
1491
|
+
log.error(`${c.slug} update failed — ${error}`);
|
|
1492
|
+
return { slug: c.slug, outcome: "failed", status: updated.length > 0 ? "updated-with-failures" : "failed", warnings, error };
|
|
1493
|
+
}
|
|
1494
|
+
if (!opts.json)
|
|
1495
|
+
for (const w of warnings)
|
|
1496
|
+
log.warn(`${c.slug} — ${w}`);
|
|
1497
|
+
if (updated.length > 0 || owned.length > 0) {
|
|
1498
|
+
// Every field it could advance did; a field you edited stays yours (warned above).
|
|
1499
|
+
const status = updated.length > 0 ? "updated" : "unchanged";
|
|
1500
|
+
if (!opts.json)
|
|
1501
|
+
log.success(`${c.slug} — ${status}`);
|
|
1502
|
+
return { slug: c.slug, outcome: status, status, warnings };
|
|
1503
|
+
}
|
|
1504
|
+
// Skipped (cache miss, unresolved definition…) or not reported at all: NOT updated — never "current".
|
|
1505
|
+
const error = mine(r.skipped).map((e) => `${e.name}: ${e.reason}`).join("; ") || "the pod's reconcile did not report this package";
|
|
1506
|
+
if (!opts.json)
|
|
1507
|
+
log.warn(`${c.slug} was not updated — ${error}`);
|
|
1508
|
+
return { slug: c.slug, outcome: "not-updated", status: "not-updated", warnings, error };
|
|
1509
|
+
}
|
|
664
1510
|
export async function marketUpdate(slugsArg, opts) {
|
|
665
1511
|
const applyTimeoutMs = parseApplyTimeoutMs(opts.timeout);
|
|
666
|
-
|
|
1512
|
+
// STRICT fetch: distinguish "pod unreachable" (transient — don't claim the
|
|
1513
|
+
// pod is empty, that reads as data loss) from "genuinely nothing installed".
|
|
1514
|
+
const cat = await buildMarketCatalog();
|
|
1515
|
+
let installedTemplates;
|
|
1516
|
+
// Whether the pod answered from the ONE read door (`GET /installed`, all six
|
|
1517
|
+
// kinds) or is old enough that only workspace packages were visible. An old
|
|
1518
|
+
// pod is not an empty pod — say which, rather than letting a narrower list
|
|
1519
|
+
// read as the whole truth.
|
|
1520
|
+
let degraded = false;
|
|
1521
|
+
try {
|
|
1522
|
+
const inv = await fetchInstalledInventoryStrict();
|
|
1523
|
+
installedTemplates = installedRowsToTemplates(inv.rows);
|
|
1524
|
+
degraded = inv.degraded;
|
|
1525
|
+
}
|
|
1526
|
+
catch (e) {
|
|
1527
|
+
// Unreachable ≠ success — a script checking `$?` (or `&&`-chaining) must see
|
|
1528
|
+
// failure, not exit 0, even though the JSON payload also carries the reason.
|
|
1529
|
+
process.exitCode = 1;
|
|
1530
|
+
if (opts.json) {
|
|
1531
|
+
console.log(JSON.stringify({ checks: [], loggedIn: cat.loggedIn, error: "pod-unreachable" }, null, 2));
|
|
1532
|
+
return;
|
|
1533
|
+
}
|
|
1534
|
+
log.error("Couldn't reach your pod to read what's installed — not saying it's empty.");
|
|
1535
|
+
renderHubError(e);
|
|
1536
|
+
log.hint("Check the pod is up / you're on the right --pod, then retry.");
|
|
1537
|
+
return;
|
|
1538
|
+
}
|
|
1539
|
+
const degradedNote = degraded
|
|
1540
|
+
? "This pod does not serve GET /api/hub/installed — only workspace packages were checked. Capability / view / skill / automation / card installs were NOT (upgrade the pod to include them)."
|
|
1541
|
+
: undefined;
|
|
667
1542
|
if (installedTemplates.length === 0) {
|
|
668
1543
|
if (opts.json) {
|
|
669
|
-
console.log(JSON.stringify({ checks: [], loggedIn: cat.loggedIn }, null, 2));
|
|
1544
|
+
console.log(JSON.stringify({ checks: [], loggedIn: cat.loggedIn, degraded, ...(degradedNote ? { degradedNote } : {}) }, null, 2));
|
|
670
1545
|
return;
|
|
671
1546
|
}
|
|
672
1547
|
log.warn("No installed packages found on this pod.");
|
|
1548
|
+
if (degradedNote)
|
|
1549
|
+
log.dim(degradedNote);
|
|
673
1550
|
log.hint("Install one: synap market install <slug>");
|
|
674
1551
|
return;
|
|
675
1552
|
}
|
|
676
|
-
|
|
1553
|
+
// ONE ROW PER SLUG. `GET /installed` reports one row per installed OBJECT —
|
|
1554
|
+
// a views pack that installed four views is four rows — and an update is
|
|
1555
|
+
// applied per PACKAGE, not per object. Without this the preview listed
|
|
1556
|
+
// `task-views-pack` four times and the "N up to date" tally counted it four
|
|
1557
|
+
// times. `market installed` has always deduped the same way.
|
|
1558
|
+
let checks = dedupeBySlug(computeUpdates(installedTemplates, cat));
|
|
677
1559
|
const entryBySlug = new Map(cat.entries.map((e) => [e.slug, e]));
|
|
678
1560
|
const explicitSlugs = !!slugsArg && slugsArg.length > 0;
|
|
679
1561
|
let unknownSlugs = [];
|
|
@@ -695,7 +1577,26 @@ export async function marketUpdate(slugsArg, opts) {
|
|
|
695
1577
|
}
|
|
696
1578
|
checks = checks.filter((c) => validSlugs.includes(c.slug));
|
|
697
1579
|
}
|
|
698
|
-
|
|
1580
|
+
// ── Template lifecycle (retired / unlisted / unknown) ──
|
|
1581
|
+
// Read from the pod's template health (Hub `/workspaces`). A FAILED read is
|
|
1582
|
+
// said out loud and never folded into "every template is current".
|
|
1583
|
+
const lifecycleBySlug = new Map();
|
|
1584
|
+
let lifecycleReadFailed = false;
|
|
1585
|
+
try {
|
|
1586
|
+
for (const a of await fetchWorkspaceAttachmentsStrict(await resolveHubConfig(opts))) {
|
|
1587
|
+
if (a.packageSlug && a.lifecycle && !lifecycleBySlug.has(a.packageSlug))
|
|
1588
|
+
lifecycleBySlug.set(a.packageSlug, a.lifecycle);
|
|
1589
|
+
}
|
|
1590
|
+
}
|
|
1591
|
+
catch {
|
|
1592
|
+
lifecycleReadFailed = true;
|
|
1593
|
+
}
|
|
1594
|
+
const leaving = checks
|
|
1595
|
+
.map((c) => ({ slug: c.slug, info: lifecycleBySlug.get(c.slug) }))
|
|
1596
|
+
.filter((r) => !!r.info && r.info.lifecycle !== "current");
|
|
1597
|
+
const retiredSlugs = new Set(leaving.filter((r) => r.info.lifecycle === "retired").map((r) => r.slug));
|
|
1598
|
+
// A retired template is never re-applied: its data moves to the replacement instead.
|
|
1599
|
+
const withUpdates = checks.filter((c) => c.updateAvailable && !retiredSlugs.has(c.slug));
|
|
699
1600
|
const noVersionInfo = checks.filter((c) => c.noVersionInfo);
|
|
700
1601
|
// Private templates resolve only via the authed CP path — an update check
|
|
701
1602
|
// that found NOTHING while logged out is ambiguous (could be genuinely
|
|
@@ -705,24 +1606,86 @@ export async function marketUpdate(slugsArg, opts) {
|
|
|
705
1606
|
log.dim("Not logged in — private template updates aren't visible. Run: synap login");
|
|
706
1607
|
}
|
|
707
1608
|
// --dry-run is a NAMED alias for the "no --yes" preview path — forces the
|
|
708
|
-
// preview even when --yes or explicit slugs would otherwise trigger apply.
|
|
709
|
-
const shouldApply = !opts.dryRun && (explicitSlugs || opts.yes);
|
|
1609
|
+
// preview even when --yes / `all` or explicit slugs would otherwise trigger apply.
|
|
1610
|
+
const shouldApply = !opts.dryRun && (explicitSlugs || opts.yes || opts.all);
|
|
1611
|
+
// ── Data changes each update would run (contract C) ──
|
|
1612
|
+
// Asked of the pod's write-free preview door BEFORE anything is shown, so the
|
|
1613
|
+
// person reads what will happen to their data before the template diff.
|
|
1614
|
+
let cfg;
|
|
1615
|
+
const previews = new Map();
|
|
1616
|
+
if (withUpdates.length > 0) {
|
|
1617
|
+
try {
|
|
1618
|
+
cfg = await resolveHubConfig(opts);
|
|
1619
|
+
}
|
|
1620
|
+
catch (e) {
|
|
1621
|
+
renderHubError(e);
|
|
1622
|
+
process.exit(1);
|
|
1623
|
+
}
|
|
1624
|
+
for (const c of withUpdates) {
|
|
1625
|
+
// The data-change preview is the workspace door's (`/packages/preflight`).
|
|
1626
|
+
if (updateDoorFor(c.kind) !== "packages-apply")
|
|
1627
|
+
continue;
|
|
1628
|
+
const entry = entryBySlug.get(c.slug);
|
|
1629
|
+
if (entry)
|
|
1630
|
+
previews.set(c.slug, await previewDataChanges(entry, cat, cfg));
|
|
1631
|
+
}
|
|
1632
|
+
}
|
|
710
1633
|
if (!opts.json) {
|
|
1634
|
+
const withChanges = withUpdates.filter((c) => (previews.get(c.slug)?.changes?.length ?? 0) > 0);
|
|
1635
|
+
if (withChanges.length > 0) {
|
|
1636
|
+
log.heading(" Data changes");
|
|
1637
|
+
log.blank();
|
|
1638
|
+
for (const c of withChanges)
|
|
1639
|
+
printDataChanges(c.slug, previews.get(c.slug).changes);
|
|
1640
|
+
log.blank();
|
|
1641
|
+
}
|
|
1642
|
+
const unread = withUpdates.filter((c) => previews.get(c.slug)?.error);
|
|
1643
|
+
for (const c of unread) {
|
|
1644
|
+
log.warn(`Couldn't check ${c.slug} for data changes (${previews.get(c.slug).error}). The pod still refuses an update whose data changes you have not agreed to.`);
|
|
1645
|
+
}
|
|
1646
|
+
if (leaving.length > 0) {
|
|
1647
|
+
log.heading(" Catalog status");
|
|
1648
|
+
log.blank();
|
|
1649
|
+
for (const r of leaving) {
|
|
1650
|
+
const n = lifecycleNotice(r.slug, r.info);
|
|
1651
|
+
const tone = r.info.lifecycle === "unknown" ? chalk.dim : chalk.yellow;
|
|
1652
|
+
console.log(" " + tone(`⚠ ${n.label}`));
|
|
1653
|
+
// The pod's warning already carries the reason; print it only on the fallback wording.
|
|
1654
|
+
if (r.info.retiredReason && !r.info.warning)
|
|
1655
|
+
console.log(" " + chalk.dim(r.info.retiredReason));
|
|
1656
|
+
if (n.action)
|
|
1657
|
+
console.log(" " + chalk.dim(`Move its data: ${n.action}`));
|
|
1658
|
+
}
|
|
1659
|
+
log.blank();
|
|
1660
|
+
}
|
|
1661
|
+
if (lifecycleReadFailed) {
|
|
1662
|
+
log.dim("Couldn't read which templates are retired from the pod — retired templates are not flagged above.");
|
|
1663
|
+
}
|
|
711
1664
|
log.heading(" Installed packages");
|
|
712
1665
|
log.blank();
|
|
713
1666
|
for (const c of checks) {
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
1667
|
+
// ONE label door. This was a hand-rolled second copy that diverged from
|
|
1668
|
+
// `installedStatusLabel` in two ways that both LIED: it had no cache-cold
|
|
1669
|
+
// branch (so a pod with no catalog version to compare against printed a
|
|
1670
|
+
// green "up to date" nothing had verified) and no hash-version branch (so
|
|
1671
|
+
// a content change read as "update available").
|
|
1672
|
+
// A retired template is not "behind": its next step is the move, said above.
|
|
1673
|
+
const label = retiredSlugs.has(c.slug)
|
|
1674
|
+
? chalk.yellow("retired") + chalk.dim(` · synap templates retire ${c.slug}`)
|
|
1675
|
+
: installedStatusLabel(c, entryBySlug.get(c.slug)?.isPrivate ?? false, cat.loggedIn);
|
|
719
1676
|
console.log(" " + chalk.cyan(c.slug.padEnd(28)) + label);
|
|
720
1677
|
}
|
|
721
1678
|
log.blank();
|
|
722
1679
|
if (noVersionInfo.length > 0) {
|
|
723
1680
|
log.dim(`${noVersionInfo.length} installed without a version stamp — can't check ${noVersionInfo.length === 1 ? "it" : "them"} for updates.`);
|
|
724
1681
|
}
|
|
1682
|
+
if (degradedNote)
|
|
1683
|
+
log.dim(degradedNote);
|
|
725
1684
|
}
|
|
1685
|
+
// What the JSON carries for the new facts: per slug, the pod's data changes
|
|
1686
|
+
// (`null` = this pod sent none: an older pod) and the template lifecycle.
|
|
1687
|
+
const dataChangesJson = Object.fromEntries([...previews].map(([slug, pv]) => [slug, pv.error ? { error: pv.error } : pv.changes ?? null]));
|
|
1688
|
+
const lifecycleJson = Object.fromEntries(leaving.map((r) => [r.slug, r.info]));
|
|
726
1689
|
if (withUpdates.length === 0) {
|
|
727
1690
|
// "Nothing to update" is only honest when every installed row was
|
|
728
1691
|
// actually CHECKED. Rows the pod can't version-check are "couldn't be
|
|
@@ -736,6 +1699,7 @@ export async function marketUpdate(slugsArg, opts) {
|
|
|
736
1699
|
applied: [],
|
|
737
1700
|
status: noVersionInfo.length > 0 ? "partial" : "clean",
|
|
738
1701
|
uncheckable: noVersionInfo.map((c) => c.slug),
|
|
1702
|
+
templateLifecycle: lifecycleJson,
|
|
739
1703
|
}, null, 2));
|
|
740
1704
|
}
|
|
741
1705
|
else if (noVersionInfo.length > 0) {
|
|
@@ -744,44 +1708,118 @@ export async function marketUpdate(slugsArg, opts) {
|
|
|
744
1708
|
chalk.yellow(`⚠ ${upToDate} up to date · ${noVersionInfo.length} couldn't be checked`));
|
|
745
1709
|
log.hint("Run 'synap market installed' for why — unstamped install (reinstall to fix) or a private template while logged out (synap login).");
|
|
746
1710
|
}
|
|
1711
|
+
else if (leaving.length > 0) {
|
|
1712
|
+
log.info("No updates to apply.");
|
|
1713
|
+
}
|
|
747
1714
|
else {
|
|
748
1715
|
log.success("Nothing to update.");
|
|
1716
|
+
// `market update` only checks TEMPLATE-ATTACHED workspaces — a hand-built
|
|
1717
|
+
// one (no packageSlug) can't be "behind" a template, so it's silently
|
|
1718
|
+
// absent here. Point at the fuller home so "nothing to update" never reads
|
|
1719
|
+
// as "nothing to do" when an unattached workspace is waiting to be linked.
|
|
1720
|
+
log.hint("See every workspace's template status (incl. unattached): synap templates");
|
|
749
1721
|
}
|
|
750
1722
|
return;
|
|
751
1723
|
}
|
|
752
1724
|
if (!shouldApply) {
|
|
753
1725
|
const steps = FLOW.afterMarketUpdateCheck(withUpdates.map((c) => c.slug));
|
|
754
1726
|
if (opts.json) {
|
|
755
|
-
console.log(JSON.stringify({
|
|
1727
|
+
console.log(JSON.stringify({
|
|
1728
|
+
checks,
|
|
1729
|
+
loggedIn: cat.loggedIn,
|
|
1730
|
+
unknownSlugs,
|
|
1731
|
+
applied: [],
|
|
1732
|
+
nextSteps: steps,
|
|
1733
|
+
dataChanges: dataChangesJson,
|
|
1734
|
+
templateLifecycle: lifecycleJson,
|
|
1735
|
+
}, null, 2));
|
|
756
1736
|
return;
|
|
757
1737
|
}
|
|
758
1738
|
log.info(`${withUpdates.length} package${withUpdates.length === 1 ? "" : "s"} have updates.`);
|
|
1739
|
+
if ([...previews.values()].some((pv) => (pv.changes?.length ?? 0) > 0)) {
|
|
1740
|
+
log.hint("Updates with data changes ask before they run. To agree up front: --accept-data-changes <opKey,…> (or --yes for all listed)");
|
|
1741
|
+
}
|
|
759
1742
|
renderNextSteps(steps);
|
|
760
1743
|
return;
|
|
761
1744
|
}
|
|
762
|
-
|
|
763
|
-
try {
|
|
764
|
-
cfg = await resolveHubConfig(opts);
|
|
765
|
-
}
|
|
766
|
-
catch (e) {
|
|
767
|
-
renderHubError(e);
|
|
768
|
-
process.exit(1);
|
|
769
|
-
}
|
|
1745
|
+
const acceptList = parseAcceptList(opts.acceptDataChanges);
|
|
770
1746
|
const results = [];
|
|
1747
|
+
// One pod-wide reconcile serves every view/skill/automation update — run it
|
|
1748
|
+
// at most once, lazily, and read each slug's outcome from its report.
|
|
1749
|
+
let configsReport;
|
|
771
1750
|
for (const c of withUpdates) {
|
|
772
|
-
const
|
|
1751
|
+
const door = updateDoorFor(c.kind);
|
|
1752
|
+
if (door === null) {
|
|
1753
|
+
if (!opts.json)
|
|
1754
|
+
log.warn(`${c.slug} is a ${c.kind} — the pod has no per-package update for this kind, so it was not updated. Nothing was written.`);
|
|
1755
|
+
results.push({ slug: c.slug, outcome: "no-updater", status: "no-updater", warnings: [] });
|
|
1756
|
+
continue;
|
|
1757
|
+
}
|
|
1758
|
+
if (door === "configs-reconcile") {
|
|
1759
|
+
configsReport ??= hubPost("/configs/reconcile", {}, cfg, applyTimeoutMs);
|
|
1760
|
+
results.push(await reconcileOutcome(c, configsReport, { json: opts.json }));
|
|
1761
|
+
continue;
|
|
1762
|
+
}
|
|
1763
|
+
const entry = entryBySlug.get(c.slug);
|
|
773
1764
|
if (!entry) {
|
|
1765
|
+
// Absent from the catalog THIS CLI read — not proof it left the catalog
|
|
1766
|
+
// (that is the pod's `unlisted`, shown above, and only after a complete sync).
|
|
774
1767
|
if (!opts.json)
|
|
775
|
-
log.warn(`"${c.slug}"
|
|
1768
|
+
log.warn(`Couldn't find "${c.slug}" in the catalog read just now — skipping.`);
|
|
776
1769
|
results.push({ slug: c.slug, outcome: "not-in-catalog", status: "not-in-catalog", warnings: [] });
|
|
777
1770
|
continue;
|
|
778
1771
|
}
|
|
779
|
-
|
|
1772
|
+
const pv = previews.get(c.slug);
|
|
1773
|
+
const pending = pv?.changes ?? [];
|
|
1774
|
+
let accepted = [];
|
|
1775
|
+
if (pending.length > 0) {
|
|
1776
|
+
const decision = decideConsent(pending, { yes: opts.yes, acceptList });
|
|
1777
|
+
accepted = decision.accepted;
|
|
1778
|
+
let missing = decision.missing;
|
|
1779
|
+
if (decision.refusedOperatorSteps.length > 0 && !opts.json) {
|
|
1780
|
+
log.warn(`Not agreed here: ${decision.refusedOperatorSteps.join(", ")} retire${decision.refusedOperatorSteps.length === 1 ? "s" : ""} old data, so an operator runs ${decision.refusedOperatorSteps.length === 1 ? "it" : "them"} (see Operator steps above).`);
|
|
1781
|
+
}
|
|
1782
|
+
if (missing.length > 0 && !opts.json && process.stdin.isTTY) {
|
|
1783
|
+
const answer = await prompts({
|
|
1784
|
+
type: "confirm",
|
|
1785
|
+
name: "ok",
|
|
1786
|
+
message: `Run ${missing.length === 1 ? "this data change" : `these ${missing.length} data changes`} with the ${c.slug} update? (${missing.map((d) => d.opKey).join(", ")})`,
|
|
1787
|
+
initial: false,
|
|
1788
|
+
});
|
|
1789
|
+
if (answer.ok === true) {
|
|
1790
|
+
accepted = [...accepted, ...missing.map((d) => d.opKey)];
|
|
1791
|
+
missing = [];
|
|
1792
|
+
}
|
|
1793
|
+
}
|
|
1794
|
+
if (missing.length > 0) {
|
|
1795
|
+
if (!opts.json) {
|
|
1796
|
+
log.warn(`${entry.name} not updated: you have not agreed to its data changes. Nothing was written.`);
|
|
1797
|
+
log.hint(`To agree and update: ${consentRerunCommand(c.slug, missing)}`);
|
|
1798
|
+
}
|
|
1799
|
+
results.push({ slug: c.slug, outcome: "needs-consent", status: "needs-consent", warnings: [], pendingDataChanges: missing });
|
|
1800
|
+
continue;
|
|
1801
|
+
}
|
|
1802
|
+
}
|
|
1803
|
+
results.push(await applyOnePackage(entry, cat, cfg, { json: opts.json }, applyTimeoutMs, {
|
|
1804
|
+
payload: pv?.payload,
|
|
1805
|
+
acceptDataChanges: accepted,
|
|
1806
|
+
}));
|
|
780
1807
|
}
|
|
1808
|
+
if (results.some((r) => r.status === "needs-consent"))
|
|
1809
|
+
process.exitCode = 1;
|
|
781
1810
|
const summary = summarizeApplyResults(results);
|
|
782
1811
|
const steps = FLOW.afterMarketUpdateApply();
|
|
783
1812
|
if (opts.json) {
|
|
784
|
-
console.log(JSON.stringify({
|
|
1813
|
+
console.log(JSON.stringify({
|
|
1814
|
+
checks,
|
|
1815
|
+
loggedIn: cat.loggedIn,
|
|
1816
|
+
unknownSlugs,
|
|
1817
|
+
applied: results,
|
|
1818
|
+
summary,
|
|
1819
|
+
nextSteps: steps,
|
|
1820
|
+
dataChanges: dataChangesJson,
|
|
1821
|
+
templateLifecycle: lifecycleJson,
|
|
1822
|
+
}, null, 2));
|
|
785
1823
|
return;
|
|
786
1824
|
}
|
|
787
1825
|
log.blank();
|
|
@@ -800,12 +1838,14 @@ export async function marketUpdate(slugsArg, opts) {
|
|
|
800
1838
|
* else (locked/proposed/fetch-failed/not-in-catalog/no-workspace/unknown)
|
|
801
1839
|
* rolls into "failed".
|
|
802
1840
|
*/
|
|
803
|
-
function summarizeApplyResults(results) {
|
|
1841
|
+
export function summarizeApplyResults(results) {
|
|
804
1842
|
let updated = 0;
|
|
805
1843
|
let unchanged = 0;
|
|
806
1844
|
let withFailures = 0;
|
|
807
1845
|
let timedOut = 0;
|
|
808
1846
|
let failed = 0;
|
|
1847
|
+
let proposed = 0;
|
|
1848
|
+
let needsConsent = 0;
|
|
809
1849
|
for (const r of results) {
|
|
810
1850
|
switch (r.status) {
|
|
811
1851
|
case "installed":
|
|
@@ -817,35 +1857,60 @@ function summarizeApplyResults(results) {
|
|
|
817
1857
|
case "unchanged":
|
|
818
1858
|
unchanged++;
|
|
819
1859
|
break;
|
|
1860
|
+
// A governed write queued for review — SUCCESS, not a failure. Counting
|
|
1861
|
+
// this as `failed` (the old `default` fall-through) was dishonest: the
|
|
1862
|
+
// whole point of governance is "proposed ≠ error". It just isn't LIVE yet.
|
|
1863
|
+
case "proposed":
|
|
1864
|
+
// Layer 2 filed proposals and nothing failed: same bucket, same reason.
|
|
1865
|
+
case "awaiting-approval":
|
|
1866
|
+
proposed++;
|
|
1867
|
+
break;
|
|
820
1868
|
case "installed-with-failures":
|
|
821
1869
|
case "updated-with-failures":
|
|
822
1870
|
case "composed-with-failures":
|
|
1871
|
+
case "not-finished":
|
|
823
1872
|
case "stamp-contradiction":
|
|
824
1873
|
withFailures++;
|
|
825
1874
|
break;
|
|
826
1875
|
case "timed-out":
|
|
827
1876
|
timedOut++;
|
|
828
1877
|
break;
|
|
1878
|
+
// Held back on purpose: data changes the person has not agreed to.
|
|
1879
|
+
// Not a failure of the pod, not a success either.
|
|
1880
|
+
case "needs-consent":
|
|
1881
|
+
needsConsent++;
|
|
1882
|
+
break;
|
|
829
1883
|
default:
|
|
830
1884
|
failed++;
|
|
831
1885
|
}
|
|
832
1886
|
}
|
|
833
1887
|
const parts = [`${updated} updated`, `${unchanged} already current`];
|
|
1888
|
+
if (proposed > 0)
|
|
1889
|
+
parts.push(`${proposed} proposed (awaiting review)`);
|
|
1890
|
+
// The vocabulary's `unfinished` ("Not finished"): one state, one name on
|
|
1891
|
+
// every surface — never "completed with failures" (review2 UX §1 #2).
|
|
834
1892
|
if (withFailures > 0)
|
|
835
|
-
parts.push(`${withFailures}
|
|
1893
|
+
parts.push(`${withFailures} ${templateStepPhrase("unfinished")}`);
|
|
836
1894
|
if (failed > 0)
|
|
837
1895
|
parts.push(`${failed} failed`);
|
|
838
1896
|
if (timedOut > 0)
|
|
839
1897
|
parts.push(`${timedOut} timed out`);
|
|
840
|
-
|
|
841
|
-
|
|
1898
|
+
if (needsConsent > 0)
|
|
1899
|
+
parts.push(`${needsConsent} waiting for your agreement to data changes`);
|
|
1900
|
+
const clean = withFailures === 0 && failed === 0 && timedOut === 0 && needsConsent === 0;
|
|
1901
|
+
// Proposed isn't a failure (never ⚠) but isn't live yet (not a bare ✓) —
|
|
1902
|
+
// a distinct "→ awaiting review" marker keeps the summary honest.
|
|
1903
|
+
const marker = !clean ? "⚠" : proposed > 0 ? "→" : "✓";
|
|
1904
|
+
const color = !clean ? chalk.yellow : proposed > 0 ? chalk.cyan : chalk.green;
|
|
842
1905
|
return {
|
|
843
1906
|
updated,
|
|
844
1907
|
unchanged,
|
|
845
1908
|
withFailures,
|
|
846
1909
|
failed,
|
|
847
1910
|
timedOut,
|
|
848
|
-
|
|
1911
|
+
proposed,
|
|
1912
|
+
needsConsent,
|
|
1913
|
+
line: color(`${marker} ${parts.join(" · ")}`),
|
|
849
1914
|
};
|
|
850
1915
|
}
|
|
851
1916
|
// ── `synap market installed [--json] [--outdated]` — pure-read inventory ────
|
|
@@ -862,21 +1927,33 @@ function truncateVersion(v) {
|
|
|
862
1927
|
function noVersionLabel(isPrivate, loggedIn) {
|
|
863
1928
|
return isPrivate && !loggedIn
|
|
864
1929
|
? chalk.dim("connect to the Control Plane to check/update — run: synap login")
|
|
865
|
-
: chalk.dim("version unknown —
|
|
1930
|
+
: chalk.dim("version unknown — run `synap market attach <workspace>` to stamp it");
|
|
866
1931
|
}
|
|
867
1932
|
/**
|
|
868
1933
|
* Human status cell for one installed row — distinguishes hash drift (no
|
|
869
1934
|
* ordering, just "changed") from semver drift ("update available").
|
|
870
1935
|
*/
|
|
871
|
-
function installedStatusLabel(c, isPrivate, loggedIn) {
|
|
1936
|
+
export function installedStatusLabel(c, isPrivate, loggedIn) {
|
|
1937
|
+
// No version recorded BUT the pod says it drifted: the last install did not
|
|
1938
|
+
// finish (it waits on an approval, or part of it failed) — the pod holds the
|
|
1939
|
+
// version until it does. Not "version unknown": the fix is to finish it.
|
|
1940
|
+
if (c.noVersionInfo && c.updateAvailable)
|
|
1941
|
+
return chalk.yellow(templateStepPhrase("unfinished")) + chalk.dim(` — run synap templates update ${c.slug}`);
|
|
872
1942
|
if (c.noVersionInfo)
|
|
873
1943
|
return noVersionLabel(isPrivate, loggedIn);
|
|
874
|
-
if (!c.updateAvailable)
|
|
875
|
-
|
|
1944
|
+
if (!c.updateAvailable) {
|
|
1945
|
+
// Cache-cold honesty: stamped + no drift reported, but the catalog has no
|
|
1946
|
+
// version to compare against (a freshly-deployed pod before its first sync,
|
|
1947
|
+
// or a private/removed slug). Don't assert a green "up to date" we never
|
|
1948
|
+
// actually verified — say we couldn't check (ArgoCD's Unknown ≠ Synced).
|
|
1949
|
+
if (c.latestVersion == null && c.installedVersion)
|
|
1950
|
+
return chalk.dim("can't check — catalog version unknown");
|
|
1951
|
+
return chalk.green(templateStepPhrase("current"));
|
|
1952
|
+
}
|
|
876
1953
|
const installed = truncateVersion(c.installedVersion);
|
|
877
1954
|
const latest = c.latestVersion ? truncateVersion(c.latestVersion) : "?";
|
|
878
1955
|
const hashPair = isHashVersion(c.installedVersion) || isHashVersion(c.latestVersion ?? "");
|
|
879
|
-
const label = hashPair ? "content changed" : "update
|
|
1956
|
+
const label = hashPair ? "content changed" : templateStepPhrase("update");
|
|
880
1957
|
return chalk.yellow(`${label} `) + chalk.dim(installed) + " → " + latest;
|
|
881
1958
|
}
|
|
882
1959
|
// ── Composition engine — the ONE traversal door ─────────────────────────────
|
|
@@ -935,9 +2012,21 @@ async function buildEdgesOf(seeds, cat, cpToken) {
|
|
|
935
2012
|
// safe either way since it's the same bundled-only lookup as the default.
|
|
936
2013
|
return (slug) => cache.get(slug) ?? bundledEdgesOf(slug);
|
|
937
2014
|
}
|
|
938
|
-
/**
|
|
2015
|
+
/**
|
|
2016
|
+
* `CompositionInstalledEntry[]` from the Hub's installed-templates rows — the
|
|
2017
|
+
* shape both `--tree` and `--layers` feed the engine.
|
|
2018
|
+
*
|
|
2019
|
+
* POD-GLOBAL rows (`workspaceId: null` — a capability/cell/skill installed with
|
|
2020
|
+
* no owning workspace) are dropped: `CompositionInstalledEntry` is
|
|
2021
|
+
* workspace-keyed by construction, and the composition views answer "which
|
|
2022
|
+
* workspace does this template land in", which a pod-wide row has no answer to.
|
|
2023
|
+
* They are still listed by the flat `market installed` table, which is where
|
|
2024
|
+
* they belong.
|
|
2025
|
+
*/
|
|
939
2026
|
function toInstalledEntries(installedTemplates) {
|
|
940
|
-
return installedTemplates
|
|
2027
|
+
return installedTemplates
|
|
2028
|
+
.filter((t) => t.workspaceId != null)
|
|
2029
|
+
.map((t) => ({
|
|
941
2030
|
slug: t.slug,
|
|
942
2031
|
workspaceId: t.workspaceId,
|
|
943
2032
|
workspaceName: t.workspaceName,
|
|
@@ -1009,7 +2098,7 @@ export async function buildCompositionTrees(rows, cat, installedTemplates, works
|
|
|
1009
2098
|
relation: edge.relation,
|
|
1010
2099
|
depKind: edge.depKind,
|
|
1011
2100
|
installed: !!installedInfo,
|
|
1012
|
-
workspaceId: installedInfo?.workspaceId,
|
|
2101
|
+
workspaceId: installedInfo?.workspaceId ?? undefined,
|
|
1013
2102
|
workspaceName: installedInfo?.workspaceName,
|
|
1014
2103
|
installedVersion: check?.installedVersion,
|
|
1015
2104
|
latestVersion: check?.latestVersion ?? null,
|
|
@@ -1044,10 +2133,10 @@ function nodeStatusLabel(node) {
|
|
|
1044
2133
|
if (node.noVersionInfo)
|
|
1045
2134
|
return chalk.dim("can't check");
|
|
1046
2135
|
if (!node.updateAvailable)
|
|
1047
|
-
return chalk.green("
|
|
2136
|
+
return chalk.green(templateStepPhrase("current"));
|
|
1048
2137
|
const installed = node.installedVersion ? truncateVersion(node.installedVersion) : "?";
|
|
1049
2138
|
const latest = node.latestVersion ? truncateVersion(node.latestVersion) : "?";
|
|
1050
|
-
return chalk.yellow("update
|
|
2139
|
+
return chalk.yellow(`${templateStepPhrase("update")} `) + chalk.dim(installed) + " → " + latest;
|
|
1051
2140
|
}
|
|
1052
2141
|
function printDependencyNode(node, prefix, isLast) {
|
|
1053
2142
|
const branch = isLast ? "└─ " : "├─ ";
|
|
@@ -1077,8 +2166,8 @@ export function printCompositionTree(pkg) {
|
|
|
1077
2166
|
(pkg.noVersionInfo
|
|
1078
2167
|
? chalk.dim("can't check — reinstall")
|
|
1079
2168
|
: pkg.updateAvailable
|
|
1080
|
-
? chalk.yellow("update
|
|
1081
|
-
: chalk.green("
|
|
2169
|
+
? chalk.yellow(templateStepPhrase("update"))
|
|
2170
|
+
: chalk.green(templateStepPhrase("current"))) +
|
|
1082
2171
|
suiteTag);
|
|
1083
2172
|
pkg.children.forEach((child, i) => printDependencyNode(child, "", i === pkg.children.length - 1));
|
|
1084
2173
|
log.blank();
|
|
@@ -1141,10 +2230,10 @@ function layerNodeStatus(node, isPrivate, loggedIn) {
|
|
|
1141
2230
|
if (node.noVersionInfo)
|
|
1142
2231
|
return noVersionLabel(isPrivate, loggedIn);
|
|
1143
2232
|
if (!node.updateAvailable)
|
|
1144
|
-
return chalk.green("
|
|
2233
|
+
return chalk.green(templateStepPhrase("current"));
|
|
1145
2234
|
const installed = node.installedVersion ? truncateVersion(node.installedVersion) : "?";
|
|
1146
2235
|
const latest = node.latestVersion ? truncateVersion(node.latestVersion) : "?";
|
|
1147
|
-
return chalk.yellow("update
|
|
2236
|
+
return chalk.yellow(`${templateStepPhrase("update")} `) + chalk.dim(installed) + " → " + latest;
|
|
1148
2237
|
}
|
|
1149
2238
|
/**
|
|
1150
2239
|
* Render the layered DAG bottom-up: bedrock (the highest layer index, most
|
|
@@ -1204,25 +2293,90 @@ function printLayersGraph(graph, cat) {
|
|
|
1204
2293
|
* (`market update`'s own drift check), never writes anything. Pairs with
|
|
1205
2294
|
* `market update` to apply what this surfaces.
|
|
1206
2295
|
*/
|
|
2296
|
+
/** Fan-out bucket for a POD-GLOBAL row — a row with no owning workspace. */
|
|
2297
|
+
const POD_WIDE = "\u0000pod-wide";
|
|
2298
|
+
/**
|
|
2299
|
+
* "3 workspaces" / "pod-wide" / "2 workspaces · pod-wide" — where a slug landed.
|
|
2300
|
+
*
|
|
2301
|
+
* Counts DISTINCT placements, not rows. Since `GET /installed` reports one row
|
|
2302
|
+
* per installed OBJECT (four `task-views-pack` views across two workspaces is
|
|
2303
|
+
* four rows), tallying rows would have printed "4 workspaces" for two. The
|
|
2304
|
+
* workspace-kind rows this replaced are one-per-workspace either way, so their
|
|
2305
|
+
* output is unchanged.
|
|
2306
|
+
*/
|
|
2307
|
+
export function fanOutLabel(placements) {
|
|
2308
|
+
const podWide = placements.has(POD_WIDE);
|
|
2309
|
+
const wsCount = placements.size - (podWide ? 1 : 0);
|
|
2310
|
+
const parts = [];
|
|
2311
|
+
if (wsCount > 0)
|
|
2312
|
+
parts.push(`${wsCount} workspace${wsCount === 1 ? "" : "s"}`);
|
|
2313
|
+
if (podWide)
|
|
2314
|
+
parts.push("pod-wide");
|
|
2315
|
+
return parts.join(" · ");
|
|
2316
|
+
}
|
|
2317
|
+
/** Dedupe the not-package-linked rows by `kind + templateKey` (falling back to the row name). */
|
|
2318
|
+
export function collectUnlinked(rows) {
|
|
2319
|
+
const byKey = new Map();
|
|
2320
|
+
for (const r of rows) {
|
|
2321
|
+
if (r.packageSlug)
|
|
2322
|
+
continue;
|
|
2323
|
+
const key = `${r.kind}::${r.templateKey ?? r.name}`;
|
|
2324
|
+
const existing = byKey.get(key);
|
|
2325
|
+
const placement = r.workspaceId ?? POD_WIDE;
|
|
2326
|
+
if (existing) {
|
|
2327
|
+
existing.placements.add(placement);
|
|
2328
|
+
continue;
|
|
2329
|
+
}
|
|
2330
|
+
byKey.set(key, {
|
|
2331
|
+
kind: r.kind,
|
|
2332
|
+
templateKey: r.templateKey,
|
|
2333
|
+
name: r.templateKey ?? r.name,
|
|
2334
|
+
placements: new Set([placement]),
|
|
2335
|
+
...(r.note ? { note: r.note } : {}),
|
|
2336
|
+
});
|
|
2337
|
+
}
|
|
2338
|
+
return [...byKey.values()];
|
|
2339
|
+
}
|
|
1207
2340
|
export async function marketInstalled(opts) {
|
|
1208
|
-
|
|
1209
|
-
|
|
2341
|
+
// THE ONE READ DOOR. `GET /api/hub/installed` unions all four install
|
|
2342
|
+
// ledgers; before this, `market installed` asked `GET /workspaces` and so
|
|
2343
|
+
// reported workspace packages ONLY — a pod-wide capability, cell, skill or
|
|
2344
|
+
// view install was invisible here, to `market update`, and to every drift
|
|
2345
|
+
// marker. On a pod too old to serve it, `fetchInstalledInventory` degrades to
|
|
2346
|
+
// exactly the old workspace-only behaviour and says so (`degraded`).
|
|
2347
|
+
const [inv, cat] = await Promise.all([fetchInstalledInventory(), buildMarketCatalog()]);
|
|
2348
|
+
const installedTemplates = installedRowsToTemplates(inv.rows);
|
|
2349
|
+
const unlinked = collectUnlinked(inv.rows);
|
|
2350
|
+
const degradedNote = inv.degraded
|
|
2351
|
+
? "This pod does not serve GET /api/hub/installed — showing workspace packages only. Capability / view / skill / automation / card installs are NOT listed (upgrade the pod to see them)."
|
|
2352
|
+
: undefined;
|
|
2353
|
+
if (installedTemplates.length === 0 && unlinked.length === 0) {
|
|
1210
2354
|
if (opts.json) {
|
|
1211
|
-
console.log(JSON.stringify({ rows: [], loggedIn: cat.loggedIn }, null, 2));
|
|
2355
|
+
console.log(JSON.stringify({ rows: [], unlinked: [], loggedIn: cat.loggedIn, degraded: inv.degraded, degradedNote }, null, 2));
|
|
1212
2356
|
return;
|
|
1213
2357
|
}
|
|
1214
2358
|
log.warn("No installed packages found on this pod.");
|
|
2359
|
+
if (degradedNote)
|
|
2360
|
+
log.dim(degradedNote);
|
|
1215
2361
|
log.hint("Browse what's available: synap market --list");
|
|
1216
2362
|
return;
|
|
1217
2363
|
}
|
|
1218
2364
|
const checks = computeUpdates(installedTemplates, cat);
|
|
1219
2365
|
// Multiple workspaces can install the same slug (a template composed onto
|
|
1220
|
-
// several bases,
|
|
1221
|
-
// DISTINCT slug, with
|
|
1222
|
-
|
|
2366
|
+
// several bases, the same additive pack in >1 workspace, a views pack whose
|
|
2367
|
+
// views span workspaces) — one row per DISTINCT slug, with the fan-out
|
|
2368
|
+
// carried as the set of PLACEMENTS it landed in.
|
|
2369
|
+
const placementsBySlug = new Map();
|
|
2370
|
+
const kindBySlug = new Map();
|
|
1223
2371
|
for (const t of installedTemplates) {
|
|
1224
|
-
|
|
2372
|
+
const set = placementsBySlug.get(t.slug) ?? new Set();
|
|
2373
|
+
set.add(t.workspaceId ?? POD_WIDE);
|
|
2374
|
+
placementsBySlug.set(t.slug, set);
|
|
2375
|
+
if (!kindBySlug.has(t.slug))
|
|
2376
|
+
kindBySlug.set(t.slug, t.kind ?? "workspace");
|
|
1225
2377
|
}
|
|
2378
|
+
const workspaceCountBySlug = new Map([...placementsBySlug].map(([slug, set]) => [slug, set.size]));
|
|
2379
|
+
const placementLabel = (slug) => fanOutLabel(placementsBySlug.get(slug) ?? new Set());
|
|
1226
2380
|
const seen = new Set();
|
|
1227
2381
|
let rows = checks.filter((c) => {
|
|
1228
2382
|
if (seen.has(c.slug))
|
|
@@ -1243,6 +2397,7 @@ export async function marketInstalled(opts) {
|
|
|
1243
2397
|
layers: graph.layers.map((bucket) => bucket.map((n) => n.slug)),
|
|
1244
2398
|
cycles: graph.cycles,
|
|
1245
2399
|
loggedIn: cat.loggedIn,
|
|
2400
|
+
degraded: inv.degraded,
|
|
1246
2401
|
nextSteps: steps,
|
|
1247
2402
|
}, null, 2));
|
|
1248
2403
|
return;
|
|
@@ -1252,6 +2407,8 @@ export async function marketInstalled(opts) {
|
|
|
1252
2407
|
return;
|
|
1253
2408
|
}
|
|
1254
2409
|
printLayersGraph(graph, cat);
|
|
2410
|
+
if (degradedNote)
|
|
2411
|
+
log.dim(degradedNote);
|
|
1255
2412
|
if (!cat.loggedIn)
|
|
1256
2413
|
log.dim("Not logged in — private template updates aren't visible. Run: synap login");
|
|
1257
2414
|
renderNextSteps(steps);
|
|
@@ -1277,6 +2434,7 @@ export async function marketInstalled(opts) {
|
|
|
1277
2434
|
};
|
|
1278
2435
|
}),
|
|
1279
2436
|
loggedIn: cat.loggedIn,
|
|
2437
|
+
degraded: inv.degraded,
|
|
1280
2438
|
nextSteps: steps,
|
|
1281
2439
|
}, null, 2));
|
|
1282
2440
|
return;
|
|
@@ -1299,7 +2457,6 @@ export async function marketInstalled(opts) {
|
|
|
1299
2457
|
const entry = entryBySlug.get(c.slug);
|
|
1300
2458
|
const name = entry?.name ?? c.slug;
|
|
1301
2459
|
const priv = entry?.isPrivate ? chalk.yellow("private") : "";
|
|
1302
|
-
const wsCount = workspaceCountBySlug.get(c.slug) ?? 0;
|
|
1303
2460
|
console.log(" " +
|
|
1304
2461
|
chalk.bold(name.padEnd(nameW)) +
|
|
1305
2462
|
chalk.cyan(c.slug.padEnd(slugW)) +
|
|
@@ -1307,10 +2464,12 @@ export async function marketInstalled(opts) {
|
|
|
1307
2464
|
" " +
|
|
1308
2465
|
priv +
|
|
1309
2466
|
(priv ? " " : "") +
|
|
1310
|
-
chalk.dim(
|
|
2467
|
+
chalk.dim(placementLabel(c.slug)));
|
|
1311
2468
|
}
|
|
1312
2469
|
log.blank();
|
|
1313
2470
|
}
|
|
2471
|
+
if (degradedNote)
|
|
2472
|
+
log.dim(degradedNote);
|
|
1314
2473
|
if (!cat.loggedIn)
|
|
1315
2474
|
log.dim("Not logged in — private template updates aren't visible. Run: synap login");
|
|
1316
2475
|
renderNextSteps(steps);
|
|
@@ -1323,6 +2482,9 @@ export async function marketInstalled(opts) {
|
|
|
1323
2482
|
const entry = entryBySlug.get(c.slug);
|
|
1324
2483
|
return {
|
|
1325
2484
|
slug: c.slug,
|
|
2485
|
+
// Which install ledger this row came from. Absent kinds mean the
|
|
2486
|
+
// pod is older than `GET /installed` — see `degraded`.
|
|
2487
|
+
kind: kindBySlug.get(c.slug) ?? "workspace",
|
|
1326
2488
|
name: entry?.name ?? c.slug,
|
|
1327
2489
|
installedVersion: c.installedVersion,
|
|
1328
2490
|
latestVersion: c.latestVersion ?? null,
|
|
@@ -1331,38 +2493,807 @@ export async function marketInstalled(opts) {
|
|
|
1331
2493
|
isPrivate: entry?.isPrivate ?? false,
|
|
1332
2494
|
isHashVersion: isHashVersion(c.installedVersion) || isHashVersion(c.latestVersion ?? ""),
|
|
1333
2495
|
workspaceCount: workspaceCountBySlug.get(c.slug) ?? 0,
|
|
2496
|
+
placement: placementLabel(c.slug),
|
|
1334
2497
|
};
|
|
1335
2498
|
}),
|
|
2499
|
+
// Installed but not package-source-linked: no slug, so no update
|
|
2500
|
+
// path. `drift` is NOT COMPUTED for these — never reported as clean.
|
|
2501
|
+
unlinked: opts.outdated
|
|
2502
|
+
? []
|
|
2503
|
+
: unlinked.map((u) => ({
|
|
2504
|
+
kind: u.kind,
|
|
2505
|
+
templateKey: u.templateKey,
|
|
2506
|
+
name: u.name,
|
|
2507
|
+
driftComputed: false,
|
|
2508
|
+
placement: fanOutLabel(u.placements),
|
|
2509
|
+
...(u.note ? { note: u.note } : {}),
|
|
2510
|
+
})),
|
|
1336
2511
|
loggedIn: cat.loggedIn,
|
|
2512
|
+
degraded: inv.degraded,
|
|
2513
|
+
...(degradedNote ? { degradedNote } : {}),
|
|
1337
2514
|
nextSteps: steps,
|
|
1338
2515
|
}, null, 2));
|
|
1339
2516
|
return;
|
|
1340
2517
|
}
|
|
1341
|
-
if (rows.length === 0) {
|
|
2518
|
+
if (rows.length === 0 && (opts.outdated || unlinked.length === 0)) {
|
|
1342
2519
|
log.success(opts.outdated ? "Nothing outdated." : "Nothing installed.");
|
|
2520
|
+
if (degradedNote)
|
|
2521
|
+
log.dim(degradedNote);
|
|
1343
2522
|
return;
|
|
1344
2523
|
}
|
|
1345
2524
|
log.heading(" Installed packages");
|
|
2525
|
+
// Widths are computed across ALL rows (and the unlinked block below) so the
|
|
2526
|
+
// per-kind groups stay in one column grid rather than each re-aligning.
|
|
2527
|
+
//
|
|
2528
|
+
// CAPPED: some CP catalog entries carry a sentence where a name belongs (the
|
|
2529
|
+
// view packs' `name` is their whole description), which uncapped pushed the
|
|
2530
|
+
// status column ~90 columns right and wrapped every line. The cap truncates
|
|
2531
|
+
// the label; it never truncates the SLUG, which is the identifier a user
|
|
2532
|
+
// retypes into `market update`.
|
|
2533
|
+
const displayName = (raw) => raw.length > NAME_COL_MAX ? raw.slice(0, NAME_COL_MAX - 1) + "…" : raw;
|
|
2534
|
+
const labelLengths = [
|
|
2535
|
+
...rows.map((c) => displayName(entryBySlug.get(c.slug)?.name ?? c.slug).length),
|
|
2536
|
+
...unlinked.map((u) => displayName(u.name).length),
|
|
2537
|
+
];
|
|
2538
|
+
const nameW = Math.max(4, ...labelLengths) + 2;
|
|
2539
|
+
const slugW = Math.max(4, ...rows.map((c) => c.slug.length), ...unlinked.map((u) => u.name.length)) + 2;
|
|
2540
|
+
// Grouped by the kind its ledger reports, in the canonical kind order, with
|
|
2541
|
+
// headings from the pinned `KIND_HEADINGS` vocabulary (Card, never "cell";
|
|
2542
|
+
// Automations, never "Workflow").
|
|
2543
|
+
for (const kind of INSTALLED_KINDS) {
|
|
2544
|
+
const group = rows.filter((c) => (kindBySlug.get(c.slug) ?? "workspace") === kind);
|
|
2545
|
+
if (group.length === 0)
|
|
2546
|
+
continue;
|
|
2547
|
+
log.heading(` ${kindHeading(kind)}`);
|
|
2548
|
+
for (const c of group) {
|
|
2549
|
+
const entry = entryBySlug.get(c.slug);
|
|
2550
|
+
const name = displayName(entry?.name ?? c.slug);
|
|
2551
|
+
const priv = entry?.isPrivate ? chalk.yellow("private") : "";
|
|
2552
|
+
console.log(" " +
|
|
2553
|
+
chalk.bold(name.padEnd(nameW)) +
|
|
2554
|
+
chalk.cyan(c.slug.padEnd(slugW)) +
|
|
2555
|
+
installedStatusLabel(c, entry?.isPrivate ?? false, cat.loggedIn) +
|
|
2556
|
+
" " +
|
|
2557
|
+
priv +
|
|
2558
|
+
(priv ? " " : "") +
|
|
2559
|
+
chalk.dim(placementLabel(c.slug)));
|
|
2560
|
+
}
|
|
2561
|
+
log.blank();
|
|
2562
|
+
}
|
|
2563
|
+
// Installed, but with no package slug to update THROUGH — listed so they are
|
|
2564
|
+
// not invisible, kept out of the table above so nothing offers `market update
|
|
2565
|
+
// <templateKey>`, which the catalog would miss.
|
|
2566
|
+
if (!opts.outdated && unlinked.length > 0) {
|
|
2567
|
+
log.heading(" Installed, not from a package");
|
|
2568
|
+
log.dim(" source-linked to a Control-Plane template, not a catalog slug — `market update` can't act on these");
|
|
2569
|
+
for (const kind of INSTALLED_KINDS) {
|
|
2570
|
+
const group = unlinked.filter((u) => u.kind === kind);
|
|
2571
|
+
if (group.length === 0)
|
|
2572
|
+
continue;
|
|
2573
|
+
log.blank();
|
|
2574
|
+
log.heading(` ${kindHeading(kind)}`);
|
|
2575
|
+
for (const u of group) {
|
|
2576
|
+
console.log(" " +
|
|
2577
|
+
chalk.bold(displayName(u.name).padEnd(nameW)) +
|
|
2578
|
+
// No slug column value: there IS no package slug for these rows,
|
|
2579
|
+
// and printing the template key twice (it is already the label)
|
|
2580
|
+
// invites pasting it into `market update`, which would miss.
|
|
2581
|
+
chalk.dim("—".padEnd(slugW)) +
|
|
2582
|
+
chalk.dim("drift not computed") +
|
|
2583
|
+
" " +
|
|
2584
|
+
chalk.dim(fanOutLabel(u.placements)) +
|
|
2585
|
+
// The pod's own explanation of WHY drift is unknown for this row —
|
|
2586
|
+
// "not computed" without the reason is where a user gives up and
|
|
2587
|
+
// assumes it means clean.
|
|
2588
|
+
(u.note ? chalk.dim(` ${u.note}`) : ""));
|
|
2589
|
+
}
|
|
2590
|
+
}
|
|
2591
|
+
log.blank();
|
|
2592
|
+
}
|
|
2593
|
+
if (degradedNote)
|
|
2594
|
+
log.dim(degradedNote);
|
|
2595
|
+
if (!cat.loggedIn)
|
|
2596
|
+
log.dim("Not logged in — private template updates aren't visible. Run: synap login");
|
|
2597
|
+
renderNextSteps(FLOW.afterMarketInstalledCheck(rows.filter((c) => c.updateAvailable).map((c) => c.slug)));
|
|
2598
|
+
}
|
|
2599
|
+
/** Classify one workspace's attachment, reusing `computeUpdates`' drift verdict for the stamped case. */
|
|
2600
|
+
function classifyAttachment(ws, check) {
|
|
2601
|
+
if (!ws.packageSlug)
|
|
2602
|
+
return { kind: "unattached" };
|
|
2603
|
+
if (!ws.packageVersion && ws.drifted === true)
|
|
2604
|
+
return { kind: "unfinished", slug: ws.packageSlug };
|
|
2605
|
+
if (!ws.packageVersion || check?.noVersionInfo)
|
|
2606
|
+
return { kind: "no-version", slug: ws.packageSlug };
|
|
2607
|
+
return {
|
|
2608
|
+
kind: "attached",
|
|
2609
|
+
slug: ws.packageSlug,
|
|
2610
|
+
version: ws.packageVersion,
|
|
2611
|
+
updateAvailable: check?.updateAvailable ?? false,
|
|
2612
|
+
latestVersion: check?.latestVersion,
|
|
2613
|
+
};
|
|
2614
|
+
}
|
|
2615
|
+
/** Human status cell for one workspace row — the same vocabulary as `market installed`, keyed on attachment. */
|
|
2616
|
+
export function attachmentStatusLabel(s) {
|
|
2617
|
+
switch (s.kind) {
|
|
2618
|
+
case "unattached":
|
|
2619
|
+
return chalk.yellow("⚠ not attached to a template");
|
|
2620
|
+
case "unfinished":
|
|
2621
|
+
return chalk.yellow("⚠ ") + chalk.cyan(s.slug) + chalk.yellow(` · ${templateStepPhrase("unfinished")}`);
|
|
2622
|
+
case "no-version":
|
|
2623
|
+
return (chalk.yellow("⚠ attached to ") +
|
|
2624
|
+
chalk.cyan(s.slug) +
|
|
2625
|
+
chalk.yellow(" · no version stamp — reattach to enable updates"));
|
|
2626
|
+
case "attached": {
|
|
2627
|
+
if (!s.updateAvailable) {
|
|
2628
|
+
// Cache-cold: stamped but the catalog has no version to compare against
|
|
2629
|
+
// → show the stamp without a green "up to date" claim we never verified.
|
|
2630
|
+
if (s.latestVersion == null)
|
|
2631
|
+
return chalk.cyan(s.slug) + chalk.dim(` @ ${truncateVersion(s.version)} · can't check`);
|
|
2632
|
+
return chalk.green("✓ ") + chalk.cyan(s.slug) + chalk.dim(` @ ${truncateVersion(s.version)}`);
|
|
2633
|
+
}
|
|
2634
|
+
const latest = s.latestVersion ? truncateVersion(s.latestVersion) : "?";
|
|
2635
|
+
return (chalk.cyan(s.slug) +
|
|
2636
|
+
chalk.dim(` @ ${truncateVersion(s.version)} → ${latest} `) +
|
|
2637
|
+
chalk.yellow(templateStepPhrase("update")));
|
|
2638
|
+
}
|
|
2639
|
+
}
|
|
2640
|
+
}
|
|
2641
|
+
/**
|
|
2642
|
+
* Pure-read discovery surface — every workspace on the pod with its template-
|
|
2643
|
+
* attachment status. Sources the linkage from the SAME Hub `GET /workspaces`
|
|
2644
|
+
* call `market installed` uses (`fetchWorkspaceAttachments`), and reuses
|
|
2645
|
+
* `computeUpdates` for the stamped-drift verdict. Never writes.
|
|
2646
|
+
*/
|
|
2647
|
+
export async function marketWorkspaces(opts) {
|
|
2648
|
+
const [attachments, cat] = await Promise.all([fetchWorkspaceAttachments(), buildMarketCatalog()]);
|
|
2649
|
+
if (attachments.length === 0) {
|
|
2650
|
+
if (opts.json) {
|
|
2651
|
+
console.log(JSON.stringify({ workspaces: [], loggedIn: cat.loggedIn }, null, 2));
|
|
2652
|
+
return;
|
|
2653
|
+
}
|
|
2654
|
+
log.warn("No workspaces found on this pod.");
|
|
2655
|
+
log.hint("Stand one up: synap launch · or install a template: synap market --list");
|
|
2656
|
+
return;
|
|
2657
|
+
}
|
|
2658
|
+
// Reuse the exact drift check `market installed`/`update` use. Only the
|
|
2659
|
+
// STAMPED workspaces need it; `computeUpdates` preserves input order, so the
|
|
2660
|
+
// result is index-aligned with `stamped` for a clean zip-back by workspace.
|
|
2661
|
+
const stamped = attachments.filter((a) => a.packageSlug);
|
|
2662
|
+
const stampedChecks = computeUpdates(stamped.map((a) => ({
|
|
2663
|
+
slug: a.packageSlug,
|
|
2664
|
+
version: a.packageVersion ?? undefined,
|
|
2665
|
+
workspaceId: a.workspaceId,
|
|
2666
|
+
workspaceName: a.workspaceName,
|
|
2667
|
+
latestVersion: a.latestVersion,
|
|
2668
|
+
drifted: a.drifted,
|
|
2669
|
+
})), cat);
|
|
2670
|
+
const checkByWorkspace = new Map();
|
|
2671
|
+
stamped.forEach((a, i) => checkByWorkspace.set(a.workspaceId, stampedChecks[i]));
|
|
2672
|
+
let rows = attachments.map((ws) => ({
|
|
2673
|
+
ws,
|
|
2674
|
+
status: classifyAttachment(ws, checkByWorkspace.get(ws.workspaceId)),
|
|
2675
|
+
}));
|
|
2676
|
+
if (opts.unattached) {
|
|
2677
|
+
rows = rows.filter((r) => r.status.kind !== "attached" && r.status.kind !== "unfinished");
|
|
2678
|
+
}
|
|
2679
|
+
if (opts.json) {
|
|
2680
|
+
console.log(JSON.stringify({
|
|
2681
|
+
workspaces: rows.map((r) => ({
|
|
2682
|
+
id: r.ws.workspaceId,
|
|
2683
|
+
name: r.ws.workspaceName,
|
|
2684
|
+
domain: r.ws.domain,
|
|
2685
|
+
packageSlug: r.ws.packageSlug,
|
|
2686
|
+
packageVersion: r.ws.packageVersion,
|
|
2687
|
+
attachment: r.status.kind,
|
|
2688
|
+
...(r.status.kind === "attached"
|
|
2689
|
+
? { updateAvailable: r.status.updateAvailable, latestVersion: r.status.latestVersion ?? null }
|
|
2690
|
+
: {}),
|
|
2691
|
+
})),
|
|
2692
|
+
loggedIn: cat.loggedIn,
|
|
2693
|
+
}, null, 2));
|
|
2694
|
+
return;
|
|
2695
|
+
}
|
|
2696
|
+
if (rows.length === 0) {
|
|
2697
|
+
log.success("Every workspace is attached to a template.");
|
|
2698
|
+
return;
|
|
2699
|
+
}
|
|
2700
|
+
log.heading(opts.unattached ? " Workspaces needing attention" : " Your workspaces");
|
|
1346
2701
|
log.blank();
|
|
1347
|
-
const nameW = Math.max(4, ...rows.map((
|
|
1348
|
-
const
|
|
1349
|
-
|
|
1350
|
-
const entry = entryBySlug.get(c.slug);
|
|
1351
|
-
const name = entry?.name ?? c.slug;
|
|
1352
|
-
const priv = entry?.isPrivate ? chalk.yellow("private") : "";
|
|
1353
|
-
const wsCount = workspaceCountBySlug.get(c.slug) ?? 0;
|
|
2702
|
+
const nameW = Math.max(4, ...rows.map((r) => r.ws.workspaceName.length)) + 2;
|
|
2703
|
+
for (const { ws, status } of rows) {
|
|
2704
|
+
const domain = ws.domain ? chalk.dim(String(ws.domain)) : chalk.dim("—");
|
|
1354
2705
|
console.log(" " +
|
|
1355
|
-
chalk.bold(
|
|
1356
|
-
chalk.
|
|
1357
|
-
installedStatusLabel(c, entry?.isPrivate ?? false, cat.loggedIn) +
|
|
2706
|
+
chalk.bold(ws.workspaceName.padEnd(nameW)) +
|
|
2707
|
+
chalk.dim(ws.workspaceId) +
|
|
1358
2708
|
" " +
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
2709
|
+
domain +
|
|
2710
|
+
"\n " +
|
|
2711
|
+
attachmentStatusLabel(status));
|
|
1362
2712
|
}
|
|
1363
2713
|
log.blank();
|
|
2714
|
+
const needsAttach = rows.filter((r) => r.status.kind !== "attached" && r.status.kind !== "unfinished");
|
|
2715
|
+
if (needsAttach.length > 0) {
|
|
2716
|
+
log.hint(`Attach a template to a workspace: synap market attach <workspaceId> [--slug <slug>]`);
|
|
2717
|
+
log.dim("This is how you relink a workspace that was installed before the marketplace existed.");
|
|
2718
|
+
}
|
|
1364
2719
|
if (!cat.loggedIn)
|
|
1365
2720
|
log.dim("Not logged in — private template updates aren't visible. Run: synap login");
|
|
1366
|
-
|
|
2721
|
+
}
|
|
2722
|
+
// ── `synap market attach <workspaceId>` — ergonomic reattach/relink ──────────
|
|
2723
|
+
//
|
|
2724
|
+
// A friendly wrapper over `market install <slug> --onto <workspaceId>`: it
|
|
2725
|
+
// resolves WHICH template to reconcile onto the workspace (from `--slug`, the
|
|
2726
|
+
// workspace's existing `packageSlug`, or an unambiguous domain match), then
|
|
2727
|
+
// hands off to `marketInstall` so the reconcile + version-stamp path is shared,
|
|
2728
|
+
// never duplicated. `market install --onto` reconciles ADDITIVELY and stamps —
|
|
2729
|
+
// this command just makes it discoverable per-workspace.
|
|
2730
|
+
export async function marketAttach(workspaceId, opts) {
|
|
2731
|
+
let cfg;
|
|
2732
|
+
try {
|
|
2733
|
+
cfg = await resolveHubConfig(opts);
|
|
2734
|
+
}
|
|
2735
|
+
catch (e) {
|
|
2736
|
+
renderHubError(e);
|
|
2737
|
+
process.exit(1);
|
|
2738
|
+
}
|
|
2739
|
+
const attachments = await fetchWorkspaceAttachments(cfg);
|
|
2740
|
+
const ws = attachments.find((a) => a.workspaceId === workspaceId);
|
|
2741
|
+
if (!ws) {
|
|
2742
|
+
log.error(`No workspace "${workspaceId}" found on this pod.`);
|
|
2743
|
+
log.hint("List your workspaces: synap market workspaces");
|
|
2744
|
+
process.exit(1);
|
|
2745
|
+
}
|
|
2746
|
+
// Resolve the slug to reconcile. Priority: explicit --slug > the workspace's
|
|
2747
|
+
// own recorded packageSlug (the reattach case) > the SHARED `suggestTemplateFor`
|
|
2748
|
+
// inference (same algorithm `synap templates attach` uses — domain/name match
|
|
2749
|
+
// with the generic-domain guard) so the two attach doors never disagree on the
|
|
2750
|
+
// same workspace. This non-interactive door only auto-uses a STRONG (exact
|
|
2751
|
+
// domain) match; a weak/none guess is never silently written — it asks for --slug.
|
|
2752
|
+
let slug = opts.slug;
|
|
2753
|
+
let inferredFrom;
|
|
2754
|
+
if (!slug && ws.packageSlug) {
|
|
2755
|
+
slug = ws.packageSlug;
|
|
2756
|
+
inferredFrom = "the workspace's existing attachment";
|
|
2757
|
+
}
|
|
2758
|
+
if (!slug) {
|
|
2759
|
+
const cat = await buildMarketCatalog();
|
|
2760
|
+
const suggestion = suggestTemplateFor(ws, cat.entries);
|
|
2761
|
+
if (suggestion && suggestion.confidence === "strong") {
|
|
2762
|
+
slug = suggestion.slug;
|
|
2763
|
+
inferredFrom = `its domain "${ws.domain}"`;
|
|
2764
|
+
}
|
|
2765
|
+
else {
|
|
2766
|
+
log.error(`Couldn't confidently infer which template to attach to "${ws.workspaceName}".`);
|
|
2767
|
+
if (suggestion)
|
|
2768
|
+
log.hint(`Best guess: ${chalk.cyan(suggestion.slug)} (${suggestion.confidence}) — confirm it with --slug ${suggestion.slug}`);
|
|
2769
|
+
const templates = cat.entries.filter((e) => typeOf(e) === "workspace").slice(0, 12);
|
|
2770
|
+
if (templates.length > 0) {
|
|
2771
|
+
log.hint("Available workspace templates:");
|
|
2772
|
+
for (const e of templates) {
|
|
2773
|
+
console.log(" " + chalk.cyan(e.slug.padEnd(28)) + chalk.dim(e.name));
|
|
2774
|
+
}
|
|
2775
|
+
}
|
|
2776
|
+
log.hint(`Re-run with the one you want: synap market attach ${workspaceId} --slug <slug>`);
|
|
2777
|
+
log.hint(`Or use the name-friendly door: synap templates attach "${ws.workspaceName}"`);
|
|
2778
|
+
process.exit(1);
|
|
2779
|
+
}
|
|
2780
|
+
}
|
|
2781
|
+
if (!opts.json && inferredFrom) {
|
|
2782
|
+
log.info(`Attaching ${chalk.cyan(slug)} — inferred from ${inferredFrom}.`);
|
|
2783
|
+
}
|
|
2784
|
+
// Hand off to the SAME reconcile+stamp door `market install --onto` uses. It
|
|
2785
|
+
// prints the honest "Reconciled <slug> onto <workspace>" verdict, surfaces a
|
|
2786
|
+
// governed proposal's review link when the write is queued, and stamps the
|
|
2787
|
+
// version — none of which we re-implement here.
|
|
2788
|
+
await marketInstall(slug, {
|
|
2789
|
+
onto: workspaceId,
|
|
2790
|
+
project: opts.project,
|
|
2791
|
+
timeout: opts.timeout,
|
|
2792
|
+
json: opts.json,
|
|
2793
|
+
podUrl: opts.podUrl,
|
|
2794
|
+
apiKey: opts.apiKey,
|
|
2795
|
+
});
|
|
2796
|
+
}
|
|
2797
|
+
/** Best template guess for an unattached workspace: exact domain match wins; else the name containing a template slug. Null when nothing fits — we never invent a link. */
|
|
2798
|
+
export function suggestTemplateFor(ws, entries) {
|
|
2799
|
+
const templates = entries.filter((e) => typeOf(e) === "workspace");
|
|
2800
|
+
const norm = (s) => s.toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
2801
|
+
const wsDomain = ws.domain ? norm(String(ws.domain)) : "";
|
|
2802
|
+
const wsName = norm(ws.workspaceName);
|
|
2803
|
+
// Generic/default domains carry NO subject signal — `personal` is the default
|
|
2804
|
+
// `workspaceType` for any hand-built or system workspace (Pod Admin, "New
|
|
2805
|
+
// Workspace"). Matching those to a same-named base template is a false positive
|
|
2806
|
+
// on every unconfigured workspace, so we never suggest from them.
|
|
2807
|
+
// `personal` is the CONFIRMED default `workspaceType` (backend schema default);
|
|
2808
|
+
// `general`/`foundation` are precautionary — not observed as real subtypes, but
|
|
2809
|
+
// if a base template ever used one, matching it would be the same false positive.
|
|
2810
|
+
const GENERIC_DOMAINS = new Set(["personal", "general", "foundation"]);
|
|
2811
|
+
// STRONG: a SPECIFIC domain IS a template slug (or that template's domain).
|
|
2812
|
+
if (wsDomain && !GENERIC_DOMAINS.has(wsDomain)) {
|
|
2813
|
+
for (const e of templates) {
|
|
2814
|
+
if (norm(e.slug) === wsDomain || norm(e.domain ?? "") === wsDomain) {
|
|
2815
|
+
return { slug: e.slug, name: e.name, confidence: "strong" };
|
|
2816
|
+
}
|
|
2817
|
+
}
|
|
2818
|
+
}
|
|
2819
|
+
// WEAK: the workspace name contains a template's slug token (≥3 chars to avoid
|
|
2820
|
+
// noise), skipping the generic base templates a name would falsely brush.
|
|
2821
|
+
for (const e of templates) {
|
|
2822
|
+
const s = norm(e.slug);
|
|
2823
|
+
if (GENERIC_DOMAINS.has(s))
|
|
2824
|
+
continue;
|
|
2825
|
+
if (s.length >= 3 && wsName.includes(s)) {
|
|
2826
|
+
return { slug: e.slug, name: e.name, confidence: "weak" };
|
|
2827
|
+
}
|
|
2828
|
+
}
|
|
2829
|
+
return null;
|
|
2830
|
+
}
|
|
2831
|
+
/** Resolve a user-typed workspace reference — exact id, else exact (case-insensitive) name, else a UNIQUE substring name match. No silent pick when >1 fits. */
|
|
2832
|
+
export function resolveWorkspaceQuery(query, attachments) {
|
|
2833
|
+
const byId = attachments.find((a) => a.workspaceId === query);
|
|
2834
|
+
if (byId)
|
|
2835
|
+
return { kind: "found", ws: byId };
|
|
2836
|
+
const q = query.toLowerCase().trim();
|
|
2837
|
+
const exact = attachments.filter((a) => a.workspaceName.toLowerCase() === q);
|
|
2838
|
+
if (exact.length === 1)
|
|
2839
|
+
return { kind: "found", ws: exact[0] };
|
|
2840
|
+
if (exact.length > 1)
|
|
2841
|
+
return { kind: "ambiguous", matches: exact };
|
|
2842
|
+
const partial = attachments.filter((a) => a.workspaceName.toLowerCase().includes(q));
|
|
2843
|
+
if (partial.length === 1)
|
|
2844
|
+
return { kind: "found", ws: partial[0] };
|
|
2845
|
+
if (partial.length > 1)
|
|
2846
|
+
return { kind: "ambiguous", matches: partial };
|
|
2847
|
+
return { kind: "none" };
|
|
2848
|
+
}
|
|
2849
|
+
/** Sort one workspace into the action-ranked home: drifted / unstamped / (unattached-with-suggestion) → attention; current → ok; unattached-no-match → unlinked. */
|
|
2850
|
+
export function classifyTemplateRow(ws, check, entries) {
|
|
2851
|
+
const status = classifyAttachment(ws, check);
|
|
2852
|
+
let suggestion = null;
|
|
2853
|
+
let group = "ok";
|
|
2854
|
+
let action = null;
|
|
2855
|
+
switch (status.kind) {
|
|
2856
|
+
case "attached":
|
|
2857
|
+
if (status.updateAvailable) {
|
|
2858
|
+
group = "attention";
|
|
2859
|
+
action = `synap templates update ${status.slug}`;
|
|
2860
|
+
}
|
|
2861
|
+
break;
|
|
2862
|
+
case "unfinished":
|
|
2863
|
+
group = "attention";
|
|
2864
|
+
action = `synap templates update ${status.slug}`;
|
|
2865
|
+
break;
|
|
2866
|
+
case "no-version":
|
|
2867
|
+
group = "attention";
|
|
2868
|
+
action = `synap templates attach "${ws.workspaceName}"`;
|
|
2869
|
+
break;
|
|
2870
|
+
case "unattached":
|
|
2871
|
+
suggestion = suggestTemplateFor(ws, entries);
|
|
2872
|
+
if (suggestion) {
|
|
2873
|
+
group = "attention";
|
|
2874
|
+
action = `synap templates attach "${ws.workspaceName}"`;
|
|
2875
|
+
}
|
|
2876
|
+
else {
|
|
2877
|
+
group = "unlinked";
|
|
2878
|
+
}
|
|
2879
|
+
break;
|
|
2880
|
+
}
|
|
2881
|
+
// Lifecycle outranks drift: updating a retired template is the wrong move,
|
|
2882
|
+
// moving its data into the replacement is the right one. `unknown` is only
|
|
2883
|
+
// a note — an incomplete catalog read never claims the template is gone.
|
|
2884
|
+
const notice = ws.packageSlug && ws.lifecycle ? lifecycleNotice(ws.packageSlug, ws.lifecycle) : null;
|
|
2885
|
+
if (notice && ws.lifecycle.lifecycle !== "unknown") {
|
|
2886
|
+
group = "attention";
|
|
2887
|
+
action = notice.action;
|
|
2888
|
+
}
|
|
2889
|
+
// An overlay retired with its replacement absent from the space needs the
|
|
2890
|
+
// person too; it never takes the row's action (an overlay is not a space).
|
|
2891
|
+
if ((ws.overlays ?? []).some(overlayNeedsAttention))
|
|
2892
|
+
group = "attention";
|
|
2893
|
+
const overlayNotices = (ws.overlays ?? []).map((o) => ({
|
|
2894
|
+
slug: o.slug,
|
|
2895
|
+
label: overlayNotice(o)?.label ?? null,
|
|
2896
|
+
lifecycle: o.info.lifecycle,
|
|
2897
|
+
}));
|
|
2898
|
+
// Why the last update did not finish — the POD's sentence, printed as is.
|
|
2899
|
+
// Waiting on the person's approval (live, by the pod): the update cannot
|
|
2900
|
+
// finish before it, so the next step is opening the proposal.
|
|
2901
|
+
const waiting = ws.awaitingApproval ?? [];
|
|
2902
|
+
const dataChanges = ws.pendingDataChanges ?? null;
|
|
2903
|
+
const updateProposalId = ws.updateProposal?.id ?? null;
|
|
2904
|
+
// The space's ONE step, by the shared rule and precedence (unfinished >
|
|
2905
|
+
// needs-consent > agent-asked > awaiting-approval > update): the mark and
|
|
2906
|
+
// the door the desktop and the catalog draw for the same row.
|
|
2907
|
+
const step = classifyTemplateStep(ws);
|
|
2908
|
+
// A held update's sentence ("…waits for you to agree…") is what the data
|
|
2909
|
+
// changes block says, door and all: print it once. Any unfinished state's
|
|
2910
|
+
// sentence wins over it at the pod, so it is kept then.
|
|
2911
|
+
const applyNotice = dataChanges && !stepFromApplyState(ws.state, waiting) ? null : ws.applyWarning ?? null;
|
|
2912
|
+
if (applyNotice || (step && step !== "update"))
|
|
2913
|
+
group = "attention";
|
|
2914
|
+
// The door of the leading step. Finishing and agreeing to data changes are
|
|
2915
|
+
// both `templates update` (its prompt lists the changes with their keys; the
|
|
2916
|
+
// health row carries none). An agent's ask: approving it beats a second
|
|
2917
|
+
// update beside it. Proposals the last apply waits on: the one proposal, or
|
|
2918
|
+
// the review queue for several — never no door. `update` keeps the action
|
|
2919
|
+
// above (lifecycle outranks drift).
|
|
2920
|
+
if (ws.packageSlug && (step === "unfinished" || step === "needs-consent")) {
|
|
2921
|
+
action = `synap templates update ${ws.packageSlug}`;
|
|
2922
|
+
}
|
|
2923
|
+
else if (step === "agent-asked" && updateProposalId) {
|
|
2924
|
+
action = `synap open proposal ${updateProposalId}`;
|
|
2925
|
+
}
|
|
2926
|
+
else if (step === "awaiting-approval" || (!ws.state && waiting.length > 0)) {
|
|
2927
|
+
// (An older pod sends no `state`: its live pending list is the signal.)
|
|
2928
|
+
action = proposalsDoor(waiting);
|
|
2929
|
+
}
|
|
2930
|
+
if (!ws.state && waiting.length > 0)
|
|
2931
|
+
group = "attention";
|
|
2932
|
+
return {
|
|
2933
|
+
ws,
|
|
2934
|
+
status,
|
|
2935
|
+
suggestion,
|
|
2936
|
+
group,
|
|
2937
|
+
action,
|
|
2938
|
+
lifecycleNotice: notice,
|
|
2939
|
+
overlayNotices,
|
|
2940
|
+
applyNotice,
|
|
2941
|
+
step,
|
|
2942
|
+
dataChanges,
|
|
2943
|
+
updateProposalId,
|
|
2944
|
+
};
|
|
2945
|
+
}
|
|
2946
|
+
function templateRowToJson(r) {
|
|
2947
|
+
return {
|
|
2948
|
+
workspaceId: r.ws.workspaceId,
|
|
2949
|
+
name: r.ws.workspaceName,
|
|
2950
|
+
domain: r.ws.domain,
|
|
2951
|
+
status: r.status.kind,
|
|
2952
|
+
slug: r.ws.packageSlug,
|
|
2953
|
+
version: r.ws.packageVersion,
|
|
2954
|
+
latestVersion: r.status.kind === "attached" ? r.status.latestVersion ?? null : null,
|
|
2955
|
+
updateAvailable: r.status.kind === "attached" ? r.status.updateAvailable : false,
|
|
2956
|
+
suggestedSlug: r.suggestion?.slug ?? null,
|
|
2957
|
+
action: r.action,
|
|
2958
|
+
awaitingApproval: r.ws.awaitingApproval ?? [],
|
|
2959
|
+
applyWarning: r.ws.applyWarning ?? null,
|
|
2960
|
+
state: r.ws.state ?? null,
|
|
2961
|
+
step: r.step,
|
|
2962
|
+
pendingDataChanges: r.dataChanges,
|
|
2963
|
+
updateProposal: r.updateProposalId ? { id: r.updateProposalId } : null,
|
|
2964
|
+
stepWarnings: r.ws.stepWarnings ?? [],
|
|
2965
|
+
lifecycle: r.ws.lifecycle?.lifecycle ?? null,
|
|
2966
|
+
replacedBy: r.ws.lifecycle?.replacedBy ?? null,
|
|
2967
|
+
retiredReason: r.ws.lifecycle?.retiredReason ?? null,
|
|
2968
|
+
...(r.ws.overlays
|
|
2969
|
+
? {
|
|
2970
|
+
overlays: r.ws.overlays.map((o) => ({
|
|
2971
|
+
slug: o.slug,
|
|
2972
|
+
lifecycle: o.info.lifecycle,
|
|
2973
|
+
replacedBy: o.info.replacedBy,
|
|
2974
|
+
retiredReason: o.info.retiredReason,
|
|
2975
|
+
warning: o.info.warning,
|
|
2976
|
+
replacementHere: o.replacementHere,
|
|
2977
|
+
})),
|
|
2978
|
+
}
|
|
2979
|
+
: {}),
|
|
2980
|
+
};
|
|
2981
|
+
}
|
|
2982
|
+
/** "2 data changes to existing records" — the count the pod sends, or that it could not tell. */
|
|
2983
|
+
function dataChangesCountPhrase(d) {
|
|
2984
|
+
if (d.unreadable && d.count === 0)
|
|
2985
|
+
return "this update changes existing data";
|
|
2986
|
+
return `${d.count} ${d.count === 1 ? "data change" : "data changes"} to existing records`;
|
|
2987
|
+
}
|
|
2988
|
+
export function renderTemplateRow(r) {
|
|
2989
|
+
const raw = r.ws.workspaceName;
|
|
2990
|
+
const name = raw.length > 34 ? raw.slice(0, 33) + "…" : raw;
|
|
2991
|
+
let label = attachmentStatusLabel(r.status);
|
|
2992
|
+
if (r.status.kind === "unattached" && r.suggestion) {
|
|
2993
|
+
label =
|
|
2994
|
+
chalk.yellow("⚠ not attached") +
|
|
2995
|
+
chalk.dim(" · looks like → ") +
|
|
2996
|
+
chalk.cyan(r.suggestion.slug);
|
|
2997
|
+
}
|
|
2998
|
+
// The pod's apply state outranks the stamp-based cell: a stamped space whose
|
|
2999
|
+
// last update failed is not "update available", and one waiting on live
|
|
3000
|
+
// proposals is waiting, not unfinished.
|
|
3001
|
+
if (r.step && r.step !== "update" && r.ws.packageSlug) {
|
|
3002
|
+
const glyph = templateStepGlyph(r.step);
|
|
3003
|
+
const tone = r.step === "awaiting-approval" || r.step === "agent-asked" ? chalk.cyan : chalk.yellow;
|
|
3004
|
+
label = tone(`${glyph} `) + chalk.cyan(r.ws.packageSlug) + tone(` · ${templateStepPhrase(r.step)}`);
|
|
3005
|
+
}
|
|
3006
|
+
const indent = " " + " ".repeat(36);
|
|
3007
|
+
console.log(" " + chalk.bold(name.padEnd(36)) + label);
|
|
3008
|
+
if (r.lifecycleNotice) {
|
|
3009
|
+
const tone = r.ws.lifecycle?.lifecycle === "unknown" ? chalk.dim : chalk.yellow;
|
|
3010
|
+
console.log(" " + " ".repeat(36) + tone(`⚠ ${r.lifecycleNotice.label}`));
|
|
3011
|
+
if (r.ws.lifecycle?.retiredReason && !r.ws.lifecycle.warning)
|
|
3012
|
+
console.log(" " + " ".repeat(36) + chalk.dim(r.ws.lifecycle.retiredReason));
|
|
3013
|
+
}
|
|
3014
|
+
if (r.applyNotice) {
|
|
3015
|
+
// ⏳ only while the space waits on someone's decision (live pending
|
|
3016
|
+
// proposals — an older pod sends no `state`); anything else is the
|
|
3017
|
+
// reader's to act on.
|
|
3018
|
+
const waiting = r.step === "awaiting-approval" || (r.ws.awaitingApproval ?? []).length > 0;
|
|
3019
|
+
console.log(indent + (waiting ? chalk.cyan(`⏳ ${r.applyNotice}`) : chalk.yellow(`⚠ ${r.applyNotice}`)));
|
|
3020
|
+
}
|
|
3021
|
+
if (r.dataChanges) {
|
|
3022
|
+
// Leading, the mark above already says "needs your OK"; behind another
|
|
3023
|
+
// step (a failed apply that also holds data changes) the block names itself.
|
|
3024
|
+
const what = dataChangesCountPhrase(r.dataChanges);
|
|
3025
|
+
console.log(indent +
|
|
3026
|
+
chalk.yellow(r.step === "needs-consent" ? `${what}:` : `⚠ ${TEMPLATE_STEP_LABELS["needs-consent"]}: ${what}`));
|
|
3027
|
+
for (const sentence of r.dataChanges.sentences)
|
|
3028
|
+
console.log(indent + " • " + sentence);
|
|
3029
|
+
if (r.dataChanges.needsPodAdmin)
|
|
3030
|
+
console.log(indent + " " + chalk.dim("Only a pod owner or admin can agree to them."));
|
|
3031
|
+
}
|
|
3032
|
+
for (const w of r.ws.stepWarnings ?? [])
|
|
3033
|
+
console.log(indent + chalk.dim(w));
|
|
3034
|
+
// An agent's ask behind another step keeps its own door on its own line;
|
|
3035
|
+
// leading, the mark says it and the action line below is its door.
|
|
3036
|
+
const agentDoor = r.updateProposalId && r.step !== "agent-asked" ? `synap open proposal ${r.updateProposalId}` : null;
|
|
3037
|
+
if (agentDoor)
|
|
3038
|
+
console.log(indent + chalk.cyan(`→ ${TEMPLATE_STEP_LABELS["agent-asked"]}`) + chalk.dim(` · ${agentDoor}`));
|
|
3039
|
+
if (r.action && r.action !== agentDoor)
|
|
3040
|
+
console.log(indent + chalk.dim(r.action));
|
|
3041
|
+
for (const o of r.overlayNotices) {
|
|
3042
|
+
console.log(" " + " ".repeat(36) + chalk.dim(`↳ overlay ${o.slug}`));
|
|
3043
|
+
if (o.label) {
|
|
3044
|
+
const tone = o.lifecycle === "unknown" ? chalk.dim : chalk.yellow;
|
|
3045
|
+
console.log(" " + " ".repeat(38) + tone(`⚠ ${o.label}`));
|
|
3046
|
+
}
|
|
3047
|
+
}
|
|
3048
|
+
}
|
|
3049
|
+
/**
|
|
3050
|
+
* `synap templates` — grouped, action-ranked view of every workspace's template
|
|
3051
|
+
* link. Default surfaces "Needs attention" first (drifted + unattached-with-
|
|
3052
|
+
* suggestion + unstamped), then up-to-date, then unlinked. `--needs-attention`
|
|
3053
|
+
* shows only the first group; `--json` for scripts. Pure read.
|
|
3054
|
+
*/
|
|
3055
|
+
export async function marketTemplates(opts) {
|
|
3056
|
+
let cfg;
|
|
3057
|
+
try {
|
|
3058
|
+
cfg = await resolveHubConfig(opts);
|
|
3059
|
+
}
|
|
3060
|
+
catch (e) {
|
|
3061
|
+
renderHubError(e);
|
|
3062
|
+
process.exit(1);
|
|
3063
|
+
}
|
|
3064
|
+
const cat = await buildMarketCatalog();
|
|
3065
|
+
// STRICT: this is the consolidated home — "pod unreachable" must never render
|
|
3066
|
+
// as "No workspaces found · stand one up", the exact false-empty lie the
|
|
3067
|
+
// strict fetchers exist to prevent.
|
|
3068
|
+
let attachments;
|
|
3069
|
+
try {
|
|
3070
|
+
attachments = await fetchWorkspaceAttachmentsStrict(cfg);
|
|
3071
|
+
}
|
|
3072
|
+
catch (e) {
|
|
3073
|
+
process.exitCode = 1;
|
|
3074
|
+
if (opts.json) {
|
|
3075
|
+
console.log(JSON.stringify({ needsAttention: [], upToDate: [], unlinked: [], loggedIn: cat.loggedIn, error: "pod-unreachable" }, null, 2));
|
|
3076
|
+
return;
|
|
3077
|
+
}
|
|
3078
|
+
log.error("Couldn't reach your pod to read its workspaces — not saying it's empty.");
|
|
3079
|
+
renderHubError(e);
|
|
3080
|
+
log.hint("Check the pod is up / you're on the right --pod, then retry.");
|
|
3081
|
+
return;
|
|
3082
|
+
}
|
|
3083
|
+
if (attachments.length === 0) {
|
|
3084
|
+
if (opts.json) {
|
|
3085
|
+
console.log(JSON.stringify({ needsAttention: [], upToDate: [], unlinked: [], loggedIn: cat.loggedIn }, null, 2));
|
|
3086
|
+
return;
|
|
3087
|
+
}
|
|
3088
|
+
log.warn("No workspaces found on this pod.");
|
|
3089
|
+
log.hint("Stand one up: synap launch · browse templates: synap market --list");
|
|
3090
|
+
return;
|
|
3091
|
+
}
|
|
3092
|
+
// Drift for the stamped rows via the server-first `computeUpdates`.
|
|
3093
|
+
const stamped = attachments.filter((a) => a.packageSlug);
|
|
3094
|
+
const checks = computeUpdates(stamped.map((a) => ({
|
|
3095
|
+
slug: a.packageSlug,
|
|
3096
|
+
version: a.packageVersion ?? undefined,
|
|
3097
|
+
workspaceId: a.workspaceId,
|
|
3098
|
+
workspaceName: a.workspaceName,
|
|
3099
|
+
latestVersion: a.latestVersion,
|
|
3100
|
+
drifted: a.drifted,
|
|
3101
|
+
})), cat);
|
|
3102
|
+
const checkByWs = new Map();
|
|
3103
|
+
stamped.forEach((a, i) => checkByWs.set(a.workspaceId, checks[i]));
|
|
3104
|
+
const rows = attachments.map((ws) => classifyTemplateRow(ws, checkByWs.get(ws.workspaceId), cat.entries));
|
|
3105
|
+
const attention = rows.filter((r) => r.group === "attention");
|
|
3106
|
+
const ok = rows.filter((r) => r.group === "ok");
|
|
3107
|
+
const unlinked = rows.filter((r) => r.group === "unlinked");
|
|
3108
|
+
if (opts.json) {
|
|
3109
|
+
console.log(JSON.stringify({
|
|
3110
|
+
needsAttention: attention.map(templateRowToJson),
|
|
3111
|
+
upToDate: ok.map(templateRowToJson),
|
|
3112
|
+
unlinked: unlinked.map(templateRowToJson),
|
|
3113
|
+
loggedIn: cat.loggedIn,
|
|
3114
|
+
}, null, 2));
|
|
3115
|
+
return;
|
|
3116
|
+
}
|
|
3117
|
+
// Band — the one-line summary, most-actionable count first.
|
|
3118
|
+
log.blank();
|
|
3119
|
+
const bandParts = [];
|
|
3120
|
+
if (attention.length)
|
|
3121
|
+
bandParts.push(chalk.yellow(`${attention.length} need attention`));
|
|
3122
|
+
bandParts.push(chalk.green(`${ok.length} ${templateStepPhrase("current")}`));
|
|
3123
|
+
if (unlinked.length)
|
|
3124
|
+
bandParts.push(chalk.dim(`${unlinked.length} without a template`));
|
|
3125
|
+
console.log(" " + bandParts.join(chalk.dim(" · ")));
|
|
3126
|
+
log.blank();
|
|
3127
|
+
if (attention.length) {
|
|
3128
|
+
log.heading(" Needs attention");
|
|
3129
|
+
for (const r of attention)
|
|
3130
|
+
renderTemplateRow(r);
|
|
3131
|
+
log.blank();
|
|
3132
|
+
}
|
|
3133
|
+
else {
|
|
3134
|
+
log.success(" Everything attached is up to date.");
|
|
3135
|
+
log.blank();
|
|
3136
|
+
}
|
|
3137
|
+
if (!opts.needsAttention) {
|
|
3138
|
+
if (ok.length) {
|
|
3139
|
+
console.log(chalk.dim(` ${TEMPLATE_STEP_LABELS.current}`));
|
|
3140
|
+
for (const r of ok)
|
|
3141
|
+
renderTemplateRow(r);
|
|
3142
|
+
log.blank();
|
|
3143
|
+
}
|
|
3144
|
+
if (unlinked.length) {
|
|
3145
|
+
console.log(chalk.dim(" No template (attach one if it should have it)"));
|
|
3146
|
+
for (const r of unlinked)
|
|
3147
|
+
renderTemplateRow(r);
|
|
3148
|
+
log.blank();
|
|
3149
|
+
}
|
|
3150
|
+
}
|
|
3151
|
+
// Footer — the one-glance "what can I do from here". Only surfaces verbs that
|
|
3152
|
+
// actually apply to the current state, so it's never a menu of dead options.
|
|
3153
|
+
const driftedCount = attention.filter((r) => (r.status.kind === "attached" && r.status.updateAvailable) ||
|
|
3154
|
+
r.status.kind === "unfinished").length;
|
|
3155
|
+
const attachableCount = attention.filter((r) => r.status.kind !== "attached" && r.status.kind !== "unfinished").length;
|
|
3156
|
+
if (driftedCount > 0 || attachableCount > 0) {
|
|
3157
|
+
log.blank();
|
|
3158
|
+
if (driftedCount > 0)
|
|
3159
|
+
log.hint(`Update all ${driftedCount} drifted: synap templates update all`);
|
|
3160
|
+
if (attachableCount > 0)
|
|
3161
|
+
log.hint(`Connect a workspace: synap templates attach "<name>"`);
|
|
3162
|
+
}
|
|
3163
|
+
if (!cat.loggedIn)
|
|
3164
|
+
log.dim("Not logged in — private template updates aren't visible. Run: synap login");
|
|
3165
|
+
}
|
|
3166
|
+
/**
|
|
3167
|
+
* `synap templates attach <workspace>` — the ONE natural connect command. Takes a
|
|
3168
|
+
* workspace NAME (or id), suggests the template, confirms once. `--slug` forces a
|
|
3169
|
+
* specific template; `--yes` accepts a strong suggestion non-interactively.
|
|
3170
|
+
* Hands the actual reconcile+stamp to the same door `market install --onto` uses.
|
|
3171
|
+
*/
|
|
3172
|
+
export async function templatesAttach(query, opts) {
|
|
3173
|
+
let cfg;
|
|
3174
|
+
try {
|
|
3175
|
+
cfg = await resolveHubConfig(opts);
|
|
3176
|
+
}
|
|
3177
|
+
catch (e) {
|
|
3178
|
+
renderHubError(e);
|
|
3179
|
+
process.exit(1);
|
|
3180
|
+
}
|
|
3181
|
+
const attachments = await fetchWorkspaceAttachments(cfg);
|
|
3182
|
+
const res = resolveWorkspaceQuery(query, attachments);
|
|
3183
|
+
if (res.kind === "none") {
|
|
3184
|
+
if (opts.json) {
|
|
3185
|
+
console.log(JSON.stringify({ error: "workspace-not-found", query }, null, 2));
|
|
3186
|
+
process.exitCode = 1;
|
|
3187
|
+
return;
|
|
3188
|
+
}
|
|
3189
|
+
log.error(`No workspace matching "${query}" on this pod.`);
|
|
3190
|
+
log.hint("See them all: synap templates");
|
|
3191
|
+
process.exit(1);
|
|
3192
|
+
}
|
|
3193
|
+
let ws;
|
|
3194
|
+
if (res.kind === "ambiguous") {
|
|
3195
|
+
const nonInteractive = opts.json || opts.yes || !process.stdout.isTTY;
|
|
3196
|
+
if (nonInteractive) {
|
|
3197
|
+
if (opts.json) {
|
|
3198
|
+
console.log(JSON.stringify({ error: "ambiguous-workspace", query, matches: res.matches.map((m) => ({ workspaceId: m.workspaceId, name: m.workspaceName })) }, null, 2));
|
|
3199
|
+
process.exitCode = 1;
|
|
3200
|
+
return;
|
|
3201
|
+
}
|
|
3202
|
+
log.error(`"${query}" matches ${res.matches.length} workspaces — be more specific:`);
|
|
3203
|
+
for (const m of res.matches) {
|
|
3204
|
+
console.log(" " + chalk.bold(m.workspaceName) + chalk.dim(` ${m.workspaceId}`));
|
|
3205
|
+
}
|
|
3206
|
+
process.exit(1);
|
|
3207
|
+
}
|
|
3208
|
+
const answer = await prompts({
|
|
3209
|
+
type: "select",
|
|
3210
|
+
name: "workspaceId",
|
|
3211
|
+
message: `"${query}" matches several — which one?`,
|
|
3212
|
+
choices: res.matches.map((m) => ({ title: m.workspaceName, value: m.workspaceId })),
|
|
3213
|
+
});
|
|
3214
|
+
if (!answer.workspaceId) {
|
|
3215
|
+
log.warn("Cancelled.");
|
|
3216
|
+
return;
|
|
3217
|
+
}
|
|
3218
|
+
ws = res.matches.find((m) => m.workspaceId === answer.workspaceId);
|
|
3219
|
+
}
|
|
3220
|
+
else {
|
|
3221
|
+
ws = res.ws;
|
|
3222
|
+
}
|
|
3223
|
+
const cat = await buildMarketCatalog();
|
|
3224
|
+
let slug = opts.slug;
|
|
3225
|
+
let note;
|
|
3226
|
+
if (!slug && ws.packageSlug) {
|
|
3227
|
+
slug = ws.packageSlug;
|
|
3228
|
+
note = "its existing attachment";
|
|
3229
|
+
}
|
|
3230
|
+
if (!slug) {
|
|
3231
|
+
const suggestion = suggestTemplateFor(ws, cat.entries);
|
|
3232
|
+
if (suggestion) {
|
|
3233
|
+
if (opts.yes) {
|
|
3234
|
+
// Script path: --yes accepts the suggestion (any confidence) — the ONLY
|
|
3235
|
+
// way a suggestion attaches without an interactive confirm.
|
|
3236
|
+
slug = suggestion.slug;
|
|
3237
|
+
note = `it looks like a ${suggestion.slug} workspace`;
|
|
3238
|
+
}
|
|
3239
|
+
else if (process.stdout.isTTY && !opts.json) {
|
|
3240
|
+
// Interactive: NEVER silent-attach — always confirm (a strong match just
|
|
3241
|
+
// defaults the prompt to yes). Attaching reconciles a template onto a
|
|
3242
|
+
// real workspace; that write must be consented to, not inferred.
|
|
3243
|
+
const answer = await prompts({
|
|
3244
|
+
type: "confirm",
|
|
3245
|
+
name: "ok",
|
|
3246
|
+
message: `"${ws.workspaceName}" looks like the ${chalk.cyan(suggestion.slug)} template (${suggestion.confidence} match). Attach it?`,
|
|
3247
|
+
initial: suggestion.confidence === "strong",
|
|
3248
|
+
});
|
|
3249
|
+
if (!answer.ok) {
|
|
3250
|
+
log.warn(`Cancelled. Pick another: synap templates attach "${ws.workspaceName}" --slug <slug>`);
|
|
3251
|
+
return;
|
|
3252
|
+
}
|
|
3253
|
+
slug = suggestion.slug;
|
|
3254
|
+
}
|
|
3255
|
+
else {
|
|
3256
|
+
// Non-interactive without --yes: don't write on a guess. Surface the
|
|
3257
|
+
// suggestion + how to accept it, and fail (so a script doesn't proceed
|
|
3258
|
+
// thinking it attached).
|
|
3259
|
+
if (opts.json) {
|
|
3260
|
+
console.log(JSON.stringify({ error: "suggestion-needs-confirmation", workspaceId: ws.workspaceId, suggestedSlug: suggestion.slug, confidence: suggestion.confidence, hint: "re-run with --yes to accept, or --slug <slug>" }, null, 2));
|
|
3261
|
+
}
|
|
3262
|
+
else {
|
|
3263
|
+
log.warn(`"${ws.workspaceName}" looks like the ${chalk.cyan(suggestion.slug)} template (${suggestion.confidence}). Not attaching without confirmation.`);
|
|
3264
|
+
log.hint(`Accept it: synap templates attach "${ws.workspaceName}" --yes · or pick: --slug <slug>`);
|
|
3265
|
+
}
|
|
3266
|
+
process.exitCode = 1;
|
|
3267
|
+
return;
|
|
3268
|
+
}
|
|
3269
|
+
}
|
|
3270
|
+
}
|
|
3271
|
+
if (!slug) {
|
|
3272
|
+
if (opts.json) {
|
|
3273
|
+
console.log(JSON.stringify({ error: "no-template-match", workspaceId: ws.workspaceId, name: ws.workspaceName }, null, 2));
|
|
3274
|
+
process.exitCode = 1;
|
|
3275
|
+
return;
|
|
3276
|
+
}
|
|
3277
|
+
log.error(`Couldn't tell which template fits "${ws.workspaceName}".`);
|
|
3278
|
+
const templates = cat.entries.filter((e) => typeOf(e) === "workspace").slice(0, 15);
|
|
3279
|
+
if (templates.length > 0) {
|
|
3280
|
+
log.hint("Available templates:");
|
|
3281
|
+
for (const e of templates) {
|
|
3282
|
+
console.log(" " + chalk.cyan(e.slug.padEnd(28)) + chalk.dim(e.name));
|
|
3283
|
+
}
|
|
3284
|
+
}
|
|
3285
|
+
log.hint(`Re-run with: synap templates attach "${ws.workspaceName}" --slug <slug>`);
|
|
3286
|
+
process.exit(1);
|
|
3287
|
+
}
|
|
3288
|
+
if (!opts.json && note)
|
|
3289
|
+
log.info(`Attaching ${chalk.cyan(slug)} — ${note}.`);
|
|
3290
|
+
await marketInstall(slug, {
|
|
3291
|
+
onto: ws.workspaceId,
|
|
3292
|
+
project: opts.project,
|
|
3293
|
+
timeout: opts.timeout,
|
|
3294
|
+
json: opts.json,
|
|
3295
|
+
podUrl: opts.podUrl,
|
|
3296
|
+
apiKey: opts.apiKey,
|
|
3297
|
+
});
|
|
1367
3298
|
}
|
|
1368
3299
|
//# sourceMappingURL=market.js.map
|