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/src/pool.mjs ADDED
@@ -0,0 +1,158 @@
1
+ import fs from 'fs/promises'
2
+ import { createWriteStream } from 'fs'
3
+ import path from 'path'
4
+
5
+ const LOCK_RETRIES = 20
6
+ const LOCK_DELAY_BASE_MS = 50
7
+
8
+ // Load geniejars.config.json. Resolution order:
9
+ // 1. explicit configPath argument
10
+ // 2. $GENIEJARS_HOME/geniejars.config.json
11
+ // 3. ./geniejars.config.json (cwd — works when running from project dir, including via sudo)
12
+ // 4. error
13
+ export async function loadConfig(configPath) {
14
+ let p
15
+ if (configPath) {
16
+ p = path.resolve(configPath)
17
+ } else if (process.env.GENIEJARS_HOME) {
18
+ p = path.join(process.env.GENIEJARS_HOME, 'geniejars.config.json')
19
+ } else {
20
+ const cwd = path.join(process.cwd(), 'geniejars.config.json')
21
+ try { await fs.access(cwd); p = cwd } catch {
22
+ throw new Error(
23
+ 'No config found. Set GENIEJARS_HOME to your project directory, or run from the project root.'
24
+ )
25
+ }
26
+ }
27
+ const text = await fs.readFile(p, 'utf8')
28
+ const config = JSON.parse(text)
29
+ if (!config.nodePath) config.nodePath = process.execPath
30
+ return config
31
+ }
32
+
33
+ // Derive canonical geniejars name. n is 1-indexed.
34
+ export function poolName(config, n) {
35
+ return config.poolPrefix + String(n).padStart(config.poolPadding, '0')
36
+ }
37
+
38
+ // All names in the pool, in order.
39
+ export function allNames(config) {
40
+ const names = []
41
+ for (let i = 1; i <= config.poolSize; i++) {
42
+ names.push(poolName(config, i))
43
+ }
44
+ return names
45
+ }
46
+
47
+ // Home directory for a geniejars.
48
+ export function homeDir(config, name) {
49
+ return path.join(config.homeBase, name)
50
+ }
51
+
52
+ // Build a fresh state with all geniejars and ports free.
53
+ export function initialState(config) {
54
+ const pool = {}
55
+ for (const name of allNames(config)) {
56
+ pool[name] = { status: 'free', allocatedAt: null, allocatedBy: null, tag: null, ports: null, prepared: null }
57
+ }
58
+ const ports = {}
59
+ for (let i = 0; i < config.portCount; i++) {
60
+ ports[config.portBase + i] = 'free'
61
+ }
62
+ return { version: 1, updated: new Date().toISOString(), pool, ports }
63
+ }
64
+
65
+ // Read state file. Returns initialState if the file does not yet exist.
66
+ // Migrates missing ports section if state predates port support.
67
+ export async function readState(config) {
68
+ try {
69
+ const text = await fs.readFile(config.stateFile, 'utf8')
70
+ const state = JSON.parse(text)
71
+ if (!state.ports) {
72
+ const fresh = initialState(config)
73
+ state.ports = fresh.ports
74
+ }
75
+ return state
76
+ } catch (err) {
77
+ if (err.code === 'ENOENT') return initialState(config)
78
+ throw err
79
+ }
80
+ }
81
+
82
+ // Write state file atomically via a tmp-then-rename.
83
+ // Caller must hold the lock.
84
+ export async function writeState(config, state) {
85
+ state.updated = new Date().toISOString()
86
+ const tmp = config.stateFile + '.tmp'
87
+ const dir = path.dirname(config.stateFile)
88
+ await fs.mkdir(dir, { recursive: true })
89
+ await fs.writeFile(tmp, JSON.stringify(state, null, 2), 'utf8')
90
+ await fs.rename(tmp, config.stateFile)
91
+ }
92
+
93
+ // Acquire the lock file. Returns an async release function.
94
+ // Detects stale locks by checking if the recorded PID is still alive.
95
+ export async function acquireLock(config) {
96
+ const lockFile = config.lockFile
97
+ const dir = path.dirname(lockFile)
98
+ await fs.mkdir(dir, { recursive: true })
99
+
100
+ for (let attempt = 0; attempt < LOCK_RETRIES; attempt++) {
101
+ try {
102
+ // O_EXCL: fail if exists — atomic create-exclusive
103
+ const fh = await fs.open(lockFile, 'wx')
104
+ await fh.writeFile(String(process.pid), 'utf8')
105
+ await fh.close()
106
+ return async () => {
107
+ try { await fs.unlink(lockFile) } catch {}
108
+ }
109
+ } catch (err) {
110
+ if (err.code !== 'EEXIST') throw err
111
+ // Check if the lock holder is still alive
112
+ try {
113
+ const pid = parseInt(await fs.readFile(lockFile, 'utf8'), 10)
114
+ if (!isNaN(pid) && pid !== process.pid) {
115
+ try {
116
+ process.kill(pid, 0)
117
+ } catch (killErr) {
118
+ if (killErr.code === 'ESRCH') {
119
+ // Stale lock — remove and retry immediately
120
+ await fs.unlink(lockFile).catch(() => {})
121
+ continue
122
+ }
123
+ }
124
+ }
125
+ } catch {}
126
+ // Wait with exponential backoff then retry
127
+ await sleep(LOCK_DELAY_BASE_MS * Math.pow(1.5, attempt))
128
+ }
129
+ }
130
+ throw new Error(`Could not acquire lock at ${lockFile} after ${LOCK_RETRIES} attempts`)
131
+ }
132
+
133
+ // Resolve a geniejars name or alias to a canonical pool name.
134
+ // Accepts: 'sub001', 'helloworld_1'
135
+ export function resolveName(state, nameOrAlias) {
136
+ if (nameOrAlias in state.pool) return nameOrAlias
137
+ const found = Object.entries(state.pool).find(([, e]) => e.alias === nameOrAlias)
138
+ if (found) return found[0]
139
+ throw new Error(`Unknown geniejars or alias: ${nameOrAlias}`)
140
+ }
141
+
142
+ // Free geniejars names.
143
+ export function freeNames(state) {
144
+ return Object.entries(state.pool)
145
+ .filter(([, v]) => v.status === 'free')
146
+ .map(([k]) => k)
147
+ }
148
+
149
+ // Occupied geniejars names.
150
+ export function occupiedNames(state) {
151
+ return Object.entries(state.pool)
152
+ .filter(([, v]) => v.status === 'occupied')
153
+ .map(([k]) => k)
154
+ }
155
+
156
+ function sleep(ms) {
157
+ return new Promise(resolve => setTimeout(resolve, ms))
158
+ }
@@ -0,0 +1,219 @@
1
+ import fs from 'fs/promises'
2
+ import path from 'path'
3
+ import { homeDir, acquireLock, readState, writeState } from './pool.mjs'
4
+ import { runShellAs } from './run.mjs'
5
+
6
+ // Replace ${PORT(n)} and ${COMMON} placeholders in text.
7
+ function substitute(text, ports, commonDir) {
8
+ text = text.replace(/\$\{PORT\((\d+)\)\}/g, (match, n) => {
9
+ const idx = parseInt(n, 10) - 1
10
+ if (idx < 0 || idx >= ports.length) {
11
+ throw new Error(`${match}: port index out of range (geniejars has ${ports.length} port(s))`)
12
+ }
13
+ return String(ports[idx])
14
+ })
15
+ if (commonDir != null) {
16
+ text = text.replace(/\$\{COMMON\}/g, commonDir)
17
+ }
18
+ return text
19
+ }
20
+
21
+ // Generate the next available alias for a tag across the pool.
22
+ function nextAlias(state, tag, currentName) {
23
+ const used = Object.entries(state.pool)
24
+ .filter(([n, e]) => e.tag === tag && n !== currentName)
25
+ .map(([, e]) => e.alias)
26
+ .filter(Boolean)
27
+ .map(a => parseInt(a.split('_').pop(), 10))
28
+ .filter(n => !isNaN(n))
29
+ const num = used.length > 0 ? Math.max(...used) + 1 : 1
30
+ return `${tag}_${num}`
31
+ }
32
+
33
+ // Parse JFile text. Returns array of directive objects.
34
+ // Supported directives: TAG, ENV, WORKDIR, COPY, RUN, CMD, DEPEND, ADDPATH, IMPORT
35
+ export function parseJFile(text) {
36
+ const directives = []
37
+ const lines = text.split('\n')
38
+
39
+ for (let i = 0; i < lines.length; i++) {
40
+ const line = lines[i].trim()
41
+ const lineNum = i + 1
42
+
43
+ if (!line || line.startsWith('#')) continue
44
+
45
+ const spaceIdx = line.search(/\s/)
46
+ const directive = spaceIdx === -1 ? line.toUpperCase() : line.slice(0, spaceIdx).toUpperCase()
47
+ const rest = spaceIdx === -1 ? '' : line.slice(spaceIdx + 1).trimStart()
48
+
49
+ if (directive === 'TAG') {
50
+ if (!rest || /\s/.test(rest)) throw new Error(`JFile line ${lineNum}: TAG requires a single word`)
51
+ directives.push({ type: 'TAG', tag: rest, line: lineNum })
52
+
53
+ } else if (directive === 'COPY') {
54
+ const match = rest.match(/^(\S+)\s+(\S+)$/)
55
+ if (!match) throw new Error(`JFile line ${lineNum}: COPY requires exactly two arguments: COPY <src> <dest>`)
56
+ directives.push({ type: 'COPY', src: match[1], dest: match[2], line: lineNum })
57
+
58
+ } else if (directive === 'RUN') {
59
+ if (!rest) throw new Error(`JFile line ${lineNum}: RUN requires a command`)
60
+ directives.push({ type: 'RUN', command: rest, line: lineNum })
61
+
62
+ } else if (directive === 'ENV') {
63
+ const eqIdx = rest.indexOf('=')
64
+ if (eqIdx === -1) throw new Error(`JFile line ${lineNum}: ENV requires KEY=VALUE format`)
65
+ const key = rest.slice(0, eqIdx)
66
+ const value = rest.slice(eqIdx + 1)
67
+ if (!key) throw new Error(`JFile line ${lineNum}: ENV key cannot be empty`)
68
+ directives.push({ type: 'ENV', key, value, line: lineNum })
69
+
70
+ } else if (directive === 'WORKDIR') {
71
+ if (!rest) throw new Error(`JFile line ${lineNum}: WORKDIR requires a path`)
72
+ directives.push({ type: 'WORKDIR', path: rest, line: lineNum })
73
+
74
+ } else if (directive === 'DEPEND') {
75
+ if (!rest) throw new Error(`JFile line ${lineNum}: DEPEND requires a path`)
76
+ directives.push({ type: 'DEPEND', path: rest, line: lineNum })
77
+
78
+ } else if (directive === 'ADDPATH') {
79
+ if (!rest) throw new Error(`JFile line ${lineNum}: ADDPATH requires a path`)
80
+ directives.push({ type: 'ADDPATH', path: rest, line: lineNum })
81
+
82
+ } else if (directive === 'IMPORT') {
83
+ if (!rest || /\s/.test(rest)) throw new Error(`JFile line ${lineNum}: IMPORT requires a single env var name`)
84
+ directives.push({ type: 'IMPORT', key: rest, line: lineNum })
85
+
86
+ } else if (directive === 'CMD') {
87
+ if (!rest) throw new Error(`JFile line ${lineNum}: CMD requires a command`)
88
+ let cmd
89
+ if (rest.startsWith('[')) {
90
+ try { cmd = JSON.parse(rest) } catch {
91
+ throw new Error(`JFile line ${lineNum}: CMD JSON array is invalid`)
92
+ }
93
+ } else {
94
+ cmd = ['sh', '-c', rest]
95
+ }
96
+ directives.push({ type: 'CMD', cmd, line: lineNum })
97
+
98
+ } else {
99
+ throw new Error(`JFile line ${lineNum}: Unknown directive: ${directive}`)
100
+ }
101
+ }
102
+
103
+ return directives
104
+ }
105
+
106
+ // Read and parse a JFile from disk.
107
+ export async function readJFile(filePath) {
108
+ const text = await fs.readFile(filePath, 'utf8')
109
+ return parseJFile(text)
110
+ }
111
+
112
+ // Apply parsed directives to a geniejars.
113
+ // contextDir: base directory for resolving relative COPY src paths.
114
+ // Options:
115
+ // env: object - extra env vars merged before JFile ENVs
116
+ // Returns { tag, cmd, workdir, env }
117
+ export async function applyJFile(config, name, directives, contextDir, options = {}) {
118
+ const home = homeDir(config, name)
119
+ const accEnv = { ...(options.env ?? {}) }
120
+ const addedPaths = []
121
+ let currentWorkdir = home
122
+ let cmd = null
123
+ let tag = null
124
+
125
+ for (const d of directives) {
126
+ if (d.type === 'TAG') {
127
+ tag = d.tag
128
+
129
+ } else if (d.type === 'ENV') {
130
+ accEnv[d.key] = d.value
131
+
132
+ } else if (d.type === 'WORKDIR') {
133
+ currentWorkdir = path.isAbsolute(d.path) ? d.path : path.join(home, d.path)
134
+ await fs.mkdir(currentWorkdir, { recursive: true })
135
+
136
+ } else if (d.type === 'COPY') {
137
+ const src = path.isAbsolute(d.src) ? d.src : path.resolve(contextDir, d.src)
138
+ const dest = path.isAbsolute(d.dest) ? d.dest : path.join(currentWorkdir, d.dest)
139
+ await fs.mkdir(path.dirname(dest), { recursive: true })
140
+ await fs.cp(src, dest, { recursive: true })
141
+
142
+ } else if (d.type === 'RUN') {
143
+ const result = await runShellAs(config, name, d.command, {
144
+ cwd: currentWorkdir,
145
+ env: Object.keys(accEnv).length > 0 ? accEnv : undefined,
146
+ capture: false,
147
+ })
148
+ if (result.exitCode !== 0) {
149
+ throw new Error(
150
+ `JFile line ${d.line}: RUN failed (exit ${result.exitCode}): ${d.command}`
151
+ )
152
+ }
153
+
154
+ } else if (d.type === 'DEPEND') {
155
+ try {
156
+ await fs.access(d.path)
157
+ } catch {
158
+ throw new Error(`dependency not found: ${d.path}`)
159
+ }
160
+
161
+ } else if (d.type === 'IMPORT') {
162
+ if (!(d.key in process.env)) {
163
+ throw new Error(`IMPORT ${d.key}: variable not set in manager environment`)
164
+ }
165
+ accEnv[d.key] = process.env[d.key]
166
+
167
+ } else if (d.type === 'ADDPATH') {
168
+ addedPaths.push(d.path)
169
+
170
+ } else if (d.type === 'CMD') {
171
+ cmd = d.cmd
172
+ }
173
+ }
174
+
175
+ return { tag, cmd, workdir: currentWorkdir, env: accEnv, addedPaths }
176
+ }
177
+
178
+ // Convenience: read file, apply, and save prepared config to state.
179
+ export async function prepare(config, name, geniejarsFilePath, options = {}) {
180
+ const absPath = path.resolve(geniejarsFilePath)
181
+ const contextDir = path.dirname(absPath)
182
+
183
+ // Read state to get assigned ports for substitution
184
+ const state = await readState(config)
185
+ const entry = state.pool[name]
186
+ if (!entry || entry.status !== 'occupied') {
187
+ throw new Error(`Geniejars ${name} is not allocated — run allocate first`)
188
+ }
189
+ const ports = entry.ports ?? []
190
+
191
+ // Substitute ${PORT(n)} and ${COMMON} before parsing
192
+ let text = await fs.readFile(absPath, 'utf8')
193
+ text = substitute(text, ports, config.commonDir ?? null)
194
+
195
+ const directives = parseJFile(text)
196
+ const prepared = await applyJFile(config, name, directives, contextDir, options)
197
+
198
+ // Save prepared config, tag, and alias into state
199
+ const release = await acquireLock(config)
200
+ try {
201
+ const st = await readState(config)
202
+ const e = st.pool[name]
203
+ if (e) {
204
+ const { tag } = prepared
205
+ // Keep existing alias if tag unchanged, otherwise generate a new one
206
+ const alias = (tag && e.tag === tag && e.alias)
207
+ ? e.alias
208
+ : (tag ? nextAlias(st, tag, name) : null)
209
+ e.tag = tag ?? e.tag
210
+ e.alias = alias
211
+ e.prepared = prepared
212
+ }
213
+ await writeState(config, st)
214
+ } finally {
215
+ await release()
216
+ }
217
+
218
+ return prepared
219
+ }
package/src/run.mjs ADDED
@@ -0,0 +1,162 @@
1
+ import net from 'net'
2
+ import fs from 'fs'
3
+ import path from 'path'
4
+ import { homeDir } from './pool.mjs'
5
+
6
+ function socketPath(config, name) {
7
+ return path.join(config.socketDir, `${name}.sock`)
8
+ }
9
+
10
+ // Send a request to the agent and handle the streaming response.
11
+ // Resolves when the connection closes or a terminal message is received.
12
+ function request(config, name, req, options = {}) {
13
+ const { capture = false, onMessage, logStream } = options
14
+
15
+ return new Promise((resolve, reject) => {
16
+ const client = net.createConnection(socketPath(config, name))
17
+ let buf = ''
18
+ let stdout = ''
19
+ let stderr = ''
20
+ let settled = false
21
+
22
+ function settle(fn, value) {
23
+ if (settled) return
24
+ settled = true
25
+ fn(value)
26
+ }
27
+
28
+ client.on('connect', () => { client.write(JSON.stringify(req) + '\n') })
29
+
30
+ client.on('data', chunk => {
31
+ buf += chunk.toString()
32
+ let nl
33
+ while ((nl = buf.indexOf('\n')) !== -1) {
34
+ const line = buf.slice(0, nl).trim()
35
+ buf = buf.slice(nl + 1)
36
+ if (!line) continue
37
+ let msg
38
+ try { msg = JSON.parse(line) } catch { continue }
39
+
40
+ if (onMessage) onMessage(msg)
41
+
42
+ switch (msg.type) {
43
+ case 'stdout':
44
+ if (logStream) logStream.write(msg.data)
45
+ else if (capture) stdout += msg.data
46
+ else process.stdout.write(msg.data)
47
+ break
48
+ case 'stderr':
49
+ if (logStream) logStream.write(msg.data)
50
+ else if (capture) stderr += msg.data
51
+ else process.stderr.write(msg.data)
52
+ break
53
+ case 'exit':
54
+ settle(resolve, { exitCode: msg.code, stdout, stderr })
55
+ break
56
+ case 'started':
57
+ if (options.detach) {
58
+ settle(resolve, { pid: msg.pid, detached: true })
59
+ client.destroy()
60
+ }
61
+ break
62
+ case 'stopped':
63
+ settle(resolve, { stopped: true })
64
+ break
65
+ case 'status':
66
+ settle(resolve, { running: msg.running, pid: msg.pid })
67
+ break
68
+ case 'error':
69
+ settle(reject, new Error(msg.message))
70
+ client.destroy()
71
+ break
72
+ }
73
+ }
74
+ })
75
+
76
+ client.on('error', err =>
77
+ settle(reject, new Error(`Agent connection failed (${name}): ${err.message}`))
78
+ )
79
+ client.on('close', () => {
80
+ if (!settled) settle(resolve, { exitCode: null, stdout, stderr })
81
+ })
82
+ })
83
+ }
84
+
85
+ // Run a short-lived command as the geniejars via the agent.
86
+ export async function runAs(config, name, cmd, args = [], options = {}) {
87
+ const { cwd, env, capture = false } = options
88
+ return request(config, name, { type: 'run', cmd, args, cwd, env }, { capture })
89
+ }
90
+
91
+ // Convenience: run `sh -c shellCommand` as the geniejars.
92
+ export async function runShellAs(config, name, shellCommand, options = {}) {
93
+ return runAs(config, name, 'sh', ['-c', shellCommand], options)
94
+ }
95
+
96
+ // Derive the log file path for a geniejars from config.
97
+ export function logFilePath(config, name) {
98
+ if (!config.logDir) return null
99
+ return path.join(config.logDir, `${name}.log`)
100
+ }
101
+
102
+ // Start the long-running main process.
103
+ // wait: true (default) — awaits process exit, streams output to terminal
104
+ // wait: false — returns after started confirmation (fire and forget)
105
+ // logFile: path — write stdout/stderr to file instead of terminal
106
+ // auto-derived from config.logDir when wait: false
107
+ export async function startAs(config, name, cmd, args = [], options = {}) {
108
+ const { cwd, env, onStarted, wait = true, logFile } = options
109
+
110
+ const resolvedLogFile = logFile
111
+ ?? (!wait && config.logDir ? logFilePath(config, name) : null)
112
+
113
+ let logStream = null
114
+ if (resolvedLogFile) {
115
+ await fs.promises.mkdir(path.dirname(resolvedLogFile), { recursive: true })
116
+ logStream = fs.createWriteStream(resolvedLogFile, { flags: 'a' })
117
+ }
118
+
119
+ try {
120
+ return await request(config, name, { type: 'start', cmd, args, cwd, env }, {
121
+ detach: !wait,
122
+ logStream,
123
+ onMessage: msg => { if (msg.type === 'started' && onStarted) onStarted(msg.pid) },
124
+ })
125
+ } finally {
126
+ if (logStream) logStream.end()
127
+ }
128
+ }
129
+
130
+ // Stop the main process.
131
+ export async function stopProcess(config, name) {
132
+ return request(config, name, { type: 'stop' })
133
+ }
134
+
135
+ // Get agent status.
136
+ export async function agentStatus(config, name) {
137
+ return request(config, name, { type: 'status' })
138
+ }
139
+
140
+ // Ensure a geniejars agent is up via the shell server.
141
+ // Requires the shell server to be running (geniejars sys up).
142
+ export function ensureAgent(config, name) {
143
+ const serverSock = path.join(config.socketDir, 'server.sock')
144
+ return new Promise((resolve, reject) => {
145
+ const client = net.createConnection(serverSock)
146
+ let buf = ''
147
+ client.on('connect', () => client.write(JSON.stringify({ type: 'ensure', name }) + '\n'))
148
+ client.on('data', chunk => {
149
+ buf += chunk.toString()
150
+ if (buf.includes('\n')) {
151
+ try {
152
+ const msg = JSON.parse(buf.split('\n')[0].trim())
153
+ client.destroy()
154
+ if (msg.type === 'error') reject(new Error(msg.message))
155
+ else resolve()
156
+ } catch {}
157
+ }
158
+ })
159
+ client.on('error', err => reject(new Error(`Shell server not running: ${err.message}\nRun: geniejars sys up`)))
160
+ client.on('close', () => reject(new Error('No response from shell server')))
161
+ })
162
+ }