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
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
|
+
}
|
package/src/prepare.mjs
ADDED
|
@@ -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
|
+
}
|