@nocobase/cli 2.1.0-alpha.40 → 2.1.0-alpha.46

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 (56) hide show
  1. package/README.md +20 -8
  2. package/README.zh-CN.md +20 -8
  3. package/bin/run.js +9 -37
  4. package/dist/commands/app/destroy.js +225 -0
  5. package/dist/commands/app/down.js +24 -254
  6. package/dist/commands/app/restart.js +12 -5
  7. package/dist/commands/app/shared.js +122 -0
  8. package/dist/commands/app/start.js +41 -59
  9. package/dist/commands/app/stop.js +72 -26
  10. package/dist/commands/app/upgrade.js +312 -429
  11. package/dist/commands/config/delete.js +8 -1
  12. package/dist/commands/config/get.js +5 -0
  13. package/dist/commands/config/set.js +10 -1
  14. package/dist/commands/db/ps.js +5 -18
  15. package/dist/commands/db/start.js +23 -8
  16. package/dist/commands/env/add.js +66 -6
  17. package/dist/commands/env/auth.js +86 -27
  18. package/dist/commands/env/info.js +52 -8
  19. package/dist/commands/env/list.js +6 -7
  20. package/dist/commands/env/shared.js +41 -3
  21. package/dist/commands/env/status.js +10 -7
  22. package/dist/commands/init.js +214 -136
  23. package/dist/commands/install.js +364 -294
  24. package/dist/commands/license/plugins/shared.js +9 -3
  25. package/dist/commands/license/plugins/sync.js +54 -25
  26. package/dist/commands/self/update.js +58 -1
  27. package/dist/commands/source/dev.js +5 -5
  28. package/dist/commands/source/download.js +29 -25
  29. package/dist/generated/command-registry.js +3 -2
  30. package/dist/lib/api-client.js +6 -0
  31. package/dist/lib/api-command-compat.js +641 -0
  32. package/dist/lib/app-health.js +31 -18
  33. package/dist/lib/app-managed-resources.js +1 -1
  34. package/dist/lib/app-runtime.js +2 -2
  35. package/dist/lib/auth-store.js +56 -19
  36. package/dist/lib/backup.js +3 -3
  37. package/dist/lib/bootstrap.js +14 -8
  38. package/dist/lib/cli-config.js +137 -4
  39. package/dist/lib/cli-entry-error.js +44 -0
  40. package/dist/lib/cli-locale.js +19 -7
  41. package/dist/lib/db-connection-check.js +61 -0
  42. package/dist/lib/docker-log-stream.js +45 -0
  43. package/dist/lib/env-auth.js +98 -11
  44. package/dist/lib/env-config.js +3 -2
  45. package/dist/lib/env-guard.js +1 -1
  46. package/dist/lib/generated-command.js +17 -0
  47. package/dist/lib/prompt-validators.js +23 -5
  48. package/dist/lib/prompt-web-ui.js +143 -19
  49. package/dist/lib/run-npm.js +133 -23
  50. package/dist/lib/skills-manager.js +80 -4
  51. package/dist/lib/source-publish.js +4 -1
  52. package/dist/lib/startup-update.js +74 -50
  53. package/dist/lib/ui.js +4 -1
  54. package/dist/locale/en-US.json +92 -5
  55. package/dist/locale/zh-CN.json +92 -5
  56. package/package.json +34 -2
@@ -9,12 +9,13 @@
9
9
  import { readFileSync } from 'node:fs';
10
10
  import path from 'node:path';
11
11
  import { fileURLToPath } from 'node:url';
12
+ import { resolveCliHomeDir } from './cli-home.js';
12
13
  export const SUPPORTED_CLI_LOCALES = ['en-US', 'zh-CN'];
13
14
  export const CLI_LOCALE_FLAG_OPTIONS = [...SUPPORTED_CLI_LOCALES];
14
15
  export const CLI_LOCALE_FLAG_DESCRIPTION = 'Language for CLI prompts and the local setup UI.';
15
16
  const DEFAULT_CLI_LOCALE = 'en-US';
16
17
  const localeCache = {};
17
- function normalizeCliLocale(value) {
18
+ export function normalizeCliLocale(value) {
18
19
  const raw = String(value ?? '').trim();
19
20
  if (!raw) {
20
21
  return undefined;
@@ -28,6 +29,17 @@ function normalizeCliLocale(value) {
28
29
  }
29
30
  return undefined;
30
31
  }
32
+ function readConfiguredCliLocale() {
33
+ try {
34
+ const configPath = path.join(resolveCliHomeDir(), 'config.json');
35
+ const content = readFileSync(configPath, 'utf8');
36
+ const parsed = JSON.parse(content);
37
+ return normalizeCliLocale(parsed.settings?.locale === undefined ? undefined : String(parsed.settings.locale));
38
+ }
39
+ catch {
40
+ return undefined;
41
+ }
42
+ }
31
43
  function loadLocaleMessages(locale) {
32
44
  if (localeCache[locale]) {
33
45
  return localeCache[locale];
@@ -65,9 +77,11 @@ function interpolateTemplate(template, values) {
65
77
  return value === undefined || value === null ? '' : String(value);
66
78
  });
67
79
  }
68
- export function detectCliLocale() {
80
+ export function detectCliLocale(configuredLocale) {
81
+ const resolvedConfiguredLocale = configuredLocale ?? readConfiguredCliLocale();
69
82
  const candidates = [
70
83
  process.env.NB_LOCALE,
84
+ resolvedConfiguredLocale,
71
85
  process.env.LC_ALL,
72
86
  process.env.LC_MESSAGES,
73
87
  process.env.LANG,
@@ -81,8 +95,8 @@ export function detectCliLocale() {
81
95
  }
82
96
  return DEFAULT_CLI_LOCALE;
83
97
  }
84
- export function resolveCliLocale(preferred) {
85
- return normalizeCliLocale(preferred) ?? detectCliLocale();
98
+ export function resolveCliLocale(preferred, options) {
99
+ return normalizeCliLocale(preferred) ?? detectCliLocale(options?.configuredLocale);
86
100
  }
87
101
  export function applyCliLocale(preferred) {
88
102
  const locale = resolveCliLocale(preferred);
@@ -111,9 +125,7 @@ export function localeText(key, values, fallback) {
111
125
  };
112
126
  }
113
127
  export function isLocalizedTextDef(value) {
114
- return Boolean(value
115
- && typeof value === 'object'
116
- && typeof value.key === 'string');
128
+ return Boolean(value && typeof value === 'object' && typeof value.key === 'string');
117
129
  }
118
130
  export function resolveLocalizedText(text, options) {
119
131
  if (text === undefined) {
@@ -10,6 +10,7 @@ import { translateCli } from "./cli-locale.js";
10
10
  import { validateTcpPort } from "./prompt-validators.js";
11
11
  const DB_CONNECTION_TIMEOUT_MS = 5_000;
12
12
  const externalDbValidationCache = new Map();
13
+ const mysqlLowerCaseTableNamesCache = new Map();
13
14
  function trimPromptValue(value) {
14
15
  return String(value ?? '').trim();
15
16
  }
@@ -117,6 +118,36 @@ async function checkMysqlFamilyConnection(config) {
117
118
  await Promise.resolve(connection.end()).catch(() => undefined);
118
119
  }
119
120
  }
121
+ async function readMysqlFamilyLowerCaseTableNames(config) {
122
+ const { default: mysql } = await import('mysql2/promise');
123
+ const connection = await mysql.createConnection({
124
+ host: config.host,
125
+ port: config.port,
126
+ user: config.user,
127
+ password: config.password,
128
+ database: config.database,
129
+ connectTimeout: DB_CONNECTION_TIMEOUT_MS,
130
+ });
131
+ try {
132
+ const [rows] = await connection.query(`SHOW VARIABLES LIKE 'lower_case_table_names'`);
133
+ if (!Array.isArray(rows)) {
134
+ return undefined;
135
+ }
136
+ for (const row of rows) {
137
+ if (!row || typeof row !== 'object') {
138
+ continue;
139
+ }
140
+ const value = String(row.Value ?? '').trim();
141
+ if (value === '0' || value === '1' || value === '2') {
142
+ return value;
143
+ }
144
+ }
145
+ return undefined;
146
+ }
147
+ finally {
148
+ await Promise.resolve(connection.end()).catch(() => undefined);
149
+ }
150
+ }
120
151
  async function performExternalDbConnectionCheck(config) {
121
152
  try {
122
153
  switch (config.dialect) {
@@ -146,6 +177,19 @@ export async function checkExternalDbConnection(config) {
146
177
  externalDbValidationCache.set(cacheKey, pending);
147
178
  return await pending;
148
179
  }
180
+ async function readMysqlLowerCaseTableNamesMode(config) {
181
+ if (config.dialect !== 'mysql' && config.dialect !== 'mariadb') {
182
+ return undefined;
183
+ }
184
+ const cacheKey = buildValidationCacheKey(config);
185
+ const cached = mysqlLowerCaseTableNamesCache.get(cacheKey);
186
+ if (cached) {
187
+ return await cached;
188
+ }
189
+ const pending = readMysqlFamilyLowerCaseTableNames(config);
190
+ mysqlLowerCaseTableNamesCache.set(cacheKey, pending);
191
+ return await pending;
192
+ }
149
193
  export async function validateExternalDbConfig(values) {
150
194
  const config = readExternalDbConnectionConfig(values);
151
195
  if (!config) {
@@ -153,6 +197,23 @@ export async function validateExternalDbConfig(values) {
153
197
  }
154
198
  return await checkExternalDbConnection(config);
155
199
  }
200
+ export async function validateMysqlLowerCaseTableNamesCompatibility(values) {
201
+ const config = readExternalDbConnectionConfig(values);
202
+ if (!config || (config.dialect !== 'mysql' && config.dialect !== 'mariadb')) {
203
+ return undefined;
204
+ }
205
+ try {
206
+ const mode = await readMysqlLowerCaseTableNamesMode(config);
207
+ if (mode === '1' && values.dbUnderscored !== true) {
208
+ return translateCli('validators.dbConnection.lowerCaseTableNamesRequiresUnderscored');
209
+ }
210
+ return undefined;
211
+ }
212
+ catch (error) {
213
+ return formatDbConnectionError(config, error);
214
+ }
215
+ }
156
216
  export function clearExternalDbValidationCache() {
157
217
  externalDbValidationCache.clear();
218
+ mysqlLowerCaseTableNamesCache.clear();
158
219
  }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * This file is part of the NocoBase (R) project.
3
+ * Copyright (c) 2020-2024 NocoBase Co., Ltd.
4
+ * Authors: NocoBase Team.
5
+ *
6
+ * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
7
+ * For more information, please refer to: https://www.nocobase.com/agreement.
8
+ */
9
+ import { spawn } from 'node:child_process';
10
+ const DEFAULT_DOCKER_LOG_TAIL = 50;
11
+ export function startDockerLogFollower(containerName, options) {
12
+ const tail = Math.max(0, options?.tail ?? DEFAULT_DOCKER_LOG_TAIL);
13
+ const child = spawn('docker', ['logs', '--tail', String(tail), '--follow', containerName], {
14
+ stdio: 'inherit',
15
+ });
16
+ let settled = false;
17
+ let resolveClosed;
18
+ const closed = new Promise((resolve) => {
19
+ resolveClosed = resolve;
20
+ });
21
+ const settle = () => {
22
+ if (!settled) {
23
+ settled = true;
24
+ resolveClosed();
25
+ }
26
+ };
27
+ child.once('error', settle);
28
+ child.once('close', settle);
29
+ return {
30
+ stop: async () => {
31
+ if (settled) {
32
+ return;
33
+ }
34
+ try {
35
+ if (!child.kill()) {
36
+ settle();
37
+ }
38
+ }
39
+ catch {
40
+ settle();
41
+ }
42
+ await closed;
43
+ },
44
+ };
45
+ }
@@ -34,6 +34,28 @@ export function getOauthResource(issuerOrBaseUrl) {
34
34
  export function getDefaultOauthScope() {
35
35
  return DEFAULT_OAUTH_SCOPE;
36
36
  }
37
+ function formatApiAuthError(prefix, data, fallbackStatus) {
38
+ if (typeof data === 'string' && data.trim()) {
39
+ return `${prefix}: ${data}`;
40
+ }
41
+ const errors = Array.isArray(data?.errors) ? data.errors : [];
42
+ if (errors.length > 0) {
43
+ const details = errors
44
+ .map((entry) => {
45
+ const code = entry?.code ? `[${entry.code}] ` : '';
46
+ return `${code}${entry?.message ?? 'Authentication failed.'}`;
47
+ })
48
+ .join('; ');
49
+ return `${prefix}: ${details}`;
50
+ }
51
+ if (typeof data?.error?.message === 'string' && data.error.message.trim()) {
52
+ return `${prefix}: ${data.error.message}`;
53
+ }
54
+ if (typeof fallbackStatus === 'number') {
55
+ return `${prefix}: HTTP ${fallbackStatus}`;
56
+ }
57
+ return prefix;
58
+ }
37
59
  export function isOauthAccessTokenExpired(auth, now = Date.now()) {
38
60
  if (!auth.expiresAt) {
39
61
  return false;
@@ -607,19 +629,19 @@ async function createLoopbackServer(state) {
607
629
  });
608
630
  let resolveWaiter;
609
631
  let rejectWaiter;
610
- const waitForCode = () => new Promise((resolveCode, rejectCode) => {
632
+ const waitForCode = () => new Promise((resolve, reject) => {
611
633
  resolveWaiter = (code) => {
612
634
  void close();
613
- resolveCode(code);
635
+ resolve(code);
614
636
  };
615
637
  rejectWaiter = (error) => {
616
638
  void close();
617
- rejectCode(error);
639
+ reject(error);
618
640
  };
619
641
  });
620
642
  const close = async () => {
621
- await new Promise((resolveClose) => {
622
- server.close(() => resolveClose());
643
+ await new Promise((resolve) => {
644
+ server.close(() => resolve());
623
645
  });
624
646
  };
625
647
  server.on('error', (error) => {
@@ -750,7 +772,7 @@ export async function resolveAccessToken(options) {
750
772
  }
751
773
  const baseUrl = options.baseUrl ?? env.baseUrl;
752
774
  if (!baseUrl) {
753
- throw new Error(`Env "${envName}" is missing a base URL. Run \`nb env add ${envName} --api-base-url <url>\`.`);
775
+ throw new Error(`Env "${envName}" is missing a base URL. Update it with \`nb env update ${envName} --api-base-url <url>\` first.`);
754
776
  }
755
777
  printVerbose(`Refreshing OAuth session for env "${envName}"`);
756
778
  return refreshOauthAccessToken({
@@ -771,10 +793,72 @@ export async function resolveServerRequestTarget(options) {
771
793
  scope: options.scope,
772
794
  });
773
795
  if (!baseUrl) {
774
- throw new Error('Missing base URL. Use --api-base-url or configure one with `nb env add`.');
796
+ throw new Error([
797
+ env ? `Env "${envName}" is missing a base URL.` : `Env "${envName}" is not configured.`,
798
+ env
799
+ ? `Use --api-base-url or update env "${envName}" with \`nb env update ${envName} --api-base-url <url>\`.`
800
+ : `Use --api-base-url or run \`nb init --ui --env ${envName}\` first.`,
801
+ ].join('\n'));
775
802
  }
776
803
  return { baseUrl, token };
777
804
  }
805
+ export async function authenticateEnvWithBasic(options) {
806
+ const envName = options.envName ?? (await getCurrentEnvName({ scope: options.scope }));
807
+ const env = await getEnv(envName, { scope: options.scope });
808
+ const baseUrl = env?.baseUrl;
809
+ if (!baseUrl) {
810
+ throw new Error([
811
+ env
812
+ ? `Environment "${envName}" does not have an API base URL yet.`
813
+ : `Environment "${envName}" has not been set up yet.`,
814
+ env
815
+ ? `Run \`nb env update ${envName} --api-base-url <url>\` to finish setting it up.`
816
+ : `Run \`nb init --ui --env ${envName}\` first.`,
817
+ ]
818
+ .filter(Boolean)
819
+ .join('\n'));
820
+ }
821
+ const loginUrl = `${normalizeBaseUrl(baseUrl)}/auth:signIn`;
822
+ let response;
823
+ try {
824
+ response = await fetchWithOauthRetry(loginUrl, {
825
+ method: 'POST',
826
+ headers: {
827
+ accept: 'application/json',
828
+ 'content-type': 'application/json',
829
+ 'x-authenticator': 'basic',
830
+ },
831
+ body: JSON.stringify({
832
+ account: options.username,
833
+ password: options.password,
834
+ }),
835
+ }, {
836
+ operation: 'Signing in with basic credentials',
837
+ onRetry: (message) => updateTask(message),
838
+ });
839
+ }
840
+ catch (error) {
841
+ throw new Error(formatOauthFetchFailure('Failed to sign in with basic credentials.', {
842
+ envName,
843
+ baseUrl,
844
+ url: loginUrl,
845
+ rawMessage: error?.message,
846
+ }));
847
+ }
848
+ const data = await parseJsonResponse(response);
849
+ if (!response.ok) {
850
+ throw new Error(formatApiAuthError('Basic sign-in failed', data, response.status));
851
+ }
852
+ const token = typeof data?.data?.token === 'string' && data.data.token.trim()
853
+ ? data.data.token.trim()
854
+ : typeof data?.token === 'string' && data.token.trim()
855
+ ? data.token.trim()
856
+ : '';
857
+ if (!token) {
858
+ throw new Error('Basic sign-in succeeded but no token was returned.');
859
+ }
860
+ return token;
861
+ }
778
862
  export async function authenticateEnvWithOauth(options) {
779
863
  const envName = options.envName ?? (await getCurrentEnvName({ scope: options.scope }));
780
864
  const env = await getEnv(envName, { scope: options.scope });
@@ -785,8 +869,8 @@ export async function authenticateEnvWithOauth(options) {
785
869
  ? `Environment "${envName}" does not have an API base URL yet.`
786
870
  : `Environment "${envName}" has not been set up yet.`,
787
871
  env
788
- ? `Run \`nb env add ${envName} --api-base-url <url>\` to finish setting it up.`
789
- : `Run \`nb env add ${envName}\` first.`,
872
+ ? `Run \`nb env update ${envName} --api-base-url <url>\` to finish setting it up.`
873
+ : `Run \`nb init --ui --env ${envName}\` first.`,
790
874
  ]
791
875
  .filter(Boolean)
792
876
  .join('\n'));
@@ -833,10 +917,13 @@ export async function authenticateEnvWithOauth(options) {
833
917
  const code = await new Promise((resolve, reject) => {
834
918
  const timeout = setTimeout(() => reject(new Error(`OAuth sign-in timed out after 5 minutes. Run \`nb env auth ${envName}\` to try again.`)), OAUTH_LOGIN_TIMEOUT_MS);
835
919
  timeout.unref?.();
836
- callback.waitForCode().then((value) => {
920
+ callback
921
+ .waitForCode()
922
+ .then((value) => {
837
923
  clearTimeout(timeout);
838
924
  resolve(value);
839
- }, (error) => {
925
+ })
926
+ .catch((error) => {
840
927
  clearTimeout(timeout);
841
928
  reject(error);
842
929
  });
@@ -19,6 +19,7 @@ const STRING_ENV_CONFIG_KEYS = [
19
19
  'appPort',
20
20
  'appKey',
21
21
  'timezone',
22
+ 'authUsername',
22
23
  'dbDialect',
23
24
  'builtinDbImage',
24
25
  'dbHost',
@@ -83,11 +84,11 @@ export function buildStoredEnvConfig(input) {
83
84
  }
84
85
  }
85
86
  const authType = trimConfigValue(input.authType);
86
- if (authType === 'token' || authType === 'oauth') {
87
+ if (authType === 'basic' || authType === 'token' || authType === 'oauth') {
87
88
  envConfig.authType = authType;
88
89
  }
89
90
  const accessToken = trimConfigValue(input.accessToken);
90
- if (authType === 'token' && accessToken) {
91
+ if ((authType === 'basic' || authType === 'token') && accessToken) {
91
92
  envConfig.accessToken = accessToken;
92
93
  }
93
94
  return envConfig;
@@ -43,7 +43,7 @@ export async function ensureCrossEnvConfirmed(options) {
43
43
  }
44
44
  const currentEnv = normalizeEnvName(await getCurrentEnvName());
45
45
  const interactiveTerminal = isInteractiveTerminal();
46
- const bypassInteractivePrompt = interactiveTerminal && Boolean(options.yes);
46
+ const bypassInteractivePrompt = Boolean(options.yes);
47
47
  if (!currentEnv || currentEnv === requestedEnv || bypassInteractivePrompt) {
48
48
  return true;
49
49
  }
@@ -16,8 +16,10 @@
16
16
  */
17
17
  import { Command, Flags } from '@oclif/core';
18
18
  import { executeApiRequest } from './api-client.js';
19
+ import { findApiCommandCompatViolation, formatApiCommandCompatViolation } from './api-command-compat.js';
19
20
  import { ensureCrossEnvConfirmed } from './env-guard.js';
20
21
  import { applyPostProcessor } from './post-processors.js';
22
+ import { readInstalledManagedSkillsVersion } from './skills-manager.js';
21
23
  import { registerPostProcessors } from '../post-processors/index.js';
22
24
  function buildParameterFlag(parameter, options) {
23
25
  const hints = [parameter.in];
@@ -142,6 +144,7 @@ export function createGeneratedFlags(operation) {
142
144
  }
143
145
  export class GeneratedApiCommand extends Command {
144
146
  static operation;
147
+ static runtimeVersion;
145
148
  async run() {
146
149
  registerPostProcessors();
147
150
  const ctor = this.constructor;
@@ -154,7 +157,21 @@ export class GeneratedApiCommand extends Command {
154
157
  if (!confirmed) {
155
158
  return;
156
159
  }
160
+ const cliVersion = String(this.config.pjson.version ?? '').trim();
161
+ const skillsVersion = await readInstalledManagedSkillsVersion();
162
+ const compatViolation = findApiCommandCompatViolation({
163
+ packageJson: this.config.pjson,
164
+ commandId: ctor.operation.commandId,
165
+ cliVersion,
166
+ appVersion: ctor.runtimeVersion,
167
+ skillsVersion,
168
+ });
169
+ if (compatViolation) {
170
+ this.error(formatApiCommandCompatViolation(compatViolation));
171
+ }
157
172
  const response = await executeApiRequest({
173
+ cliVersion,
174
+ skillsVersion,
158
175
  envName: flags.env,
159
176
  baseUrl: flags['api-base-url'],
160
177
  role: flags.role,
@@ -13,6 +13,7 @@ const API_BASE_URL_EXAMPLE = 'http://localhost:13000/api';
13
13
  const ENV_KEY_PATTERN = /^[A-Za-z0-9]+$/;
14
14
  const TCP_PORT_EXAMPLE = '13000';
15
15
  const API_BASE_URL_REQUEST_TIMEOUT_MS = 5_000;
16
+ const TCP_PORT_PROBE_HOSTS = ['127.0.0.1', '0.0.0.0', '::1'];
16
17
  function buildHealthCheckUrl(apiBaseUrl) {
17
18
  return `${apiBaseUrl.replace(/\/+$/, '')}/__health_check`;
18
19
  }
@@ -119,7 +120,7 @@ export function validateTcpPort(value) {
119
120
  }
120
121
  return undefined;
121
122
  }
122
- async function canListenOnTcpPort(port) {
123
+ async function canListenOnTcpPortHost(port, host) {
123
124
  return await new Promise((resolve) => {
124
125
  const server = net.createServer();
125
126
  const resolveAndCleanup = (result) => {
@@ -131,15 +132,28 @@ async function canListenOnTcpPort(port) {
131
132
  resolve(result);
132
133
  }
133
134
  };
134
- server.once('error', () => {
135
+ server.once('error', (error) => {
136
+ if (error.code === 'EADDRNOTAVAIL' || error.code === 'EAFNOSUPPORT') {
137
+ resolveAndCleanup(undefined);
138
+ return;
139
+ }
135
140
  resolveAndCleanup(false);
136
141
  });
137
142
  server.once('listening', () => {
138
143
  resolveAndCleanup(true);
139
144
  });
140
- server.listen(port, '127.0.0.1');
145
+ server.listen(port, host);
141
146
  });
142
147
  }
148
+ async function canListenOnTcpPort(port) {
149
+ for (const host of TCP_PORT_PROBE_HOSTS) {
150
+ const result = await canListenOnTcpPortHost(port, host);
151
+ if (result === false) {
152
+ return false;
153
+ }
154
+ }
155
+ return true;
156
+ }
143
157
  function parseDockerPublishedTcpPorts(output) {
144
158
  const ports = new Set();
145
159
  for (const line of output.split(/\r?\n/)) {
@@ -210,9 +224,10 @@ async function allocateAvailableTcpPort() {
210
224
  }
211
225
  export async function findAvailableTcpPort() {
212
226
  const dockerPorts = await getDockerPublishedTcpPorts();
213
- for (let attempt = 0; attempt < 10; attempt += 1) {
227
+ for (let attempt = 0; attempt < 20; attempt += 1) {
214
228
  const candidate = await allocateAvailableTcpPort();
215
- if (!dockerPorts.has(Number.parseInt(candidate, 10))) {
229
+ const port = Number.parseInt(candidate, 10);
230
+ if (!dockerPorts.has(port) && (await canListenOnTcpPort(port))) {
216
231
  return candidate;
217
232
  }
218
233
  }
@@ -228,6 +243,9 @@ export async function validateAvailableTcpPort(value) {
228
243
  return formatError;
229
244
  }
230
245
  const port = parseTcpPort(raw);
246
+ if (port === undefined) {
247
+ return translateCli('validators.tcpPort.invalid', { example: TCP_PORT_EXAMPLE });
248
+ }
231
249
  const dockerPorts = await getDockerPublishedTcpPorts();
232
250
  if (dockerPorts.has(port)) {
233
251
  return translateCli('validators.tcpPort.alreadyInUseByDocker', { port });