geniejars 0.2.7
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/COMMON-ISSUES.md +55 -0
- package/LICENSE.md +9 -0
- package/README.md +218 -0
- package/geniejars.config.example.json +21 -0
- package/package.json +22 -0
- package/script/geniejars.mjs +667 -0
- package/script/setup-geniejars.mjs +208 -0
- package/script/uninstall-geniejars.mjs +122 -0
- package/src/agent.mjs +194 -0
- package/src/allocate.mjs +96 -0
- package/src/index.mjs +5 -0
- package/src/normalize.mjs +26 -0
- package/src/pool.mjs +158 -0
- package/src/prepare.mjs +219 -0
- package/src/run.mjs +162 -0
- package/src/server.mjs +262 -0
|
@@ -0,0 +1,667 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Usage: geniejars <command> [subcommand] [options]
|
|
3
|
+
//
|
|
4
|
+
// Commands:
|
|
5
|
+
// preconfig create geniejars.config.json in current directory
|
|
6
|
+
// example install install hello world webserver example
|
|
7
|
+
// setup one-time system setup (run as root)
|
|
8
|
+
// uninstall remove all geniejars and system config (run as root)
|
|
9
|
+
// sys up start shell server
|
|
10
|
+
// sys down stop shell server and all agents
|
|
11
|
+
// sys status show running agents
|
|
12
|
+
// alloc [-s|--select] [--backup] [--no-clear] [--tag <tag>]
|
|
13
|
+
// free [name] uses selected if no name
|
|
14
|
+
// build [name] [folder|JFile] uses selected if no name
|
|
15
|
+
// app up [-n|--nowait] [name] start CMD, uses selected if no name
|
|
16
|
+
// app down [name] stop CMD, uses selected if no name
|
|
17
|
+
// exec [name] <cmd> [args...] run one-off command, uses selected if no name
|
|
18
|
+
// normalize [name] fix file permissions, uses selected if no name
|
|
19
|
+
// status [--json] show pool status
|
|
20
|
+
// mount [-C] [name] <path> symlink geniejars home, uses selected if no name
|
|
21
|
+
// mount -C <path> symlink commonDir
|
|
22
|
+
// unmount <path> remove symlink
|
|
23
|
+
// select <name> select geniejars for subsequent commands
|
|
24
|
+
// unselect clear selection
|
|
25
|
+
|
|
26
|
+
import net from 'net'
|
|
27
|
+
import { spawn, execFile } from 'child_process'
|
|
28
|
+
import { promisify } from 'util'
|
|
29
|
+
import fs from 'fs/promises'
|
|
30
|
+
import fsSync from 'fs'
|
|
31
|
+
import path from 'path'
|
|
32
|
+
import os from 'os'
|
|
33
|
+
import { fileURLToPath } from 'url'
|
|
34
|
+
import { loadConfig, readState, allNames, homeDir, resolveName } from '../src/pool.mjs'
|
|
35
|
+
import { allocate, deallocate } from '../src/allocate.mjs'
|
|
36
|
+
import { prepare } from '../src/prepare.mjs'
|
|
37
|
+
import { runAs, startAs, stopProcess, agentStatus, ensureAgent } from '../src/run.mjs'
|
|
38
|
+
import { normalize } from '../src/normalize.mjs'
|
|
39
|
+
|
|
40
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
41
|
+
const serverScript = path.resolve(__dirname, '../src/server.mjs')
|
|
42
|
+
const setupScript = path.resolve(__dirname, 'setup-geniejars.mjs')
|
|
43
|
+
const uninstallScript = path.resolve(__dirname, 'uninstall-geniejars.mjs')
|
|
44
|
+
|
|
45
|
+
// --- Selection ---
|
|
46
|
+
|
|
47
|
+
const SELECTED_FILE = path.resolve('.geniejars-selected')
|
|
48
|
+
|
|
49
|
+
function getSelected() {
|
|
50
|
+
if (process.env.GENIEJARS) return process.env.GENIEJARS.trim()
|
|
51
|
+
try { return fsSync.readFileSync(SELECTED_FILE, 'utf8').trim() || null } catch { return null }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function setSelected(name) {
|
|
55
|
+
await fs.writeFile(SELECTED_FILE, name + '\n')
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function clearSelected() {
|
|
59
|
+
await fs.unlink(SELECTED_FILE).catch(() => {})
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// --- Helpers ---
|
|
63
|
+
|
|
64
|
+
function die(msg) {
|
|
65
|
+
console.error('Error:', msg)
|
|
66
|
+
process.exit(1)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function requireName(arg, state) {
|
|
70
|
+
const raw = arg ?? getSelected()
|
|
71
|
+
if (!raw) die('no geniejars specified and none selected (use: geniejars select <name>)')
|
|
72
|
+
return resolveName(state, raw)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// --- Server helpers ---
|
|
76
|
+
|
|
77
|
+
function serverSockPath(config) {
|
|
78
|
+
return path.join(config.socketDir, 'server.sock')
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function serverPidPath(config) {
|
|
82
|
+
return path.join(config.socketDir, 'server.pid')
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function isRunning(pid) {
|
|
86
|
+
try { process.kill(pid, 0); return true } catch { return false }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Send one request to the shell server, return response
|
|
90
|
+
function serverRequest(config, req) {
|
|
91
|
+
return new Promise((resolve, reject) => {
|
|
92
|
+
const client = net.createConnection(serverSockPath(config))
|
|
93
|
+
let buf = ''
|
|
94
|
+
client.on('connect', () => { client.write(JSON.stringify(req) + '\n') })
|
|
95
|
+
client.on('data', chunk => {
|
|
96
|
+
buf += chunk.toString()
|
|
97
|
+
if (buf.includes('\n')) {
|
|
98
|
+
try {
|
|
99
|
+
const msg = JSON.parse(buf.split('\n')[0].trim())
|
|
100
|
+
client.destroy()
|
|
101
|
+
if (msg.type === 'error') reject(new Error(msg.message))
|
|
102
|
+
else resolve(msg)
|
|
103
|
+
} catch {}
|
|
104
|
+
}
|
|
105
|
+
})
|
|
106
|
+
client.on('error', err => reject(new Error(`Shell server not running: ${err.message}\nRun: geniejars sys up`)))
|
|
107
|
+
client.on('close', () => reject(new Error('No response from shell server')))
|
|
108
|
+
})
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// --- Command dispatch ---
|
|
112
|
+
|
|
113
|
+
const argv = process.argv.slice(2)
|
|
114
|
+
const [cmd, ...args] = argv
|
|
115
|
+
|
|
116
|
+
function showHelp() {
|
|
117
|
+
console.log(`Usage: geniejars <command> [options]
|
|
118
|
+
|
|
119
|
+
Commands:
|
|
120
|
+
preconfig create geniejars.config.json in current directory
|
|
121
|
+
example install install hello world webserver example
|
|
122
|
+
setup one-time system setup (run as root)
|
|
123
|
+
uninstall remove all geniejars and system config (run as root)
|
|
124
|
+
sys up start shell server
|
|
125
|
+
sys down stop shell server and all agents
|
|
126
|
+
sys status show running agents
|
|
127
|
+
alloc [-s|--select] [--backup] [--no-clear] [--tag <tag>]
|
|
128
|
+
free [name] uses selected if no name
|
|
129
|
+
build [name] [folder|JFile] uses selected if no name
|
|
130
|
+
app up [-n|--nowait] [name] start CMD, uses selected if no name
|
|
131
|
+
app down [name] stop CMD, uses selected if no name
|
|
132
|
+
exec [name] <cmd> [args...] run one-off command, uses selected if no name
|
|
133
|
+
normalize [name] fix file permissions, uses selected if no name
|
|
134
|
+
status [--json] show pool status
|
|
135
|
+
mount [name] <path> symlink geniejars home, uses selected if no name
|
|
136
|
+
mount -C <path> symlink commonDir
|
|
137
|
+
unmount <path> remove symlink
|
|
138
|
+
select <name> select geniejars for subsequent commands
|
|
139
|
+
unselect clear selection
|
|
140
|
+
readme show the README
|
|
141
|
+
common-issues show common issues and solutions`)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h') {
|
|
145
|
+
showHelp()
|
|
146
|
+
process.exit(0)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (cmd === 'example') {
|
|
150
|
+
const [subcmd] = args
|
|
151
|
+
if (subcmd !== 'install') die('Usage: geniejars example install')
|
|
152
|
+
const dir = path.resolve('geniejars-example')
|
|
153
|
+
try {
|
|
154
|
+
await fs.access(dir)
|
|
155
|
+
console.error('geniejars-example/ already exists — remove it first if you want to reinstall')
|
|
156
|
+
process.exit(1)
|
|
157
|
+
} catch {}
|
|
158
|
+
await fs.mkdir(dir)
|
|
159
|
+
await fs.mkdir(path.join(dir, 'helloworld'))
|
|
160
|
+
|
|
161
|
+
// --- helloworld/server.mjs ---
|
|
162
|
+
await fs.writeFile(path.join(dir, 'helloworld', 'server.mjs'), `\
|
|
163
|
+
import http from 'http'
|
|
164
|
+
import os from 'os'
|
|
165
|
+
|
|
166
|
+
const port = parseInt(process.env.PORT_1 || '3000')
|
|
167
|
+
const user = process.env.USER || 'unknown'
|
|
168
|
+
const started = new Date().toISOString()
|
|
169
|
+
|
|
170
|
+
function log(msg) { console.log(\`[\${new Date().toISOString()}] \${msg}\`) }
|
|
171
|
+
|
|
172
|
+
log(\`starting — user: \${user} host: \${os.hostname()} port: \${port}\`)
|
|
173
|
+
|
|
174
|
+
const heartbeat = setInterval(() => log(\`heartbeat — uptime \${process.uptime().toFixed(1)}s\`), 3000)
|
|
175
|
+
heartbeat.unref()
|
|
176
|
+
|
|
177
|
+
const html = \`<!DOCTYPE html>
|
|
178
|
+
<html lang="en">
|
|
179
|
+
<head>
|
|
180
|
+
<meta charset="UTF-8">
|
|
181
|
+
<title>geniejars example</title>
|
|
182
|
+
<style>
|
|
183
|
+
body { font-family: monospace; max-width: 600px; margin: 80px auto; padding: 0 20px; color: #1a1a1a; }
|
|
184
|
+
h1 { font-size: 1.4rem; margin-bottom: 0.2em; }
|
|
185
|
+
.tag { display: inline-block; background: #e8f5e9; color: #2e7d32; padding: 2px 8px; border-radius: 3px; font-size: 0.85rem; }
|
|
186
|
+
table { margin-top: 2em; border-collapse: collapse; width: 100%; }
|
|
187
|
+
td { padding: 6px 12px; border-bottom: 1px solid #eee; }
|
|
188
|
+
td:first-child { color: #888; width: 120px; }
|
|
189
|
+
footer { margin-top: 3em; font-size: 0.8rem; color: #aaa; }
|
|
190
|
+
</style>
|
|
191
|
+
</head>
|
|
192
|
+
<body>
|
|
193
|
+
<h1>geniejars example server</h1>
|
|
194
|
+
<span class="tag">running</span>
|
|
195
|
+
<table>
|
|
196
|
+
<tr><td>user</td><td>\${user}</td></tr>
|
|
197
|
+
<tr><td>port</td><td>\${port}</td></tr>
|
|
198
|
+
<tr><td>host</td><td>\${os.hostname()}</td></tr>
|
|
199
|
+
<tr><td>started</td><td>\${started}</td></tr>
|
|
200
|
+
<tr><td>uptime</td><td>\${process.uptime().toFixed(0)}s</td></tr>
|
|
201
|
+
</table>
|
|
202
|
+
<footer>This process is isolated inside its own Linux user account.<br>Powered by <strong>geniejars</strong>.</footer>
|
|
203
|
+
</body>
|
|
204
|
+
</html>\`
|
|
205
|
+
|
|
206
|
+
const server = http.createServer((req, res) => {
|
|
207
|
+
log(\`\${req.method} \${req.url} from \${req.socket.remoteAddress}\`)
|
|
208
|
+
res.writeHead(200, { 'Content-Type': 'text/html' })
|
|
209
|
+
res.end(html)
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
server.listen(port, () => log(\`listening on http://localhost:\${port}\`))
|
|
213
|
+
|
|
214
|
+
process.on('SIGTERM', () => {
|
|
215
|
+
log('received SIGTERM — shutting down')
|
|
216
|
+
clearInterval(heartbeat)
|
|
217
|
+
server.close()
|
|
218
|
+
})
|
|
219
|
+
`)
|
|
220
|
+
|
|
221
|
+
// --- helloworld/JFile ---
|
|
222
|
+
await fs.writeFile(path.join(dir, 'helloworld', 'JFile'), `\
|
|
223
|
+
TAG helloworld
|
|
224
|
+
COPY server.mjs server.mjs
|
|
225
|
+
CMD node server.mjs
|
|
226
|
+
`)
|
|
227
|
+
|
|
228
|
+
// --- libcall.mjs ---
|
|
229
|
+
await fs.writeFile(path.join(dir, 'libcall.mjs'), `\
|
|
230
|
+
#!/usr/bin/env node
|
|
231
|
+
// geniejars libcall example
|
|
232
|
+
//
|
|
233
|
+
// Allocates a geniejars, builds and runs the helloworld webserver for 15 seconds,
|
|
234
|
+
// then stops it and prints the captured log.
|
|
235
|
+
//
|
|
236
|
+
// Run from your project directory:
|
|
237
|
+
// node geniejars-example/libcall.mjs
|
|
238
|
+
|
|
239
|
+
import fs from 'fs/promises'
|
|
240
|
+
import path from 'path'
|
|
241
|
+
import { fileURLToPath } from 'url'
|
|
242
|
+
import * as geniejars from 'geniejars'
|
|
243
|
+
|
|
244
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
245
|
+
const subFile = path.join(__dirname, 'helloworld', 'JFile')
|
|
246
|
+
|
|
247
|
+
const config = await geniejars.loadConfig()
|
|
248
|
+
|
|
249
|
+
console.log('=== geniejars libcall example ===')
|
|
250
|
+
console.log()
|
|
251
|
+
|
|
252
|
+
let name
|
|
253
|
+
try {
|
|
254
|
+
process.stdout.write('Allocating geniejars... ')
|
|
255
|
+
name = await geniejars.allocate(config, { tag: 'libcall' })
|
|
256
|
+
console.log(name)
|
|
257
|
+
|
|
258
|
+
process.stdout.write('Building helloworld... ')
|
|
259
|
+
const prepared = await geniejars.prepare(config, name, subFile)
|
|
260
|
+
console.log('done')
|
|
261
|
+
|
|
262
|
+
process.stdout.write('Starting agent... ')
|
|
263
|
+
await geniejars.ensureAgent(config, name)
|
|
264
|
+
console.log('ready')
|
|
265
|
+
|
|
266
|
+
// Build port env from allocated ports
|
|
267
|
+
const state = await geniejars.readState(config)
|
|
268
|
+
const ports = state.pool[name]?.ports ?? []
|
|
269
|
+
const portEnv = Object.fromEntries(ports.map((p, i) => [\`PORT_\${i + 1}\`, String(p)]))
|
|
270
|
+
|
|
271
|
+
const logFile = path.join(config.logDir ?? '/tmp', \`libcall-\${name}.log\`)
|
|
272
|
+
await fs.rm(logFile, { force: true })
|
|
273
|
+
|
|
274
|
+
const [cmd, ...args] = prepared.cmd
|
|
275
|
+
console.log(\`Starting app... (log: \${logFile})\`)
|
|
276
|
+
if (ports.length > 0) console.log(\` curl http://localhost:\${ports[0]}\`)
|
|
277
|
+
console.log()
|
|
278
|
+
|
|
279
|
+
const stopTimer = setTimeout(() => geniejars.stopProcess(config, name).catch(() => {}), 15000)
|
|
280
|
+
|
|
281
|
+
await geniejars.startAs(config, name, cmd, args, {
|
|
282
|
+
cwd: prepared.workdir,
|
|
283
|
+
env: { ...prepared.env, ...portEnv },
|
|
284
|
+
wait: true,
|
|
285
|
+
logFile,
|
|
286
|
+
onStarted: pid => console.log(\` [pid \${pid}] started — running for 15 seconds...\`),
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
clearTimeout(stopTimer)
|
|
290
|
+
console.log()
|
|
291
|
+
console.log(\`--- captured log (\${logFile}) ---\`)
|
|
292
|
+
const log = await fs.readFile(logFile, 'utf8').catch(() => '(no log captured)')
|
|
293
|
+
console.log(log.trimEnd())
|
|
294
|
+
console.log('--- end of log ---')
|
|
295
|
+
console.log()
|
|
296
|
+
|
|
297
|
+
} finally {
|
|
298
|
+
if (name) {
|
|
299
|
+
process.stdout.write('Deallocating... ')
|
|
300
|
+
await geniejars.deallocate(config, name).catch(() => {})
|
|
301
|
+
console.log('done')
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
`)
|
|
305
|
+
|
|
306
|
+
console.log('Examples installed in ./geniejars-example/')
|
|
307
|
+
console.log()
|
|
308
|
+
console.log('helloworld — simple isolated webserver:')
|
|
309
|
+
console.log(' geniejars alloc -s')
|
|
310
|
+
console.log(' geniejars build ./geniejars-example/helloworld')
|
|
311
|
+
console.log(' geniejars app up')
|
|
312
|
+
console.log()
|
|
313
|
+
console.log('libcall — library usage demo:')
|
|
314
|
+
console.log(' node geniejars-example/libcall.mjs')
|
|
315
|
+
process.exit(0)
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
if (cmd === 'preconfig') {
|
|
319
|
+
const dest = path.resolve('geniejars.config.json')
|
|
320
|
+
const src = path.resolve(__dirname, '../geniejars.config.example.json')
|
|
321
|
+
try {
|
|
322
|
+
await fs.access(dest)
|
|
323
|
+
console.error('geniejars.config.json already exists — remove it first if you want to reset')
|
|
324
|
+
process.exit(1)
|
|
325
|
+
} catch {}
|
|
326
|
+
await fs.copyFile(src, dest)
|
|
327
|
+
console.log(`geniejars.config.json created in ${process.cwd()}`)
|
|
328
|
+
console.log('Edit it to match your system, then run: sudo geniejars setup')
|
|
329
|
+
process.exit(0)
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
process.on('uncaughtException', err => {
|
|
333
|
+
console.error('Error:', err.message)
|
|
334
|
+
process.exit(1)
|
|
335
|
+
})
|
|
336
|
+
|
|
337
|
+
process.on('unhandledRejection', err => {
|
|
338
|
+
console.error('Error:', err?.message ?? err)
|
|
339
|
+
process.exit(1)
|
|
340
|
+
})
|
|
341
|
+
|
|
342
|
+
const config = await loadConfig()
|
|
343
|
+
|
|
344
|
+
// setup
|
|
345
|
+
if (cmd === 'setup') {
|
|
346
|
+
const child = spawn(config.nodePath ?? process.execPath, [setupScript], { stdio: 'inherit' })
|
|
347
|
+
child.on('exit', code => process.exit(code ?? 0))
|
|
348
|
+
|
|
349
|
+
// uninstall
|
|
350
|
+
} else if (cmd === 'uninstall') {
|
|
351
|
+
const child = spawn(config.nodePath ?? process.execPath, [uninstallScript], { stdio: 'inherit' })
|
|
352
|
+
child.on('exit', code => process.exit(code ?? 0))
|
|
353
|
+
|
|
354
|
+
// sys
|
|
355
|
+
} else if (cmd === 'sys') {
|
|
356
|
+
const [subcmd] = args
|
|
357
|
+
if (!subcmd || !['up', 'down', 'status'].includes(subcmd)) {
|
|
358
|
+
die('Usage: geniejars sys <up|down|status>')
|
|
359
|
+
}
|
|
360
|
+
await fs.mkdir(config.socketDir, { recursive: true })
|
|
361
|
+
|
|
362
|
+
if (subcmd === 'up') {
|
|
363
|
+
// Check that all required groups are active in this session
|
|
364
|
+
const execFileAsync = promisify(execFile)
|
|
365
|
+
const activeGids = new Set(process.getgroups())
|
|
366
|
+
const requiredGroups = [config.managerGroup, ...allNames(config)]
|
|
367
|
+
const missingGroups = []
|
|
368
|
+
for (const group of requiredGroups) {
|
|
369
|
+
try {
|
|
370
|
+
const { stdout } = await execFileAsync('getent', ['group', group])
|
|
371
|
+
const gid = parseInt(stdout.split(':')[2], 10)
|
|
372
|
+
if (!activeGids.has(gid)) missingGroups.push(group)
|
|
373
|
+
} catch {
|
|
374
|
+
missingGroups.push(group)
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
if (missingGroups.length > 0) {
|
|
378
|
+
console.error(`Error: missing active group membership for: ${missingGroups.join(', ')}`)
|
|
379
|
+
console.error(`Group membership changes require a new login session.`)
|
|
380
|
+
console.error(`Run: su ${config.managerUser} (or log out and back in)`)
|
|
381
|
+
process.exit(1)
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const pidPath = serverPidPath(config)
|
|
385
|
+
try {
|
|
386
|
+
const existing = parseInt(await fs.readFile(pidPath, 'utf8'), 10)
|
|
387
|
+
if (existing && isRunning(existing)) {
|
|
388
|
+
console.log(`Server already running (pid ${existing})`)
|
|
389
|
+
process.exit(0)
|
|
390
|
+
}
|
|
391
|
+
} catch {}
|
|
392
|
+
const child = spawn(config.nodePath ?? process.execPath, [serverScript], {
|
|
393
|
+
detached: true,
|
|
394
|
+
stdio: ['ignore', 'ignore', 'ignore'],
|
|
395
|
+
})
|
|
396
|
+
child.unref()
|
|
397
|
+
await fs.writeFile(pidPath, String(child.pid))
|
|
398
|
+
console.log(`Server started (pid ${child.pid})`)
|
|
399
|
+
|
|
400
|
+
} else if (subcmd === 'down') {
|
|
401
|
+
try {
|
|
402
|
+
await serverRequest(config, { type: 'shutdown' })
|
|
403
|
+
await fs.unlink(serverPidPath(config)).catch(() => {})
|
|
404
|
+
console.log('Server shutting down')
|
|
405
|
+
} catch (err) { die(err.message) }
|
|
406
|
+
|
|
407
|
+
} else if (subcmd === 'status') {
|
|
408
|
+
try {
|
|
409
|
+
const resp = await serverRequest(config, { type: 'status' })
|
|
410
|
+
if (resp.agents.length === 0) {
|
|
411
|
+
console.log('No agents running')
|
|
412
|
+
} else {
|
|
413
|
+
for (const { name, pid } of resp.agents) console.log(`${name}: running (pid ${pid})`)
|
|
414
|
+
}
|
|
415
|
+
} catch (err) { die(err.message) }
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// alloc
|
|
419
|
+
} else if (cmd === 'alloc') {
|
|
420
|
+
const backup = args.includes('--backup')
|
|
421
|
+
const clear = !args.includes('--no-clear')
|
|
422
|
+
const select = args.includes('--select') || args.includes('-s')
|
|
423
|
+
let tag = null
|
|
424
|
+
const tagIdx = args.indexOf('--tag')
|
|
425
|
+
if (tagIdx !== -1) {
|
|
426
|
+
tag = args[tagIdx + 1]
|
|
427
|
+
if (!tag) die('--tag requires a value')
|
|
428
|
+
}
|
|
429
|
+
try {
|
|
430
|
+
const name = await allocate(config, { backup, clear, tag })
|
|
431
|
+
if (select) await setSelected(name)
|
|
432
|
+
console.log(name)
|
|
433
|
+
} catch (err) { die(err.message) }
|
|
434
|
+
|
|
435
|
+
// free
|
|
436
|
+
} else if (cmd === 'free') {
|
|
437
|
+
const state = await readState(config)
|
|
438
|
+
const name = requireName(args[0], state)
|
|
439
|
+
try {
|
|
440
|
+
await deallocate(config, name)
|
|
441
|
+
if (getSelected() === name) await clearSelected()
|
|
442
|
+
console.log(`Released: ${name}`)
|
|
443
|
+
} catch (err) { die(err.message) }
|
|
444
|
+
|
|
445
|
+
// build
|
|
446
|
+
} else if (cmd === 'build') {
|
|
447
|
+
const selected = getSelected()
|
|
448
|
+
let name, subFile
|
|
449
|
+
if (selected) {
|
|
450
|
+
name = resolveName(await readState(config), selected)
|
|
451
|
+
const arg = args[0] ?? 'JFile'
|
|
452
|
+
const argStat = await fs.stat(path.resolve(arg)).catch(() => null)
|
|
453
|
+
subFile = argStat?.isDirectory() ? path.join(arg, 'JFile') : arg
|
|
454
|
+
} else {
|
|
455
|
+
if (!args[0]) die('Usage: geniejars build <name> [folder|JFile]')
|
|
456
|
+
const state = await readState(config)
|
|
457
|
+
name = resolveName(state, args[0])
|
|
458
|
+
const arg = args[1] ?? 'JFile'
|
|
459
|
+
const argStat = await fs.stat(path.resolve(arg)).catch(() => null)
|
|
460
|
+
subFile = argStat?.isDirectory() ? path.join(arg, 'JFile') : arg
|
|
461
|
+
}
|
|
462
|
+
try {
|
|
463
|
+
await prepare(config, name, subFile)
|
|
464
|
+
const fresh = await readState(config)
|
|
465
|
+
const alias = fresh.pool[name]?.alias
|
|
466
|
+
console.log(`Built: ${name}${alias ? ` (${alias})` : ''}`)
|
|
467
|
+
} catch (err) { die(err.message) }
|
|
468
|
+
|
|
469
|
+
// app
|
|
470
|
+
} else if (cmd === 'app') {
|
|
471
|
+
const flags = args.filter(a => a.startsWith('-'))
|
|
472
|
+
const positional = args.filter(a => !a.startsWith('-'))
|
|
473
|
+
const [subcmd, nameOrAlias] = positional
|
|
474
|
+
|
|
475
|
+
if (!subcmd || !['up', 'down'].includes(subcmd)) die('Usage: geniejars app <up|down> [name]')
|
|
476
|
+
|
|
477
|
+
const state = await readState(config)
|
|
478
|
+
const name = requireName(nameOrAlias, state)
|
|
479
|
+
const entry = state.pool[name]
|
|
480
|
+
|
|
481
|
+
if (subcmd === 'up') {
|
|
482
|
+
if (!entry.prepared?.cmd) die('No CMD found — run: geniejars build first')
|
|
483
|
+
const [appCmd, ...cmdArgs] = entry.prepared.cmd
|
|
484
|
+
const cwd = entry.prepared.workdir ?? homeDir(config, name)
|
|
485
|
+
const portEnv = {}
|
|
486
|
+
if (entry.ports) entry.ports.forEach((port, i) => { portEnv[`PORT_${i + 1}`] = String(port) })
|
|
487
|
+
const addedPaths = entry.prepared.addedPaths ?? []
|
|
488
|
+
const basePath = process.env.PATH || '/usr/local/bin:/usr/bin:/bin'
|
|
489
|
+
const pathEnv = addedPaths.length > 0 ? { PATH: addedPaths.join(':') + ':' + basePath } : {}
|
|
490
|
+
const env = { ...portEnv, ...pathEnv, ...(entry.prepared.env ?? {}) }
|
|
491
|
+
const wait = !flags.includes('--nowait') && !flags.includes('-n')
|
|
492
|
+
|
|
493
|
+
await ensureAgent(config, name)
|
|
494
|
+
|
|
495
|
+
if (wait) {
|
|
496
|
+
process.on('SIGINT', async () => { await stopProcess(config, name).catch(() => {}); process.exit(0) })
|
|
497
|
+
}
|
|
498
|
+
try {
|
|
499
|
+
const result = await startAs(config, name, appCmd, cmdArgs, {
|
|
500
|
+
cwd, env, wait,
|
|
501
|
+
onStarted: pid => console.error(`[geniejars] ${name} started (pid ${pid})`),
|
|
502
|
+
})
|
|
503
|
+
if (!wait) console.log(name)
|
|
504
|
+
else process.exit(result.exitCode ?? 0)
|
|
505
|
+
} catch (err) { die(err.message) }
|
|
506
|
+
|
|
507
|
+
} else if (subcmd === 'down') {
|
|
508
|
+
try {
|
|
509
|
+
await stopProcess(config, name)
|
|
510
|
+
console.log(`Stopped: ${name}`)
|
|
511
|
+
} catch (err) { die(err.message) }
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// exec
|
|
515
|
+
} else if (cmd === 'exec') {
|
|
516
|
+
const selected = getSelected()
|
|
517
|
+
let name, execCmd, execArgs
|
|
518
|
+
if (selected) {
|
|
519
|
+
const state = await readState(config)
|
|
520
|
+
name = resolveName(state, selected)
|
|
521
|
+
;[execCmd, ...execArgs] = args
|
|
522
|
+
} else {
|
|
523
|
+
const [nameOrAlias, c, ...rest] = args
|
|
524
|
+
if (!nameOrAlias || !c) die('Usage: geniejars exec [name] <cmd> [args...]')
|
|
525
|
+
const state = await readState(config)
|
|
526
|
+
name = resolveName(state, nameOrAlias)
|
|
527
|
+
execCmd = c
|
|
528
|
+
execArgs = rest
|
|
529
|
+
}
|
|
530
|
+
if (!execCmd) die('No command specified')
|
|
531
|
+
// Single argument with no sub-args — run through shell so redirects, pipes, etc. work
|
|
532
|
+
if (execArgs.length === 0) { execArgs = ['-c', execCmd]; execCmd = 'sh' }
|
|
533
|
+
await ensureAgent(config, name)
|
|
534
|
+
try {
|
|
535
|
+
const result = await runAs(config, name, execCmd, execArgs)
|
|
536
|
+
process.exit(result.exitCode ?? 0)
|
|
537
|
+
} catch (err) { die(err.message) }
|
|
538
|
+
|
|
539
|
+
// normalize
|
|
540
|
+
} else if (cmd === 'normalize') {
|
|
541
|
+
const state = await readState(config)
|
|
542
|
+
const name = requireName(args[0], state)
|
|
543
|
+
try {
|
|
544
|
+
await normalize(config, name)
|
|
545
|
+
console.log(`Normalized: ${name}`)
|
|
546
|
+
} catch (err) { die(err.message) }
|
|
547
|
+
|
|
548
|
+
// status
|
|
549
|
+
} else if (cmd === 'status') {
|
|
550
|
+
const jsonMode = args.includes('--json')
|
|
551
|
+
const state = await readState(config)
|
|
552
|
+
if (jsonMode) { console.log(JSON.stringify(state, null, 2)); process.exit(0) }
|
|
553
|
+
const names = allNames(config)
|
|
554
|
+
const agentResults = await Promise.all(
|
|
555
|
+
names.map(async name => {
|
|
556
|
+
const entry = state.pool[name]
|
|
557
|
+
if (entry?.status !== 'occupied') return { name, running: false, pid: null }
|
|
558
|
+
try {
|
|
559
|
+
const s = await agentStatus(config, name)
|
|
560
|
+
return { name, running: s.running, pid: s.pid }
|
|
561
|
+
} catch { return { name, running: false, pid: null } }
|
|
562
|
+
})
|
|
563
|
+
)
|
|
564
|
+
const agentMap = Object.fromEntries(agentResults.map(r => [r.name, r]))
|
|
565
|
+
const rows = names.map(name => {
|
|
566
|
+
const entry = state.pool[name] ?? { status: 'unknown', allocatedAt: null, alias: null }
|
|
567
|
+
const agent = agentMap[name]
|
|
568
|
+
return {
|
|
569
|
+
name,
|
|
570
|
+
alias: entry.alias ?? '-',
|
|
571
|
+
status: entry.status,
|
|
572
|
+
process: entry.status === 'occupied'
|
|
573
|
+
? (agent.running ? `running (pid ${agent.pid})` : 'stopped')
|
|
574
|
+
: '-',
|
|
575
|
+
allocatedAt: entry.allocatedAt ?? '-',
|
|
576
|
+
}
|
|
577
|
+
})
|
|
578
|
+
const cols = {
|
|
579
|
+
name: Math.max(4, ...rows.map(r => r.name.length)),
|
|
580
|
+
alias: Math.max(5, ...rows.map(r => r.alias.length)),
|
|
581
|
+
status: Math.max(6, ...rows.map(r => r.status.length)),
|
|
582
|
+
process: Math.max(7, ...rows.map(r => r.process.length)),
|
|
583
|
+
allocatedAt: Math.max(12, ...rows.map(r => r.allocatedAt.length)),
|
|
584
|
+
}
|
|
585
|
+
const fmt = r =>
|
|
586
|
+
r.name.padEnd(cols.name) + ' ' + r.alias.padEnd(cols.alias) + ' ' +
|
|
587
|
+
r.status.padEnd(cols.status) + ' ' + r.process.padEnd(cols.process) + ' ' + r.allocatedAt
|
|
588
|
+
console.log(
|
|
589
|
+
'NAME'.padEnd(cols.name) + ' ' + 'ALIAS'.padEnd(cols.alias) + ' ' +
|
|
590
|
+
'STATUS'.padEnd(cols.status) + ' ' + 'PROCESS'.padEnd(cols.process) + ' ' + 'ALLOCATED AT'
|
|
591
|
+
)
|
|
592
|
+
console.log('-'.repeat(cols.name + cols.alias + cols.status + cols.process + cols.allocatedAt + 8))
|
|
593
|
+
for (const row of rows) console.log(fmt(row))
|
|
594
|
+
const selected = getSelected()
|
|
595
|
+
console.log()
|
|
596
|
+
console.log(selected ? `Selected: ${selected}` : 'Selected: (none)')
|
|
597
|
+
|
|
598
|
+
// mount
|
|
599
|
+
} else if (cmd === 'mount') {
|
|
600
|
+
const isCommon = args[0] === '-C'
|
|
601
|
+
let target, linkPath
|
|
602
|
+
if (isCommon) {
|
|
603
|
+
linkPath = args[1]
|
|
604
|
+
if (!linkPath) die('Usage: geniejars mount -C <linkpath>')
|
|
605
|
+
if (!config.commonDir) die('commonDir not set in config')
|
|
606
|
+
target = config.commonDir
|
|
607
|
+
} else {
|
|
608
|
+
const selected = getSelected()
|
|
609
|
+
let nameOrAlias, lp
|
|
610
|
+
if (selected && args.length === 1) {
|
|
611
|
+
nameOrAlias = selected
|
|
612
|
+
lp = args[0]
|
|
613
|
+
} else {
|
|
614
|
+
;[nameOrAlias, lp] = args
|
|
615
|
+
}
|
|
616
|
+
if (!nameOrAlias || !lp) die('Usage: geniejars mount [name] <linkpath>')
|
|
617
|
+
const state = await readState(config)
|
|
618
|
+
const name = resolveName(state, nameOrAlias)
|
|
619
|
+
target = homeDir(config, name)
|
|
620
|
+
linkPath = lp
|
|
621
|
+
}
|
|
622
|
+
const link = path.resolve(linkPath)
|
|
623
|
+
try {
|
|
624
|
+
await fs.symlink(target, link)
|
|
625
|
+
console.log(`Mounted: ${link} -> ${target}`)
|
|
626
|
+
} catch (err) { die(err.message) }
|
|
627
|
+
|
|
628
|
+
// unmount
|
|
629
|
+
} else if (cmd === 'unmount') {
|
|
630
|
+
const [linkPath] = args
|
|
631
|
+
if (!linkPath) die('Usage: geniejars unmount <linkpath>')
|
|
632
|
+
const link = path.resolve(linkPath)
|
|
633
|
+
try {
|
|
634
|
+
const stat = await fs.lstat(link)
|
|
635
|
+
if (!stat.isSymbolicLink()) die(`${link} is not a symlink`)
|
|
636
|
+
await fs.unlink(link)
|
|
637
|
+
console.log(`Unmounted: ${link}`)
|
|
638
|
+
} catch (err) { die(err.message) }
|
|
639
|
+
|
|
640
|
+
// select
|
|
641
|
+
} else if (cmd === 'select') {
|
|
642
|
+
const [nameOrAlias] = args
|
|
643
|
+
if (!nameOrAlias) die('Usage: geniejars select <name|alias>')
|
|
644
|
+
const state = await readState(config)
|
|
645
|
+
const name = resolveName(state, nameOrAlias)
|
|
646
|
+
if (state.pool[name]?.status !== 'occupied') die(`${name} is not allocated`)
|
|
647
|
+
await setSelected(name)
|
|
648
|
+
console.log(`Selected: ${name}`)
|
|
649
|
+
|
|
650
|
+
// unselect
|
|
651
|
+
} else if (cmd === 'unselect') {
|
|
652
|
+
await clearSelected()
|
|
653
|
+
console.log('Selection cleared')
|
|
654
|
+
|
|
655
|
+
} else if (cmd === 'readme') {
|
|
656
|
+
const readmePath = path.resolve(__dirname, '../README.md')
|
|
657
|
+
const child = spawn('more', [readmePath], { stdio: 'inherit' })
|
|
658
|
+
child.on('exit', code => process.exit(code ?? 0))
|
|
659
|
+
|
|
660
|
+
} else if (cmd === 'common-issues') {
|
|
661
|
+
const issuePath = path.resolve(__dirname, '../COMMON-ISSUES.md')
|
|
662
|
+
const child = spawn('more', [issuePath], { stdio: 'inherit' })
|
|
663
|
+
child.on('exit', code => process.exit(code ?? 0))
|
|
664
|
+
|
|
665
|
+
} else {
|
|
666
|
+
die(`Unknown command: ${cmd}. Run: geniejars help`)
|
|
667
|
+
}
|