@useconductor/conductor 1.0.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/.claude-plugin/marketplace.json +33 -0
- package/.claude-plugin/plugin.json +23 -0
- package/.eslintrc.json +23 -0
- package/.gitattributes +6 -0
- package/.github/FUNDING.yml +15 -0
- package/.github/ISSUE_TEMPLATE/bug_report.yml +91 -0
- package/.github/ISSUE_TEMPLATE/config.yml +8 -0
- package/.github/ISSUE_TEMPLATE/feature_request.yml +63 -0
- package/.github/ISSUE_TEMPLATE/plugin_request.yml +71 -0
- package/.github/README.md +13 -0
- package/.github/workflows/README.md +22 -0
- package/.github/workflows/auto-release.yml +112 -0
- package/.github/workflows/ci.yml +49 -0
- package/.github/workflows/claude-code-review.yml +44 -0
- package/.github/workflows/claude.yml +36 -0
- package/.github/workflows/sync-install.yml +47 -0
- package/.mcp.json +9 -0
- package/.prettierrc.json +7 -0
- package/C.png +0 -0
- package/CHANGELOG.md +74 -0
- package/CLAUDE.md +118 -0
- package/CONTRIBUTING.md +231 -0
- package/LICENSE +201 -0
- package/README.md +179 -0
- package/SECURITY.md +47 -0
- package/commands/conductor-setup.md +11 -0
- package/commands/conductor-status.md +7 -0
- package/dist/ai/base.d.ts +44 -0
- package/dist/ai/base.d.ts.map +1 -0
- package/dist/ai/base.js +47 -0
- package/dist/ai/base.js.map +1 -0
- package/dist/ai/claude.d.ts +11 -0
- package/dist/ai/claude.d.ts.map +1 -0
- package/dist/ai/claude.js +149 -0
- package/dist/ai/claude.js.map +1 -0
- package/dist/ai/gemini.d.ts +15 -0
- package/dist/ai/gemini.d.ts.map +1 -0
- package/dist/ai/gemini.js +156 -0
- package/dist/ai/gemini.js.map +1 -0
- package/dist/ai/maestro.d.ts +22 -0
- package/dist/ai/maestro.d.ts.map +1 -0
- package/dist/ai/maestro.js +142 -0
- package/dist/ai/maestro.js.map +1 -0
- package/dist/ai/manager.d.ts +47 -0
- package/dist/ai/manager.d.ts.map +1 -0
- package/dist/ai/manager.js +450 -0
- package/dist/ai/manager.js.map +1 -0
- package/dist/ai/ollama.d.ts +16 -0
- package/dist/ai/ollama.d.ts.map +1 -0
- package/dist/ai/ollama.js +151 -0
- package/dist/ai/ollama.js.map +1 -0
- package/dist/ai/openai.d.ts +11 -0
- package/dist/ai/openai.d.ts.map +1 -0
- package/dist/ai/openai.js +132 -0
- package/dist/ai/openai.js.map +1 -0
- package/dist/ai/openrouter.d.ts +11 -0
- package/dist/ai/openrouter.d.ts.map +1 -0
- package/dist/ai/openrouter.js +139 -0
- package/dist/ai/openrouter.js.map +1 -0
- package/dist/bot/slack.d.ts +17 -0
- package/dist/bot/slack.d.ts.map +1 -0
- package/dist/bot/slack.js +144 -0
- package/dist/bot/slack.js.map +1 -0
- package/dist/bot/telegram.d.ts +19 -0
- package/dist/bot/telegram.d.ts.map +1 -0
- package/dist/bot/telegram.js +157 -0
- package/dist/bot/telegram.js.map +1 -0
- package/dist/cli/commands/ai.d.ts +4 -0
- package/dist/cli/commands/ai.d.ts.map +1 -0
- package/dist/cli/commands/ai.js +161 -0
- package/dist/cli/commands/ai.js.map +1 -0
- package/dist/cli/commands/doctor.d.ts +18 -0
- package/dist/cli/commands/doctor.d.ts.map +1 -0
- package/dist/cli/commands/doctor.js +213 -0
- package/dist/cli/commands/doctor.js.map +1 -0
- package/dist/cli/commands/init.d.ts +15 -0
- package/dist/cli/commands/init.d.ts.map +1 -0
- package/dist/cli/commands/init.js +281 -0
- package/dist/cli/commands/init.js.map +1 -0
- package/dist/cli/commands/install.d.ts +16 -0
- package/dist/cli/commands/install.d.ts.map +1 -0
- package/dist/cli/commands/install.js +750 -0
- package/dist/cli/commands/install.js.map +1 -0
- package/dist/cli/commands/lifecycle.d.ts +4 -0
- package/dist/cli/commands/lifecycle.d.ts.map +1 -0
- package/dist/cli/commands/lifecycle.js +84 -0
- package/dist/cli/commands/lifecycle.js.map +1 -0
- package/dist/cli/commands/marketplace.d.ts +13 -0
- package/dist/cli/commands/marketplace.d.ts.map +1 -0
- package/dist/cli/commands/marketplace.js +197 -0
- package/dist/cli/commands/marketplace.js.map +1 -0
- package/dist/cli/commands/mcp.d.ts +6 -0
- package/dist/cli/commands/mcp.d.ts.map +1 -0
- package/dist/cli/commands/mcp.js +83 -0
- package/dist/cli/commands/mcp.js.map +1 -0
- package/dist/cli/commands/onboard.d.ts +10 -0
- package/dist/cli/commands/onboard.d.ts.map +1 -0
- package/dist/cli/commands/onboard.js +207 -0
- package/dist/cli/commands/onboard.js.map +1 -0
- package/dist/cli/commands/plugin-create.d.ts +13 -0
- package/dist/cli/commands/plugin-create.d.ts.map +1 -0
- package/dist/cli/commands/plugin-create.js +122 -0
- package/dist/cli/commands/plugin-create.js.map +1 -0
- package/dist/cli/commands/plugins.d.ts +5 -0
- package/dist/cli/commands/plugins.d.ts.map +1 -0
- package/dist/cli/commands/plugins.js +30 -0
- package/dist/cli/commands/plugins.js.map +1 -0
- package/dist/cli/commands/release.d.ts +13 -0
- package/dist/cli/commands/release.d.ts.map +1 -0
- package/dist/cli/commands/release.js +243 -0
- package/dist/cli/commands/release.js.map +1 -0
- package/dist/cli/commands/telegram.d.ts +3 -0
- package/dist/cli/commands/telegram.d.ts.map +1 -0
- package/dist/cli/commands/telegram.js +20 -0
- package/dist/cli/commands/telegram.js.map +1 -0
- package/dist/cli/index.d.ts +3 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +402 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/config/oauth.d.ts +8 -0
- package/dist/config/oauth.d.ts.map +1 -0
- package/dist/config/oauth.js +13 -0
- package/dist/config/oauth.js.map +1 -0
- package/dist/core/audit.d.ts +91 -0
- package/dist/core/audit.d.ts.map +1 -0
- package/dist/core/audit.js +233 -0
- package/dist/core/audit.js.map +1 -0
- package/dist/core/circuit-breaker.d.ts +56 -0
- package/dist/core/circuit-breaker.d.ts.map +1 -0
- package/dist/core/circuit-breaker.js +107 -0
- package/dist/core/circuit-breaker.js.map +1 -0
- package/dist/core/conductor.d.ts +44 -0
- package/dist/core/conductor.d.ts.map +1 -0
- package/dist/core/conductor.js +200 -0
- package/dist/core/conductor.js.map +1 -0
- package/dist/core/config.d.ts +66 -0
- package/dist/core/config.d.ts.map +1 -0
- package/dist/core/config.js +86 -0
- package/dist/core/config.js.map +1 -0
- package/dist/core/database.d.ts +59 -0
- package/dist/core/database.d.ts.map +1 -0
- package/dist/core/database.js +342 -0
- package/dist/core/database.js.map +1 -0
- package/dist/core/errors.d.ts +231 -0
- package/dist/core/errors.d.ts.map +1 -0
- package/dist/core/errors.js +254 -0
- package/dist/core/errors.js.map +1 -0
- package/dist/core/health.d.ts +72 -0
- package/dist/core/health.d.ts.map +1 -0
- package/dist/core/health.js +116 -0
- package/dist/core/health.js.map +1 -0
- package/dist/core/interfaces.d.ts +62 -0
- package/dist/core/interfaces.d.ts.map +1 -0
- package/dist/core/interfaces.js +8 -0
- package/dist/core/interfaces.js.map +1 -0
- package/dist/core/logger.d.ts +15 -0
- package/dist/core/logger.d.ts.map +1 -0
- package/dist/core/logger.js +30 -0
- package/dist/core/logger.js.map +1 -0
- package/dist/core/rbac.d.ts +132 -0
- package/dist/core/rbac.d.ts.map +1 -0
- package/dist/core/rbac.js +230 -0
- package/dist/core/rbac.js.map +1 -0
- package/dist/core/retry.d.ts +22 -0
- package/dist/core/retry.d.ts.map +1 -0
- package/dist/core/retry.js +41 -0
- package/dist/core/retry.js.map +1 -0
- package/dist/core/webhooks.d.ts +92 -0
- package/dist/core/webhooks.d.ts.map +1 -0
- package/dist/core/webhooks.js +176 -0
- package/dist/core/webhooks.js.map +1 -0
- package/dist/core/zero-config.d.ts +22 -0
- package/dist/core/zero-config.d.ts.map +1 -0
- package/dist/core/zero-config.js +59 -0
- package/dist/core/zero-config.js.map +1 -0
- package/dist/dashboard/cli.d.ts +6 -0
- package/dist/dashboard/cli.d.ts.map +1 -0
- package/dist/dashboard/cli.js +42 -0
- package/dist/dashboard/cli.js.map +1 -0
- package/dist/dashboard/index.html +3426 -0
- package/dist/dashboard/server.d.ts +7 -0
- package/dist/dashboard/server.d.ts.map +1 -0
- package/dist/dashboard/server.js +1427 -0
- package/dist/dashboard/server.js.map +1 -0
- package/dist/mcp/server.d.ts +27 -0
- package/dist/mcp/server.d.ts.map +1 -0
- package/dist/mcp/server.js +380 -0
- package/dist/mcp/server.js.map +1 -0
- package/dist/mcp/tools/misc.d.ts +15 -0
- package/dist/mcp/tools/misc.d.ts.map +1 -0
- package/dist/mcp/tools/misc.js +49 -0
- package/dist/mcp/tools/misc.js.map +1 -0
- package/dist/plugins/builtin/calculator.d.ts +11 -0
- package/dist/plugins/builtin/calculator.d.ts.map +1 -0
- package/dist/plugins/builtin/calculator.js +166 -0
- package/dist/plugins/builtin/calculator.js.map +1 -0
- package/dist/plugins/builtin/colors.d.ts +15 -0
- package/dist/plugins/builtin/colors.d.ts.map +1 -0
- package/dist/plugins/builtin/colors.js +193 -0
- package/dist/plugins/builtin/colors.js.map +1 -0
- package/dist/plugins/builtin/cron.d.ts +40 -0
- package/dist/plugins/builtin/cron.d.ts.map +1 -0
- package/dist/plugins/builtin/cron.js +578 -0
- package/dist/plugins/builtin/cron.js.map +1 -0
- package/dist/plugins/builtin/crypto.d.ts +11 -0
- package/dist/plugins/builtin/crypto.d.ts.map +1 -0
- package/dist/plugins/builtin/crypto.js +83 -0
- package/dist/plugins/builtin/crypto.js.map +1 -0
- package/dist/plugins/builtin/database.d.ts +29 -0
- package/dist/plugins/builtin/database.d.ts.map +1 -0
- package/dist/plugins/builtin/database.js +230 -0
- package/dist/plugins/builtin/database.js.map +1 -0
- package/dist/plugins/builtin/docker.d.ts +12 -0
- package/dist/plugins/builtin/docker.d.ts.map +1 -0
- package/dist/plugins/builtin/docker.js +436 -0
- package/dist/plugins/builtin/docker.js.map +1 -0
- package/dist/plugins/builtin/fun.d.ts +11 -0
- package/dist/plugins/builtin/fun.d.ts.map +1 -0
- package/dist/plugins/builtin/fun.js +114 -0
- package/dist/plugins/builtin/fun.js.map +1 -0
- package/dist/plugins/builtin/gcal.d.ts +38 -0
- package/dist/plugins/builtin/gcal.d.ts.map +1 -0
- package/dist/plugins/builtin/gcal.js +280 -0
- package/dist/plugins/builtin/gcal.js.map +1 -0
- package/dist/plugins/builtin/gdrive.d.ts +26 -0
- package/dist/plugins/builtin/gdrive.d.ts.map +1 -0
- package/dist/plugins/builtin/gdrive.js +295 -0
- package/dist/plugins/builtin/gdrive.js.map +1 -0
- package/dist/plugins/builtin/github-actions.d.ts +38 -0
- package/dist/plugins/builtin/github-actions.d.ts.map +1 -0
- package/dist/plugins/builtin/github-actions.js +629 -0
- package/dist/plugins/builtin/github-actions.js.map +1 -0
- package/dist/plugins/builtin/github.d.ts +26 -0
- package/dist/plugins/builtin/github.d.ts.map +1 -0
- package/dist/plugins/builtin/github.js +800 -0
- package/dist/plugins/builtin/github.js.map +1 -0
- package/dist/plugins/builtin/gmail.d.ts +50 -0
- package/dist/plugins/builtin/gmail.d.ts.map +1 -0
- package/dist/plugins/builtin/gmail.js +445 -0
- package/dist/plugins/builtin/gmail.js.map +1 -0
- package/dist/plugins/builtin/hash.d.ts +11 -0
- package/dist/plugins/builtin/hash.d.ts.map +1 -0
- package/dist/plugins/builtin/hash.js +95 -0
- package/dist/plugins/builtin/hash.js.map +1 -0
- package/dist/plugins/builtin/homekit.d.ts +53 -0
- package/dist/plugins/builtin/homekit.d.ts.map +1 -0
- package/dist/plugins/builtin/homekit.js +341 -0
- package/dist/plugins/builtin/homekit.js.map +1 -0
- package/dist/plugins/builtin/index.d.ts +4 -0
- package/dist/plugins/builtin/index.d.ts.map +1 -0
- package/dist/plugins/builtin/index.js +96 -0
- package/dist/plugins/builtin/index.js.map +1 -0
- package/dist/plugins/builtin/jira.d.ts +50 -0
- package/dist/plugins/builtin/jira.d.ts.map +1 -0
- package/dist/plugins/builtin/jira.js +353 -0
- package/dist/plugins/builtin/jira.js.map +1 -0
- package/dist/plugins/builtin/linear.d.ts +35 -0
- package/dist/plugins/builtin/linear.d.ts.map +1 -0
- package/dist/plugins/builtin/linear.js +397 -0
- package/dist/plugins/builtin/linear.js.map +1 -0
- package/dist/plugins/builtin/lumen.d.ts +21 -0
- package/dist/plugins/builtin/lumen.d.ts.map +1 -0
- package/dist/plugins/builtin/lumen.js +404 -0
- package/dist/plugins/builtin/lumen.js.map +1 -0
- package/dist/plugins/builtin/memory.d.ts +22 -0
- package/dist/plugins/builtin/memory.d.ts.map +1 -0
- package/dist/plugins/builtin/memory.js +184 -0
- package/dist/plugins/builtin/memory.js.map +1 -0
- package/dist/plugins/builtin/n8n.d.ts +60 -0
- package/dist/plugins/builtin/n8n.d.ts.map +1 -0
- package/dist/plugins/builtin/n8n.js +519 -0
- package/dist/plugins/builtin/n8n.js.map +1 -0
- package/dist/plugins/builtin/network.d.ts +11 -0
- package/dist/plugins/builtin/network.d.ts.map +1 -0
- package/dist/plugins/builtin/network.js +88 -0
- package/dist/plugins/builtin/network.js.map +1 -0
- package/dist/plugins/builtin/notes.d.ts +47 -0
- package/dist/plugins/builtin/notes.d.ts.map +1 -0
- package/dist/plugins/builtin/notes.js +641 -0
- package/dist/plugins/builtin/notes.js.map +1 -0
- package/dist/plugins/builtin/notion.d.ts +47 -0
- package/dist/plugins/builtin/notion.d.ts.map +1 -0
- package/dist/plugins/builtin/notion.js +317 -0
- package/dist/plugins/builtin/notion.js.map +1 -0
- package/dist/plugins/builtin/shell.d.ts +12 -0
- package/dist/plugins/builtin/shell.d.ts.map +1 -0
- package/dist/plugins/builtin/shell.js +310 -0
- package/dist/plugins/builtin/shell.js.map +1 -0
- package/dist/plugins/builtin/slack.d.ts +31 -0
- package/dist/plugins/builtin/slack.d.ts.map +1 -0
- package/dist/plugins/builtin/slack.js +295 -0
- package/dist/plugins/builtin/slack.js.map +1 -0
- package/dist/plugins/builtin/spotify.d.ts +55 -0
- package/dist/plugins/builtin/spotify.d.ts.map +1 -0
- package/dist/plugins/builtin/spotify.js +623 -0
- package/dist/plugins/builtin/spotify.js.map +1 -0
- package/dist/plugins/builtin/stripe.d.ts +35 -0
- package/dist/plugins/builtin/stripe.d.ts.map +1 -0
- package/dist/plugins/builtin/stripe.js +376 -0
- package/dist/plugins/builtin/stripe.js.map +1 -0
- package/dist/plugins/builtin/system.d.ts +11 -0
- package/dist/plugins/builtin/system.d.ts.map +1 -0
- package/dist/plugins/builtin/system.js +91 -0
- package/dist/plugins/builtin/system.js.map +1 -0
- package/dist/plugins/builtin/text-tools.d.ts +11 -0
- package/dist/plugins/builtin/text-tools.d.ts.map +1 -0
- package/dist/plugins/builtin/text-tools.js +146 -0
- package/dist/plugins/builtin/text-tools.js.map +1 -0
- package/dist/plugins/builtin/timezone.d.ts +13 -0
- package/dist/plugins/builtin/timezone.d.ts.map +1 -0
- package/dist/plugins/builtin/timezone.js +164 -0
- package/dist/plugins/builtin/timezone.js.map +1 -0
- package/dist/plugins/builtin/todoist.d.ts +49 -0
- package/dist/plugins/builtin/todoist.d.ts.map +1 -0
- package/dist/plugins/builtin/todoist.js +540 -0
- package/dist/plugins/builtin/todoist.js.map +1 -0
- package/dist/plugins/builtin/translate.d.ts +11 -0
- package/dist/plugins/builtin/translate.d.ts.map +1 -0
- package/dist/plugins/builtin/translate.js +42 -0
- package/dist/plugins/builtin/translate.js.map +1 -0
- package/dist/plugins/builtin/url-tools.d.ts +11 -0
- package/dist/plugins/builtin/url-tools.d.ts.map +1 -0
- package/dist/plugins/builtin/url-tools.js +70 -0
- package/dist/plugins/builtin/url-tools.js.map +1 -0
- package/dist/plugins/builtin/vercel.d.ts +55 -0
- package/dist/plugins/builtin/vercel.d.ts.map +1 -0
- package/dist/plugins/builtin/vercel.js +514 -0
- package/dist/plugins/builtin/vercel.js.map +1 -0
- package/dist/plugins/builtin/weather.d.ts +13 -0
- package/dist/plugins/builtin/weather.d.ts.map +1 -0
- package/dist/plugins/builtin/weather.js +103 -0
- package/dist/plugins/builtin/weather.js.map +1 -0
- package/dist/plugins/builtin/x.d.ts +54 -0
- package/dist/plugins/builtin/x.d.ts.map +1 -0
- package/dist/plugins/builtin/x.js +402 -0
- package/dist/plugins/builtin/x.js.map +1 -0
- package/dist/plugins/manager.d.ts +77 -0
- package/dist/plugins/manager.d.ts.map +1 -0
- package/dist/plugins/manager.js +141 -0
- package/dist/plugins/manager.js.map +1 -0
- package/dist/plugins/validation.d.ts +18 -0
- package/dist/plugins/validation.d.ts.map +1 -0
- package/dist/plugins/validation.js +81 -0
- package/dist/plugins/validation.js.map +1 -0
- package/dist/security/auth.d.ts +23 -0
- package/dist/security/auth.d.ts.map +1 -0
- package/dist/security/auth.js +56 -0
- package/dist/security/auth.js.map +1 -0
- package/dist/security/keychain.d.ts +60 -0
- package/dist/security/keychain.d.ts.map +1 -0
- package/dist/security/keychain.js +213 -0
- package/dist/security/keychain.js.map +1 -0
- package/dist/utils/google-auth.d.ts +21 -0
- package/dist/utils/google-auth.d.ts.map +1 -0
- package/dist/utils/google-auth.js +135 -0
- package/dist/utils/google-auth.js.map +1 -0
- package/dist/utils/retry.d.ts +5 -0
- package/dist/utils/retry.d.ts.map +1 -0
- package/dist/utils/retry.js +34 -0
- package/dist/utils/retry.js.map +1 -0
- package/docs/README.md +13 -0
- package/docs/api.md +210 -0
- package/docs/getting-started.md +100 -0
- package/docs/plugins.md +306 -0
- package/docs-site/.vitepress/config.ts +59 -0
- package/docs-site/README.md +12 -0
- package/docs-site/index.md +30 -0
- package/eslint.config.js +29 -0
- package/install.ps1 +334 -0
- package/install.sh +1119 -0
- package/local-install.sh +304 -0
- package/package.json +90 -0
- package/packages/README.md +11 -0
- package/packages/plugin-sdk/README.md +12 -0
- package/packages/plugin-sdk/package.json +22 -0
- package/packages/plugin-sdk/src/README.md +11 -0
- package/packages/plugin-sdk/src/index.ts +191 -0
- package/sdks/README.md +26 -0
- package/sdks/csharp/ConductorClient.cs +65 -0
- package/sdks/csharp/README.md +11 -0
- package/sdks/go/README.md +11 -0
- package/sdks/go/conductor.go +257 -0
- package/sdks/java/ConductorClient.java +27 -0
- package/sdks/java/README.md +11 -0
- package/sdks/php/README.md +11 -0
- package/sdks/php/src/Client.php +72 -0
- package/sdks/python/README.md +12 -0
- package/sdks/python/conductor/__init__.py +227 -0
- package/sdks/python/pyproject.toml +30 -0
- package/sdks/ruby/README.md +11 -0
- package/sdks/ruby/lib/conductor.rb +46 -0
- package/sdks/rust/Cargo.toml +14 -0
- package/sdks/rust/README.md +11 -0
- package/sdks/swift/README.md +11 -0
- package/sdks/swift/Sources/Conductor/ConductorClient.swift +65 -0
- package/skills/conductor-mcp/SKILL.md +38 -0
- package/src/README.md +20 -0
- package/src/ai/README.md +18 -0
- package/src/ai/base.ts +93 -0
- package/src/ai/claude.ts +162 -0
- package/src/ai/gemini.ts +188 -0
- package/src/ai/maestro.ts +168 -0
- package/src/ai/manager.ts +537 -0
- package/src/ai/ollama.ts +186 -0
- package/src/ai/openai.ts +147 -0
- package/src/ai/openrouter.ts +152 -0
- package/src/bot/README.md +12 -0
- package/src/bot/slack.ts +164 -0
- package/src/bot/telegram.ts +185 -0
- package/src/cli/README.md +24 -0
- package/src/cli/commands/README.md +20 -0
- package/src/cli/commands/ai.ts +170 -0
- package/src/cli/commands/doctor.ts +221 -0
- package/src/cli/commands/init.ts +348 -0
- package/src/cli/commands/install.ts +792 -0
- package/src/cli/commands/lifecycle.ts +95 -0
- package/src/cli/commands/marketplace.ts +253 -0
- package/src/cli/commands/mcp.ts +92 -0
- package/src/cli/commands/onboard.ts +248 -0
- package/src/cli/commands/plugin-create.ts +130 -0
- package/src/cli/commands/plugins.ts +36 -0
- package/src/cli/commands/release.ts +251 -0
- package/src/cli/commands/telegram.ts +25 -0
- package/src/cli/index.ts +450 -0
- package/src/config/README.md +11 -0
- package/src/config/oauth.ts +26 -0
- package/src/core/README.md +22 -0
- package/src/core/audit.ts +291 -0
- package/src/core/circuit-breaker.ts +129 -0
- package/src/core/conductor.ts +240 -0
- package/src/core/config.ts +149 -0
- package/src/core/database.ts +411 -0
- package/src/core/errors.ts +275 -0
- package/src/core/health.ts +159 -0
- package/src/core/interfaces.ts +75 -0
- package/src/core/logger.ts +33 -0
- package/src/core/rbac.ts +321 -0
- package/src/core/retry.ts +61 -0
- package/src/core/webhooks.ts +234 -0
- package/src/core/zero-config.ts +72 -0
- package/src/dashboard/README.md +15 -0
- package/src/dashboard/cli.ts +48 -0
- package/src/dashboard/index.html +3426 -0
- package/src/dashboard/server.ts +1544 -0
- package/src/mcp/README.md +20 -0
- package/src/mcp/server.ts +475 -0
- package/src/mcp/tools/README.md +11 -0
- package/src/mcp/tools/misc.ts +61 -0
- package/src/plugins/README.md +28 -0
- package/src/plugins/builtin/README.md +23 -0
- package/src/plugins/builtin/calculator.ts +178 -0
- package/src/plugins/builtin/colors.ts +201 -0
- package/src/plugins/builtin/cron.ts +649 -0
- package/src/plugins/builtin/crypto.ts +85 -0
- package/src/plugins/builtin/database.ts +235 -0
- package/src/plugins/builtin/docker.ts +426 -0
- package/src/plugins/builtin/fun.ts +118 -0
- package/src/plugins/builtin/gcal.ts +305 -0
- package/src/plugins/builtin/gdrive.ts +326 -0
- package/src/plugins/builtin/github-actions.ts +666 -0
- package/src/plugins/builtin/github.ts +912 -0
- package/src/plugins/builtin/gmail.ts +492 -0
- package/src/plugins/builtin/hash.ts +98 -0
- package/src/plugins/builtin/homekit.ts +389 -0
- package/src/plugins/builtin/index.ts +116 -0
- package/src/plugins/builtin/jira.ts +380 -0
- package/src/plugins/builtin/linear.ts +448 -0
- package/src/plugins/builtin/lumen.ts +497 -0
- package/src/plugins/builtin/memory.ts +200 -0
- package/src/plugins/builtin/n8n.ts +565 -0
- package/src/plugins/builtin/network.ts +92 -0
- package/src/plugins/builtin/notes.ts +689 -0
- package/src/plugins/builtin/notion.ts +348 -0
- package/src/plugins/builtin/shell.ts +334 -0
- package/src/plugins/builtin/slack.ts +327 -0
- package/src/plugins/builtin/spotify.ts +665 -0
- package/src/plugins/builtin/stripe.ts +388 -0
- package/src/plugins/builtin/system.ts +93 -0
- package/src/plugins/builtin/text-tools.ts +150 -0
- package/src/plugins/builtin/timezone.ts +173 -0
- package/src/plugins/builtin/todoist.ts +625 -0
- package/src/plugins/builtin/translate.ts +47 -0
- package/src/plugins/builtin/url-tools.ts +73 -0
- package/src/plugins/builtin/vercel.ts +546 -0
- package/src/plugins/builtin/weather.ts +112 -0
- package/src/plugins/builtin/x.ts +440 -0
- package/src/plugins/manager.ts +213 -0
- package/src/plugins/validation.ts +94 -0
- package/src/security/README.md +12 -0
- package/src/security/auth.ts +72 -0
- package/src/security/keychain.ts +226 -0
- package/src/utils/README.md +12 -0
- package/src/utils/google-auth.ts +159 -0
- package/src/utils/retry.ts +41 -0
- package/test-all.mjs +1256 -0
- package/test.mjs +633 -0
- package/tests/README.md +19 -0
- package/tests/calculator.test.ts +54 -0
- package/tests/docker.test.ts +42 -0
- package/tests/load.test.ts +129 -0
- package/tests/mcp.test.ts +14 -0
- package/tests/shell.test.ts +42 -0
- package/tsconfig.json +21 -0
- package/vitest.config.ts +14 -0
|
@@ -0,0 +1,1544 @@
|
|
|
1
|
+
import express, { Request, Response, NextFunction } from 'express';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { fileURLToPath } from 'url';
|
|
4
|
+
import fs from 'fs/promises';
|
|
5
|
+
import crypto from 'crypto';
|
|
6
|
+
import os from 'os';
|
|
7
|
+
import { execFile } from 'child_process';
|
|
8
|
+
import { promisify } from 'util';
|
|
9
|
+
import rateLimit from 'express-rate-limit';
|
|
10
|
+
import { ConfigManager } from '../core/config.js';
|
|
11
|
+
import { DatabaseManager } from '../core/database.js';
|
|
12
|
+
import { Keychain } from '../security/keychain.js';
|
|
13
|
+
import type { Conductor } from '../core/conductor.js';
|
|
14
|
+
|
|
15
|
+
const execFileAsync = promisify(execFile);
|
|
16
|
+
|
|
17
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
18
|
+
const __dirname = path.dirname(__filename);
|
|
19
|
+
|
|
20
|
+
// ── Constants ────────────────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
const ALL_PLUGINS: readonly string[] = [
|
|
23
|
+
'calculator',
|
|
24
|
+
'colors',
|
|
25
|
+
'cron',
|
|
26
|
+
'crypto',
|
|
27
|
+
'fun',
|
|
28
|
+
'gcal',
|
|
29
|
+
'gdrive',
|
|
30
|
+
'github',
|
|
31
|
+
'github-actions',
|
|
32
|
+
'gmail',
|
|
33
|
+
'hash',
|
|
34
|
+
'homekit',
|
|
35
|
+
'memory',
|
|
36
|
+
'n8n',
|
|
37
|
+
'network',
|
|
38
|
+
'notes',
|
|
39
|
+
'notion',
|
|
40
|
+
'slack',
|
|
41
|
+
'spotify',
|
|
42
|
+
'system',
|
|
43
|
+
'text-tools',
|
|
44
|
+
'timezone',
|
|
45
|
+
'todoist',
|
|
46
|
+
'translate',
|
|
47
|
+
'url-tools',
|
|
48
|
+
'vercel',
|
|
49
|
+
'weather',
|
|
50
|
+
'x',
|
|
51
|
+
] as const;
|
|
52
|
+
|
|
53
|
+
const PLUGIN_REQUIRED_CREDS: Record<string, { service: string; key: string }[]> = {
|
|
54
|
+
github: [{ service: 'github', key: 'token' }],
|
|
55
|
+
'github-actions': [{ service: 'github', key: 'token' }],
|
|
56
|
+
gmail: [{ service: 'google', key: 'access_token' }],
|
|
57
|
+
gcal: [{ service: 'google', key: 'access_token' }],
|
|
58
|
+
gdrive: [{ service: 'google', key: 'access_token' }],
|
|
59
|
+
notion: [{ service: 'notion', key: 'api_key' }],
|
|
60
|
+
spotify: [{ service: 'spotify', key: 'client_id' }],
|
|
61
|
+
n8n: [{ service: 'n8n', key: 'api_key' }],
|
|
62
|
+
vercel: [{ service: 'vercel', key: 'token' }],
|
|
63
|
+
weather: [{ service: 'weather', key: 'api_key' }],
|
|
64
|
+
x: [{ service: 'x', key: 'api_key' }],
|
|
65
|
+
homekit: [{ service: 'homekit', key: 'base_url' }],
|
|
66
|
+
slack: [{ service: 'slack', key: 'bot_token' }],
|
|
67
|
+
todoist: [{ service: 'todoist', key: 'api_token' }],
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
interface CredentialEntry {
|
|
71
|
+
service: string;
|
|
72
|
+
key: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const KNOWN_CREDENTIALS: CredentialEntry[] = [
|
|
76
|
+
{ service: 'conductor', key: 'api_key' },
|
|
77
|
+
{ service: 'claude', key: 'api_key' },
|
|
78
|
+
{ service: 'openai', key: 'api_key' },
|
|
79
|
+
{ service: 'gemini', key: 'api_key' },
|
|
80
|
+
{ service: 'github', key: 'token' },
|
|
81
|
+
{ service: 'telegram', key: 'bot_token' },
|
|
82
|
+
{ service: 'spotify', key: 'client_id' },
|
|
83
|
+
{ service: 'spotify', key: 'client_secret' },
|
|
84
|
+
{ service: 'notion', key: 'api_key' },
|
|
85
|
+
{ service: 'n8n', key: 'api_key' },
|
|
86
|
+
{ service: 'vercel', key: 'token' },
|
|
87
|
+
{ service: 'weather', key: 'api_key' },
|
|
88
|
+
{ service: 'x', key: 'api_key' },
|
|
89
|
+
{ service: 'google', key: 'access_token' },
|
|
90
|
+
{ service: 'slack', key: 'bot_token' },
|
|
91
|
+
{ service: 'todoist', key: 'api_token' },
|
|
92
|
+
];
|
|
93
|
+
|
|
94
|
+
// Bundled Google OAuth app — users never need to create their own
|
|
95
|
+
const GOOGLE_CLIENT_ID = '529105409300-vmtlgnvcpfohtd7ha9o98fkel6ldjmin.apps.googleusercontent.com';
|
|
96
|
+
const GOOGLE_CLIENT_SECRET: string | undefined = process.env.GOOGLE_CLIENT_SECRET;
|
|
97
|
+
const GOOGLE_REDIRECT_URI = 'http://localhost:4242/api/auth/google/callback';
|
|
98
|
+
|
|
99
|
+
// ── Public types ──────────────────────────────────────────────────────────────
|
|
100
|
+
|
|
101
|
+
export interface DashboardServer {
|
|
102
|
+
port: number;
|
|
103
|
+
close(): Promise<void>;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ── Main export ───────────────────────────────────────────────────────────────
|
|
107
|
+
|
|
108
|
+
/** Generate or load the persistent dashboard session token. */
|
|
109
|
+
async function getDashboardToken(configDir: string): Promise<string> {
|
|
110
|
+
const tokenPath = path.join(configDir, 'dashboard.token');
|
|
111
|
+
try {
|
|
112
|
+
const existing = (await fs.readFile(tokenPath, 'utf-8')).trim();
|
|
113
|
+
if (existing.length >= 32) return existing;
|
|
114
|
+
} catch {
|
|
115
|
+
/* not yet created */
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const token = crypto.randomBytes(24).toString('hex');
|
|
119
|
+
await fs.mkdir(configDir, { recursive: true });
|
|
120
|
+
await fs.writeFile(tokenPath, token, { mode: 0o600 });
|
|
121
|
+
return token;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** In-memory log buffer for SSE streaming */
|
|
125
|
+
const logBuffer: Array<{ level: string; message: string; timestamp: string }> = [];
|
|
126
|
+
const maxLogBuffer = 500;
|
|
127
|
+
const sseClients: Set<Response> = new Set();
|
|
128
|
+
|
|
129
|
+
function interceptLogs(): void {
|
|
130
|
+
const origLog = console.log.bind(console);
|
|
131
|
+
const origError = console.error.bind(console);
|
|
132
|
+
const origWarn = console.warn.bind(console);
|
|
133
|
+
|
|
134
|
+
function pushLog(level: string, args: unknown[]): void {
|
|
135
|
+
const message = args.map((a) => (typeof a === 'string' ? a : JSON.stringify(a))).join(' ');
|
|
136
|
+
const entry = { level, message, timestamp: new Date().toISOString() };
|
|
137
|
+
logBuffer.push(entry);
|
|
138
|
+
if (logBuffer.length > maxLogBuffer) logBuffer.shift();
|
|
139
|
+
const data = `data: ${JSON.stringify(entry)}\n\n`;
|
|
140
|
+
for (const client of sseClients) {
|
|
141
|
+
try {
|
|
142
|
+
client.write(data);
|
|
143
|
+
} catch {
|
|
144
|
+
sseClients.delete(client);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
console.log = (...args: unknown[]) => {
|
|
150
|
+
origLog(...args);
|
|
151
|
+
pushLog('info', args);
|
|
152
|
+
};
|
|
153
|
+
console.error = (...args: unknown[]) => {
|
|
154
|
+
origError(...args);
|
|
155
|
+
pushLog('error', args);
|
|
156
|
+
};
|
|
157
|
+
console.warn = (...args: unknown[]) => {
|
|
158
|
+
origWarn(...args);
|
|
159
|
+
pushLog('warn', args);
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export async function startDashboard(port = 4242, conductorInstance?: Conductor): Promise<DashboardServer> {
|
|
164
|
+
interceptLogs();
|
|
165
|
+
|
|
166
|
+
const config = new ConfigManager();
|
|
167
|
+
await config.initialize();
|
|
168
|
+
const keychain = new Keychain(config.getConfigDir());
|
|
169
|
+
|
|
170
|
+
// Initialize database for conversations endpoint
|
|
171
|
+
const db = new DatabaseManager(config.getConfigDir());
|
|
172
|
+
try {
|
|
173
|
+
await db.initialize();
|
|
174
|
+
} catch {
|
|
175
|
+
// DB may not exist yet — conversations endpoint will return empty
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Generate / load session token
|
|
179
|
+
const dashboardToken = await getDashboardToken(config.getConfigDir());
|
|
180
|
+
|
|
181
|
+
// Auto-store bundled Google OAuth creds so the rest of Conductor can use them
|
|
182
|
+
const existingOAuth = config.get<{ clientId?: string }>('oauth.google');
|
|
183
|
+
if (!existingOAuth?.clientId && GOOGLE_CLIENT_SECRET) {
|
|
184
|
+
await config.set('oauth.google', {
|
|
185
|
+
clientId: GOOGLE_CLIENT_ID,
|
|
186
|
+
clientSecret: GOOGLE_CLIENT_SECRET,
|
|
187
|
+
redirectUri: GOOGLE_REDIRECT_URI,
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const app = express();
|
|
192
|
+
app.use(express.json());
|
|
193
|
+
|
|
194
|
+
// CORS — allow both localhost and 127.0.0.1
|
|
195
|
+
app.use((_req: Request, res: Response, next: NextFunction): void => {
|
|
196
|
+
const origin = (_req.headers.origin as string) || '';
|
|
197
|
+
const allowed = ['http://localhost:4242', 'http://127.0.0.1:4242'];
|
|
198
|
+
if (allowed.includes(origin)) {
|
|
199
|
+
res.setHeader('Access-Control-Allow-Origin', origin);
|
|
200
|
+
} else {
|
|
201
|
+
res.setHeader('Access-Control-Allow-Origin', 'http://127.0.0.1:4242');
|
|
202
|
+
}
|
|
203
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET,POST,DELETE,OPTIONS');
|
|
204
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type,Authorization');
|
|
205
|
+
next();
|
|
206
|
+
});
|
|
207
|
+
app.options('/{*path}', (_req: Request, res: Response): void => {
|
|
208
|
+
res.sendStatus(204);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
// ── Rate limiting ─────────────────────────────────────────────────────────
|
|
212
|
+
app.use(
|
|
213
|
+
'/api',
|
|
214
|
+
rateLimit({
|
|
215
|
+
windowMs: 60 * 1000, // 1 minute
|
|
216
|
+
max: 120, // 120 requests per minute per IP
|
|
217
|
+
standardHeaders: true,
|
|
218
|
+
legacyHeaders: false,
|
|
219
|
+
message: { error: 'COND-RATE-001: Too many requests. Slow down.' },
|
|
220
|
+
}),
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
// ── Authentication middleware for /api/* routes ───────────────────────────
|
|
224
|
+
// The Google OAuth callback must remain open (browser redirect from google.com)
|
|
225
|
+
app.use('/api', (req: Request, res: Response, next: NextFunction): void => {
|
|
226
|
+
if (req.path === '/auth/google/callback') {
|
|
227
|
+
next();
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
// Accept token from Authorization header OR ?token= query param (needed for EventSource / SSE)
|
|
231
|
+
const authHeader = req.headers['authorization'];
|
|
232
|
+
const queryToken = (req.query as Record<string, string>).token;
|
|
233
|
+
const rawToken = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : (queryToken ?? '');
|
|
234
|
+
if (!rawToken) {
|
|
235
|
+
res.status(401).json({
|
|
236
|
+
error:
|
|
237
|
+
'COND-AUTH-001: Unauthorized — include Authorization: Bearer <token>. Generate a token with: conductor dashboard token',
|
|
238
|
+
});
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
const provided = rawToken;
|
|
242
|
+
// Constant-time comparison to prevent timing attacks
|
|
243
|
+
try {
|
|
244
|
+
const tokenBuf = Buffer.from(dashboardToken, 'utf-8');
|
|
245
|
+
const providedBuf = Buffer.from(provided, 'utf-8');
|
|
246
|
+
if (tokenBuf.length !== providedBuf.length || !crypto.timingSafeEqual(tokenBuf, providedBuf)) {
|
|
247
|
+
res
|
|
248
|
+
.status(401)
|
|
249
|
+
.json({ error: 'COND-AUTH-002: Invalid token. Generate a new token with: conductor dashboard token' });
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
} catch {
|
|
253
|
+
res
|
|
254
|
+
.status(401)
|
|
255
|
+
.json({ error: 'COND-AUTH-003: Invalid token format. Generate a new token with: conductor dashboard token' });
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
next();
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
// ── Static ────────────────────────────────────────────────────────────────
|
|
262
|
+
// Inject dashboard token as a meta tag so the JS can read it
|
|
263
|
+
app.get('/', async (_req: Request, res: Response): Promise<void> => {
|
|
264
|
+
try {
|
|
265
|
+
const htmlPath = path.join(__dirname, 'index.html');
|
|
266
|
+
let html = await fs.readFile(htmlPath, 'utf-8');
|
|
267
|
+
// Inject meta tag with the token right before </head>
|
|
268
|
+
// Replace the placeholder meta tag that's already in the HTML template
|
|
269
|
+
html = html.replace(
|
|
270
|
+
'<meta name="dashboard-token" content="">',
|
|
271
|
+
`<meta name="dashboard-token" content="${dashboardToken}">`,
|
|
272
|
+
);
|
|
273
|
+
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
274
|
+
res.send(html);
|
|
275
|
+
} catch {
|
|
276
|
+
res.status(500).send('Dashboard HTML not found. Run: npm run build');
|
|
277
|
+
}
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
// ── Status ────────────────────────────────────────────────────────────────
|
|
281
|
+
app.get('/api/status', async (_req: Request, res: Response): Promise<void> => {
|
|
282
|
+
let version = 'unknown';
|
|
283
|
+
try {
|
|
284
|
+
const pkgPath = path.resolve(__dirname, '..', '..', 'package.json');
|
|
285
|
+
version = (JSON.parse(await fs.readFile(pkgPath, 'utf-8')) as { version?: string }).version ?? 'unknown';
|
|
286
|
+
} catch {
|
|
287
|
+
/* ignore */
|
|
288
|
+
}
|
|
289
|
+
res.json({ version, configDir: config.getConfigDir(), nodeVersion: process.version, platform: process.platform });
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
// ── Config ────────────────────────────────────────────────────────────────
|
|
293
|
+
app.get('/api/config', (_req: Request, res: Response): void => {
|
|
294
|
+
res.json(config.getConfig());
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
app.post('/api/config', async (req: Request, res: Response): Promise<void> => {
|
|
298
|
+
const body = req.body as { key?: string; value?: unknown };
|
|
299
|
+
if (typeof body.key !== 'string' || body.key.trim() === '') {
|
|
300
|
+
res.status(400).json({ error: '`key` must be a non-empty string' });
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
await config.set(body.key, body.value);
|
|
304
|
+
res.json({ ok: true });
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
// ── Plugins ───────────────────────────────────────────────────────────────
|
|
308
|
+
app.get('/api/plugins', (_req: Request, res: Response): void => {
|
|
309
|
+
const installed = config.get<string[]>('plugins.installed') ?? [];
|
|
310
|
+
const enabled = config.get<string[]>('plugins.enabled') ?? [];
|
|
311
|
+
res.json({ installed, enabled, all: ALL_PLUGINS, requiredCreds: PLUGIN_REQUIRED_CREDS });
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
app.post('/api/plugins/toggle', async (req: Request, res: Response): Promise<void> => {
|
|
315
|
+
const body = req.body as { plugin?: string; enabled?: boolean };
|
|
316
|
+
if (typeof body.plugin !== 'string' || body.plugin.trim() === '') {
|
|
317
|
+
res.status(400).json({ error: '`plugin` must be a non-empty string' });
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
if (typeof body.enabled !== 'boolean') {
|
|
321
|
+
res.status(400).json({ error: '`enabled` must be a boolean' });
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (body.enabled && PLUGIN_REQUIRED_CREDS[body.plugin]) {
|
|
326
|
+
const missing: string[] = [];
|
|
327
|
+
for (const { service, key } of PLUGIN_REQUIRED_CREDS[body.plugin]) {
|
|
328
|
+
if (!(await keychain.has(service, key))) missing.push(`${service} / ${key}`);
|
|
329
|
+
}
|
|
330
|
+
if (missing.length > 0) {
|
|
331
|
+
res.status(400).json({ error: `Missing credentials: ${missing.join(', ')}`, missingCreds: missing });
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const current = config.get<string[]>('plugins.enabled') ?? [];
|
|
337
|
+
const updated = body.enabled
|
|
338
|
+
? current.includes(body.plugin)
|
|
339
|
+
? current
|
|
340
|
+
: [...current, body.plugin]
|
|
341
|
+
: current.filter((p: string) => p !== body.plugin);
|
|
342
|
+
|
|
343
|
+
await config.set('plugins.enabled', updated);
|
|
344
|
+
res.json({ ok: true, enabled: updated });
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
// ── Credentials ───────────────────────────────────────────────────────────
|
|
348
|
+
app.get('/api/credentials', async (_req: Request, res: Response): Promise<void> => {
|
|
349
|
+
const result = await Promise.all(
|
|
350
|
+
KNOWN_CREDENTIALS.map(async ({ service, key }) => ({
|
|
351
|
+
service,
|
|
352
|
+
key,
|
|
353
|
+
hasValue: await keychain.has(service, key),
|
|
354
|
+
})),
|
|
355
|
+
);
|
|
356
|
+
res.json(result);
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
app.post('/api/credentials', async (req: Request, res: Response): Promise<void> => {
|
|
360
|
+
const body = req.body as { service?: string; key?: string; value?: string };
|
|
361
|
+
if (!body.service || !body.key || !body.value) {
|
|
362
|
+
res.status(400).json({ error: '`service`, `key`, and `value` are all required' });
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
await keychain.set(body.service, body.key, body.value);
|
|
366
|
+
res.json({ ok: true });
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
app.delete('/api/credentials/:service/:key', async (req: Request, res: Response): Promise<void> => {
|
|
370
|
+
const { service, key } = req.params as { service: string; key: string };
|
|
371
|
+
await keychain.delete(service, key);
|
|
372
|
+
res.json({ ok: true });
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
// ── Google OAuth ──────────────────────────────────────────────────────────
|
|
376
|
+
app.get('/api/auth/google/status', async (_req: Request, res: Response): Promise<void> => {
|
|
377
|
+
const connected = await keychain.has('google', 'access_token');
|
|
378
|
+
res.json({ connected });
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
app.get('/api/auth/google/url', async (_req: Request, res: Response): Promise<void> => {
|
|
382
|
+
if (!GOOGLE_CLIENT_SECRET) {
|
|
383
|
+
res.status(503).json({ error: 'Google OAuth is not configured — set GOOGLE_CLIENT_SECRET env var' });
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
try {
|
|
387
|
+
const { google } = await import('googleapis');
|
|
388
|
+
const oauth2Client = new google.auth.OAuth2(GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, GOOGLE_REDIRECT_URI);
|
|
389
|
+
const url = oauth2Client.generateAuthUrl({
|
|
390
|
+
access_type: 'offline',
|
|
391
|
+
prompt: 'consent',
|
|
392
|
+
scope: [
|
|
393
|
+
'https://www.googleapis.com/auth/userinfo.email',
|
|
394
|
+
'https://www.googleapis.com/auth/userinfo.profile',
|
|
395
|
+
'https://www.googleapis.com/auth/gmail.modify',
|
|
396
|
+
'https://www.googleapis.com/auth/calendar',
|
|
397
|
+
'https://www.googleapis.com/auth/drive.file',
|
|
398
|
+
],
|
|
399
|
+
});
|
|
400
|
+
res.json({ url });
|
|
401
|
+
} catch (e: unknown) {
|
|
402
|
+
res.status(500).json({ error: (e as Error).message });
|
|
403
|
+
}
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
app.get('/api/auth/google/callback', async (req: Request, res: Response): Promise<void> => {
|
|
407
|
+
if (!GOOGLE_CLIENT_SECRET) {
|
|
408
|
+
res.status(503).json({ error: 'Google OAuth is not configured — set GOOGLE_CLIENT_SECRET env var' });
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
const code = (req.query as Record<string, string>).code;
|
|
412
|
+
if (!code) {
|
|
413
|
+
res.status(400).send('<h2>Missing code</h2>');
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
try {
|
|
417
|
+
const { google } = await import('googleapis');
|
|
418
|
+
const oauth2Client = new google.auth.OAuth2(GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, GOOGLE_REDIRECT_URI);
|
|
419
|
+
const { tokens } = await oauth2Client.getToken(code);
|
|
420
|
+
if (tokens.access_token) await keychain.set('google', 'access_token', tokens.access_token);
|
|
421
|
+
if (tokens.refresh_token) await keychain.set('google', 'refresh_token', tokens.refresh_token);
|
|
422
|
+
res.send(`<!DOCTYPE html><html><head><title>Connected</title></head>
|
|
423
|
+
<body style="font-family:system-ui;text-align:center;padding:60px;background:#0d0d0d;color:#e8e8e8">
|
|
424
|
+
<h2 style="color:#22c55e;margin-bottom:12px">✓ Google Connected</h2>
|
|
425
|
+
<p style="color:#888">Gmail, Calendar, and Drive are ready.</p>
|
|
426
|
+
<p style="color:#555;font-size:12px;margin-top:8px">This tab will close automatically.</p>
|
|
427
|
+
<script>setTimeout(()=>{window.close();},1800);</script>
|
|
428
|
+
</body></html>`);
|
|
429
|
+
} catch (e: unknown) {
|
|
430
|
+
res.status(500).send(`<h2 style="color:#ef4444">Auth failed: ${(e as Error).message}</h2>`);
|
|
431
|
+
}
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
app.delete('/api/auth/google', async (_req: Request, res: Response): Promise<void> => {
|
|
435
|
+
await keychain.delete('google', 'access_token');
|
|
436
|
+
await keychain.delete('google', 'refresh_token');
|
|
437
|
+
res.json({ ok: true });
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
// ── Activity log ──────────────────────────────────────────────────────────
|
|
441
|
+
app.get('/api/activity', async (_req: Request, res: Response): Promise<void> => {
|
|
442
|
+
try {
|
|
443
|
+
const logsDir = path.join(config.getConfigDir(), 'logs');
|
|
444
|
+
let entries: unknown[] = [];
|
|
445
|
+
try {
|
|
446
|
+
const files = await fs.readdir(logsDir);
|
|
447
|
+
for (const f of files.filter((f: string) => f.endsWith('.json')).slice(-5)) {
|
|
448
|
+
try {
|
|
449
|
+
const raw = await fs.readFile(path.join(logsDir, f), 'utf-8');
|
|
450
|
+
const parsed = JSON.parse(raw);
|
|
451
|
+
if (Array.isArray(parsed)) entries.push(...parsed);
|
|
452
|
+
else entries.push(parsed);
|
|
453
|
+
} catch {
|
|
454
|
+
/* skip bad file */
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
} catch {
|
|
458
|
+
/* no logs dir */
|
|
459
|
+
}
|
|
460
|
+
res.json({ entries: entries.slice(-20) });
|
|
461
|
+
} catch {
|
|
462
|
+
res.json({ entries: [] });
|
|
463
|
+
}
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
// ── System Control ────────────────────────────────────────────────────────
|
|
467
|
+
|
|
468
|
+
// Safe command runner using execFile (no shell interpretation)
|
|
469
|
+
async function runCmd(cmd: string, timeoutMs = 30000): Promise<{ stdout: string; stderr: string; exitCode: number }> {
|
|
470
|
+
// Whitelist of allowed dashboard commands
|
|
471
|
+
const allowedPrefixes = [
|
|
472
|
+
'ps ',
|
|
473
|
+
'tasklist',
|
|
474
|
+
'open ',
|
|
475
|
+
'xdg-open',
|
|
476
|
+
'screencapture',
|
|
477
|
+
'scrot',
|
|
478
|
+
'pbpaste',
|
|
479
|
+
'xclip',
|
|
480
|
+
'xsel',
|
|
481
|
+
'ifconfig',
|
|
482
|
+
'ip ',
|
|
483
|
+
'netstat',
|
|
484
|
+
'ss ',
|
|
485
|
+
'lsof',
|
|
486
|
+
'docker ',
|
|
487
|
+
'crontab',
|
|
488
|
+
'git ',
|
|
489
|
+
];
|
|
490
|
+
const trimmed = cmd.trim();
|
|
491
|
+
const isAllowed = allowedPrefixes.some((p) => trimmed.startsWith(p));
|
|
492
|
+
if (!isAllowed) {
|
|
493
|
+
return { stdout: '', stderr: `Command not allowed in dashboard: ${trimmed}`, exitCode: 1 };
|
|
494
|
+
}
|
|
495
|
+
const [executable, ...args] = trimmed.split(/\s+/);
|
|
496
|
+
try {
|
|
497
|
+
const { stdout, stderr } = await execFileAsync(executable, args, {
|
|
498
|
+
timeout: timeoutMs,
|
|
499
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
500
|
+
});
|
|
501
|
+
return { stdout: (stdout ?? '').trim(), stderr: (stderr ?? '').trim(), exitCode: 0 };
|
|
502
|
+
} catch (err: unknown) {
|
|
503
|
+
if (err && typeof err === 'object' && 'code' in err) {
|
|
504
|
+
const e = err as { code?: number; stdout?: string; stderr?: string };
|
|
505
|
+
return { stdout: (e.stdout ?? '').trim(), stderr: (e.stderr ?? '').trim(), exitCode: e.code ?? 1 };
|
|
506
|
+
}
|
|
507
|
+
return { stdout: '', stderr: String(err), exitCode: 1 };
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// GET /api/system/info
|
|
512
|
+
app.get('/api/system/info', async (_req: Request, res: Response): Promise<void> => {
|
|
513
|
+
const cpus = os.cpus();
|
|
514
|
+
res.json({
|
|
515
|
+
platform: os.platform(),
|
|
516
|
+
arch: os.arch(),
|
|
517
|
+
hostname: os.hostname(),
|
|
518
|
+
uptime: os.uptime(),
|
|
519
|
+
memory: {
|
|
520
|
+
total: os.totalmem(),
|
|
521
|
+
free: os.freemem(),
|
|
522
|
+
used: os.totalmem() - os.freemem(),
|
|
523
|
+
},
|
|
524
|
+
cpu: {
|
|
525
|
+
model: cpus[0]?.model ?? 'unknown',
|
|
526
|
+
cores: cpus.length,
|
|
527
|
+
usage: null, // point-in-time snapshot not meaningful without sampling interval
|
|
528
|
+
},
|
|
529
|
+
});
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
// POST /api/system/shell — REMOVED for security
|
|
533
|
+
// Shell access through a web dashboard is an unacceptable attack surface.
|
|
534
|
+
// Use the CLI directly for shell operations.
|
|
535
|
+
app.post('/api/system/shell', async (_req: Request, res: Response): Promise<void> => {
|
|
536
|
+
res.status(410).json({ error: 'Shell endpoint has been removed for security. Use the CLI directly.' });
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
// GET /api/system/processes — REMOVED (relied on shell execution)
|
|
540
|
+
app.get('/api/system/processes', async (_req: Request, res: Response): Promise<void> => {
|
|
541
|
+
res.status(410).json({ error: 'Process listing endpoint has been removed (relied on shell execution).' });
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
// POST /api/system/open
|
|
545
|
+
app.post('/api/system/open', async (req: Request, res: Response): Promise<void> => {
|
|
546
|
+
const body = req.body as { path?: string };
|
|
547
|
+
if (!body.path || typeof body.path !== 'string' || body.path.trim() === '') {
|
|
548
|
+
res.status(400).json({ error: '`path` is required' });
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
const platform = os.platform();
|
|
552
|
+
let opener: string;
|
|
553
|
+
if (platform === 'darwin') opener = 'open';
|
|
554
|
+
else if (platform === 'win32') opener = 'start ""';
|
|
555
|
+
else opener = 'xdg-open';
|
|
556
|
+
const result = await runCmd(`${opener} ${JSON.stringify(body.path.trim())}`);
|
|
557
|
+
res.json(result);
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
// POST /api/system/notify
|
|
561
|
+
app.post('/api/system/notify', async (req: Request, res: Response): Promise<void> => {
|
|
562
|
+
const body = req.body as { title?: string; message?: string };
|
|
563
|
+
if (!body.title || !body.message) {
|
|
564
|
+
res.status(400).json({ error: '`title` and `message` are required' });
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
const platform = os.platform();
|
|
568
|
+
let cmd: string;
|
|
569
|
+
if (platform === 'darwin') {
|
|
570
|
+
const t = body.title.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
571
|
+
const m = body.message.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
572
|
+
cmd = `osascript -e 'display notification "${m}" with title "${t}"'`;
|
|
573
|
+
} else {
|
|
574
|
+
cmd = `notify-send ${JSON.stringify(body.title)} ${JSON.stringify(body.message)}`;
|
|
575
|
+
}
|
|
576
|
+
const result = await runCmd(cmd);
|
|
577
|
+
res.json(result);
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
// GET /api/system/clipboard
|
|
581
|
+
app.get('/api/system/clipboard', async (_req: Request, res: Response): Promise<void> => {
|
|
582
|
+
const platform = os.platform();
|
|
583
|
+
const cmd = platform === 'darwin' ? 'pbpaste' : 'xclip -o';
|
|
584
|
+
const result = await runCmd(cmd);
|
|
585
|
+
res.json({ text: result.stdout, ...result });
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
// POST /api/system/clipboard
|
|
589
|
+
app.post('/api/system/clipboard', async (req: Request, res: Response): Promise<void> => {
|
|
590
|
+
const body = req.body as { text?: string };
|
|
591
|
+
if (typeof body.text !== 'string') {
|
|
592
|
+
res.status(400).json({ error: '`text` is required' });
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
595
|
+
const platform = os.platform();
|
|
596
|
+
const cmd = platform === 'darwin' ? 'pbcopy' : 'xclip -selection clipboard';
|
|
597
|
+
const result = await runCmd(`printf '%s' ${JSON.stringify(body.text)} | ${cmd}`);
|
|
598
|
+
res.json(result);
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
// GET /api/system/screenshot
|
|
602
|
+
app.get('/api/system/screenshot', async (_req: Request, res: Response): Promise<void> => {
|
|
603
|
+
const platform = os.platform();
|
|
604
|
+
const tmpFile = '/tmp/conductor-screenshot.png';
|
|
605
|
+
let captureResult: { stdout: string; stderr: string; exitCode: number };
|
|
606
|
+
if (platform === 'darwin') {
|
|
607
|
+
captureResult = await runCmd(`screencapture -x -t png ${tmpFile}`);
|
|
608
|
+
} else {
|
|
609
|
+
captureResult = await runCmd(`scrot ${tmpFile}`);
|
|
610
|
+
}
|
|
611
|
+
if (captureResult.exitCode !== 0) {
|
|
612
|
+
res.status(500).json({ error: 'Screenshot failed', ...captureResult });
|
|
613
|
+
return;
|
|
614
|
+
}
|
|
615
|
+
try {
|
|
616
|
+
const imgBuf = await fs.readFile(tmpFile);
|
|
617
|
+
const image = imgBuf.toString('base64');
|
|
618
|
+
await fs.unlink(tmpFile).catch(() => {
|
|
619
|
+
/* best-effort cleanup */
|
|
620
|
+
});
|
|
621
|
+
res.json({ image, mimeType: 'image/png' });
|
|
622
|
+
} catch (e: unknown) {
|
|
623
|
+
res.status(500).json({ error: (e as Error).message });
|
|
624
|
+
}
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
// POST /api/system/type
|
|
628
|
+
app.post('/api/system/type', async (req: Request, res: Response): Promise<void> => {
|
|
629
|
+
const body = req.body as { text?: string };
|
|
630
|
+
if (typeof body.text !== 'string' || body.text === '') {
|
|
631
|
+
res.status(400).json({ error: '`text` is required' });
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
const platform = os.platform();
|
|
635
|
+
let cmd: string;
|
|
636
|
+
if (platform === 'darwin') {
|
|
637
|
+
const escaped = body.text.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
638
|
+
cmd = `osascript -e 'tell application "System Events" to keystroke "${escaped}"'`;
|
|
639
|
+
} else {
|
|
640
|
+
cmd = `xdotool type ${JSON.stringify(body.text)}`;
|
|
641
|
+
}
|
|
642
|
+
const result = await runCmd(cmd);
|
|
643
|
+
res.json(result);
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
// GET /api/system/windows
|
|
647
|
+
app.get('/api/system/windows', async (_req: Request, res: Response): Promise<void> => {
|
|
648
|
+
const platform = os.platform();
|
|
649
|
+
let cmd: string;
|
|
650
|
+
if (platform === 'darwin') {
|
|
651
|
+
cmd = `osascript -e 'tell application "System Events" to get name of every process whose background only is false'`;
|
|
652
|
+
} else if (platform === 'win32') {
|
|
653
|
+
cmd = `powershell -Command "Get-Process | Where-Object {$_.MainWindowTitle -ne ''} | Select-Object -ExpandProperty MainWindowTitle"`;
|
|
654
|
+
} else {
|
|
655
|
+
cmd = `wmctrl -l 2>/dev/null || xdotool search --onlyvisible --name "" getwindowname 2>/dev/null || echo ''`;
|
|
656
|
+
}
|
|
657
|
+
const result = await runCmd(cmd);
|
|
658
|
+
const apps = result.stdout
|
|
659
|
+
.split(/,\s*|\n/)
|
|
660
|
+
.map((s: string) => s.trim())
|
|
661
|
+
.filter((s: string) => s.length > 0);
|
|
662
|
+
res.json({ apps, raw: result.stdout });
|
|
663
|
+
});
|
|
664
|
+
|
|
665
|
+
// ── File System Routes ────────────────────────────────────────────────────
|
|
666
|
+
|
|
667
|
+
const ALLOWED_TEXT_EXTENSIONS = new Set([
|
|
668
|
+
'.txt',
|
|
669
|
+
'.md',
|
|
670
|
+
'.json',
|
|
671
|
+
'.ts',
|
|
672
|
+
'.js',
|
|
673
|
+
'.py',
|
|
674
|
+
'.sh',
|
|
675
|
+
'.yaml',
|
|
676
|
+
'.yml',
|
|
677
|
+
'.toml',
|
|
678
|
+
'.env',
|
|
679
|
+
'.log',
|
|
680
|
+
'.csv',
|
|
681
|
+
'.html',
|
|
682
|
+
'.css',
|
|
683
|
+
'.xml',
|
|
684
|
+
'.sql',
|
|
685
|
+
'.go',
|
|
686
|
+
'.rs',
|
|
687
|
+
'.rb',
|
|
688
|
+
'.php',
|
|
689
|
+
'.java',
|
|
690
|
+
'.c',
|
|
691
|
+
'.cpp',
|
|
692
|
+
'.h',
|
|
693
|
+
]);
|
|
694
|
+
|
|
695
|
+
function isSafePath(rawPath: string, mustBeUnder?: string): { safe: boolean; resolved: string } {
|
|
696
|
+
const resolved = path.resolve(rawPath);
|
|
697
|
+
if (rawPath.includes('..') || resolved !== path.normalize(resolved)) {
|
|
698
|
+
return { safe: false, resolved };
|
|
699
|
+
}
|
|
700
|
+
if (mustBeUnder) {
|
|
701
|
+
const base = path.resolve(mustBeUnder);
|
|
702
|
+
if (!resolved.startsWith(base + path.sep) && resolved !== base) {
|
|
703
|
+
return { safe: false, resolved };
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
return { safe: true, resolved };
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
// GET /api/fs/list
|
|
710
|
+
app.get('/api/fs/list', async (req: Request, res: Response): Promise<void> => {
|
|
711
|
+
let rawPath = (req.query as Record<string, string>).path;
|
|
712
|
+
// Normalize: missing, empty, or '~' → home dir; relative → join with home dir
|
|
713
|
+
if (!rawPath || rawPath === '~') {
|
|
714
|
+
rawPath = os.homedir();
|
|
715
|
+
} else if (!path.isAbsolute(rawPath)) {
|
|
716
|
+
rawPath = path.join(os.homedir(), rawPath);
|
|
717
|
+
}
|
|
718
|
+
const { safe, resolved } = isSafePath(rawPath);
|
|
719
|
+
if (!safe) {
|
|
720
|
+
res.status(400).json({ error: 'Invalid path — traversal not allowed' });
|
|
721
|
+
return;
|
|
722
|
+
}
|
|
723
|
+
try {
|
|
724
|
+
const names = await fs.readdir(resolved);
|
|
725
|
+
const capped = names.slice(0, 200);
|
|
726
|
+
const entries = await Promise.all(
|
|
727
|
+
capped.map(async (name) => {
|
|
728
|
+
try {
|
|
729
|
+
const stat = await fs.stat(path.join(resolved, name));
|
|
730
|
+
return {
|
|
731
|
+
name,
|
|
732
|
+
type: stat.isDirectory() ? 'dir' : 'file',
|
|
733
|
+
size: stat.size,
|
|
734
|
+
modified: stat.mtime.toISOString(),
|
|
735
|
+
permissions: (stat.mode & 0o777).toString(8).padStart(3, '0'),
|
|
736
|
+
};
|
|
737
|
+
} catch {
|
|
738
|
+
return { name, type: 'unknown', size: 0, modified: null, permissions: '000' };
|
|
739
|
+
}
|
|
740
|
+
}),
|
|
741
|
+
);
|
|
742
|
+
res.json({ entries, path: resolved });
|
|
743
|
+
} catch (e: unknown) {
|
|
744
|
+
res.status(500).json({ error: (e as Error).message });
|
|
745
|
+
}
|
|
746
|
+
});
|
|
747
|
+
|
|
748
|
+
// GET /api/fs/read
|
|
749
|
+
app.get('/api/fs/read', async (req: Request, res: Response): Promise<void> => {
|
|
750
|
+
const rawPath = (req.query as Record<string, string>).path;
|
|
751
|
+
if (!rawPath) {
|
|
752
|
+
res.status(400).json({ error: '`path` query parameter is required' });
|
|
753
|
+
return;
|
|
754
|
+
}
|
|
755
|
+
const { safe, resolved } = isSafePath(rawPath);
|
|
756
|
+
if (!safe) {
|
|
757
|
+
res.status(400).json({ error: 'Invalid path — traversal not allowed' });
|
|
758
|
+
return;
|
|
759
|
+
}
|
|
760
|
+
const ext = path.extname(resolved).toLowerCase();
|
|
761
|
+
if (!ALLOWED_TEXT_EXTENSIONS.has(ext)) {
|
|
762
|
+
res.status(400).json({ error: `File extension '${ext}' is not allowed` });
|
|
763
|
+
return;
|
|
764
|
+
}
|
|
765
|
+
try {
|
|
766
|
+
const stat = await fs.stat(resolved);
|
|
767
|
+
if (stat.size > 1024 * 1024) {
|
|
768
|
+
res.status(400).json({ error: 'File exceeds 1MB limit' });
|
|
769
|
+
return;
|
|
770
|
+
}
|
|
771
|
+
const content = await fs.readFile(resolved, 'utf-8');
|
|
772
|
+
res.json({ content, size: stat.size, encoding: 'utf-8' });
|
|
773
|
+
} catch (e: unknown) {
|
|
774
|
+
res.status(500).json({ error: (e as Error).message });
|
|
775
|
+
}
|
|
776
|
+
});
|
|
777
|
+
|
|
778
|
+
// POST /api/fs/write
|
|
779
|
+
app.post('/api/fs/write', async (req: Request, res: Response): Promise<void> => {
|
|
780
|
+
const body = req.body as { path?: string; content?: string };
|
|
781
|
+
if (!body.path || typeof body.path !== 'string') {
|
|
782
|
+
res.status(400).json({ error: '`path` is required' });
|
|
783
|
+
return;
|
|
784
|
+
}
|
|
785
|
+
if (typeof body.content !== 'string') {
|
|
786
|
+
res.status(400).json({ error: '`content` is required' });
|
|
787
|
+
return;
|
|
788
|
+
}
|
|
789
|
+
const { safe, resolved } = isSafePath(body.path, os.homedir());
|
|
790
|
+
if (!safe) {
|
|
791
|
+
res.status(400).json({ error: 'Invalid path — traversal above home directory not allowed' });
|
|
792
|
+
return;
|
|
793
|
+
}
|
|
794
|
+
const contentBytes = Buffer.byteLength(body.content, 'utf-8');
|
|
795
|
+
if (contentBytes > 512 * 1024) {
|
|
796
|
+
res.status(400).json({ error: 'Content exceeds 512KB limit' });
|
|
797
|
+
return;
|
|
798
|
+
}
|
|
799
|
+
try {
|
|
800
|
+
await fs.writeFile(resolved, body.content, 'utf-8');
|
|
801
|
+
res.json({ ok: true, bytesWritten: contentBytes });
|
|
802
|
+
} catch (e: unknown) {
|
|
803
|
+
res.status(500).json({ error: (e as Error).message });
|
|
804
|
+
}
|
|
805
|
+
});
|
|
806
|
+
|
|
807
|
+
// POST /api/fs/delete
|
|
808
|
+
app.post('/api/fs/delete', async (req: Request, res: Response): Promise<void> => {
|
|
809
|
+
const body = req.body as { path?: string };
|
|
810
|
+
if (!body.path || typeof body.path !== 'string') {
|
|
811
|
+
res.status(400).json({ error: '`path` is required' });
|
|
812
|
+
return;
|
|
813
|
+
}
|
|
814
|
+
const { safe, resolved } = isSafePath(body.path, os.homedir());
|
|
815
|
+
if (!safe) {
|
|
816
|
+
res.status(400).json({ error: 'Invalid path — traversal above home directory not allowed' });
|
|
817
|
+
return;
|
|
818
|
+
}
|
|
819
|
+
try {
|
|
820
|
+
const stat = await fs.stat(resolved);
|
|
821
|
+
if (stat.isDirectory()) {
|
|
822
|
+
await fs.rmdir(resolved); // only succeeds on empty dirs
|
|
823
|
+
} else {
|
|
824
|
+
await fs.unlink(resolved);
|
|
825
|
+
}
|
|
826
|
+
res.json({ ok: true });
|
|
827
|
+
} catch (e: unknown) {
|
|
828
|
+
res.status(500).json({ error: (e as Error).message });
|
|
829
|
+
}
|
|
830
|
+
});
|
|
831
|
+
|
|
832
|
+
// ── Enhanced Process Manager ──────────────────────────────────────────────
|
|
833
|
+
|
|
834
|
+
// GET /api/system/processes/detail
|
|
835
|
+
app.get('/api/system/processes/detail', async (_req: Request, res: Response): Promise<void> => {
|
|
836
|
+
const platform = os.platform();
|
|
837
|
+
const cmd = platform === 'win32' ? 'tasklist /FO CSV /NH' : 'ps aux';
|
|
838
|
+
const result = await runCmd(cmd);
|
|
839
|
+
const lines = result.stdout.split('\n').filter((l: string) => l.trim().length > 0);
|
|
840
|
+
const dataLines = platform === 'win32' ? lines : lines.slice(1); // skip header on unix
|
|
841
|
+
const processes = dataLines.slice(0, 30).map((line: string) => {
|
|
842
|
+
if (platform === 'win32') {
|
|
843
|
+
const parts = line.split(',').map((p: string) => p.replace(/"/g, '').trim());
|
|
844
|
+
return { pid: parseInt(parts[1] ?? '0', 10), user: 'N/A', cpu: 'N/A', mem: 'N/A', command: parts[0] ?? line };
|
|
845
|
+
}
|
|
846
|
+
// ps aux format: USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
|
|
847
|
+
const parts = line.trim().split(/\s+/);
|
|
848
|
+
return {
|
|
849
|
+
pid: parseInt(parts[1] ?? '0', 10),
|
|
850
|
+
user: parts[0] ?? '',
|
|
851
|
+
cpu: parts[2] ?? '',
|
|
852
|
+
mem: parts[3] ?? '',
|
|
853
|
+
command: parts.slice(10).join(' '),
|
|
854
|
+
};
|
|
855
|
+
});
|
|
856
|
+
res.json({ processes });
|
|
857
|
+
});
|
|
858
|
+
|
|
859
|
+
// POST /api/system/processes/kill
|
|
860
|
+
app.post('/api/system/processes/kill', (req: Request, res: Response): void => {
|
|
861
|
+
const body = req.body as { pid?: number };
|
|
862
|
+
if (typeof body.pid !== 'number' || !Number.isInteger(body.pid)) {
|
|
863
|
+
res.status(400).json({ error: '`pid` must be an integer' });
|
|
864
|
+
return;
|
|
865
|
+
}
|
|
866
|
+
if (body.pid <= 1000) {
|
|
867
|
+
res.status(400).json({ error: 'Refusing to kill system process (pid <= 1000)' });
|
|
868
|
+
return;
|
|
869
|
+
}
|
|
870
|
+
try {
|
|
871
|
+
process.kill(body.pid, 'SIGTERM');
|
|
872
|
+
res.json({ ok: true });
|
|
873
|
+
} catch (e: unknown) {
|
|
874
|
+
res.status(500).json({ error: (e as Error).message });
|
|
875
|
+
}
|
|
876
|
+
});
|
|
877
|
+
|
|
878
|
+
// ── Real-time System Metrics ───────────────────────────────────────────────
|
|
879
|
+
|
|
880
|
+
// GET /api/system/metrics
|
|
881
|
+
app.get('/api/system/metrics', (_req: Request, res: Response): void => {
|
|
882
|
+
const total = os.totalmem();
|
|
883
|
+
const free = os.freemem();
|
|
884
|
+
const used = total - free;
|
|
885
|
+
res.json({
|
|
886
|
+
loadAvg: os.loadavg(),
|
|
887
|
+
memory: {
|
|
888
|
+
total,
|
|
889
|
+
free,
|
|
890
|
+
used,
|
|
891
|
+
usedPercent: Math.round((used / total) * 10000) / 100,
|
|
892
|
+
},
|
|
893
|
+
uptime: os.uptime(),
|
|
894
|
+
platform: os.platform(),
|
|
895
|
+
});
|
|
896
|
+
});
|
|
897
|
+
|
|
898
|
+
// ── Network Connections ───────────────────────────────────────────────────
|
|
899
|
+
|
|
900
|
+
// GET /api/system/network
|
|
901
|
+
app.get('/api/system/network', async (_req: Request, res: Response): Promise<void> => {
|
|
902
|
+
const platform = os.platform();
|
|
903
|
+
let connCmd: string;
|
|
904
|
+
let ifaceCmd: string;
|
|
905
|
+
if (platform === 'darwin') {
|
|
906
|
+
connCmd = 'netstat -an | grep ESTABLISHED | head -20';
|
|
907
|
+
ifaceCmd = 'ifconfig';
|
|
908
|
+
} else {
|
|
909
|
+
connCmd = 'ss -tuln | head -20';
|
|
910
|
+
ifaceCmd = 'ip addr';
|
|
911
|
+
}
|
|
912
|
+
const [connResult, ifaceResult] = await Promise.all([runCmd(connCmd), runCmd(ifaceCmd)]);
|
|
913
|
+
const connections = connResult.stdout
|
|
914
|
+
.split('\n')
|
|
915
|
+
.map((l: string) => l.trim())
|
|
916
|
+
.filter((l: string) => l.length > 0);
|
|
917
|
+
res.json({ connections, interfaces: ifaceResult.stdout });
|
|
918
|
+
});
|
|
919
|
+
|
|
920
|
+
// ── Environment Variables ─────────────────────────────────────────────────
|
|
921
|
+
|
|
922
|
+
// GET /api/system/env
|
|
923
|
+
app.get('/api/system/env', (_req: Request, res: Response): void => {
|
|
924
|
+
const SAFE_KEYS = new Set(['PATH', 'HOME', 'USER', 'SHELL', 'TERM', 'LANG', 'NODE_ENV', 'PORT']);
|
|
925
|
+
const env: Record<string, string> = {};
|
|
926
|
+
for (const [k, v] of Object.entries(process.env)) {
|
|
927
|
+
if (v !== undefined && (SAFE_KEYS.has(k) || k.startsWith('CONDUCTOR_'))) {
|
|
928
|
+
env[k] = v;
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
res.json({ env });
|
|
932
|
+
});
|
|
933
|
+
|
|
934
|
+
// ── Git Status ────────────────────────────────────────────────────────────
|
|
935
|
+
|
|
936
|
+
// GET /api/git/status
|
|
937
|
+
app.get('/api/git/status', async (req: Request, res: Response): Promise<void> => {
|
|
938
|
+
const rawPath = (req.query as Record<string, string>).path;
|
|
939
|
+
if (!rawPath) {
|
|
940
|
+
res.status(400).json({ error: '`path` query parameter is required' });
|
|
941
|
+
return;
|
|
942
|
+
}
|
|
943
|
+
const { safe, resolved } = isSafePath(rawPath);
|
|
944
|
+
if (!safe) {
|
|
945
|
+
res.status(400).json({ error: 'Invalid path — traversal not allowed' });
|
|
946
|
+
return;
|
|
947
|
+
}
|
|
948
|
+
const [statusResult, logResult] = await Promise.all([
|
|
949
|
+
runCmd(`git -C ${JSON.stringify(resolved)} status --porcelain -b 2>&1`),
|
|
950
|
+
runCmd(`git -C ${JSON.stringify(resolved)} log --oneline -10 2>&1`),
|
|
951
|
+
]);
|
|
952
|
+
|
|
953
|
+
const isRepo = statusResult.exitCode === 0;
|
|
954
|
+
let branch = '';
|
|
955
|
+
let statusText = statusResult.stdout;
|
|
956
|
+
|
|
957
|
+
if (isRepo) {
|
|
958
|
+
// First line of --porcelain -b is "## <branch>..." or "## HEAD (no branch)"
|
|
959
|
+
const lines = statusResult.stdout.split('\n');
|
|
960
|
+
const branchLine = lines[0] ?? '';
|
|
961
|
+
const branchMatch = branchLine.match(/^## ([^.]+)/);
|
|
962
|
+
branch = branchMatch ? branchMatch[1].trim() : '';
|
|
963
|
+
statusText = lines.slice(1).join('\n');
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
const recentCommits = isRepo ? logResult.stdout.split('\n').filter((l: string) => l.trim().length > 0) : [];
|
|
967
|
+
|
|
968
|
+
res.json({ branch, status: statusText, recentCommits, isRepo });
|
|
969
|
+
});
|
|
970
|
+
|
|
971
|
+
// ── Docker ────────────────────────────────────────────────────────────────
|
|
972
|
+
|
|
973
|
+
// GET /api/docker/containers
|
|
974
|
+
app.get('/api/docker/containers', async (_req: Request, res: Response): Promise<void> => {
|
|
975
|
+
const result = await runCmd('docker ps --format "{{json .}}" 2>&1');
|
|
976
|
+
if (
|
|
977
|
+
result.exitCode !== 0 ||
|
|
978
|
+
result.stdout.includes('command not found') ||
|
|
979
|
+
result.stdout.includes('Cannot connect')
|
|
980
|
+
) {
|
|
981
|
+
res.json({ available: false, containers: [] });
|
|
982
|
+
return;
|
|
983
|
+
}
|
|
984
|
+
const containers = result.stdout
|
|
985
|
+
.split('\n')
|
|
986
|
+
.filter((l: string) => l.trim().startsWith('{'))
|
|
987
|
+
.map((l: string) => {
|
|
988
|
+
try {
|
|
989
|
+
return JSON.parse(l) as unknown;
|
|
990
|
+
} catch {
|
|
991
|
+
return null;
|
|
992
|
+
}
|
|
993
|
+
})
|
|
994
|
+
.filter((c): c is NonNullable<typeof c> => c !== null);
|
|
995
|
+
res.json({ available: true, containers });
|
|
996
|
+
});
|
|
997
|
+
|
|
998
|
+
// POST /api/docker/containers/:id/action
|
|
999
|
+
app.post('/api/docker/containers/:id/action', async (req: Request, res: Response): Promise<void> => {
|
|
1000
|
+
const { id } = req.params as { id: string };
|
|
1001
|
+
const body = req.body as { action?: string };
|
|
1002
|
+
const allowed = new Set(['start', 'stop', 'restart']);
|
|
1003
|
+
if (!body.action || !allowed.has(body.action)) {
|
|
1004
|
+
res.status(400).json({ error: "`action` must be 'start', 'stop', or 'restart'" });
|
|
1005
|
+
return;
|
|
1006
|
+
}
|
|
1007
|
+
// Validate container id: alphanumeric, dashes, underscores only
|
|
1008
|
+
if (!/^[a-zA-Z0-9_\-]+$/.test(id)) {
|
|
1009
|
+
res.status(400).json({ error: 'Invalid container id' });
|
|
1010
|
+
return;
|
|
1011
|
+
}
|
|
1012
|
+
const result = await runCmd(`docker ${body.action} ${id} 2>&1`);
|
|
1013
|
+
if (result.exitCode !== 0) {
|
|
1014
|
+
res.status(500).json({ error: result.stdout || result.stderr });
|
|
1015
|
+
return;
|
|
1016
|
+
}
|
|
1017
|
+
res.json({ ok: true });
|
|
1018
|
+
});
|
|
1019
|
+
|
|
1020
|
+
// ── Notes & Memory ────────────────────────────────────────────────────────
|
|
1021
|
+
|
|
1022
|
+
interface Note {
|
|
1023
|
+
id: string;
|
|
1024
|
+
title: string;
|
|
1025
|
+
content: string;
|
|
1026
|
+
created: string;
|
|
1027
|
+
updated: string;
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
const notesDir = path.join(os.homedir(), '.conductor');
|
|
1031
|
+
const notesFile = path.join(notesDir, 'notes.json');
|
|
1032
|
+
|
|
1033
|
+
async function readNotes(): Promise<Note[]> {
|
|
1034
|
+
try {
|
|
1035
|
+
const raw = await fs.readFile(notesFile, 'utf-8');
|
|
1036
|
+
return JSON.parse(raw) as Note[];
|
|
1037
|
+
} catch {
|
|
1038
|
+
return [];
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
async function writeNotes(notes: Note[]): Promise<void> {
|
|
1043
|
+
await fs.mkdir(notesDir, { recursive: true });
|
|
1044
|
+
await fs.writeFile(notesFile, JSON.stringify(notes, null, 2), 'utf-8');
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
// GET /api/notes
|
|
1048
|
+
app.get('/api/notes', async (_req: Request, res: Response): Promise<void> => {
|
|
1049
|
+
const notes = await readNotes();
|
|
1050
|
+
res.json({ notes });
|
|
1051
|
+
});
|
|
1052
|
+
|
|
1053
|
+
// POST /api/notes
|
|
1054
|
+
app.post('/api/notes', async (req: Request, res: Response): Promise<void> => {
|
|
1055
|
+
const body = req.body as { title?: string; content?: string };
|
|
1056
|
+
if (typeof body.title !== 'string' || body.title.trim() === '') {
|
|
1057
|
+
res.status(400).json({ error: '`title` is required' });
|
|
1058
|
+
return;
|
|
1059
|
+
}
|
|
1060
|
+
if (typeof body.content !== 'string') {
|
|
1061
|
+
res.status(400).json({ error: '`content` is required' });
|
|
1062
|
+
return;
|
|
1063
|
+
}
|
|
1064
|
+
const notes = await readNotes();
|
|
1065
|
+
const now = new Date().toISOString();
|
|
1066
|
+
const note: Note = {
|
|
1067
|
+
id: crypto.randomBytes(8).toString('hex'),
|
|
1068
|
+
title: body.title.trim(),
|
|
1069
|
+
content: body.content,
|
|
1070
|
+
created: now,
|
|
1071
|
+
updated: now,
|
|
1072
|
+
};
|
|
1073
|
+
notes.push(note);
|
|
1074
|
+
await writeNotes(notes);
|
|
1075
|
+
res.json({ ok: true, note });
|
|
1076
|
+
});
|
|
1077
|
+
|
|
1078
|
+
// PUT /api/notes/:id — update title and/or content
|
|
1079
|
+
app.put('/api/notes/:id', async (req: Request, res: Response): Promise<void> => {
|
|
1080
|
+
const { id } = req.params as { id: string };
|
|
1081
|
+
const body = req.body as { title?: string; content?: string };
|
|
1082
|
+
const notes = await readNotes();
|
|
1083
|
+
const idx = notes.findIndex((n) => n.id === id);
|
|
1084
|
+
if (idx === -1) {
|
|
1085
|
+
res.status(404).json({ error: 'Note not found' });
|
|
1086
|
+
return;
|
|
1087
|
+
}
|
|
1088
|
+
if (typeof body.title === 'string') notes[idx].title = body.title.trim() || notes[idx].title;
|
|
1089
|
+
if (typeof body.content === 'string') notes[idx].content = body.content;
|
|
1090
|
+
notes[idx].updated = new Date().toISOString();
|
|
1091
|
+
await writeNotes(notes);
|
|
1092
|
+
res.json({ ok: true, note: notes[idx] });
|
|
1093
|
+
});
|
|
1094
|
+
|
|
1095
|
+
// DELETE /api/notes/:id
|
|
1096
|
+
app.delete('/api/notes/:id', async (req: Request, res: Response): Promise<void> => {
|
|
1097
|
+
const { id } = req.params as { id: string };
|
|
1098
|
+
const notes = await readNotes();
|
|
1099
|
+
const filtered = notes.filter((n) => n.id !== id);
|
|
1100
|
+
if (filtered.length === notes.length) {
|
|
1101
|
+
res.status(404).json({ error: 'Note not found' });
|
|
1102
|
+
return;
|
|
1103
|
+
}
|
|
1104
|
+
await writeNotes(filtered);
|
|
1105
|
+
res.json({ ok: true });
|
|
1106
|
+
});
|
|
1107
|
+
|
|
1108
|
+
// ── Cron Jobs ─────────────────────────────────────────────────────────────
|
|
1109
|
+
|
|
1110
|
+
// GET /api/cron
|
|
1111
|
+
app.get('/api/cron', async (_req: Request, res: Response): Promise<void> => {
|
|
1112
|
+
const result = await runCmd('crontab -l 2>/dev/null');
|
|
1113
|
+
const raw = result.stdout;
|
|
1114
|
+
const entries = raw
|
|
1115
|
+
.split('\n')
|
|
1116
|
+
.map((l: string) => l.trim())
|
|
1117
|
+
.filter((l: string) => l.length > 0 && !l.startsWith('#'));
|
|
1118
|
+
res.json({ entries, raw });
|
|
1119
|
+
});
|
|
1120
|
+
|
|
1121
|
+
// ── Todoist proxy ─────────────────────────────────────────────────────────
|
|
1122
|
+
//
|
|
1123
|
+
// All routes forward to https://api.todoist.com/api/v1 using the token
|
|
1124
|
+
// stored in the keychain. The token is never sent to the browser.
|
|
1125
|
+
|
|
1126
|
+
async function todoistProxy(
|
|
1127
|
+
token: string,
|
|
1128
|
+
path: string,
|
|
1129
|
+
options: RequestInit = {},
|
|
1130
|
+
): Promise<{ status: number; body: unknown }> {
|
|
1131
|
+
let todoistRes: globalThis.Response;
|
|
1132
|
+
try {
|
|
1133
|
+
const url = `https://api.todoist.com/api/v1${path}`;
|
|
1134
|
+
const { headers: extraHeaders, ...restOptions } = options;
|
|
1135
|
+
todoistRes = await fetch(url, {
|
|
1136
|
+
...restOptions,
|
|
1137
|
+
headers: {
|
|
1138
|
+
Authorization: `Bearer ${token}`,
|
|
1139
|
+
'Content-Type': 'application/json',
|
|
1140
|
+
...((extraHeaders as Record<string, string> | undefined) ?? {}),
|
|
1141
|
+
},
|
|
1142
|
+
});
|
|
1143
|
+
} catch (fetchErr: unknown) {
|
|
1144
|
+
const msg = fetchErr instanceof Error ? fetchErr.message : String(fetchErr);
|
|
1145
|
+
console.error('[todoist-proxy] fetch error:', msg);
|
|
1146
|
+
return { status: 502, body: { error: `Failed to reach Todoist: ${msg}` } };
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
if (todoistRes.status === 204) {
|
|
1150
|
+
return { status: 200, body: { ok: true } };
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
const rawText = await todoistRes.text().catch(() => '');
|
|
1154
|
+
|
|
1155
|
+
if (!todoistRes.ok) {
|
|
1156
|
+
console.error(`[todoist-proxy] Todoist ${todoistRes.status} for ${path}:`, rawText.slice(0, 200));
|
|
1157
|
+
return {
|
|
1158
|
+
status: todoistRes.status,
|
|
1159
|
+
body: { error: `Todoist error ${todoistRes.status}: ${rawText.slice(0, 120)}` },
|
|
1160
|
+
};
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
if (!rawText) return { status: 200, body: [] };
|
|
1164
|
+
|
|
1165
|
+
try {
|
|
1166
|
+
const data = JSON.parse(rawText);
|
|
1167
|
+
// API v1 wraps list responses in { results: [], next_cursor }
|
|
1168
|
+
if (data !== null && typeof data === 'object' && Array.isArray((data as Record<string, unknown>).results)) {
|
|
1169
|
+
return { status: 200, body: (data as Record<string, unknown>).results };
|
|
1170
|
+
}
|
|
1171
|
+
return { status: 200, body: data };
|
|
1172
|
+
} catch {
|
|
1173
|
+
console.error('[todoist-proxy] JSON parse failed, raw:', rawText.slice(0, 200));
|
|
1174
|
+
return { status: 502, body: { error: `Todoist returned non-JSON: ${rawText.slice(0, 80)}` } };
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
// GET /api/todoist/status
|
|
1179
|
+
app.get('/api/todoist/status', async (_req: Request, res: Response): Promise<void> => {
|
|
1180
|
+
const configured = await keychain.has('todoist', 'api_token');
|
|
1181
|
+
res.json({ configured });
|
|
1182
|
+
});
|
|
1183
|
+
|
|
1184
|
+
// GET /api/todoist/projects
|
|
1185
|
+
app.get('/api/todoist/projects', async (_req: Request, res: Response): Promise<void> => {
|
|
1186
|
+
const token = await keychain.get('todoist', 'api_token');
|
|
1187
|
+
if (!token) {
|
|
1188
|
+
res.status(400).json({ error: 'Todoist not configured' });
|
|
1189
|
+
return;
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
const { status, body } = await todoistProxy(token, '/projects');
|
|
1193
|
+
res.status(status).json(body);
|
|
1194
|
+
});
|
|
1195
|
+
|
|
1196
|
+
// GET /api/todoist/tasks — supports ?project_id, ?label, ?filter
|
|
1197
|
+
app.get('/api/todoist/tasks', async (req: Request, res: Response): Promise<void> => {
|
|
1198
|
+
const token = await keychain.get('todoist', 'api_token');
|
|
1199
|
+
if (!token) {
|
|
1200
|
+
res.status(400).json({ error: 'Todoist not configured' });
|
|
1201
|
+
return;
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
const query = req.query as Record<string, string>;
|
|
1205
|
+
const params = new URLSearchParams();
|
|
1206
|
+
for (const key of ['project_id', 'label', 'filter'] as const) {
|
|
1207
|
+
if (query[key]) params.set(key, query[key]);
|
|
1208
|
+
}
|
|
1209
|
+
const qs = params.toString() ? `?${params.toString()}` : '';
|
|
1210
|
+
|
|
1211
|
+
const { status, body } = await todoistProxy(token, `/tasks${qs}`);
|
|
1212
|
+
res.status(status).json(body);
|
|
1213
|
+
});
|
|
1214
|
+
|
|
1215
|
+
// POST /api/todoist/tasks — create a task
|
|
1216
|
+
app.post('/api/todoist/tasks', async (req: Request, res: Response): Promise<void> => {
|
|
1217
|
+
const token = await keychain.get('todoist', 'api_token');
|
|
1218
|
+
if (!token) {
|
|
1219
|
+
res.status(400).json({ error: 'Todoist not configured' });
|
|
1220
|
+
return;
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
const { status, body } = await todoistProxy(token, '/tasks', {
|
|
1224
|
+
method: 'POST',
|
|
1225
|
+
body: JSON.stringify(req.body),
|
|
1226
|
+
});
|
|
1227
|
+
res.status(status).json(body);
|
|
1228
|
+
});
|
|
1229
|
+
|
|
1230
|
+
// POST /api/todoist/tasks/:id — update a task
|
|
1231
|
+
app.post('/api/todoist/tasks/:id', async (req: Request, res: Response): Promise<void> => {
|
|
1232
|
+
const token = await keychain.get('todoist', 'api_token');
|
|
1233
|
+
if (!token) {
|
|
1234
|
+
res.status(400).json({ error: 'Todoist not configured' });
|
|
1235
|
+
return;
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
const { id } = req.params as { id: string };
|
|
1239
|
+
const { status, body } = await todoistProxy(token, `/tasks/${id}`, {
|
|
1240
|
+
method: 'POST',
|
|
1241
|
+
body: JSON.stringify(req.body),
|
|
1242
|
+
});
|
|
1243
|
+
res.status(status).json(body);
|
|
1244
|
+
});
|
|
1245
|
+
|
|
1246
|
+
// POST /api/todoist/tasks/:id/close — complete a task
|
|
1247
|
+
app.post('/api/todoist/tasks/:id/close', async (req: Request, res: Response): Promise<void> => {
|
|
1248
|
+
const token = await keychain.get('todoist', 'api_token');
|
|
1249
|
+
if (!token) {
|
|
1250
|
+
res.status(400).json({ error: 'Todoist not configured' });
|
|
1251
|
+
return;
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
const { id } = req.params as { id: string };
|
|
1255
|
+
const { status, body } = await todoistProxy(token, `/tasks/${id}/close`, {
|
|
1256
|
+
method: 'POST',
|
|
1257
|
+
});
|
|
1258
|
+
// Todoist returns 204 on success — normalise to a JSON ok response for the
|
|
1259
|
+
// frontend so it doesn't have to special-case empty bodies.
|
|
1260
|
+
if (status === 204) {
|
|
1261
|
+
res.json({ ok: true });
|
|
1262
|
+
return;
|
|
1263
|
+
}
|
|
1264
|
+
res.status(status).json(body);
|
|
1265
|
+
});
|
|
1266
|
+
|
|
1267
|
+
// DELETE /api/todoist/tasks/:id — delete a task
|
|
1268
|
+
app.delete('/api/todoist/tasks/:id', async (req: Request, res: Response): Promise<void> => {
|
|
1269
|
+
const token = await keychain.get('todoist', 'api_token');
|
|
1270
|
+
if (!token) {
|
|
1271
|
+
res.status(400).json({ error: 'Todoist not configured' });
|
|
1272
|
+
return;
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
const { id } = req.params as { id: string };
|
|
1276
|
+
const { status, body } = await todoistProxy(token, `/tasks/${id}`, {
|
|
1277
|
+
method: 'DELETE',
|
|
1278
|
+
});
|
|
1279
|
+
if (status === 204) {
|
|
1280
|
+
res.json({ ok: true });
|
|
1281
|
+
return;
|
|
1282
|
+
}
|
|
1283
|
+
res.status(status).json(body);
|
|
1284
|
+
});
|
|
1285
|
+
|
|
1286
|
+
// ── Conversations ─────────────────────────────────────────────────────────
|
|
1287
|
+
app.get('/api/conversations', async (_req: Request, res: Response): Promise<void> => {
|
|
1288
|
+
try {
|
|
1289
|
+
const messages = await db.getRecentMessages(200);
|
|
1290
|
+
res.json({ messages: messages.reverse() });
|
|
1291
|
+
} catch {
|
|
1292
|
+
res.json({ messages: [] });
|
|
1293
|
+
}
|
|
1294
|
+
});
|
|
1295
|
+
|
|
1296
|
+
// ── Live Log Stream (SSE) ─────────────────────────────────────────────────
|
|
1297
|
+
app.get('/api/logs/stream', (req: Request, res: Response): void => {
|
|
1298
|
+
res.setHeader('Content-Type', 'text/event-stream');
|
|
1299
|
+
res.setHeader('Cache-Control', 'no-cache');
|
|
1300
|
+
res.setHeader('Connection', 'keep-alive');
|
|
1301
|
+
res.flushHeaders();
|
|
1302
|
+
|
|
1303
|
+
// Send buffered logs first
|
|
1304
|
+
for (const entry of logBuffer) {
|
|
1305
|
+
res.write(`data: ${JSON.stringify(entry)}\n\n`);
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
sseClients.add(res);
|
|
1309
|
+
|
|
1310
|
+
req.on('close', () => {
|
|
1311
|
+
sseClients.delete(res);
|
|
1312
|
+
});
|
|
1313
|
+
});
|
|
1314
|
+
|
|
1315
|
+
// ── Chat (AI) ─────────────────────────────────────────────────────────────
|
|
1316
|
+
|
|
1317
|
+
const PLUGIN_CATALOG: Record<string, { desc: string; category: string; requiresAuth: boolean; authLabel?: string }> =
|
|
1318
|
+
{
|
|
1319
|
+
calculator: {
|
|
1320
|
+
desc: 'Evaluate mathematical expressions and unit conversions',
|
|
1321
|
+
category: 'Utilities',
|
|
1322
|
+
requiresAuth: false,
|
|
1323
|
+
},
|
|
1324
|
+
colors: {
|
|
1325
|
+
desc: 'Convert and manipulate colors (hex, rgb, hsl, name)',
|
|
1326
|
+
category: 'Utilities',
|
|
1327
|
+
requiresAuth: false,
|
|
1328
|
+
},
|
|
1329
|
+
cron: { desc: 'Manage and inspect system cron jobs', category: 'System', requiresAuth: false },
|
|
1330
|
+
crypto: { desc: 'Encrypt, decrypt, and generate cryptographic keys', category: 'Security', requiresAuth: false },
|
|
1331
|
+
fun: { desc: 'Jokes, trivia, dice rolls, and random fun', category: 'Utilities', requiresAuth: false },
|
|
1332
|
+
gcal: {
|
|
1333
|
+
desc: 'Read, create, and manage Google Calendar events',
|
|
1334
|
+
category: 'Google',
|
|
1335
|
+
requiresAuth: true,
|
|
1336
|
+
authLabel: 'Google OAuth',
|
|
1337
|
+
},
|
|
1338
|
+
gdrive: {
|
|
1339
|
+
desc: 'List, read, and upload files to Google Drive',
|
|
1340
|
+
category: 'Google',
|
|
1341
|
+
requiresAuth: true,
|
|
1342
|
+
authLabel: 'Google OAuth',
|
|
1343
|
+
},
|
|
1344
|
+
github: {
|
|
1345
|
+
desc: 'Manage repos, issues, PRs, and gists on GitHub',
|
|
1346
|
+
category: 'Developer',
|
|
1347
|
+
requiresAuth: true,
|
|
1348
|
+
authLabel: 'GitHub Token',
|
|
1349
|
+
},
|
|
1350
|
+
'github-actions': {
|
|
1351
|
+
desc: 'Trigger and monitor GitHub Actions CI/CD workflows',
|
|
1352
|
+
category: 'Developer',
|
|
1353
|
+
requiresAuth: true,
|
|
1354
|
+
authLabel: 'GitHub Token',
|
|
1355
|
+
},
|
|
1356
|
+
gmail: {
|
|
1357
|
+
desc: 'Read, search, send, and label Gmail messages',
|
|
1358
|
+
category: 'Google',
|
|
1359
|
+
requiresAuth: true,
|
|
1360
|
+
authLabel: 'Google OAuth',
|
|
1361
|
+
},
|
|
1362
|
+
hash: { desc: 'Compute MD5, SHA-1, SHA-256, and bcrypt hashes', category: 'Security', requiresAuth: false },
|
|
1363
|
+
homekit: {
|
|
1364
|
+
desc: 'Control HomeKit smart home devices and accessories',
|
|
1365
|
+
category: 'Smart Home',
|
|
1366
|
+
requiresAuth: true,
|
|
1367
|
+
authLabel: 'HomeKit Bridge URL',
|
|
1368
|
+
},
|
|
1369
|
+
memory: { desc: 'Store and recall information across conversations', category: 'AI', requiresAuth: false },
|
|
1370
|
+
n8n: {
|
|
1371
|
+
desc: 'Trigger n8n automation workflows via webhook',
|
|
1372
|
+
category: 'Automation',
|
|
1373
|
+
requiresAuth: true,
|
|
1374
|
+
authLabel: 'n8n API Key',
|
|
1375
|
+
},
|
|
1376
|
+
network: { desc: 'DNS lookup, ping, port scan, and IP geolocation', category: 'System', requiresAuth: false },
|
|
1377
|
+
notes: { desc: 'Create, read, update, and delete personal notes', category: 'Productivity', requiresAuth: false },
|
|
1378
|
+
notion: {
|
|
1379
|
+
desc: 'Query, create, and update Notion databases and pages',
|
|
1380
|
+
category: 'Productivity',
|
|
1381
|
+
requiresAuth: true,
|
|
1382
|
+
authLabel: 'Notion API Key',
|
|
1383
|
+
},
|
|
1384
|
+
slack: {
|
|
1385
|
+
desc: 'Send messages and read channels in Slack workspaces',
|
|
1386
|
+
category: 'Communication',
|
|
1387
|
+
requiresAuth: true,
|
|
1388
|
+
authLabel: 'Slack Bot Token',
|
|
1389
|
+
},
|
|
1390
|
+
spotify: {
|
|
1391
|
+
desc: 'Control Spotify playback and browse music catalog',
|
|
1392
|
+
category: 'Entertainment',
|
|
1393
|
+
requiresAuth: true,
|
|
1394
|
+
authLabel: 'Spotify OAuth',
|
|
1395
|
+
},
|
|
1396
|
+
system: {
|
|
1397
|
+
desc: 'CPU, memory, disk stats, processes, and shell commands',
|
|
1398
|
+
category: 'System',
|
|
1399
|
+
requiresAuth: false,
|
|
1400
|
+
},
|
|
1401
|
+
'text-tools': {
|
|
1402
|
+
desc: 'Transform text: case, trim, word count, slugify, base64',
|
|
1403
|
+
category: 'Utilities',
|
|
1404
|
+
requiresAuth: false,
|
|
1405
|
+
},
|
|
1406
|
+
timezone: { desc: 'Convert times between timezones worldwide', category: 'Utilities', requiresAuth: false },
|
|
1407
|
+
todoist: {
|
|
1408
|
+
desc: 'Manage Todoist tasks, projects, and priorities',
|
|
1409
|
+
category: 'Productivity',
|
|
1410
|
+
requiresAuth: true,
|
|
1411
|
+
authLabel: 'Todoist API Token',
|
|
1412
|
+
},
|
|
1413
|
+
translate: { desc: 'Translate text between 100+ languages', category: 'Utilities', requiresAuth: false },
|
|
1414
|
+
'url-tools': {
|
|
1415
|
+
desc: 'Parse, encode, decode, and expand shortened URLs',
|
|
1416
|
+
category: 'Utilities',
|
|
1417
|
+
requiresAuth: false,
|
|
1418
|
+
},
|
|
1419
|
+
vercel: {
|
|
1420
|
+
desc: 'Manage Vercel deployments, projects, and domains',
|
|
1421
|
+
category: 'Developer',
|
|
1422
|
+
requiresAuth: true,
|
|
1423
|
+
authLabel: 'Vercel Token',
|
|
1424
|
+
},
|
|
1425
|
+
weather: {
|
|
1426
|
+
desc: 'Current conditions and forecasts for any location',
|
|
1427
|
+
category: 'Utilities',
|
|
1428
|
+
requiresAuth: true,
|
|
1429
|
+
authLabel: 'Weather API Key',
|
|
1430
|
+
},
|
|
1431
|
+
x: {
|
|
1432
|
+
desc: 'Post tweets and read your X/Twitter timeline',
|
|
1433
|
+
category: 'Social',
|
|
1434
|
+
requiresAuth: true,
|
|
1435
|
+
authLabel: 'X API Key',
|
|
1436
|
+
},
|
|
1437
|
+
};
|
|
1438
|
+
|
|
1439
|
+
let _chatConductorInstance: Conductor | null = conductorInstance ?? null;
|
|
1440
|
+
|
|
1441
|
+
async function getChatConductor(): Promise<Conductor> {
|
|
1442
|
+
if (_chatConductorInstance) return _chatConductorInstance;
|
|
1443
|
+
const { Conductor: ConductorClass } = await import('../core/conductor.js');
|
|
1444
|
+
_chatConductorInstance = new ConductorClass(undefined, { quiet: true });
|
|
1445
|
+
await _chatConductorInstance.initialize();
|
|
1446
|
+
return _chatConductorInstance;
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
// POST /api/chat
|
|
1450
|
+
app.post('/api/chat', async (req: Request, res: Response): Promise<void> => {
|
|
1451
|
+
const body = req.body as { message?: string; userId?: string };
|
|
1452
|
+
if (!body.message || typeof body.message !== 'string' || body.message.trim() === '') {
|
|
1453
|
+
res.status(400).json({ error: '`message` is required' });
|
|
1454
|
+
return;
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
const userId = body.userId && typeof body.userId === 'string' ? body.userId.trim() : 'dashboard-user';
|
|
1458
|
+
|
|
1459
|
+
try {
|
|
1460
|
+
const c = await getChatConductor();
|
|
1461
|
+
const ai = c.getAIManager();
|
|
1462
|
+
|
|
1463
|
+
const result = await ai.handleConversation(userId, body.message.trim());
|
|
1464
|
+
|
|
1465
|
+
// Extract tool calls from the current turn: walk backwards from the end,
|
|
1466
|
+
// collect tool messages until we hit the user message we just added.
|
|
1467
|
+
const history = await c.getDatabase().getHistory(userId, 60);
|
|
1468
|
+
const toolCalls: Array<{ tool: string; success: boolean }> = [];
|
|
1469
|
+
for (let i = history.length - 1; i >= 0; i--) {
|
|
1470
|
+
const msg = history[i];
|
|
1471
|
+
if (msg.role === 'user') break; // stop at the user message for this turn
|
|
1472
|
+
if (msg.role === 'tool' && msg.name) {
|
|
1473
|
+
toolCalls.unshift({ tool: msg.name, success: !String(msg.content ?? '').startsWith('Error') });
|
|
1474
|
+
}
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
const providerName = c.getConfig().get<string>('ai.provider') ?? 'unknown';
|
|
1478
|
+
const modelName = c.getConfig().get<string>('ai.model') ?? '';
|
|
1479
|
+
|
|
1480
|
+
res.json({
|
|
1481
|
+
response: result.text,
|
|
1482
|
+
toolCalls,
|
|
1483
|
+
approvalRequired: result.approvalRequired ?? null,
|
|
1484
|
+
provider: providerName,
|
|
1485
|
+
model: modelName,
|
|
1486
|
+
});
|
|
1487
|
+
} catch (e: unknown) {
|
|
1488
|
+
res.status(500).json({ error: (e as Error).message });
|
|
1489
|
+
}
|
|
1490
|
+
});
|
|
1491
|
+
|
|
1492
|
+
// GET /api/chat/history
|
|
1493
|
+
app.get('/api/chat/history', async (req: Request, res: Response): Promise<void> => {
|
|
1494
|
+
const userId = ((req.query as Record<string, string>).userId ?? 'dashboard-user').trim();
|
|
1495
|
+
try {
|
|
1496
|
+
const c = await getChatConductor();
|
|
1497
|
+
const messages = await c.getDatabase().getHistory(userId, 100);
|
|
1498
|
+
res.json({ messages });
|
|
1499
|
+
} catch (e: unknown) {
|
|
1500
|
+
res.status(500).json({ error: (e as Error).message });
|
|
1501
|
+
}
|
|
1502
|
+
});
|
|
1503
|
+
|
|
1504
|
+
// DELETE /api/chat/history
|
|
1505
|
+
app.delete('/api/chat/history', async (req: Request, res: Response): Promise<void> => {
|
|
1506
|
+
const userId = ((req.query as Record<string, string>).userId ?? 'dashboard-user').trim();
|
|
1507
|
+
try {
|
|
1508
|
+
const c = await getChatConductor();
|
|
1509
|
+
await c.getDatabase().clearHistory(userId);
|
|
1510
|
+
res.json({ ok: true });
|
|
1511
|
+
} catch (e: unknown) {
|
|
1512
|
+
res.status(500).json({ error: (e as Error).message });
|
|
1513
|
+
}
|
|
1514
|
+
});
|
|
1515
|
+
|
|
1516
|
+
// GET /api/marketplace
|
|
1517
|
+
app.get('/api/marketplace', (_req: Request, res: Response): void => {
|
|
1518
|
+
const installedPlugins = config.get<string[]>('plugins.installed') ?? [];
|
|
1519
|
+
const enabledPlugins = config.get<string[]>('plugins.enabled') ?? [];
|
|
1520
|
+
|
|
1521
|
+
const plugins = Object.entries(PLUGIN_CATALOG).map(([name, meta]) => ({
|
|
1522
|
+
name,
|
|
1523
|
+
...meta,
|
|
1524
|
+
installed: installedPlugins.includes(name) || ALL_PLUGINS.includes(name as never),
|
|
1525
|
+
enabled: enabledPlugins.includes(name),
|
|
1526
|
+
requiredCreds: PLUGIN_REQUIRED_CREDS[name] ?? [],
|
|
1527
|
+
}));
|
|
1528
|
+
|
|
1529
|
+
res.json({ plugins });
|
|
1530
|
+
});
|
|
1531
|
+
|
|
1532
|
+
// ── Start — bind to 127.0.0.1 only (not 0.0.0.0) ────────────────────────
|
|
1533
|
+
return new Promise<DashboardServer>((resolve, reject) => {
|
|
1534
|
+
const server = app.listen(port, '127.0.0.1', () => {
|
|
1535
|
+
process.stderr.write(`Dashboard running at http://127.0.0.1:${port}\n`);
|
|
1536
|
+
process.stderr.write(`Dashboard token stored at ${path.join(config.getConfigDir(), 'dashboard.token')}\n`);
|
|
1537
|
+
resolve({
|
|
1538
|
+
port,
|
|
1539
|
+
close: (): Promise<void> => new Promise<void>((res, rej) => server.close((err) => (err ? rej(err) : res()))),
|
|
1540
|
+
});
|
|
1541
|
+
});
|
|
1542
|
+
server.on('error', reject);
|
|
1543
|
+
});
|
|
1544
|
+
}
|