mekong-cli 1.0.0 → 1.1.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/bin/mekong-cli.js CHANGED
@@ -118,6 +118,16 @@ function parseArgs(argv) {
118
118
  // Main
119
119
  // ---------------------------------------------------------------------------
120
120
  async function main() {
121
+ // init subcommand
122
+ if (process.argv[2] === 'init') {
123
+ const { runInit } = require('../lib/init.js')
124
+ runInit().catch(err => {
125
+ process.stderr.write(`${RED}mekong-cli init: ${err.message}${RESET}\n`)
126
+ process.exit(1)
127
+ })
128
+ return
129
+ }
130
+
121
131
  const opts = parseArgs(process.argv);
122
132
 
123
133
  if (opts.help) {
package/lib/init.js ADDED
@@ -0,0 +1,410 @@
1
+ 'use strict'
2
+
3
+ const fs = require('fs')
4
+ const path = require('path')
5
+ const os = require('os')
6
+ const readline = require('readline')
7
+
8
+ const BOLD = '\x1b[1m'
9
+ const DIM = '\x1b[2m'
10
+ const CYAN = '\x1b[36m'
11
+ const GREEN = '\x1b[32m'
12
+ const YELLOW = '\x1b[33m'
13
+ const RED = '\x1b[31m'
14
+ const RESET = '\x1b[0m'
15
+ const CHECK = '\x1b[32m✓\x1b[0m'
16
+
17
+ // ---------------------------------------------------------------------------
18
+ // Framework tables
19
+ // ---------------------------------------------------------------------------
20
+ const FRAMEWORKS = [
21
+ { deps: ['next'], name: 'Next.js', cmd: 'next dev', port: 3000 },
22
+ { deps: ['nuxt', 'nuxt3', 'nuxt-edge'], name: 'Nuxt', cmd: 'nuxt dev', port: 3000 },
23
+ { deps: ['vite'], name: 'Vite', cmd: 'vite', port: 5173 },
24
+ { deps: ['react-scripts'], name: 'CRA', cmd: 'react-scripts start', port: 3000 },
25
+ { deps: ['@angular/core'], name: 'Angular', cmd: 'ng serve', port: 4200 },
26
+ { deps: ['@sveltejs/kit'], name: 'SvelteKit', cmd: 'vite dev', port: 5173 },
27
+ { deps: ['svelte'], name: 'Svelte', cmd: 'vite', port: 5173 },
28
+ { deps: ['astro'], name: 'Astro', cmd: 'astro dev', port: 4321 },
29
+ { deps: ['gatsby'], name: 'Gatsby', cmd: 'gatsby develop', port: 8000 },
30
+ { deps: ['remix', '@remix-run/react'], name: 'Remix', cmd: 'remix dev', port: 3000 },
31
+ { deps: ['@remix-run/dev'], name: 'Remix', cmd: 'remix dev', port: 3000 },
32
+ { deps: ['express'], name: 'Express', cmd: 'node server.js', port: 3000 },
33
+ { deps: ['fastify'], name: 'Fastify', cmd: 'node server.js', port: 3000 },
34
+ { deps: ['hono', '@hono/node-server'], name: 'Hono', cmd: 'node server.js', port: 3000 },
35
+ ]
36
+
37
+ const PY_FRAMEWORKS = [
38
+ { pkg: 'fastapi', name: 'FastAPI', cmd: 'uvicorn main:app --reload', port: 8000 },
39
+ { pkg: 'flask', name: 'Flask', cmd: 'flask run', port: 5000 },
40
+ { pkg: 'django', name: 'Django', cmd: 'python manage.py runserver', port: 8000 },
41
+ { pkg: 'starlette', name: 'Starlette', cmd: 'uvicorn main:app --reload', port: 8000 },
42
+ { pkg: 'tornado', name: 'Tornado', cmd: 'python main.py', port: 8888 },
43
+ { pkg: 'sanic', name: 'Sanic', cmd: 'sanic main.app', port: 8000 },
44
+ { pkg: 'litestar', name: 'Litestar', cmd: 'uvicorn main:app --reload', port: 8000 },
45
+ ]
46
+
47
+ // ---------------------------------------------------------------------------
48
+ // Input helpers
49
+ // ---------------------------------------------------------------------------
50
+
51
+ // Read all stdin lines upfront when stdin is not a TTY (piped/test mode),
52
+ // so readline auto-close on EOF does not swallow buffered answers.
53
+ function readAllStdinLines() {
54
+ return new Promise((resolve) => {
55
+ const lines = []
56
+ const rl = readline.createInterface({ input: process.stdin, terminal: false })
57
+ rl.on('line', (l) => lines.push(l))
58
+ rl.on('close', () => resolve(lines))
59
+ })
60
+ }
61
+
62
+ // Build an `ask(question)` function. In non-TTY (piped) mode, pre-reads all
63
+ // stdin lines into a queue so readline close-on-EOF doesn't eat answers.
64
+ // Returns { ask, close } where close() tears down the readline interface if any.
65
+ async function makeAsker() {
66
+ if (!process.stdin.isTTY) {
67
+ const lines = await readAllStdinLines()
68
+ let idx = 0
69
+ function ask(question) {
70
+ const answer = idx < lines.length ? lines[idx++] : ''
71
+ process.stdout.write(question + answer + '\n')
72
+ return Promise.resolve(answer)
73
+ }
74
+ return { ask, close: () => {} }
75
+ }
76
+
77
+ // Interactive TTY
78
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout })
79
+ function ask(question) {
80
+ return new Promise((resolve) => {
81
+ rl.question(question, (answer) => resolve(answer))
82
+ })
83
+ }
84
+ return { ask, close: () => rl.close() }
85
+ }
86
+
87
+ // ---------------------------------------------------------------------------
88
+ // Filesystem helpers
89
+ // ---------------------------------------------------------------------------
90
+ function fileExists(filePath) {
91
+ try {
92
+ fs.accessSync(filePath, fs.constants.F_OK)
93
+ return true
94
+ } catch {
95
+ return false
96
+ }
97
+ }
98
+
99
+ // ---------------------------------------------------------------------------
100
+ // Node.js detection
101
+ // ---------------------------------------------------------------------------
102
+ function detectNode(cwd) {
103
+ const pkgPath = path.join(cwd, 'package.json')
104
+ let pkg
105
+ try {
106
+ pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'))
107
+ } catch (err) {
108
+ throw new Error(`Failed to parse package.json: ${err.message}`)
109
+ }
110
+
111
+ const allDeps = Object.assign({}, pkg.dependencies || {}, pkg.devDependencies || {})
112
+
113
+ let detected = null
114
+ for (const fw of FRAMEWORKS) {
115
+ if (fw.deps.some((d) => allDeps[d] !== undefined)) {
116
+ detected = { name: fw.name, cmd: fw.cmd, port: fw.port }
117
+ break
118
+ }
119
+ }
120
+
121
+ // Try to extract port from scripts.dev if it has --port N
122
+ const devScript = (pkg.scripts && pkg.scripts.dev) || null
123
+ if (devScript) {
124
+ const portMatch = devScript.match(/--port[=\s]+(\d+)/)
125
+ if (portMatch) {
126
+ const extractedPort = parseInt(portMatch[1], 10)
127
+ if (detected) {
128
+ detected.port = extractedPort
129
+ }
130
+ }
131
+ // If no framework detected but dev script exists, use it as the command
132
+ if (!detected) {
133
+ detected = { name: null, cmd: devScript, port: 3000 }
134
+ }
135
+ }
136
+
137
+ return { detected, pkg, pkgPath }
138
+ }
139
+
140
+ // ---------------------------------------------------------------------------
141
+ // Python detection
142
+ // ---------------------------------------------------------------------------
143
+ function detectPython(cwd) {
144
+ // Django via manage.py
145
+ if (fileExists(path.join(cwd, 'manage.py'))) {
146
+ return { name: 'Django', cmd: 'python manage.py runserver', port: 8000 }
147
+ }
148
+
149
+ const packages = new Set()
150
+
151
+ // requirements.txt
152
+ const reqPath = path.join(cwd, 'requirements.txt')
153
+ if (fileExists(reqPath)) {
154
+ const lines = fs.readFileSync(reqPath, 'utf8').split('\n')
155
+ for (const line of lines) {
156
+ const clean = line.trim().split(/[>=<![\s]/)[0].toLowerCase()
157
+ if (clean) packages.add(clean)
158
+ }
159
+ }
160
+
161
+ // pyproject.toml — scan [project] dependencies section
162
+ const pyprojectPath = path.join(cwd, 'pyproject.toml')
163
+ if (fileExists(pyprojectPath)) {
164
+ const content = fs.readFileSync(pyprojectPath, 'utf8')
165
+ const lines = content.split('\n')
166
+ let inDeps = false
167
+ for (const line of lines) {
168
+ const trimmed = line.trim()
169
+ if (trimmed === '[project]') { inDeps = false }
170
+ if (inDeps) {
171
+ if (trimmed.startsWith('[') && trimmed !== '[project.dependencies]') { inDeps = false; continue }
172
+ const clean = trimmed.replace(/^["']/, '').split(/[>=<![\s"']/)[0].toLowerCase()
173
+ if (clean && !clean.startsWith('#')) packages.add(clean)
174
+ }
175
+ if (trimmed === 'dependencies' || trimmed === '[project.dependencies]' ||
176
+ (trimmed.startsWith('dependencies') && trimmed.includes('='))) {
177
+ inDeps = true
178
+ }
179
+ }
180
+ }
181
+
182
+ for (const fw of PY_FRAMEWORKS) {
183
+ if (packages.has(fw.pkg)) {
184
+ return { name: fw.name, cmd: fw.cmd, port: fw.port }
185
+ }
186
+ }
187
+
188
+ return null
189
+ }
190
+
191
+ // ---------------------------------------------------------------------------
192
+ // Inject Node.js (package.json scripts)
193
+ // ---------------------------------------------------------------------------
194
+ async function injectNode(pkgPath, pkg, cmd, port, ask) {
195
+ if (pkg.scripts && pkg.scripts['dev:tunnel']) {
196
+ const answer = await ask(`${YELLOW}dev:tunnel already exists. Overwrite? (Y/n):${RESET} `)
197
+ if (answer.trim().toLowerCase() === 'n') {
198
+ process.stdout.write(`Skipped. No changes made.\n`)
199
+ return false
200
+ }
201
+ }
202
+
203
+ if (!pkg.scripts) pkg.scripts = {}
204
+ pkg.scripts['dev:tunnel'] = `mekong-cli --with "${cmd}" --port ${port}`
205
+ fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n', 'utf8')
206
+ return true
207
+ }
208
+
209
+ // ---------------------------------------------------------------------------
210
+ // Inject Python (Makefile)
211
+ // ---------------------------------------------------------------------------
212
+ function injectPython(cwd, cmd, port) {
213
+ const makefilePath = path.join(cwd, 'Makefile')
214
+ const target = `dev-tunnel:\n\tmekong ${cmd} --port ${port}\n`
215
+
216
+ if (fileExists(makefilePath)) {
217
+ const existing = fs.readFileSync(makefilePath, 'utf8')
218
+ fs.writeFileSync(makefilePath, existing.trimEnd() + '\n\n' + target, 'utf8')
219
+ } else {
220
+ fs.writeFileSync(makefilePath, target, 'utf8')
221
+ }
222
+
223
+ process.stdout.write(`Also run directly: ${CYAN}mekong ${cmd} --port ${port}${RESET}\n`)
224
+ }
225
+
226
+ // ---------------------------------------------------------------------------
227
+ // Prompt for manual command + port
228
+ // ---------------------------------------------------------------------------
229
+ async function promptManual(ask) {
230
+ const cmd = await ask(`Enter dev server command: `)
231
+ const portStr = await ask(`Enter local port: `)
232
+ const port = parseInt(portStr.trim(), 10)
233
+ if (isNaN(port)) throw new Error(`Invalid port: ${portStr.trim()}`)
234
+ return { cmd: cmd.trim(), port }
235
+ }
236
+
237
+ // ---------------------------------------------------------------------------
238
+ // Configure Node.js ecosystem
239
+ // ---------------------------------------------------------------------------
240
+ async function configureNode(cwd, ask) {
241
+ let { detected, pkg, pkgPath } = detectNode(cwd)
242
+ let cmd, port, frameworkName
243
+
244
+ if (detected) {
245
+ frameworkName = detected.name || 'custom'
246
+ cmd = detected.cmd
247
+ port = detected.port
248
+
249
+ const label = detected.name ? detected.name : 'project'
250
+ process.stdout.write(`\nDetected: ${BOLD}${label}${RESET} on port ${CYAN}${port}${RESET}\n`)
251
+ process.stdout.write(`Command: ${DIM}${cmd}${RESET}\n\n`)
252
+ process.stdout.write(`Will add to package.json:\n`)
253
+ process.stdout.write(` ${CYAN}"dev:tunnel": "mekong-cli --with \\"${cmd}\\" --port ${port}"${RESET}\n\n`)
254
+
255
+ const answer = await ask(`Confirm? (Y/n): `)
256
+ if (answer.trim().toLowerCase() === 'n') {
257
+ const manual = await promptManual(ask)
258
+ cmd = manual.cmd
259
+ port = manual.port
260
+ frameworkName = 'custom'
261
+
262
+ process.stdout.write(`\nWill add to package.json:\n`)
263
+ process.stdout.write(` ${CYAN}"dev:tunnel": "mekong-cli --with \\"${cmd}\\" --port ${port}"${RESET}\n\n`)
264
+ const confirm2 = await ask(`Confirm? (Y/n): `)
265
+ if (confirm2.trim().toLowerCase() === 'n') {
266
+ process.stdout.write(`Skipped. No changes made.\n`)
267
+ return
268
+ }
269
+ }
270
+ } else {
271
+ process.stdout.write(`\n${YELLOW}No known Node.js framework detected.${RESET}\n`)
272
+ const manual = await promptManual(ask)
273
+ cmd = manual.cmd
274
+ port = manual.port
275
+ frameworkName = 'custom'
276
+
277
+ process.stdout.write(`\nWill add to package.json:\n`)
278
+ process.stdout.write(` ${CYAN}"dev:tunnel": "mekong-cli --with \\"${cmd}\\" --port ${port}"${RESET}\n\n`)
279
+ const confirm2 = await ask(`Confirm? (Y/n): `)
280
+ if (confirm2.trim().toLowerCase() === 'n') {
281
+ process.stdout.write(`Skipped. No changes made.\n`)
282
+ return
283
+ }
284
+ }
285
+
286
+ const wrote = await injectNode(pkgPath, pkg, cmd, port, ask)
287
+ if (!wrote) return
288
+
289
+ const label = detected && detected.name ? detected.name : frameworkName
290
+ process.stdout.write(`\n${CHECK} Done! mekong-cli is set up for ${BOLD}${label}${RESET}\n\n`)
291
+ process.stdout.write(`Run your tunnel:\n`)
292
+ process.stdout.write(` ${CYAN}npm run dev:tunnel${RESET}\n\n`)
293
+ process.stdout.write(`What it does:\n`)
294
+ process.stdout.write(` 1. Starts: ${DIM}${cmd}${RESET} (port ${port})\n`)
295
+ process.stdout.write(` 2. Waits for port ${port} to open\n`)
296
+ process.stdout.write(` 3. Opens a Mekong tunnel\n`)
297
+ process.stdout.write(` 4. Prints your public URL\n\n`)
298
+ process.stdout.write(`Make sure mekong binary is installed:\n`)
299
+ process.stdout.write(` ${CYAN}https://github.com/MuyleangIng/MekongTunnel/releases/latest${RESET}\n`)
300
+ }
301
+
302
+ // ---------------------------------------------------------------------------
303
+ // Configure Python ecosystem
304
+ // ---------------------------------------------------------------------------
305
+ async function configurePython(cwd, ask) {
306
+ let detected = detectPython(cwd)
307
+ let cmd, port, frameworkName
308
+
309
+ if (detected) {
310
+ frameworkName = detected.name
311
+ cmd = detected.cmd
312
+ port = detected.port
313
+
314
+ process.stdout.write(`\nDetected: ${BOLD}${detected.name}${RESET} on port ${CYAN}${port}${RESET}\n`)
315
+ process.stdout.write(`Command: ${DIM}${cmd}${RESET}\n\n`)
316
+ process.stdout.write(`Will add to Makefile:\n`)
317
+ process.stdout.write(` ${CYAN}dev-tunnel:${RESET}\n`)
318
+ process.stdout.write(` ${CYAN} mekong ${cmd} --port ${port}${RESET}\n\n`)
319
+
320
+ const answer = await ask(`Confirm? (Y/n): `)
321
+ if (answer.trim().toLowerCase() === 'n') {
322
+ const manual = await promptManual(ask)
323
+ cmd = manual.cmd
324
+ port = manual.port
325
+ frameworkName = 'custom'
326
+
327
+ process.stdout.write(`\nWill add to Makefile:\n`)
328
+ process.stdout.write(` ${CYAN}dev-tunnel:${RESET}\n`)
329
+ process.stdout.write(` ${CYAN} mekong ${cmd} --port ${port}${RESET}\n\n`)
330
+ const confirm2 = await ask(`Confirm? (Y/n): `)
331
+ if (confirm2.trim().toLowerCase() === 'n') {
332
+ process.stdout.write(`Skipped. No changes made.\n`)
333
+ return
334
+ }
335
+ }
336
+ } else {
337
+ process.stdout.write(`\n${YELLOW}No known Python framework detected.${RESET}\n`)
338
+ const manual = await promptManual(ask)
339
+ cmd = manual.cmd
340
+ port = manual.port
341
+ frameworkName = 'custom'
342
+
343
+ process.stdout.write(`\nWill add to Makefile:\n`)
344
+ process.stdout.write(` ${CYAN}dev-tunnel:${RESET}\n`)
345
+ process.stdout.write(` ${CYAN} mekong ${cmd} --port ${port}${RESET}\n\n`)
346
+ const confirm2 = await ask(`Confirm? (Y/n): `)
347
+ if (confirm2.trim().toLowerCase() === 'n') {
348
+ process.stdout.write(`Skipped. No changes made.\n`)
349
+ return
350
+ }
351
+ }
352
+
353
+ injectPython(cwd, cmd, port)
354
+
355
+ process.stdout.write(`\n${CHECK} Done! mekong-tunnel is set up for ${BOLD}${frameworkName}${RESET}\n\n`)
356
+ process.stdout.write(`Run your tunnel:\n`)
357
+ process.stdout.write(` ${CYAN}make dev-tunnel${RESET}\n`)
358
+ process.stdout.write(` (or) ${CYAN}mekong ${cmd} --port ${port}${RESET}\n\n`)
359
+ process.stdout.write(`Make sure mekong binary is installed:\n`)
360
+ process.stdout.write(` ${CYAN}https://github.com/MuyleangIng/MekongTunnel/releases/latest${RESET}\n`)
361
+ process.stdout.write(` ${CYAN}pip install mekong-tunnel${RESET}\n`)
362
+ }
363
+
364
+ // ---------------------------------------------------------------------------
365
+ // Main exported function
366
+ // ---------------------------------------------------------------------------
367
+ async function runInit() {
368
+ const cwd = process.cwd()
369
+
370
+ const hasPkg = fileExists(path.join(cwd, 'package.json'))
371
+ const hasPyProj = fileExists(path.join(cwd, 'pyproject.toml'))
372
+ const hasReqs = fileExists(path.join(cwd, 'requirements.txt'))
373
+ const hasManage = fileExists(path.join(cwd, 'manage.py'))
374
+ const hasPipfile = fileExists(path.join(cwd, 'Pipfile'))
375
+
376
+ const isNode = hasPkg
377
+ const isPython = hasPyProj || hasReqs || hasManage || hasPipfile
378
+
379
+ if (!isNode && !isPython) {
380
+ process.stderr.write(`${RED}No supported project found in current directory.${RESET}\n`)
381
+ process.exit(1)
382
+ }
383
+
384
+ const { ask, close } = await makeAsker()
385
+
386
+ try {
387
+ if (isNode && isPython) {
388
+ process.stdout.write(`\nBoth ${BOLD}Node.js${RESET} and ${BOLD}Python${RESET} projects detected.\n`)
389
+ const answer = await ask(`Configure which? (Node.js / Python / Both) [Node.js]: `)
390
+ const choice = answer.trim().toLowerCase()
391
+
392
+ if (choice === 'python') {
393
+ await configurePython(cwd, ask)
394
+ } else if (choice === 'both') {
395
+ await configureNode(cwd, ask)
396
+ await configurePython(cwd, ask)
397
+ } else {
398
+ await configureNode(cwd, ask)
399
+ }
400
+ } else if (isNode) {
401
+ await configureNode(cwd, ask)
402
+ } else {
403
+ await configurePython(cwd, ask)
404
+ }
405
+ } finally {
406
+ close()
407
+ }
408
+ }
409
+
410
+ module.exports = { runInit }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mekong-cli",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "Run your dev server + Mekong tunnel in one command",
5
5
  "bin": {
6
6
  "mekong-cli": "bin/mekong-cli.js"