@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,47 @@
1
+ import { execa } from 'execa'
2
+ import chalk from 'chalk'
3
+
4
+ export class SecretInjector {
5
+ constructor (secretsManager) {
6
+ this.secretsManager = secretsManager
7
+ }
8
+
9
+ formatEnv ({ format = 'shell', keys } = {}) {
10
+ const envMap = this.secretsManager.getEnvMap()
11
+ const filtered = this._filterKeys(envMap, keys)
12
+ if (format === 'json') {
13
+ return JSON.stringify(filtered, null, 2)
14
+ }
15
+ return Object.entries(filtered)
16
+ .map(([key, value]) => `${key}=${value}`)
17
+ .join('\n')
18
+ }
19
+
20
+ async run (command, args = [], { verbose = false } = {}) {
21
+ const envMap = this.secretsManager.getEnvMap()
22
+ if (verbose) {
23
+ const keyList = Object.keys(envMap).join(', ')
24
+ console.log(chalk.green(`\u2713 ${Object.keys(envMap).length} secrets injected (${keyList})`))
25
+ }
26
+ const result = await execa(command, args, {
27
+ env: { ...process.env, ...envMap },
28
+ stdio: 'inherit',
29
+ reject: false
30
+ })
31
+ if (result.exitCode !== 0) {
32
+ process.exit(result.exitCode)
33
+ }
34
+ }
35
+
36
+ _filterKeys (envMap, keys) {
37
+ if (!keys) return envMap
38
+ const keyList = Array.isArray(keys) ? keys : keys.split(',').map(k => k.trim())
39
+ const filtered = {}
40
+ for (const key of keyList) {
41
+ if (key in envMap) {
42
+ filtered[key] = envMap[key]
43
+ }
44
+ }
45
+ return filtered
46
+ }
47
+ }
@@ -0,0 +1,141 @@
1
+ import fs from 'fs'
2
+ import path from 'path'
3
+ import chalk from 'chalk'
4
+
5
+ export class SecretsConfig {
6
+ constructor (secretsManager) {
7
+ this.secretsManager = secretsManager
8
+ }
9
+
10
+ async loadConfig (projectDir) {
11
+ const configPath = path.join(projectDir, '.dev-tools', 'config.js')
12
+ if (!fs.existsSync(configPath)) return null
13
+ try {
14
+ const mod = await import(configPath)
15
+ return mod.default?.secrets || null
16
+ } catch {
17
+ return null
18
+ }
19
+ }
20
+
21
+ async setup () {
22
+ const projectDir = this.secretsManager.projectDir
23
+ if (!projectDir) {
24
+ console.log(chalk.blue(' \u2139 no project detected \u2014 nothing to set up'))
25
+ return
26
+ }
27
+ const config = await this.loadConfig(projectDir)
28
+ if (!config) {
29
+ console.log(chalk.blue(' \u2139 no secrets defined in .dev-tools/config.js'))
30
+ return
31
+ }
32
+ const projectName = this.secretsManager.projectName
33
+ const entries = Object.entries(config)
34
+ console.log(chalk.blue(` \u2139 project: ${projectName} (from .dev-tools/config.js)`))
35
+ console.log()
36
+ console.log(` Checking ${entries.length} secrets...`)
37
+ const missing = []
38
+ for (const [key, def] of entries) {
39
+ const scope = def.scope || 'project'
40
+ const scopeLabel = scope === 'global' ? 'global' : `project`
41
+ const value = this.secretsManager.get(key)
42
+ if (value !== undefined) {
43
+ console.log(chalk.green(` \u2713 ${key} (${scopeLabel}) \u2014 already set`))
44
+ } else if (def.default !== undefined) {
45
+ console.log(chalk.green(` \u2713 ${key} (${scopeLabel}) \u2014 using default`))
46
+ await this.secretsManager.set(key, String(def.default), {
47
+ scope,
48
+ description: def.description
49
+ })
50
+ } else {
51
+ console.log(chalk.red(` \u2717 ${key} (${scopeLabel}) \u2014 missing`))
52
+ missing.push({ key, scope, description: def.description })
53
+ }
54
+ }
55
+ if (missing.length > 0) {
56
+ console.log()
57
+ const inquirer = (await import('inquirer')).default
58
+ for (const { key, scope, description } of missing) {
59
+ const { value } = await inquirer.prompt([{
60
+ type: 'password',
61
+ name: 'value',
62
+ message: `${key}:`,
63
+ mask: '\u25CF'
64
+ }])
65
+ await this.secretsManager.set(key, value, { scope, description })
66
+ const scopeLabel = scope === 'global'
67
+ ? 'global'
68
+ : `project: ${projectName}`
69
+ console.log(chalk.green(` \u2713 set ${key} (${scopeLabel})`))
70
+ }
71
+ }
72
+ console.log()
73
+ console.log(chalk.green(` \u2713 all ${entries.length} secrets configured`))
74
+ // Scan for plaintext credentials and offer to fix
75
+ await this._offerDoctorFix()
76
+ }
77
+
78
+ async _offerDoctorFix () {
79
+ const { SecretsDoctor } = await import('./SecretsDoctor.js')
80
+ const projectDir = this.secretsManager.projectDir || process.cwd()
81
+ const doctor = new SecretsDoctor(projectDir)
82
+ const findings = await doctor.scan()
83
+ if (findings.length === 0) return
84
+ console.log()
85
+ const label = findings.length === 1 ? 'plaintext credential' : 'plaintext credentials'
86
+ console.log(` ${chalk.yellow('\u26a0')} Found ${chalk.bold(findings.length)} ${label} in project files`)
87
+ const inquirer = (await import('inquirer')).default
88
+ const { fix } = await inquirer.prompt([{
89
+ type: 'confirm',
90
+ name: 'fix',
91
+ message: 'Fix plaintext credentials now?',
92
+ default: true
93
+ }])
94
+ if (!fix) {
95
+ console.log(chalk.dim(' \u2192 Run `goki-secrets doctor --fix` anytime'))
96
+ return
97
+ }
98
+ await doctor.interactiveFix(findings, this.secretsManager)
99
+ }
100
+
101
+ async check () {
102
+ const projectDir = this.secretsManager.projectDir
103
+ if (!projectDir) {
104
+ console.log(chalk.blue(' \u2139 no project detected \u2014 nothing to check'))
105
+ return true
106
+ }
107
+ const config = await this.loadConfig(projectDir)
108
+ if (!config) {
109
+ console.log(chalk.blue(' \u2139 no secrets defined in .dev-tools/config.js'))
110
+ return true
111
+ }
112
+ const projectName = this.secretsManager.projectName
113
+ const entries = Object.entries(config)
114
+ let missingCount = 0
115
+ for (const [key, def] of entries) {
116
+ const scope = def.scope || 'project'
117
+ const scopeLabel = scope === 'global'
118
+ ? 'global'
119
+ : `project: ${projectName}`
120
+ const value = this.secretsManager.get(key)
121
+ if (value !== undefined) {
122
+ console.log(chalk.green(` \u2713 ${key} (${scopeLabel})`))
123
+ } else if (def.default !== undefined) {
124
+ console.log(chalk.green(` \u2713 ${key} (${scopeLabel}) \u2014 default`))
125
+ } else if (def.required) {
126
+ console.log(chalk.red(` \u2717 ${key} (${scopeLabel}) \u2014 missing`))
127
+ missingCount++
128
+ } else {
129
+ console.log(chalk.gray(` - ${key} (${scopeLabel}) \u2014 not set (optional)`))
130
+ }
131
+ }
132
+ console.log()
133
+ if (missingCount > 0) {
134
+ const label = missingCount === 1 ? 'required secret' : 'required secrets'
135
+ console.log(chalk.red(` \u2717 ${missingCount} ${label} missing. Run: goki-secrets setup`))
136
+ return false
137
+ }
138
+ console.log(chalk.green(' \u2713 all secrets configured'))
139
+ return true
140
+ }
141
+ }
@@ -0,0 +1,384 @@
1
+ import fs from 'fs'
2
+ import path from 'path'
3
+ import os from 'os'
4
+ import chalk from 'chalk'
5
+
6
+ const SENSITIVE_PATTERNS = /^(.*_)?(PASSWORD|SECRET|API_KEY|AUTH_TOKEN|PRIVATE_KEY|NPM_TOKEN|ACCESS_TOKEN|REFRESH_TOKEN|CLIENT_SECRET)$/i
7
+
8
+ const ENV_FILES = ['.env', '.env.local', '.env.development', '.env.production', '.env.test']
9
+ const CONFIG_FILES = ['config.development', 'config.production', 'config.test', 'config.staging']
10
+ const COMPOSE_FILES = [
11
+ 'docker-compose.yml',
12
+ 'docker-compose.yaml',
13
+ 'docker-compose.override.yml',
14
+ 'docker-compose.services.yml'
15
+ ]
16
+
17
+ function isPlaceholder (value) {
18
+ if (!value || value.trim() === '') return true
19
+ if (/^\$\{.+\}$/.test(value)) return true
20
+ if (/^<.+>$/.test(value)) return true
21
+ return false
22
+ }
23
+
24
+ function maskValue (value) {
25
+ if (!value) return '****'
26
+ if (value.length <= 4) return '****'
27
+ return value.substring(0, 4) + '****'
28
+ }
29
+
30
+ function resolveAbsPath (projectDir, displayPath) {
31
+ if (displayPath === '~/.npmrc') return path.join(os.homedir(), '.npmrc')
32
+ return path.join(projectDir, displayPath)
33
+ }
34
+
35
+ export class SecretsDoctor {
36
+ constructor (projectDir) {
37
+ this._projectDir = path.resolve(projectDir)
38
+ }
39
+
40
+ async scan () {
41
+ const findings = []
42
+ this._scanNpmrc(findings)
43
+ this._scanEnvFiles(findings)
44
+ this._scanConfigFiles(findings)
45
+ this._scanComposeFiles(findings)
46
+ return findings
47
+ }
48
+
49
+ report (findings) {
50
+ console.log()
51
+ console.log(' Scanning for plaintext credentials...')
52
+ console.log()
53
+ if (findings.length === 0) {
54
+ console.log(` ${chalk.green('✓')} No plaintext credentials found`)
55
+ console.log()
56
+ return
57
+ }
58
+ for (const finding of findings) {
59
+ console.log(` ${chalk.yellow('⚠')} ${chalk.white(`${finding.file}:${finding.line}`)} — ${finding.message}`)
60
+ console.log(` ${chalk.dim('→')} ${chalk.cyan(finding.fix)}`)
61
+ console.log()
62
+ }
63
+ const label = findings.length === 1 ? 'plaintext credential' : 'plaintext credentials'
64
+ console.log(` Found ${chalk.yellow(findings.length)} ${label}`)
65
+ console.log()
66
+ }
67
+
68
+ fixFinding (finding) {
69
+ const absPath = resolveAbsPath(this._projectDir, finding.file)
70
+ const content = fs.readFileSync(absPath, 'utf-8')
71
+ const lines = content.split('\n')
72
+ const lineIdx = finding.line - 1
73
+ if (lineIdx < 0 || lineIdx >= lines.length) return
74
+ const line = lines[lineIdx]
75
+ switch (finding.type) {
76
+ case 'npm_token':
77
+ lines[lineIdx] = line.replace(/_authToken=.+/, `_authToken=\${${finding.secretKey}}`)
78
+ break
79
+ case 'env_secret':
80
+ lines[lineIdx] = `# ${finding.secretKey}= # managed by goki-secrets`
81
+ break
82
+ case 'config_secret':
83
+ lines[lineIdx] = line.replace(
84
+ /([A-Z_][A-Z0-9_]*)[\s]*[=:][\s]*.+/,
85
+ `# $1= # managed by goki-secrets`
86
+ )
87
+ break
88
+ case 'compose_secret': {
89
+ const listMatch = line.match(/^(\s*-\s*)[A-Z_][A-Z0-9_]*=.+$/)
90
+ const mapMatch = line.match(/^(\s*)[A-Z_][A-Z0-9_]*\s*:\s*.+$/)
91
+ if (listMatch) {
92
+ lines[lineIdx] = `${listMatch[1]}${finding.secretKey}`
93
+ } else if (mapMatch) {
94
+ lines[lineIdx] = `${mapMatch[1]}- ${finding.secretKey}`
95
+ }
96
+ break
97
+ }
98
+ }
99
+ fs.writeFileSync(absPath, lines.join('\n'))
100
+ }
101
+
102
+ async interactiveFix (findings, secretsManager) {
103
+ const inquirer = (await import('inquirer')).default
104
+ let migratedKeys = 0
105
+ let fixedFiles = 0
106
+ let skippedKeys = 0
107
+ let skipAll = false
108
+ // Group findings by secretKey (preserving insertion order)
109
+ const byKey = new Map()
110
+ for (const f of findings) {
111
+ if (!byKey.has(f.secretKey)) byKey.set(f.secretKey, [])
112
+ byKey.get(f.secretKey).push(f)
113
+ }
114
+ for (const [secretKey, keyFindings] of byKey) {
115
+ if (skipAll) {
116
+ skippedKeys++
117
+ continue
118
+ }
119
+ console.log()
120
+ const scope = keyFindings[0].scope || 'global'
121
+ const scopeLabel = scope === 'global' ? 'global' : `project: ${secretsManager.projectName}`
122
+ if (keyFindings.length === 1) {
123
+ // Single finding — simple prompt
124
+ const f = keyFindings[0]
125
+ console.log(` ${chalk.yellow('⚠')} ${chalk.cyan(secretKey)} — ${chalk.white(`${f.file}:${f.line}`)}`)
126
+ console.log(` ${f.message}`)
127
+ console.log(` Value: ${chalk.dim(maskValue(f.value))}`)
128
+ const { action } = await inquirer.prompt([{
129
+ type: 'list',
130
+ name: 'action',
131
+ message: `Migrate ${chalk.cyan(secretKey)} to keychain and fix ${f.file}?`,
132
+ choices: [
133
+ { name: 'Yes', value: 'yes' },
134
+ { name: 'Skip', value: 'skip' },
135
+ { name: 'Skip all remaining', value: 'skip_all' }
136
+ ]
137
+ }])
138
+ if (action === 'skip_all') {
139
+ skipAll = true
140
+ skippedKeys++
141
+ console.log(` ${chalk.dim('⊘ skipped')}`)
142
+ continue
143
+ }
144
+ if (action === 'skip') {
145
+ skippedKeys++
146
+ console.log(` ${chalk.dim('⊘ skipped')}`)
147
+ continue
148
+ }
149
+ await secretsManager.set(secretKey, f.value, { scope })
150
+ this.fixFinding(f)
151
+ console.log(` ${chalk.green('✓')} saved ${chalk.cyan(secretKey)} (${scopeLabel}) — ${f.file}:${f.line} updated`)
152
+ migratedKeys++
153
+ fixedFiles++
154
+ } else {
155
+ // Multiple findings — show all locations, offer bulk fix
156
+ console.log(` ${chalk.yellow('⚠')} ${chalk.cyan(secretKey)} — found in ${chalk.bold(keyFindings.length)} files:`)
157
+ for (let i = 0; i < keyFindings.length; i++) {
158
+ const f = keyFindings[i]
159
+ console.log(` ${i + 1}. ${chalk.white(`${f.file}:${f.line}`)} — ${f.message}`)
160
+ }
161
+ console.log(` Value: ${chalk.dim(maskValue(keyFindings[0].value))}`)
162
+ const { action } = await inquirer.prompt([{
163
+ type: 'list',
164
+ name: 'action',
165
+ message: `Fix ${chalk.cyan(secretKey)}?`,
166
+ choices: [
167
+ { name: `Fix all ${keyFindings.length} usages`, value: 'fix_all' },
168
+ { name: 'Choose per file', value: 'per_file' },
169
+ { name: 'Skip this key', value: 'skip' },
170
+ { name: 'Skip all remaining', value: 'skip_all' }
171
+ ]
172
+ }])
173
+ if (action === 'skip_all') {
174
+ skipAll = true
175
+ skippedKeys++
176
+ console.log(` ${chalk.dim('⊘ skipped')}`)
177
+ continue
178
+ }
179
+ if (action === 'skip') {
180
+ skippedKeys++
181
+ console.log(` ${chalk.dim('⊘ skipped')}`)
182
+ continue
183
+ }
184
+ if (action === 'fix_all') {
185
+ await secretsManager.set(secretKey, keyFindings[0].value, { scope })
186
+ console.log(` ${chalk.green('✓')} saved ${chalk.cyan(secretKey)} (${scopeLabel})`)
187
+ for (const f of keyFindings) {
188
+ this.fixFinding(f)
189
+ console.log(` ${chalk.green('✓')} ${f.file}:${f.line} updated`)
190
+ fixedFiles++
191
+ }
192
+ migratedKeys++
193
+ continue
194
+ }
195
+ // per_file — prompt for each file individually
196
+ let savedToKeychain = false
197
+ let anyFixed = false
198
+ for (const f of keyFindings) {
199
+ const { fileAction } = await inquirer.prompt([{
200
+ type: 'list',
201
+ name: 'fileAction',
202
+ message: ` Fix ${chalk.white(`${f.file}:${f.line}`)}?`,
203
+ choices: [
204
+ { name: 'Yes', value: 'yes' },
205
+ { name: 'Skip', value: 'skip' }
206
+ ]
207
+ }])
208
+ if (fileAction === 'skip') {
209
+ console.log(` ${chalk.dim('⊘ skipped')}`)
210
+ continue
211
+ }
212
+ if (!savedToKeychain) {
213
+ await secretsManager.set(secretKey, f.value, { scope })
214
+ console.log(` ${chalk.green('✓')} saved ${chalk.cyan(secretKey)} (${scopeLabel})`)
215
+ savedToKeychain = true
216
+ }
217
+ this.fixFinding(f)
218
+ console.log(` ${chalk.green('✓')} ${f.file}:${f.line} updated`)
219
+ fixedFiles++
220
+ anyFixed = true
221
+ }
222
+ if (anyFixed) migratedKeys++
223
+ else skippedKeys++
224
+ }
225
+ }
226
+ console.log()
227
+ if (migratedKeys === 0 && skippedKeys === 0) {
228
+ console.log(` ${chalk.green('✓')} Nothing to migrate`)
229
+ } else {
230
+ const fileLabel = fixedFiles === 1 ? 'file' : 'files'
231
+ console.log(` ${chalk.green('✓')} migrated ${chalk.bold(migratedKeys)} secrets (${fixedFiles} ${fileLabel} fixed), skipped ${skippedKeys}`)
232
+ }
233
+ console.log()
234
+ }
235
+
236
+ _scanNpmrc (findings) {
237
+ const locations = [
238
+ path.join(this._projectDir, '.npmrc'),
239
+ path.join(os.homedir(), '.npmrc')
240
+ ]
241
+ for (const filePath of locations) {
242
+ if (!fs.existsSync(filePath)) continue
243
+ const lines = fs.readFileSync(filePath, 'utf-8').split('\n')
244
+ for (let i = 0; i < lines.length; i++) {
245
+ const line = lines[i].trim()
246
+ const match = line.match(/_authToken=(.+)/)
247
+ if (!match) continue
248
+ const value = match[1].trim()
249
+ if (/^\$\{.+\}$/.test(value)) continue
250
+ const preview = value.substring(0, 8)
251
+ const isHomeNpmrc = filePath === path.join(os.homedir(), '.npmrc')
252
+ const displayPath = isHomeNpmrc
253
+ ? '~/.npmrc'
254
+ : path.relative(this._projectDir, filePath)
255
+ findings.push({
256
+ file: displayPath,
257
+ line: i + 1,
258
+ type: 'npm_token',
259
+ message: `contains auth token (${preview}...)`,
260
+ fix: 'goki-secrets set NPM_TOKEN --global',
261
+ value,
262
+ secretKey: 'NPM_TOKEN',
263
+ scope: 'global'
264
+ })
265
+ }
266
+ }
267
+ }
268
+
269
+ _scanEnvFiles (findings) {
270
+ for (const fileName of ENV_FILES) {
271
+ const filePath = path.join(this._projectDir, fileName)
272
+ if (!fs.existsSync(filePath)) continue
273
+ const lines = fs.readFileSync(filePath, 'utf-8').split('\n')
274
+ for (let i = 0; i < lines.length; i++) {
275
+ const line = lines[i].trim()
276
+ if (!line || line.startsWith('#')) continue
277
+ const match = line.match(/^([A-Z_][A-Z0-9_]*)=(.*)$/)
278
+ if (!match) continue
279
+ const key = match[1]
280
+ const value = match[2].trim().replace(/^["']|["']$/g, '')
281
+ if (!SENSITIVE_PATTERNS.test(key)) continue
282
+ if (isPlaceholder(value)) continue
283
+ findings.push({
284
+ file: fileName,
285
+ line: i + 1,
286
+ type: 'env_secret',
287
+ message: `${key} contains plaintext value`,
288
+ fix: `goki-secrets set ${key}`,
289
+ value,
290
+ secretKey: key,
291
+ scope: 'project'
292
+ })
293
+ }
294
+ }
295
+ }
296
+
297
+ _scanConfigFiles (findings) {
298
+ for (const fileName of CONFIG_FILES) {
299
+ const filePath = path.join(this._projectDir, fileName)
300
+ if (!fs.existsSync(filePath)) continue
301
+ const lines = fs.readFileSync(filePath, 'utf-8').split('\n')
302
+ for (let i = 0; i < lines.length; i++) {
303
+ const line = lines[i].trim()
304
+ if (!line || line.startsWith('#') || line.startsWith('//')) continue
305
+ const match = line.match(/([A-Z_][A-Z0-9_]*)[\s]*[=:][\s]*(.+)/)
306
+ if (!match) continue
307
+ const key = match[1]
308
+ const value = match[2].trim().replace(/[,'"]/g, '').trim()
309
+ if (!SENSITIVE_PATTERNS.test(key)) continue
310
+ if (isPlaceholder(value)) continue
311
+ findings.push({
312
+ file: fileName,
313
+ line: i + 1,
314
+ type: 'config_secret',
315
+ message: `${key} in plaintext`,
316
+ fix: `goki-secrets set ${key}`,
317
+ value,
318
+ secretKey: key,
319
+ scope: 'project'
320
+ })
321
+ }
322
+ }
323
+ }
324
+
325
+ _scanComposeFiles (findings) {
326
+ const composeGlobs = this._findComposeFiles()
327
+ for (const filePath of composeGlobs) {
328
+ if (!fs.existsSync(filePath)) continue
329
+ const lines = fs.readFileSync(filePath, 'utf-8').split('\n')
330
+ let inEnvironment = false
331
+ for (let i = 0; i < lines.length; i++) {
332
+ const line = lines[i]
333
+ const trimmed = line.trim()
334
+ if (/^environment\s*:/.test(trimmed)) {
335
+ inEnvironment = true
336
+ continue
337
+ }
338
+ if (inEnvironment && trimmed && !trimmed.startsWith('-') && !trimmed.startsWith('#') && !trimmed.includes('=') && !trimmed.includes(':')) {
339
+ inEnvironment = false
340
+ }
341
+ if (inEnvironment && /^\S/.test(line) && !/^\s/.test(line)) {
342
+ inEnvironment = false
343
+ }
344
+ if (!inEnvironment) continue
345
+ const listMatch = trimmed.match(/^-\s*([A-Z_][A-Z0-9_]*)=(.+)$/)
346
+ const mapMatch = trimmed.match(/^([A-Z_][A-Z0-9_]*)\s*:\s*(.+)$/)
347
+ const kvMatch = listMatch || mapMatch
348
+ if (!kvMatch) continue
349
+ const key = kvMatch[1]
350
+ const value = kvMatch[2].trim().replace(/^["']|["']$/g, '')
351
+ if (!SENSITIVE_PATTERNS.test(key)) continue
352
+ if (isPlaceholder(value)) continue
353
+ findings.push({
354
+ file: path.relative(this._projectDir, filePath),
355
+ line: i + 1,
356
+ type: 'compose_secret',
357
+ message: `${key} hardcoded as "${value}"`,
358
+ fix: `goki-secrets set ${key} --global`,
359
+ value,
360
+ secretKey: key,
361
+ scope: 'global'
362
+ })
363
+ }
364
+ }
365
+ }
366
+
367
+ _findComposeFiles () {
368
+ const files = []
369
+ for (const name of COMPOSE_FILES) {
370
+ files.push(path.join(this._projectDir, name))
371
+ }
372
+ try {
373
+ const entries = fs.readdirSync(this._projectDir)
374
+ for (const entry of entries) {
375
+ if (/^docker-compose\..+\.(yml|yaml)$/.test(entry) && !COMPOSE_FILES.includes(entry)) {
376
+ files.push(path.join(this._projectDir, entry))
377
+ }
378
+ }
379
+ } catch {
380
+ // Directory not readable, skip
381
+ }
382
+ return files
383
+ }
384
+ }