@gokiteam/goki-dev 0.2.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/README.md +478 -0
- package/bin/goki-dev.js +452 -0
- package/bin/mcp-server.js +16 -0
- package/bin/secrets-cli.js +302 -0
- package/cli/ComposeOverrideGenerator.js +226 -0
- package/cli/ComposeParser.js +73 -0
- package/cli/ConfigGenerator.js +304 -0
- package/cli/ConfigManager.js +46 -0
- package/cli/DatabaseManager.js +94 -0
- package/cli/DevToolsChecker.js +21 -0
- package/cli/DevToolsDir.js +66 -0
- package/cli/DevToolsManager.js +451 -0
- package/cli/DockerManager.js +138 -0
- package/cli/FunctionManager.js +95 -0
- package/cli/HttpProxyRewriter.js +91 -0
- package/cli/Logger.js +10 -0
- package/cli/McpConfigManager.js +123 -0
- package/cli/NgrokManager.js +431 -0
- package/cli/ProjectCLI.js +2322 -0
- package/cli/PubSubManager.js +129 -0
- package/cli/SnapshotManager.js +88 -0
- package/cli/UiFormatter.js +292 -0
- package/cli/WebhookUrlRewriter.js +32 -0
- package/cli/secrets/BiometricAuth.js +125 -0
- package/cli/secrets/SecretInjector.js +47 -0
- package/cli/secrets/SecretsConfig.js +141 -0
- package/cli/secrets/SecretsDoctor.js +384 -0
- package/cli/secrets/SecretsManager.js +255 -0
- package/client/dist/client.d.ts +332 -0
- package/client/dist/client.js +507 -0
- package/client/dist/helpers.d.ts +62 -0
- package/client/dist/helpers.js +122 -0
- package/client/dist/index.d.ts +59 -0
- package/client/dist/index.js +78 -0
- package/client/dist/package.json +1 -0
- package/client/dist/types.d.ts +280 -0
- package/client/dist/types.js +7 -0
- package/config.development +46 -0
- package/config.test +18 -0
- package/guidelines/CodingStyleGuideline.md +148 -0
- package/guidelines/CommentingGuideline.md +10 -0
- package/guidelines/HttpApiImplementationGuideline.md +137 -0
- package/guidelines/NamingGuideline.md +182 -0
- package/package.json +138 -0
- package/patterns/api/[collectionName]/Controllers.md +62 -0
- package/patterns/api/[collectionName]/Logic.md +154 -0
- package/patterns/api/[collectionName]/Permissions.md +81 -0
- package/patterns/api/[collectionName]/Router.md +83 -0
- package/patterns/api/[collectionName]/Schemas.md +197 -0
- package/patterns/configs/Patterns.md +7 -0
- package/patterns/enums/Patterns.md +24 -0
- package/patterns/errorHandling/Patterns.md +185 -0
- package/patterns/testing/Patterns.md +232 -0
- package/src/Server.js +238 -0
- package/src/api/dashboard/Controllers.js +9 -0
- package/src/api/dashboard/Logic.js +76 -0
- package/src/api/dashboard/Router.js +11 -0
- package/src/api/dashboard/Schemas.js +47 -0
- package/src/api/data/Controllers.js +26 -0
- package/src/api/data/Logic.js +188 -0
- package/src/api/data/Router.js +16 -0
- package/src/api/docker/Controllers.js +33 -0
- package/src/api/docker/Logic.js +268 -0
- package/src/api/docker/Router.js +15 -0
- package/src/api/docker/Schemas.js +80 -0
- package/src/api/docs/Controllers.js +15 -0
- package/src/api/docs/Logic.js +85 -0
- package/src/api/docs/Router.js +12 -0
- package/src/api/export/Controllers.js +30 -0
- package/src/api/export/Logic.js +143 -0
- package/src/api/export/Router.js +18 -0
- package/src/api/export/Schemas.js +104 -0
- package/src/api/firestore/Controllers.js +152 -0
- package/src/api/firestore/Logic.js +474 -0
- package/src/api/firestore/Router.js +23 -0
- package/src/api/functions/Controllers.js +261 -0
- package/src/api/functions/Logic.js +710 -0
- package/src/api/functions/Router.js +50 -0
- package/src/api/functions/Schemas.js +193 -0
- package/src/api/gateway/Controllers.js +72 -0
- package/src/api/gateway/Logic.js +74 -0
- package/src/api/gateway/Router.js +10 -0
- package/src/api/gateway/Schemas.js +19 -0
- package/src/api/health/Controllers.js +14 -0
- package/src/api/health/Logic.js +24 -0
- package/src/api/health/Router.js +12 -0
- package/src/api/httpTraffic/Controllers.js +29 -0
- package/src/api/httpTraffic/Logic.js +33 -0
- package/src/api/httpTraffic/Router.js +9 -0
- package/src/api/httpTraffic/Schemas.js +23 -0
- package/src/api/logging/Controllers.js +80 -0
- package/src/api/logging/Logic.js +461 -0
- package/src/api/logging/Router.js +24 -0
- package/src/api/logging/Schemas.js +43 -0
- package/src/api/mqtt/Controllers.js +17 -0
- package/src/api/mqtt/Logic.js +66 -0
- package/src/api/mqtt/Router.js +12 -0
- package/src/api/postgres/Controllers.js +97 -0
- package/src/api/postgres/Logic.js +221 -0
- package/src/api/postgres/Router.js +21 -0
- package/src/api/pubsub/Controllers.js +236 -0
- package/src/api/pubsub/Logic.js +732 -0
- package/src/api/pubsub/Router.js +41 -0
- package/src/api/pubsub/Schemas.js +355 -0
- package/src/api/redis/Controllers.js +63 -0
- package/src/api/redis/Logic.js +239 -0
- package/src/api/redis/Router.js +21 -0
- package/src/api/scheduler/Controllers.js +27 -0
- package/src/api/scheduler/Logic.js +49 -0
- package/src/api/scheduler/Router.js +16 -0
- package/src/api/services/Controllers.js +26 -0
- package/src/api/services/Logic.js +205 -0
- package/src/api/services/Router.js +14 -0
- package/src/api/services/Schemas.js +66 -0
- package/src/api/snapshots/Controllers.js +37 -0
- package/src/api/snapshots/Logic.js +797 -0
- package/src/api/snapshots/Router.js +15 -0
- package/src/api/snapshots/Schemas.js +23 -0
- package/src/api/webhooks/Controllers.js +49 -0
- package/src/api/webhooks/Logic.js +137 -0
- package/src/api/webhooks/Router.js +12 -0
- package/src/api/webhooks/Schemas.js +31 -0
- package/src/configs/Application.js +147 -0
- package/src/configs/Default.js +13 -0
- package/src/consumers/BlackboxLogsConsumer.js +235 -0
- package/src/consumers/DockerLogsConsumer.js +687 -0
- package/src/db/Tables.js +66 -0
- package/src/db/schemas/firestore.js +18 -0
- package/src/db/schemas/functions.js +65 -0
- package/src/db/schemas/httpTraffic.js +43 -0
- package/src/db/schemas/logging.js +74 -0
- package/src/db/schemas/migrations.js +64 -0
- package/src/db/schemas/mqtt.js +56 -0
- package/src/db/schemas/pubsub.js +90 -0
- package/src/db/schemas/pubsubRegistry.js +22 -0
- package/src/db/schemas/webhooks.js +28 -0
- package/src/emulation/awsiot/Controllers.js +91 -0
- package/src/emulation/awsiot/Logic.js +70 -0
- package/src/emulation/awsiot/Router.js +19 -0
- package/src/emulation/awsiot/Server.js +100 -0
- package/src/emulation/firestore/Server.js +136 -0
- package/src/emulation/logging/Controllers.js +212 -0
- package/src/emulation/logging/Logic.js +416 -0
- package/src/emulation/logging/Router.js +36 -0
- package/src/emulation/logging/Schemas.js +82 -0
- package/src/emulation/logging/Server.js +108 -0
- package/src/emulation/pubsub/Controllers.js +279 -0
- package/src/emulation/pubsub/DefaultTopics.js +162 -0
- package/src/emulation/pubsub/Logic.js +427 -0
- package/src/emulation/pubsub/README.md +309 -0
- package/src/emulation/pubsub/Router.js +33 -0
- package/src/emulation/pubsub/Server.js +104 -0
- package/src/emulation/pubsub/ShadowPoller.js +276 -0
- package/src/emulation/pubsub/ShadowSubscriptionManager.js +199 -0
- package/src/enums/ContainerNames.js +106 -0
- package/src/enums/ErrorReason.js +28 -0
- package/src/enums/FunctionStatuses.js +15 -0
- package/src/enums/FunctionTriggerTypes.js +15 -0
- package/src/enums/GatewayState.js +7 -0
- package/src/enums/ServiceNames.js +68 -0
- package/src/jobs/DatabaseMaintenance.js +184 -0
- package/src/jobs/MessageHistoryCleanup.js +152 -0
- package/src/mcp/ApiClient.js +25 -0
- package/src/mcp/Server.js +52 -0
- package/src/mcp/prompts/debugging.js +104 -0
- package/src/mcp/resources/platform.js +118 -0
- package/src/mcp/tools/data.js +84 -0
- package/src/mcp/tools/docker.js +166 -0
- package/src/mcp/tools/firestore.js +162 -0
- package/src/mcp/tools/functions.js +380 -0
- package/src/mcp/tools/httpTraffic.js +69 -0
- package/src/mcp/tools/logging.js +174 -0
- package/src/mcp/tools/mqtt.js +37 -0
- package/src/mcp/tools/postgres.js +130 -0
- package/src/mcp/tools/pubsub.js +316 -0
- package/src/mcp/tools/redis.js +146 -0
- package/src/mcp/tools/services.js +169 -0
- package/src/mcp/tools/snapshots.js +88 -0
- package/src/mcp/tools/webhooks.js +115 -0
- package/src/middleware/DevProxy.js +67 -0
- package/src/middleware/ErrorCatcher.js +35 -0
- package/src/middleware/HttpProxy.js +215 -0
- package/src/middleware/Reply.js +24 -0
- package/src/middleware/TraceId.js +9 -0
- package/src/middleware/WebhookProxy.js +234 -0
- package/src/protocols/mqtt/Broker.js +92 -0
- package/src/protocols/mqtt/Handlers.js +175 -0
- package/src/protocols/mqtt/PubSubBridge.js +162 -0
- package/src/protocols/mqtt/Server.js +116 -0
- package/src/runtime/FunctionRunner.js +179 -0
- package/src/services/AppGatewayService.js +582 -0
- package/src/singletons/FirestoreBroadcaster.js +367 -0
- package/src/singletons/FunctionTriggerDispatcher.js +456 -0
- package/src/singletons/FunctionsService.js +418 -0
- package/src/singletons/HttpProxy.js +224 -0
- package/src/singletons/LogBroadcaster.js +159 -0
- package/src/singletons/Logger.js +49 -0
- package/src/singletons/MemoryJsonStore.js +175 -0
- package/src/singletons/MessageBroadcaster.js +190 -0
- package/src/singletons/PostgresBroadcaster.js +367 -0
- package/src/singletons/PostgresClient.js +180 -0
- package/src/singletons/RedisClient.js +184 -0
- package/src/singletons/SqliteStore.js +480 -0
- package/src/singletons/TickService.js +151 -0
- package/src/singletons/WebhookProxy.js +223 -0
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import path from 'path'
|
|
2
|
+
import { collectAllEnvVars } from './ComposeParser.js'
|
|
3
|
+
|
|
4
|
+
const DEV_TOOLS_PROXY_BASE = 'http://goki-dev-tools-backend:9000/proxy'
|
|
5
|
+
|
|
6
|
+
// Env var names that should be checked for proxy rewriting
|
|
7
|
+
const URL_SUFFIXES = ['_URL', '_BASE_URL']
|
|
8
|
+
|
|
9
|
+
// Infrastructure hostnames to skip (not HTTP API services)
|
|
10
|
+
const INFRA_SKIP_LIST = [
|
|
11
|
+
'redis', 'goki-redis', 'goki-redis-logs',
|
|
12
|
+
'postgres', 'goki-postgres',
|
|
13
|
+
'goki-pubsub-emulator', 'pubsub-emulator',
|
|
14
|
+
'goki-firestore-emulator', 'firestore-emulator',
|
|
15
|
+
'goki-elasticsearch', 'elasticsearch',
|
|
16
|
+
'goki-kibana', 'kibana',
|
|
17
|
+
'goki-dev-tools-backend', 'goki-dev-tools-frontend',
|
|
18
|
+
'localhost', '127.0.0.1'
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Check if an env var name is a URL variable that should be proxied
|
|
23
|
+
*/
|
|
24
|
+
function isUrlEnvVar (name) {
|
|
25
|
+
return URL_SUFFIXES.some(suffix => name.endsWith(suffix))
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Check if a hostname matches a filter pattern (substring match)
|
|
30
|
+
*/
|
|
31
|
+
function hostnameMatchesPattern (hostname, pattern) {
|
|
32
|
+
return hostname === pattern || hostname.includes(pattern)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Check if a URL value should be proxied through dev-tools for monitoring
|
|
37
|
+
*/
|
|
38
|
+
function isProxiableUrl (value, proxyConfig = {}) {
|
|
39
|
+
if (!value.startsWith('http://') && !value.startsWith('https://')) return false
|
|
40
|
+
try {
|
|
41
|
+
const url = new URL(value)
|
|
42
|
+
const hostname = url.hostname
|
|
43
|
+
if (INFRA_SKIP_LIST.includes(hostname)) return false
|
|
44
|
+
if (proxyConfig.include?.length > 0) {
|
|
45
|
+
return proxyConfig.include.some(p => hostnameMatchesPattern(hostname, p))
|
|
46
|
+
}
|
|
47
|
+
if (proxyConfig.exclude?.length > 0) {
|
|
48
|
+
if (proxyConfig.exclude.some(p => hostnameMatchesPattern(hostname, p))) {
|
|
49
|
+
return false
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return true
|
|
53
|
+
} catch {
|
|
54
|
+
return false
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Compute HTTP proxy URL rewrites as pure data (no file writing)
|
|
60
|
+
*
|
|
61
|
+
* @param {object} config - The config object with httpProxy settings
|
|
62
|
+
* @param {string} projectDir - Absolute path to project root
|
|
63
|
+
* @returns {{ rewrites: Array<{key, original, proxied}>, skipped: Array<{key, original, hostname}>, serviceName: string }} or null
|
|
64
|
+
*/
|
|
65
|
+
export function computeProxyRewrites (config, projectDir) {
|
|
66
|
+
const docker = config.docker || {}
|
|
67
|
+
const proxyConfig = config.httpProxy || {}
|
|
68
|
+
const composeFile = docker.composeFile || 'docker-compose.yaml'
|
|
69
|
+
const envFilePath = path.join(projectDir, 'config.development')
|
|
70
|
+
const composePath = path.join(projectDir, composeFile)
|
|
71
|
+
const { allVars, serviceName } = collectAllEnvVars(envFilePath, composePath)
|
|
72
|
+
if (!serviceName) return null
|
|
73
|
+
const rewrites = []
|
|
74
|
+
const skipped = []
|
|
75
|
+
for (const [key, value] of Object.entries(allVars)) {
|
|
76
|
+
if (!isUrlEnvVar(key)) continue
|
|
77
|
+
if (isProxiableUrl(value, proxyConfig)) {
|
|
78
|
+
const proxiedUrl = `${DEV_TOOLS_PROXY_BASE}/${value}`
|
|
79
|
+
rewrites.push({ key, original: value, proxied: proxiedUrl })
|
|
80
|
+
} else if (value.startsWith('http://') || value.startsWith('https://')) {
|
|
81
|
+
try {
|
|
82
|
+
const hostname = new URL(value).hostname
|
|
83
|
+
if (!INFRA_SKIP_LIST.includes(hostname)) {
|
|
84
|
+
skipped.push({ key, original: value, hostname })
|
|
85
|
+
}
|
|
86
|
+
} catch {}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (rewrites.length === 0) return null
|
|
90
|
+
return { rewrites, skipped, serviceName }
|
|
91
|
+
}
|
package/cli/Logger.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import chalk from 'chalk'
|
|
2
|
+
import ora from 'ora'
|
|
3
|
+
|
|
4
|
+
export const Logger = {
|
|
5
|
+
info: (msg) => console.log(chalk.blue('ā¹'), msg),
|
|
6
|
+
success: (msg) => console.log(chalk.green('ā'), msg),
|
|
7
|
+
error: (msg) => console.log(chalk.red('ā'), msg),
|
|
8
|
+
warn: (msg) => console.log(chalk.yellow('ā '), msg),
|
|
9
|
+
spinner: (text) => ora(text).start()
|
|
10
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import fs from 'fs'
|
|
2
|
+
import path from 'path'
|
|
3
|
+
import { ensureGitignore } from './DevToolsDir.js'
|
|
4
|
+
|
|
5
|
+
const MCP_SERVER_NAME = 'goki-dev-tools'
|
|
6
|
+
const MCP_COMMAND = 'goki-dev-mcp'
|
|
7
|
+
|
|
8
|
+
function buildServerEntry () {
|
|
9
|
+
return {
|
|
10
|
+
command: MCP_COMMAND,
|
|
11
|
+
env: {
|
|
12
|
+
DEV_TOOLS_URL: 'http://localhost:9000'
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function isEntryUpToDate (current, expected) {
|
|
18
|
+
if (!current) return false
|
|
19
|
+
if (current.command !== expected.command) return false
|
|
20
|
+
if (current.env?.DEV_TOOLS_URL !== expected.env.DEV_TOOLS_URL) return false
|
|
21
|
+
return true
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Ensures the target project's .mcp.json includes the goki-dev-tools MCP server.
|
|
26
|
+
* Creates the file if missing, or merges/fixes the entry if it exists.
|
|
27
|
+
* Uses the globally-linked `goki-dev-mcp` command (no absolute paths).
|
|
28
|
+
* Also ensures .dev-tools/ and .mcp.json are in .gitignore.
|
|
29
|
+
*
|
|
30
|
+
* Returns { created, updated, skipped, path }
|
|
31
|
+
*/
|
|
32
|
+
export function ensureMcpConfig () {
|
|
33
|
+
const projectDir = process.cwd()
|
|
34
|
+
const mcpPath = path.join(projectDir, '.mcp.json')
|
|
35
|
+
const serverEntry = buildServerEntry()
|
|
36
|
+
let existing = {}
|
|
37
|
+
let fileExists = false
|
|
38
|
+
if (fs.existsSync(mcpPath)) {
|
|
39
|
+
fileExists = true
|
|
40
|
+
try {
|
|
41
|
+
existing = JSON.parse(fs.readFileSync(mcpPath, 'utf-8'))
|
|
42
|
+
} catch {
|
|
43
|
+
existing = {}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
if (!existing.mcpServers) {
|
|
47
|
+
existing.mcpServers = {}
|
|
48
|
+
}
|
|
49
|
+
const current = existing.mcpServers[MCP_SERVER_NAME]
|
|
50
|
+
if (isEntryUpToDate(current, serverEntry)) {
|
|
51
|
+
ensureGitignore(projectDir)
|
|
52
|
+
return { created: false, updated: false, skipped: true, path: mcpPath }
|
|
53
|
+
}
|
|
54
|
+
const wasUpdate = !!current
|
|
55
|
+
existing.mcpServers[MCP_SERVER_NAME] = serverEntry
|
|
56
|
+
fs.writeFileSync(mcpPath, JSON.stringify(existing, null, 2) + '\n')
|
|
57
|
+
ensureGitignore(projectDir)
|
|
58
|
+
return {
|
|
59
|
+
created: !fileExists,
|
|
60
|
+
updated: wasUpdate,
|
|
61
|
+
skipped: false,
|
|
62
|
+
path: mcpPath
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const MCP_ALLOW_PATTERN = `mcp__${MCP_SERVER_NAME}__*`
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Checks if Claude Code permissions already allow all dev-tools MCP tools.
|
|
70
|
+
*/
|
|
71
|
+
export function checkClaudePermissions () {
|
|
72
|
+
const projectDir = process.cwd()
|
|
73
|
+
const settingsPath = path.join(projectDir, '.claude', 'settings.local.json')
|
|
74
|
+
if (!fs.existsSync(settingsPath)) {
|
|
75
|
+
return { allowed: false, path: settingsPath }
|
|
76
|
+
}
|
|
77
|
+
try {
|
|
78
|
+
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'))
|
|
79
|
+
const allowList = settings?.permissions?.allow || []
|
|
80
|
+
const allowed = allowList.includes(MCP_ALLOW_PATTERN)
|
|
81
|
+
return { allowed, path: settingsPath }
|
|
82
|
+
} catch {
|
|
83
|
+
return { allowed: false, path: settingsPath }
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Adds the MCP wildcard allow pattern to .claude/settings.local.json.
|
|
89
|
+
*/
|
|
90
|
+
export function addClaudePermissions () {
|
|
91
|
+
const projectDir = process.cwd()
|
|
92
|
+
const claudeDir = path.join(projectDir, '.claude')
|
|
93
|
+
const settingsPath = path.join(claudeDir, 'settings.local.json')
|
|
94
|
+
if (!fs.existsSync(claudeDir)) {
|
|
95
|
+
fs.mkdirSync(claudeDir, { recursive: true })
|
|
96
|
+
}
|
|
97
|
+
let settings = {}
|
|
98
|
+
let fileExists = false
|
|
99
|
+
if (fs.existsSync(settingsPath)) {
|
|
100
|
+
fileExists = true
|
|
101
|
+
try {
|
|
102
|
+
settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'))
|
|
103
|
+
} catch {
|
|
104
|
+
settings = {}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (!settings.permissions) {
|
|
108
|
+
settings.permissions = {}
|
|
109
|
+
}
|
|
110
|
+
if (!Array.isArray(settings.permissions.allow)) {
|
|
111
|
+
settings.permissions.allow = []
|
|
112
|
+
}
|
|
113
|
+
if (settings.permissions.allow.includes(MCP_ALLOW_PATTERN)) {
|
|
114
|
+
return { created: false, updated: false, path: settingsPath }
|
|
115
|
+
}
|
|
116
|
+
settings.permissions.allow.push(MCP_ALLOW_PATTERN)
|
|
117
|
+
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n')
|
|
118
|
+
return {
|
|
119
|
+
created: !fileExists,
|
|
120
|
+
updated: fileExists,
|
|
121
|
+
path: settingsPath
|
|
122
|
+
}
|
|
123
|
+
}
|
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
import { execa } from 'execa'
|
|
2
|
+
import fs from 'fs'
|
|
3
|
+
import path from 'path'
|
|
4
|
+
import { fileURLToPath } from 'url'
|
|
5
|
+
import { Logger } from './Logger.js'
|
|
6
|
+
|
|
7
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
8
|
+
const DEV_TOOLS_API = 'http://localhost:9000'
|
|
9
|
+
const NGROK_API = 'http://localhost:4040'
|
|
10
|
+
const DATA_DIR = path.resolve(__dirname, '..', 'data')
|
|
11
|
+
const PID_FILE = path.join(DATA_DIR, 'ngrok.pid')
|
|
12
|
+
const STATE_FILE = path.join(DATA_DIR, 'ngrok.json')
|
|
13
|
+
|
|
14
|
+
export class NgrokManager {
|
|
15
|
+
// --- PID File Management ---
|
|
16
|
+
|
|
17
|
+
static ensureDataDir () {
|
|
18
|
+
if (!fs.existsSync(DATA_DIR)) {
|
|
19
|
+
fs.mkdirSync(DATA_DIR, { recursive: true })
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
static writePid (pid) {
|
|
24
|
+
this.ensureDataDir()
|
|
25
|
+
fs.writeFileSync(PID_FILE, String(pid), 'utf8')
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
static readPid () {
|
|
29
|
+
try {
|
|
30
|
+
const content = fs.readFileSync(PID_FILE, 'utf8').trim()
|
|
31
|
+
const pid = parseInt(content, 10)
|
|
32
|
+
return isNaN(pid) ? null : pid
|
|
33
|
+
} catch {
|
|
34
|
+
return null
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
static writeState ({ pid, domain, port, startedAt, startedBy }) {
|
|
39
|
+
this.ensureDataDir()
|
|
40
|
+
fs.writeFileSync(STATE_FILE, JSON.stringify({ pid, domain, port, startedAt, startedBy }, null, 2), 'utf8')
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
static readState () {
|
|
44
|
+
try {
|
|
45
|
+
const content = fs.readFileSync(STATE_FILE, 'utf8')
|
|
46
|
+
return JSON.parse(content)
|
|
47
|
+
} catch {
|
|
48
|
+
return null
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
static cleanStateFiles () {
|
|
53
|
+
try { fs.unlinkSync(PID_FILE) } catch { /* ignore */ }
|
|
54
|
+
try { fs.unlinkSync(STATE_FILE) } catch { /* ignore */ }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// --- Process Validation ---
|
|
58
|
+
|
|
59
|
+
static isProcessAlive (pid) {
|
|
60
|
+
try {
|
|
61
|
+
process.kill(pid, 0)
|
|
62
|
+
return true
|
|
63
|
+
} catch {
|
|
64
|
+
return false
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
static async isInstalled () {
|
|
69
|
+
try {
|
|
70
|
+
await execa('ngrok', ['version'], { stdio: 'pipe' })
|
|
71
|
+
return true
|
|
72
|
+
} catch {
|
|
73
|
+
return false
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
static async isRunning () {
|
|
78
|
+
try {
|
|
79
|
+
const response = await fetch(`${NGROK_API}/api/tunnels`, {
|
|
80
|
+
signal: AbortSignal.timeout(2000)
|
|
81
|
+
})
|
|
82
|
+
return response.ok
|
|
83
|
+
} catch {
|
|
84
|
+
return false
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
static async getTunnelUrl () {
|
|
89
|
+
try {
|
|
90
|
+
const response = await fetch(`${NGROK_API}/api/tunnels`, {
|
|
91
|
+
signal: AbortSignal.timeout(2000)
|
|
92
|
+
})
|
|
93
|
+
if (!response.ok) return null
|
|
94
|
+
const data = await response.json()
|
|
95
|
+
const tunnel = data.tunnels?.[0]
|
|
96
|
+
return tunnel?.public_url || null
|
|
97
|
+
} catch {
|
|
98
|
+
return null
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
static async getTunnelInfo () {
|
|
103
|
+
try {
|
|
104
|
+
const response = await fetch(`${NGROK_API}/api/tunnels`, {
|
|
105
|
+
signal: AbortSignal.timeout(2000)
|
|
106
|
+
})
|
|
107
|
+
if (!response.ok) return null
|
|
108
|
+
const data = await response.json()
|
|
109
|
+
const tunnel = data.tunnels?.[0]
|
|
110
|
+
if (!tunnel) return null
|
|
111
|
+
return {
|
|
112
|
+
url: tunnel.public_url,
|
|
113
|
+
addr: tunnel.config?.addr,
|
|
114
|
+
proto: tunnel.proto
|
|
115
|
+
}
|
|
116
|
+
} catch {
|
|
117
|
+
return null
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
static async verifyTunnel (expectedDomain, expectedPort) {
|
|
122
|
+
const info = await this.getTunnelInfo()
|
|
123
|
+
if (!info) return { valid: false, reason: 'No tunnel found' }
|
|
124
|
+
if (expectedDomain && !info.url?.includes(expectedDomain)) {
|
|
125
|
+
return { valid: false, url: info.url, reason: `Domain mismatch: running ${info.url}, expected ${expectedDomain}` }
|
|
126
|
+
}
|
|
127
|
+
if (expectedPort && info.addr && !info.addr.includes(String(expectedPort))) {
|
|
128
|
+
return { valid: false, url: info.url, reason: `Port mismatch: forwarding to ${info.addr}, expected ${expectedPort}` }
|
|
129
|
+
}
|
|
130
|
+
return { valid: true, url: info.url }
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// --- Lifecycle ---
|
|
134
|
+
|
|
135
|
+
static async start (domain, port = 9000, projectName = null) {
|
|
136
|
+
// Check if ngrok is already running via its API
|
|
137
|
+
if (await this.isRunning()) {
|
|
138
|
+
const verification = await this.verifyTunnel(domain, port)
|
|
139
|
+
if (verification.valid) {
|
|
140
|
+
return { alreadyRunning: true, url: verification.url, pid: this.readPid() }
|
|
141
|
+
}
|
|
142
|
+
// Tunnel exists but domain/port mismatch ā restart
|
|
143
|
+
Logger.info(`Restarting ngrok ā ${verification.reason}`)
|
|
144
|
+
await this.stop()
|
|
145
|
+
}
|
|
146
|
+
// Clean up stale PID file if process is dead
|
|
147
|
+
const existingPid = this.readPid()
|
|
148
|
+
if (existingPid) {
|
|
149
|
+
if (this.isProcessAlive(existingPid)) {
|
|
150
|
+
// Process alive but API not responding ā kill it
|
|
151
|
+
Logger.info('Cleaning up unresponsive ngrok process')
|
|
152
|
+
try { process.kill(existingPid, 'SIGKILL') } catch { /* ignore */ }
|
|
153
|
+
await new Promise(resolve => setTimeout(resolve, 500))
|
|
154
|
+
}
|
|
155
|
+
this.cleanStateFiles()
|
|
156
|
+
}
|
|
157
|
+
// Build ngrok args
|
|
158
|
+
const ngrokArgs = ['http', '--log', 'stderr']
|
|
159
|
+
if (domain) {
|
|
160
|
+
ngrokArgs.push('--url', domain)
|
|
161
|
+
}
|
|
162
|
+
ngrokArgs.push(String(port))
|
|
163
|
+
// Start ngrok with nohup to survive terminal close
|
|
164
|
+
// Note: Both stdout and stderr go to /dev/null to prevent blocking the CLI process
|
|
165
|
+
// Verification happens via ngrok's API and health check requests
|
|
166
|
+
const child = execa('nohup', ['ngrok', ...ngrokArgs], {
|
|
167
|
+
stdio: ['ignore', fs.openSync('/dev/null', 'w'), fs.openSync('/dev/null', 'w')],
|
|
168
|
+
detached: true,
|
|
169
|
+
cleanup: false
|
|
170
|
+
})
|
|
171
|
+
child.unref()
|
|
172
|
+
const pid = child.pid
|
|
173
|
+
// Write PID and state files
|
|
174
|
+
this.writePid(pid)
|
|
175
|
+
this.writeState({
|
|
176
|
+
pid,
|
|
177
|
+
domain,
|
|
178
|
+
port,
|
|
179
|
+
startedAt: new Date().toISOString(),
|
|
180
|
+
startedBy: projectName
|
|
181
|
+
})
|
|
182
|
+
// Wait for tunnel to be ready and verify it works
|
|
183
|
+
try {
|
|
184
|
+
const url = await this.waitForReady()
|
|
185
|
+
return { alreadyRunning: false, url, pid }
|
|
186
|
+
} catch (error) {
|
|
187
|
+
// Tunnel failed to start - clean up and provide helpful error
|
|
188
|
+
this.cleanStateFiles()
|
|
189
|
+
if (this.isProcessAlive(pid)) {
|
|
190
|
+
// Process is alive but tunnel didn't come up - likely auth/config issue
|
|
191
|
+
try { process.kill(pid, 'SIGKILL') } catch { /* ignore */ }
|
|
192
|
+
throw new Error('ngrok process started but tunnel failed to initialize. Check your ngrok auth token and domain configuration.')
|
|
193
|
+
} else {
|
|
194
|
+
// Process died - likely ngrok not found or crashed
|
|
195
|
+
throw new Error('ngrok process failed to start. Verify ngrok is installed and accessible.')
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
static async waitForReady (timeout = 15000) {
|
|
201
|
+
const startTime = Date.now()
|
|
202
|
+
while (Date.now() - startTime < timeout) {
|
|
203
|
+
const url = await this.getTunnelUrl()
|
|
204
|
+
if (url) {
|
|
205
|
+
// Verify tunnel actually works by making a test request
|
|
206
|
+
const tunnelWorks = await this.verifyTunnelWorks(url)
|
|
207
|
+
if (tunnelWorks) {
|
|
208
|
+
return url
|
|
209
|
+
}
|
|
210
|
+
// URL exists but tunnel doesn't work yet - keep waiting
|
|
211
|
+
}
|
|
212
|
+
await new Promise(resolve => setTimeout(resolve, 500))
|
|
213
|
+
}
|
|
214
|
+
throw new Error('ngrok did not become ready within timeout')
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
static async verifyTunnelWorks (tunnelUrl) {
|
|
218
|
+
try {
|
|
219
|
+
// Make a health check request through the tunnel
|
|
220
|
+
const response = await fetch(`${tunnelUrl}/health`, {
|
|
221
|
+
method: 'GET',
|
|
222
|
+
signal: AbortSignal.timeout(3000),
|
|
223
|
+
headers: {
|
|
224
|
+
'User-Agent': 'goki-dev-cli-verification'
|
|
225
|
+
}
|
|
226
|
+
})
|
|
227
|
+
// Any response (even 404) means tunnel is working
|
|
228
|
+
// We just need to verify the request reaches the backend
|
|
229
|
+
return response.status !== undefined
|
|
230
|
+
} catch (error) {
|
|
231
|
+
// Network errors mean tunnel isn't working yet
|
|
232
|
+
return false
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
static async stop () {
|
|
237
|
+
const pid = this.readPid()
|
|
238
|
+
if (pid && this.isProcessAlive(pid)) {
|
|
239
|
+
// Graceful shutdown
|
|
240
|
+
try { process.kill(pid, 'SIGTERM') } catch { /* ignore */ }
|
|
241
|
+
// Wait up to 3 seconds for process to exit
|
|
242
|
+
const deadline = Date.now() + 3000
|
|
243
|
+
while (Date.now() < deadline && this.isProcessAlive(pid)) {
|
|
244
|
+
await new Promise(resolve => setTimeout(resolve, 200))
|
|
245
|
+
}
|
|
246
|
+
// Force kill if still alive
|
|
247
|
+
if (this.isProcessAlive(pid)) {
|
|
248
|
+
try { process.kill(pid, 'SIGKILL') } catch { /* ignore */ }
|
|
249
|
+
}
|
|
250
|
+
this.cleanStateFiles()
|
|
251
|
+
return { stopped: true, pid }
|
|
252
|
+
}
|
|
253
|
+
this.cleanStateFiles()
|
|
254
|
+
// No PID file but ngrok might still be running ā try targeted pgrep
|
|
255
|
+
if (await this.isRunning()) {
|
|
256
|
+
try {
|
|
257
|
+
const { stdout } = await execa('pgrep', ['-f', 'ngrok http'], { stdio: 'pipe' })
|
|
258
|
+
const pids = stdout.trim().split('\n').filter(Boolean)
|
|
259
|
+
for (const p of pids) {
|
|
260
|
+
try { process.kill(parseInt(p, 10), 'SIGTERM') } catch { /* ignore */ }
|
|
261
|
+
}
|
|
262
|
+
return { stopped: true, pid: pids[0] ? parseInt(pids[0], 10) : null }
|
|
263
|
+
} catch {
|
|
264
|
+
// pgrep found nothing
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
return { stopped: false, pid: null }
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// --- Status ---
|
|
271
|
+
|
|
272
|
+
static async getStatus () {
|
|
273
|
+
const state = this.readState()
|
|
274
|
+
const pid = this.readPid()
|
|
275
|
+
const apiRunning = await this.isRunning()
|
|
276
|
+
const processAlive = pid ? this.isProcessAlive(pid) : false
|
|
277
|
+
const running = apiRunning && (processAlive || !pid)
|
|
278
|
+
if (!running) {
|
|
279
|
+
// Clean up stale files if process is dead
|
|
280
|
+
if (pid && !processAlive) this.cleanStateFiles()
|
|
281
|
+
return { running: false, pid: null, url: null, domain: null, port: null, uptime: null, startedBy: null }
|
|
282
|
+
}
|
|
283
|
+
const url = await this.getTunnelUrl()
|
|
284
|
+
let uptime = null
|
|
285
|
+
if (state?.startedAt) {
|
|
286
|
+
const ms = Date.now() - new Date(state.startedAt).getTime()
|
|
287
|
+
const hours = Math.floor(ms / 3600000)
|
|
288
|
+
const minutes = Math.floor((ms % 3600000) / 60000)
|
|
289
|
+
uptime = hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`
|
|
290
|
+
}
|
|
291
|
+
return {
|
|
292
|
+
running: true,
|
|
293
|
+
pid: pid || null,
|
|
294
|
+
url,
|
|
295
|
+
domain: state?.domain || null,
|
|
296
|
+
port: state?.port || null,
|
|
297
|
+
uptime,
|
|
298
|
+
startedBy: state?.startedBy || null
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// --- Domain Management ---
|
|
303
|
+
|
|
304
|
+
static async loadDomain () {
|
|
305
|
+
try {
|
|
306
|
+
const response = await fetch(`${DEV_TOOLS_API}/v1/webhooks/settings/get`, {
|
|
307
|
+
method: 'POST',
|
|
308
|
+
headers: { 'Content-Type': 'application/json' },
|
|
309
|
+
body: JSON.stringify({}),
|
|
310
|
+
signal: AbortSignal.timeout(3000)
|
|
311
|
+
})
|
|
312
|
+
if (response.ok) {
|
|
313
|
+
const result = await response.json()
|
|
314
|
+
const domain = result.data?.settings?.ngrok_domain
|
|
315
|
+
if (domain) return domain
|
|
316
|
+
}
|
|
317
|
+
} catch {
|
|
318
|
+
// API failed, fall through to local file fallback
|
|
319
|
+
}
|
|
320
|
+
// Fallback: read from local state file
|
|
321
|
+
const state = this.readState()
|
|
322
|
+
return state?.domain || null
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
static async saveDomain (domain) {
|
|
326
|
+
try {
|
|
327
|
+
await fetch(`${DEV_TOOLS_API}/v1/webhooks/settings/update`, {
|
|
328
|
+
method: 'POST',
|
|
329
|
+
headers: { 'Content-Type': 'application/json' },
|
|
330
|
+
body: JSON.stringify({
|
|
331
|
+
settings: { ngrok_domain: domain }
|
|
332
|
+
}),
|
|
333
|
+
signal: AbortSignal.timeout(3000)
|
|
334
|
+
})
|
|
335
|
+
} catch (error) {
|
|
336
|
+
Logger.warn(`Failed to save ngrok domain: ${error.message}`)
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
static async promptForDomain (defaultDomain = null) {
|
|
341
|
+
const inquirer = await import('inquirer')
|
|
342
|
+
console.log('\nš Webhook tunneling requires an ngrok static domain.')
|
|
343
|
+
console.log(' Get your free domain at: https://dashboard.ngrok.com/domains\n')
|
|
344
|
+
const promptConfig = {
|
|
345
|
+
type: 'input',
|
|
346
|
+
name: 'domain',
|
|
347
|
+
message: defaultDomain
|
|
348
|
+
? 'Confirm or update your ngrok static domain:'
|
|
349
|
+
: 'Enter your ngrok static domain (e.g., your-name.ngrok-free.app):',
|
|
350
|
+
validate: (input) => {
|
|
351
|
+
if (!input || !input.includes('.')) {
|
|
352
|
+
return 'Please enter a valid domain (e.g., your-name.ngrok-free.app)'
|
|
353
|
+
}
|
|
354
|
+
return true
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
if (defaultDomain) {
|
|
358
|
+
promptConfig.default = defaultDomain
|
|
359
|
+
}
|
|
360
|
+
const { domain } = await inquirer.default.prompt([promptConfig])
|
|
361
|
+
return domain.trim()
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// --- Webhook Routes ---
|
|
365
|
+
|
|
366
|
+
static async registerWebhookRoutes (webhooks) {
|
|
367
|
+
const registered = []
|
|
368
|
+
for (const [prefix, config] of Object.entries(webhooks)) {
|
|
369
|
+
try {
|
|
370
|
+
await fetch(`${DEV_TOOLS_API}/v1/webhooks/register`, {
|
|
371
|
+
method: 'POST',
|
|
372
|
+
headers: { 'Content-Type': 'application/json' },
|
|
373
|
+
body: JSON.stringify({
|
|
374
|
+
prefix,
|
|
375
|
+
target: config.target,
|
|
376
|
+
pathRewrite: config.pathRewrite,
|
|
377
|
+
description: config.description || `${prefix} webhooks`
|
|
378
|
+
}),
|
|
379
|
+
signal: AbortSignal.timeout(3000)
|
|
380
|
+
})
|
|
381
|
+
registered.push(prefix)
|
|
382
|
+
} catch (error) {
|
|
383
|
+
Logger.warn(`Failed to register webhook route '${prefix}': ${error.message}`)
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
return registered
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// --- Orchestration ---
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Full webhook setup: register routes, ensure ngrok domain, start tunnel
|
|
393
|
+
* @param {Object} webhooks - Webhook configuration from cli.config.js
|
|
394
|
+
* @param {Object} spinner - ora spinner instance
|
|
395
|
+
* @param {string} projectName - Name of the project starting the tunnel
|
|
396
|
+
* @returns {Object} { tunnelUrl, domain, routes, pid }
|
|
397
|
+
*/
|
|
398
|
+
static async setup (webhooks, spinner, projectName = null) {
|
|
399
|
+
// Step 1: Register webhook routes
|
|
400
|
+
if (spinner) spinner.text = 'Registering webhook routes...'
|
|
401
|
+
const registeredRoutes = await this.registerWebhookRoutes(webhooks)
|
|
402
|
+
// Step 2: Check for ngrok
|
|
403
|
+
const installed = await this.isInstalled()
|
|
404
|
+
if (!installed) {
|
|
405
|
+
if (spinner) spinner.warn('ngrok not installed ā webhook routes registered but no tunnel started')
|
|
406
|
+
console.log(' Install ngrok: https://ngrok.com/download')
|
|
407
|
+
console.log(' Then run "goki-dev start" again to auto-start the tunnel\n')
|
|
408
|
+
return { tunnelUrl: null, domain: null, routes: registeredRoutes, pid: null }
|
|
409
|
+
}
|
|
410
|
+
// Step 3: Get or prompt for domain
|
|
411
|
+
let domain = await this.loadDomain()
|
|
412
|
+
if (!domain) {
|
|
413
|
+
if (spinner) spinner.stop()
|
|
414
|
+
domain = await this.promptForDomain()
|
|
415
|
+
await this.saveDomain(domain)
|
|
416
|
+
if (spinner) spinner.start()
|
|
417
|
+
}
|
|
418
|
+
// Step 4: Start ngrok tunnel
|
|
419
|
+
if (spinner) spinner.text = `Starting ngrok tunnel (${domain})...`
|
|
420
|
+
try {
|
|
421
|
+
const { url, alreadyRunning, pid } = await this.start(domain, 9000, projectName)
|
|
422
|
+
if (alreadyRunning) {
|
|
423
|
+
if (spinner) spinner.text = `ngrok tunnel already running (PID: ${pid || 'unknown'})`
|
|
424
|
+
}
|
|
425
|
+
return { tunnelUrl: url, domain, routes: registeredRoutes, pid }
|
|
426
|
+
} catch (error) {
|
|
427
|
+
if (spinner) spinner.warn(`Failed to start ngrok: ${error.message}`)
|
|
428
|
+
return { tunnelUrl: null, domain, routes: registeredRoutes, pid: null }
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
}
|