@ticatec/omniflow-core 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +112 -54
  3. package/README_CN.md +113 -55
  4. package/dist/index.d.ts +5 -4
  5. package/dist/index.d.ts.map +1 -1
  6. package/dist/index.js +2 -2
  7. package/dist/index.js.map +1 -1
  8. package/dist/plugin/PluginContext.d.ts +39 -0
  9. package/dist/plugin/PluginContext.d.ts.map +1 -0
  10. package/dist/plugin/PluginContext.js +8 -0
  11. package/dist/plugin/PluginContext.js.map +1 -0
  12. package/dist/primitives/docker.d.ts.map +1 -1
  13. package/dist/primitives/docker.js +9 -0
  14. package/dist/primitives/docker.js.map +1 -1
  15. package/dist/primitives/git.d.ts +58 -16
  16. package/dist/primitives/git.d.ts.map +1 -1
  17. package/dist/primitives/git.js +91 -33
  18. package/dist/primitives/git.js.map +1 -1
  19. package/dist/primitives/shell.d.ts +4 -0
  20. package/dist/primitives/shell.d.ts.map +1 -1
  21. package/dist/primitives/shell.js +20 -60
  22. package/dist/primitives/shell.js.map +1 -1
  23. package/dist/primitives/ssh.d.ts +8 -5
  24. package/dist/primitives/ssh.d.ts.map +1 -1
  25. package/dist/primitives/ssh.js +49 -14
  26. package/dist/primitives/ssh.js.map +1 -1
  27. package/dist/primitives/subprocess.d.ts +52 -0
  28. package/dist/primitives/subprocess.d.ts.map +1 -0
  29. package/dist/primitives/subprocess.js +353 -0
  30. package/dist/primitives/subprocess.js.map +1 -0
  31. package/dist/toolchain/providers/GradleToolchain.d.ts +1 -1
  32. package/dist/toolchain/providers/GradleToolchain.d.ts.map +1 -1
  33. package/dist/toolchain/providers/GradleToolchain.js +4 -3
  34. package/dist/toolchain/providers/GradleToolchain.js.map +1 -1
  35. package/dist/toolchain/providers/MavenToolchain.d.ts +1 -1
  36. package/dist/toolchain/providers/MavenToolchain.d.ts.map +1 -1
  37. package/dist/toolchain/providers/MavenToolchain.js +3 -3
  38. package/dist/toolchain/providers/MavenToolchain.js.map +1 -1
  39. package/dist/toolchain/providers/NodeToolchain.d.ts +1 -1
  40. package/dist/toolchain/providers/NodeToolchain.d.ts.map +1 -1
  41. package/dist/toolchain/providers/NodeToolchain.js +61 -0
  42. package/dist/toolchain/providers/NodeToolchain.js.map +1 -1
  43. package/dist/toolchain/providers/pom.d.ts.map +1 -1
  44. package/dist/toolchain/providers/pom.js +13 -0
  45. package/dist/toolchain/providers/pom.js.map +1 -1
  46. package/dist/toolchain/registry.d.ts +11 -3
  47. package/dist/toolchain/registry.d.ts.map +1 -1
  48. package/dist/toolchain/registry.js +33 -8
  49. package/dist/toolchain/registry.js.map +1 -1
  50. package/dist/utils/mask.d.ts.map +1 -1
  51. package/dist/utils/mask.js +30 -5
  52. package/dist/utils/mask.js.map +1 -1
  53. package/docs/toolchain-extension.md +301 -0
  54. package/docs/toolchain-extension_CN.md +304 -0
  55. package/package.json +23 -1
  56. package/src/context/index.ts +74 -0
  57. package/src/context/storage.ts +8 -0
  58. package/src/context/types.ts +69 -0
  59. package/src/index.ts +97 -0
  60. package/src/plugin/PluginContext.ts +57 -0
  61. package/src/primitives/docker.ts +164 -0
  62. package/src/primitives/git.ts +172 -0
  63. package/src/primitives/index.ts +4 -0
  64. package/src/primitives/shell.ts +157 -0
  65. package/src/primitives/ssh.ts +249 -0
  66. package/src/primitives/subprocess.ts +389 -0
  67. package/src/toolchain/index.ts +6 -0
  68. package/src/toolchain/providers/GradleToolchain.ts +137 -0
  69. package/src/toolchain/providers/MavenToolchain.ts +64 -0
  70. package/src/toolchain/providers/NodeToolchain.ts +172 -0
  71. package/src/toolchain/providers/pom.ts +145 -0
  72. package/src/toolchain/registry.ts +161 -0
  73. package/src/toolchain/types.ts +40 -0
  74. package/src/utils/mask.ts +73 -0
  75. package/src/utils/template.ts +62 -0
@@ -0,0 +1,389 @@
1
+ import path from 'node:path'
2
+ import fs from 'node:fs'
3
+ import { Transform } from 'node:stream'
4
+ import type { execa } from 'execa'
5
+ import { tryGetContext } from '../context/index.js'
6
+ import { isSecretKey, maskString } from '../utils/mask.js'
7
+
8
+ export interface SubprocessResult {
9
+ stdout: string
10
+ stderr: string
11
+ exitCode: number
12
+ failed: boolean
13
+ signal?: string
14
+ }
15
+
16
+ export interface SubprocessRunOptions {
17
+ subprocess: ReturnType<typeof execa>
18
+ displayCmd: string
19
+ cmdText?: string
20
+ errorPrefix?: string
21
+ reject?: boolean
22
+ secrets?: string[]
23
+ }
24
+
25
+ /**
26
+ * Resolve process.env merged with context.env and any explicit overrides.
27
+ */
28
+ export function resolveEnv(explicitEnv?: Record<string, string>): Record<string, string> {
29
+ const ctx = tryGetContext()
30
+ const base = { ...process.env }
31
+ if (ctx?.env) {
32
+ Object.assign(base, ctx.env)
33
+ }
34
+ if (explicitEnv) {
35
+ Object.assign(base, explicitEnv)
36
+ }
37
+ return base as Record<string, string>
38
+ }
39
+
40
+ /**
41
+ * Extract active secret values from execution context.
42
+ */
43
+ export function getSecretValues(): string[] {
44
+ const ctx = tryGetContext()
45
+ if (!ctx?.env) return []
46
+ const secrets: string[] = []
47
+ for (const [k, v] of Object.entries(ctx.env)) {
48
+ if (isSecretKey(k) && typeof v === 'string' && v.length >= 3) {
49
+ secrets.push(v)
50
+ }
51
+ }
52
+ return secrets
53
+ }
54
+
55
+ /**
56
+ * Create a stateful Transform stream that masks secrets across chunk boundaries.
57
+ * Retains up to maxSecretLen - 1 bytes in tail buffer to prevent boundary leaks.
58
+ */
59
+ export function createMaskTransform(secrets: string[]): Transform {
60
+ const activeSecrets = secrets
61
+ .filter(s => typeof s === 'string' && s.length >= 3)
62
+ .flatMap(s => s.includes('\n') ? s.split(/\r?\n/).map(l => l.trim()).filter(l => l.length >= 3) : [s])
63
+
64
+ if (activeSecrets.length === 0) {
65
+ return new Transform({
66
+ transform(chunk, _encoding, callback) {
67
+ callback(null, chunk)
68
+ }
69
+ })
70
+ }
71
+
72
+ // Sort descending by length so longer matches are prioritized
73
+ activeSecrets.sort((a, b) => b.length - a.length)
74
+
75
+ const maxSecretLen = Math.max(...activeSecrets.map(s => s.length))
76
+ const keepLen = Math.max(0, maxSecretLen - 1)
77
+ let tail = ''
78
+
79
+ return new Transform({
80
+ transform(chunk, _encoding, callback) {
81
+ const text = tail + chunk.toString('utf8')
82
+ const masked = maskString(text, activeSecrets)
83
+
84
+ if (masked.length > keepLen) {
85
+ const emitLen = masked.length - keepLen
86
+ const toEmit = masked.slice(0, emitLen)
87
+ tail = masked.slice(emitLen)
88
+ callback(null, Buffer.from(toEmit, 'utf8'))
89
+ } else {
90
+ tail = masked
91
+ callback()
92
+ }
93
+ },
94
+ flush(callback) {
95
+ if (tail.length > 0) {
96
+ const finalMasked = maskString(tail, activeSecrets)
97
+ tail = ''
98
+ callback(null, Buffer.from(finalMasked, 'utf8'))
99
+ } else {
100
+ callback()
101
+ }
102
+ }
103
+ })
104
+ }
105
+
106
+ /**
107
+ * Prefix every line in the stream with a tag (e.g. PID).
108
+ */
109
+ export function createLinePrefixTransform(prefix: string): Transform {
110
+ if (!prefix) {
111
+ return new Transform({
112
+ transform(chunk, _encoding, callback) {
113
+ callback(null, chunk)
114
+ }
115
+ })
116
+ }
117
+
118
+ let remainder = ''
119
+ return new Transform({
120
+ transform(chunk, _encoding, callback) {
121
+ const text = remainder + chunk.toString('utf8')
122
+ const lines = text.split('\n')
123
+ remainder = lines.pop() ?? ''
124
+ const out = lines.map(l => `${prefix}${l}\n`).join('')
125
+ callback(null, Buffer.from(out, 'utf8'))
126
+ },
127
+ flush(callback) {
128
+ if (remainder.length > 0) {
129
+ callback(null, Buffer.from(`${prefix}${remainder}\n`, 'utf8'))
130
+ } else {
131
+ callback()
132
+ }
133
+ }
134
+ })
135
+ }
136
+
137
+ /**
138
+ * Open appendable write stream to the log file, auto-creating directories.
139
+ */
140
+ export function openLogStream(logFile: string, logger?: { warn?: (msg: string) => void }): fs.WriteStream | undefined {
141
+ try {
142
+ const logDir = path.dirname(logFile)
143
+ if (!fs.existsSync(logDir)) {
144
+ fs.mkdirSync(logDir, { recursive: true })
145
+ }
146
+ if (fs.existsSync(logFile) && fs.statSync(logFile).isDirectory()) {
147
+ logger?.warn?.(`Failed to open logFile ${logFile}: path is a directory`)
148
+ return undefined
149
+ }
150
+ const logStream = fs.createWriteStream(logFile, { flags: 'a' })
151
+ logStream.on('error', (err) => {
152
+ logger?.warn?.(`Failed to write to logFile ${logFile}: ${err.message}`)
153
+ })
154
+ return logStream
155
+ } catch (err: any) {
156
+ logger?.warn?.(`Failed to open logFile ${logFile}: ${err?.message}`)
157
+ return undefined
158
+ }
159
+ }
160
+
161
+ /**
162
+ * Mask all known string and stdio properties on an ExecaError.
163
+ */
164
+ export function maskExecaError(thrownError: any, secrets: string[]): any {
165
+ if (!thrownError) return thrownError
166
+ if (secrets.length > 0) {
167
+ const props = ['message', 'shortMessage', 'originalMessage', 'command', 'escapedCommand', 'stack', 'all']
168
+ for (const prop of props) {
169
+ if (typeof thrownError[prop] === 'string') {
170
+ thrownError[prop] = maskString(thrownError[prop], secrets)
171
+ }
172
+ }
173
+ if (Array.isArray(thrownError.stdio)) {
174
+ thrownError.stdio = thrownError.stdio.map((item: unknown) => {
175
+ if (typeof item === 'string') {
176
+ return maskString(item, secrets)
177
+ }
178
+ if (item && typeof (item as any).toString === 'function' && Buffer.isBuffer(item)) {
179
+ return Buffer.from(maskString(item.toString('utf-8'), secrets))
180
+ }
181
+ return item
182
+ })
183
+ }
184
+ }
185
+ return thrownError
186
+ }
187
+
188
+ let cmdCounter = 0
189
+
190
+ export function resetCmdCounter(): void {
191
+ cmdCounter = 0
192
+ }
193
+
194
+ /**
195
+ * Unified subprocess runner: pipes stdout/stderr to logFile with PID prefix and masking,
196
+ * manages stream flushing, exit code logging, failure log level downgrade, and ExecaError masking.
197
+ */
198
+ export async function runSubprocess(options: SubprocessRunOptions): Promise<SubprocessResult> {
199
+ const { subprocess, displayCmd, reject = true } = options
200
+ const ctx = tryGetContext()
201
+ const secrets = options.secrets ?? getSecretValues()
202
+ const pid = subprocess.pid
203
+ const pidTag = pid ? `[PID ${pid}] ` : `[CMD ${++cmdCounter}] `
204
+ const cmdText = options.cmdText ?? displayCmd
205
+ const errorPrefix = options.errorPrefix ?? 'Command'
206
+
207
+ let logStream: fs.WriteStream | undefined
208
+ if (ctx?.logFile) {
209
+ logStream = openLogStream(ctx.logFile, ctx.logger)
210
+ }
211
+
212
+ if (logStream && !logStream.destroyed && logStream.writable) {
213
+ try {
214
+ logStream.write(`${pidTag}${displayCmd}\n`)
215
+ } catch {}
216
+ }
217
+
218
+ let lineStdout: Transform | undefined
219
+ let lineStderr: Transform | undefined
220
+ const streamDone: Promise<void>[] = []
221
+
222
+ const unpipeAndDrain = () => {
223
+ if (lineStdout && logStream) {
224
+ try {
225
+ lineStdout.unpipe(logStream)
226
+ lineStdout.resume()
227
+ } catch {}
228
+ }
229
+ if (lineStderr && logStream) {
230
+ try {
231
+ lineStderr.unpipe(logStream)
232
+ lineStderr.resume()
233
+ } catch {}
234
+ }
235
+ }
236
+
237
+ if (logStream) {
238
+ logStream.on('error', unpipeAndDrain)
239
+ logStream.on('close', unpipeAndDrain)
240
+ }
241
+
242
+ if (subprocess.stdout && logStream) {
243
+ const maskStdout = createMaskTransform(secrets)
244
+ lineStdout = createLinePrefixTransform(pidTag)
245
+ maskStdout.pipe(lineStdout).pipe(logStream, { end: false })
246
+ subprocess.stdout.pipe(maskStdout)
247
+ streamDone.push(new Promise(resolve => {
248
+ let resolved = false
249
+ const done = () => {
250
+ if (!resolved) {
251
+ resolved = true
252
+ resolve()
253
+ }
254
+ }
255
+ lineStdout!.once('end', done)
256
+ lineStdout!.once('close', done)
257
+ lineStdout!.once('error', done)
258
+ if (logStream) {
259
+ logStream.once('error', done)
260
+ logStream.once('close', done)
261
+ }
262
+ }))
263
+ }
264
+ if (subprocess.stderr && logStream) {
265
+ const maskStderr = createMaskTransform(secrets)
266
+ lineStderr = createLinePrefixTransform(pidTag)
267
+ maskStderr.pipe(lineStderr).pipe(logStream, { end: false })
268
+ subprocess.stderr.pipe(maskStderr)
269
+ streamDone.push(new Promise(resolve => {
270
+ let resolved = false
271
+ const done = () => {
272
+ if (!resolved) {
273
+ resolved = true
274
+ resolve()
275
+ }
276
+ }
277
+ lineStderr!.once('end', done)
278
+ lineStderr!.once('close', done)
279
+ lineStderr!.once('error', done)
280
+ if (logStream) {
281
+ logStream.once('error', done)
282
+ logStream.once('close', done)
283
+ }
284
+ }))
285
+ }
286
+
287
+ let execResult: any
288
+ let thrownError: any
289
+
290
+ try {
291
+ execResult = await subprocess
292
+ } catch (err: any) {
293
+ thrownError = err
294
+ execResult = err
295
+ }
296
+
297
+ // Always wait for stream completion regardless of subprocess exit status, with timeout safety
298
+ if (streamDone.length > 0) {
299
+ let timeoutId: NodeJS.Timeout | undefined
300
+ const drainTimeout = new Promise<void>(resolve => {
301
+ timeoutId = setTimeout(resolve, 3000)
302
+ timeoutId.unref?.()
303
+ })
304
+ await Promise.race([Promise.all(streamDone), drainTimeout])
305
+ if (timeoutId) {
306
+ clearTimeout(timeoutId)
307
+ }
308
+ }
309
+
310
+ try {
311
+ const isFailed = Boolean(execResult.failed || (execResult.exitCode !== undefined && execResult.exitCode !== 0))
312
+ let exitCode = execResult.exitCode
313
+ if (exitCode === undefined) {
314
+ exitCode = isFailed ? -1 : 0
315
+ }
316
+
317
+ if (logStream && !logStream.destroyed && logStream.writable) {
318
+ try {
319
+ logStream.write(`${pidTag}[Process exited with code ${exitCode}]\n`)
320
+ } catch {}
321
+ }
322
+
323
+ if (isFailed) {
324
+ if (reject === false) {
325
+ ctx?.logger?.debug?.(`${errorPrefix} exited with code ${exitCode} (reject: false): ${cmdText}`)
326
+ } else {
327
+ ctx?.logger?.error?.(`${errorPrefix} failed with exit code ${exitCode}: ${cmdText}`)
328
+ }
329
+ }
330
+
331
+ const rawStdout = typeof execResult.stdout === 'string' ? execResult.stdout : (execResult.stdout ? String(execResult.stdout) : '')
332
+ const rawStderr = typeof execResult.stderr === 'string' ? execResult.stderr : (execResult.stderr ? String(execResult.stderr) : '')
333
+
334
+ const stdout = secrets.length > 0 ? maskString(rawStdout, secrets) : rawStdout
335
+ const stderr = secrets.length > 0 ? maskString(rawStderr, secrets) : rawStderr
336
+
337
+ if (thrownError) {
338
+ maskExecaError(thrownError, secrets)
339
+ thrownError.stdout = stdout
340
+ thrownError.stderr = stderr
341
+ thrownError.exitCode = exitCode
342
+ thrownError.failed = true
343
+
344
+ if (reject === false) {
345
+ return {
346
+ stdout,
347
+ stderr,
348
+ exitCode,
349
+ failed: true,
350
+ signal: execResult.signal
351
+ }
352
+ }
353
+ throw thrownError
354
+ }
355
+
356
+ return {
357
+ stdout,
358
+ stderr,
359
+ exitCode,
360
+ failed: isFailed,
361
+ signal: execResult.signal
362
+ }
363
+ } finally {
364
+ if (logStream) {
365
+ await new Promise<void>(resolve => {
366
+ if (logStream!.destroyed || logStream!.closed || !logStream!.writable) {
367
+ resolve()
368
+ return
369
+ }
370
+ let settled = false
371
+ let timer: NodeJS.Timeout | undefined
372
+ const done = () => {
373
+ if (!settled) {
374
+ settled = true
375
+ if (timer) {
376
+ clearTimeout(timer)
377
+ }
378
+ resolve()
379
+ }
380
+ }
381
+ timer = setTimeout(done, 1000)
382
+ timer.unref?.()
383
+ logStream!.once('error', done)
384
+ logStream!.once('close', done)
385
+ logStream!.end(done)
386
+ })
387
+ }
388
+ }
389
+ }
@@ -0,0 +1,6 @@
1
+ export * from './types.js'
2
+ export * from './registry.js'
3
+ export { MavenToolchain } from './providers/MavenToolchain.js'
4
+ export { GradleToolchain } from './providers/GradleToolchain.js'
5
+ export { NodeToolchain } from './providers/NodeToolchain.js'
6
+ export { readPom, parseXml } from './providers/pom.js'
@@ -0,0 +1,137 @@
1
+ import path from 'node:path'
2
+ import fs from 'node:fs/promises'
3
+ import { shell } from '../../primitives/shell.js'
4
+ import type { ToolchainProvider, DetectionResult, ProjectInfo } from '../types.js'
5
+
6
+ const BUILD_FILES = ['build.gradle', 'build.gradle.kts']
7
+ const SETTINGS_FILES = ['settings.gradle', 'settings.gradle.kts']
8
+
9
+ async function readIfPresent(file: string): Promise<string | undefined> {
10
+ try {
11
+ return await fs.readFile(file, 'utf-8')
12
+ } catch {
13
+ return undefined
14
+ }
15
+ }
16
+
17
+ function fromProperties(text: string, key: string): string | undefined {
18
+ for (const line of text.split('\n')) {
19
+ const trimmed = line.trim()
20
+ if (!trimmed || trimmed.startsWith('#') || trimmed.startsWith('!')) continue
21
+ const eq = trimmed.indexOf('=')
22
+ if (eq === -1) continue
23
+ if (trimmed.slice(0, eq).trim() !== key) continue
24
+ return trimmed.slice(eq + 1).trim() || undefined
25
+ }
26
+ return undefined
27
+ }
28
+
29
+ function fromBuildScript(text: string, key: string): string | undefined {
30
+ const assignment = new RegExp(`^\\s*${key}(?:\\s*=|\\s+)\\s*['"]([^'"]+)['"]`, 'm')
31
+ return assignment.exec(text)?.[1]
32
+ }
33
+
34
+ export class GradleToolchain implements ToolchainProvider {
35
+ readonly name = 'gradle'
36
+ readonly priority: number = 20
37
+
38
+ async detect(projectDir: string): Promise<DetectionResult | null> {
39
+ for (const file of BUILD_FILES) {
40
+ try {
41
+ await fs.access(path.join(projectDir, file))
42
+ return { name: 'gradle', reason: `found ${file}` }
43
+ } catch {
44
+ // continue
45
+ }
46
+ }
47
+ return null
48
+ }
49
+
50
+ async projectInfo(projectDir: string): Promise<ProjectInfo> {
51
+ let name = await this.staticName(projectDir)
52
+ let version = await this.staticVersion(projectDir)
53
+
54
+ if (!name) name = path.basename(projectDir)
55
+ if (!version || version === 'unspecified') {
56
+ throw new Error(
57
+ `Could not determine project version for Gradle project in '${projectDir}'. ` +
58
+ `Please specify 'version' in build.gradle(.kts) or gradle.properties.`
59
+ )
60
+ }
61
+
62
+ const group = await this.staticGroup(projectDir)
63
+ return {
64
+ name,
65
+ version,
66
+ fullName: group ? `${group}:${name}` : name,
67
+ namespace: group
68
+ }
69
+ }
70
+
71
+ async install(projectDir: string, _flags: string[] = []): Promise<void> {
72
+ // Gradle resolves dependencies as part of tasks, no dedicated install needed
73
+ }
74
+
75
+ async build(projectDir: string, flags: string[] = []): Promise<void> {
76
+ const cmd = await this.getCommand(projectDir)
77
+ await shell.run({ cwd: projectDir })`${cmd} build ${flags}`
78
+ }
79
+
80
+ async run(projectDir: string, task: string, flags: string[] = []): Promise<void> {
81
+ const cmd = await this.getCommand(projectDir)
82
+ await shell.run({ cwd: projectDir })`${cmd} ${task} ${flags}`
83
+ }
84
+
85
+ private async getCommand(projectDir: string): Promise<string> {
86
+ const wrapper = path.join(projectDir, process.platform === 'win32' ? 'gradlew.bat' : 'gradlew')
87
+ try {
88
+ await fs.access(wrapper, fs.constants.X_OK)
89
+ return wrapper
90
+ } catch {
91
+ return 'gradle'
92
+ }
93
+ }
94
+
95
+ private async staticVersion(projectDir: string): Promise<string | undefined> {
96
+ const props = await readIfPresent(path.join(projectDir, 'gradle.properties'))
97
+ if (props) {
98
+ const found = fromProperties(props, 'version')
99
+ if (found) return found
100
+ }
101
+ for (const file of BUILD_FILES) {
102
+ const text = await readIfPresent(path.join(projectDir, file))
103
+ if (text) {
104
+ const found = fromBuildScript(text, 'version')
105
+ if (found) return found
106
+ }
107
+ }
108
+ return undefined
109
+ }
110
+
111
+ private async staticGroup(projectDir: string): Promise<string | undefined> {
112
+ const props = await readIfPresent(path.join(projectDir, 'gradle.properties'))
113
+ if (props) {
114
+ const found = fromProperties(props, 'group')
115
+ if (found) return found
116
+ }
117
+ for (const file of BUILD_FILES) {
118
+ const text = await readIfPresent(path.join(projectDir, file))
119
+ if (text) {
120
+ const found = fromBuildScript(text, 'group')
121
+ if (found) return found
122
+ }
123
+ }
124
+ return undefined
125
+ }
126
+
127
+ private async staticName(projectDir: string): Promise<string | undefined> {
128
+ for (const file of SETTINGS_FILES) {
129
+ const text = await readIfPresent(path.join(projectDir, file))
130
+ if (text) {
131
+ const found = /rootProject\.name\s*=\s*['"]([^'"]+)['"]/.exec(text)?.[1]
132
+ if (found) return found
133
+ }
134
+ }
135
+ return undefined
136
+ }
137
+ }
@@ -0,0 +1,64 @@
1
+ import path from 'node:path'
2
+ import fs from 'node:fs/promises'
3
+ import { shell } from '../../primitives/shell.js'
4
+ import { readPom } from './pom.js'
5
+ import type { ToolchainProvider, DetectionResult, ProjectInfo } from '../types.js'
6
+
7
+ export class MavenToolchain implements ToolchainProvider {
8
+ readonly name = 'maven'
9
+ readonly priority: number = 30
10
+
11
+ async detect(projectDir: string): Promise<DetectionResult | null> {
12
+ try {
13
+ await fs.access(path.join(projectDir, 'pom.xml'))
14
+ return { name: 'maven', reason: 'found pom.xml' }
15
+ } catch {
16
+ return null
17
+ }
18
+ }
19
+
20
+ async projectInfo(projectDir: string): Promise<ProjectInfo> {
21
+ const pomPath = path.join(projectDir, 'pom.xml')
22
+ const content = await fs.readFile(pomPath, 'utf-8')
23
+ const { groupId, artifactId, version } = readPom(content)
24
+
25
+ if (!artifactId) {
26
+ throw new Error(`Invalid pom.xml (missing <artifactId>): ${pomPath}`)
27
+ }
28
+ if (!version || version.includes('${')) {
29
+ throw new Error(`Invalid pom.xml (missing or unexpanded <version> '${version ?? ''}'): ${pomPath}`)
30
+ }
31
+
32
+ return {
33
+ name: artifactId,
34
+ version,
35
+ fullName: groupId ? `${groupId}:${artifactId}` : artifactId,
36
+ namespace: groupId
37
+ }
38
+ }
39
+
40
+ async install(projectDir: string, flags: string[] = []): Promise<void> {
41
+ const cmd = await this.getCommand(projectDir)
42
+ await shell.run({ cwd: projectDir })`${cmd} -B dependency:go-offline ${flags}`
43
+ }
44
+
45
+ async build(projectDir: string, flags: string[] = []): Promise<void> {
46
+ const cmd = await this.getCommand(projectDir)
47
+ await shell.run({ cwd: projectDir })`${cmd} -B package ${flags}`
48
+ }
49
+
50
+ async run(projectDir: string, task: string, flags: string[] = []): Promise<void> {
51
+ const cmd = await this.getCommand(projectDir)
52
+ await shell.run({ cwd: projectDir })`${cmd} -B ${task} ${flags}`
53
+ }
54
+
55
+ private async getCommand(projectDir: string): Promise<string> {
56
+ const wrapper = path.join(projectDir, process.platform === 'win32' ? 'mvnw.cmd' : 'mvnw')
57
+ try {
58
+ await fs.access(wrapper, fs.constants.X_OK)
59
+ return wrapper
60
+ } catch {
61
+ return 'mvn'
62
+ }
63
+ }
64
+ }