@ticatec/omniflow-nexus 1.0.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.
@@ -0,0 +1,143 @@
1
+ import {
2
+ maskSecrets,
3
+ safeFetch,
4
+ extractErrorMessage,
5
+ type SafeFetchOptions
6
+ } from '@ticatec/omniflow-artifact'
7
+
8
+ export interface NexusClientConfig {
9
+ baseUrl: string
10
+ username?: string
11
+ password?: string
12
+ token?: string
13
+ timeoutMs?: number
14
+ }
15
+
16
+ export class NexusClient {
17
+ readonly baseUrl: string
18
+ readonly username?: string
19
+ readonly password?: string
20
+ readonly token?: string
21
+ readonly timeoutMs: number
22
+
23
+ constructor(config: NexusClientConfig) {
24
+ let base = config.baseUrl.trim().replace(/\/+$/, '')
25
+ if (!/^https?:\/\//i.test(base)) {
26
+ base = `https://${base}`
27
+ }
28
+ this.baseUrl = base
29
+ this.username = config.username
30
+ this.password = config.password
31
+ this.token = config.token
32
+ this.timeoutMs = config.timeoutMs ?? 30000
33
+ }
34
+
35
+ get host(): string {
36
+ const parsed = new URL(this.baseUrl)
37
+ return parsed.host
38
+ }
39
+
40
+ get hostname(): string {
41
+ const parsed = new URL(this.baseUrl)
42
+ return parsed.hostname
43
+ }
44
+
45
+ /**
46
+ * Return sensitive credential strings of length >= 3 for scrubbing.
47
+ * Username is intentionally excluded to preserve debugging visibility.
48
+ */
49
+ getSecrets(): string[] {
50
+ const secrets = [this.token, this.password].filter(
51
+ (s): s is string => typeof s === 'string' && s.length >= 3
52
+ )
53
+ if (this.username && this.password) {
54
+ const basic = Buffer.from(`${this.username}:${this.password}`).toString('base64')
55
+ if (basic.length >= 3) {
56
+ secrets.push(basic)
57
+ }
58
+ }
59
+ return secrets
60
+ }
61
+
62
+ maskSecrets(input: string): string {
63
+ return maskSecrets(input, this.getSecrets())
64
+ }
65
+
66
+ async extractErrorMessage(response: Response): Promise<string> {
67
+ return extractErrorMessage(response, this.getSecrets())
68
+ }
69
+
70
+ getAuthHeaders(): Record<string, string> {
71
+ const headers: Record<string, string> = {}
72
+ if (this.token) {
73
+ headers['Authorization'] = `Bearer ${this.token}`
74
+ } else if (this.username && this.password) {
75
+ const basic = Buffer.from(`${this.username}:${this.password}`).toString('base64')
76
+ headers['Authorization'] = `Basic ${basic}`
77
+ }
78
+ return headers
79
+ }
80
+
81
+ /**
82
+ * Resolve root repository URL for a given repository name.
83
+ * Handles cases where baseUrl might already contain "/repository/<name>".
84
+ */
85
+ getRepositoryBaseUrl(repoName = ''): string {
86
+ let cleanBase = this.baseUrl.replace(/\/+$/, '')
87
+
88
+ if (cleanBase.includes('/repository/')) {
89
+ const idx = cleanBase.indexOf('/repository/')
90
+ const root = cleanBase.substring(0, idx)
91
+ const existingRepo = cleanBase.substring(idx + '/repository/'.length).split('/')[0]
92
+ const targetRepo = repoName || existingRepo
93
+ return `${root}/repository/${targetRepo}`
94
+ }
95
+
96
+ if (!repoName) {
97
+ return `${cleanBase}/repository`
98
+ }
99
+
100
+ return `${cleanBase}/repository/${repoName.replace(/^\/+/, '').replace(/\/+$/, '')}`
101
+ }
102
+
103
+ /**
104
+ * Build URL for Maven artifact.
105
+ */
106
+ getMavenArtifactUrl(
107
+ repoName: string,
108
+ groupId: string,
109
+ artifactId: string,
110
+ version: string,
111
+ filename: string
112
+ ): string {
113
+ const base = this.getRepositoryBaseUrl(repoName)
114
+ const groupPath = groupId.replace(/\./g, '/')
115
+ return `${base}/${groupPath}/${artifactId}/${version}/${filename}`
116
+ }
117
+
118
+ /**
119
+ * Build URL for Raw (Generic) artifact.
120
+ */
121
+ getRawArtifactUrl(repoName: string, subpath: string, filename: string): string {
122
+ const base = this.getRepositoryBaseUrl(repoName)
123
+ const cleanSubpath = subpath ? subpath.replace(/^\/+/, '').replace(/\/+$/, '') : ''
124
+ return cleanSubpath ? `${base}/${cleanSubpath}/${filename}` : `${base}/${filename}`
125
+ }
126
+
127
+ async request(url: string, init: RequestInit = {}): Promise<Response> {
128
+ const headers = {
129
+ ...this.getAuthHeaders(),
130
+ ...(init.headers as Record<string, string> || {})
131
+ }
132
+
133
+ return safeFetch(url, {
134
+ ...init,
135
+ headers,
136
+ timeoutMs: this.timeoutMs,
137
+ secrets: this.getSecrets()
138
+ })
139
+ }
140
+ }
141
+
142
+ export { maskSecrets, safeFetch, extractErrorMessage, type SafeFetchOptions }
143
+
@@ -0,0 +1,66 @@
1
+ import { BaseDockerPublisher } from '@ticatec/omniflow-artifact'
2
+ import type { RegistryConfig } from '@ticatec/omniflow-artifact'
3
+
4
+ /**
5
+ * Sonatype Nexus 3 Docker Registry Publisher.
6
+ *
7
+ * Supports Nexus Docker hosted repositories via:
8
+ * 1. Dedicated HTTP connector port (e.g. nexus.example.com:8082/my-app:tag)
9
+ * 2. Repository path routing (e.g. nexus.example.com/repository/docker-hosted/my-app:tag)
10
+ */
11
+ export class NexusDockerPublisher extends BaseDockerPublisher {
12
+ readonly id = 'nexus-docker'
13
+ readonly name = 'Nexus 3 Docker Registry'
14
+ readonly platform = 'nexus'
15
+ override readonly priority = 20
16
+
17
+ override detect(registry: RegistryConfig): boolean {
18
+ if (registry.type !== 'docker' && registry.type !== 'container') {
19
+ return false
20
+ }
21
+
22
+ const regName = (registry.name || '').toLowerCase()
23
+ if (regName === 'nexus' || regName.startsWith('nexus-') || regName === 'nexus-docker') {
24
+ return true
25
+ }
26
+
27
+ const server = (registry.server || '').toLowerCase()
28
+ try {
29
+ const parsed = new URL(server.includes('://') ? server : `https://${server}`)
30
+ const host = parsed.hostname.toLowerCase()
31
+
32
+ // Exclude other known platforms
33
+ if (
34
+ host === 'aliyun.com' ||
35
+ host.endsWith('.aliyun.com') ||
36
+ host.endsWith('.aliyuncs.com') ||
37
+ host === 'gitlab.com' ||
38
+ host.endsWith('.gitlab.com') ||
39
+ host === 'github.com' ||
40
+ host.endsWith('.github.com')
41
+ ) {
42
+ return false
43
+ }
44
+
45
+ return host.includes('nexus') || parsed.pathname.includes('/repository/docker')
46
+ } catch {
47
+ return false
48
+ }
49
+ }
50
+
51
+ override buildRepositoryUrl(registry: RegistryConfig, imageName: string): string {
52
+ let host = this.normalizeServerHost(registry.server)
53
+ const cleanImage = imageName.replace(/^\/+/, '')
54
+
55
+ // Check if server or namespace includes repository path
56
+ const ns = (registry.namespace || '').replace(/\/+$/, '')
57
+ if (ns) {
58
+ if (cleanImage.startsWith(`${ns}/`)) {
59
+ return `${host}/${cleanImage}`
60
+ }
61
+ return `${host}/${ns}/${cleanImage}`
62
+ }
63
+
64
+ return `${host}/${cleanImage}`
65
+ }
66
+ }
package/src/index.ts ADDED
@@ -0,0 +1,54 @@
1
+ import type { PluginContext } from '@ticatec/omniflow-core'
2
+ import type { ArtifactPublisherRegistry } from '@ticatec/omniflow-artifact'
3
+ import { NexusMavenPublisher } from './maven/NexusMavenPublisher.js'
4
+ import { NexusNpmPublisher } from './npm/NexusNpmPublisher.js'
5
+ import { NexusDockerPublisher } from './docker/NexusDockerPublisher.js'
6
+ import { NexusRawPublisher } from './raw/NexusRawPublisher.js'
7
+
8
+ export * from './client/NexusClient.js'
9
+ export * from './maven/NexusMavenPublisher.js'
10
+ export * from './npm/NexusNpmPublisher.js'
11
+ export * from './docker/NexusDockerPublisher.js'
12
+ export * from './raw/NexusRawPublisher.js'
13
+
14
+ export interface RegisterNexusOptions {
15
+ artifactRegistry?: ArtifactPublisherRegistry
16
+ }
17
+
18
+ /**
19
+ * Standard OmniFlow Plugin SPI entry point.
20
+ */
21
+ export function register(ctx: PluginContext, options: Record<string, unknown> = {}): void {
22
+ registerNexus({ artifactRegistry: ctx.artifactRegistry })
23
+ }
24
+
25
+ export default register
26
+
27
+ /**
28
+ * Register all Sonatype Nexus 3 artifact publishers into the target registry.
29
+ */
30
+ export function registerNexus(options: RegisterNexusOptions = {}): {
31
+ mavenPublisher: NexusMavenPublisher
32
+ npmPublisher: NexusNpmPublisher
33
+ dockerPublisher: NexusDockerPublisher
34
+ rawPublisher: NexusRawPublisher
35
+ } {
36
+ const mavenPublisher = new NexusMavenPublisher()
37
+ const npmPublisher = new NexusNpmPublisher()
38
+ const dockerPublisher = new NexusDockerPublisher()
39
+ const rawPublisher = new NexusRawPublisher()
40
+
41
+ if (options.artifactRegistry) {
42
+ options.artifactRegistry.register(mavenPublisher)
43
+ options.artifactRegistry.register(npmPublisher)
44
+ options.artifactRegistry.register(dockerPublisher)
45
+ options.artifactRegistry.register(rawPublisher)
46
+ }
47
+
48
+ return {
49
+ mavenPublisher,
50
+ npmPublisher,
51
+ dockerPublisher,
52
+ rawPublisher
53
+ }
54
+ }
@@ -0,0 +1,247 @@
1
+ import { promises as fs } from 'fs'
2
+ import path from 'path'
3
+ import crypto from 'crypto'
4
+ import { BasePublisher, resolveMavenArtifactFilenames, mavenPomPathFor, isMavenArtifactFile } from '@ticatec/omniflow-artifact'
5
+ import type { ArtifactPayload, PublishOptions, RegistryConfig } from '@ticatec/omniflow-artifact'
6
+ import { NexusClient } from '../client/NexusClient.js'
7
+
8
+ /**
9
+ * Sonatype Nexus 3 Maven Repository Publisher.
10
+ *
11
+ * Endpoint format: PUT {server}/repository/{repoName}/{groupId-as-path}/{artifactId}/{version}/{filename}
12
+ * Typically used with repositories like "maven-releases" or "maven-snapshots".
13
+ */
14
+ export class NexusMavenPublisher extends BasePublisher {
15
+ readonly id = 'nexus-maven'
16
+ readonly name = 'Nexus 3 Maven Repository'
17
+ readonly platform = 'nexus'
18
+ readonly artifactType = 'maven'
19
+ override readonly priority = 20
20
+
21
+ override detect(registry: RegistryConfig): boolean {
22
+ if (registry.type !== 'maven' && registry.type !== 'jar') {
23
+ return false
24
+ }
25
+
26
+ const regName = (registry.name || '').toLowerCase()
27
+ if (regName === 'nexus' || regName.startsWith('nexus-')) {
28
+ return true
29
+ }
30
+
31
+ const server = (registry.server || '').toLowerCase()
32
+ try {
33
+ const parsed = new URL(server.includes('://') ? server : `https://${server}`)
34
+ const host = parsed.hostname.toLowerCase()
35
+
36
+ // Exclude other known platforms
37
+ if (
38
+ host === 'aliyun.com' ||
39
+ host.endsWith('.aliyun.com') ||
40
+ host.endsWith('.aliyuncs.com') ||
41
+ host === 'gitlab.com' ||
42
+ host.endsWith('.gitlab.com') ||
43
+ host === 'github.com' ||
44
+ host.endsWith('.github.com')
45
+ ) {
46
+ return false
47
+ }
48
+
49
+ return host.includes('nexus') || parsed.pathname.includes('/repository/')
50
+ } catch {
51
+ return false
52
+ }
53
+ }
54
+
55
+ async login(registry: RegistryConfig, _options?: PublishOptions): Promise<void> {
56
+ const creds = this.resolveCredentials(registry)
57
+ if (!creds.token && (!creds.username || !creds.password)) {
58
+ this.log(` ℹ No auth credentials configured for Nexus Maven repository ${registry.server}`)
59
+ }
60
+ }
61
+
62
+ override buildTargetRef(artifact: ArtifactPayload, _registry: RegistryConfig): string {
63
+ try {
64
+ const { groupId, artifactId, version } = resolveMavenArtifactFilenames(artifact, undefined, undefined, 'Nexus')
65
+ return `${groupId}:${artifactId}:${version}`
66
+ } catch {
67
+ const version = artifact.tags && artifact.tags.length > 0 ? artifact.tags[0] : '1.0.0'
68
+ return `${artifact.name}:${version}`
69
+ }
70
+ }
71
+
72
+ override async exists(
73
+ artifact: ArtifactPayload,
74
+ registry: RegistryConfig,
75
+ options?: PublishOptions
76
+ ): Promise<boolean> {
77
+ const creds = this.resolveCredentials(registry)
78
+ const client = new NexusClient({
79
+ baseUrl: registry.server,
80
+ username: creds.username,
81
+ password: creds.password,
82
+ token: creds.token,
83
+ timeoutMs: options?.timeoutMs
84
+ })
85
+
86
+ let jarPath = artifact.source
87
+ if (jarPath) {
88
+ const stat = await fs.stat(jarPath).catch(() => null)
89
+ if (stat?.isDirectory()) {
90
+ const files = await fs.readdir(jarPath).catch(() => [] as string[])
91
+ const jarFile = files.find(
92
+ f => isMavenArtifactFile(f)
93
+ )
94
+ if (jarFile) {
95
+ jarPath = path.join(jarPath, jarFile)
96
+ }
97
+ }
98
+ }
99
+
100
+ const { groupId, artifactId, version, jarFilename } = resolveMavenArtifactFilenames(
101
+ artifact,
102
+ jarPath,
103
+ undefined,
104
+ 'Nexus'
105
+ )
106
+
107
+ const defaultRepo = version.toUpperCase().endsWith('-SNAPSHOT') ? 'maven-snapshots' : 'maven-releases'
108
+ const repoName =
109
+ registry.namespace?.trim() ||
110
+ (registry.options?.repository as string | undefined)?.trim() ||
111
+ ((registry as any).metadata?.repository as string | undefined)?.trim() ||
112
+ defaultRepo
113
+
114
+ const targetUrl = client.getMavenArtifactUrl(repoName, groupId, artifactId, version, jarFilename)
115
+
116
+ try {
117
+ const res = await client.request(targetUrl, { method: 'HEAD' })
118
+ if (res.ok || res.status === 200 || res.status === 204) {
119
+ return true
120
+ }
121
+ if (res.status === 404) {
122
+ return false
123
+ }
124
+ const err = await client.extractErrorMessage(res)
125
+ throw new Error(`Failed to check Maven artifact existence (${res.status}): ${err}`)
126
+ } catch (err: any) {
127
+ if (err?.message?.includes('Failed to check Maven artifact existence')) {
128
+ throw err
129
+ }
130
+ throw new Error(`Failed to check Maven artifact existence: ${client.maskSecrets(err?.message || String(err))}`)
131
+ }
132
+ }
133
+
134
+ protected async doPublish(
135
+ artifact: ArtifactPayload,
136
+ registry: RegistryConfig,
137
+ options?: PublishOptions
138
+ ): Promise<{
139
+ ref: string
140
+ digest?: string
141
+ metadata?: Record<string, unknown>
142
+ }> {
143
+ const creds = this.resolveCredentials(registry)
144
+ const client = new NexusClient({
145
+ baseUrl: registry.server,
146
+ username: creds.username,
147
+ password: creds.password,
148
+ token: creds.token,
149
+ timeoutMs: options?.timeoutMs
150
+ })
151
+
152
+ // Locate jar file
153
+ let jarPath = artifact.source
154
+ const stat = await fs.stat(jarPath).catch(() => null)
155
+ if (stat?.isDirectory()) {
156
+ const files = await fs.readdir(jarPath)
157
+ const jarFile = files.find(f => isMavenArtifactFile(f))
158
+ if (!jarFile) {
159
+ throw new Error(`No jar/war artifact found in directory: ${jarPath}`)
160
+ }
161
+ jarPath = path.join(jarPath, jarFile)
162
+ }
163
+
164
+ const { groupId, artifactId, version, jarFilename, pomFilename } = resolveMavenArtifactFilenames(
165
+ artifact,
166
+ jarPath,
167
+ undefined,
168
+ 'Nexus'
169
+ )
170
+
171
+ const defaultRepo = version.toUpperCase().endsWith('-SNAPSHOT') ? 'maven-snapshots' : 'maven-releases'
172
+ const repoName =
173
+ registry.namespace?.trim() ||
174
+ (registry.options?.repository as string | undefined)?.trim() ||
175
+ ((registry as any).metadata?.repository as string | undefined)?.trim() ||
176
+ defaultRepo
177
+
178
+ const jarBuffer = await fs.readFile(jarPath)
179
+ const digest = `sha256:${crypto.createHash('sha256').update(jarBuffer).digest('hex')}`
180
+
181
+ const targetUrl = client.getMavenArtifactUrl(repoName, groupId, artifactId, version, jarFilename)
182
+ this.log(` 📤 Uploading Maven JAR to Nexus: ${client.maskSecrets(targetUrl)}`)
183
+
184
+ if (options?.dryRun) {
185
+ this.log(` [dryRun] Would PUT ${jarFilename} (${jarBuffer.length} bytes) to ${client.maskSecrets(targetUrl)}`)
186
+ return {
187
+ ref: `${groupId}:${artifactId}:${version}`,
188
+ digest,
189
+ metadata: { repoName, targetUrl, size: jarBuffer.length }
190
+ }
191
+ }
192
+
193
+ // The POM goes up first, deliberately.
194
+ //
195
+ // The existence probe checks for the jar/war, so uploading that last is what
196
+ // makes the probe mean "this artifact is complete". With the artifact first, a
197
+ // run that uploaded it and then failed on the POM would leave a coordinate that
198
+ // probes as present: the retry skips it as already published, and the POM never
199
+ // arrives — a jar in the repository forever, with no dependency information.
200
+ // Upload pom if exists
201
+ const pomPath = mavenPomPathFor(jarPath)
202
+ const pomStat = await fs.stat(pomPath).catch(() => null)
203
+ if (pomStat?.isFile()) {
204
+ const pomBuffer = await fs.readFile(pomPath)
205
+ const pomTargetUrl = client.getMavenArtifactUrl(repoName, groupId, artifactId, version, pomFilename)
206
+ this.log(` 📤 Uploading Maven POM to Nexus: ${client.maskSecrets(pomTargetUrl)}`)
207
+
208
+ const pomRes = await client.request(pomTargetUrl, {
209
+ method: 'PUT',
210
+ headers: { 'Content-Type': 'application/xml' },
211
+ body: pomBuffer as unknown as BodyInit
212
+ })
213
+
214
+ if (!pomRes.ok && pomRes.status !== 201 && pomRes.status !== 200) {
215
+ const pomErr = await client.extractErrorMessage(pomRes)
216
+ throw new Error(`Failed to upload Maven POM to Nexus (${pomRes.status}): ${pomErr}`)
217
+ }
218
+ }
219
+
220
+ const res = await client.request(targetUrl, {
221
+ method: 'PUT',
222
+ headers: {
223
+ 'Content-Type': 'application/java-archive'
224
+ },
225
+ body: jarBuffer as unknown as BodyInit
226
+ })
227
+
228
+ if (!res.ok && res.status !== 201 && res.status !== 200) {
229
+ const errText = await client.extractErrorMessage(res)
230
+ throw new Error(`Failed to upload Maven JAR to Nexus (${res.status}): ${errText}`)
231
+ }
232
+
233
+ return {
234
+ ref: `${groupId}:${artifactId}:${version}`,
235
+ digest,
236
+ metadata: {
237
+ groupId,
238
+ artifactId,
239
+ version,
240
+ repoName,
241
+ targetUrl,
242
+ size: jarBuffer.length
243
+ }
244
+ }
245
+ }
246
+ }
247
+
@@ -0,0 +1,81 @@
1
+ import { BaseNpmPublisher } from '@ticatec/omniflow-artifact'
2
+ import type { RegistryConfig } from '@ticatec/omniflow-artifact'
3
+
4
+ /**
5
+ * Sonatype Nexus 3 NPM Package Registry Publisher.
6
+ *
7
+ * Endpoint format: {server}/repository/{repoName}/
8
+ * Typically used with repositories like "npm-hosted".
9
+ */
10
+ export class NexusNpmPublisher extends BaseNpmPublisher {
11
+ readonly id = 'nexus-npm'
12
+ readonly name = 'Nexus 3 NPM Repository'
13
+ readonly platform = 'nexus'
14
+ override readonly priority = 20
15
+
16
+ override detect(registry: RegistryConfig): boolean {
17
+ if (registry.type !== 'npm') {
18
+ return false
19
+ }
20
+
21
+ const regName = (registry.name || '').toLowerCase()
22
+ if (regName === 'nexus' || regName.startsWith('nexus-')) {
23
+ return true
24
+ }
25
+
26
+ const server = (registry.server || '').toLowerCase()
27
+ try {
28
+ const parsed = new URL(server.includes('://') ? server : `https://${server}`)
29
+ const host = parsed.hostname.toLowerCase()
30
+
31
+ // Exclude other known platforms
32
+ if (
33
+ host === 'aliyun.com' ||
34
+ host.endsWith('.aliyun.com') ||
35
+ host.endsWith('.aliyuncs.com') ||
36
+ host === 'gitlab.com' ||
37
+ host.endsWith('.gitlab.com') ||
38
+ host === 'github.com' ||
39
+ host.endsWith('.github.com')
40
+ ) {
41
+ return false
42
+ }
43
+
44
+ return host.includes('nexus') || parsed.pathname.includes('/repository/')
45
+ } catch {
46
+ return false
47
+ }
48
+ }
49
+
50
+ override normalizeRegistryUrl(
51
+ server: string,
52
+ _registryOrNamespace?: RegistryConfig | string
53
+ ): string {
54
+ let url = server.trim()
55
+ if (!/^https?:\/\//i.test(url)) {
56
+ url = `https://${url}`
57
+ }
58
+
59
+ if (url.includes('/repository/')) {
60
+ return url.endsWith('/') ? url : `${url}/`
61
+ }
62
+
63
+ let repoName: string | undefined
64
+ if (typeof _registryOrNamespace === 'string') {
65
+ repoName = _registryOrNamespace.trim()
66
+ } else if (_registryOrNamespace && typeof _registryOrNamespace === 'object') {
67
+ const reg = _registryOrNamespace as RegistryConfig & { metadata?: Record<string, unknown> }
68
+ repoName =
69
+ reg.namespace?.trim() ||
70
+ (reg.options?.repository as string | undefined)?.trim() ||
71
+ (reg.options?.repoName as string | undefined)?.trim() ||
72
+ (reg.metadata?.repository as string | undefined)?.trim() ||
73
+ (reg.metadata?.repoName as string | undefined)?.trim()
74
+ }
75
+
76
+ const cleanBase = url.replace(/\/+$/, '')
77
+ const targetRepo = repoName ? repoName.replace(/^\/+/, '').replace(/\/+$/, '') : 'npm-hosted'
78
+ return `${cleanBase}/repository/${targetRepo}/`
79
+ }
80
+ }
81
+