@ticatec/omniflow-aliyun 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.
Files changed (34) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +100 -0
  3. package/README_CN.md +100 -0
  4. package/dist/client/AliyunClient.d.ts +7 -0
  5. package/dist/client/AliyunClient.d.ts.map +1 -0
  6. package/dist/client/AliyunClient.js +6 -0
  7. package/dist/client/AliyunClient.js.map +1 -0
  8. package/dist/docker/AliyunAcrPublisher.d.ts +22 -0
  9. package/dist/docker/AliyunAcrPublisher.d.ts.map +1 -0
  10. package/dist/docker/AliyunAcrPublisher.js +54 -0
  11. package/dist/docker/AliyunAcrPublisher.js.map +1 -0
  12. package/dist/generic/AliyunGenericPublisher.d.ts +35 -0
  13. package/dist/generic/AliyunGenericPublisher.d.ts.map +1 -0
  14. package/dist/generic/AliyunGenericPublisher.js +212 -0
  15. package/dist/generic/AliyunGenericPublisher.js.map +1 -0
  16. package/dist/index.d.ts +29 -0
  17. package/dist/index.d.ts.map +1 -0
  18. package/dist/index.js +39 -0
  19. package/dist/index.js.map +1 -0
  20. package/dist/maven/AliyunMavenPublisher.d.ts +41 -0
  21. package/dist/maven/AliyunMavenPublisher.d.ts.map +1 -0
  22. package/dist/maven/AliyunMavenPublisher.js +261 -0
  23. package/dist/maven/AliyunMavenPublisher.js.map +1 -0
  24. package/dist/npm/AliyunNpmPublisher.d.ts +17 -0
  25. package/dist/npm/AliyunNpmPublisher.d.ts.map +1 -0
  26. package/dist/npm/AliyunNpmPublisher.js +63 -0
  27. package/dist/npm/AliyunNpmPublisher.js.map +1 -0
  28. package/package.json +58 -0
  29. package/src/client/AliyunClient.ts +14 -0
  30. package/src/docker/AliyunAcrPublisher.ts +61 -0
  31. package/src/generic/AliyunGenericPublisher.ts +273 -0
  32. package/src/index.ts +56 -0
  33. package/src/maven/AliyunMavenPublisher.ts +330 -0
  34. package/src/npm/AliyunNpmPublisher.ts +76 -0
@@ -0,0 +1,273 @@
1
+ import { promises as fs } from 'fs'
2
+ import path from 'path'
3
+ import crypto from 'crypto'
4
+ import { BasePublisher } from '@ticatec/omniflow-artifact'
5
+ import type { ArtifactPayload, DownloadOptions, DownloadResult, PublishOptions, RegistryConfig } from '@ticatec/omniflow-artifact'
6
+ import { safeFetch, extractErrorMessage, maskSecrets } from '../client/AliyunClient.js'
7
+
8
+ /**
9
+ * Aliyun Generic Packages & Object Storage Archive Publisher.
10
+ *
11
+ * Supports publishing tar.gz/zip frontend builds or binary packages to Aliyun Packages.
12
+ *
13
+ * Endpoint format: https://packages.aliyun.com/generic/repository/<repoId>/
14
+ * Or direct custom console URL: https://packages.aliyun.com/<orgId>/generic/<repoId>/
15
+ */
16
+ export class AliyunGenericPublisher extends BasePublisher {
17
+ readonly id = 'aliyun-generic'
18
+ readonly name = 'Aliyun Generic Archive'
19
+ readonly platform = 'aliyun-generic'
20
+ readonly artifactType = 'generic'
21
+ override readonly priority = 20
22
+
23
+ override detect(registry: RegistryConfig): boolean {
24
+ if (registry.type !== 'generic' && registry.type !== 'archive') {
25
+ return false
26
+ }
27
+
28
+ const regName = (registry.name || '').toLowerCase()
29
+ if (regName === 'aliyun' || regName.startsWith('aliyun-')) {
30
+ return true
31
+ }
32
+
33
+ const server = (registry.server || '').toLowerCase()
34
+ try {
35
+ const parsed = new URL(server.includes('://') ? server : `https://${server}`)
36
+ const host = parsed.hostname.toLowerCase()
37
+ const isAliyun =
38
+ host === 'aliyun.com' ||
39
+ host.endsWith('.aliyun.com') ||
40
+ host === 'aliyuncs.com' ||
41
+ host.endsWith('.aliyuncs.com')
42
+ return isAliyun
43
+ } catch {
44
+ return false
45
+ }
46
+ }
47
+
48
+ async login(registry: RegistryConfig, _options?: PublishOptions): Promise<void> {
49
+ const creds = this.resolveCredentials(registry)
50
+ if (!creds.token && !creds.username) {
51
+ this.log(` ℹ No credentials configured for Aliyun Generic storage ${registry.server}`)
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Resolve the base repository URL.
57
+ * If server already contains path segments (e.g. console URL), respect it directly (M6 fix).
58
+ * If bare host, requires a valid namespace/repoId (M4 fix).
59
+ */
60
+ protected resolveBaseRepoUrl(registry: RegistryConfig): string {
61
+ let serverUrl = registry.server.trim()
62
+ if (!/^https?:\/\//i.test(serverUrl)) {
63
+ serverUrl = `https://${serverUrl}`
64
+ }
65
+
66
+ const parsed = new URL(serverUrl)
67
+ const cleanServer = serverUrl.replace(/\/+$/, '')
68
+
69
+ // M6 Escape hatch: if server contains path segments beyond '/', use it as-is
70
+ if (parsed.pathname !== '/' && parsed.pathname !== '') {
71
+ return cleanServer
72
+ }
73
+
74
+ const reg = registry as RegistryConfig & { metadata?: Record<string, unknown> }
75
+ const repoId =
76
+ reg.namespace?.trim() ||
77
+ (reg.options?.repoId as string | undefined)?.trim() ||
78
+ (reg.metadata?.repoId as string | undefined)?.trim()
79
+ if (!repoId) {
80
+ throw new Error(
81
+ `Aliyun Generic publisher requires a valid repository ID (specify via registry.namespace or full server URL)`
82
+ )
83
+ }
84
+
85
+ return `${cleanServer}/generic/repository/${encodeURIComponent(repoId)}`
86
+ }
87
+
88
+ protected buildAuthHeaders(
89
+ username?: string,
90
+ token?: string,
91
+ secrets: string[] = []
92
+ ): Record<string, string> {
93
+ const headers: Record<string, string> = {}
94
+ if (username && token) {
95
+ const basic = Buffer.from(`${username}:${token}`).toString('base64')
96
+ if (basic.length >= 3) {
97
+ secrets.push(basic)
98
+ }
99
+ headers['Authorization'] = `Basic ${basic}`
100
+ } else if (token) {
101
+ if (token.includes(':')) {
102
+ const basic = Buffer.from(token).toString('base64')
103
+ if (basic.length >= 3) {
104
+ secrets.push(basic)
105
+ }
106
+ headers['Authorization'] = `Basic ${basic}`
107
+ } else {
108
+ headers['Authorization'] = `Bearer ${token}`
109
+ }
110
+ }
111
+ return headers
112
+ }
113
+
114
+ override buildTargetRef(artifact: ArtifactPayload, _registry: RegistryConfig): string {
115
+ const version = artifact.tags && artifact.tags.length > 0 ? artifact.tags[0] : 'latest'
116
+ const filename = artifact.source ? path.basename(artifact.source) : 'artifact'
117
+ return `${artifact.name}/${version}/${filename}`
118
+ }
119
+
120
+ override async exists(
121
+ artifact: ArtifactPayload,
122
+ registry: RegistryConfig,
123
+ options?: PublishOptions
124
+ ): Promise<boolean> {
125
+ const creds = this.resolveCredentials(registry)
126
+ const token = creds.token || creds.password
127
+ const username = creds.username
128
+ const secrets = [token, creds.password].filter(
129
+ (s): s is string => typeof s === 'string' && s.length >= 3
130
+ )
131
+
132
+ const version = artifact.tags && artifact.tags.length > 0 ? artifact.tags[0] : 'latest'
133
+ const packageName = artifact.name
134
+ const filename = artifact.source ? path.basename(artifact.source) : `${packageName}.tar.gz`
135
+
136
+ const baseRepoUrl = this.resolveBaseRepoUrl(registry)
137
+ const probeUrl = `${baseRepoUrl}/${encodeURIComponent(packageName)}/${encodeURIComponent(version)}/${encodeURIComponent(filename)}`
138
+
139
+ const headers = this.buildAuthHeaders(username, token, secrets)
140
+ const timeoutMs = options?.timeoutMs ?? 10000
141
+
142
+ try {
143
+ const res = await safeFetch(probeUrl, {
144
+ method: 'HEAD',
145
+ headers,
146
+ timeoutMs,
147
+ secrets
148
+ })
149
+
150
+ if (res.ok || res.status === 200 || res.status === 204) {
151
+ return true
152
+ }
153
+ if (res.status === 404) {
154
+ return false
155
+ }
156
+
157
+ const errText = await extractErrorMessage(res, secrets)
158
+ throw new Error(`Failed to check Generic artifact existence (${res.status}): ${errText}`)
159
+ } catch (err: any) {
160
+ if (err?.message?.includes('Failed to check Generic artifact existence')) {
161
+ throw err
162
+ }
163
+ throw new Error(`Failed to check Generic artifact existence: ${maskSecrets(err?.message || String(err), secrets)}`)
164
+ }
165
+ }
166
+
167
+ override async download(
168
+ artifact: ArtifactPayload,
169
+ registry: RegistryConfig,
170
+ dest: string,
171
+ options?: DownloadOptions
172
+ ): Promise<DownloadResult> {
173
+ const creds = this.resolveCredentials(registry)
174
+ const token = creds.token || creds.password
175
+ const secrets = [token, creds.password].filter(
176
+ (s): s is string => typeof s === 'string' && s.length >= 3
177
+ )
178
+
179
+ const version = artifact.tags?.[0] || 'latest'
180
+ const filename = artifact.source ? path.basename(artifact.source) : `${artifact.name}.tar.gz`
181
+ const baseRepoUrl = this.resolveBaseRepoUrl(registry)
182
+ const targetUrl = `${baseRepoUrl}/${encodeURIComponent(artifact.name)}/${encodeURIComponent(version)}/${encodeURIComponent(filename)}`
183
+ const masked = maskSecrets(targetUrl, secrets)
184
+
185
+ this.log(` 📥 Downloading ${artifact.name} ${version} from Aliyun: ${masked}`)
186
+ const res = await safeFetch(targetUrl, {
187
+ method: 'GET',
188
+ headers: this.buildAuthHeaders(creds.username, token, secrets),
189
+ timeoutMs: options?.timeoutMs ?? 30000,
190
+ secrets
191
+ })
192
+ if (!res.ok) {
193
+ throw await this.downloadError(res, `${artifact.name} ${version} (${filename})`, masked)
194
+ }
195
+ return this.saveResponse(res, dest, masked)
196
+ }
197
+
198
+ protected async doPublish(
199
+ artifact: ArtifactPayload,
200
+ registry: RegistryConfig,
201
+ options?: PublishOptions
202
+ ): Promise<{
203
+ ref: string
204
+ digest?: string
205
+ metadata?: Record<string, unknown>
206
+ }> {
207
+ const creds = this.resolveCredentials(registry)
208
+ const token = creds.token || creds.password
209
+ const username = creds.username
210
+ const secrets = [token, creds.password].filter(
211
+ (s): s is string => typeof s === 'string' && s.length >= 3
212
+ )
213
+
214
+ const version = artifact.tags && artifact.tags.length > 0 ? artifact.tags[0] : 'latest'
215
+ const packageName = artifact.name
216
+
217
+ let filePath = artifact.source
218
+ const stat = await fs.stat(filePath).catch(() => null)
219
+ if (!stat || !stat.isFile()) {
220
+ throw new Error(`Generic artifact source must be an existing file: ${filePath}`)
221
+ }
222
+
223
+ const filename = path.basename(filePath)
224
+ const fileBuffer = await fs.readFile(filePath)
225
+ const digest = `sha256:${crypto.createHash('sha256').update(fileBuffer).digest('hex')}`
226
+
227
+ const baseRepoUrl = this.resolveBaseRepoUrl(registry)
228
+ const targetUrl = `${baseRepoUrl}/${encodeURIComponent(packageName)}/${encodeURIComponent(version)}/${encodeURIComponent(filename)}`
229
+
230
+ this.log(` 📤 Uploading Generic package to Aliyun: ${targetUrl}`)
231
+
232
+ if (options?.dryRun) {
233
+ this.log(` [dryRun] Would PUT ${filename} (${fileBuffer.length} bytes) to ${targetUrl}`)
234
+ return {
235
+ ref: `${packageName}/${version}/${filename}`,
236
+ digest,
237
+ metadata: { targetUrl, size: fileBuffer.length }
238
+ }
239
+ }
240
+
241
+ const headers: Record<string, string> = {
242
+ 'Content-Type': 'application/octet-stream',
243
+ ...this.buildAuthHeaders(username, token, secrets)
244
+ }
245
+
246
+ const timeoutMs = options?.timeoutMs ?? 30000
247
+
248
+ const res = await safeFetch(targetUrl, {
249
+ method: 'PUT',
250
+ headers,
251
+ body: fileBuffer,
252
+ timeoutMs,
253
+ secrets
254
+ })
255
+
256
+ if (!res.ok && res.status !== 201 && res.status !== 200) {
257
+ const errText = await extractErrorMessage(res, secrets)
258
+ throw new Error(`Failed to upload Generic package to Aliyun (${res.status}): ${errText}`)
259
+ }
260
+
261
+ return {
262
+ ref: `${packageName}/${version}/${filename}`,
263
+ digest,
264
+ metadata: {
265
+ packageName,
266
+ version,
267
+ filename,
268
+ targetUrl,
269
+ size: fileBuffer.length
270
+ }
271
+ }
272
+ }
273
+ }
package/src/index.ts ADDED
@@ -0,0 +1,56 @@
1
+ import type { PluginContext } from '@ticatec/omniflow-core'
2
+ import type { ArtifactPublisherRegistry } from '@ticatec/omniflow-artifact'
3
+ import { AliyunAcrPublisher } from './docker/AliyunAcrPublisher.js'
4
+ import { AliyunNpmPublisher } from './npm/AliyunNpmPublisher.js'
5
+ import { AliyunMavenPublisher } from './maven/AliyunMavenPublisher.js'
6
+ import { AliyunGenericPublisher } from './generic/AliyunGenericPublisher.js'
7
+
8
+ export * from './docker/AliyunAcrPublisher.js'
9
+ export * from './npm/AliyunNpmPublisher.js'
10
+ export * from './maven/AliyunMavenPublisher.js'
11
+ export * from './generic/AliyunGenericPublisher.js'
12
+ export * from './client/AliyunClient.js'
13
+
14
+ export interface RegisterAliyunOptions {
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
+ const artifactsEnabled = options.artifacts !== false
23
+ registerAliyun({ artifactRegistry: artifactsEnabled ? ctx.artifactRegistry : undefined })
24
+ }
25
+
26
+ export default register
27
+
28
+ /**
29
+ * Register all official Aliyun artifact publishers (ACR, NPM, Maven, Generic) into the target registry.
30
+ */
31
+ export function registerAliyun(options: RegisterAliyunOptions = {}): {
32
+ acrPublisher: AliyunAcrPublisher
33
+ npmPublisher: AliyunNpmPublisher
34
+ mavenPublisher: AliyunMavenPublisher
35
+ genericPublisher: AliyunGenericPublisher
36
+ } {
37
+ const acrPublisher = new AliyunAcrPublisher()
38
+ const npmPublisher = new AliyunNpmPublisher()
39
+ const mavenPublisher = new AliyunMavenPublisher()
40
+ const genericPublisher = new AliyunGenericPublisher()
41
+
42
+ if (options.artifactRegistry) {
43
+ options.artifactRegistry.register(acrPublisher)
44
+ options.artifactRegistry.register(npmPublisher)
45
+ options.artifactRegistry.register(mavenPublisher)
46
+ options.artifactRegistry.register(genericPublisher)
47
+ }
48
+
49
+ return {
50
+ acrPublisher,
51
+ npmPublisher,
52
+ mavenPublisher,
53
+ genericPublisher
54
+ }
55
+ }
56
+
@@ -0,0 +1,330 @@
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 { safeFetch, extractErrorMessage, maskSecrets } from '../client/AliyunClient.js'
7
+
8
+ /**
9
+ * Aliyun Apsara DevOps (云效) Maven Package Registry Publisher.
10
+ *
11
+ * Endpoint format: https://packages.aliyun.com/maven/repository/<repoId>/
12
+ * Or direct custom console URL: https://packages.aliyun.com/<orgId>/maven/<repoId>/
13
+ */
14
+ export class AliyunMavenPublisher extends BasePublisher {
15
+ readonly id = 'aliyun-maven'
16
+ readonly name = 'Aliyun Packages Maven'
17
+ readonly platform = 'aliyun-maven'
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 === 'aliyun' || regName.startsWith('aliyun-')) {
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
+ const segments = host.split('.')
36
+ const isAliyun = host === 'aliyun.com' || host.endsWith('.aliyun.com')
37
+ const isPackages = segments.includes('packages') || host === 'packages.aliyun.com'
38
+ return isAliyun && isPackages
39
+ } catch {
40
+ return false
41
+ }
42
+ }
43
+
44
+ async login(registry: RegistryConfig, _options?: PublishOptions): Promise<void> {
45
+ const creds = this.resolveCredentials(registry)
46
+ if (!creds.token && !creds.username) {
47
+ this.log(` ℹ No credentials configured for Aliyun Maven repository ${registry.server}`)
48
+ } else if (creds.token && !creds.username && !creds.token.includes(':')) {
49
+ this.warn(` ⚠ Aliyun Maven repository typically requires username + password/token for Basic authentication`)
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Resolve Maven coordinates (groupId, artifactId, version) from artifact.
55
+ * Throws an error if groupId is missing rather than guessing 'com.aliyun' (M4 fix).
56
+ */
57
+ protected resolveCoordinates(artifact: ArtifactPayload): {
58
+ groupId: string
59
+ artifactId: string
60
+ version: string
61
+ } {
62
+ let groupId = (artifact.metadata?.groupId as string | undefined)?.trim()
63
+ let artifactId = (artifact.metadata?.artifactId as string | undefined)?.trim()
64
+
65
+ if (!groupId && artifact.name.includes(':')) {
66
+ const parts = artifact.name.split(':')
67
+ groupId = parts[0].trim()
68
+ artifactId = artifactId || parts[1].trim()
69
+ }
70
+
71
+ if (!groupId) {
72
+ throw new Error(
73
+ `Aliyun Maven publisher requires a valid 'groupId' (specify via artifact.metadata.groupId or 'groupId:artifactId' name)`
74
+ )
75
+ }
76
+
77
+ if (!artifactId) {
78
+ artifactId = artifact.name.trim()
79
+ }
80
+
81
+ const version =
82
+ artifact.tags && artifact.tags.length > 0 ? artifact.tags[0] : (artifact.metadata?.version as string) || '1.0.0'
83
+
84
+ return { groupId, artifactId, version }
85
+ }
86
+
87
+ /**
88
+ * Resolve the base repository URL.
89
+ * If server already contains path segments (e.g. console URL), respect it directly (M6 fix).
90
+ * If bare host, requires a valid namespace/repoId (M4 fix).
91
+ */
92
+ protected resolveBaseRepoUrl(registry: RegistryConfig): string {
93
+ let serverUrl = registry.server.trim()
94
+ if (!/^https?:\/\//i.test(serverUrl)) {
95
+ serverUrl = `https://${serverUrl}`
96
+ }
97
+
98
+ const parsed = new URL(serverUrl)
99
+ const cleanServer = serverUrl.replace(/\/+$/, '')
100
+
101
+ // M6 Escape hatch: if server contains path segments beyond '/', use it as-is
102
+ if (parsed.pathname !== '/' && parsed.pathname !== '') {
103
+ return cleanServer
104
+ }
105
+
106
+ const reg = registry as RegistryConfig & { metadata?: Record<string, unknown> }
107
+ const repoId =
108
+ reg.namespace?.trim() ||
109
+ (reg.options?.repoId as string | undefined)?.trim() ||
110
+ (reg.metadata?.repoId as string | undefined)?.trim()
111
+ if (!repoId) {
112
+ throw new Error(
113
+ `Aliyun Maven publisher requires a valid repository ID (specify via registry.namespace or full server URL)`
114
+ )
115
+ }
116
+
117
+ return `${cleanServer}/maven/repository/${encodeURIComponent(repoId)}`
118
+ }
119
+
120
+ protected buildAuthHeaders(
121
+ username?: string,
122
+ token?: string,
123
+ secrets: string[] = []
124
+ ): Record<string, string> {
125
+ const headers: Record<string, string> = {}
126
+ if (username && token) {
127
+ const basic = Buffer.from(`${username}:${token}`).toString('base64')
128
+ if (basic.length >= 3) {
129
+ secrets.push(basic)
130
+ }
131
+ headers['Authorization'] = `Basic ${basic}`
132
+ } else if (token) {
133
+ if (token.includes(':')) {
134
+ const basic = Buffer.from(token).toString('base64')
135
+ if (basic.length >= 3) {
136
+ secrets.push(basic)
137
+ }
138
+ headers['Authorization'] = `Basic ${basic}`
139
+ } else {
140
+ headers['Authorization'] = `Bearer ${token}`
141
+ }
142
+ }
143
+ return headers
144
+ }
145
+
146
+ override buildTargetRef(artifact: ArtifactPayload, _registry: RegistryConfig): string {
147
+ try {
148
+ const { groupId, artifactId, version } = this.resolveCoordinates(artifact)
149
+ return `${groupId}:${artifactId}:${version}`
150
+ } catch {
151
+ const version = artifact.tags && artifact.tags.length > 0 ? artifact.tags[0] : '1.0.0'
152
+ return `${artifact.name}:${version}`
153
+ }
154
+ }
155
+
156
+ override async exists(
157
+ artifact: ArtifactPayload,
158
+ registry: RegistryConfig,
159
+ options?: PublishOptions
160
+ ): Promise<boolean> {
161
+ const creds = this.resolveCredentials(registry)
162
+ const token = creds.token || creds.password
163
+ const username = creds.username
164
+ const secrets = [token, creds.password].filter(
165
+ (s): s is string => typeof s === 'string' && s.length >= 3
166
+ )
167
+
168
+ let jarPath = artifact.source
169
+ if (jarPath) {
170
+ const stat = await fs.stat(jarPath).catch(() => null)
171
+ if (stat?.isDirectory()) {
172
+ const files = await fs.readdir(jarPath).catch(() => [] as string[])
173
+ const jarFile = files.find(
174
+ f => isMavenArtifactFile(f)
175
+ )
176
+ if (jarFile) {
177
+ jarPath = path.join(jarPath, jarFile)
178
+ }
179
+ }
180
+ }
181
+
182
+ const { groupId, artifactId, version, groupPath, jarFilename } = resolveMavenArtifactFilenames(artifact, jarPath, undefined, 'Aliyun')
183
+
184
+ const baseRepoUrl = this.resolveBaseRepoUrl(registry)
185
+ const probeUrl = `${baseRepoUrl}/${groupPath}/${artifactId}/${version}/${jarFilename}`
186
+
187
+ const headers = this.buildAuthHeaders(username, token, secrets)
188
+ const timeoutMs = options?.timeoutMs ?? 10000
189
+
190
+ try {
191
+ const res = await safeFetch(probeUrl, {
192
+ method: 'HEAD',
193
+ headers,
194
+ timeoutMs,
195
+ secrets
196
+ })
197
+
198
+ if (res.ok || res.status === 200 || res.status === 204) {
199
+ return true
200
+ }
201
+ if (res.status === 404) {
202
+ return false
203
+ }
204
+
205
+ // Fail-close on authorization or server errors
206
+ const errText = await extractErrorMessage(res, secrets)
207
+ throw new Error(`Failed to check Maven artifact existence (${res.status}): ${errText}`)
208
+ } catch (err: any) {
209
+ if (err?.message?.includes('Failed to check Maven artifact existence')) {
210
+ throw err
211
+ }
212
+ throw new Error(`Failed to check Maven artifact existence: ${maskSecrets(err?.message || String(err), secrets)}`)
213
+ }
214
+ }
215
+
216
+ protected async doPublish(
217
+ artifact: ArtifactPayload,
218
+ registry: RegistryConfig,
219
+ options?: PublishOptions
220
+ ): Promise<{
221
+ ref: string
222
+ digest?: string
223
+ metadata?: Record<string, unknown>
224
+ }> {
225
+ const creds = this.resolveCredentials(registry)
226
+ const token = creds.token || creds.password
227
+ const username = creds.username
228
+ const secrets = [token, creds.password].filter(
229
+ (s): s is string => typeof s === 'string' && s.length >= 3
230
+ )
231
+
232
+ // Locate jar file
233
+ let jarPath = artifact.source
234
+ const stat = await fs.stat(jarPath).catch(() => null)
235
+ if (stat?.isDirectory()) {
236
+ const files = await fs.readdir(jarPath)
237
+ const jarFile = files.find(
238
+ f => isMavenArtifactFile(f)
239
+ )
240
+ if (!jarFile) {
241
+ throw new Error(`No jar/war artifact found in directory: ${jarPath}`)
242
+ }
243
+ jarPath = path.join(jarPath, jarFile)
244
+ }
245
+
246
+ const { groupId, artifactId, version, groupPath, jarFilename, pomFilename } = resolveMavenArtifactFilenames(artifact, jarPath, undefined, 'Aliyun')
247
+
248
+ const jarBuffer = await fs.readFile(jarPath)
249
+ const digest = `sha256:${crypto.createHash('sha256').update(jarBuffer).digest('hex')}`
250
+
251
+ const baseRepoUrl = this.resolveBaseRepoUrl(registry)
252
+ const targetUrl = `${baseRepoUrl}/${groupPath}/${artifactId}/${version}/${jarFilename}`
253
+
254
+ this.log(` 📤 Uploading Maven JAR to Aliyun Packages: ${targetUrl}`)
255
+
256
+ if (options?.dryRun) {
257
+ this.log(` [dryRun] Would PUT ${jarFilename} (${jarBuffer.length} bytes) to ${targetUrl}`)
258
+ return {
259
+ ref: `${groupId}:${artifactId}:${version}`,
260
+ digest,
261
+ metadata: { targetUrl, size: jarBuffer.length }
262
+ }
263
+ }
264
+
265
+ const headers: Record<string, string> = {
266
+ 'Content-Type': 'application/java-archive',
267
+ ...this.buildAuthHeaders(username, token, secrets)
268
+ }
269
+
270
+ const timeoutMs = options?.timeoutMs ?? 30000
271
+
272
+ // The POM goes up first, deliberately.
273
+ //
274
+ // The existence probe checks for the jar/war, so uploading that last is what
275
+ // makes the probe mean "this artifact is complete". With the artifact first, a
276
+ // run that uploaded it and then failed on the POM would leave a coordinate that
277
+ // probes as present: the retry skips it as already published, and the POM never
278
+ // arrives — a jar in the repository forever, with no dependency information.
279
+ // Check if matching .pom exists and upload it as well (M2 verification)
280
+ const pomPath = mavenPomPathFor(jarPath)
281
+ const pomStat = await fs.stat(pomPath).catch(() => null)
282
+ if (pomStat?.isFile()) {
283
+ const pomBuffer = await fs.readFile(pomPath)
284
+ const pomFilename = `${artifactId}-${version}.pom`
285
+ const pomTargetUrl = `${baseRepoUrl}/${groupPath}/${artifactId}/${version}/${pomFilename}`
286
+ this.log(` 📤 Uploading Maven POM to Aliyun Packages: ${pomTargetUrl}`)
287
+
288
+ const pomRes = await safeFetch(pomTargetUrl, {
289
+ method: 'PUT',
290
+ headers: {
291
+ ...headers,
292
+ 'Content-Type': 'application/xml'
293
+ },
294
+ body: pomBuffer,
295
+ timeoutMs,
296
+ secrets
297
+ })
298
+
299
+ if (!pomRes.ok && pomRes.status !== 201 && pomRes.status !== 200) {
300
+ const errText = await extractErrorMessage(pomRes, secrets)
301
+ throw new Error(`Failed to upload Maven POM to Aliyun Packages (${pomRes.status}): ${errText}`)
302
+ }
303
+ }
304
+
305
+ const res = await safeFetch(targetUrl, {
306
+ method: 'PUT',
307
+ headers,
308
+ body: jarBuffer,
309
+ timeoutMs,
310
+ secrets
311
+ })
312
+
313
+ if (!res.ok && res.status !== 201 && res.status !== 200) {
314
+ const errText = await extractErrorMessage(res, secrets)
315
+ throw new Error(`Failed to upload Maven JAR to Aliyun Packages (${res.status}): ${errText}`)
316
+ }
317
+
318
+ return {
319
+ ref: `${groupId}:${artifactId}:${version}`,
320
+ digest,
321
+ metadata: {
322
+ groupId,
323
+ artifactId,
324
+ version,
325
+ targetUrl,
326
+ size: jarBuffer.length
327
+ }
328
+ }
329
+ }
330
+ }