@synap-core/cli 1.8.0 → 1.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/agent-chat.d.ts +47 -0
- package/dist/commands/agent-chat.js +250 -0
- package/dist/commands/agent-chat.js.map +1 -0
- package/dist/commands/agent-run.js +3 -4
- package/dist/commands/agent-run.js.map +1 -1
- package/dist/commands/agents.js +85 -46
- package/dist/commands/agents.js.map +1 -1
- package/dist/commands/automation.js +2 -1
- package/dist/commands/automation.js.map +1 -1
- package/dist/commands/bridge-setup.d.ts +66 -0
- package/dist/commands/bridge-setup.js +1112 -0
- package/dist/commands/bridge-setup.js.map +1 -0
- package/dist/commands/cap-connections.d.ts +28 -0
- package/dist/commands/cap-connections.js +224 -0
- package/dist/commands/cap-connections.js.map +1 -0
- package/dist/commands/capability.d.ts +89 -0
- package/dist/commands/capability.js +1523 -0
- package/dist/commands/capability.js.map +1 -0
- package/dist/commands/centrality.d.ts +20 -0
- package/dist/commands/centrality.js +94 -0
- package/dist/commands/centrality.js.map +1 -0
- package/dist/commands/connect.d.ts +2 -0
- package/dist/commands/connect.js +16 -35
- package/dist/commands/connect.js.map +1 -1
- package/dist/commands/context.js +4 -6
- package/dist/commands/context.js.map +1 -1
- package/dist/commands/data-extra.d.ts +1 -0
- package/dist/commands/data-extra.js +34 -10
- package/dist/commands/data-extra.js.map +1 -1
- package/dist/commands/data.d.ts +4 -4
- package/dist/commands/data.js +241 -184
- package/dist/commands/data.js.map +1 -1
- package/dist/commands/diagnose.d.ts +20 -0
- package/dist/commands/diagnose.js +143 -0
- package/dist/commands/diagnose.js.map +1 -0
- package/dist/commands/digest.d.ts +17 -0
- package/dist/commands/digest.js +121 -0
- package/dist/commands/digest.js.map +1 -0
- package/dist/commands/discover.d.ts +2 -0
- package/dist/commands/discover.js +25 -8
- package/dist/commands/discover.js.map +1 -1
- package/dist/commands/events.js +2 -1
- package/dist/commands/events.js.map +1 -1
- package/dist/commands/explain.d.ts +1 -1
- package/dist/commands/explain.js +18 -15
- package/dist/commands/explain.js.map +1 -1
- package/dist/commands/facet.d.ts +29 -0
- package/dist/commands/facet.js +113 -0
- package/dist/commands/facet.js.map +1 -0
- package/dist/commands/graph.js +2 -1
- package/dist/commands/graph.js.map +1 -1
- package/dist/commands/import.d.ts +38 -0
- package/dist/commands/import.js +440 -0
- package/dist/commands/import.js.map +1 -0
- package/dist/commands/init.js +35 -16
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/keys.js +3 -4
- package/dist/commands/keys.js.map +1 -1
- package/dist/commands/knowledge.d.ts +2 -0
- package/dist/commands/knowledge.js +61 -9
- package/dist/commands/knowledge.js.map +1 -1
- package/dist/commands/launch.d.ts +50 -0
- package/dist/commands/launch.js +790 -0
- package/dist/commands/launch.js.map +1 -0
- package/dist/commands/lens.d.ts +45 -0
- package/dist/commands/lens.js +386 -0
- package/dist/commands/lens.js.map +1 -0
- package/dist/commands/market.d.ts +209 -0
- package/dist/commands/market.js +1368 -0
- package/dist/commands/market.js.map +1 -0
- package/dist/commands/mcp.d.ts +42 -0
- package/dist/commands/mcp.js +184 -0
- package/dist/commands/mcp.js.map +1 -0
- package/dist/commands/observe.js +14 -11
- package/dist/commands/observe.js.map +1 -1
- package/dist/commands/open.d.ts +21 -4
- package/dist/commands/open.js +45 -4
- package/dist/commands/open.js.map +1 -1
- package/dist/commands/pod.d.ts +100 -0
- package/dist/commands/pod.js +359 -0
- package/dist/commands/pod.js.map +1 -0
- package/dist/commands/pods.d.ts +1 -0
- package/dist/commands/pods.js +77 -15
- package/dist/commands/pods.js.map +1 -1
- package/dist/commands/proposals-actions.js +40 -3
- package/dist/commands/proposals-actions.js.map +1 -1
- package/dist/commands/purge.d.ts +23 -0
- package/dist/commands/purge.js +148 -0
- package/dist/commands/purge.js.map +1 -0
- package/dist/commands/raycast.d.ts +15 -0
- package/dist/commands/raycast.js +153 -0
- package/dist/commands/raycast.js.map +1 -0
- package/dist/commands/sessions.js +45 -21
- package/dist/commands/sessions.js.map +1 -1
- package/dist/commands/skill-manage.d.ts +21 -0
- package/dist/commands/skill-manage.js +195 -0
- package/dist/commands/skill-manage.js.map +1 -0
- package/dist/commands/skills-commands.js +2 -1
- package/dist/commands/skills-commands.js.map +1 -1
- package/dist/commands/skills.js +4 -3
- package/dist/commands/skills.js.map +1 -1
- package/dist/commands/status.d.ts +3 -1
- package/dist/commands/status.js +166 -14
- package/dist/commands/status.js.map +1 -1
- package/dist/commands/statusline.d.ts +24 -0
- package/dist/commands/statusline.js +450 -0
- package/dist/commands/statusline.js.map +1 -0
- package/dist/commands/subscribe.js +2 -2
- package/dist/commands/subscribe.js.map +1 -1
- package/dist/commands/tools.d.ts +30 -0
- package/dist/commands/tools.js +315 -0
- package/dist/commands/tools.js.map +1 -0
- package/dist/commands/versions.d.ts +27 -0
- package/dist/commands/versions.js +277 -0
- package/dist/commands/versions.js.map +1 -0
- package/dist/commands/view.js +3 -2
- package/dist/commands/view.js.map +1 -1
- package/dist/commands/whoami.d.ts +21 -0
- package/dist/commands/whoami.js +129 -0
- package/dist/commands/whoami.js.map +1 -0
- package/dist/index.js +776 -60
- package/dist/index.js.map +1 -1
- package/dist/lib/approval-poll.d.ts +42 -0
- package/dist/lib/approval-poll.js +52 -0
- package/dist/lib/approval-poll.js.map +1 -0
- package/dist/lib/auth.d.ts +29 -6
- package/dist/lib/auth.js +105 -94
- package/dist/lib/auth.js.map +1 -1
- package/dist/lib/bundle-version.d.ts +11 -0
- package/dist/lib/bundle-version.js +33 -0
- package/dist/lib/bundle-version.js.map +1 -0
- package/dist/lib/capture-lane.d.ts +30 -12
- package/dist/lib/capture-lane.js +71 -14
- package/dist/lib/capture-lane.js.map +1 -1
- package/dist/lib/cp-packages.d.ts +121 -0
- package/dist/lib/cp-packages.js +218 -0
- package/dist/lib/cp-packages.js.map +1 -0
- package/dist/lib/describe-lens.d.ts +46 -0
- package/dist/lib/describe-lens.js +33 -0
- package/dist/lib/describe-lens.js.map +1 -0
- package/dist/lib/hub-client.d.ts +78 -1
- package/dist/lib/hub-client.js +474 -22
- package/dist/lib/hub-client.js.map +1 -1
- package/dist/lib/installed.d.ts +51 -0
- package/dist/lib/installed.js +70 -0
- package/dist/lib/installed.js.map +1 -0
- package/dist/lib/next-steps.d.ts +61 -0
- package/dist/lib/next-steps.js +146 -0
- package/dist/lib/next-steps.js.map +1 -0
- package/dist/lib/pod.d.ts +91 -19
- package/dist/lib/pod.js +222 -56
- package/dist/lib/pod.js.map +1 -1
- package/dist/lib/project-ref.d.ts +95 -0
- package/dist/lib/project-ref.js +144 -0
- package/dist/lib/project-ref.js.map +1 -0
- package/dist/lib/project.d.ts +35 -0
- package/dist/lib/project.js +39 -0
- package/dist/lib/project.js.map +1 -0
- package/dist/lib/session-lens.d.ts +33 -0
- package/dist/lib/session-lens.js +76 -0
- package/dist/lib/session-lens.js.map +1 -0
- package/dist/lib/skill-import.d.ts +40 -0
- package/dist/lib/skill-import.js +203 -0
- package/dist/lib/skill-import.js.map +1 -0
- package/dist/lib/skills-installer.d.ts +17 -6
- package/dist/lib/skills-installer.js +133 -19
- package/dist/lib/skills-installer.js.map +1 -1
- package/dist/lib/suite.d.ts +28 -0
- package/dist/lib/suite.js +29 -0
- package/dist/lib/suite.js.map +1 -0
- package/dist/lib/superwhisper-import.d.ts +40 -0
- package/dist/lib/superwhisper-import.js +389 -0
- package/dist/lib/superwhisper-import.js.map +1 -0
- package/dist/lib/targets.d.ts +49 -1
- package/dist/lib/targets.js +420 -288
- package/dist/lib/targets.js.map +1 -1
- package/dist/lib/unwrapList.d.ts +1 -0
- package/dist/lib/unwrapList.js +20 -0
- package/dist/lib/unwrapList.js.map +1 -0
- package/dist/utils/logger.d.ts +2 -0
- package/dist/utils/logger.js +10 -2
- package/dist/utils/logger.js.map +1 -1
- package/package.json +9 -4
- package/skills/agent-os/SKILL.md +212 -0
- package/skills/manifest.json +5 -0
- package/skills/onboard/SKILL.md +186 -0
- package/skills/synap/SKILL.md +1182 -37
- package/skills/synap/_order.txt +37 -0
- package/skills/synap/_skill.yaml +24 -0
- package/skills/synap/authentication.md +10 -0
- package/skills/synap/automations.md +264 -0
- package/skills/synap/capabilities.md +107 -0
- package/skills/synap/capture.md +10 -0
- package/skills/synap/cli-operations.md +107 -0
- package/skills/synap/common-mistakes.md +12 -0
- package/skills/synap/creative-loop.md +43 -0
- package/skills/synap/crm.md +117 -0
- package/skills/synap/diagnostics.md +35 -0
- package/skills/synap/digesting-a-channel.md +38 -0
- package/skills/synap/document-embeds.md +123 -0
- package/skills/synap/escalation-ladder.md +71 -0
- package/skills/synap/focus-sessions.md +47 -0
- package/skills/synap/governance-rules.md +64 -0
- package/skills/synap/governance.md +6 -6
- package/skills/synap/graph-gardening.md +21 -0
- package/skills/synap/inline-patterns.md +38 -0
- package/skills/synap/lenses.md +38 -0
- package/skills/synap/linking-principle.md +39 -0
- package/skills/synap/linking.md +1 -0
- package/skills/synap/mental-model.md +45 -0
- package/skills/synap/more.md +7 -0
- package/skills/synap/multi-entity-capture.md +10 -0
- package/skills/synap/next-action.md +37 -0
- package/skills/synap/north-star.md +17 -0
- package/skills/synap/operating-mode-rules.md +45 -0
- package/skills/synap/operating-mode.md +9 -0
- package/skills/synap/quick-reference.md +67 -0
- package/skills/synap/reading.md +43 -0
- package/skills/synap/recap.md +21 -0
- package/skills/synap/reflexes.md +46 -0
- package/skills/synap/response-style.md +21 -0
- package/skills/synap/scope.md +13 -0
- package/skills/synap/showing.md +17 -0
- package/skills/synap/user-model.md +25 -0
- package/skills/synap/viewframe-cells.md +290 -0
- package/skills/synap/work-flow.md +113 -0
- package/skills/synap/worked-examples.md +53 -0
- package/skills/synap/workspace-design.md +45 -0
- package/skills/synap/workspace-edges.md +49 -0
- package/skills/synap/writes.md +156 -0
- package/skills/synap-schema/SKILL.md +97 -6
- package/skills/synap-schema/_order.txt +13 -0
- package/skills/synap-schema/_skill.yaml +22 -0
- package/skills/synap-schema/before-you-touch.md +14 -0
- package/skills/synap-schema/common-mistakes.md +9 -0
- package/skills/synap-schema/creating-profiles.md +17 -0
- package/skills/synap-schema/discover-profiles.md +24 -0
- package/skills/synap-schema/entity-scope.md +8 -0
- package/skills/synap-schema/extend-vs-create.md +31 -0
- package/skills/synap-schema/more.md +6 -0
- package/skills/synap-schema/overlay-properties.md +22 -0
- package/skills/synap-schema/profiles.md +3 -0
- package/skills/synap-schema/property-defs.md +29 -0
- package/skills/synap-schema/read-before-write.md +50 -0
- package/skills/synap-schema/relations.md +18 -0
- package/skills/synap-schema/worked-example.md +30 -0
- package/skills/synap-ui/SKILL.md +26 -2
- package/skills/synap-ui/_order.txt +13 -0
- package/skills/synap-ui/_skill.yaml +23 -0
- package/skills/synap-ui/ai-companion.md +27 -0
- package/skills/synap-ui/arranging-bento.md +8 -0
- package/skills/synap-ui/before-you-build.md +20 -0
- package/skills/synap-ui/bento-layouts.md +35 -0
- package/skills/synap-ui/common-mistakes.md +10 -0
- package/skills/synap-ui/creating-views.md +26 -0
- package/skills/synap-ui/four-builds.md +24 -0
- package/skills/synap-ui/more.md +7 -0
- package/skills/synap-ui/proposing-vs-extending.md +11 -0
- package/skills/synap-ui/view-list.md +24 -0
- package/skills/synap-ui/views.md +3 -0
- package/skills/synap-ui/worked-example.md +18 -0
- package/skills/synap-ui/workspaces.md +47 -0
- package/templates/automations/bookmark-enrichment.automation.json +44 -0
- package/templates/automations/mail-feed.automation.json +86 -0
- package/templates/automations/url-bookmark-capture.automation.json +70 -0
- package/templates/skills/README.md +21 -0
- package/templates/skills/authoring-automations/SKILL.md +95 -0
- package/templates/skills/authoring-automations/reference/node-matrix.md +49 -0
- package/templates/skills/authoring-capabilities/SKILL.md +93 -0
- package/templates/skills/authoring-capabilities/reference/template-anatomy.md +70 -0
- package/templates/skills/authoring-playbooks/SKILL.md +102 -0
- package/templates/skills/authoring-playbooks/reference/stages-contract.md +35 -0
- package/templates/skills/authoring-playbooks/reference/worked-example.md +64 -0
- package/templates/skills/bookmark-enrichment/SKILL.md +40 -0
- package/templates/skills/coordinating-agents/SKILL.md +32 -0
- package/templates/skills/coordinating-branches/SKILL.md +27 -0
- package/templates/skills/mapping-the-server/SKILL.md +54 -0
- package/templates/skills/reading-client-progress/SKILL.md +65 -0
- package/templates/skills/routing-to-channels/SKILL.md +54 -0
- package/templates/skills/stellar-scf-grants-advisor/ASSOCIATE-CHEATSHEET.md +133 -0
- package/templates/skills/stellar-scf-grants-advisor/ASSOCIATE-ONBOARDING-GUIDE.md +761 -0
- package/templates/skills/stellar-scf-grants-advisor/CLIENT-FAQ.md +181 -0
- package/templates/skills/stellar-scf-grants-advisor/PARTNER-HANDOVER.md +130 -0
- package/templates/skills/stellar-scf-grants-advisor/SKILL.md +102 -0
- package/templates/skills/stellar-scf-grants-advisor/examples/README.md +43 -0
- package/templates/skills/stellar-scf-grants-advisor/examples/chat-summary-templates.md +158 -0
- package/templates/skills/stellar-scf-grants-advisor/examples/corpus-comparable-rows.md +136 -0
- package/templates/skills/stellar-scf-grants-advisor/examples/q10-team-rewrites.md +109 -0
- package/templates/skills/stellar-scf-grants-advisor/examples/q2-description-rewrites.md +88 -0
- package/templates/skills/stellar-scf-grants-advisor/examples/q6-integration-rewrites.md +107 -0
- package/templates/skills/stellar-scf-grants-advisor/examples/tranche-structures.md +176 -0
- package/templates/skills/stellar-scf-grants-advisor/reference/01-methodology.md +96 -0
- package/templates/skills/stellar-scf-grants-advisor/reference/02-scoring-framework.md +199 -0
- package/templates/skills/stellar-scf-grants-advisor/reference/03-rejection-patterns.md +241 -0
- package/templates/skills/stellar-scf-grants-advisor/reference/04-winner-patterns.md +120 -0
- package/templates/skills/stellar-scf-grants-advisor/reference/05-comeback-patterns.md +92 -0
- package/templates/skills/stellar-scf-grants-advisor/reference/06-budget-benchmarks.md +125 -0
- package/templates/skills/stellar-scf-grants-advisor/reference/07-integration-list.md +147 -0
- package/templates/skills/stellar-scf-grants-advisor/reference/08-abstract-fields.md +258 -0
- package/templates/skills/stellar-scf-grants-advisor/reference/09-build-fields.md +259 -0
- package/templates/skills/stellar-scf-grants-advisor/reference/10-corpus-winners.md +135 -0
- package/templates/skills/stellar-scf-grants-advisor/reference/11-corpus-rejects.md +267 -0
- package/templates/skills/stellar-scf-grants-advisor/reference/12-client-communication.md +131 -0
- package/templates/skills/stellar-scf-grants-advisor/reference/13-excel-output-spec.md +199 -0
- package/templates/skills/stellar-scf-grants-advisor/reference/14-worked-examples.md +160 -0
- package/templates/skills/stellar-scf-grants-advisor/templates/abstract-review-builder.py +253 -0
- package/templates/skills/stellar-scf-grants-advisor/templates/build-review-builder.py +213 -0
- package/dist/commands/connectors.d.ts +0 -31
- package/dist/commands/connectors.js +0 -276
- package/dist/commands/connectors.js.map +0 -1
- package/dist/commands/finish.d.ts +0 -25
- package/dist/commands/finish.js +0 -274
- package/dist/commands/finish.js.map +0 -1
- package/dist/commands/openclaw.d.ts +0 -40
- package/dist/commands/openclaw.js +0 -1148
- package/dist/commands/openclaw.js.map +0 -1
- package/dist/commands/security-audit.d.ts +0 -12
- package/dist/commands/security-audit.js +0 -100
- package/dist/commands/security-audit.js.map +0 -1
- package/dist/lib/hardening.d.ts +0 -18
- package/dist/lib/hardening.js +0 -214
- package/dist/lib/hardening.js.map +0 -1
- package/node_modules/@synap/hub-rest-client/dist/index.cjs +0 -881
- package/node_modules/@synap/hub-rest-client/dist/index.d.cts +0 -907
- package/node_modules/@synap/hub-rest-client/dist/index.d.ts +0 -907
- package/node_modules/@synap/hub-rest-client/dist/index.js +0 -851
- package/node_modules/@synap/hub-rest-client/package.json +0 -45
- package/node_modules/@synap/hub-rest-client/src/client.ts +0 -1143
- package/node_modules/@synap/hub-rest-client/src/errors.ts +0 -30
- package/node_modules/@synap/hub-rest-client/src/index.ts +0 -111
- package/node_modules/@synap/hub-rest-client/src/setup.ts +0 -77
- package/node_modules/@synap/hub-rest-client/src/types.ts +0 -639
|
@@ -0,0 +1,1112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* synap bridge-setup (HIDDEN)
|
|
3
|
+
*
|
|
4
|
+
* One-shot provisioning for the Discord ↔ Synap bridge — the terminal-native
|
|
5
|
+
* "complete it all" flow. Automates everything that CAN be automated:
|
|
6
|
+
*
|
|
7
|
+
* 1. lets you CHOOSE which saved pod the bridge connects to (kept separate
|
|
8
|
+
* from your global CLI pod via the "discord" surface override), then
|
|
9
|
+
* resolves a durable hub key + workspace from it
|
|
10
|
+
* 2. sanity-checks pod reachability + key scope (GET /capabilities)
|
|
11
|
+
* 3. applies the bridge's OWN capability definition (POST /capabilities/apply)
|
|
12
|
+
* — read from the bridge repo so there is ONE source of truth
|
|
13
|
+
* 4. optionally routes the agent's proactive posts back to a Discord channel
|
|
14
|
+
* 5. writes the bridge .env (pod creds + feature flags), preserving your
|
|
15
|
+
* existing Discord token / guild / relay settings
|
|
16
|
+
* 6. prints the bot invite URL + the few steps only a human can do
|
|
17
|
+
*
|
|
18
|
+
* Hidden on purpose: this is a dogfood/dev convenience, not a first-class
|
|
19
|
+
* surface. Not shown in `synap --help`.
|
|
20
|
+
*
|
|
21
|
+
* Usage:
|
|
22
|
+
* synap bridge-setup --client-id <discord-app-id> [--bridge-dir <path>]
|
|
23
|
+
* [--pod <profile-name>] [--workspace-id <uuid>]
|
|
24
|
+
* [--proactive-channel <discord-channel-id>] [--enable-react]
|
|
25
|
+
* [--pod-url <url>] [--api-key <key>]
|
|
26
|
+
*/
|
|
27
|
+
import path from "node:path";
|
|
28
|
+
import os from "node:os";
|
|
29
|
+
import fs from "node:fs";
|
|
30
|
+
import { createRequire } from "node:module";
|
|
31
|
+
import { fileURLToPath } from "node:url";
|
|
32
|
+
import chalk from "chalk";
|
|
33
|
+
import ora from "ora";
|
|
34
|
+
import prompts from "prompts";
|
|
35
|
+
import { hubGet, hubPost, hubPatch, resolveUserId } from "../lib/hub-client.js";
|
|
36
|
+
import { unwrapList } from "../lib/unwrapList.js";
|
|
37
|
+
import { discoverSkillDirs, parseSkillDir, importSkill } from "../lib/skill-import.js";
|
|
38
|
+
import { checkPodHealth, getSurfaceAgentKey, setSurfaceAgentKey, listPodProfiles, getSurfacePodName, setSurfacePod, podNotFoundError, } from "../lib/pod.js";
|
|
39
|
+
import { enrollAgentIfNeeded, ensureAgentGovernance, provisionAgentKey, configureAgentContext, } from "../lib/targets.js";
|
|
40
|
+
import { log, banner } from "../utils/logger.js";
|
|
41
|
+
const DEFAULT_BRIDGE_DIR = path.join(os.homedir(), "Documents", "Code", "telegram-discord-bridge");
|
|
42
|
+
// Bot invite permissions — the full set the bridge's features actually use.
|
|
43
|
+
// Bits per Discord's permission reference (https://discord.com/developers/docs/topics/permissions).
|
|
44
|
+
// BigInt because thread permissions exceed 2^31. Least-privilege *within* what we
|
|
45
|
+
// need: NO Administrator / Manage Server / Manage Roles / Kick / Ban / Mention-Everyone.
|
|
46
|
+
// (The privileged MESSAGE CONTENT gateway intent is enabled in the Developer Portal,
|
|
47
|
+
// NOT here — invite permissions and gateway intents are separate systems.)
|
|
48
|
+
const BOT_PERMISSION_BITS = {
|
|
49
|
+
VIEW_CHANNEL: 1n << 10n, // see channels/threads
|
|
50
|
+
SEND_MESSAGES: 1n << 11n, // reply in channels
|
|
51
|
+
SEND_MESSAGES_IN_THREADS: 1n << 38n, // reply inside threads (client-comms / team)
|
|
52
|
+
CREATE_PUBLIC_THREADS: 1n << 35n, // /link-client creates the per-client threads
|
|
53
|
+
CREATE_PRIVATE_THREADS: 1n << 36n, // private team threads
|
|
54
|
+
MANAGE_THREADS: 1n << 34n, // archive / rename / reuse threads
|
|
55
|
+
MANAGE_CHANNELS: 1n << 4n, // /link-client creates the per-client room (the missing one)
|
|
56
|
+
MANAGE_MESSAGES: 1n << 13n, // pin context (Google Drive links, key messages)
|
|
57
|
+
MANAGE_EVENTS: 1n << 33n, // create native Discord scheduled events (calendar → events sync)
|
|
58
|
+
READ_MESSAGE_HISTORY: 1n << 16n, // read channel context for the agent
|
|
59
|
+
ADD_REACTIONS: 1n << 6n, // react-capture
|
|
60
|
+
EMBED_LINKS: 1n << 14n, // rich link/button embeds (open-in-browser deep links)
|
|
61
|
+
ATTACH_FILES: 1n << 15n, // attachments (skill-from-file, exports)
|
|
62
|
+
USE_EXTERNAL_EMOJIS: 1n << 18n, // richer reactions
|
|
63
|
+
};
|
|
64
|
+
const BOT_PERMISSIONS = Object.values(BOT_PERMISSION_BITS)
|
|
65
|
+
.reduce((acc, bit) => acc | bit, 0n)
|
|
66
|
+
.toString();
|
|
67
|
+
/**
|
|
68
|
+
* Pick the pod the BRIDGE connects to — independent of the global CLI pod.
|
|
69
|
+
*
|
|
70
|
+
* The bridge is the "discord" surface, so we resolve/persist its pod via the
|
|
71
|
+
* per-surface override (setSurfacePod) and NEVER touch the global activePod.
|
|
72
|
+
* That lets the operator keep a personal pod as their CLI default while the
|
|
73
|
+
* Discord bridge points at a different (e.g. team / client) pod.
|
|
74
|
+
*
|
|
75
|
+
* Order: explicit --pod-url/--api-key escape hatch → --pod <name> →
|
|
76
|
+
* single saved profile (auto) → interactive picker (default = the pod already
|
|
77
|
+
* assigned to the discord surface, else the CLI-active one).
|
|
78
|
+
*/
|
|
79
|
+
async function resolveBridgePod(opts) {
|
|
80
|
+
// 1. Raw URL+key escape hatch — bypasses profiles entirely.
|
|
81
|
+
if (opts.podUrl && opts.apiKey) {
|
|
82
|
+
return {
|
|
83
|
+
cfg: { podUrl: opts.podUrl, apiKey: opts.apiKey, userId: process.env.SYNAP_USER_ID ?? "cli" },
|
|
84
|
+
podName: null,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
const profiles = listPodProfiles();
|
|
88
|
+
if (profiles.length === 0) {
|
|
89
|
+
throw new Error("No pod profiles saved. Run `synap pods add` first, or pass --pod-url and --api-key.");
|
|
90
|
+
}
|
|
91
|
+
// 2. Resolve which profile to use.
|
|
92
|
+
let chosen;
|
|
93
|
+
if (opts.pod) {
|
|
94
|
+
chosen = profiles.find((p) => p.name === opts.pod);
|
|
95
|
+
if (!chosen)
|
|
96
|
+
throw podNotFoundError(opts.pod);
|
|
97
|
+
}
|
|
98
|
+
else if (profiles.length === 1) {
|
|
99
|
+
chosen = profiles[0];
|
|
100
|
+
log.dim(`Using your only saved pod profile: ${chosen.name}`);
|
|
101
|
+
}
|
|
102
|
+
else {
|
|
103
|
+
const defaultName = getSurfacePodName("discord") ?? profiles.find((p) => p.active)?.name ?? profiles[0].name;
|
|
104
|
+
const initial = Math.max(0, profiles.findIndex((p) => p.name === defaultName));
|
|
105
|
+
const { picked } = await prompts({
|
|
106
|
+
type: "select",
|
|
107
|
+
name: "picked",
|
|
108
|
+
message: "Which data pod should the bridge connect to?",
|
|
109
|
+
initial,
|
|
110
|
+
choices: profiles.map((p) => ({
|
|
111
|
+
title: `${p.name}${p.active ? " (CLI default)" : ""}`,
|
|
112
|
+
description: p.config.podUrl,
|
|
113
|
+
value: p.name,
|
|
114
|
+
})),
|
|
115
|
+
});
|
|
116
|
+
if (!picked)
|
|
117
|
+
throw new Error("No pod selected — aborting.");
|
|
118
|
+
chosen = profiles.find((p) => p.name === picked);
|
|
119
|
+
}
|
|
120
|
+
// Remember the choice for the discord surface — without changing the CLI default.
|
|
121
|
+
setSurfacePod("discord", chosen.name);
|
|
122
|
+
const c = chosen.config;
|
|
123
|
+
return {
|
|
124
|
+
cfg: {
|
|
125
|
+
podUrl: c.podUrl,
|
|
126
|
+
apiKey: c.hubApiKey,
|
|
127
|
+
userId: c.agentUserId,
|
|
128
|
+
workspaceId: c.workspaceId || undefined,
|
|
129
|
+
},
|
|
130
|
+
podName: chosen.name,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
export async function bridgeSetup(opts) {
|
|
134
|
+
banner();
|
|
135
|
+
log.heading("Discord bridge — one-shot setup");
|
|
136
|
+
// ── 1. Choose the pod the BRIDGE connects to (independent of the CLI pod) ──
|
|
137
|
+
let cfg;
|
|
138
|
+
let bridgePodName = null;
|
|
139
|
+
try {
|
|
140
|
+
const resolved = await resolveBridgePod(opts);
|
|
141
|
+
cfg = resolved.cfg;
|
|
142
|
+
bridgePodName = resolved.podName;
|
|
143
|
+
}
|
|
144
|
+
catch (err) {
|
|
145
|
+
log.error(err.message);
|
|
146
|
+
log.dim("Run `synap init` or `synap pods add` first to save a pod + key.");
|
|
147
|
+
process.exit(1);
|
|
148
|
+
}
|
|
149
|
+
if (!cfg.podUrl || !cfg.apiKey) {
|
|
150
|
+
log.error("No pod URL / API key resolved. Run `synap init` first.");
|
|
151
|
+
process.exit(1);
|
|
152
|
+
}
|
|
153
|
+
// ── 2. Pod health ────────────────────────────────────────────────────────
|
|
154
|
+
const health = await checkPodHealth(cfg.podUrl);
|
|
155
|
+
if (!health.healthy) {
|
|
156
|
+
log.error(`Pod not reachable at ${cfg.podUrl}`);
|
|
157
|
+
process.exit(1);
|
|
158
|
+
}
|
|
159
|
+
log.success(`Pod healthy: ${cfg.podUrl}${bridgePodName ? ` (profile: ${bridgePodName})` : ""}`);
|
|
160
|
+
// ── 3. Resolve workspace ───────────────────────────────────────────────
|
|
161
|
+
const workspaceId = await resolveWorkspaceId(opts.workspaceId, cfg.workspaceId, cfg);
|
|
162
|
+
if (!workspaceId) {
|
|
163
|
+
log.error("Could not resolve a workspace. Pass --workspace-id <uuid>.");
|
|
164
|
+
process.exit(1);
|
|
165
|
+
}
|
|
166
|
+
log.dim(`Workspace: ${workspaceId}`);
|
|
167
|
+
// ── 3b. Key + scope sanity check (GET /capabilities needs hub-protocol.read) ─
|
|
168
|
+
const probe = ora("Checking key scope…").start();
|
|
169
|
+
try {
|
|
170
|
+
await hubGet("/capabilities", { workspaceId }, cfg);
|
|
171
|
+
probe.succeed("Key valid (hub-protocol scope OK)");
|
|
172
|
+
}
|
|
173
|
+
catch (err) {
|
|
174
|
+
probe.fail("Key/scope check failed");
|
|
175
|
+
log.error(err.message);
|
|
176
|
+
log.dim("The key may be expired or missing scope. Run `synap keys rotate` or `synap init`.");
|
|
177
|
+
process.exit(1);
|
|
178
|
+
}
|
|
179
|
+
// ── 3c. Provision a Discord AGENT key linked to the operator ──────────────
|
|
180
|
+
// The bridge must run as a real backend agent — NOT with the operator's raw
|
|
181
|
+
// key. An agent key carries linkedUserId=<operator>; the backend remaps its
|
|
182
|
+
// reads to the operator's data floor (hub-protocol-rest.ts auth middleware)
|
|
183
|
+
// while attributing writes to the agent for governed proposals. All the
|
|
184
|
+
// operator-key work below (capability apply, vault grant) keeps using `cfg`.
|
|
185
|
+
const agentKey = await provisionDiscordAgentKey(cfg.podUrl, cfg.apiKey, cfg.workspaceId);
|
|
186
|
+
// ── 4. Provision the capability (vault credential + tool + skill) ─────────
|
|
187
|
+
const bridgeDir = opts.bridgeDir ?? DEFAULT_BRIDGE_DIR;
|
|
188
|
+
const botToken = await resolveBotToken(opts.botToken);
|
|
189
|
+
const { vaultRef, secretId } = await applyBridgeCapability(bridgeDir, workspaceId, cfg, botToken);
|
|
190
|
+
// ── 4. Choose agent governance mode ──────────────────────────────────
|
|
191
|
+
const discordAgentUserId = getSurfaceAgentKey("discord")?.agentUserId;
|
|
192
|
+
if (discordAgentUserId) {
|
|
193
|
+
await ensureAgentGovernance(cfg, discordAgentUserId, opts.governance);
|
|
194
|
+
}
|
|
195
|
+
// ── 4b. Seed the bundled instruction skills FIRST, then the capabilities ────
|
|
196
|
+
// Order matters: the `stellar-scf-grants-advisor` skill now ALSO ships embedded
|
|
197
|
+
// in the stellar-grant-client capability (so a plain `packages/apply` install
|
|
198
|
+
// stands it up with NO bridge-setup). Seeding the bundled SKILL.md dir first
|
|
199
|
+
// attaches its full reference-doc corpus; the capability apply below then
|
|
200
|
+
// REUSES that same pod-scoped skill row by name (create-from-definition is
|
|
201
|
+
// idempotent) instead of creating a docless instruction-only copy.
|
|
202
|
+
await applyAgencySkills(cfg);
|
|
203
|
+
// ── 4c. Apply the agency capability templates (idempotent, self-healing) ────
|
|
204
|
+
await applyAgencyCapabilities(cfg, workspaceId);
|
|
205
|
+
// ── 4e. Seed the GENERIC automation templates (idempotent, event-driven) ────
|
|
206
|
+
// Only the domain-agnostic automations live in templates/automations/ now
|
|
207
|
+
// (mail-feed, url/bookmark capture). The grant-specific automations
|
|
208
|
+
// (stellar-grant-provision + proactive-grant-review) SHIP INSIDE the
|
|
209
|
+
// stellar-grant-client capability's automations[] (seeded above via
|
|
210
|
+
// applyAgencyCapabilities), so any install door — including a plain
|
|
211
|
+
// packages/apply with no bridge-setup — stands them up. Thread the operator's
|
|
212
|
+
// chosen proactive channel through — output nodes pin their post target with it
|
|
213
|
+
// when it's a Synap channels.id (see the note in applyAutomationTemplates).
|
|
214
|
+
await applyAutomationTemplates(cfg, workspaceId, opts.proactiveChannel);
|
|
215
|
+
// ── 4f. React-capture is POD CONFIG now (discord tool metadata), not an env
|
|
216
|
+
// var. When --enable-react is passed, set it on the tool so the bridge
|
|
217
|
+
// reads it back from the pod — changeable later in Studio / /configure.
|
|
218
|
+
if (opts.enableReact) {
|
|
219
|
+
await setDiscordToolConfig(cfg, workspaceId, { reactCapture: true });
|
|
220
|
+
}
|
|
221
|
+
// ── 4d. Grant the bridge redeem access — token lives ONLY in the vault ─────
|
|
222
|
+
let granted = false;
|
|
223
|
+
if (secretId && botToken) {
|
|
224
|
+
// Grant to the AGENT (the bridge's redeem identity), not the operator.
|
|
225
|
+
const agentUserId = getSurfaceAgentKey("discord")?.agentUserId;
|
|
226
|
+
granted = await grantRedeemAccess(secretId, cfg, agentUserId);
|
|
227
|
+
}
|
|
228
|
+
// ── 5. Proactive delivery routing (optional) ─────────────────────────────
|
|
229
|
+
if (opts.proactiveChannel) {
|
|
230
|
+
await configureProactive(workspaceId, opts.proactiveChannel, cfg);
|
|
231
|
+
}
|
|
232
|
+
// ── 6. Write the bridge .env ─────────────────────────────────────────────
|
|
233
|
+
const envPath = path.join(bridgeDir, ".env");
|
|
234
|
+
const managed = {
|
|
235
|
+
SYNAP_POD_URL: cfg.podUrl,
|
|
236
|
+
// The bridge runs as the Discord AGENT (linked to the operator), not with
|
|
237
|
+
// the operator's raw key. Reads remap to the operator floor; writes are
|
|
238
|
+
// governed as the agent.
|
|
239
|
+
SYNAP_HUB_API_KEY: agentKey,
|
|
240
|
+
SYNAP_WORKSPACE_ID: workspaceId,
|
|
241
|
+
};
|
|
242
|
+
// Project is a peer lens to workspace — only injected when the resolved
|
|
243
|
+
// config carries one (e.g. via the durable `activeProjectId` or session lens).
|
|
244
|
+
if (cfg.projectId)
|
|
245
|
+
managed.SYNAP_PROJECT_ID = cfg.projectId;
|
|
246
|
+
// The token is NEVER written to .env — it lives in the pod vault and the
|
|
247
|
+
// bridge redeems it on boot via this ref. (Requires the grant below to land;
|
|
248
|
+
// if it didn't, we warn loudly rather than leaking the token to env.)
|
|
249
|
+
if (vaultRef)
|
|
250
|
+
managed.SYNAP_DISCORD_TOKEN_REF = vaultRef;
|
|
251
|
+
if (opts.guildId)
|
|
252
|
+
managed.DISCORD_GUILD_ID = opts.guildId;
|
|
253
|
+
if (opts.proactiveChannel)
|
|
254
|
+
managed.SYNAP_PROACTIVE_CHANNEL_ID = opts.proactiveChannel;
|
|
255
|
+
try {
|
|
256
|
+
// Once the token is in the vault, purge any stale env copy of it.
|
|
257
|
+
upsertEnv(envPath, managed, vaultRef ? ["DISCORD_BOT_TOKEN"] : []);
|
|
258
|
+
log.success(`Wrote pod credentials → ${envPath}`);
|
|
259
|
+
}
|
|
260
|
+
catch (err) {
|
|
261
|
+
log.warn(`Could not write ${envPath}: ${err.message}`);
|
|
262
|
+
log.dim("Set these manually in the bridge .env:");
|
|
263
|
+
for (const [k, v] of Object.entries(managed)) {
|
|
264
|
+
log.dim(` ${k}=${k === "SYNAP_HUB_API_KEY" ? "<key>" : v}`);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
// ── 7. Next steps (the human-only remainder) ─────────────────────────────
|
|
268
|
+
// Re-running without --guild-id is fine: the upsert preserves an existing
|
|
269
|
+
// DISCORD_GUILD_ID. Treat that as "have guild" so we don't nag.
|
|
270
|
+
const envHasGuild = fs.existsSync(envPath) &&
|
|
271
|
+
/^DISCORD_GUILD_ID=.+/m.test(fs.readFileSync(envPath, "utf-8"));
|
|
272
|
+
printNextSteps(opts.clientId, bridgeDir, {
|
|
273
|
+
hasToken: Boolean(botToken),
|
|
274
|
+
hasGuild: Boolean(opts.guildId) || envHasGuild,
|
|
275
|
+
vaultRef,
|
|
276
|
+
granted,
|
|
277
|
+
});
|
|
278
|
+
await printSynapNextSteps(cfg, workspaceId);
|
|
279
|
+
}
|
|
280
|
+
// ── Agency capability seeding ────────────────────────────────────────────────
|
|
281
|
+
/**
|
|
282
|
+
* Secret-gating plan for agency capability templates.
|
|
283
|
+
*
|
|
284
|
+
* Each entry:
|
|
285
|
+
* key — CP catalog templateKey (the pod loads the definition from there)
|
|
286
|
+
* needs — env var that must be present to apply (null = unconditional)
|
|
287
|
+
* params — values to pass as the `params` body field
|
|
288
|
+
* workspaceScoped — true if the capability bundles PLAYBOOKS (which require a
|
|
289
|
+
* workspaceId); false seeds pod-wide tools/skills.
|
|
290
|
+
*
|
|
291
|
+
* Google is ONE capability — `nango-google` (Gmail + Calendar + Drive through a
|
|
292
|
+
* single OAuth connection). discord-bot is intentionally absent: applyBridgeCapability
|
|
293
|
+
* already handles it from the live bridge repo (ONE source of truth) — don't double-apply.
|
|
294
|
+
*/
|
|
295
|
+
const AGENCY_CAPABILITY_PLAN = [
|
|
296
|
+
{ key: "nango-google", needs: null, params: {}, workspaceScoped: false },
|
|
297
|
+
{ key: "agency-skills", needs: null, params: {}, workspaceScoped: true },
|
|
298
|
+
// stellar-grant-client bundles the grant PLAYBOOK, all four grant AUTOMATIONS
|
|
299
|
+
// (including stellar-grant-provision + proactive-grant-review, moved here from
|
|
300
|
+
// templates/automations/), and the stellar-scf-grants-advisor SKILL — so one
|
|
301
|
+
// capability apply stands up the whole grant operation on ANY install door.
|
|
302
|
+
{ key: "stellar-grant-client", needs: null, params: {}, workspaceScoped: true },
|
|
303
|
+
// Cal.com scheduling connector — requires a `calApiKey` param (a `cal_live_...`
|
|
304
|
+
// token), so gate it like the other keyed connectors: skip gracefully unless
|
|
305
|
+
// CALCOM_API_KEY is set. Seeds the `cal_com` tool + booking skills + vault key,
|
|
306
|
+
// which the Cal.com booking→CRM webhook/backfill pipeline depends on.
|
|
307
|
+
{ key: "cal-com", needs: "CALCOM_API_KEY", params: { calApiKey: process.env.CALCOM_API_KEY }, workspaceScoped: false },
|
|
308
|
+
// Generic API-key connector — requires an `apiKey` param, so gate it like the
|
|
309
|
+
// other keyed connectors: skip gracefully unless GENERIC_API_KEY is set (else
|
|
310
|
+
// the applier 500s with "requires parameter apiKey").
|
|
311
|
+
{ key: "generic-apikey", needs: "GENERIC_API_KEY", params: { apiKey: process.env.GENERIC_API_KEY }, workspaceScoped: false },
|
|
312
|
+
{
|
|
313
|
+
key: "unipile-linkedin",
|
|
314
|
+
needs: "UNIPILE_API_KEY",
|
|
315
|
+
params: {
|
|
316
|
+
unipileApiKey: process.env.UNIPILE_API_KEY,
|
|
317
|
+
...(process.env.UNIPILE_BASE_URL && { unipileBaseUrl: process.env.UNIPILE_BASE_URL }),
|
|
318
|
+
...(process.env.UNIPILE_ACCOUNT_ID && { unipileAccountId: process.env.UNIPILE_ACCOUNT_ID }),
|
|
319
|
+
},
|
|
320
|
+
workspaceScoped: false,
|
|
321
|
+
},
|
|
322
|
+
{
|
|
323
|
+
key: "telegram-bridge",
|
|
324
|
+
needs: "TELEGRAM_BOT_TOKEN",
|
|
325
|
+
params: { telegramBotToken: process.env.TELEGRAM_BOT_TOKEN },
|
|
326
|
+
workspaceScoped: false,
|
|
327
|
+
},
|
|
328
|
+
];
|
|
329
|
+
/**
|
|
330
|
+
* Resolve the automations templates directory — CLI-BUNDLED (like the skills at
|
|
331
|
+
* templates/skills). The backend stays agnostic: it ships no feature templates;
|
|
332
|
+
* automation config is bundled with the CLI that seeds it.
|
|
333
|
+
*
|
|
334
|
+
* Resolution order:
|
|
335
|
+
* 1. AUTOMATION_TEMPLATES_DIR env var (override for CI / custom installs)
|
|
336
|
+
* 2. CLI-bundled default: <cli-package-root>/templates/automations
|
|
337
|
+
*/
|
|
338
|
+
function resolveAutomationsTemplatesDir() {
|
|
339
|
+
if (process.env.AUTOMATION_TEMPLATES_DIR) {
|
|
340
|
+
const override = process.env.AUTOMATION_TEMPLATES_DIR;
|
|
341
|
+
return fs.existsSync(override) ? override : null;
|
|
342
|
+
}
|
|
343
|
+
const thisFile = fileURLToPath(import.meta.url);
|
|
344
|
+
const cliRoot = path.resolve(path.dirname(thisFile), "..", "..");
|
|
345
|
+
const candidate = path.resolve(cliRoot, "templates", "automations");
|
|
346
|
+
return fs.existsSync(candidate) ? candidate : null;
|
|
347
|
+
}
|
|
348
|
+
/**
|
|
349
|
+
* Apply the agency capability templates idempotently via the Control Plane catalog.
|
|
350
|
+
*
|
|
351
|
+
* - POSTs to /capabilities/apply with { templateKey, params, workspaceId? }; the
|
|
352
|
+
* pod loads the definition from the CP catalog (the single source of truth — no
|
|
353
|
+
* local template files anymore).
|
|
354
|
+
* - workspaceScoped capabilities (those bundling PLAYBOOKS, which the backend
|
|
355
|
+
* rejects pod-wide with "playbooks require a workspaceId") pass workspaceId so
|
|
356
|
+
* the playbooks land in the chosen workspace. Pod-wide tools/skills omit it so
|
|
357
|
+
* the browser's capabilities view and the agent see them everywhere.
|
|
358
|
+
* - Skips secret-backed templates when the required env var is absent.
|
|
359
|
+
* - Skips discord-bot (already applied via applyBridgeCapability).
|
|
360
|
+
* - Logs a concise per-template result line (applied / reused / skipped).
|
|
361
|
+
* - NEVER throws — errors are warned so the overall setup continues.
|
|
362
|
+
*/
|
|
363
|
+
async function applyAgencyCapabilities(cfg, workspaceId) {
|
|
364
|
+
log.dim("Seeding agency capabilities from the Control Plane catalog…");
|
|
365
|
+
for (const item of AGENCY_CAPABILITY_PLAN) {
|
|
366
|
+
if (item.needs && !process.env[item.needs]) {
|
|
367
|
+
log.dim(` [skip] ${item.key} — set ${item.needs} to seed it`);
|
|
368
|
+
continue;
|
|
369
|
+
}
|
|
370
|
+
try {
|
|
371
|
+
const res = (await hubPost("/capabilities/apply", item.workspaceScoped
|
|
372
|
+
? { templateKey: item.key, params: item.params, workspaceId }
|
|
373
|
+
: { templateKey: item.key, params: item.params }, cfg));
|
|
374
|
+
const container = res.created?.container;
|
|
375
|
+
const tools = res.created?.tools?.length ?? 0;
|
|
376
|
+
const skills = res.created?.skills?.length ?? 0;
|
|
377
|
+
const containerLabel = container
|
|
378
|
+
? `${container.name}(${container.status})`
|
|
379
|
+
: "—";
|
|
380
|
+
log.dim(` [ok] ${item.key.padEnd(18)} container=${containerLabel} tools=${tools} skills=${skills}`);
|
|
381
|
+
}
|
|
382
|
+
catch (err) {
|
|
383
|
+
log.warn(` [error] ${item.key} — ${err.message}`);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
// ── Agency skill seeding (instruction skills) ──────────────────────────────
|
|
388
|
+
/**
|
|
389
|
+
* Resolve the bundled skills templates directory (standard SKILL.md dirs).
|
|
390
|
+
* Shared with `synap skill add --bundled` via resolveBundledSkillsDir.
|
|
391
|
+
*/
|
|
392
|
+
export function resolveBundledSkillsDir() {
|
|
393
|
+
const thisFile = fileURLToPath(import.meta.url);
|
|
394
|
+
const cliRoot = path.resolve(path.dirname(thisFile), "..", "..");
|
|
395
|
+
const candidate = path.resolve(cliRoot, "templates", "skills");
|
|
396
|
+
return fs.existsSync(candidate) ? candidate : null;
|
|
397
|
+
}
|
|
398
|
+
/**
|
|
399
|
+
* Seed the bundled Synap-native skills from templates/skills/ (standard SKILL.md
|
|
400
|
+
* directories). Each becomes a pod-wide instruction skill via /agent-skills/import;
|
|
401
|
+
* references/ files become linked documents. Idempotent (slug-unique guard).
|
|
402
|
+
*/
|
|
403
|
+
async function applyAgencySkills(cfg) {
|
|
404
|
+
const skillsDir = resolveBundledSkillsDir();
|
|
405
|
+
if (!skillsDir) {
|
|
406
|
+
log.warn("Bundled skill templates not found — skipping.");
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
const userId = await resolveUserId(cfg);
|
|
410
|
+
const dirs = discoverSkillDirs(skillsDir);
|
|
411
|
+
log.dim(`Seeding ${dirs.length} bundled skill(s) from ${skillsDir}…`);
|
|
412
|
+
for (const dir of dirs) {
|
|
413
|
+
const parsed = parseSkillDir(dir);
|
|
414
|
+
if (!parsed)
|
|
415
|
+
continue;
|
|
416
|
+
const res = await importSkill(parsed, userId, cfg);
|
|
417
|
+
const marker = res.status === "error" ? "!!" : res.status === "exists" ? "skip" : "ok";
|
|
418
|
+
const tag = res.status === "exists" ? "already seeded"
|
|
419
|
+
: res.status === "error" ? `[error] ${res.error}`
|
|
420
|
+
: `imported${res.docs ? ` · ${res.docs} docs` : ""}`;
|
|
421
|
+
const line = ` [${marker}]${" ".repeat(Math.max(1, 5 - marker.length))}${parsed.slug.padEnd(34)} ${tag}`;
|
|
422
|
+
if (res.status === "error")
|
|
423
|
+
log.warn(line);
|
|
424
|
+
else
|
|
425
|
+
log.dim(line);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
/**
|
|
429
|
+
* Static structural validation of an automation flow, run at seed time. Catches
|
|
430
|
+
* the silent-failure class the engine can't warn about at runtime: a template
|
|
431
|
+
* reference to a step that doesn't exist (resolves to ""), a condition node
|
|
432
|
+
* whose out-edges carry no sourceHandle (the branch prune becomes a no-op — both
|
|
433
|
+
* branches run, the exact link-gate bug), and a cycle (throws at runtime).
|
|
434
|
+
* Returns human-readable issue strings; the caller logs them warn-only.
|
|
435
|
+
*/
|
|
436
|
+
export function validateFlowStructure(flow) {
|
|
437
|
+
const issues = [];
|
|
438
|
+
const nodeIds = new Set(flow.nodes.map((n) => n.id));
|
|
439
|
+
const ROOTS = new Set(["trigger", "steps", "automation", "loop", "item"]);
|
|
440
|
+
// 1. Every {{ ... }} reference has a known root, and steps.<id> targets a real node.
|
|
441
|
+
const collect = (v, acc) => {
|
|
442
|
+
if (typeof v === "string") {
|
|
443
|
+
for (const m of v.matchAll(/\{\{([^}]+)\}\}/g))
|
|
444
|
+
acc.push(m[1].trim());
|
|
445
|
+
}
|
|
446
|
+
else if (Array.isArray(v)) {
|
|
447
|
+
for (const x of v)
|
|
448
|
+
collect(x, acc);
|
|
449
|
+
}
|
|
450
|
+
else if (v && typeof v === "object") {
|
|
451
|
+
for (const x of Object.values(v))
|
|
452
|
+
collect(x, acc);
|
|
453
|
+
}
|
|
454
|
+
};
|
|
455
|
+
for (const node of flow.nodes) {
|
|
456
|
+
const refs = [];
|
|
457
|
+
collect(node.data, refs);
|
|
458
|
+
// condition/switch expressions use BARE paths (no {{ }}), e.g.
|
|
459
|
+
// `steps.detect.output.x === true` — scan them for step refs too.
|
|
460
|
+
if (node.type === "condition" || node.type === "switch") {
|
|
461
|
+
const expr = node.data.expression;
|
|
462
|
+
if (typeof expr === "string") {
|
|
463
|
+
for (const m of expr.matchAll(/\b(steps\.[\w-]+(?:\.[\w-]+)*)/g))
|
|
464
|
+
refs.push(m[1]);
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
for (const ref of refs) {
|
|
468
|
+
const [root, stepId] = ref.split(".");
|
|
469
|
+
if (!ROOTS.has(root)) {
|
|
470
|
+
issues.push(`node "${node.id}" references unknown root {{${ref}}} (expected ${[...ROOTS].join("/")})`);
|
|
471
|
+
}
|
|
472
|
+
else if (root === "steps" && stepId && !nodeIds.has(stepId)) {
|
|
473
|
+
issues.push(`node "${node.id}" references missing step "${ref}" — no node "${stepId}"`);
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
// 2. Condition out-edges must carry a sourceHandle, else pruning is a silent no-op.
|
|
478
|
+
for (const node of flow.nodes) {
|
|
479
|
+
if (node.type !== "condition")
|
|
480
|
+
continue;
|
|
481
|
+
const out = flow.edges.filter((e) => e.source === node.id);
|
|
482
|
+
const handled = out.filter((e) => typeof e.sourceHandle === "string");
|
|
483
|
+
if (out.length > 0 && handled.length === 0) {
|
|
484
|
+
issues.push(`condition node "${node.id}" has out-edges but none carry a sourceHandle ("yes"/"no") — the branch prune is a silent no-op (both branches run)`);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
// 3. Cycle detection (Kahn) — a cycle throws at runtime.
|
|
488
|
+
const inDeg = new Map();
|
|
489
|
+
const adj = new Map();
|
|
490
|
+
for (const n of flow.nodes) {
|
|
491
|
+
inDeg.set(n.id, 0);
|
|
492
|
+
adj.set(n.id, []);
|
|
493
|
+
}
|
|
494
|
+
for (const e of flow.edges) {
|
|
495
|
+
const s = e.source;
|
|
496
|
+
const t = e.target;
|
|
497
|
+
if (s && t && adj.has(s) && inDeg.has(t)) {
|
|
498
|
+
adj.get(s).push(t);
|
|
499
|
+
inDeg.set(t, (inDeg.get(t) ?? 0) + 1);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
const queue = [...inDeg.entries()].filter(([, d]) => d === 0).map(([id]) => id);
|
|
503
|
+
let visited = 0;
|
|
504
|
+
while (queue.length) {
|
|
505
|
+
const id = queue.shift();
|
|
506
|
+
visited++;
|
|
507
|
+
for (const t of adj.get(id) ?? []) {
|
|
508
|
+
inDeg.set(t, inDeg.get(t) - 1);
|
|
509
|
+
if (inDeg.get(t) === 0)
|
|
510
|
+
queue.push(t);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
if (visited < flow.nodes.length) {
|
|
514
|
+
issues.push(`flow has a cycle — ${flow.nodes.length - visited} node(s) cannot be ordered (would throw at runtime)`);
|
|
515
|
+
}
|
|
516
|
+
return issues;
|
|
517
|
+
}
|
|
518
|
+
/**
|
|
519
|
+
* Set behavioral config on the `discord` tool's metadata (the pod-resident config
|
|
520
|
+
* home — read back by the bridge at runtime). Idempotent: merges into the existing
|
|
521
|
+
* metadata.discord. Best-effort — never throws (setup continues if the tool isn't
|
|
522
|
+
* found yet). Uses PATCH /tools/:id which does NOT reset tool approval for metadata.
|
|
523
|
+
*/
|
|
524
|
+
async function setDiscordToolConfig(cfg, workspaceId, patch) {
|
|
525
|
+
try {
|
|
526
|
+
const res = (await hubGet("/tools", { workspaceId }, cfg));
|
|
527
|
+
const tool = unwrapList(res, ["tools"]).find((t) => t?.name === "discord");
|
|
528
|
+
if (!tool?.id) {
|
|
529
|
+
log.dim(" [skip] discord tool not found — config will use defaults");
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
const meta = (tool.metadata ?? {});
|
|
533
|
+
const discord = (meta.discord ?? {});
|
|
534
|
+
const nextMeta = { ...meta, discord: { ...discord, ...patch } };
|
|
535
|
+
await hubPatch(`/tools/${tool.id}`, { metadata: nextMeta }, cfg);
|
|
536
|
+
log.success(`Discord config updated (${Object.keys(patch).join(", ")})`);
|
|
537
|
+
}
|
|
538
|
+
catch (err) {
|
|
539
|
+
log.warn(`Could not set discord tool config: ${err.message}`);
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
/** Matches a Synap channels.id / connection id (a UUID) — distinct from a raw
|
|
543
|
+
* Discord snowflake, which is a numeric string. */
|
|
544
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
545
|
+
/**
|
|
546
|
+
* Best-effort: resolve the pod's Google/Nango connection id — the `secrets` row
|
|
547
|
+
* id the automation executor expects in `connectionSelector.connectionId` (it
|
|
548
|
+
* verifies the id belongs to the nango-google capability + the acting user).
|
|
549
|
+
*
|
|
550
|
+
* Finds the nango-google capability container (by name) then its default (or
|
|
551
|
+
* first) connection. Returns null when Google isn't connected yet or the door
|
|
552
|
+
* isn't deployed — capability nodes then fall back to default-connection
|
|
553
|
+
* resolution at runtime. Never throws.
|
|
554
|
+
*/
|
|
555
|
+
async function resolveGoogleConnectionId(cfg, workspaceId) {
|
|
556
|
+
try {
|
|
557
|
+
const res = await hubGet("/capabilities/containers", { workspaceId }, cfg);
|
|
558
|
+
const containers = unwrapList(res, ["capabilities"]);
|
|
559
|
+
const google = containers.find((c) => /google/i.test(c.name ?? ""));
|
|
560
|
+
if (!google?.id)
|
|
561
|
+
return null;
|
|
562
|
+
const connRes = await hubGet(`/capabilities/${google.id}/connections`, {}, cfg);
|
|
563
|
+
const conns = unwrapList(connRes, ["connections"]);
|
|
564
|
+
if (conns.length === 0)
|
|
565
|
+
return null;
|
|
566
|
+
return (conns.find((c) => c.isDefault) ?? conns[0]).id;
|
|
567
|
+
}
|
|
568
|
+
catch {
|
|
569
|
+
return null;
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
/**
|
|
573
|
+
* Apply automation templates from the CLI-bundled templates/automations/*.automation.json.
|
|
574
|
+
*
|
|
575
|
+
* For each file:
|
|
576
|
+
* - Fill placeholders + validate references in the flowDefinition:
|
|
577
|
+
* · `skill` node → resolve data.skillId by data.skillName (GET /skills).
|
|
578
|
+
* · `capability` node → validate data.verbId against the seeded skills (a verb
|
|
579
|
+
* IS a skill name); fill an empty/placeholder connectionSelector.connectionId
|
|
580
|
+
* with the pod's Google connection id (dropped when Google isn't connected).
|
|
581
|
+
* · `output` node → fill config.channelId only with a Synap channels.id
|
|
582
|
+
* (UUID); the Discord snowflake goes elsewhere, so a non-UUID drops the
|
|
583
|
+
* placeholder for the channelType fallback.
|
|
584
|
+
* A required skill or verb that isn't seeded → WARN + skip that automation (a
|
|
585
|
+
* dangling reference would fail at runtime), mirroring the skill-node handling.
|
|
586
|
+
* - UPSERT by name: PATCH /automations/:id when it already exists, else
|
|
587
|
+
* POST /automations/create. Idempotent — re-running updates in place.
|
|
588
|
+
* - Aborts (never partial) if the prerequisite skills/automations fetch fails.
|
|
589
|
+
*/
|
|
590
|
+
async function applyAutomationTemplates(cfg, workspaceId, feedChannelId) {
|
|
591
|
+
const templatesDir = resolveAutomationsTemplatesDir();
|
|
592
|
+
if (!templatesDir) {
|
|
593
|
+
log.warn("Automation templates dir not found — skipping. " +
|
|
594
|
+
"Set AUTOMATION_TEMPLATES_DIR=<path> to seed from a custom location.");
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
597
|
+
const files = fs.readdirSync(templatesDir).filter((f) => f.endsWith(".automation.json"));
|
|
598
|
+
if (files.length === 0) {
|
|
599
|
+
log.dim("No *.automation.json templates found — skipping automation seeding.");
|
|
600
|
+
return;
|
|
601
|
+
}
|
|
602
|
+
log.dim(`Seeding ${files.length} automation template(s) from ${templatesDir}…`);
|
|
603
|
+
const userId = await resolveUserId(cfg);
|
|
604
|
+
// Fetch existing automations once (avoid N+1 round-trips).
|
|
605
|
+
let existingAutos = [];
|
|
606
|
+
try {
|
|
607
|
+
const res = await hubGet("/automations", { userId, workspaceId }, cfg);
|
|
608
|
+
existingAutos = unwrapList(res, ["automations"]);
|
|
609
|
+
}
|
|
610
|
+
catch (err) {
|
|
611
|
+
// Abort rather than proceed on an empty list: a transient fetch error would
|
|
612
|
+
// make every template take the create path → duplicate automations on re-run.
|
|
613
|
+
log.warn(`Could not fetch existing automations — aborting seeding: ${err.message}`);
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
// Fetch existing skills once — needed for skill-node injection.
|
|
617
|
+
let existingSkills = [];
|
|
618
|
+
try {
|
|
619
|
+
const res = await hubGet("/skills", { userId, workspaceId }, cfg);
|
|
620
|
+
existingSkills = unwrapList(res, ["skills"]);
|
|
621
|
+
}
|
|
622
|
+
catch (err) {
|
|
623
|
+
// Abort rather than proceed on an empty list: every skill/verb reference would
|
|
624
|
+
// resolve to "missing" and ALL automations would be wrongly skipped.
|
|
625
|
+
log.warn(`Could not fetch existing skills — aborting seeding: ${err.message}`);
|
|
626
|
+
return;
|
|
627
|
+
}
|
|
628
|
+
// Resolve the Google/Nango connection id once — capability nodes pin their
|
|
629
|
+
// mailbox/drive with it. Null when Google isn't connected yet (nodes then use
|
|
630
|
+
// the default connection at runtime).
|
|
631
|
+
const googleConnectionId = await resolveGoogleConnectionId(cfg, workspaceId);
|
|
632
|
+
for (const file of files) {
|
|
633
|
+
const filePath = path.join(templatesDir, file);
|
|
634
|
+
let tpl;
|
|
635
|
+
try {
|
|
636
|
+
tpl = JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
|
637
|
+
}
|
|
638
|
+
catch (err) {
|
|
639
|
+
log.warn(` [error] ${file} — could not parse: ${err.message}`);
|
|
640
|
+
continue;
|
|
641
|
+
}
|
|
642
|
+
// ── Fill placeholders + validate references ──────────────────────────────
|
|
643
|
+
// A "placeholder" is an empty string or an <angle-bracket> token. Skill/verb
|
|
644
|
+
// references are seeded BEFORE this runs (applyAgencySkills / applyAgency-
|
|
645
|
+
// Capabilities), so a missing one means seeding failed — a dangling reference
|
|
646
|
+
// would fail at runtime, so we skip that automation loudly instead of seeding
|
|
647
|
+
// a dead one (mirrors the original skill-node handling).
|
|
648
|
+
const flow = tpl.flowDefinition;
|
|
649
|
+
const isPlaceholder = (v) => typeof v === "string" && (v.trim() === "" || /^<.*>$/.test(v.trim()));
|
|
650
|
+
let missing = null;
|
|
651
|
+
for (const node of flow.nodes) {
|
|
652
|
+
// `skill` node → resolve skillId by name.
|
|
653
|
+
if (node.type === "skill") {
|
|
654
|
+
const skillName = node.data.skillName;
|
|
655
|
+
if (skillName) {
|
|
656
|
+
const match = existingSkills.find((s) => s.name === skillName);
|
|
657
|
+
if (match)
|
|
658
|
+
node.data = { ...node.data, skillId: match.id };
|
|
659
|
+
else
|
|
660
|
+
missing ??= `skill "${skillName}"`;
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
// `capability` node → validate verbId (a verb IS a seeded skill NAME) and
|
|
664
|
+
// fill an empty/placeholder connection id with the Google/Nango connection.
|
|
665
|
+
if (node.type === "capability") {
|
|
666
|
+
const data = node.data;
|
|
667
|
+
if (data.verbId && !existingSkills.some((s) => s.name === data.verbId)) {
|
|
668
|
+
missing ??= `verb "${data.verbId}"`;
|
|
669
|
+
}
|
|
670
|
+
const sel = data.connectionSelector;
|
|
671
|
+
if (sel && isPlaceholder(sel.connectionId)) {
|
|
672
|
+
if (googleConnectionId) {
|
|
673
|
+
node.data = {
|
|
674
|
+
...node.data,
|
|
675
|
+
connectionSelector: { ...sel, connectionId: googleConnectionId },
|
|
676
|
+
};
|
|
677
|
+
}
|
|
678
|
+
else {
|
|
679
|
+
// No Google connected yet — DROP the placeholder key so the executor's
|
|
680
|
+
// default-connection resolution kicks in. Leaving a `<...>` literal
|
|
681
|
+
// would be treated as a real id and rejected. Symmetric with channelId.
|
|
682
|
+
const { connectionId: _drop, ...restSel } = sel;
|
|
683
|
+
node.data = { ...node.data, connectionSelector: restSel };
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
// `output` node → fill an empty/placeholder config.channelId. NOTE: the
|
|
688
|
+
// executor treats config.channelId as a Synap `channels.id` (UUID), NOT a
|
|
689
|
+
// Discord snowflake — so only a UUID `feedChannelId` is pinned here. When
|
|
690
|
+
// it isn't one, drop the placeholder so the node's `channelType` fallback
|
|
691
|
+
// ('proactive'/'personal_thread') auto-resolves the user's feed channel,
|
|
692
|
+
// which mirrors out to the bound Discord channel. Keeps the automation LIVE.
|
|
693
|
+
if (node.type === "output") {
|
|
694
|
+
const data = node.data;
|
|
695
|
+
const config = data.config;
|
|
696
|
+
if (config && isPlaceholder(config.channelId)) {
|
|
697
|
+
if (feedChannelId && UUID_RE.test(feedChannelId)) {
|
|
698
|
+
node.data = { ...node.data, config: { ...config, channelId: feedChannelId } };
|
|
699
|
+
}
|
|
700
|
+
else if (config.channelType) {
|
|
701
|
+
const { channelId: _drop, ...restConfig } = config;
|
|
702
|
+
node.data = { ...node.data, config: restConfig };
|
|
703
|
+
}
|
|
704
|
+
else {
|
|
705
|
+
log.warn(` [warn] ${tpl.name} — output node "${node.id}" has a placeholder ` +
|
|
706
|
+
"channelId and no channelType fallback; its post target is unresolved.");
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
if (missing) {
|
|
712
|
+
log.warn(` [skip] ${tpl.name} — required ${missing} not seeded; skipping ` +
|
|
713
|
+
"(re-run after it exists, else the automation would fail at runtime).");
|
|
714
|
+
continue;
|
|
715
|
+
}
|
|
716
|
+
// Structural validation (warn-only): surface silent-failure footguns the
|
|
717
|
+
// runtime can't warn about — dangling step refs, handle-less condition
|
|
718
|
+
// edges, cycles. Warn (don't skip) so a valid-but-imperfect template still
|
|
719
|
+
// seeds; a genuinely broken one is now visible at seed time, not silently.
|
|
720
|
+
for (const issue of validateFlowStructure(flow)) {
|
|
721
|
+
log.warn(` [warn] ${tpl.name} — ${issue}`);
|
|
722
|
+
}
|
|
723
|
+
// ── UPSERT by name (idempotent — re-running updates in place) ─────────────
|
|
724
|
+
const body = {
|
|
725
|
+
userId,
|
|
726
|
+
workspaceId,
|
|
727
|
+
name: tpl.name,
|
|
728
|
+
triggerType: tpl.triggerType,
|
|
729
|
+
status: tpl.status,
|
|
730
|
+
triggerConfig: tpl.triggerConfig,
|
|
731
|
+
flowDefinition: flow,
|
|
732
|
+
metadata: { ...(tpl.metadata ?? {}), source: "bridge-setup" },
|
|
733
|
+
...(tpl.state ? { state: tpl.state } : {}),
|
|
734
|
+
};
|
|
735
|
+
const existing = existingAutos.find((a) => a.name === tpl.name);
|
|
736
|
+
if (existing) {
|
|
737
|
+
try {
|
|
738
|
+
await hubPatch(`/automations/${existing.id}`, body, cfg);
|
|
739
|
+
log.dim(` [ok] ${tpl.name} — updated (id=${existing.id})`);
|
|
740
|
+
log.dim(` ${tpl.status === "draft" ? "review + activate" : "open"} → ${cfg.podUrl}/open/${existing.id}`);
|
|
741
|
+
}
|
|
742
|
+
catch (err) {
|
|
743
|
+
log.warn(` [error] ${tpl.name} — update failed: ${err.message}`);
|
|
744
|
+
}
|
|
745
|
+
continue;
|
|
746
|
+
}
|
|
747
|
+
try {
|
|
748
|
+
const newAuto = await hubPost("/automations/create", body, cfg);
|
|
749
|
+
const autoId = newAuto?.id;
|
|
750
|
+
if (autoId) {
|
|
751
|
+
log.dim(` [ok] ${tpl.name} — created (id=${autoId})`);
|
|
752
|
+
log.dim(` ${tpl.status === "draft" ? "review + activate" : "open"} → ${cfg.podUrl}/open/${autoId}`);
|
|
753
|
+
}
|
|
754
|
+
else if (newAuto?.status === "proposed") {
|
|
755
|
+
// These are operator-installed TEMPLATES, not AI-authored automations —
|
|
756
|
+
// so apply them directly as the user instead of leaving a proposal. The
|
|
757
|
+
// operator is the one running bridge-setup, so we approve their own
|
|
758
|
+
// template-install proposal (POST /proposals/:id/approve runs as the
|
|
759
|
+
// caller = the operator), materializing the automation attributed to them.
|
|
760
|
+
const proposalId = newAuto.proposalId;
|
|
761
|
+
if (proposalId) {
|
|
762
|
+
try {
|
|
763
|
+
await hubPost(`/proposals/${proposalId}/approve`, {}, cfg);
|
|
764
|
+
log.dim(` [ok] ${tpl.name} — applied (template)`);
|
|
765
|
+
}
|
|
766
|
+
catch (approveErr) {
|
|
767
|
+
log.warn(` [proposed] ${tpl.name} (${proposalId}) — auto-approve failed ` +
|
|
768
|
+
`(${approveErr.message}); approve it in Synap Studio.`);
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
else {
|
|
772
|
+
log.warn(` [proposed] ${tpl.name} — no proposalId returned; approve it in Synap Studio.`);
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
else {
|
|
776
|
+
log.warn(` [warn] ${tpl.name} — unexpected response: ${JSON.stringify(newAuto)}`);
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
catch (err) {
|
|
780
|
+
log.warn(` [error] ${tpl.name} — ${err.message}`);
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
785
|
+
/**
|
|
786
|
+
* Provision a Discord AGENT key linked to the operator, via the shared
|
|
787
|
+
* `provisionAgentKey()` wrapper (same door every target in `lib/targets.ts`
|
|
788
|
+
* uses). The resulting key carries linkedUserId=<operator>, so the backend
|
|
789
|
+
* remaps its reads/redeems to the operator's data floor.
|
|
790
|
+
*
|
|
791
|
+
* NOTE — behavior change from the previous hand-rolled version: this always
|
|
792
|
+
* mints a FRESH key (provisionAgentKey sends `idempotent: false`), whereas the
|
|
793
|
+
* old code reused the stored key across re-runs (`idempotent: true`) and only
|
|
794
|
+
* reprovisioned when it no longer validated. Re-running `bridge-setup` now
|
|
795
|
+
* rotates the Discord agent's key every time — a running bridge process must
|
|
796
|
+
* be restarted to pick up the new key. `requireApproval: false` (mirroring
|
|
797
|
+
* `mcp.ts`'s `mcp url` provisioning) keeps this a one-shot, non-interactive
|
|
798
|
+
* mint rather than opening a browser approval wait on every run.
|
|
799
|
+
*
|
|
800
|
+
* @param podUrl Pod base URL.
|
|
801
|
+
* @param operatorKey The operator's raw hub key (authorizes the provisioning).
|
|
802
|
+
* @param workspaceId Workspace to enroll the agent into (omit for all).
|
|
803
|
+
* @returns the Discord agent key to write as SYNAP_HUB_API_KEY.
|
|
804
|
+
*/
|
|
805
|
+
async function provisionDiscordAgentKey(podUrl, operatorKey, workspaceId) {
|
|
806
|
+
const spinner = ora("Provisioning Discord agent key…").start();
|
|
807
|
+
let hubApiKey;
|
|
808
|
+
let agentUserId;
|
|
809
|
+
try {
|
|
810
|
+
// idempotent: true — REUSE the existing valid key rather than rotate it. A live
|
|
811
|
+
// bridge holds this key in its .env; minting fresh would revoke it and 401 the
|
|
812
|
+
// bridge until it's redeployed. provisionAgentKey now handles the pod's
|
|
813
|
+
// { alreadyValid: true } reuse response by recovering the plaintext from local
|
|
814
|
+
// storage (verified), so re-running bridge-setup does NOT force a redeploy. It
|
|
815
|
+
// only mints fresh when there's no valid local key to reuse (new machine).
|
|
816
|
+
const result = await provisionAgentKey(podUrl, operatorKey, "discord", {
|
|
817
|
+
requireApproval: false,
|
|
818
|
+
idempotent: true,
|
|
819
|
+
});
|
|
820
|
+
hubApiKey = result.hubApiKey;
|
|
821
|
+
agentUserId = result.agentUserId;
|
|
822
|
+
spinner.succeed(result.reused
|
|
823
|
+
? "Discord agent key reused (same key — no rotation, no bridge restart needed)"
|
|
824
|
+
: "Discord agent key minted (new key — redeploy the bridge to pick it up)");
|
|
825
|
+
}
|
|
826
|
+
catch (err) {
|
|
827
|
+
spinner.fail("Could not provision the Discord agent key");
|
|
828
|
+
log.error(err instanceof Error ? err.message : String(err));
|
|
829
|
+
process.exit(1);
|
|
830
|
+
}
|
|
831
|
+
// Persist so downstream steps (governance lookup, .env write) can read it back.
|
|
832
|
+
setSurfaceAgentKey("discord", { hubApiKey, agentUserId });
|
|
833
|
+
// Enroll the agent user into the operator's workspace(s) using the OPERATOR
|
|
834
|
+
// key (before the bridge ever uses the agent key).
|
|
835
|
+
await enrollAgentIfNeeded(podUrl, operatorKey, agentUserId, workspaceId);
|
|
836
|
+
// Same CONTEXT.md routing file every other provisioned agent gets.
|
|
837
|
+
try {
|
|
838
|
+
await configureAgentContext(podUrl, operatorKey, "discord", agentUserId);
|
|
839
|
+
}
|
|
840
|
+
catch (err) {
|
|
841
|
+
log.warn(`Agent context wizard failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
842
|
+
}
|
|
843
|
+
return hubApiKey;
|
|
844
|
+
}
|
|
845
|
+
async function resolveWorkspaceId(explicit, defaultWs, cfg) {
|
|
846
|
+
// An explicit --workspace-id wins outright. Otherwise ALWAYS ask (with the
|
|
847
|
+
// saved profile workspace pre-selected) — the operator must consciously pick
|
|
848
|
+
// the workspace every channel + entity the bridge creates will live in (e.g.
|
|
849
|
+
// your CRM workspace), instead of silently inheriting the saved default.
|
|
850
|
+
if (explicit)
|
|
851
|
+
return explicit;
|
|
852
|
+
let list = [];
|
|
853
|
+
try {
|
|
854
|
+
const res = (await hubGet("/workspaces", {}, cfg));
|
|
855
|
+
const arr = unwrapList(res, ["workspaces"]);
|
|
856
|
+
list = arr.filter((w) => w?.id);
|
|
857
|
+
}
|
|
858
|
+
catch {
|
|
859
|
+
return defaultWs;
|
|
860
|
+
}
|
|
861
|
+
if (list.length === 0)
|
|
862
|
+
return defaultWs;
|
|
863
|
+
if (list.length === 1)
|
|
864
|
+
return list[0].id;
|
|
865
|
+
const defaultIdx = defaultWs ? list.findIndex((w) => w.id === defaultWs) : -1;
|
|
866
|
+
const { ws } = await prompts({
|
|
867
|
+
type: "select",
|
|
868
|
+
name: "ws",
|
|
869
|
+
message: "Which workspace should the bridge write into? (channels + entities land here)",
|
|
870
|
+
choices: list.map((w) => ({ title: `${w.name ?? "(unnamed)"} ${chalk.dim(w.id)}`, value: w.id })),
|
|
871
|
+
initial: defaultIdx >= 0 ? defaultIdx : 0,
|
|
872
|
+
});
|
|
873
|
+
return ws || defaultWs;
|
|
874
|
+
}
|
|
875
|
+
async function resolveBotToken(provided) {
|
|
876
|
+
if (provided)
|
|
877
|
+
return provided;
|
|
878
|
+
const { token } = await prompts({
|
|
879
|
+
type: "password",
|
|
880
|
+
name: "token",
|
|
881
|
+
message: "Discord bot token (stored in the pod vault; leave blank to skip):",
|
|
882
|
+
});
|
|
883
|
+
return token || undefined;
|
|
884
|
+
}
|
|
885
|
+
async function applyBridgeCapability(bridgeDir, workspaceId, cfg, botToken) {
|
|
886
|
+
const spinner = ora("Provisioning capability…").start();
|
|
887
|
+
// Read the capability definition from the bridge repo — ONE source of truth.
|
|
888
|
+
const capPath = path.join(bridgeDir, "src", "synapCapabilities.js");
|
|
889
|
+
let rawDef;
|
|
890
|
+
try {
|
|
891
|
+
const require = createRequire(import.meta.url);
|
|
892
|
+
const resolved = require.resolve(capPath);
|
|
893
|
+
if (require.cache && require.cache[resolved])
|
|
894
|
+
delete require.cache[resolved];
|
|
895
|
+
const mod = require(capPath);
|
|
896
|
+
rawDef = mod.CAPABILITY_DEFINITION;
|
|
897
|
+
}
|
|
898
|
+
catch (err) {
|
|
899
|
+
spinner.fail("Capability definition not found");
|
|
900
|
+
log.dim(` Looked in: ${capPath}`);
|
|
901
|
+
log.dim(` ${err.message}`);
|
|
902
|
+
return {};
|
|
903
|
+
}
|
|
904
|
+
if (!rawDef) {
|
|
905
|
+
spinner.fail("No CAPABILITY_DEFINITION export in the bridge");
|
|
906
|
+
return {};
|
|
907
|
+
}
|
|
908
|
+
// Deep-clone + inject the bot token into the vault credential. With no token,
|
|
909
|
+
// drop the vault + credentialRef so we never store an empty/placeholder secret.
|
|
910
|
+
const def = JSON.parse(JSON.stringify(rawDef));
|
|
911
|
+
if (botToken) {
|
|
912
|
+
for (const v of def.vault ?? []) {
|
|
913
|
+
if (typeof v.value === "string") {
|
|
914
|
+
v.value = v.value.replace("{{botToken}}", botToken);
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
else {
|
|
919
|
+
// No token → drop the vault and any tool credentialRef that referenced it
|
|
920
|
+
// (a bare template-local ref or a vault:// string), so we never store a
|
|
921
|
+
// placeholder secret or leave a dangling reference.
|
|
922
|
+
const vaultRefs = new Set((def.vault ?? []).map((v) => v.ref).filter((r) => Boolean(r)));
|
|
923
|
+
delete def.vault;
|
|
924
|
+
for (const t of def.tools ?? []) {
|
|
925
|
+
if (t.credentialRef &&
|
|
926
|
+
(vaultRefs.has(t.credentialRef) || t.credentialRef.startsWith("vault://"))) {
|
|
927
|
+
delete t.credentialRef;
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
spinner.text = "Provisioning capability (no token → structure only)…";
|
|
931
|
+
}
|
|
932
|
+
try {
|
|
933
|
+
const res = (await hubPost("/capabilities/apply", { definition: def, workspaceId }, cfg));
|
|
934
|
+
const tools = res.created?.tools?.length ?? 0;
|
|
935
|
+
const skills = res.created?.skills?.length ?? 0;
|
|
936
|
+
const vaultRef = res.created?.vault?.[0]?.vaultRef;
|
|
937
|
+
const secretId = res.created?.vault?.[0]?.secretId;
|
|
938
|
+
spinner.succeed(`Capability provisioned: ${res.capabilityKey ?? "?"} (${vaultRef ? "vault + " : ""}${tools} tool, ${skills} skill)`);
|
|
939
|
+
return { vaultRef, secretId };
|
|
940
|
+
}
|
|
941
|
+
catch (err) {
|
|
942
|
+
const msg = err.message;
|
|
943
|
+
spinner.fail("Capability apply failed");
|
|
944
|
+
log.error(` ${msg}`);
|
|
945
|
+
if (botToken) {
|
|
946
|
+
// The bot token MUST be vaulted for the bridge to run — continuing to write
|
|
947
|
+
// a .env and printing "Done" would leave a crash-looping bridge (redeem
|
|
948
|
+
// 404). A frequent cause is a missing/empty VAULT_SERVER_KEY on the target
|
|
949
|
+
// pod's backend (the vault can't encrypt). Fail loud at SETUP time instead.
|
|
950
|
+
if (/VAULT_SERVER_KEY/i.test(msg)) {
|
|
951
|
+
log.dim(" The target pod's secret vault is not configured.");
|
|
952
|
+
log.dim(" Set a durable VAULT_SERVER_KEY (openssl rand -hex 32) in the pod's");
|
|
953
|
+
log.dim(" deploy/.env, redeploy the backend, then re-run bridge-setup.");
|
|
954
|
+
}
|
|
955
|
+
process.exit(1);
|
|
956
|
+
}
|
|
957
|
+
return {};
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
/**
|
|
961
|
+
* Grant the bridge principal redeem access to the bot-token secret — the headless
|
|
962
|
+
* equivalent of the UI "grant access" flow, using the user's own credentials.
|
|
963
|
+
* After this, the token lives ONLY in the vault; the bridge redeems it on boot.
|
|
964
|
+
*/
|
|
965
|
+
async function grantRedeemAccess(secretId, cfg, agentUserId) {
|
|
966
|
+
const spinner = ora("Granting the bridge redeem access…").start();
|
|
967
|
+
try {
|
|
968
|
+
// The bridge redeems AS the agent: the backend binds redemption to the
|
|
969
|
+
// agent key's userId (linkedUserId remap → vault_grants.granted_to must be
|
|
970
|
+
// the AGENT, not the operator). Granting to the operator leaves the bridge
|
|
971
|
+
// with no_grant. Fall back to the operator only if the agent id is unknown.
|
|
972
|
+
const principal = agentUserId || (await resolveUserId(cfg));
|
|
973
|
+
const res = (await hubPost(`/vault/secrets/${secretId}/grant`, { grantedTo: principal, scope: "permanent" }, cfg));
|
|
974
|
+
spinner.succeed(`Redeem access granted${res.reused ? " (existing)" : ""} — token stays in the vault`);
|
|
975
|
+
return Boolean(res.grantId);
|
|
976
|
+
}
|
|
977
|
+
catch (err) {
|
|
978
|
+
const msg = err.message;
|
|
979
|
+
if (msg.includes("404")) {
|
|
980
|
+
spinner.warn("Grant door not deployed yet — deploy the backend, then re-run this command");
|
|
981
|
+
}
|
|
982
|
+
else {
|
|
983
|
+
spinner.warn(`Grant skipped: ${msg}`);
|
|
984
|
+
}
|
|
985
|
+
return false;
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
async function configureProactive(workspaceId, channelRef, cfg) {
|
|
989
|
+
const spinner = ora("Routing proactive posts to Discord…").start();
|
|
990
|
+
const rule = {
|
|
991
|
+
surfaces: [{ kind: "external", provider: "discord", channelRef }],
|
|
992
|
+
};
|
|
993
|
+
try {
|
|
994
|
+
await hubPatch(`/workspaces/${workspaceId}/delivery-preferences`, { deliveryPreferences: { ai_insight: rule, proactive: rule } }, cfg);
|
|
995
|
+
spinner.succeed(`Proactive posts → Discord channel ${channelRef}`);
|
|
996
|
+
log.dim(" (Pod also needs DISCORD_BOT_TOKEN set for outbound to send.)");
|
|
997
|
+
}
|
|
998
|
+
catch (err) {
|
|
999
|
+
const msg = err.message;
|
|
1000
|
+
if (msg.includes("404")) {
|
|
1001
|
+
spinner.warn("Proactive routing needs a backend deploy (door not live yet)");
|
|
1002
|
+
}
|
|
1003
|
+
else {
|
|
1004
|
+
spinner.warn(`Proactive routing skipped: ${msg}`);
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
/**
|
|
1009
|
+
* Upsert KEY=value lines into a dotenv file, preserving comments and any other
|
|
1010
|
+
* keys. Existing managed keys are replaced in place; new ones are appended.
|
|
1011
|
+
*/
|
|
1012
|
+
function upsertEnv(filePath, kv, remove = []) {
|
|
1013
|
+
const dir = path.dirname(filePath);
|
|
1014
|
+
if (!fs.existsSync(dir)) {
|
|
1015
|
+
throw new Error(`bridge dir not found: ${dir}`);
|
|
1016
|
+
}
|
|
1017
|
+
const existing = fs.existsSync(filePath)
|
|
1018
|
+
? fs.readFileSync(filePath, "utf-8")
|
|
1019
|
+
: "";
|
|
1020
|
+
const lines = existing.length ? existing.split("\n") : [];
|
|
1021
|
+
const remaining = new Map(Object.entries(kv));
|
|
1022
|
+
const removeSet = new Set(remove);
|
|
1023
|
+
const out = [];
|
|
1024
|
+
for (const line of lines) {
|
|
1025
|
+
const m = line.match(/^([A-Z0-9_]+)=/);
|
|
1026
|
+
if (m && removeSet.has(m[1]))
|
|
1027
|
+
continue; // drop purged keys entirely
|
|
1028
|
+
if (m && remaining.has(m[1])) {
|
|
1029
|
+
const key = m[1];
|
|
1030
|
+
out.push(`${key}=${remaining.get(key)}`);
|
|
1031
|
+
remaining.delete(key);
|
|
1032
|
+
}
|
|
1033
|
+
else {
|
|
1034
|
+
out.push(line);
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
if (remaining.size) {
|
|
1038
|
+
if (out.length && out[out.length - 1] !== "")
|
|
1039
|
+
out.push("");
|
|
1040
|
+
out.push("# --- Synap pod (written by `synap bridge-setup`) ---");
|
|
1041
|
+
for (const [k, v] of remaining)
|
|
1042
|
+
out.push(`${k}=${v}`);
|
|
1043
|
+
}
|
|
1044
|
+
fs.writeFileSync(filePath, out.join("\n"), { mode: 0o600 });
|
|
1045
|
+
}
|
|
1046
|
+
/**
|
|
1047
|
+
* Data-aware "what you must do next INSIDE Synap" — the discoverability layer:
|
|
1048
|
+
* setup seeds everything it can, but a few things are the operator's call
|
|
1049
|
+
* (approving proposals, activating drafts, turning on feeds). Surface them
|
|
1050
|
+
* explicitly so nothing is silently waiting. Best-effort: never throws.
|
|
1051
|
+
*/
|
|
1052
|
+
async function printSynapNextSteps(cfg, workspaceId) {
|
|
1053
|
+
log.blank();
|
|
1054
|
+
log.heading("Next in Synap — review + activate (nothing else auto-runs until you do)");
|
|
1055
|
+
log.blank();
|
|
1056
|
+
// 1. Pending proposals awaiting the operator's approval.
|
|
1057
|
+
let pending = 0;
|
|
1058
|
+
try {
|
|
1059
|
+
const res = await hubGet("/proposals", { workspaceId, status: "pending" }, cfg);
|
|
1060
|
+
pending = unwrapList(res, ["proposals"]).length;
|
|
1061
|
+
}
|
|
1062
|
+
catch {
|
|
1063
|
+
/* best-effort — fall through to the generic pointer */
|
|
1064
|
+
}
|
|
1065
|
+
if (pending > 0) {
|
|
1066
|
+
console.log(` ${chalk.bold("1.")} ${chalk.yellow(`${pending} proposal(s) await your approval`)} — approve them in the app:`);
|
|
1067
|
+
}
|
|
1068
|
+
else {
|
|
1069
|
+
console.log(` ${chalk.bold("1.")} Approve any pending proposals in the app:`);
|
|
1070
|
+
}
|
|
1071
|
+
console.log(` ${chalk.cyan(`${cfg.podUrl}/proposals`)}`);
|
|
1072
|
+
console.log(` ${chalk.bold("2.")} Automations seed as ${chalk.bold("draft")} — open each ${chalk.dim("review + activate")} link above to turn on the ones you want.`);
|
|
1073
|
+
console.log(` ${chalk.bold("3.")} Feeds are ${chalk.bold("OFF by default")} — turn them on ${chalk.bold("in Discord")} (the bridge hot-reloads, no restart):`);
|
|
1074
|
+
console.log(` mail feed → ${chalk.green("/mail-feed enable:true channel:#your-channel")} · Discord events → ${chalk.green("/events enable:true")}`);
|
|
1075
|
+
console.log(` ${chalk.bold("4.")} Connect Google if you haven't: ${chalk.green(`synap cap enable "Nango — Google Workspace"`)}`);
|
|
1076
|
+
log.blank();
|
|
1077
|
+
}
|
|
1078
|
+
function printNextSteps(clientId, bridgeDir, state) {
|
|
1079
|
+
log.blank();
|
|
1080
|
+
log.heading("Done");
|
|
1081
|
+
log.blank();
|
|
1082
|
+
if (state.vaultRef && state.granted) {
|
|
1083
|
+
console.log(` ${chalk.green("✓")} Bot token in the pod vault, bridge granted redeem access — no env token ${chalk.dim(state.vaultRef)}`);
|
|
1084
|
+
}
|
|
1085
|
+
else if (state.vaultRef && state.hasToken) {
|
|
1086
|
+
console.log(` ${chalk.yellow("⚠")} Token stored in the vault (${chalk.dim(state.vaultRef)}) but the redeem GRANT did not land.`);
|
|
1087
|
+
console.log(` Deploy the backend (grant door) and re-run — the bridge can't log in until the grant exists.`);
|
|
1088
|
+
}
|
|
1089
|
+
console.log(` ${chalk.green("✓")} Bridge .env written (pod creds${state.hasGuild ? " + guild" : ""}; token stays in the vault)`);
|
|
1090
|
+
log.blank();
|
|
1091
|
+
log.heading("Remaining (Discord portal — human only)");
|
|
1092
|
+
log.blank();
|
|
1093
|
+
if (clientId) {
|
|
1094
|
+
const url = `https://discord.com/oauth2/authorize?client_id=${clientId}&permissions=${BOT_PERMISSIONS}&scope=bot+applications.commands`;
|
|
1095
|
+
console.log(` ${chalk.bold("•")} Invite the bot (open, pick your server):`);
|
|
1096
|
+
console.log(` ${chalk.cyan(url)}`);
|
|
1097
|
+
}
|
|
1098
|
+
else {
|
|
1099
|
+
console.log(` ${chalk.bold("•")} Invite URL — re-run with ${chalk.cyan("--client-id <app-id>")} to print it.`);
|
|
1100
|
+
}
|
|
1101
|
+
console.log(` ${chalk.bold("•")} Bot → Privileged Intents → enable ${chalk.bold("MESSAGE CONTENT")}`);
|
|
1102
|
+
if (!state.hasToken) {
|
|
1103
|
+
console.log(` ${chalk.bold("•")} Token not provisioned — re-run with ${chalk.cyan("--bot-token <token>")} (stores it in the vault)`);
|
|
1104
|
+
}
|
|
1105
|
+
if (!state.hasGuild) {
|
|
1106
|
+
console.log(` ${chalk.bold("•")} Server ID — re-run with ${chalk.cyan("--guild-id <id>")} (or set DISCORD_GUILD_ID)`);
|
|
1107
|
+
}
|
|
1108
|
+
log.blank();
|
|
1109
|
+
console.log(` Then: ${chalk.green(`cd ${bridgeDir} && npm run smoke && npm start`)}`);
|
|
1110
|
+
log.blank();
|
|
1111
|
+
}
|
|
1112
|
+
//# sourceMappingURL=bridge-setup.js.map
|