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.
@@ -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
+ }