@gokiteam/goki-dev 0.2.1 → 0.2.3

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.
@@ -1,15 +1,15 @@
1
1
  import { execa } from 'execa'
2
2
  import fs from 'fs'
3
+ import os from 'os'
3
4
  import path from 'path'
4
- import { fileURLToPath } from 'url'
5
5
  import { Logger } from './Logger.js'
6
6
 
7
- const __dirname = path.dirname(fileURLToPath(import.meta.url))
8
7
  const DEV_TOOLS_API = 'http://localhost:9000'
9
8
  const NGROK_API = 'http://localhost:4040'
10
- const DATA_DIR = path.resolve(__dirname, '..', 'data')
9
+ const DATA_DIR = path.join(os.homedir(), '.goki-dev', 'data')
11
10
  const PID_FILE = path.join(DATA_DIR, 'ngrok.pid')
12
11
  const STATE_FILE = path.join(DATA_DIR, 'ngrok.json')
12
+ const DOMAIN_FILE = path.join(DATA_DIR, 'ngrok-domain.txt')
13
13
 
14
14
  export class NgrokManager {
15
15
  // --- PID File Management ---
@@ -302,6 +302,12 @@ export class NgrokManager {
302
302
  // --- Domain Management ---
303
303
 
304
304
  static async loadDomain () {
305
+ // Check local domain file first (most reliable, survives tunnel failures)
306
+ try {
307
+ const domain = fs.readFileSync(DOMAIN_FILE, 'utf8').trim()
308
+ if (domain) return domain
309
+ } catch { /* file doesn't exist yet */ }
310
+ // Try dev-tools API
305
311
  try {
306
312
  const response = await fetch(`${DEV_TOOLS_API}/v1/webhooks/settings/get`, {
307
313
  method: 'POST',
@@ -312,17 +318,24 @@ export class NgrokManager {
312
318
  if (response.ok) {
313
319
  const result = await response.json()
314
320
  const domain = result.data?.settings?.ngrok_domain
315
- if (domain) return domain
321
+ if (domain) {
322
+ // Persist locally for next time
323
+ this.ensureDataDir()
324
+ fs.writeFileSync(DOMAIN_FILE, domain.trim(), 'utf8')
325
+ return domain
326
+ }
316
327
  }
317
- } catch {
318
- // API failed, fall through to local file fallback
319
- }
320
- // Fallback: read from local state file
328
+ } catch { /* API not available */ }
329
+ // Last resort: check tunnel state file
321
330
  const state = this.readState()
322
331
  return state?.domain || null
323
332
  }
324
333
 
325
334
  static async saveDomain (domain) {
335
+ // Always save locally so it survives tunnel failures and API downtime
336
+ this.ensureDataDir()
337
+ fs.writeFileSync(DOMAIN_FILE, domain.trim(), 'utf8')
338
+ // Also save to dev-tools API (best-effort)
326
339
  try {
327
340
  await fetch(`${DEV_TOOLS_API}/v1/webhooks/settings/update`, {
328
341
  method: 'POST',
@@ -333,7 +346,7 @@ export class NgrokManager {
333
346
  signal: AbortSignal.timeout(3000)
334
347
  })
335
348
  } catch (error) {
336
- Logger.warn(`Failed to save ngrok domain: ${error.message}`)
349
+ Logger.warn(`Failed to save ngrok domain to API: ${error.message}`)
337
350
  }
338
351
  }
339
352
 
@@ -17,7 +17,6 @@ SHADOW_SUBSCRIPTION_CHECK_INTERVAL_MS=5000
17
17
  FIRESTORE_PROJECT_ID=goki-dev-local
18
18
 
19
19
  # Storage
20
- DATA_DIR=./data
21
20
  AUTO_FLUSH_INTERVAL_MS=5000
22
21
 
23
22
  # Redis (for app data)
@@ -44,7 +44,7 @@ services:
44
44
  volumes:
45
45
  - ./src:/app/src:ro
46
46
  - ./docs:/app/docs:ro
47
- - ./data:/app/data
47
+ - ~/.goki-dev/data:/app/data
48
48
  - ./logs:/app/logs
49
49
  - /var/run/docker.sock:/var/run/docker.sock:ro
50
50
  depends_on:
@@ -93,7 +93,7 @@ services:
93
93
  - APP_GATEWAY_SCAN_INTERVAL_SECONDS=30
94
94
  - APP_GATEWAY_AUTO_START=true
95
95
  volumes:
96
- - ./data:/app/data
96
+ - ~/.goki-dev/data:/app/data
97
97
  - ./logs:/app/logs
98
98
  - /var/run/docker.sock:/var/run/docker.sock
99
99
  depends_on:
package/package.json CHANGED
@@ -1,24 +1,8 @@
1
1
  {
2
2
  "name": "@gokiteam/goki-dev",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "Unified local development platform for Goki services",
5
5
  "type": "module",
6
- "main": "./client/dist/index.js",
7
- "types": "./client/dist/index.d.ts",
8
- "exports": {
9
- ".": {
10
- "types": "./client/dist/index.d.ts",
11
- "import": "./client/dist/index.js",
12
- "require": "./client/dist/index.js"
13
- }
14
- },
15
- "typesVersions": {
16
- "*": {
17
- ".": [
18
- "./client/dist/index.d.ts"
19
- ]
20
- }
21
- },
22
6
  "bin": {
23
7
  "goki-dev": "./bin/goki-dev.js",
24
8
  "goki-dev-mcp": "./bin/mcp-server.js",
@@ -28,7 +12,6 @@
28
12
  "bin/",
29
13
  "cli/",
30
14
  "src/",
31
- "client/dist/",
32
15
  "ui/build/",
33
16
  "docker-compose.yml",
34
17
  "docker-compose.services.yml",
@@ -48,12 +31,11 @@
48
31
  "dev:watch": "dotenv -e config.development node --watch src/Server.js",
49
32
  "dev:ui": "concurrently \"npm run dev\" \"cd ui && npm start\" --names \"backend,frontend\" --prefix-colors \"blue,green\"",
50
33
  "ui:start": "cd ui && npm start",
51
- "ui:build": "cd ui && npm run build",
52
- "build:client": "cd client && npx tsc && echo '{\"type\":\"commonjs\"}' > dist/package.json",
53
- "prepublishOnly": "npm run ui:build && npm run build:client",
54
- "release": "npm version patch && npm run build:client && npm publish",
55
- "release:minor": "npm version minor && npm run build:client && npm publish",
56
- "release:major": "npm version major && npm run build:client && npm publish",
34
+ "build": "cd ui && npm run build && cd ../client && npm run build",
35
+ "prepublishOnly": "npm run build",
36
+ "release": "standard-version && npm run build && npm publish && cd client && npm publish",
37
+ "release:minor": "standard-version --release-as minor && npm run build && npm publish && cd client && npm publish",
38
+ "release:major": "standard-version --release-as major && npm run build && npm publish && cd client && npm publish",
57
39
  "test": "dotenv -e config.test mocha 'tests/**/*.test.js'",
58
40
  "test:api": "dotenv -e config.test mocha 'tests/api/**/*.test.js'",
59
41
  "test:emulation": "dotenv -e config.test mocha 'tests/emulation/**/*.test.js'",
@@ -137,6 +119,7 @@
137
119
  "mqtt": "^5.15.0",
138
120
  "playwright": "^1.58.1",
139
121
  "standard": "^17.1.0",
122
+ "standard-version": "^9.5.0",
140
123
  "supertest": "^6.3.0",
141
124
  "typescript": "^5.9.3"
142
125
  },
@@ -145,5 +128,17 @@
145
128
  "mocha"
146
129
  ]
147
130
  },
131
+ "standard-version": {
132
+ "bumpFiles": [
133
+ {
134
+ "filename": "package.json",
135
+ "type": "json"
136
+ },
137
+ {
138
+ "filename": "client/package.json",
139
+ "type": "json"
140
+ }
141
+ ]
142
+ },
148
143
  "packageManager": "yarn@3.8.7+sha512.bbe7e310ff7fd20dc63b111110f96fe18192234bb0d4f10441fa6b85d2b644c8923db8fbe6d7886257ace948440ab1f83325ad02af457a1806cdc97f03d2508e"
149
144
  }
@@ -72,6 +72,20 @@ export const Controllers = {
72
72
  ctx.reply(result)
73
73
  },
74
74
 
75
+ async setDocument (ctx) {
76
+ const { traceId } = ctx.state
77
+ const { collectionPath, documentId, fields, projectId } = ctx.request.body
78
+ const result = await Logic.setDocument({ collectionPath, documentId, fields, projectId, traceId })
79
+ ctx.reply(result)
80
+ },
81
+
82
+ async createBatch (ctx) {
83
+ const { traceId } = ctx.state
84
+ const { collectionPath, documents, projectId } = ctx.request.body
85
+ const result = await Logic.createBatch({ collectionPath, documents, projectId, traceId })
86
+ ctx.reply(result)
87
+ },
88
+
75
89
  async executeQuery (ctx) {
76
90
  const { traceId } = ctx.state
77
91
  const { collectionPath, where, orderBy, limit, projectId } = ctx.request.body
@@ -1,4 +1,4 @@
1
- import { initializeApp, getApps, deleteApp } from 'firebase-admin/app'
1
+ import { initializeApp, getApps } from 'firebase-admin/app'
2
2
  import { getFirestore } from 'firebase-admin/firestore'
3
3
  import { SqliteStore } from '../../singletons/SqliteStore.js'
4
4
  import { Application } from '../../configs/Application.js'
@@ -354,6 +354,79 @@ export const Logic = {
354
354
  }
355
355
  },
356
356
 
357
+ async setDocument (params) {
358
+ const { collectionPath, documentId, fields, traceId } = params
359
+ const projectId = resolveProjectId(params)
360
+ try {
361
+ const url = `${FIRESTORE_API}/v1/projects/${projectId}/databases/${DATABASE}/documents/${collectionPath}/${documentId}`
362
+ const response = await fetch(url, {
363
+ method: 'PATCH',
364
+ headers: { 'Content-Type': 'application/json' },
365
+ body: JSON.stringify({ fields })
366
+ })
367
+ if (!response.ok) {
368
+ throw new Error(`Firestore API error: ${response.statusText}`)
369
+ }
370
+ const document = await response.json()
371
+ trackCollection(collectionPath)
372
+ return {
373
+ document,
374
+ traceId
375
+ }
376
+ } catch (error) {
377
+ return {
378
+ status: 'error',
379
+ message: error.message,
380
+ traceId
381
+ }
382
+ }
383
+ },
384
+
385
+ async createBatch (params) {
386
+ const { collectionPath, documents = [], traceId } = params
387
+ const projectId = resolveProjectId(params)
388
+ const BATCH_SIZE = 20
389
+ try {
390
+ const failedIds = []
391
+ let createdCount = 0
392
+ for (let i = 0; i < documents.length; i += BATCH_SIZE) {
393
+ const batch = documents.slice(i, i + BATCH_SIZE)
394
+ const results = await Promise.allSettled(
395
+ batch.map(doc =>
396
+ this.createDocument({
397
+ collectionPath,
398
+ projectId,
399
+ documentId: doc.documentId,
400
+ fields: doc.fields,
401
+ traceId
402
+ })
403
+ )
404
+ )
405
+ for (let j = 0; j < results.length; j++) {
406
+ const result = results[j]
407
+ if (result.status === 'fulfilled' && !result.value.status) {
408
+ createdCount++
409
+ } else {
410
+ const docId = batch[j].documentId || `index-${i + j}`
411
+ failedIds.push(docId)
412
+ }
413
+ }
414
+ }
415
+ return {
416
+ success: true,
417
+ createdCount,
418
+ failedIds,
419
+ traceId
420
+ }
421
+ } catch (error) {
422
+ return {
423
+ status: 'error',
424
+ message: error.message,
425
+ traceId
426
+ }
427
+ }
428
+ },
429
+
357
430
  async clearCollection (params) {
358
431
  const { collectionPath, projectId, traceId } = params
359
432
  try {
@@ -11,6 +11,8 @@ v1.post('/documents/get', Controllers.getDocument)
11
11
  v1.post('/documents/create', Controllers.createDocument)
12
12
  v1.post('/documents/update', Controllers.updateDocument)
13
13
  v1.post('/documents/delete', Controllers.deleteDocument)
14
+ v1.post('/documents/set', Controllers.setDocument)
15
+ v1.post('/documents/create-batch', Controllers.createBatch)
14
16
  v1.post('/documents/delete-by-query', Controllers.deleteByQuery)
15
17
  v1.post('/documents/delete-by-prefix', Controllers.deleteByPrefix)
16
18
  v1.post('/documents/delete-batch', Controllers.deleteBatch)
@@ -0,0 +1,160 @@
1
+ import { Joi } from '@gokiteam/koa'
2
+
3
+ const whereClause = Joi.object({
4
+ field: Joi.string().required(),
5
+ operator: Joi.string().valid('==', '!=', '<', '<=', '>', '>=', 'array-contains', 'in', 'array-contains-any').required(),
6
+ value: Joi.any().required()
7
+ })
8
+
9
+ const pageOptions = Joi.object({
10
+ limit: Joi.number().integer().min(1).max(10000).default(50),
11
+ offset: Joi.number().integer().min(0).default(0)
12
+ }).optional()
13
+
14
+ export const Schemas = {
15
+ listProjects: {
16
+ request: {
17
+ body: {}
18
+ }
19
+ },
20
+
21
+ listCollections: {
22
+ request: {
23
+ body: {
24
+ projectId: Joi.string().optional()
25
+ }
26
+ }
27
+ },
28
+
29
+ listDocuments: {
30
+ request: {
31
+ body: {
32
+ collectionPath: Joi.string().required(),
33
+ projectId: Joi.string().optional(),
34
+ page: pageOptions
35
+ }
36
+ }
37
+ },
38
+
39
+ getDocument: {
40
+ request: {
41
+ body: {
42
+ collectionPath: Joi.string().required(),
43
+ documentId: Joi.string().required(),
44
+ projectId: Joi.string().optional()
45
+ }
46
+ }
47
+ },
48
+
49
+ createDocument: {
50
+ request: {
51
+ body: {
52
+ collectionPath: Joi.string().required(),
53
+ documentId: Joi.string().optional(),
54
+ fields: Joi.object().required(),
55
+ projectId: Joi.string().optional()
56
+ }
57
+ }
58
+ },
59
+
60
+ setDocument: {
61
+ request: {
62
+ body: {
63
+ collectionPath: Joi.string().required(),
64
+ documentId: Joi.string().required(),
65
+ fields: Joi.object().required(),
66
+ projectId: Joi.string().optional()
67
+ }
68
+ }
69
+ },
70
+
71
+ updateDocument: {
72
+ request: {
73
+ body: {
74
+ collectionPath: Joi.string().required(),
75
+ documentId: Joi.string().required(),
76
+ fields: Joi.object().required(),
77
+ projectId: Joi.string().optional()
78
+ }
79
+ }
80
+ },
81
+
82
+ deleteDocument: {
83
+ request: {
84
+ body: {
85
+ collectionPath: Joi.string().required(),
86
+ documentId: Joi.string().required(),
87
+ projectId: Joi.string().optional()
88
+ }
89
+ }
90
+ },
91
+
92
+ createBatch: {
93
+ request: {
94
+ body: {
95
+ collectionPath: Joi.string().required(),
96
+ documents: Joi.array().items(
97
+ Joi.object({
98
+ documentId: Joi.string().optional(),
99
+ fields: Joi.object().required()
100
+ })
101
+ ).min(1).max(500).required(),
102
+ projectId: Joi.string().optional()
103
+ }
104
+ }
105
+ },
106
+
107
+ deleteByQuery: {
108
+ request: {
109
+ body: {
110
+ collectionPath: Joi.string().required(),
111
+ where: whereClause.required(),
112
+ projectId: Joi.string().optional()
113
+ }
114
+ }
115
+ },
116
+
117
+ deleteByPrefix: {
118
+ request: {
119
+ body: {
120
+ collectionPath: Joi.string().required(),
121
+ prefix: Joi.string().required(),
122
+ projectId: Joi.string().optional()
123
+ }
124
+ }
125
+ },
126
+
127
+ deleteBatch: {
128
+ request: {
129
+ body: {
130
+ collectionPath: Joi.string().required(),
131
+ documentIds: Joi.array().items(Joi.string()).min(1).max(500).required(),
132
+ projectId: Joi.string().optional()
133
+ }
134
+ }
135
+ },
136
+
137
+ executeQuery: {
138
+ request: {
139
+ body: {
140
+ collectionPath: Joi.string().required(),
141
+ where: Joi.array().items(whereClause).optional(),
142
+ orderBy: Joi.object({
143
+ field: Joi.string().required(),
144
+ direction: Joi.string().valid('ASCENDING', 'DESCENDING').default('ASCENDING')
145
+ }).optional(),
146
+ limit: Joi.number().integer().min(1).max(10000).default(50),
147
+ projectId: Joi.string().optional()
148
+ }
149
+ }
150
+ },
151
+
152
+ clearCollection: {
153
+ request: {
154
+ body: {
155
+ collectionPath: Joi.string().required(),
156
+ projectId: Joi.string().optional()
157
+ }
158
+ }
159
+ }
160
+ }
@@ -1,7 +1,7 @@
1
1
  import { execa } from 'execa'
2
2
  import fs from 'fs/promises'
3
+ import os from 'os'
3
4
  import path from 'path'
4
- import { fileURLToPath } from 'url'
5
5
  import { Logic as FirestoreLogic } from '../firestore/Logic.js'
6
6
  import { Logic as DockerLogic } from '../docker/Logic.js'
7
7
  import { Logic as PubSubLogic } from '../pubsub/Logic.js'
@@ -23,10 +23,7 @@ import {
23
23
  PUBSUB_MESSAGE_HISTORY
24
24
  } from '../../db/Tables.js'
25
25
 
26
- const __filename = fileURLToPath(import.meta.url)
27
- const __dirname = path.dirname(__filename)
28
- const PROJECT_ROOT = path.join(__dirname, '../../..')
29
- const SNAPSHOTS_DIR = path.join(PROJECT_ROOT, '.goki-dev/snapshots')
26
+ const SNAPSHOTS_DIR = path.join(os.homedir(), '.goki-dev', 'snapshots')
30
27
 
31
28
  // Ensure snapshot directories exist
32
29
  async function ensureSnapshotDirs () {
@@ -1,4 +1,6 @@
1
1
  import Moment from 'moment'
2
+ import os from 'os'
3
+ import path from 'path'
2
4
 
3
5
  const {
4
6
  NODE_ENV = 'development',
@@ -77,7 +79,7 @@ export const Application = {
77
79
  shadowSubscriptionCheckIntervalMs: parseInt(SHADOW_SUBSCRIPTION_CHECK_INTERVAL_MS) || 5000
78
80
  },
79
81
  storage: {
80
- dataDir: DATA_DIR
82
+ dataDir: DATA_DIR || path.join(os.homedir(), '.goki-dev', 'data')
81
83
  },
82
84
  redis: {
83
85
  host: REDIS_HOST || 'localhost',
@@ -5,6 +5,7 @@ export function registerDataTools (server, apiClient) {
5
5
  'platform_stats',
6
6
  'Get platform dashboard statistics (topic count, message count, log entries, MQTT clients, etc.)',
7
7
  {},
8
+ { readOnlyHint: true },
8
9
  async () => {
9
10
  try {
10
11
  const data = await apiClient.post('/v1/dashboard/stats')
@@ -19,6 +20,7 @@ export function registerDataTools (server, apiClient) {
19
20
  'platform_export',
20
21
  'Export all platform data (topics, subscriptions, messages, logs, etc.)',
21
22
  {},
23
+ { readOnlyHint: true },
22
24
  async () => {
23
25
  try {
24
26
  const data = await apiClient.post('/v1/data/export')
@@ -31,11 +33,12 @@ export function registerDataTools (server, apiClient) {
31
33
 
32
34
  server.tool(
33
35
  'platform_import',
34
- 'Import platform data (optionally clearing existing data first)',
36
+ '[DESTRUCTIVE] Import platform data (optionally clearing existing data first). Ask the user for confirmation before calling this tool.',
35
37
  {
36
38
  data: z.record(z.any()).describe('Platform data object to import'),
37
39
  clearExisting: z.boolean().optional().describe('Whether to clear existing data before importing')
38
40
  },
41
+ { destructiveHint: true },
39
42
  async ({ data, clearExisting }) => {
40
43
  try {
41
44
  const body = { data }
@@ -50,8 +53,9 @@ export function registerDataTools (server, apiClient) {
50
53
 
51
54
  server.tool(
52
55
  'platform_clear',
53
- 'Clear all platform data (topics, messages, logs, traffic records)',
56
+ '[DESTRUCTIVE] Clear all platform data (topics, messages, logs, traffic records). Ask the user for confirmation before calling this tool.',
54
57
  {},
58
+ { destructiveHint: true },
55
59
  async () => {
56
60
  try {
57
61
  const data = await apiClient.post('/v1/data/clear')
@@ -64,11 +68,12 @@ export function registerDataTools (server, apiClient) {
64
68
 
65
69
  server.tool(
66
70
  'platform_clear_services',
67
- 'Clear data for specific services. Useful for resetting test state without clearing everything.',
71
+ '[DESTRUCTIVE] Clear data for specific services. Useful for resetting test state without clearing everything. Ask the user for confirmation before calling this tool.',
68
72
  {
69
73
  services: z.array(z.string()).optional().describe('Optional list of service names to clear'),
70
74
  keepSystemData: z.boolean().optional().describe('Whether to keep system/infrastructure data')
71
75
  },
76
+ { destructiveHint: true },
72
77
  async ({ services, keepSystemData }) => {
73
78
  try {
74
79
  const body = {}
@@ -63,6 +63,7 @@ export function registerDockerTools (server, apiClient) {
63
63
  'docker_list_containers',
64
64
  'List all Docker containers on the goki-network with their status, ports, and uptime',
65
65
  {},
66
+ { readOnlyHint: true },
66
67
  async () => {
67
68
  try {
68
69
  const { stdout } = await execAsync(
@@ -114,10 +115,11 @@ export function registerDockerTools (server, apiClient) {
114
115
 
115
116
  server.tool(
116
117
  'docker_stop_container',
117
- 'Stop a running Docker container by name',
118
+ '[DESTRUCTIVE] Stop a running Docker container by name. Ask the user for confirmation before calling this tool.',
118
119
  {
119
120
  containerName: z.string().describe('Name of the Docker container to stop')
120
121
  },
122
+ { destructiveHint: true },
121
123
  async ({ containerName }) => {
122
124
  try {
123
125
  await execAsync(`docker stop ${containerName}`)
@@ -131,10 +133,11 @@ export function registerDockerTools (server, apiClient) {
131
133
 
132
134
  server.tool(
133
135
  'docker_restart_container',
134
- 'Restart a Docker container by name',
136
+ '[DESTRUCTIVE] Restart a Docker container by name. Ask the user for confirmation before calling this tool.',
135
137
  {
136
138
  containerName: z.string().describe('Name of the Docker container to restart')
137
139
  },
140
+ { destructiveHint: true },
138
141
  async ({ containerName }) => {
139
142
  try {
140
143
  await execAsync(`docker restart ${containerName}`)
@@ -153,6 +156,7 @@ export function registerDockerTools (server, apiClient) {
153
156
  containerName: z.string().describe('Name of the Docker container'),
154
157
  lines: z.number().optional().describe('Number of recent log lines to retrieve (default 100)')
155
158
  },
159
+ { readOnlyHint: true },
156
160
  async ({ containerName, lines = 100 }) => {
157
161
  try {
158
162
  const { stdout, stderr } = await execAsync(`docker logs --tail ${lines} ${containerName} 2>&1`)