@metaplay/metaplay-auth 1.9.1 → 1.9.3-next.878

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.
Binary file
package/index.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  import { Command } from 'commander'
3
3
  import Docker from 'dockerode'
4
- import { writeFile } from 'fs/promises'
5
- import { exit } from 'process'
4
+ import { writeFile } from 'node:fs/promises'
5
+ import { exit } from 'node:process'
6
6
  import * as semver from 'semver'
7
7
 
8
8
  import { KubeConfig } from '@kubernetes/client-node'
@@ -13,7 +13,7 @@ import { portalBaseUrl, setPortalBaseUrl } from './src/config.js'
13
13
  import { checkGameServerDeployment, debugGameServer } from './src/deployment.js'
14
14
  import { logger, setLogLevel } from './src/logging.js'
15
15
  import { TargetEnvironment } from './src/targetenvironment.js'
16
- import { isValidFQDN, removeTrailingSlash, fetchHelmChartVersions, resolveBestMatchingVersion, executeHelmCommand, getGameServerHelmRelease } from './src/utils.js'
16
+ import { isValidFQDN, removeTrailingSlash, fetchHelmChartVersions, resolveBestMatchingVersion, checkHelmVersion, executeHelmCommand, getGameServerHelmRelease } from './src/utils.js'
17
17
  import { PACKAGE_VERSION } from './src/version.js'
18
18
 
19
19
  /** Stack API base url override, specified with the '--stack-api' global flag. */
@@ -250,6 +250,10 @@ program
250
250
  setLogLevel(10)
251
251
  }
252
252
 
253
+ // Show a deprecation warning.
254
+ console.warn('Warning: The metaplay-auth CLI is deprecated and will only receive critical updates.')
255
+ console.warn(' Please upgrade to Metaplay SDK release 32 or newer and the new Metaplay CLI (https://github.com/metaplay/cli).')
256
+
253
257
  // Store the portal base URL for accessing globally
254
258
  const overridePortalUrl: string | undefined = opts.portalBaseUrl ?? process.env.AUTHCLI_PORTAL_BASEURL
255
259
  if (overridePortalUrl) {
@@ -275,29 +279,34 @@ program
275
279
  .description('login to the Metaplay cloud using a machine account (using credentials in environment variable METAPLAY_CREDENTIALS)')
276
280
  .option('--dev-credentials', 'machine user credentials to use, only for dev purposes, use METAPLAY_CREDENTIALS env variable for better safety!')
277
281
  .action(async (options) => {
278
- // Get credentials from command line or from METAPLAY_CREDENTIALS environment variable
279
- let credentials: string
280
- if (options.devCredentials) {
281
- credentials = options.devCredentials
282
- } else {
283
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
284
- credentials = process.env.METAPLAY_CREDENTIALS!
285
- if (!credentials || credentials === '') {
286
- throw new Error('Unable to find the credentials, the environment variable METAPLAY_CREDENTIALS is not defined!')
282
+ try {
283
+ // Get credentials from command line or from METAPLAY_CREDENTIALS environment variable
284
+ let credentials: string
285
+ if (options.devCredentials) {
286
+ credentials = options.devCredentials
287
+ } else {
288
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
289
+ credentials = process.env.METAPLAY_CREDENTIALS!
290
+ if (!credentials || credentials === '') {
291
+ throw new Error('Unable to find the credentials, the environment variable METAPLAY_CREDENTIALS is not defined!')
292
+ }
287
293
  }
288
- }
289
294
 
290
- // Parse the clientId and clientSecret from the credentials (separate by a '+' character)
291
- // \note We can't be certain that the secret does not contain pluses so split at the first occurrence
292
- const splitOffset = credentials.indexOf('+')
293
- if (splitOffset === -1) {
294
- throw new Error('Invalid format for credentials, you should copy-paste the value from the developer portal verbatim')
295
- }
296
- const clientId = credentials.substring(0, splitOffset)
297
- const clientSecret = credentials.substring(splitOffset + 1)
295
+ // Parse the clientId and clientSecret from the credentials (separate by a '+' character)
296
+ // \note We can't be certain that the secret does not contain pluses so split at the first occurrence
297
+ const splitOffset = credentials.indexOf('+')
298
+ if (splitOffset === -1) {
299
+ throw new Error('Invalid format for credentials, you should copy-paste the value from the developer portal verbatim')
300
+ }
301
+ const clientId = credentials.substring(0, splitOffset)
302
+ const clientSecret = credentials.substring(splitOffset + 1)
298
303
 
299
- // Login with machine user and save the tokens
300
- await machineLoginAndSaveTokens(clientId, clientSecret)
304
+ // Login with machine user and save the tokens
305
+ await machineLoginAndSaveTokens(clientId, clientSecret)
306
+ } catch (error) {
307
+ console.error(`Error during machine login: ${error instanceof Error ? error.message : String(error)}`)
308
+ exit(1)
309
+ }
301
310
  })
302
311
 
303
312
  program
@@ -716,7 +725,10 @@ program
716
725
  })
717
726
  } catch (error) {
718
727
  const errMessage = error instanceof Error ? error.message : String(error)
719
- throw new Error(`Failed to fetch docker image ${imageName} from target environment registry: ${errMessage}`)
728
+ throw new Error(
729
+ `Failed to fetch docker image ${imageName} from target environment registry: ${errMessage}`,
730
+ { cause: error }
731
+ )
720
732
  }
721
733
 
722
734
  // Resolve the labels
@@ -726,7 +738,10 @@ program
726
738
  imageLabels = (await localDockerImage.inspect()).Config.Labels || {}
727
739
  } catch (error) {
728
740
  const errMessage = error instanceof Error ? error.message : String(error)
729
- throw new Error(`Failed to resolve docker image metadata from pulled image ${imageName}: ${errMessage}`)
741
+ throw new Error(
742
+ `Failed to resolve docker image metadata from pulled image ${imageName}: ${errMessage}`,
743
+ { cause: error }
744
+ )
730
745
  }
731
746
  }
732
747
 
@@ -763,7 +778,10 @@ program
763
778
  try {
764
779
  helmChartRange = new semver.Range(helmChartVersionSpec)
765
780
  } catch (error) {
766
- throw new Error(`Helm chart version '${helmChartVersionSpec}' is not a valid SemVer range: ${String(error)}`)
781
+ throw new Error(
782
+ `Helm chart version '${helmChartVersionSpec}' is not a valid SemVer range: ${String(error)}`,
783
+ { cause: error }
784
+ )
767
785
  }
768
786
  }
769
787
 
@@ -783,6 +801,9 @@ program
783
801
  }
784
802
  logger.debug('Resolved Helm chart version: ', resolvedHelmChartVersion)
785
803
 
804
+ // Check that the Helm version is compatible
805
+ await checkHelmVersion()
806
+
786
807
  // Fetch kubeconfig and write it to a temporary file
787
808
  // \todo allow passing a custom kubeconfig file?
788
809
  const kubeconfigPayload = await targetEnv.getKubeConfigWithEmbeddedCredentials()
@@ -866,6 +887,9 @@ program
866
887
  exit(0)
867
888
  }
868
889
 
890
+ // Check that the Helm version is compatible
891
+ await checkHelmVersion()
892
+
869
893
  // Construct Helm invocation.
870
894
  const deploymentName = gameServerHelmRelease.name
871
895
  const helmArgs = ['uninstall', '--wait'] // \note waits for resources to be deleted before returning
@@ -915,7 +939,7 @@ program
915
939
  kubeconfig.loadFromString(kubeconfigPayload)
916
940
  } catch (error) {
917
941
  const errMessage = error instanceof Error ? error.message : String(error)
918
- throw new Error(`Failed to load or validate kubeconfig. ${errMessage}`)
942
+ throw new Error(`Failed to load or validate kubeconfig. ${errMessage}`, { cause: error })
919
943
  }
920
944
 
921
945
  // Resolve Helm deployment
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@metaplay/metaplay-auth",
3
3
  "description": "Utility CLI for authenticating with the Metaplay Auth and making authenticated calls to infrastructure endpoints.",
4
- "version": "1.9.1",
4
+ "version": "1.9.3-next.878",
5
5
  "type": "module",
6
6
  "license": "SEE LICENSE IN LICENSE",
7
7
  "homepage": "https://metaplay.io",
@@ -17,29 +17,28 @@
17
17
  },
18
18
  "devDependencies": {
19
19
  "@metaplay/eslint-config": "workspace:*",
20
- "@types/dockerode": "3.3.39",
21
- "@types/express": "5.0.2",
22
- "@types/js-yaml": "4.0.9",
23
- "@types/jsonwebtoken": "9.0.9",
20
+ "@types/dockerode": "3.3.44",
21
+ "@types/express": "5.0.3",
22
+ "@types/js-yaml": "^4.0.9",
23
+ "@types/jsonwebtoken": "9.0.10",
24
24
  "@types/jwk-to-pem": "2.0.3",
25
- "@types/node": "22.15.29",
26
- "@types/semver": "7.7.0",
27
- "esbuild": "0.25.5",
28
- "tsx": "4.19.4",
29
- "typescript": "5.8.3",
30
- "vitest": "3.1.3",
31
- "@aws-sdk/client-ecr": "3.810.0",
32
- "@kubernetes/client-node": "1.0.0-rc7",
33
- "@ory/client": "1.20.11",
34
- "commander": "12.1.0",
35
- "dockerode": "4.0.6",
36
- "h3": "1.15.3",
37
- "js-yaml": "4.1.0",
25
+ "@types/node": "24.5.2",
26
+ "@types/semver": "7.7.1",
27
+ "esbuild": "0.25.10",
28
+ "tsx": "4.20.5",
29
+ "typescript": "5.9.2",
30
+ "vitest": "3.2.4",
31
+ "@aws-sdk/client-ecr": "3.891.0",
32
+ "@kubernetes/client-node": "1.3.0",
33
+ "commander": "14.0.1",
34
+ "dockerode": "4.0.8",
35
+ "h3": "1.15.4",
36
+ "js-yaml": "^4.1.0",
38
37
  "jsonwebtoken": "9.0.2",
39
38
  "jwk-to-pem": "2.0.7",
40
39
  "open": "8.4.2",
41
40
  "semver": "7.7.2",
42
41
  "tslog": "4.9.3",
43
- "eslint": "9.31.0"
42
+ "eslint": "9.35.0"
44
43
  }
45
44
  }
package/src/auth.ts CHANGED
@@ -1,3 +1,4 @@
1
+ /* eslint-disable @typescript-eslint/prefer-nullish-coalescing */
1
2
 
2
3
  import { toNodeListener, createApp, defineEventHandler, getQuery, sendError } from 'h3'
3
4
  import jwt, { JwtPayload } from 'jsonwebtoken'
@@ -6,8 +7,6 @@ import { randomBytes, createHash } from 'node:crypto'
6
7
  import { createServer } from 'node:http'
7
8
  import open from 'open'
8
9
 
9
- import { Configuration, OidcApi } from '@ory/client'
10
-
11
10
  import { portalBaseUrl } from './config.js'
12
11
  import { logger } from './logging.js'
13
12
  import { setSecret, getSecret, removeSecret } from './secret_store.js'
@@ -23,11 +22,6 @@ const clientId = 'c16ea663-ced3-46c6-8f85-38c9681fe1f0'
23
22
  const baseURL = 'https://auth.metaplay.dev'
24
23
  const authorizationEndpoint = `${baseURL}/oauth2/auth`
25
24
  const tokenEndpoint = `${baseURL}/oauth2/token`
26
- const oidcApi = new OidcApi(
27
- new Configuration({
28
- basePath: baseURL,
29
- })
30
- )
31
25
 
32
26
  /**
33
27
  * A helper function which generates a code verifier and challenge for exchanging code from Ory server.
@@ -45,16 +39,9 @@ function generateCodeVerifierAndChallenge(): { verifier: string; challenge: stri
45
39
  * @returns An object containing the user's info.
46
40
  */
47
41
  export async function getUserinfo(token: string): Promise<any> {
48
- logger.debug('Trying to find OIDC well-known endpoints...')
49
- const oidcRes = await oidcApi.discoverOidcConfiguration()
50
-
51
- const userinfoEndpoint = oidcRes.data?.userinfo_endpoint
52
- if (!userinfoEndpoint) {
53
- throw new Error('No userinfo endpoint found in OIDC configuration')
54
- }
55
- logger.debug(`Found userinfo endpoint: ${userinfoEndpoint}`)
56
-
57
- const userinfoRes = await fetch(userinfoEndpoint, {
42
+ // To relieve pressure on Ory endpoints, get the userinfo directly from portal,
43
+ // instead of discovering OIDC endpoints from Ory first. Portal is where Ory would redirect the request anyway.
44
+ const userinfoRes = await fetch(`${portalBaseUrl}/api/external/userinfo`, {
58
45
  headers: {
59
46
  Authorization: `Bearer ${token}`,
60
47
  },
@@ -201,8 +188,30 @@ export async function machineLoginAndSaveTokens(clientId: string, clientSecret:
201
188
  body: params.toString(),
202
189
  })
203
190
 
204
- // Return type checked manually by Teemu on 2024-3-7.
205
- const tokens = (await res.json()) as { access_token: string; token_type: string; expires_in: number; scope: string }
191
+ // Improved error handling for response
192
+ let tokens: { access_token: string; token_type: string; expires_in: number; scope: string }
193
+ if (!res.ok) {
194
+ let message: string | undefined
195
+ try {
196
+ const errorBody: any = await res.json()
197
+ message = errorBody && (errorBody.error_description || errorBody.error || errorBody.message)
198
+ } catch (_error) {
199
+ try {
200
+ message = await res.text()
201
+ } catch (_error) {
202
+ message = undefined
203
+ }
204
+ }
205
+ throw new Error(`Machine login failed: ${message || `HTTP ${res.status}`}`)
206
+ }
207
+ try {
208
+ tokens = (await res.json()) as { access_token: string; token_type: string; expires_in: number; scope: string }
209
+ } catch (error) {
210
+ throw new Error(
211
+ "Invalid response from authentication server. Please check your network connection and credentials and try again.",
212
+ { cause: error }
213
+ )
214
+ }
206
215
 
207
216
  logger.debug('Received machine authentication tokens, saving them for future use...')
208
217
 
@@ -214,7 +223,29 @@ export async function machineLoginAndSaveTokens(clientId: string, clientSecret:
214
223
  },
215
224
  })
216
225
 
217
- const userInfo = (await userInfoResponse.json()) as { given_name: string; family_name: string }
226
+ let userInfo: { given_name: string; family_name: string }
227
+ if (!userInfoResponse.ok) {
228
+ let message: string | undefined
229
+ try {
230
+ const errorBody: any = await userInfoResponse.json()
231
+ message = errorBody && (errorBody.error_description || errorBody.error || errorBody.message)
232
+ } catch (_error) {
233
+ try {
234
+ message = await userInfoResponse.text()
235
+ } catch (_error) {
236
+ message = undefined
237
+ }
238
+ }
239
+ throw new Error(`Failed to fetch user info: ${message || `HTTP ${userInfoResponse.status}`}`)
240
+ }
241
+ try {
242
+ userInfo = (await userInfoResponse.json()) as { given_name: string; family_name: string }
243
+ } catch (error) {
244
+ throw new Error(
245
+ "Invalid response from user info endpoint. Please check your network connection and try again.",
246
+ { cause: error }
247
+ )
248
+ }
218
249
 
219
250
  console.log(
220
251
  `You are now logged in with machine user ${userInfo.given_name} ${userInfo.family_name} (clientId=${clientId}) and can execute the other commands.`
@@ -267,7 +298,6 @@ export async function extendCurrentSession(): Promise<void> {
267
298
  async function extendCurrentSessionWithRefreshToken(
268
299
  refreshToken: string
269
300
  ): Promise<{ id_token: string; access_token: string; refresh_token: string }> {
270
- // TODO: similar to the todo task in getTokensWithAuthorizationCode, http request can be handled by ory/client.
271
301
  const params = new URLSearchParams({
272
302
  grant_type: 'refresh_token',
273
303
  refresh_token: refreshToken,
@@ -292,9 +322,12 @@ async function extendCurrentSessionWithRefreshToken(
292
322
  logger.error(`Failed to refresh tokens via endpoint ${tokenEndpoint}`)
293
323
  logger.error('Fetch error details:', error)
294
324
  if (error.cause?.code === 'UNABLE_TO_VERIFY_LEAF_SIGNATURE') {
295
- throw new Error(`Failed to refresh tokens: SSL certificate validation failed for ${tokenEndpoint}. Is someone trying to tamper with your internet connection?`)
325
+ throw new Error(
326
+ `Failed to refresh tokens: SSL certificate validation failed for ${tokenEndpoint}. Is someone trying to tamper with your internet connection?`,
327
+ { cause: error }
328
+ )
296
329
  }
297
- throw new Error(`Failed to refresh tokens via ${tokenEndpoint}: ${error}`)
330
+ throw new Error(`Failed to refresh tokens via ${tokenEndpoint}: ${error}`, { cause: error })
298
331
  }
299
332
 
300
333
  // Check if the response is OK
@@ -323,14 +356,12 @@ async function extendCurrentSessionWithRefreshToken(
323
356
  * @param code
324
357
  * @returns
325
358
  */
326
-
327
359
  async function getTokensWithAuthorizationCode(
328
360
  state: string,
329
361
  redirectUri: string,
330
362
  verifier: string,
331
363
  code: string
332
364
  ): Promise<{ id_token: string; access_token: string; refresh_token: string }> {
333
- // TODO: the authorization code exchange flow might be better to be handled by ory/client, could check if there's any useful tools there.
334
365
  try {
335
366
  const response = await fetch(tokenEndpoint, {
336
367
  method: 'POST',
@@ -364,7 +395,7 @@ export async function loadTokens(): Promise<TokenSet> {
364
395
  return tokens
365
396
  } catch (error) {
366
397
  if (error instanceof Error) {
367
- throw new Error(`Error loading tokens: ${error.message}`)
398
+ throw new Error(`Error loading tokens: ${error.message}`, { cause: error })
368
399
  }
369
400
 
370
401
  throw error
@@ -393,7 +424,7 @@ export async function saveTokens(tokens: Record<string, string>): Promise<void>
393
424
  showTokenInfo(tokens.access_token)
394
425
  } catch (error) {
395
426
  if (error instanceof Error) {
396
- throw new Error(`Failed to save tokens: ${error.message}`)
427
+ throw new Error(`Failed to save tokens: ${error.message}`, { cause: error })
397
428
  }
398
429
 
399
430
  throw error
@@ -409,7 +440,7 @@ export async function removeTokens(): Promise<void> {
409
440
  logger.debug('Removed tokens.')
410
441
  } catch (error) {
411
442
  if (error instanceof Error) {
412
- throw new Error(`Error removing tokens: ${error.message}`)
443
+ throw new Error(`Error removing tokens: ${error.message}`, { cause: error })
413
444
  }
414
445
 
415
446
  throw error
@@ -1,7 +1,7 @@
1
1
  import { Command } from 'commander'
2
- import { readFileSync, existsSync } from 'fs'
3
- import path from 'path'
4
- import { exit } from 'process'
2
+ import { readFileSync, existsSync } from 'node:fs'
3
+ import path from 'node:path'
4
+ import { exit } from 'node:process'
5
5
  import * as semver from 'semver'
6
6
 
7
7
  import { pathJoin, executeCommand, ExecuteCommandResult } from './utils.js'
package/src/deployment.ts CHANGED
@@ -1,10 +1,10 @@
1
- import { unlink, writeFile } from 'fs/promises'
2
- import os from 'os'
3
- import path from 'path'
4
- import { exit } from 'process'
1
+ import { unlink, writeFile } from 'node:fs/promises'
2
+ import os from 'node:os'
3
+ import path from 'node:path'
4
+ import { exit } from 'node:process'
5
5
  import { EnvironmentDetails, TargetEnvironment } from 'targetenvironment.js'
6
- import { promises as dns } from 'dns'
7
- import tls from 'tls'
6
+ import { promises as dns } from 'node:dns'
7
+ import tls from 'node:tls'
8
8
 
9
9
  import {
10
10
  KubeConfig,
@@ -48,7 +48,7 @@ async function fetchGameServerPods(k8sApi: CoreV1Api, namespace: string): Promis
48
48
  } catch (error) {
49
49
  // \todo Better error handling ..
50
50
  console.error('Failed to fetch pods from Kubernetes:', error)
51
- throw new Error('Failed to fetch pods from Kubernetes')
51
+ throw new Error('Failed to fetch pods from Kubernetes', { cause: error })
52
52
  }
53
53
  }
54
54
 
@@ -337,7 +337,7 @@ async function fetchPodLogs(k8sApi: CoreV1Api, pod: V1Pod): Promise<string> {
337
337
  } catch (error) {
338
338
  // \todo Better error handling ..
339
339
  console.log('Failed to fetch pod logs from Kubernetes:', error)
340
- throw new Error('Failed to fetch pod logs from Kubernetes')
340
+ throw new Error('Failed to fetch pod logs from Kubernetes', { cause: error })
341
341
  }
342
342
  }
343
343
 
@@ -363,7 +363,9 @@ async function checkGameServerPod(
363
363
  }
364
364
 
365
365
  async function delay(ms: number): Promise<void> {
366
- await new Promise<void>((resolve) => setTimeout(resolve, ms))
366
+ await new Promise<void>((resolve) => {
367
+ setTimeout(resolve, ms)
368
+ })
367
369
  }
368
370
 
369
371
  function anyPodsInPhase(podStatuses: GameServerPodStatus[], phase: GameServerPodPhase): boolean {
@@ -457,7 +459,7 @@ async function waitForDomainResolution(hostname: string): Promise<void> {
457
459
  return
458
460
  } catch (err) {
459
461
  if (Date.now() > timeoutAt) {
460
- throw new Error(`Could not resolve domain ${hostname} before timeout.`)
462
+ throw new Error(`Could not resolve domain ${hostname} before timeout.`, { cause: err })
461
463
  }
462
464
 
463
465
  if (err instanceof Error && (err as NodeJS.ErrnoException).code === 'ENOTFOUND') {
@@ -587,6 +589,7 @@ export async function debugGameServer(targetEnv: TargetEnvironment, targetPodNam
587
589
  if (!metadata?.name) {
588
590
  throw new Error('Unable to resolve name for the Kubernetes pod!')
589
591
  }
592
+ // eslint-disable-next-line no-param-reassign
590
593
  targetPodName = metadata.name
591
594
  } else {
592
595
  const podNames = gameServerPods.map((pod) => pod.metadata?.name).join(', ')
@@ -1,7 +1,7 @@
1
- import { randomBytes, createCipheriv, createDecipheriv, scryptSync } from 'crypto'
2
- import { promises as fs } from 'fs'
3
- import { homedir } from 'os'
4
- import { join } from 'path'
1
+ import { randomBytes, createCipheriv, createDecipheriv, scryptSync } from 'node:crypto'
2
+ import { promises as fs } from 'node:fs'
3
+ import { homedir } from 'node:os'
4
+ import { join } from 'node:path'
5
5
 
6
6
  import { logger } from './logging.js'
7
7
 
@@ -36,7 +36,7 @@ async function loadSecrets(): Promise<Secrets> {
36
36
  return {}
37
37
  }
38
38
  if (password.length === 0) {
39
- throw new Error('The file is encrypted. Please set the METAPLAY_AUTH_PASSWORD environment variable.')
39
+ throw new Error('The file is encrypted. Please set the METAPLAY_AUTH_PASSWORD environment variable.', { cause: error })
40
40
  }
41
41
 
42
42
  throw error
package/src/stackapi.ts CHANGED
@@ -4,7 +4,7 @@ export class StackAPI {
4
4
  private readonly stackApiBaseUrl: string
5
5
 
6
6
  constructor(accessToken: string, stackApiBaseUrl: string | undefined) {
7
- if (accessToken == null) {
7
+ if (!accessToken) {
8
8
  throw new Error('accessToken must be provided')
9
9
  }
10
10
 
@@ -133,9 +133,9 @@ export class TargetEnvironment {
133
133
  } catch (error: any) {
134
134
  logger.error(`Failed to fetch ${method} ${url}:`, error)
135
135
  if (error.cause?.code === 'UNABLE_TO_VERIFY_LEAF_SIGNATURE') {
136
- throw new Error(`Failed to to fetch ${url}: SSL certificate validation failed. Is someone trying to tamper with your internet connection?`)
136
+ throw new Error(`Failed to to fetch ${url}: SSL certificate validation failed. Is someone trying to tamper with your internet connection?`, { cause: error })
137
137
  }
138
- throw new Error(`Failed to fetch ${url}: ${error}`)
138
+ throw new Error(`Failed to fetch ${url}: ${error}`, { cause: error })
139
139
  }
140
140
 
141
141
  if (response.status !== 200) {
@@ -183,13 +183,13 @@ export class TargetEnvironment {
183
183
  // User-friendly error messages for well-known HTTP errors.
184
184
  if (error instanceof FetchJsonHttpError) {
185
185
  if (error.response.status === 404) {
186
- throw new Error(`Environment ${this.humanId} does not exist.`)
186
+ throw new Error(`Environment ${this.humanId} does not exist.`, { cause: error })
187
187
  } else if (error.response.status === 403) {
188
- throw new Error(`Your account does not have permissions to access environment ${this.humanId}.`)
188
+ throw new Error(`Your account does not have permissions to access environment ${this.humanId}.`, { cause: error })
189
189
  }
190
190
  }
191
191
  const errorMessage = (error instanceof Error) ? error.message : String(error)
192
- throw new Error(`Failed to fetch Kubernetes KubeConfig: ${errorMessage}`)
192
+ throw new Error(`Failed to fetch Kubernetes KubeConfig: ${errorMessage}`, { cause: error })
193
193
  }
194
194
 
195
195
  if (!kubeExecCredential.spec.cluster) {
package/src/utils.ts CHANGED
@@ -1,12 +1,12 @@
1
- import { spawn } from 'child_process'
1
+ import { spawn } from 'node:child_process'
2
2
  import yaml from 'js-yaml'
3
- import path from 'path'
3
+ import path from 'node:path'
4
4
  import * as semver from 'semver'
5
5
 
6
6
  import { logger } from './logging.js'
7
- import { tmpdir } from 'os'
8
- import { randomBytes } from 'crypto'
9
- import { unlink, writeFile } from 'fs/promises'
7
+ import { tmpdir } from 'node:os'
8
+ import { randomBytes } from 'node:crypto'
9
+ import { unlink, writeFile } from 'node:fs/promises'
10
10
 
11
11
  /**
12
12
  * Checks if the given string is a fully qualified domain name (FQDN).
@@ -44,9 +44,9 @@ export function splitUrlComponents(urlString: string): {
44
44
  const subpaths = url.pathname.slice(1) ?? null
45
45
 
46
46
  return { scheme, hostname, port, subpaths }
47
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
47
+
48
48
  } catch (error) {
49
- throw new Error('Invalid URL')
49
+ throw new Error('Invalid URL', { cause: error })
50
50
  }
51
51
  }
52
52
 
@@ -220,6 +220,58 @@ export function resolveBestMatchingVersion(versions: string[], range: semver.Ran
220
220
  return sortedVersions[0]
221
221
  }
222
222
 
223
+ /**
224
+ * Check that the installed Helm version is compatible with the required version range.
225
+ * Validates that Helm version is >= 3.18.0 and < 3.18.5.
226
+ *
227
+ * Version 3.18.5 (and recent other patch versions) tightened up the Helm chart validation logic
228
+ * and we can't retroactively fix the charts. The path forward is to upgrade to more recent Metaplay
229
+ * SDK versions and switch to the new CLI (https://github.com/metaplay/cli).
230
+ *
231
+ * @throws Error if Helm is not installed, version cannot be determined, or version is incompatible
232
+ */
233
+ export async function checkHelmVersion(): Promise<void> {
234
+ try {
235
+ // Execute 'helm version' command to get version information
236
+ const result = await executeCommand('helm', ['version', '--short'])
237
+
238
+ if (result.exitCode !== 0) {
239
+ throw new Error(`Helm version command failed with exit code ${result.exitCode}: ${result.stderr.join('')}`)
240
+ }
241
+
242
+ // Parse version from output (format: "v3.18.0+g123abc")
243
+ const versionOutput = result.stdout.join('').trim()
244
+ const versionRegex = /v(\d+\.\d+\.\d+)/
245
+ const versionMatch = versionRegex.exec(versionOutput)
246
+
247
+ if (!versionMatch) {
248
+ throw new Error(`Could not parse Helm version from output: ${versionOutput}`)
249
+ }
250
+
251
+ const helmVersion = versionMatch[1]
252
+ logger.debug(`Detected Helm version: ${helmVersion}`)
253
+
254
+ // Check version constraints: >= 3.18.0 and <= 3.18.4
255
+ const minVersion = '3.18.0'
256
+ const maxVersion = '3.18.4'
257
+
258
+ if (!semver.gte(helmVersion, minVersion)) {
259
+ throw new Error(`Locally installed Helm version ${helmVersion} is too old. Use Helm v${maxVersion} which is the latest compatible version`)
260
+ }
261
+
262
+ if (!semver.lte(helmVersion, maxVersion)) {
263
+ throw new Error(`Locally installed Helm version ${helmVersion} is too new. Use Helm v${maxVersion} which is the latest compatible version`)
264
+ }
265
+
266
+ logger.debug(`Helm version ${helmVersion} is compatible`)
267
+ } catch (error) {
268
+ if (error instanceof Error && error.message.includes('ENOENT')) {
269
+ throw new Error('Helm is not installed or not found in PATH. Please install Helm version >= 3.18.0 and < 3.18.5', { cause: error })
270
+ }
271
+ throw error
272
+ }
273
+ }
274
+
223
275
  /**
224
276
  * Execute a Helm command by invoking the Helm CLI client with the given kubeconfig and arguments to `helm`.
225
277
  * The provided kubeconfig is written to a temporary file and cleaned up after, and passed to `helm` with
@@ -230,6 +282,7 @@ export async function executeHelmCommand(kubeconfigPayload: string, args: string
230
282
  const kubeconfigPath = pathJoin(tmpdir(), randomBytes(20).toString('hex'))
231
283
  logger.debug(`Write temporary kubeconfig in ${kubeconfigPath}`)
232
284
  await writeFile(kubeconfigPath, kubeconfigPayload, { mode: 0o600 })
285
+ // eslint-disable-next-line no-param-reassign
233
286
  args = args.concat(['--kubeconfig', kubeconfigPath])
234
287
 
235
288
  // Use try-finally to remove the temp kubeconfig at the end
@@ -241,7 +294,7 @@ export async function executeHelmCommand(kubeconfigPayload: string, args: string
241
294
  // \todo output something from Helm result?
242
295
  } catch (error) {
243
296
  const errMessage = error instanceof Error ? error.message : String(error)
244
- throw new Error(`Failed to execute 'helm': ${errMessage}. You need to have Helm v3 installed to deploy a game server with metaplay-auth.`)
297
+ throw new Error(`Failed to execute 'helm': ${errMessage}. You need to have Helm v3 installed to deploy a game server with metaplay-auth.`, { cause: error })
245
298
  }
246
299
 
247
300
  // Throw on Helm non-success exit code
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const PACKAGE_VERSION = "1.9.1"
1
+ export const PACKAGE_VERSION = "1.9.3-next.878"
Binary file