@synap-core/cli 1.11.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 +218 -168
- 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 +1 -13
- 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 +7 -1
- package/dist/commands/context.js.map +1 -1
- package/dist/commands/data-extra.d.ts +34 -0
- package/dist/commands/data-extra.js +193 -23
- package/dist/commands/data-extra.js.map +1 -1
- package/dist/commands/data.d.ts +120 -2
- package/dist/commands/data.js +293 -20
- package/dist/commands/data.js.map +1 -1
- package/dist/commands/diagnose.d.ts +66 -4
- package/dist/commands/diagnose.js +285 -28
- package/dist/commands/diagnose.js.map +1 -1
- package/dist/commands/doc.d.ts +11 -4
- package/dist/commands/doc.js +39 -14
- package/dist/commands/doc.js.map +1 -1
- package/dist/commands/doctor.d.ts +112 -3
- package/dist/commands/doctor.js +657 -32
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/explain.js +5 -5
- package/dist/commands/explain.js.map +1 -1
- package/dist/commands/import.d.ts +368 -13
- package/dist/commands/import.js +1064 -54
- 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 +22 -6
- package/dist/commands/knowledge.js +117 -55
- package/dist/commands/knowledge.js.map +1 -1
- package/dist/commands/lens.d.ts +37 -1
- package/dist/commands/lens.js +192 -71
- package/dist/commands/lens.js.map +1 -1
- package/dist/commands/market-authoring.d.ts +29 -2
- package/dist/commands/market-authoring.js +348 -7
- package/dist/commands/market-authoring.js.map +1 -1
- 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 +400 -16
- package/dist/commands/market.js +1802 -146
- package/dist/commands/market.js.map +1 -1
- package/dist/commands/mcp.d.ts +60 -25
- package/dist/commands/mcp.js +251 -64
- 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 +5 -0
- package/dist/commands/pods.js +97 -116
- package/dist/commands/pods.js.map +1 -1
- package/dist/commands/proposals-actions.d.ts +62 -0
- package/dist/commands/proposals-actions.js +143 -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 +478 -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 +16 -0
- 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 +41 -3
- package/dist/commands/upload.js +120 -18
- package/dist/commands/upload.js.map +1 -1
- 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 +900 -180
- 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 +6 -5
- package/dist/lib/capture-lane.js.map +1 -1
- package/dist/lib/capture-structure.d.ts +230 -11
- package/dist/lib/capture-structure.js +251 -13
- package/dist/lib/capture-structure.js.map +1 -1
- 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 +167 -11
- package/dist/lib/cp-packages.js +253 -21
- 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 +29 -8
- package/dist/lib/hub-client.js +328 -55
- package/dist/lib/hub-client.js.map +1 -1
- 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 +66 -112
- package/dist/lib/pod.js +134 -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 +4 -0
- package/dist/lib/template-file.js.map +1 -1
- 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 +23 -17
- package/skills/README.md +12 -10
- package/skills/agent-os/SKILL.md +214 -99
- package/skills/manifest.json +1 -1
- package/skills/onboard/SKILL.md +55 -26
- package/skills/synap/README.md +5 -8
- package/skills/synap/SKILL.md +1086 -283
- 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 +5 -0
- package/skills/synap/cli-operations.md +24 -18
- package/skills/synap/concepts.md +28 -0
- package/skills/synap/content-os.md +10 -1
- package/skills/synap/creative-loop.md +15 -10
- package/skills/synap/crm.md +21 -52
- 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 +17 -15
- 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 +4 -5
- package/skills/synap/workspace-design.md +14 -10
- package/skills/synap/writes.md +97 -24
- package/skills/synap/writing-titles.md +63 -0
- package/skills/synap-app/SKILL.md +2 -2
- package/skills/synap-app/_skill.yaml +1 -1
- package/skills/synap-app/install-commands.md +1 -1
- 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 +306 -16
- package/skills/synap-market/_skill.yaml +1 -1
- package/skills/synap-market/author-and-publish.md +145 -8
- package/skills/synap-market/governance-and-catalog.md +7 -0
- package/skills/synap-market/install.md +79 -1
- package/skills/synap-market/overview.md +74 -6
- 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/commands/template.d.ts +0 -42
- package/dist/commands/template.js +0 -191
- package/dist/commands/template.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/node_modules/@synap/hub-rest-client/dist/index.cjs +0 -1261
- package/node_modules/@synap/hub-rest-client/dist/index.d.cts +0 -1647
- package/node_modules/@synap/hub-rest-client/dist/index.d.ts +0 -1647
- package/node_modules/@synap/hub-rest-client/dist/index.js +0 -1231
- package/node_modules/@synap/hub-rest-client/package.json +0 -45
- package/node_modules/@synap/hub-rest-client/src/client.ts +0 -1631
- package/node_modules/@synap/hub-rest-client/src/errors.ts +0 -30
- package/node_modules/@synap/hub-rest-client/src/index.ts +0 -153
- package/node_modules/@synap/hub-rest-client/src/setup.ts +0 -133
- package/node_modules/@synap/hub-rest-client/src/types.ts +0 -1299
package/dist/commands/market.js
CHANGED
|
@@ -23,19 +23,22 @@
|
|
|
23
23
|
*/
|
|
24
24
|
import ora from "ora";
|
|
25
25
|
import chalk from "chalk";
|
|
26
|
-
import { assembleCatalog, computeTemplateUpdates, isHashVersion, listPublicTemplates, listWorkspaceTemplates, toPackageDefinition, layerTemplateGraph, bundledEdgesOf, groupByDomain, } from "@synap-core/workspace-templates";
|
|
26
|
+
import { assembleCatalog, computeTemplateUpdates, isHashVersion, listPublicTemplates, listWorkspaceTemplates, getWorkspaceTemplate, mergeCatalog, toPackageDefinition, layerTemplateGraph, bundledEdgesOf, groupByDomain, } from "@synap-core/workspace-templates";
|
|
27
27
|
import { log } from "../utils/logger.js";
|
|
28
28
|
import { resolveHubConfig, hubPost, renderHubError, HubError } from "../lib/hub-client.js";
|
|
29
29
|
import { fetchProjects } from "../lib/project.js";
|
|
30
30
|
import { writeGovernance } from "../lib/capture-lane.js";
|
|
31
31
|
import { renderNextSteps, FLOW } from "../lib/next-steps.js";
|
|
32
32
|
import { getStoredToken, isTokenLocallyExpired } from "../lib/auth.js";
|
|
33
|
-
import
|
|
34
|
-
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";
|
|
35
36
|
import { isSuite } from "../lib/suite.js";
|
|
37
|
+
import { TEMPLATE_STEP_LABELS, classifyTemplateStep, proposalsDoor, stepFromApplyState, templateStepGlyph, templateStepPhrase, } from "../lib/template-steps.js";
|
|
36
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,89 @@ 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
|
+
];
|
|
119
231
|
/**
|
|
120
232
|
* Map a CP package `category` (what {@link typeOf} returns) → the pod
|
|
121
233
|
* `market.install` verb's `kind` vocabulary, or `null` when the verb can't
|
|
@@ -137,10 +249,53 @@ export function marketInstallKind(type) {
|
|
|
137
249
|
return "automation";
|
|
138
250
|
case "template":
|
|
139
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";
|
|
140
258
|
default:
|
|
141
259
|
return null;
|
|
142
260
|
}
|
|
143
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
|
+
}
|
|
144
299
|
/**
|
|
145
300
|
* Client-side filter. The CP browse route filters REMOTE rows server-side, but
|
|
146
301
|
* `assembleCatalog` always adds the BUNDLED (all-`workspace`) templates — so a
|
|
@@ -163,6 +318,17 @@ function matchesFilters(entry, filters) {
|
|
|
163
318
|
function clip(s, n) {
|
|
164
319
|
return s.length <= n ? s : s.slice(0, n - 1).trimEnd() + "…";
|
|
165
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
|
+
}
|
|
166
332
|
/**
|
|
167
333
|
* Print an honest per-dependency seed summary — no more silent "✓ ready" when
|
|
168
334
|
* a dependency's capabilities/playbooks failed to seed. Prefers the top-level
|
|
@@ -170,6 +336,23 @@ function clip(s, n) {
|
|
|
170
336
|
* back to re-deriving from the per-dependency `dependencies[]` array on older
|
|
171
337
|
* pod builds. No-op when there's nothing to report.
|
|
172
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
|
+
}
|
|
173
356
|
function printSeedOutcomes(dependencies, seedSummary) {
|
|
174
357
|
if (seedSummary) {
|
|
175
358
|
if (seedSummary.attempted === 0)
|
|
@@ -218,11 +401,61 @@ function hasSeedFailure(dependencies, seedSummary) {
|
|
|
218
401
|
* not that the install was genuinely a no-op (the observed `market install
|
|
219
402
|
* the-arch` → "already up to date" bug).
|
|
220
403
|
*/
|
|
221
|
-
function detectStampContradiction(ws, seedSummary, remoteVersionSent, wasUnstamped) {
|
|
222
|
-
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)
|
|
223
418
|
return false;
|
|
224
419
|
return ws?.outcome === "unchanged" || (ws?.outcome === undefined && seedSummary === undefined);
|
|
225
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
|
+
}
|
|
226
459
|
/**
|
|
227
460
|
* Classify one `/packages/apply` response into the honest verdict both
|
|
228
461
|
* `marketInstall` and `applyOnePackage` print (and both put in `--json`).
|
|
@@ -232,8 +465,11 @@ function detectStampContradiction(ws, seedSummary, remoteVersionSent, wasUnstamp
|
|
|
232
465
|
* already-versioned slugs, so it always passes `false`).
|
|
233
466
|
*/
|
|
234
467
|
export function classifyApplyResult(entry, ws, dependencies, seedSummary, ctx) {
|
|
235
|
-
|
|
236
|
-
|
|
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)) {
|
|
237
473
|
return {
|
|
238
474
|
status: "stamp-contradiction",
|
|
239
475
|
warnings: [
|
|
@@ -248,8 +484,13 @@ export function classifyApplyResult(entry, ws, dependencies, seedSummary, ctx) {
|
|
|
248
484
|
if (ws?.outcome === "unchanged")
|
|
249
485
|
return { status: "unchanged", warnings: [] };
|
|
250
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".
|
|
251
492
|
const warnings = [
|
|
252
|
-
`${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}`,
|
|
253
494
|
];
|
|
254
495
|
return { status: seedFailed ? "composed-with-failures" : "composed", warnings };
|
|
255
496
|
}
|
|
@@ -257,6 +498,25 @@ export function classifyApplyResult(entry, ws, dependencies, seedSummary, ctx) {
|
|
|
257
498
|
return { status: "legacy-unknown", warnings: [] };
|
|
258
499
|
return { status: "no-workspace", warnings: [] };
|
|
259
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
|
+
}
|
|
260
520
|
/** Render a `classifyApplyResult` verdict — the ONE honest-text door for both `marketInstall` and `applyOnePackage`. */
|
|
261
521
|
export function printApplyVerdict(entry, verdict) {
|
|
262
522
|
switch (verdict.status) {
|
|
@@ -264,13 +524,13 @@ export function printApplyVerdict(entry, verdict) {
|
|
|
264
524
|
log.success(`Installed ${entry.name}.`);
|
|
265
525
|
return;
|
|
266
526
|
case "installed-with-failures":
|
|
267
|
-
log.warn(`Installed ${entry.name}
|
|
527
|
+
log.warn(`Installed ${entry.name}, but ${templateStepPhrase("unfinished")}: some capabilities failed (see below).`);
|
|
268
528
|
return;
|
|
269
529
|
case "updated":
|
|
270
530
|
log.success(`Updated ${entry.name}.`);
|
|
271
531
|
return;
|
|
272
532
|
case "updated-with-failures":
|
|
273
|
-
log.warn(`Updated ${entry.name}
|
|
533
|
+
log.warn(`Updated ${entry.name}, but ${templateStepPhrase("unfinished")}: some capabilities failed (see below).`);
|
|
274
534
|
return;
|
|
275
535
|
case "unchanged":
|
|
276
536
|
log.info(`• ${entry.name} is already up to date.`);
|
|
@@ -280,7 +540,15 @@ export function printApplyVerdict(entry, verdict) {
|
|
|
280
540
|
log.hint(verdict.warnings[0]);
|
|
281
541
|
return;
|
|
282
542
|
case "composed-with-failures":
|
|
283
|
-
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.`);
|
|
284
552
|
log.hint(verdict.warnings[0]);
|
|
285
553
|
return;
|
|
286
554
|
case "stamp-contradiction":
|
|
@@ -463,7 +731,7 @@ function renderCatalog(entries, cat) {
|
|
|
463
731
|
}
|
|
464
732
|
const types = [...byType.keys()].sort((a, b) => (order.indexOf(a) + 1 || 99) - (order.indexOf(b) + 1 || 99));
|
|
465
733
|
for (const t of types) {
|
|
466
|
-
log.heading(` ${
|
|
734
|
+
log.heading(` ${kindHeading(t)}`);
|
|
467
735
|
log.blank();
|
|
468
736
|
for (const e of byType.get(t))
|
|
469
737
|
row(e);
|
|
@@ -578,7 +846,41 @@ export async function marketInstall(slug, opts) {
|
|
|
578
846
|
log.hint("Browse what's available: synap market --list");
|
|
579
847
|
process.exit(1);
|
|
580
848
|
}
|
|
581
|
-
|
|
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
|
+
}
|
|
582
884
|
// A CAPABILITY installs through the SAME door as `cap add` — one code path, so
|
|
583
885
|
// the two commands can never diverge. That door resolves the template by key
|
|
584
886
|
// (so opt-in / syncByDefault:false caps like unipile-linkedin work — the
|
|
@@ -612,13 +914,36 @@ export async function marketInstall(slug, opts) {
|
|
|
612
914
|
renderHubError(e);
|
|
613
915
|
process.exit(1);
|
|
614
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
|
+
}
|
|
615
935
|
const s = opts.json ? null : ora(`Installing ${entry.name}…`).start();
|
|
616
936
|
let res;
|
|
617
937
|
try {
|
|
618
|
-
// Pod-wide install: no workspaceId (a capability/cell
|
|
619
|
-
// the pod).
|
|
620
|
-
//
|
|
621
|
-
|
|
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));
|
|
622
947
|
}
|
|
623
948
|
catch (e) {
|
|
624
949
|
s?.stop();
|
|
@@ -641,18 +966,85 @@ export async function marketInstall(slug, opts) {
|
|
|
641
966
|
}
|
|
642
967
|
s?.stop();
|
|
643
968
|
// The execute door wraps the verb's return as { status:"run", result: <MarketInstallOutcome> }.
|
|
644
|
-
//
|
|
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.
|
|
645
984
|
const outcome = (res.result ?? res);
|
|
646
|
-
const
|
|
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";
|
|
647
996
|
if (opts.json) {
|
|
648
997
|
console.log(JSON.stringify({ slug, kind, outcome: status, result: outcome }, null, 2));
|
|
649
998
|
return;
|
|
650
999
|
}
|
|
651
1000
|
if (status === "proposed") {
|
|
652
1001
|
const proposalId = outcome.proposalId ? String(outcome.proposalId) : undefined;
|
|
653
|
-
|
|
1002
|
+
const reviewUrl = outcome.reviewUrl ? String(outcome.reviewUrl) : undefined;
|
|
1003
|
+
log.info(`${entry.name} — proposed (under review, NOT installed yet).`);
|
|
654
1004
|
if (proposalId)
|
|
655
|
-
log.hint(`
|
|
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;
|
|
1015
|
+
}
|
|
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}).`);
|
|
656
1048
|
return;
|
|
657
1049
|
}
|
|
658
1050
|
log.success(`Installed ${entry.name}.`);
|
|
@@ -686,25 +1078,17 @@ export async function marketInstall(slug, opts) {
|
|
|
686
1078
|
// Build the install payload — bundled (offline, byte-identical to the CP
|
|
687
1079
|
// definition) when available, else the authed CP definition.
|
|
688
1080
|
const cpToken = getStoredToken()?.token;
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
log.error(`Couldn't fetch "${entry.name}"'s definition from the control plane.`);
|
|
697
|
-
log.hint(cat.loggedIn ? "" : "It may be private — try: synap login");
|
|
698
|
-
process.exit(1);
|
|
699
|
-
}
|
|
700
|
-
pkg = def;
|
|
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);
|
|
701
1088
|
}
|
|
702
|
-
|
|
703
|
-
//
|
|
704
|
-
|
|
705
|
-
const remoteVersion = cat.remoteVersionBySlug.get(slug);
|
|
706
|
-
if (remoteVersion)
|
|
707
|
-
pkg._meta = { ...(pkg._meta ?? {}), version: remoteVersion };
|
|
1089
|
+
const pkg = payload.pkg;
|
|
1090
|
+
// Only a catalog-sourced install can predict the stamp the pod will record.
|
|
1091
|
+
const remoteVersion = payload.catalogVersion;
|
|
708
1092
|
// ── --dry-run: write-free preflight of the CREATE path. ───────────────────
|
|
709
1093
|
// Runs the pod's REAL resolver (write-free) so a template author sees
|
|
710
1094
|
// profileKind conflicts and other resolution failures BEFORE installing.
|
|
@@ -749,18 +1133,27 @@ export async function marketInstall(slug, opts) {
|
|
|
749
1133
|
}
|
|
750
1134
|
log.info(`${entry.name} — proposed (under review, not live yet).`);
|
|
751
1135
|
if (proposalId)
|
|
752
|
-
log.hint(`
|
|
1136
|
+
log.hint(`Review and approve: synap open proposal ${proposalId}`);
|
|
753
1137
|
renderNextSteps(steps);
|
|
754
1138
|
return;
|
|
755
1139
|
}
|
|
756
1140
|
const ws = res.workspace;
|
|
757
1141
|
const dependencies = res.dependencies;
|
|
758
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
|
+
}
|
|
759
1151
|
const steps = FLOW.afterMarketInstall({ slug, proposed: false, projectName });
|
|
760
1152
|
s?.stop();
|
|
761
|
-
const verdict =
|
|
1153
|
+
const verdict = classifyApplyResponse(entry, res, {
|
|
762
1154
|
remoteVersionSent: remoteVersion,
|
|
763
1155
|
wasUnstamped,
|
|
1156
|
+
stampVerified,
|
|
764
1157
|
});
|
|
765
1158
|
if (opts.json) {
|
|
766
1159
|
console.log(JSON.stringify({
|
|
@@ -789,7 +1182,7 @@ export async function marketInstall(slug, opts) {
|
|
|
789
1182
|
break;
|
|
790
1183
|
case "installed-with-failures":
|
|
791
1184
|
case "updated-with-failures":
|
|
792
|
-
log.warn(`Reconciled ${entry.name} onto ${wsLabel}
|
|
1185
|
+
log.warn(`Reconciled ${entry.name} onto ${wsLabel}, but ${templateStepPhrase("unfinished")}: some capabilities failed (see below).`);
|
|
793
1186
|
break;
|
|
794
1187
|
case "unchanged":
|
|
795
1188
|
log.info(`• ${entry.name} is already up to date on ${wsLabel}.`);
|
|
@@ -802,6 +1195,9 @@ export async function marketInstall(slug, opts) {
|
|
|
802
1195
|
printApplyVerdict(entry, verdict);
|
|
803
1196
|
}
|
|
804
1197
|
printSeedOutcomes(dependencies, seedSummary);
|
|
1198
|
+
printLayerOutcomes(ws?.layers);
|
|
1199
|
+
for (const line of deferredPlacementLines(res))
|
|
1200
|
+
log.info(line);
|
|
805
1201
|
if (ws?.outcome || ws?.status === "composed" || ws?.status === "created")
|
|
806
1202
|
renderNextSteps(steps);
|
|
807
1203
|
}
|
|
@@ -813,18 +1209,103 @@ export async function marketInstall(slug, opts) {
|
|
|
813
1209
|
}
|
|
814
1210
|
}
|
|
815
1211
|
/**
|
|
816
|
-
*
|
|
817
|
-
* (
|
|
818
|
-
*
|
|
819
|
-
*
|
|
820
|
-
*
|
|
821
|
-
*
|
|
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".
|
|
822
1287
|
*/
|
|
823
1288
|
export function computeUpdates(installedTemplates, cat) {
|
|
824
|
-
|
|
825
|
-
|
|
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
|
+
: [];
|
|
826
1295
|
const baseBySlug = new Map(base.map((b) => [b.slug, b]));
|
|
827
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) ──
|
|
828
1309
|
if (!t.version) {
|
|
829
1310
|
return {
|
|
830
1311
|
slug: t.slug,
|
|
@@ -832,6 +1313,7 @@ export function computeUpdates(installedTemplates, cat) {
|
|
|
832
1313
|
latestVersion: cat.remoteVersionBySlug.get(t.slug),
|
|
833
1314
|
updateAvailable: false,
|
|
834
1315
|
noVersionInfo: true,
|
|
1316
|
+
kind: t.kind,
|
|
835
1317
|
};
|
|
836
1318
|
}
|
|
837
1319
|
const b = baseBySlug.get(t.slug);
|
|
@@ -842,18 +1324,24 @@ export function computeUpdates(installedTemplates, cat) {
|
|
|
842
1324
|
latestVersion: remoteVersion,
|
|
843
1325
|
updateAvailable: remoteVersion !== t.version,
|
|
844
1326
|
noVersionInfo: false,
|
|
1327
|
+
kind: t.kind,
|
|
845
1328
|
};
|
|
846
1329
|
}
|
|
847
|
-
return { ...b, noVersionInfo: false };
|
|
1330
|
+
return { ...b, noVersionInfo: false, kind: t.kind };
|
|
848
1331
|
});
|
|
849
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
|
+
}
|
|
850
1338
|
/**
|
|
851
1339
|
* Apply one package via the SAME door `marketInstall` uses
|
|
852
1340
|
* (`POST /packages/apply`) — for an already-installed slug this re-runs the
|
|
853
1341
|
* pod's idempotent-create path, which reconciles when the pod build supports
|
|
854
1342
|
* it (see the `applyOnePackage` caveat below for what "created" means here).
|
|
855
1343
|
*/
|
|
856
|
-
async function applyOnePackage(entry, cat, cfg, opts, applyTimeoutMs = APPLY_TIMEOUT_MS) {
|
|
1344
|
+
async function applyOnePackage(entry, cat, cfg, opts, applyTimeoutMs = APPLY_TIMEOUT_MS, extra = {}) {
|
|
857
1345
|
if (cat.lockedSlugs.has(entry.slug)) {
|
|
858
1346
|
const tier = cat.tierBySlug.get(entry.slug)?.requiredTier;
|
|
859
1347
|
if (!opts.json)
|
|
@@ -861,27 +1349,19 @@ async function applyOnePackage(entry, cat, cfg, opts, applyTimeoutMs = APPLY_TIM
|
|
|
861
1349
|
return { slug: entry.slug, outcome: "locked", status: "locked", warnings: [] };
|
|
862
1350
|
}
|
|
863
1351
|
const cpToken = getStoredToken()?.token;
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
if (!opts.json) {
|
|
872
|
-
log.error(`Couldn't fetch "${entry.name}"'s definition from the control plane.`);
|
|
873
|
-
if (!cat.loggedIn)
|
|
874
|
-
log.hint("It may be private — try: synap login");
|
|
875
|
-
}
|
|
876
|
-
return { slug: entry.slug, outcome: "fetch-failed", status: "fetch-failed", warnings: [] };
|
|
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");
|
|
877
1359
|
}
|
|
878
|
-
|
|
1360
|
+
return { slug: entry.slug, outcome: "fetch-failed", status: "fetch-failed", warnings: [] };
|
|
879
1361
|
}
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
if (remoteVersion)
|
|
884
|
-
pkg._meta = { ...(pkg._meta ?? {}), version: remoteVersion };
|
|
1362
|
+
const pkg = extra.acceptDataChanges && extra.acceptDataChanges.length > 0
|
|
1363
|
+
? { ...payload.pkg, acceptDataChanges: extra.acceptDataChanges }
|
|
1364
|
+
: payload.pkg;
|
|
885
1365
|
const s = opts.json ? null : ora(`Updating ${entry.name}…`).start();
|
|
886
1366
|
try {
|
|
887
1367
|
const res = (await hubPost("/packages/apply", pkg, cfg, applyTimeoutMs));
|
|
@@ -891,7 +1371,7 @@ async function applyOnePackage(entry, cat, cfg, opts, applyTimeoutMs = APPLY_TIM
|
|
|
891
1371
|
if (!opts.json) {
|
|
892
1372
|
log.info(`${entry.name} — update proposed (under review, not live yet).`);
|
|
893
1373
|
if (proposalId)
|
|
894
|
-
log.hint(`
|
|
1374
|
+
log.hint(`Review and approve: synap open proposal ${proposalId}`);
|
|
895
1375
|
}
|
|
896
1376
|
return { slug: entry.slug, outcome: "proposed", status: "proposed", warnings: [] };
|
|
897
1377
|
}
|
|
@@ -903,13 +1383,16 @@ async function applyOnePackage(entry, cat, cfg, opts, applyTimeoutMs = APPLY_TIM
|
|
|
903
1383
|
// which by construction means it already carried a recorded version. The
|
|
904
1384
|
// stamp-contradiction check is therefore a no-op here by design; it's
|
|
905
1385
|
// `marketInstall`'s job to catch it on a first-time/unstamped install.
|
|
906
|
-
const verdict =
|
|
907
|
-
remoteVersionSent:
|
|
1386
|
+
const verdict = classifyApplyResponse(entry, res, {
|
|
1387
|
+
remoteVersionSent: payload.catalogVersion,
|
|
908
1388
|
wasUnstamped: false,
|
|
909
1389
|
});
|
|
910
1390
|
if (!opts.json) {
|
|
911
1391
|
printApplyVerdict(entry, verdict);
|
|
912
1392
|
printSeedOutcomes(dependencies, seedSummary);
|
|
1393
|
+
printLayerOutcomes(ws?.layers);
|
|
1394
|
+
for (const line of deferredPlacementLines(res))
|
|
1395
|
+
log.info(line);
|
|
913
1396
|
}
|
|
914
1397
|
return {
|
|
915
1398
|
slug: entry.slug,
|
|
@@ -922,10 +1405,33 @@ async function applyOnePackage(entry, cat, cfg, opts, applyTimeoutMs = APPLY_TIM
|
|
|
922
1405
|
}
|
|
923
1406
|
catch (e) {
|
|
924
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
|
+
}
|
|
925
1427
|
const timedOut = e instanceof HubNetworkError && e.code === "TIMEOUT";
|
|
1428
|
+
const refusal = dataChangeRefusalMessage(e);
|
|
926
1429
|
if (!opts.json) {
|
|
927
1430
|
log.error(`${entry.name} update failed`);
|
|
928
|
-
|
|
1431
|
+
if (refusal)
|
|
1432
|
+
log.warn(refusal);
|
|
1433
|
+
else
|
|
1434
|
+
renderHubError(e);
|
|
929
1435
|
}
|
|
930
1436
|
return {
|
|
931
1437
|
slug: entry.slug,
|
|
@@ -936,19 +1442,120 @@ async function applyOnePackage(entry, cat, cfg, opts, applyTimeoutMs = APPLY_TIM
|
|
|
936
1442
|
};
|
|
937
1443
|
}
|
|
938
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
|
+
}
|
|
939
1510
|
export async function marketUpdate(slugsArg, opts) {
|
|
940
1511
|
const applyTimeoutMs = parseApplyTimeoutMs(opts.timeout);
|
|
941
|
-
|
|
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;
|
|
942
1542
|
if (installedTemplates.length === 0) {
|
|
943
1543
|
if (opts.json) {
|
|
944
|
-
console.log(JSON.stringify({ checks: [], loggedIn: cat.loggedIn }, null, 2));
|
|
1544
|
+
console.log(JSON.stringify({ checks: [], loggedIn: cat.loggedIn, degraded, ...(degradedNote ? { degradedNote } : {}) }, null, 2));
|
|
945
1545
|
return;
|
|
946
1546
|
}
|
|
947
1547
|
log.warn("No installed packages found on this pod.");
|
|
1548
|
+
if (degradedNote)
|
|
1549
|
+
log.dim(degradedNote);
|
|
948
1550
|
log.hint("Install one: synap market install <slug>");
|
|
949
1551
|
return;
|
|
950
1552
|
}
|
|
951
|
-
|
|
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));
|
|
952
1559
|
const entryBySlug = new Map(cat.entries.map((e) => [e.slug, e]));
|
|
953
1560
|
const explicitSlugs = !!slugsArg && slugsArg.length > 0;
|
|
954
1561
|
let unknownSlugs = [];
|
|
@@ -970,7 +1577,26 @@ export async function marketUpdate(slugsArg, opts) {
|
|
|
970
1577
|
}
|
|
971
1578
|
checks = checks.filter((c) => validSlugs.includes(c.slug));
|
|
972
1579
|
}
|
|
973
|
-
|
|
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));
|
|
974
1600
|
const noVersionInfo = checks.filter((c) => c.noVersionInfo);
|
|
975
1601
|
// Private templates resolve only via the authed CP path — an update check
|
|
976
1602
|
// that found NOTHING while logged out is ambiguous (could be genuinely
|
|
@@ -980,24 +1606,86 @@ export async function marketUpdate(slugsArg, opts) {
|
|
|
980
1606
|
log.dim("Not logged in — private template updates aren't visible. Run: synap login");
|
|
981
1607
|
}
|
|
982
1608
|
// --dry-run is a NAMED alias for the "no --yes" preview path — forces the
|
|
983
|
-
// preview even when --yes or explicit slugs would otherwise trigger apply.
|
|
984
|
-
const shouldApply = !opts.dryRun && (explicitSlugs || opts.yes);
|
|
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
|
+
}
|
|
985
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
|
+
}
|
|
986
1664
|
log.heading(" Installed packages");
|
|
987
1665
|
log.blank();
|
|
988
1666
|
for (const c of checks) {
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
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);
|
|
994
1676
|
console.log(" " + chalk.cyan(c.slug.padEnd(28)) + label);
|
|
995
1677
|
}
|
|
996
1678
|
log.blank();
|
|
997
1679
|
if (noVersionInfo.length > 0) {
|
|
998
1680
|
log.dim(`${noVersionInfo.length} installed without a version stamp — can't check ${noVersionInfo.length === 1 ? "it" : "them"} for updates.`);
|
|
999
1681
|
}
|
|
1682
|
+
if (degradedNote)
|
|
1683
|
+
log.dim(degradedNote);
|
|
1000
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]));
|
|
1001
1689
|
if (withUpdates.length === 0) {
|
|
1002
1690
|
// "Nothing to update" is only honest when every installed row was
|
|
1003
1691
|
// actually CHECKED. Rows the pod can't version-check are "couldn't be
|
|
@@ -1011,6 +1699,7 @@ export async function marketUpdate(slugsArg, opts) {
|
|
|
1011
1699
|
applied: [],
|
|
1012
1700
|
status: noVersionInfo.length > 0 ? "partial" : "clean",
|
|
1013
1701
|
uncheckable: noVersionInfo.map((c) => c.slug),
|
|
1702
|
+
templateLifecycle: lifecycleJson,
|
|
1014
1703
|
}, null, 2));
|
|
1015
1704
|
}
|
|
1016
1705
|
else if (noVersionInfo.length > 0) {
|
|
@@ -1019,44 +1708,118 @@ export async function marketUpdate(slugsArg, opts) {
|
|
|
1019
1708
|
chalk.yellow(`⚠ ${upToDate} up to date · ${noVersionInfo.length} couldn't be checked`));
|
|
1020
1709
|
log.hint("Run 'synap market installed' for why — unstamped install (reinstall to fix) or a private template while logged out (synap login).");
|
|
1021
1710
|
}
|
|
1711
|
+
else if (leaving.length > 0) {
|
|
1712
|
+
log.info("No updates to apply.");
|
|
1713
|
+
}
|
|
1022
1714
|
else {
|
|
1023
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");
|
|
1024
1721
|
}
|
|
1025
1722
|
return;
|
|
1026
1723
|
}
|
|
1027
1724
|
if (!shouldApply) {
|
|
1028
1725
|
const steps = FLOW.afterMarketUpdateCheck(withUpdates.map((c) => c.slug));
|
|
1029
1726
|
if (opts.json) {
|
|
1030
|
-
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));
|
|
1031
1736
|
return;
|
|
1032
1737
|
}
|
|
1033
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
|
+
}
|
|
1034
1742
|
renderNextSteps(steps);
|
|
1035
1743
|
return;
|
|
1036
1744
|
}
|
|
1037
|
-
|
|
1038
|
-
try {
|
|
1039
|
-
cfg = await resolveHubConfig(opts);
|
|
1040
|
-
}
|
|
1041
|
-
catch (e) {
|
|
1042
|
-
renderHubError(e);
|
|
1043
|
-
process.exit(1);
|
|
1044
|
-
}
|
|
1745
|
+
const acceptList = parseAcceptList(opts.acceptDataChanges);
|
|
1045
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;
|
|
1046
1750
|
for (const c of withUpdates) {
|
|
1047
|
-
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);
|
|
1048
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).
|
|
1049
1767
|
if (!opts.json)
|
|
1050
|
-
log.warn(`"${c.slug}"
|
|
1768
|
+
log.warn(`Couldn't find "${c.slug}" in the catalog read just now — skipping.`);
|
|
1051
1769
|
results.push({ slug: c.slug, outcome: "not-in-catalog", status: "not-in-catalog", warnings: [] });
|
|
1052
1770
|
continue;
|
|
1053
1771
|
}
|
|
1054
|
-
|
|
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
|
+
}));
|
|
1055
1807
|
}
|
|
1808
|
+
if (results.some((r) => r.status === "needs-consent"))
|
|
1809
|
+
process.exitCode = 1;
|
|
1056
1810
|
const summary = summarizeApplyResults(results);
|
|
1057
1811
|
const steps = FLOW.afterMarketUpdateApply();
|
|
1058
1812
|
if (opts.json) {
|
|
1059
|
-
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));
|
|
1060
1823
|
return;
|
|
1061
1824
|
}
|
|
1062
1825
|
log.blank();
|
|
@@ -1075,12 +1838,14 @@ export async function marketUpdate(slugsArg, opts) {
|
|
|
1075
1838
|
* else (locked/proposed/fetch-failed/not-in-catalog/no-workspace/unknown)
|
|
1076
1839
|
* rolls into "failed".
|
|
1077
1840
|
*/
|
|
1078
|
-
function summarizeApplyResults(results) {
|
|
1841
|
+
export function summarizeApplyResults(results) {
|
|
1079
1842
|
let updated = 0;
|
|
1080
1843
|
let unchanged = 0;
|
|
1081
1844
|
let withFailures = 0;
|
|
1082
1845
|
let timedOut = 0;
|
|
1083
1846
|
let failed = 0;
|
|
1847
|
+
let proposed = 0;
|
|
1848
|
+
let needsConsent = 0;
|
|
1084
1849
|
for (const r of results) {
|
|
1085
1850
|
switch (r.status) {
|
|
1086
1851
|
case "installed":
|
|
@@ -1092,35 +1857,60 @@ function summarizeApplyResults(results) {
|
|
|
1092
1857
|
case "unchanged":
|
|
1093
1858
|
unchanged++;
|
|
1094
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;
|
|
1095
1868
|
case "installed-with-failures":
|
|
1096
1869
|
case "updated-with-failures":
|
|
1097
1870
|
case "composed-with-failures":
|
|
1871
|
+
case "not-finished":
|
|
1098
1872
|
case "stamp-contradiction":
|
|
1099
1873
|
withFailures++;
|
|
1100
1874
|
break;
|
|
1101
1875
|
case "timed-out":
|
|
1102
1876
|
timedOut++;
|
|
1103
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;
|
|
1104
1883
|
default:
|
|
1105
1884
|
failed++;
|
|
1106
1885
|
}
|
|
1107
1886
|
}
|
|
1108
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).
|
|
1109
1892
|
if (withFailures > 0)
|
|
1110
|
-
parts.push(`${withFailures}
|
|
1893
|
+
parts.push(`${withFailures} ${templateStepPhrase("unfinished")}`);
|
|
1111
1894
|
if (failed > 0)
|
|
1112
1895
|
parts.push(`${failed} failed`);
|
|
1113
1896
|
if (timedOut > 0)
|
|
1114
1897
|
parts.push(`${timedOut} timed out`);
|
|
1115
|
-
|
|
1116
|
-
|
|
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;
|
|
1117
1905
|
return {
|
|
1118
1906
|
updated,
|
|
1119
1907
|
unchanged,
|
|
1120
1908
|
withFailures,
|
|
1121
1909
|
failed,
|
|
1122
1910
|
timedOut,
|
|
1123
|
-
|
|
1911
|
+
proposed,
|
|
1912
|
+
needsConsent,
|
|
1913
|
+
line: color(`${marker} ${parts.join(" · ")}`),
|
|
1124
1914
|
};
|
|
1125
1915
|
}
|
|
1126
1916
|
// ── `synap market installed [--json] [--outdated]` — pure-read inventory ────
|
|
@@ -1137,21 +1927,33 @@ function truncateVersion(v) {
|
|
|
1137
1927
|
function noVersionLabel(isPrivate, loggedIn) {
|
|
1138
1928
|
return isPrivate && !loggedIn
|
|
1139
1929
|
? chalk.dim("connect to the Control Plane to check/update — run: synap login")
|
|
1140
|
-
: chalk.dim("version unknown —
|
|
1930
|
+
: chalk.dim("version unknown — run `synap market attach <workspace>` to stamp it");
|
|
1141
1931
|
}
|
|
1142
1932
|
/**
|
|
1143
1933
|
* Human status cell for one installed row — distinguishes hash drift (no
|
|
1144
1934
|
* ordering, just "changed") from semver drift ("update available").
|
|
1145
1935
|
*/
|
|
1146
|
-
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}`);
|
|
1147
1942
|
if (c.noVersionInfo)
|
|
1148
1943
|
return noVersionLabel(isPrivate, loggedIn);
|
|
1149
|
-
if (!c.updateAvailable)
|
|
1150
|
-
|
|
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
|
+
}
|
|
1151
1953
|
const installed = truncateVersion(c.installedVersion);
|
|
1152
1954
|
const latest = c.latestVersion ? truncateVersion(c.latestVersion) : "?";
|
|
1153
1955
|
const hashPair = isHashVersion(c.installedVersion) || isHashVersion(c.latestVersion ?? "");
|
|
1154
|
-
const label = hashPair ? "content changed" : "update
|
|
1956
|
+
const label = hashPair ? "content changed" : templateStepPhrase("update");
|
|
1155
1957
|
return chalk.yellow(`${label} `) + chalk.dim(installed) + " → " + latest;
|
|
1156
1958
|
}
|
|
1157
1959
|
// ── Composition engine — the ONE traversal door ─────────────────────────────
|
|
@@ -1210,9 +2012,21 @@ async function buildEdgesOf(seeds, cat, cpToken) {
|
|
|
1210
2012
|
// safe either way since it's the same bundled-only lookup as the default.
|
|
1211
2013
|
return (slug) => cache.get(slug) ?? bundledEdgesOf(slug);
|
|
1212
2014
|
}
|
|
1213
|
-
/**
|
|
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
|
+
*/
|
|
1214
2026
|
function toInstalledEntries(installedTemplates) {
|
|
1215
|
-
return installedTemplates
|
|
2027
|
+
return installedTemplates
|
|
2028
|
+
.filter((t) => t.workspaceId != null)
|
|
2029
|
+
.map((t) => ({
|
|
1216
2030
|
slug: t.slug,
|
|
1217
2031
|
workspaceId: t.workspaceId,
|
|
1218
2032
|
workspaceName: t.workspaceName,
|
|
@@ -1284,7 +2098,7 @@ export async function buildCompositionTrees(rows, cat, installedTemplates, works
|
|
|
1284
2098
|
relation: edge.relation,
|
|
1285
2099
|
depKind: edge.depKind,
|
|
1286
2100
|
installed: !!installedInfo,
|
|
1287
|
-
workspaceId: installedInfo?.workspaceId,
|
|
2101
|
+
workspaceId: installedInfo?.workspaceId ?? undefined,
|
|
1288
2102
|
workspaceName: installedInfo?.workspaceName,
|
|
1289
2103
|
installedVersion: check?.installedVersion,
|
|
1290
2104
|
latestVersion: check?.latestVersion ?? null,
|
|
@@ -1319,10 +2133,10 @@ function nodeStatusLabel(node) {
|
|
|
1319
2133
|
if (node.noVersionInfo)
|
|
1320
2134
|
return chalk.dim("can't check");
|
|
1321
2135
|
if (!node.updateAvailable)
|
|
1322
|
-
return chalk.green("
|
|
2136
|
+
return chalk.green(templateStepPhrase("current"));
|
|
1323
2137
|
const installed = node.installedVersion ? truncateVersion(node.installedVersion) : "?";
|
|
1324
2138
|
const latest = node.latestVersion ? truncateVersion(node.latestVersion) : "?";
|
|
1325
|
-
return chalk.yellow("update
|
|
2139
|
+
return chalk.yellow(`${templateStepPhrase("update")} `) + chalk.dim(installed) + " → " + latest;
|
|
1326
2140
|
}
|
|
1327
2141
|
function printDependencyNode(node, prefix, isLast) {
|
|
1328
2142
|
const branch = isLast ? "└─ " : "├─ ";
|
|
@@ -1352,8 +2166,8 @@ export function printCompositionTree(pkg) {
|
|
|
1352
2166
|
(pkg.noVersionInfo
|
|
1353
2167
|
? chalk.dim("can't check — reinstall")
|
|
1354
2168
|
: pkg.updateAvailable
|
|
1355
|
-
? chalk.yellow("update
|
|
1356
|
-
: chalk.green("
|
|
2169
|
+
? chalk.yellow(templateStepPhrase("update"))
|
|
2170
|
+
: chalk.green(templateStepPhrase("current"))) +
|
|
1357
2171
|
suiteTag);
|
|
1358
2172
|
pkg.children.forEach((child, i) => printDependencyNode(child, "", i === pkg.children.length - 1));
|
|
1359
2173
|
log.blank();
|
|
@@ -1416,10 +2230,10 @@ function layerNodeStatus(node, isPrivate, loggedIn) {
|
|
|
1416
2230
|
if (node.noVersionInfo)
|
|
1417
2231
|
return noVersionLabel(isPrivate, loggedIn);
|
|
1418
2232
|
if (!node.updateAvailable)
|
|
1419
|
-
return chalk.green("
|
|
2233
|
+
return chalk.green(templateStepPhrase("current"));
|
|
1420
2234
|
const installed = node.installedVersion ? truncateVersion(node.installedVersion) : "?";
|
|
1421
2235
|
const latest = node.latestVersion ? truncateVersion(node.latestVersion) : "?";
|
|
1422
|
-
return chalk.yellow("update
|
|
2236
|
+
return chalk.yellow(`${templateStepPhrase("update")} `) + chalk.dim(installed) + " → " + latest;
|
|
1423
2237
|
}
|
|
1424
2238
|
/**
|
|
1425
2239
|
* Render the layered DAG bottom-up: bedrock (the highest layer index, most
|
|
@@ -1479,25 +2293,90 @@ function printLayersGraph(graph, cat) {
|
|
|
1479
2293
|
* (`market update`'s own drift check), never writes anything. Pairs with
|
|
1480
2294
|
* `market update` to apply what this surfaces.
|
|
1481
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
|
+
}
|
|
1482
2340
|
export async function marketInstalled(opts) {
|
|
1483
|
-
|
|
1484
|
-
|
|
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) {
|
|
1485
2354
|
if (opts.json) {
|
|
1486
|
-
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));
|
|
1487
2356
|
return;
|
|
1488
2357
|
}
|
|
1489
2358
|
log.warn("No installed packages found on this pod.");
|
|
2359
|
+
if (degradedNote)
|
|
2360
|
+
log.dim(degradedNote);
|
|
1490
2361
|
log.hint("Browse what's available: synap market --list");
|
|
1491
2362
|
return;
|
|
1492
2363
|
}
|
|
1493
2364
|
const checks = computeUpdates(installedTemplates, cat);
|
|
1494
2365
|
// Multiple workspaces can install the same slug (a template composed onto
|
|
1495
|
-
// several bases,
|
|
1496
|
-
// DISTINCT slug, with
|
|
1497
|
-
|
|
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();
|
|
1498
2371
|
for (const t of installedTemplates) {
|
|
1499
|
-
|
|
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");
|
|
1500
2377
|
}
|
|
2378
|
+
const workspaceCountBySlug = new Map([...placementsBySlug].map(([slug, set]) => [slug, set.size]));
|
|
2379
|
+
const placementLabel = (slug) => fanOutLabel(placementsBySlug.get(slug) ?? new Set());
|
|
1501
2380
|
const seen = new Set();
|
|
1502
2381
|
let rows = checks.filter((c) => {
|
|
1503
2382
|
if (seen.has(c.slug))
|
|
@@ -1518,6 +2397,7 @@ export async function marketInstalled(opts) {
|
|
|
1518
2397
|
layers: graph.layers.map((bucket) => bucket.map((n) => n.slug)),
|
|
1519
2398
|
cycles: graph.cycles,
|
|
1520
2399
|
loggedIn: cat.loggedIn,
|
|
2400
|
+
degraded: inv.degraded,
|
|
1521
2401
|
nextSteps: steps,
|
|
1522
2402
|
}, null, 2));
|
|
1523
2403
|
return;
|
|
@@ -1527,6 +2407,8 @@ export async function marketInstalled(opts) {
|
|
|
1527
2407
|
return;
|
|
1528
2408
|
}
|
|
1529
2409
|
printLayersGraph(graph, cat);
|
|
2410
|
+
if (degradedNote)
|
|
2411
|
+
log.dim(degradedNote);
|
|
1530
2412
|
if (!cat.loggedIn)
|
|
1531
2413
|
log.dim("Not logged in — private template updates aren't visible. Run: synap login");
|
|
1532
2414
|
renderNextSteps(steps);
|
|
@@ -1552,6 +2434,7 @@ export async function marketInstalled(opts) {
|
|
|
1552
2434
|
};
|
|
1553
2435
|
}),
|
|
1554
2436
|
loggedIn: cat.loggedIn,
|
|
2437
|
+
degraded: inv.degraded,
|
|
1555
2438
|
nextSteps: steps,
|
|
1556
2439
|
}, null, 2));
|
|
1557
2440
|
return;
|
|
@@ -1574,7 +2457,6 @@ export async function marketInstalled(opts) {
|
|
|
1574
2457
|
const entry = entryBySlug.get(c.slug);
|
|
1575
2458
|
const name = entry?.name ?? c.slug;
|
|
1576
2459
|
const priv = entry?.isPrivate ? chalk.yellow("private") : "";
|
|
1577
|
-
const wsCount = workspaceCountBySlug.get(c.slug) ?? 0;
|
|
1578
2460
|
console.log(" " +
|
|
1579
2461
|
chalk.bold(name.padEnd(nameW)) +
|
|
1580
2462
|
chalk.cyan(c.slug.padEnd(slugW)) +
|
|
@@ -1582,10 +2464,12 @@ export async function marketInstalled(opts) {
|
|
|
1582
2464
|
" " +
|
|
1583
2465
|
priv +
|
|
1584
2466
|
(priv ? " " : "") +
|
|
1585
|
-
chalk.dim(
|
|
2467
|
+
chalk.dim(placementLabel(c.slug)));
|
|
1586
2468
|
}
|
|
1587
2469
|
log.blank();
|
|
1588
2470
|
}
|
|
2471
|
+
if (degradedNote)
|
|
2472
|
+
log.dim(degradedNote);
|
|
1589
2473
|
if (!cat.loggedIn)
|
|
1590
2474
|
log.dim("Not logged in — private template updates aren't visible. Run: synap login");
|
|
1591
2475
|
renderNextSteps(steps);
|
|
@@ -1598,6 +2482,9 @@ export async function marketInstalled(opts) {
|
|
|
1598
2482
|
const entry = entryBySlug.get(c.slug);
|
|
1599
2483
|
return {
|
|
1600
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",
|
|
1601
2488
|
name: entry?.name ?? c.slug,
|
|
1602
2489
|
installedVersion: c.installedVersion,
|
|
1603
2490
|
latestVersion: c.latestVersion ?? null,
|
|
@@ -1606,38 +2493,807 @@ export async function marketInstalled(opts) {
|
|
|
1606
2493
|
isPrivate: entry?.isPrivate ?? false,
|
|
1607
2494
|
isHashVersion: isHashVersion(c.installedVersion) || isHashVersion(c.latestVersion ?? ""),
|
|
1608
2495
|
workspaceCount: workspaceCountBySlug.get(c.slug) ?? 0,
|
|
2496
|
+
placement: placementLabel(c.slug),
|
|
1609
2497
|
};
|
|
1610
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
|
+
})),
|
|
1611
2511
|
loggedIn: cat.loggedIn,
|
|
2512
|
+
degraded: inv.degraded,
|
|
2513
|
+
...(degradedNote ? { degradedNote } : {}),
|
|
1612
2514
|
nextSteps: steps,
|
|
1613
2515
|
}, null, 2));
|
|
1614
2516
|
return;
|
|
1615
2517
|
}
|
|
1616
|
-
if (rows.length === 0) {
|
|
2518
|
+
if (rows.length === 0 && (opts.outdated || unlinked.length === 0)) {
|
|
1617
2519
|
log.success(opts.outdated ? "Nothing outdated." : "Nothing installed.");
|
|
2520
|
+
if (degradedNote)
|
|
2521
|
+
log.dim(degradedNote);
|
|
1618
2522
|
return;
|
|
1619
2523
|
}
|
|
1620
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");
|
|
1621
2701
|
log.blank();
|
|
1622
|
-
const nameW = Math.max(4, ...rows.map((
|
|
1623
|
-
const
|
|
1624
|
-
|
|
1625
|
-
const entry = entryBySlug.get(c.slug);
|
|
1626
|
-
const name = entry?.name ?? c.slug;
|
|
1627
|
-
const priv = entry?.isPrivate ? chalk.yellow("private") : "";
|
|
1628
|
-
const wsCount = workspaceCountBySlug.get(c.slug) ?? 0;
|
|
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("—");
|
|
1629
2705
|
console.log(" " +
|
|
1630
|
-
chalk.bold(
|
|
1631
|
-
chalk.
|
|
1632
|
-
installedStatusLabel(c, entry?.isPrivate ?? false, cat.loggedIn) +
|
|
2706
|
+
chalk.bold(ws.workspaceName.padEnd(nameW)) +
|
|
2707
|
+
chalk.dim(ws.workspaceId) +
|
|
1633
2708
|
" " +
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
2709
|
+
domain +
|
|
2710
|
+
"\n " +
|
|
2711
|
+
attachmentStatusLabel(status));
|
|
1637
2712
|
}
|
|
1638
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
|
+
}
|
|
1639
2719
|
if (!cat.loggedIn)
|
|
1640
2720
|
log.dim("Not logged in — private template updates aren't visible. Run: synap login");
|
|
1641
|
-
|
|
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
|
+
});
|
|
1642
3298
|
}
|
|
1643
3299
|
//# sourceMappingURL=market.js.map
|