@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.
Files changed (205) hide show
  1. package/README.md +478 -0
  2. package/bin/goki-dev.js +452 -0
  3. package/bin/mcp-server.js +16 -0
  4. package/bin/secrets-cli.js +302 -0
  5. package/cli/ComposeOverrideGenerator.js +226 -0
  6. package/cli/ComposeParser.js +73 -0
  7. package/cli/ConfigGenerator.js +304 -0
  8. package/cli/ConfigManager.js +46 -0
  9. package/cli/DatabaseManager.js +94 -0
  10. package/cli/DevToolsChecker.js +21 -0
  11. package/cli/DevToolsDir.js +66 -0
  12. package/cli/DevToolsManager.js +451 -0
  13. package/cli/DockerManager.js +138 -0
  14. package/cli/FunctionManager.js +95 -0
  15. package/cli/HttpProxyRewriter.js +91 -0
  16. package/cli/Logger.js +10 -0
  17. package/cli/McpConfigManager.js +123 -0
  18. package/cli/NgrokManager.js +431 -0
  19. package/cli/ProjectCLI.js +2322 -0
  20. package/cli/PubSubManager.js +129 -0
  21. package/cli/SnapshotManager.js +88 -0
  22. package/cli/UiFormatter.js +292 -0
  23. package/cli/WebhookUrlRewriter.js +32 -0
  24. package/cli/secrets/BiometricAuth.js +125 -0
  25. package/cli/secrets/SecretInjector.js +47 -0
  26. package/cli/secrets/SecretsConfig.js +141 -0
  27. package/cli/secrets/SecretsDoctor.js +384 -0
  28. package/cli/secrets/SecretsManager.js +255 -0
  29. package/client/dist/client.d.ts +332 -0
  30. package/client/dist/client.js +507 -0
  31. package/client/dist/helpers.d.ts +62 -0
  32. package/client/dist/helpers.js +122 -0
  33. package/client/dist/index.d.ts +59 -0
  34. package/client/dist/index.js +78 -0
  35. package/client/dist/package.json +1 -0
  36. package/client/dist/types.d.ts +280 -0
  37. package/client/dist/types.js +7 -0
  38. package/config.development +46 -0
  39. package/config.test +18 -0
  40. package/guidelines/CodingStyleGuideline.md +148 -0
  41. package/guidelines/CommentingGuideline.md +10 -0
  42. package/guidelines/HttpApiImplementationGuideline.md +137 -0
  43. package/guidelines/NamingGuideline.md +182 -0
  44. package/package.json +138 -0
  45. package/patterns/api/[collectionName]/Controllers.md +62 -0
  46. package/patterns/api/[collectionName]/Logic.md +154 -0
  47. package/patterns/api/[collectionName]/Permissions.md +81 -0
  48. package/patterns/api/[collectionName]/Router.md +83 -0
  49. package/patterns/api/[collectionName]/Schemas.md +197 -0
  50. package/patterns/configs/Patterns.md +7 -0
  51. package/patterns/enums/Patterns.md +24 -0
  52. package/patterns/errorHandling/Patterns.md +185 -0
  53. package/patterns/testing/Patterns.md +232 -0
  54. package/src/Server.js +238 -0
  55. package/src/api/dashboard/Controllers.js +9 -0
  56. package/src/api/dashboard/Logic.js +76 -0
  57. package/src/api/dashboard/Router.js +11 -0
  58. package/src/api/dashboard/Schemas.js +47 -0
  59. package/src/api/data/Controllers.js +26 -0
  60. package/src/api/data/Logic.js +188 -0
  61. package/src/api/data/Router.js +16 -0
  62. package/src/api/docker/Controllers.js +33 -0
  63. package/src/api/docker/Logic.js +268 -0
  64. package/src/api/docker/Router.js +15 -0
  65. package/src/api/docker/Schemas.js +80 -0
  66. package/src/api/docs/Controllers.js +15 -0
  67. package/src/api/docs/Logic.js +85 -0
  68. package/src/api/docs/Router.js +12 -0
  69. package/src/api/export/Controllers.js +30 -0
  70. package/src/api/export/Logic.js +143 -0
  71. package/src/api/export/Router.js +18 -0
  72. package/src/api/export/Schemas.js +104 -0
  73. package/src/api/firestore/Controllers.js +152 -0
  74. package/src/api/firestore/Logic.js +474 -0
  75. package/src/api/firestore/Router.js +23 -0
  76. package/src/api/functions/Controllers.js +261 -0
  77. package/src/api/functions/Logic.js +710 -0
  78. package/src/api/functions/Router.js +50 -0
  79. package/src/api/functions/Schemas.js +193 -0
  80. package/src/api/gateway/Controllers.js +72 -0
  81. package/src/api/gateway/Logic.js +74 -0
  82. package/src/api/gateway/Router.js +10 -0
  83. package/src/api/gateway/Schemas.js +19 -0
  84. package/src/api/health/Controllers.js +14 -0
  85. package/src/api/health/Logic.js +24 -0
  86. package/src/api/health/Router.js +12 -0
  87. package/src/api/httpTraffic/Controllers.js +29 -0
  88. package/src/api/httpTraffic/Logic.js +33 -0
  89. package/src/api/httpTraffic/Router.js +9 -0
  90. package/src/api/httpTraffic/Schemas.js +23 -0
  91. package/src/api/logging/Controllers.js +80 -0
  92. package/src/api/logging/Logic.js +461 -0
  93. package/src/api/logging/Router.js +24 -0
  94. package/src/api/logging/Schemas.js +43 -0
  95. package/src/api/mqtt/Controllers.js +17 -0
  96. package/src/api/mqtt/Logic.js +66 -0
  97. package/src/api/mqtt/Router.js +12 -0
  98. package/src/api/postgres/Controllers.js +97 -0
  99. package/src/api/postgres/Logic.js +221 -0
  100. package/src/api/postgres/Router.js +21 -0
  101. package/src/api/pubsub/Controllers.js +236 -0
  102. package/src/api/pubsub/Logic.js +732 -0
  103. package/src/api/pubsub/Router.js +41 -0
  104. package/src/api/pubsub/Schemas.js +355 -0
  105. package/src/api/redis/Controllers.js +63 -0
  106. package/src/api/redis/Logic.js +239 -0
  107. package/src/api/redis/Router.js +21 -0
  108. package/src/api/scheduler/Controllers.js +27 -0
  109. package/src/api/scheduler/Logic.js +49 -0
  110. package/src/api/scheduler/Router.js +16 -0
  111. package/src/api/services/Controllers.js +26 -0
  112. package/src/api/services/Logic.js +205 -0
  113. package/src/api/services/Router.js +14 -0
  114. package/src/api/services/Schemas.js +66 -0
  115. package/src/api/snapshots/Controllers.js +37 -0
  116. package/src/api/snapshots/Logic.js +797 -0
  117. package/src/api/snapshots/Router.js +15 -0
  118. package/src/api/snapshots/Schemas.js +23 -0
  119. package/src/api/webhooks/Controllers.js +49 -0
  120. package/src/api/webhooks/Logic.js +137 -0
  121. package/src/api/webhooks/Router.js +12 -0
  122. package/src/api/webhooks/Schemas.js +31 -0
  123. package/src/configs/Application.js +147 -0
  124. package/src/configs/Default.js +13 -0
  125. package/src/consumers/BlackboxLogsConsumer.js +235 -0
  126. package/src/consumers/DockerLogsConsumer.js +687 -0
  127. package/src/db/Tables.js +66 -0
  128. package/src/db/schemas/firestore.js +18 -0
  129. package/src/db/schemas/functions.js +65 -0
  130. package/src/db/schemas/httpTraffic.js +43 -0
  131. package/src/db/schemas/logging.js +74 -0
  132. package/src/db/schemas/migrations.js +64 -0
  133. package/src/db/schemas/mqtt.js +56 -0
  134. package/src/db/schemas/pubsub.js +90 -0
  135. package/src/db/schemas/pubsubRegistry.js +22 -0
  136. package/src/db/schemas/webhooks.js +28 -0
  137. package/src/emulation/awsiot/Controllers.js +91 -0
  138. package/src/emulation/awsiot/Logic.js +70 -0
  139. package/src/emulation/awsiot/Router.js +19 -0
  140. package/src/emulation/awsiot/Server.js +100 -0
  141. package/src/emulation/firestore/Server.js +136 -0
  142. package/src/emulation/logging/Controllers.js +212 -0
  143. package/src/emulation/logging/Logic.js +416 -0
  144. package/src/emulation/logging/Router.js +36 -0
  145. package/src/emulation/logging/Schemas.js +82 -0
  146. package/src/emulation/logging/Server.js +108 -0
  147. package/src/emulation/pubsub/Controllers.js +279 -0
  148. package/src/emulation/pubsub/DefaultTopics.js +162 -0
  149. package/src/emulation/pubsub/Logic.js +427 -0
  150. package/src/emulation/pubsub/README.md +309 -0
  151. package/src/emulation/pubsub/Router.js +33 -0
  152. package/src/emulation/pubsub/Server.js +104 -0
  153. package/src/emulation/pubsub/ShadowPoller.js +276 -0
  154. package/src/emulation/pubsub/ShadowSubscriptionManager.js +199 -0
  155. package/src/enums/ContainerNames.js +106 -0
  156. package/src/enums/ErrorReason.js +28 -0
  157. package/src/enums/FunctionStatuses.js +15 -0
  158. package/src/enums/FunctionTriggerTypes.js +15 -0
  159. package/src/enums/GatewayState.js +7 -0
  160. package/src/enums/ServiceNames.js +68 -0
  161. package/src/jobs/DatabaseMaintenance.js +184 -0
  162. package/src/jobs/MessageHistoryCleanup.js +152 -0
  163. package/src/mcp/ApiClient.js +25 -0
  164. package/src/mcp/Server.js +52 -0
  165. package/src/mcp/prompts/debugging.js +104 -0
  166. package/src/mcp/resources/platform.js +118 -0
  167. package/src/mcp/tools/data.js +84 -0
  168. package/src/mcp/tools/docker.js +166 -0
  169. package/src/mcp/tools/firestore.js +162 -0
  170. package/src/mcp/tools/functions.js +380 -0
  171. package/src/mcp/tools/httpTraffic.js +69 -0
  172. package/src/mcp/tools/logging.js +174 -0
  173. package/src/mcp/tools/mqtt.js +37 -0
  174. package/src/mcp/tools/postgres.js +130 -0
  175. package/src/mcp/tools/pubsub.js +316 -0
  176. package/src/mcp/tools/redis.js +146 -0
  177. package/src/mcp/tools/services.js +169 -0
  178. package/src/mcp/tools/snapshots.js +88 -0
  179. package/src/mcp/tools/webhooks.js +115 -0
  180. package/src/middleware/DevProxy.js +67 -0
  181. package/src/middleware/ErrorCatcher.js +35 -0
  182. package/src/middleware/HttpProxy.js +215 -0
  183. package/src/middleware/Reply.js +24 -0
  184. package/src/middleware/TraceId.js +9 -0
  185. package/src/middleware/WebhookProxy.js +234 -0
  186. package/src/protocols/mqtt/Broker.js +92 -0
  187. package/src/protocols/mqtt/Handlers.js +175 -0
  188. package/src/protocols/mqtt/PubSubBridge.js +162 -0
  189. package/src/protocols/mqtt/Server.js +116 -0
  190. package/src/runtime/FunctionRunner.js +179 -0
  191. package/src/services/AppGatewayService.js +582 -0
  192. package/src/singletons/FirestoreBroadcaster.js +367 -0
  193. package/src/singletons/FunctionTriggerDispatcher.js +456 -0
  194. package/src/singletons/FunctionsService.js +418 -0
  195. package/src/singletons/HttpProxy.js +224 -0
  196. package/src/singletons/LogBroadcaster.js +159 -0
  197. package/src/singletons/Logger.js +49 -0
  198. package/src/singletons/MemoryJsonStore.js +175 -0
  199. package/src/singletons/MessageBroadcaster.js +190 -0
  200. package/src/singletons/PostgresBroadcaster.js +367 -0
  201. package/src/singletons/PostgresClient.js +180 -0
  202. package/src/singletons/RedisClient.js +184 -0
  203. package/src/singletons/SqliteStore.js +480 -0
  204. package/src/singletons/TickService.js +151 -0
  205. 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
+ }