@aifabrix/builder 2.47.0 → 2.47.1
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.
- package/lib/cli/index.js +2 -0
- package/lib/cli/infra-guided-auth.js +36 -12
- package/lib/cli/setup-npm.js +59 -0
- package/lib/cli/setup-secrets.js +1 -1
- package/lib/commands/login-device.js +11 -3
- package/lib/commands/login.js +8 -3
- package/lib/commands/npm-config.js +103 -0
- package/lib/utils/api.js +2 -1
- package/lib/utils/controller-url.js +4 -1
- package/lib/utils/dev-platform-tls.js +148 -0
- package/lib/utils/health-check-request.js +114 -0
- package/lib/utils/health-check-url.js +3 -1
- package/lib/utils/health-check.js +1 -113
- package/lib/utils/help-builder.js +1 -0
- package/lib/utils/keycloak-realm-ready.js +5 -1
- package/lib/utils/localhost-public-host.js +31 -0
- package/lib/utils/npm-user-config.js +145 -0
- package/lib/utils/platform-controller-url.js +56 -3
- package/lib/utils/url-declarative-public-base.js +33 -25
- package/package.json +1 -1
package/lib/cli/index.js
CHANGED
|
@@ -31,6 +31,7 @@ const { setupPlatformCommands } = require('./setup-platform');
|
|
|
31
31
|
const { setupCertificationCommands } = require('./setup-certification-commands');
|
|
32
32
|
const { setupGovernanceCommands } = require('../commands/governance');
|
|
33
33
|
const { setupChannelCommands } = require('../commands/channel');
|
|
34
|
+
const { setupNpmCommands } = require('./setup-npm');
|
|
34
35
|
|
|
35
36
|
/**
|
|
36
37
|
* Dev, secrets, parameters, and platform commands (keeps setupCommands under statement limit).
|
|
@@ -39,6 +40,7 @@ const { setupChannelCommands } = require('../commands/channel');
|
|
|
39
40
|
function setupDevAndPlatformCommands(program) {
|
|
40
41
|
setupDevCommands(program);
|
|
41
42
|
setupSecretsCommands(program);
|
|
43
|
+
setupNpmCommands(program);
|
|
42
44
|
setupParametersCommands(program);
|
|
43
45
|
setupPlatformCommands(program);
|
|
44
46
|
}
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
const chalk = require('chalk');
|
|
12
12
|
const logger = require('../utils/logger');
|
|
13
13
|
const config = require('../core/config');
|
|
14
|
-
const { resolvePlatformControllerUrl } = require('../utils/platform-controller-url');
|
|
14
|
+
const { resolvePlatformControllerUrl, resolveCliPlatformControllerUrl } = require('../utils/platform-controller-url');
|
|
15
15
|
const { withMutedLogger } = require('../utils/with-muted-logger');
|
|
16
16
|
const { formatBlockingError } = require('../utils/cli-test-layout-chalk');
|
|
17
17
|
const { isAuthenticationError } = require('../utils/cli-utils');
|
|
@@ -19,29 +19,45 @@ const { resolveControllerUrl, isPlatformAuthValidForController } = require('../u
|
|
|
19
19
|
|
|
20
20
|
/**
|
|
21
21
|
* @param {Object} authOpts
|
|
22
|
-
* @returns {Promise<string>}
|
|
22
|
+
* @returns {Promise<{ apiUrl: string, tokenUrl: string }>}
|
|
23
23
|
*/
|
|
24
|
-
async function
|
|
25
|
-
|
|
24
|
+
async function resolveGuidedAuthControllerUrls(authOpts = {}) {
|
|
25
|
+
const tokenUrl =
|
|
26
26
|
authOpts.platformControllerUrl ||
|
|
27
27
|
(await config.getPlatformControllerUrl()) ||
|
|
28
|
-
(await resolvePlatformControllerUrl())
|
|
29
|
-
);
|
|
28
|
+
(await resolvePlatformControllerUrl());
|
|
29
|
+
const remoteServer = await config.getRemoteServer();
|
|
30
|
+
let apiUrl = tokenUrl;
|
|
31
|
+
if (remoteServer && String(remoteServer).trim()) {
|
|
32
|
+
apiUrl = await resolveCliPlatformControllerUrl();
|
|
33
|
+
}
|
|
34
|
+
return { apiUrl, tokenUrl };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* @param {Object} authOpts
|
|
39
|
+
* @returns {Promise<string>}
|
|
40
|
+
*/
|
|
41
|
+
async function resolveGuidedAuthControllerUrl(authOpts = {}) {
|
|
42
|
+
const { apiUrl } = await resolveGuidedAuthControllerUrls(authOpts);
|
|
43
|
+
return apiUrl;
|
|
30
44
|
}
|
|
31
45
|
|
|
32
46
|
/**
|
|
33
47
|
* @async
|
|
34
48
|
* @param {Function} handleLoginFn
|
|
35
|
-
* @param {string} controllerUrl
|
|
36
|
-
* @param {{ skipDataplaneVersionRefresh?: boolean }} [opts]
|
|
49
|
+
* @param {string} controllerUrl - Controller API base URL (may be localhost on builder host)
|
|
50
|
+
* @param {{ skipDataplaneVersionRefresh?: boolean, tokenControllerUrl?: string }} [opts]
|
|
37
51
|
* @returns {Promise<void>}
|
|
38
52
|
*/
|
|
39
53
|
async function runGuidedDeviceLogin(handleLoginFn, controllerUrl, opts = {}) {
|
|
40
54
|
await handleLoginFn({
|
|
41
55
|
method: 'device',
|
|
42
56
|
controller: controllerUrl,
|
|
57
|
+
tokenControllerUrl: opts.tokenControllerUrl,
|
|
43
58
|
environment: 'dev',
|
|
44
59
|
compact: true,
|
|
60
|
+
skipControllerUrlPersist: Boolean(opts.tokenControllerUrl),
|
|
45
61
|
skipDataplaneVersionRefresh: opts.skipDataplaneVersionRefresh === true,
|
|
46
62
|
exitOnFailure: false
|
|
47
63
|
});
|
|
@@ -56,20 +72,27 @@ async function runGuidedDeviceLogin(handleLoginFn, controllerUrl, opts = {}) {
|
|
|
56
72
|
*/
|
|
57
73
|
async function runGuidedAuthStep(handleLoginFn, authOpts, spinners) {
|
|
58
74
|
const spin = spinners.startSpinner('Authenticating...');
|
|
59
|
-
const
|
|
75
|
+
const { apiUrl, tokenUrl } = await resolveGuidedAuthControllerUrls(authOpts);
|
|
76
|
+
const loginOpts = { skipDataplaneVersionRefresh: true, tokenControllerUrl: tokenUrl };
|
|
60
77
|
const skipLogin =
|
|
61
78
|
authOpts.skipLoginIfAuthenticated === true ||
|
|
62
|
-
(await isPlatformAuthValidForController(
|
|
79
|
+
(await isPlatformAuthValidForController(tokenUrl));
|
|
63
80
|
try {
|
|
64
81
|
if (skipLogin) {
|
|
65
82
|
spinners.stopSpinnerSuccess(spin, 'Already authenticated');
|
|
66
83
|
return;
|
|
67
84
|
}
|
|
68
|
-
await runGuidedDeviceLogin(handleLoginFn,
|
|
85
|
+
await runGuidedDeviceLogin(handleLoginFn, apiUrl, loginOpts);
|
|
69
86
|
} catch (authErr) {
|
|
70
87
|
if (spin) spin.fail('Authentication required');
|
|
88
|
+
const retriable =
|
|
89
|
+
isAuthenticationError(authErr) ||
|
|
90
|
+
/device code|authorization_pending|complete sign-in|browser/i.test(String(authErr.message || ''));
|
|
91
|
+
if (!retriable) {
|
|
92
|
+
throw authErr;
|
|
93
|
+
}
|
|
71
94
|
logger.error(formatBlockingError('Authentication required. Complete sign-in in your browser.'));
|
|
72
|
-
await runGuidedDeviceLogin(handleLoginFn,
|
|
95
|
+
await runGuidedDeviceLogin(handleLoginFn, apiUrl, loginOpts);
|
|
73
96
|
}
|
|
74
97
|
spinners.stopSpinnerSuccess(spin, 'Authenticated');
|
|
75
98
|
}
|
|
@@ -102,6 +125,7 @@ async function runGuidedDataplaneWithAuthRecovery(options, handleUpDataplane, ha
|
|
|
102
125
|
}
|
|
103
126
|
|
|
104
127
|
module.exports = {
|
|
128
|
+
resolveGuidedAuthControllerUrls,
|
|
105
129
|
resolveGuidedAuthControllerUrl,
|
|
106
130
|
runGuidedDeviceLogin,
|
|
107
131
|
runGuidedAuthStep,
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI npm command setup (user-global registry auth).
|
|
3
|
+
*
|
|
4
|
+
* @fileoverview aifabrix npm config command registration
|
|
5
|
+
* @author AI Fabrix Team
|
|
6
|
+
* @version 1.0.0
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const { handleCommandError } = require('../utils/cli-utils');
|
|
10
|
+
const { handleNpmConfig } = require('../commands/npm-config');
|
|
11
|
+
|
|
12
|
+
const NPM_CONFIG_HELP_AFTER = `
|
|
13
|
+
Writes @aifabrix scope registry auth to your user npm config (~/.npmrc by default).
|
|
14
|
+
Does not modify repo-level .npmrc files.
|
|
15
|
+
|
|
16
|
+
Token source (default): merged secrets key BASH_NPM_TOKEN (user or --shared store).
|
|
17
|
+
Docker/build: BASH_NPM_TOKEN is also mapped to NPM_TOKEN for aifabrix build.
|
|
18
|
+
|
|
19
|
+
Examples:
|
|
20
|
+
$ aifabrix secret set BASH_NPM_TOKEN "<token>" --shared
|
|
21
|
+
$ aifabrix npm config
|
|
22
|
+
$ aifabrix npm config --check
|
|
23
|
+
$ aifabrix npm config --dry-run
|
|
24
|
+
$ NPM_TOKEN="<token>" aifabrix npm config --from-env
|
|
25
|
+
|
|
26
|
+
Migration from repo .npmrc.token:
|
|
27
|
+
1. aifabrix secret remove npm-token-secretKeyVault --shared
|
|
28
|
+
2. aifabrix secret set BASH_NPM_TOKEN "<token>" --shared
|
|
29
|
+
3. aifabrix npm config
|
|
30
|
+
4. npm whoami --registry=https://registry.npmjs.org/
|
|
31
|
+
`;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* @param {import('commander').Command} program
|
|
35
|
+
*/
|
|
36
|
+
function setupNpmCommands(program) {
|
|
37
|
+
const npm = program.command('npm').description('User-global npm registry configuration');
|
|
38
|
+
|
|
39
|
+
npm
|
|
40
|
+
.command('config')
|
|
41
|
+
.description('Apply @aifabrix npm auth from BASH_NPM_TOKEN to user ~/.npmrc')
|
|
42
|
+
.option('--dry-run', 'Show what would be written without changing files')
|
|
43
|
+
.option('--check', 'Exit 0 when BASH_NPM_TOKEN is set and npm whoami succeeds')
|
|
44
|
+
.option('--from-env', 'Use NPM_TOKEN from the environment once (not merged secrets)')
|
|
45
|
+
.addHelpText('after', NPM_CONFIG_HELP_AFTER)
|
|
46
|
+
.action(async(opts) => {
|
|
47
|
+
try {
|
|
48
|
+
await handleNpmConfig({
|
|
49
|
+
dryRun: Boolean(opts.dryRun),
|
|
50
|
+
check: Boolean(opts.check),
|
|
51
|
+
fromEnv: Boolean(opts.fromEnv)
|
|
52
|
+
});
|
|
53
|
+
} catch (error) {
|
|
54
|
+
handleCommandError(error, 'npm config');
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
module.exports = { setupNpmCommands };
|
package/lib/cli/setup-secrets.js
CHANGED
|
@@ -101,7 +101,7 @@ Examples:
|
|
|
101
101
|
const SECRET_SET_SECRETS_FILE_HELP_AFTER = `
|
|
102
102
|
Examples:
|
|
103
103
|
$ aifabrix secret set-secrets-file ./shared-secrets.yaml
|
|
104
|
-
$ aifabrix secret set-secrets-file https://
|
|
104
|
+
$ aifabrix secret set-secrets-file https://builder01.local/api/dev/secrets
|
|
105
105
|
$ aifabrix secret set-secrets-file ""
|
|
106
106
|
`;
|
|
107
107
|
|
|
@@ -81,8 +81,11 @@ async function saveDeviceLoginConfig(controllerUrl, token, refreshToken, expires
|
|
|
81
81
|
* @returns {Promise<void>}
|
|
82
82
|
*/
|
|
83
83
|
async function saveTokenAndDisplaySuccess(controllerUrl, token, refreshToken, expiresAt, envKey, opts = {}) {
|
|
84
|
-
|
|
85
|
-
|
|
84
|
+
const storageUrl = opts.tokenControllerUrl
|
|
85
|
+
? String(opts.tokenControllerUrl).trim().replace(/\/+$/, '')
|
|
86
|
+
: controllerUrl;
|
|
87
|
+
await saveDeviceLoginConfig(storageUrl, token, refreshToken, expiresAt);
|
|
88
|
+
await setControllerUrl(storageUrl);
|
|
86
89
|
if (envKey) {
|
|
87
90
|
await setCurrentEnvironment(envKey);
|
|
88
91
|
}
|
|
@@ -219,6 +222,8 @@ function convertDeviceCodeResponse(apiResponse) {
|
|
|
219
222
|
device_code: deviceCodeData.deviceCode || deviceCodeData.device_code,
|
|
220
223
|
user_code: deviceCodeData.userCode || deviceCodeData.user_code,
|
|
221
224
|
verification_uri: deviceCodeData.verificationUri || deviceCodeData.verification_uri,
|
|
225
|
+
verification_uri_complete:
|
|
226
|
+
deviceCodeData.verificationUriComplete || deviceCodeData.verification_uri_complete,
|
|
222
227
|
expires_in: deviceCodeData.expiresIn || deviceCodeData.expires_in || 600,
|
|
223
228
|
interval: deviceCodeData.interval || 5
|
|
224
229
|
};
|
|
@@ -246,8 +251,11 @@ function logDeviceFlowError(deviceError, opts) {
|
|
|
246
251
|
}
|
|
247
252
|
|
|
248
253
|
function printCompactDeviceUrl(deviceCodeResponse) {
|
|
254
|
+
const visitUrl =
|
|
255
|
+
deviceCodeResponse.verification_uri_complete ||
|
|
256
|
+
`${deviceCodeResponse.verification_uri}?user_code=${deviceCodeResponse.user_code}`;
|
|
249
257
|
logger.log('\nOpen browser to authenticate:');
|
|
250
|
-
logger.log(chalk.yellow(`${
|
|
258
|
+
logger.log(chalk.yellow(`${visitUrl}\n`));
|
|
251
259
|
}
|
|
252
260
|
|
|
253
261
|
/**
|
package/lib/commands/login.js
CHANGED
|
@@ -86,8 +86,12 @@ async function normalizeControllerUrl(options) {
|
|
|
86
86
|
controllerUrl = await resolveControllerUrl();
|
|
87
87
|
}
|
|
88
88
|
controllerUrl = String(controllerUrl).trim().replace(/\/+$/, '');
|
|
89
|
-
|
|
90
|
-
|
|
89
|
+
if (!options.skipControllerUrlPersist) {
|
|
90
|
+
const persistUrl = options.tokenControllerUrl
|
|
91
|
+
? String(options.tokenControllerUrl).trim().replace(/\/+$/, '')
|
|
92
|
+
: controllerUrl;
|
|
93
|
+
await setControllerUrl(persistUrl);
|
|
94
|
+
}
|
|
91
95
|
if (!options.compact) {
|
|
92
96
|
logger.log(chalk.gray(`Controller URL: ${controllerUrl}`));
|
|
93
97
|
}
|
|
@@ -158,7 +162,8 @@ async function handleCredentialsLoginFlow(controllerUrl, environment, options) {
|
|
|
158
162
|
async function handleDeviceCodeLoginFlow(controllerUrl, environment, options) {
|
|
159
163
|
return await handleDeviceCodeLogin(controllerUrl, environment, options.online, options.scope, {
|
|
160
164
|
compact: Boolean(options.compact),
|
|
161
|
-
exitOnFailure: options.exitOnFailure !== false
|
|
165
|
+
exitOnFailure: options.exitOnFailure !== false,
|
|
166
|
+
tokenControllerUrl: options.tokenControllerUrl
|
|
162
167
|
});
|
|
163
168
|
}
|
|
164
169
|
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* aifabrix npm config — apply user-global npm auth from BASH_NPM_TOKEN.
|
|
3
|
+
*
|
|
4
|
+
* @fileoverview npm config command handler
|
|
5
|
+
* @author AI Fabrix Team
|
|
6
|
+
* @version 1.0.0
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
'use strict';
|
|
10
|
+
|
|
11
|
+
const { execFile } = require('child_process');
|
|
12
|
+
const { promisify } = require('util');
|
|
13
|
+
const chalk = require('chalk');
|
|
14
|
+
const { formatSuccessLine } = require('../utils/cli-test-layout-chalk');
|
|
15
|
+
const logger = require('../utils/logger');
|
|
16
|
+
const secretsLoad = require('../core/secrets-load');
|
|
17
|
+
const { collectBashPrefixedEnv } = require('../utils/bash-secret-env');
|
|
18
|
+
const {
|
|
19
|
+
REGISTRY_URL,
|
|
20
|
+
getUserNpmrcPath,
|
|
21
|
+
writeUserNpmrcFromToken
|
|
22
|
+
} = require('../utils/npm-user-config');
|
|
23
|
+
|
|
24
|
+
const execFileAsync = promisify(execFile);
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Resolve npm token from merged secrets or --from-env.
|
|
28
|
+
*
|
|
29
|
+
* @param {{ fromEnv?: boolean }} options
|
|
30
|
+
* @returns {Promise<string>}
|
|
31
|
+
*/
|
|
32
|
+
async function resolveNpmToken(options = {}) {
|
|
33
|
+
if (options.fromEnv) {
|
|
34
|
+
const fromProcess = (process.env.NPM_TOKEN || '').trim();
|
|
35
|
+
if (!fromProcess) {
|
|
36
|
+
throw new Error('NPM_TOKEN is not set in the environment (--from-env)');
|
|
37
|
+
}
|
|
38
|
+
return fromProcess;
|
|
39
|
+
}
|
|
40
|
+
const secrets = await secretsLoad.loadSecrets();
|
|
41
|
+
const bashEnv = collectBashPrefixedEnv(secrets);
|
|
42
|
+
const token = bashEnv.NPM_TOKEN;
|
|
43
|
+
if (!token) {
|
|
44
|
+
throw new Error(
|
|
45
|
+
'BASH_NPM_TOKEN is not set in merged secrets. Run: aifabrix secret set BASH_NPM_TOKEN "<token>" --shared'
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
return token;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Run npm whoami against registry.npmjs.org using the given token.
|
|
53
|
+
*
|
|
54
|
+
* @param {string} token
|
|
55
|
+
* @returns {Promise<string>} username
|
|
56
|
+
*/
|
|
57
|
+
async function npmWhoamiWithToken(token) {
|
|
58
|
+
const { stdout } = await execFileAsync(
|
|
59
|
+
'npm',
|
|
60
|
+
['whoami', `--registry=${REGISTRY_URL}`, `--//registry.npmjs.org/:_authToken=${token}`],
|
|
61
|
+
{ env: { ...process.env }, timeout: 30000 }
|
|
62
|
+
);
|
|
63
|
+
return String(stdout).trim();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* @param {{ dryRun?: boolean, check?: boolean, fromEnv?: boolean }} options
|
|
68
|
+
* @returns {Promise<{ ok: boolean, npmrcPath?: string, username?: string, dryRun?: boolean }>}
|
|
69
|
+
*/
|
|
70
|
+
async function handleNpmConfig(options = {}) {
|
|
71
|
+
const dryRun = Boolean(options.dryRun);
|
|
72
|
+
const checkOnly = Boolean(options.check);
|
|
73
|
+
const token = await resolveNpmToken({ fromEnv: Boolean(options.fromEnv) });
|
|
74
|
+
const npmrcPath = getUserNpmrcPath();
|
|
75
|
+
|
|
76
|
+
if (checkOnly) {
|
|
77
|
+
const username = await npmWhoamiWithToken(token);
|
|
78
|
+
return { ok: true, npmrcPath, username };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const result = writeUserNpmrcFromToken(token, { dryRun, npmrcPath });
|
|
82
|
+
|
|
83
|
+
if (dryRun) {
|
|
84
|
+
logger.log(chalk.blue('Dry run — would update user npm config:'));
|
|
85
|
+
logger.log(` path: ${npmrcPath}`);
|
|
86
|
+
for (const key of result.managedKeys) {
|
|
87
|
+
const suffix = key.includes('_authToken') ? '=<redacted>' : '';
|
|
88
|
+
logger.log(` ${key}${suffix}`);
|
|
89
|
+
}
|
|
90
|
+
return { ok: true, npmrcPath, dryRun: true };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const username = await npmWhoamiWithToken(token);
|
|
94
|
+
logger.log(formatSuccessLine(`Updated user npm config: ${npmrcPath}`));
|
|
95
|
+
logger.log(chalk.gray(`npm whoami: ${username}`));
|
|
96
|
+
return { ok: true, npmrcPath, username };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
module.exports = {
|
|
100
|
+
resolveNpmToken,
|
|
101
|
+
npmWhoamiWithToken,
|
|
102
|
+
handleNpmConfig
|
|
103
|
+
};
|
package/lib/utils/api.js
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
|
|
13
13
|
const { parseErrorResponse, formatNetworkError } = require('./api-error-handler');
|
|
14
14
|
const { inferServiceLabelFromUrl } = require('./service-url-label');
|
|
15
|
+
const { fetchWithOptionalDevPlatformTls } = require('./dev-platform-tls');
|
|
15
16
|
const auditLogger = require('../core/audit-logger');
|
|
16
17
|
|
|
17
18
|
/** Default timeout for HTTP requests (ms). Prevents hanging when the controller is unreachable. 30s allows Azure Web App cold start to complete. */
|
|
@@ -350,7 +351,7 @@ async function makeApiCall(url, options = {}) {
|
|
|
350
351
|
const { fetchOptions, singleRequestTimeoutMs } = prepareFetchOptionsForRequest(options);
|
|
351
352
|
|
|
352
353
|
try {
|
|
353
|
-
const response = await
|
|
354
|
+
const response = await fetchWithOptionalDevPlatformTls(url, fetchOptions);
|
|
354
355
|
const duration = Date.now() - startTime;
|
|
355
356
|
|
|
356
357
|
if (!response.ok) {
|
|
@@ -145,7 +145,10 @@ async function isControllerHealthReachable(controllerUrl) {
|
|
|
145
145
|
return false;
|
|
146
146
|
}
|
|
147
147
|
try {
|
|
148
|
-
const
|
|
148
|
+
const { fetchWithOptionalDevPlatformTls } = require('./dev-platform-tls');
|
|
149
|
+
const res = await fetchWithOptionalDevPlatformTls(`${base}/health`, {
|
|
150
|
+
signal: AbortSignal.timeout(2500)
|
|
151
|
+
});
|
|
149
152
|
return res.ok;
|
|
150
153
|
} catch {
|
|
151
154
|
return false;
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dev platform HTTPS: builder-server nginx front door uses TLS while infra `tlsEnabled` may be off.
|
|
3
|
+
*
|
|
4
|
+
* @fileoverview Self-signed TLS allowance for remote dev platform URLs
|
|
5
|
+
* @author AI Fabrix Team
|
|
6
|
+
* @version 1.0.0
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
'use strict';
|
|
10
|
+
|
|
11
|
+
const https = require('https');
|
|
12
|
+
const config = require('../core/config');
|
|
13
|
+
const { isLocalhostPublicSchemeHostname } = require('./localhost-public-host');
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @param {string} url
|
|
17
|
+
* @returns {boolean}
|
|
18
|
+
*/
|
|
19
|
+
function isHttpsNonLocalhostUrl(url) {
|
|
20
|
+
try {
|
|
21
|
+
const u = new URL(url);
|
|
22
|
+
return u.protocol === 'https:' && !isLocalhostPublicSchemeHostname(u.hostname);
|
|
23
|
+
} catch {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @param {string} requestHostname
|
|
30
|
+
* @param {string} remoteServerUrl
|
|
31
|
+
* @returns {boolean}
|
|
32
|
+
*/
|
|
33
|
+
function hostnameMatchesRemoteDevPlatform(requestHostname, remoteServerUrl) {
|
|
34
|
+
let remoteHost = '';
|
|
35
|
+
try {
|
|
36
|
+
remoteHost = new URL(remoteServerUrl).hostname.toLowerCase();
|
|
37
|
+
} catch {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
const host = String(requestHostname || '').trim().toLowerCase();
|
|
41
|
+
if (!host || !remoteHost) {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
return host === remoteHost || host.endsWith(`.${remoteHost}`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* True when URL is a remote dev platform HTTPS endpoint (builder-server nginx / Traefik front door).
|
|
49
|
+
*
|
|
50
|
+
* @async
|
|
51
|
+
* @param {string} url
|
|
52
|
+
* @returns {Promise<boolean>}
|
|
53
|
+
*/
|
|
54
|
+
async function shouldAllowInsecureDevPlatformTls(url) {
|
|
55
|
+
if (!isHttpsNonLocalhostUrl(url)) {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
try {
|
|
59
|
+
const remote = await config.getRemoteServer();
|
|
60
|
+
const remoteTrimmed = remote && String(remote).trim();
|
|
61
|
+
if (!remoteTrimmed) {
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
const hostname = new URL(url).hostname;
|
|
65
|
+
return hostnameMatchesRemoteDevPlatform(hostname, remoteTrimmed);
|
|
66
|
+
} catch {
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Minimal fetch for self-signed dev platform HTTPS (Node global fetch cannot set rejectUnauthorized).
|
|
73
|
+
*
|
|
74
|
+
* @param {string} url
|
|
75
|
+
* @param {Object} options
|
|
76
|
+
* @returns {Promise<{ status: number, statusText: string, headers: { get: Function }, text: Function, json: Function }>}
|
|
77
|
+
*/
|
|
78
|
+
function nodeHttpsFetchInsecure(url, options = {}) {
|
|
79
|
+
return new Promise((resolve, reject) => {
|
|
80
|
+
const u = new URL(url);
|
|
81
|
+
const method = String(options.method || 'GET').toUpperCase();
|
|
82
|
+
const headers = { ...(options.headers || {}) };
|
|
83
|
+
const body = options.body;
|
|
84
|
+
const reqOpts = {
|
|
85
|
+
hostname: u.hostname,
|
|
86
|
+
port: u.port || 443,
|
|
87
|
+
path: `${u.pathname}${u.search}`,
|
|
88
|
+
method,
|
|
89
|
+
headers,
|
|
90
|
+
rejectUnauthorized: false
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const req = https.request(reqOpts, (res) => {
|
|
94
|
+
const chunks = [];
|
|
95
|
+
res.on('data', (chunk) => chunks.push(chunk));
|
|
96
|
+
res.on('end', () => {
|
|
97
|
+
const raw = Buffer.concat(chunks).toString('utf8');
|
|
98
|
+
const status = res.statusCode || 0;
|
|
99
|
+
resolve({
|
|
100
|
+
ok: status >= 200 && status < 300,
|
|
101
|
+
status,
|
|
102
|
+
statusText: res.statusMessage || '',
|
|
103
|
+
headers: {
|
|
104
|
+
get: (name) => res.headers[String(name || '').toLowerCase()] || null
|
|
105
|
+
},
|
|
106
|
+
text: async() => raw,
|
|
107
|
+
json: async() => JSON.parse(raw)
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
req.on('error', reject);
|
|
113
|
+
if (options.signal) {
|
|
114
|
+
const onAbort = () => {
|
|
115
|
+
req.destroy(new Error('The operation was aborted'));
|
|
116
|
+
};
|
|
117
|
+
if (options.signal.aborted) {
|
|
118
|
+
onAbort();
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
options.signal.addEventListener('abort', onAbort, { once: true });
|
|
122
|
+
}
|
|
123
|
+
if (body !== undefined && body !== null) {
|
|
124
|
+
req.write(typeof body === 'string' ? body : String(body));
|
|
125
|
+
}
|
|
126
|
+
req.end();
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* @async
|
|
132
|
+
* @param {string} url
|
|
133
|
+
* @param {Object} options
|
|
134
|
+
* @returns {Promise<Response>}
|
|
135
|
+
*/
|
|
136
|
+
async function fetchWithOptionalDevPlatformTls(url, options = {}) {
|
|
137
|
+
if (await shouldAllowInsecureDevPlatformTls(url)) {
|
|
138
|
+
return nodeHttpsFetchInsecure(url, options);
|
|
139
|
+
}
|
|
140
|
+
return fetch(url, options);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
module.exports = {
|
|
144
|
+
isHttpsNonLocalhostUrl,
|
|
145
|
+
hostnameMatchesRemoteDevPlatform,
|
|
146
|
+
shouldAllowInsecureDevPlatformTls,
|
|
147
|
+
fetchWithOptionalDevPlatformTls
|
|
148
|
+
};
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP(S) health probe request helpers.
|
|
3
|
+
*
|
|
4
|
+
* @fileoverview Low-level health endpoint HTTP client
|
|
5
|
+
* @author AI Fabrix Team
|
|
6
|
+
* @version 1.0.0
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
'use strict';
|
|
10
|
+
|
|
11
|
+
const http = require('http');
|
|
12
|
+
const https = require('https');
|
|
13
|
+
const chalk = require('chalk');
|
|
14
|
+
const logger = require('./logger');
|
|
15
|
+
const { shouldAllowInsecureDevPlatformTls } = require('./dev-platform-tls');
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @param {string} data
|
|
19
|
+
* @param {number} statusCode
|
|
20
|
+
* @returns {boolean}
|
|
21
|
+
*/
|
|
22
|
+
function parseHealthResponse(data, statusCode) {
|
|
23
|
+
try {
|
|
24
|
+
const health = JSON.parse(data);
|
|
25
|
+
if (health.status === 'UP') {
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
28
|
+
if (health.status === 'ok') {
|
|
29
|
+
return health.database === 'connected' || !health.database;
|
|
30
|
+
}
|
|
31
|
+
if (health.status === 'healthy') {
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
if (health.success === true) {
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
return false;
|
|
38
|
+
} catch {
|
|
39
|
+
return statusCode === 200;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function handleHealthResponse(res, data, debug, resolve) {
|
|
44
|
+
const isHealthy = parseHealthResponse(data, res.statusCode);
|
|
45
|
+
if (debug) {
|
|
46
|
+
const truncatedData = data.length > 200 ? data.substring(0, 200) + '...' : data;
|
|
47
|
+
logger.log(chalk.gray(`[DEBUG] Response body: ${truncatedData}`));
|
|
48
|
+
logger.log(chalk.gray(`[DEBUG] Health check result: ${isHealthy ? 'healthy' : 'unhealthy'}`));
|
|
49
|
+
}
|
|
50
|
+
resolve(isHealthy);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function doHealthCheckRequest(healthCheckUrl, debug, resolve, reject, allowInsecureTls = false) {
|
|
54
|
+
try {
|
|
55
|
+
const urlObj = new URL(healthCheckUrl);
|
|
56
|
+
const transport = urlObj.protocol === 'https:' ? https : http;
|
|
57
|
+
const options = {
|
|
58
|
+
hostname: urlObj.hostname,
|
|
59
|
+
port: urlObj.port || (urlObj.protocol === 'https:' ? 443 : 80),
|
|
60
|
+
path: urlObj.pathname + urlObj.search,
|
|
61
|
+
method: 'GET',
|
|
62
|
+
...(allowInsecureTls ? { rejectUnauthorized: false } : {})
|
|
63
|
+
};
|
|
64
|
+
if (debug) {
|
|
65
|
+
logger.log(chalk.gray(`[DEBUG] Health check request: ${healthCheckUrl}`));
|
|
66
|
+
logger.log(chalk.gray(`[DEBUG] Request options: ${JSON.stringify(options, null, 2)}`));
|
|
67
|
+
}
|
|
68
|
+
const timer = { id: null };
|
|
69
|
+
const req = transport.request(options, (res) => {
|
|
70
|
+
clearTimeout(timer.id);
|
|
71
|
+
let data = '';
|
|
72
|
+
if (debug) {
|
|
73
|
+
logger.log(chalk.gray(`[DEBUG] Response status code: ${res.statusCode}`));
|
|
74
|
+
logger.log(chalk.gray(`[DEBUG] Response headers: ${JSON.stringify(res.headers, null, 2)}`));
|
|
75
|
+
}
|
|
76
|
+
res.on('data', (chunk) => {
|
|
77
|
+
data += chunk;
|
|
78
|
+
});
|
|
79
|
+
res.on('end', () => handleHealthResponse(res, data, debug, resolve));
|
|
80
|
+
});
|
|
81
|
+
timer.id = setTimeout(() => {
|
|
82
|
+
if (debug) logger.log(chalk.gray('[DEBUG] Health check request timeout after 5 seconds'));
|
|
83
|
+
req.destroy();
|
|
84
|
+
resolve(false);
|
|
85
|
+
}, 5000);
|
|
86
|
+
req.on('error', (error) => {
|
|
87
|
+
clearTimeout(timer.id);
|
|
88
|
+
if (debug) logger.log(chalk.gray(`[DEBUG] Health check request error: ${error.message}`));
|
|
89
|
+
resolve(false);
|
|
90
|
+
});
|
|
91
|
+
req.end();
|
|
92
|
+
} catch (error) {
|
|
93
|
+
if (debug) logger.log(chalk.gray(`[DEBUG] Health check exception: ${error.message}`));
|
|
94
|
+
reject(error);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* @async
|
|
100
|
+
* @param {string} healthCheckUrl
|
|
101
|
+
* @param {boolean} [debug=false]
|
|
102
|
+
* @returns {Promise<boolean>}
|
|
103
|
+
*/
|
|
104
|
+
async function checkHealthEndpoint(healthCheckUrl, debug = false) {
|
|
105
|
+
const allowInsecureTls = await shouldAllowInsecureDevPlatformTls(healthCheckUrl);
|
|
106
|
+
return new Promise((resolve, reject) => {
|
|
107
|
+
doHealthCheckRequest(healthCheckUrl, debug, resolve, reject, allowInsecureTls);
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
module.exports = {
|
|
112
|
+
parseHealthResponse,
|
|
113
|
+
checkHealthEndpoint
|
|
114
|
+
};
|
|
@@ -92,6 +92,7 @@ async function computeTraefikPublicAppUrl(_appName, _healthCheckPort, appConfig)
|
|
|
92
92
|
const remoteServer = await coreConfig.getRemoteServer();
|
|
93
93
|
const developerIdRaw = await coreConfig.getDeveloperId();
|
|
94
94
|
const developerIdNum = parseDeveloperIdNum(developerIdRaw);
|
|
95
|
+
const setupPlatformMode = await coreConfig.getSetupPlatformMode();
|
|
95
96
|
|
|
96
97
|
// URLs are resolved for the CLI on the host, not from inside a container.
|
|
97
98
|
const profile = 'local';
|
|
@@ -107,7 +108,8 @@ async function computeTraefikPublicAppUrl(_appName, _healthCheckPort, appConfig)
|
|
|
107
108
|
profile,
|
|
108
109
|
listenPort,
|
|
109
110
|
developerIdNum,
|
|
110
|
-
infraTlsEnabled
|
|
111
|
+
infraTlsEnabled,
|
|
112
|
+
setupPlatformMode
|
|
111
113
|
});
|
|
112
114
|
|
|
113
115
|
return joinUrlPath(publicBase, mountPath);
|
|
@@ -9,9 +9,8 @@ const { formatSuccessLine } = require('./cli-test-layout-chalk');
|
|
|
9
9
|
* @version 2.0.0
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
const http = require('http');
|
|
13
|
-
const https = require('https');
|
|
14
12
|
const net = require('net');
|
|
13
|
+
const { checkHealthEndpoint } = require('./health-check-request');
|
|
15
14
|
const dns = require('dns');
|
|
16
15
|
const chalk = require('chalk');
|
|
17
16
|
const logger = require('./logger');
|
|
@@ -192,117 +191,6 @@ async function getContainerPort(appName, debug = false) {
|
|
|
192
191
|
return 3000;
|
|
193
192
|
}
|
|
194
193
|
|
|
195
|
-
/**
|
|
196
|
-
* Parses health check response
|
|
197
|
-
* @function parseHealthResponse
|
|
198
|
-
* @param {string} data - Response data
|
|
199
|
-
* @param {number} statusCode - HTTP status code
|
|
200
|
-
* @returns {boolean} True if healthy
|
|
201
|
-
*/
|
|
202
|
-
function parseHealthResponse(data, statusCode) {
|
|
203
|
-
try {
|
|
204
|
-
const health = JSON.parse(data);
|
|
205
|
-
if (health.status === 'UP') {
|
|
206
|
-
return true;
|
|
207
|
-
}
|
|
208
|
-
if (health.status === 'ok') {
|
|
209
|
-
return health.database === 'connected' || !health.database;
|
|
210
|
-
}
|
|
211
|
-
if (health.status === 'healthy') {
|
|
212
|
-
return true;
|
|
213
|
-
}
|
|
214
|
-
if (health.success === true) {
|
|
215
|
-
return true;
|
|
216
|
-
}
|
|
217
|
-
return false;
|
|
218
|
-
} catch (error) {
|
|
219
|
-
return statusCode === 200;
|
|
220
|
-
}
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
function handleHealthResponse(res, data, debug, resolve) {
|
|
224
|
-
const isHealthy = parseHealthResponse(data, res.statusCode);
|
|
225
|
-
if (debug) {
|
|
226
|
-
const truncatedData = data.length > 200 ? data.substring(0, 200) + '...' : data;
|
|
227
|
-
logger.log(chalk.gray(`[DEBUG] Response body: ${truncatedData}`));
|
|
228
|
-
logger.log(chalk.gray(`[DEBUG] Health check result: ${isHealthy ? 'healthy' : 'unhealthy'}`));
|
|
229
|
-
}
|
|
230
|
-
resolve(isHealthy);
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
function doHealthCheckRequest(healthCheckUrl, debug, resolve, reject) {
|
|
234
|
-
try {
|
|
235
|
-
const urlObj = new URL(healthCheckUrl);
|
|
236
|
-
const transport = urlObj.protocol === 'https:' ? https : http;
|
|
237
|
-
const options = {
|
|
238
|
-
hostname: urlObj.hostname,
|
|
239
|
-
port: urlObj.port || (urlObj.protocol === 'https:' ? 443 : 80),
|
|
240
|
-
path: urlObj.pathname + urlObj.search,
|
|
241
|
-
method: 'GET'
|
|
242
|
-
};
|
|
243
|
-
if (debug) {
|
|
244
|
-
logger.log(chalk.gray(`[DEBUG] Health check request: ${healthCheckUrl}`));
|
|
245
|
-
logger.log(chalk.gray(`[DEBUG] Request options: ${JSON.stringify(options, null, 2)}`));
|
|
246
|
-
}
|
|
247
|
-
const timer = { id: null };
|
|
248
|
-
const req = transport.request(options, (res) => {
|
|
249
|
-
clearTimeout(timer.id);
|
|
250
|
-
let data = '';
|
|
251
|
-
if (debug) {
|
|
252
|
-
logger.log(chalk.gray(`[DEBUG] Response status code: ${res.statusCode}`));
|
|
253
|
-
logger.log(chalk.gray(`[DEBUG] Response headers: ${JSON.stringify(res.headers, null, 2)}`));
|
|
254
|
-
}
|
|
255
|
-
res.on('data', (chunk) => {
|
|
256
|
-
data += chunk;
|
|
257
|
-
});
|
|
258
|
-
res.on('end', () => handleHealthResponse(res, data, debug, resolve));
|
|
259
|
-
});
|
|
260
|
-
timer.id = setTimeout(() => {
|
|
261
|
-
if (debug) logger.log(chalk.gray('[DEBUG] Health check request timeout after 5 seconds'));
|
|
262
|
-
req.destroy();
|
|
263
|
-
resolve(false);
|
|
264
|
-
}, 5000);
|
|
265
|
-
req.on('error', (error) => {
|
|
266
|
-
clearTimeout(timer.id);
|
|
267
|
-
if (debug) logger.log(chalk.gray(`[DEBUG] Health check request error: ${error.message}`));
|
|
268
|
-
resolve(false);
|
|
269
|
-
});
|
|
270
|
-
req.end();
|
|
271
|
-
} catch (error) {
|
|
272
|
-
if (debug) logger.log(chalk.gray(`[DEBUG] Health check exception: ${error.message}`));
|
|
273
|
-
reject(error);
|
|
274
|
-
}
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
/**
|
|
278
|
-
* Checks health endpoint
|
|
279
|
-
* @async
|
|
280
|
-
* @function checkHealthEndpoint
|
|
281
|
-
* @param {string} healthCheckUrl - Health check URL
|
|
282
|
-
* @param {boolean} [debug=false] - Enable debug logging
|
|
283
|
-
* @returns {Promise<boolean>} True if healthy
|
|
284
|
-
* @throws {Error} If request fails with exception
|
|
285
|
-
*/
|
|
286
|
-
async function checkHealthEndpoint(healthCheckUrl, debug = false) {
|
|
287
|
-
return new Promise((resolve, reject) => {
|
|
288
|
-
doHealthCheckRequest(healthCheckUrl, debug, resolve, reject);
|
|
289
|
-
});
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
/**
|
|
293
|
-
* Waits for application health check to pass
|
|
294
|
-
* Checks HTTP endpoint and waits for healthy response
|
|
295
|
-
*
|
|
296
|
-
* @async
|
|
297
|
-
* @function waitForHealthCheck
|
|
298
|
-
* @param {string} appName - Application name
|
|
299
|
-
* @param {number} timeout - Timeout in seconds (default: 90)
|
|
300
|
-
* @param {number} [port] - Application port (auto-detected if not provided)
|
|
301
|
-
* @param {Object} [config] - Application configuration
|
|
302
|
-
* @param {boolean} [debug=false] - Enable debug logging
|
|
303
|
-
* @returns {Promise<void>} Resolves when health check passes
|
|
304
|
-
* @throws {Error} If health check times out
|
|
305
|
-
*/
|
|
306
194
|
/**
|
|
307
195
|
* Determines health check port
|
|
308
196
|
* @async
|
|
@@ -31,7 +31,11 @@ async function probeRealmWellKnown(url) {
|
|
|
31
31
|
const controller = new AbortController();
|
|
32
32
|
const timer = setTimeout(() => controller.abort(), 8000);
|
|
33
33
|
try {
|
|
34
|
-
const
|
|
34
|
+
const { fetchWithOptionalDevPlatformTls } = require('./dev-platform-tls');
|
|
35
|
+
const res = await fetchWithOptionalDevPlatformTls(url, {
|
|
36
|
+
method: 'GET',
|
|
37
|
+
signal: controller.signal
|
|
38
|
+
});
|
|
35
39
|
return res.ok;
|
|
36
40
|
} catch {
|
|
37
41
|
return false;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Loopback hostnames always use http for declarative public URLs.
|
|
3
|
+
*
|
|
4
|
+
* @fileoverview Localhost hostname detection for public URL scheme
|
|
5
|
+
* @author AI Fabrix Team
|
|
6
|
+
* @version 1.0.0
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
'use strict';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @param {string} hostname
|
|
13
|
+
* @returns {boolean}
|
|
14
|
+
*/
|
|
15
|
+
function isLocalhostPublicSchemeHostname(hostname) {
|
|
16
|
+
const h = String(hostname || '').trim().toLowerCase();
|
|
17
|
+
if (!h) {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
if (h === 'localhost') {
|
|
21
|
+
return true;
|
|
22
|
+
}
|
|
23
|
+
if (h === '127.0.0.1') {
|
|
24
|
+
return true;
|
|
25
|
+
}
|
|
26
|
+
return h === '::1';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
module.exports = {
|
|
30
|
+
isLocalhostPublicSchemeHostname
|
|
31
|
+
};
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Merge npm user-global auth lines from BASH_NPM_TOKEN / NPM_TOKEN.
|
|
3
|
+
*
|
|
4
|
+
* @fileoverview User-level ~/.npmrc auth for @aifabrix scope (not repo .npmrc)
|
|
5
|
+
* @author AI Fabrix Team
|
|
6
|
+
* @version 1.0.0
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
'use strict';
|
|
10
|
+
|
|
11
|
+
const fs = require('fs');
|
|
12
|
+
const os = require('os');
|
|
13
|
+
const path = require('path');
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Keys managed by aifabrix npm config (replaced or removed on each run).
|
|
17
|
+
* `always-auth` is legacy — npm 11+ warns at userconfig; auth uses scoped registry + _authToken.
|
|
18
|
+
*/
|
|
19
|
+
const MANAGED_KEYS = new Set([
|
|
20
|
+
'@aifabrix:registry',
|
|
21
|
+
'always-auth',
|
|
22
|
+
'//registry.npmjs.org/:_authToken'
|
|
23
|
+
]);
|
|
24
|
+
|
|
25
|
+
const REGISTRY_URL = 'https://registry.npmjs.org/';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Resolve npm userconfig file path (~/.npmrc by default).
|
|
29
|
+
*
|
|
30
|
+
* @returns {string}
|
|
31
|
+
*/
|
|
32
|
+
function getUserNpmrcPath() {
|
|
33
|
+
const fromEnv = process.env.npm_config_userconfig || process.env.NPM_CONFIG_USERCONFIG;
|
|
34
|
+
if (fromEnv && typeof fromEnv === 'string' && fromEnv.trim()) {
|
|
35
|
+
return path.resolve(fromEnv.trim());
|
|
36
|
+
}
|
|
37
|
+
return path.join(os.homedir(), '.npmrc');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Parse a single .npmrc line into key/value (ignores comments and blanks).
|
|
42
|
+
*
|
|
43
|
+
* @param {string} line
|
|
44
|
+
* @returns {{ key: string, value: string }|null}
|
|
45
|
+
*/
|
|
46
|
+
function parseNpmrcLine(line) {
|
|
47
|
+
const trimmed = line.trim();
|
|
48
|
+
if (!trimmed || trimmed.startsWith('#') || trimmed.startsWith(';')) {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
const eq = trimmed.indexOf('=');
|
|
52
|
+
if (eq === -1) {
|
|
53
|
+
return { key: trimmed, value: '' };
|
|
54
|
+
}
|
|
55
|
+
return {
|
|
56
|
+
key: trimmed.slice(0, eq).trim(),
|
|
57
|
+
value: trimmed.slice(eq + 1).trim()
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Build managed auth lines for @aifabrix private registry.
|
|
63
|
+
*
|
|
64
|
+
* @param {string} token - Bare npm automation token (never logged by callers)
|
|
65
|
+
* @returns {string[]}
|
|
66
|
+
*/
|
|
67
|
+
function buildManagedLines(token) {
|
|
68
|
+
return [
|
|
69
|
+
`@aifabrix:registry=${REGISTRY_URL}`,
|
|
70
|
+
`//registry.npmjs.org/:_authToken=${token}`
|
|
71
|
+
];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Merge managed lines into existing .npmrc content (idempotent).
|
|
76
|
+
*
|
|
77
|
+
* @param {string} existingContent
|
|
78
|
+
* @param {string} token
|
|
79
|
+
* @returns {string}
|
|
80
|
+
*/
|
|
81
|
+
function mergeNpmrcContent(existingContent, token) {
|
|
82
|
+
const kept = [];
|
|
83
|
+
const lines = (existingContent || '').split(/\r?\n/);
|
|
84
|
+
for (const line of lines) {
|
|
85
|
+
const parsed = parseNpmrcLine(line);
|
|
86
|
+
if (parsed && MANAGED_KEYS.has(parsed.key)) {
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
kept.push(line);
|
|
90
|
+
}
|
|
91
|
+
while (kept.length > 0 && kept[kept.length - 1] === '') {
|
|
92
|
+
kept.pop();
|
|
93
|
+
}
|
|
94
|
+
const managed = buildManagedLines(token);
|
|
95
|
+
if (kept.length > 0) {
|
|
96
|
+
return `${kept.join('\n')}\n${managed.join('\n')}\n`;
|
|
97
|
+
}
|
|
98
|
+
return `${managed.join('\n')}\n`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Write merged user-global npm auth from token.
|
|
103
|
+
*
|
|
104
|
+
* @param {string} token
|
|
105
|
+
* @param {{ dryRun?: boolean, npmrcPath?: string }} [options]
|
|
106
|
+
* @returns {{ npmrcPath: string, managedKeys: string[], dryRun: boolean, wrote: boolean }}
|
|
107
|
+
*/
|
|
108
|
+
function writeUserNpmrcFromToken(token, options = {}) {
|
|
109
|
+
const npmrcPath = options.npmrcPath || getUserNpmrcPath();
|
|
110
|
+
const dryRun = Boolean(options.dryRun);
|
|
111
|
+
const managedKeys = [...MANAGED_KEYS];
|
|
112
|
+
if (!token || !String(token).trim()) {
|
|
113
|
+
throw new Error('NPM token is required to update user npm config');
|
|
114
|
+
}
|
|
115
|
+
const existing = fs.existsSync(npmrcPath) ? fs.readFileSync(npmrcPath, 'utf8') : '';
|
|
116
|
+
const merged = mergeNpmrcContent(existing, String(token).trim());
|
|
117
|
+
if (!dryRun) {
|
|
118
|
+
const dir = path.dirname(npmrcPath);
|
|
119
|
+
if (!fs.existsSync(dir)) {
|
|
120
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
121
|
+
}
|
|
122
|
+
fs.writeFileSync(npmrcPath, merged, { mode: 0o600 });
|
|
123
|
+
try {
|
|
124
|
+
fs.chmodSync(npmrcPath, 0o600);
|
|
125
|
+
} catch {
|
|
126
|
+
// best-effort on platforms that restrict chmod
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return {
|
|
130
|
+
npmrcPath,
|
|
131
|
+
managedKeys,
|
|
132
|
+
dryRun,
|
|
133
|
+
wrote: !dryRun
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
module.exports = {
|
|
138
|
+
MANAGED_KEYS,
|
|
139
|
+
REGISTRY_URL,
|
|
140
|
+
getUserNpmrcPath,
|
|
141
|
+
parseNpmrcLine,
|
|
142
|
+
buildManagedLines,
|
|
143
|
+
mergeNpmrcContent,
|
|
144
|
+
writeUserNpmrcFromToken
|
|
145
|
+
};
|
|
@@ -103,12 +103,12 @@ async function computeAppBaseUrl(appName) {
|
|
|
103
103
|
const userCfg = await config.getConfig();
|
|
104
104
|
const remoteServer = await config.getRemoteServer();
|
|
105
105
|
const infraTlsEnabled = Boolean(userCfg && userCfg.tlsEnabled);
|
|
106
|
+
const setupPlatformMode = await config.getSetupPlatformMode();
|
|
106
107
|
|
|
107
108
|
const fd = variables.frontDoorRouting || null;
|
|
108
109
|
const frontDoorEnabled = Boolean(fd && fd.enabled === true);
|
|
109
110
|
const traefikOn = Boolean(userCfg && userCfg.traefik);
|
|
110
111
|
const pathActive = Boolean(traefikOn && frontDoorEnabled);
|
|
111
|
-
const setupPlatformMode = await config.getSetupPlatformMode();
|
|
112
112
|
|
|
113
113
|
const publicBase = computePublicUrlBaseString({
|
|
114
114
|
traefik: traefikOn,
|
|
@@ -120,7 +120,8 @@ async function computeAppBaseUrl(appName) {
|
|
|
120
120
|
profile: 'docker',
|
|
121
121
|
listenPort: basePort,
|
|
122
122
|
developerIdNum,
|
|
123
|
-
infraTlsEnabled
|
|
123
|
+
infraTlsEnabled,
|
|
124
|
+
setupPlatformMode
|
|
124
125
|
});
|
|
125
126
|
|
|
126
127
|
if (pathActive) {
|
|
@@ -148,10 +149,62 @@ async function resolvePlatformControllerUrl() {
|
|
|
148
149
|
return computeAppBaseUrl(MISO_CONTROLLER_APP);
|
|
149
150
|
}
|
|
150
151
|
|
|
152
|
+
/**
|
|
153
|
+
* Direct localhost controller URL (published Docker port + optional front-door mount).
|
|
154
|
+
* Used when the CLI runs on the builder host but the public Traefik URL is unreachable.
|
|
155
|
+
*
|
|
156
|
+
* @async
|
|
157
|
+
* @param {string} [appName]
|
|
158
|
+
* @returns {Promise<string>}
|
|
159
|
+
*/
|
|
160
|
+
async function resolveLocalhostPlatformControllerUrl(appName = MISO_CONTROLLER_APP) {
|
|
161
|
+
const variables = loadAppVariables(appName);
|
|
162
|
+
const developerIdRaw = await config.getDeveloperId();
|
|
163
|
+
const developerIdNum =
|
|
164
|
+
typeof developerIdRaw === 'string' ? parseInt(developerIdRaw, 10) : developerIdRaw;
|
|
165
|
+
const basePort = Number(variables.port || 3000);
|
|
166
|
+
const hostPort = basePort + (Number.isFinite(developerIdNum) ? developerIdNum : 0) * 100;
|
|
167
|
+
const fd = variables.frontDoorRouting;
|
|
168
|
+
if (fd && fd.enabled === true && fd.pattern) {
|
|
169
|
+
const mount = normalizeFrontDoorPatternForHealth(fd.pattern);
|
|
170
|
+
if (mount && mount !== '/') {
|
|
171
|
+
return joinUrlPath(`http://localhost:${hostPort}`, mount).replace(/\/+$/, '');
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return `http://localhost:${hostPort}`;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Controller URL for CLI HTTP calls on a remote dev host (builder-server).
|
|
179
|
+
* Keeps the public Traefik URL when reachable; otherwise falls back to localhost.
|
|
180
|
+
*
|
|
181
|
+
* @async
|
|
182
|
+
* @returns {Promise<string>}
|
|
183
|
+
*/
|
|
184
|
+
async function resolveCliPlatformControllerUrl() {
|
|
185
|
+
const publicUrl = await resolvePlatformControllerUrl();
|
|
186
|
+
const remoteServer = await config.getRemoteServer();
|
|
187
|
+
if (!remoteServer || !String(remoteServer).trim()) {
|
|
188
|
+
return publicUrl;
|
|
189
|
+
}
|
|
190
|
+
try {
|
|
191
|
+
const { checkHealthEndpoint } = require('./health-check-request');
|
|
192
|
+
const healthUrl = `${String(publicUrl).replace(/\/+$/, '')}/health`;
|
|
193
|
+
if (await checkHealthEndpoint(healthUrl)) {
|
|
194
|
+
return publicUrl;
|
|
195
|
+
}
|
|
196
|
+
} catch {
|
|
197
|
+
// fall through to localhost
|
|
198
|
+
}
|
|
199
|
+
return resolveLocalhostPlatformControllerUrl(MISO_CONTROLLER_APP);
|
|
200
|
+
}
|
|
201
|
+
|
|
151
202
|
module.exports = {
|
|
152
203
|
MISO_CONTROLLER_APP,
|
|
153
204
|
loadAppVariables,
|
|
154
205
|
loadBundledApplicationVariables,
|
|
155
206
|
computeAppBaseUrl,
|
|
156
|
-
resolvePlatformControllerUrl
|
|
207
|
+
resolvePlatformControllerUrl,
|
|
208
|
+
resolveLocalhostPlatformControllerUrl,
|
|
209
|
+
resolveCliPlatformControllerUrl
|
|
157
210
|
};
|
|
@@ -10,26 +10,11 @@
|
|
|
10
10
|
|
|
11
11
|
const { expandFrontDoorHostPlaceholders } = require('./compose-generator');
|
|
12
12
|
const { publishedHostPort, localHostPort } = require('./declarative-url-ports');
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
* @returns {boolean}
|
|
19
|
-
*/
|
|
20
|
-
function isLocalhostPublicSchemeHostname(hostname) {
|
|
21
|
-
const h = String(hostname || '').trim().toLowerCase();
|
|
22
|
-
if (!h) {
|
|
23
|
-
return false;
|
|
24
|
-
}
|
|
25
|
-
if (h === 'localhost') {
|
|
26
|
-
return true;
|
|
27
|
-
}
|
|
28
|
-
if (h === '127.0.0.1') {
|
|
29
|
-
return true;
|
|
30
|
-
}
|
|
31
|
-
return h === '::1';
|
|
32
|
-
}
|
|
13
|
+
const {
|
|
14
|
+
normalizeSetupPlatformMode,
|
|
15
|
+
SETUP_PLATFORM_MODE
|
|
16
|
+
} = require('../core/setup-platform-mode');
|
|
17
|
+
const { isLocalhostPublicSchemeHostname } = require('./localhost-public-host');
|
|
33
18
|
|
|
34
19
|
/**
|
|
35
20
|
* Expand frontDoorRouting.host placeholders for url:// (same rules as Traefik labels in compose-generator).
|
|
@@ -156,6 +141,28 @@ function templateUsesRemoteHostPlaceholder(hostTemplate) {
|
|
|
156
141
|
return /\$\{REMOTE_HOST\}/.test(String(hostTemplate || ''));
|
|
157
142
|
}
|
|
158
143
|
|
|
144
|
+
/**
|
|
145
|
+
* HTTPS for Traefik front-door public host when infra TLS is on, remote-server is https,
|
|
146
|
+
* or setup mode is single-traefik/full (builder-server nginx edge TLS).
|
|
147
|
+
*
|
|
148
|
+
* @param {Object} opts
|
|
149
|
+
* @param {boolean} [opts.infraTlsEnabled]
|
|
150
|
+
* @param {string|null|undefined} [opts.remoteServer]
|
|
151
|
+
* @param {string|null|undefined} [opts.setupPlatformMode]
|
|
152
|
+
* @returns {boolean}
|
|
153
|
+
*/
|
|
154
|
+
function traefikFrontDoorUsesHttps(opts = {}) {
|
|
155
|
+
if (opts.infraTlsEnabled) {
|
|
156
|
+
return true;
|
|
157
|
+
}
|
|
158
|
+
const remote = String(opts.remoteServer || '').trim();
|
|
159
|
+
if (remote && /^https:\/\//i.test(remote)) {
|
|
160
|
+
return true;
|
|
161
|
+
}
|
|
162
|
+
const mode = normalizeSetupPlatformMode(opts.setupPlatformMode);
|
|
163
|
+
return mode === SETUP_PLATFORM_MODE.SINGLE_TRAEFIK || mode === SETUP_PLATFORM_MODE.FULL;
|
|
164
|
+
}
|
|
165
|
+
|
|
159
166
|
/**
|
|
160
167
|
* @param {Object} opts - same shape as computePublicUrlBaseString
|
|
161
168
|
* @returns {string|null}
|
|
@@ -167,7 +174,6 @@ function buildTraefikPublicBaseIfApplicable(opts) {
|
|
|
167
174
|
hostTemplate,
|
|
168
175
|
developerIdRaw,
|
|
169
176
|
remoteServer,
|
|
170
|
-
infraTlsEnabled,
|
|
171
177
|
profile,
|
|
172
178
|
developerIdNum
|
|
173
179
|
} = opts;
|
|
@@ -189,8 +195,7 @@ function buildTraefikPublicBaseIfApplicable(opts) {
|
|
|
189
195
|
if (!expanded) {
|
|
190
196
|
return null;
|
|
191
197
|
}
|
|
192
|
-
|
|
193
|
-
const useHttps = Boolean(infraTlsEnabled);
|
|
198
|
+
const useHttps = traefikFrontDoorUsesHttps(opts);
|
|
194
199
|
let scheme = useHttps ? 'https' : 'http';
|
|
195
200
|
if (isLocalhostPublicSchemeHostname(expanded)) {
|
|
196
201
|
scheme = 'http';
|
|
@@ -210,7 +215,8 @@ function buildTraefikPublicBaseIfApplicable(opts) {
|
|
|
210
215
|
* @param {'docker'|'local'} opts.profile
|
|
211
216
|
* @param {number} opts.listenPort
|
|
212
217
|
* @param {number} opts.developerIdNum
|
|
213
|
-
* @param {boolean} [opts.infraTlsEnabled] - `tlsEnabled` from ~/.aifabrix/config.yaml (`up-infra --tls`)
|
|
218
|
+
* @param {boolean} [opts.infraTlsEnabled] - `tlsEnabled` from ~/.aifabrix/config.yaml (`up-infra --tls`). Traefik front-door public bases also use **`https`** when `remote-server` is **`https://`** or `setupPlatformMode` is **`single-traefik`** / **`full`** (builder-server nginx edge TLS). Loopback hostnames stay **http** always.
|
|
219
|
+
* @param {string|null|undefined} [opts.setupPlatformMode] - From config; used with remote-server for edge TLS scheme
|
|
214
220
|
* @param {boolean} [opts.pathActive] - traefik ∧ frontDoorRouting.enabled; required for Traefik host branch (plan 124)
|
|
215
221
|
* @param {boolean|undefined} [opts.declarativePublicUrlsUseLocalhost] - **`true`**: force localhost authority (+ port rules below). **`false`**: allow `remote-server` as authority when Traefik is on, or when global `traefik` is not `false` and per-app `proxy` opts in without Traefik. **`undefined`**: default — omit `remote-server` when Traefik is off so published services use `http://localhost:<port>` (e.g. `af setup` / device login URLs). When user config has **`traefik: false`**, expansion forces localhost for public bases regardless of per-app `proxy`.
|
|
216
222
|
* @returns {string}
|
|
@@ -270,5 +276,7 @@ function computePublicUrlBaseString(opts) {
|
|
|
270
276
|
module.exports = {
|
|
271
277
|
expandFrontDoorHostTemplateForUrls,
|
|
272
278
|
computePublicUrlBaseString,
|
|
273
|
-
resolveHostPortForDeclarativePublic
|
|
279
|
+
resolveHostPortForDeclarativePublic,
|
|
280
|
+
isLocalhostPublicSchemeHostname,
|
|
281
|
+
traefikFrontDoorUsesHttps
|
|
274
282
|
};
|