@aifabrix/builder 2.40.0 → 2.41.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -5
- package/integration/hubspot/test.js +1 -1
- package/jest.config.manual.js +29 -0
- package/lib/api/credential.api.js +40 -0
- package/lib/api/dev.api.js +423 -0
- package/lib/api/types/credential.types.js +23 -0
- package/lib/api/types/dev.types.js +140 -0
- package/lib/app/config.js +21 -0
- package/lib/app/down.js +2 -1
- package/lib/app/index.js +9 -0
- package/lib/app/push.js +36 -12
- package/lib/app/readme.js +1 -3
- package/lib/app/run-env-compose.js +201 -0
- package/lib/app/run-helpers.js +121 -118
- package/lib/app/run.js +148 -28
- package/lib/app/show.js +5 -2
- package/lib/build/index.js +11 -3
- package/lib/cli/setup-app.js +140 -14
- package/lib/cli/setup-auth.js +1 -0
- package/lib/cli/setup-dev.js +180 -17
- package/lib/cli/setup-environment.js +4 -2
- package/lib/cli/setup-external-system.js +71 -21
- package/lib/cli/setup-infra.js +29 -2
- package/lib/cli/setup-secrets.js +52 -5
- package/lib/cli/setup-utility.js +19 -4
- package/lib/commands/app-install.js +172 -0
- package/lib/commands/app-shell.js +75 -0
- package/lib/commands/app-test.js +282 -0
- package/lib/commands/app.js +1 -1
- package/lib/commands/auth-status.js +36 -3
- package/lib/commands/dev-cli-handlers.js +141 -0
- package/lib/commands/dev-down.js +114 -0
- package/lib/commands/dev-init.js +309 -0
- package/lib/commands/secrets-list.js +118 -0
- package/lib/commands/secrets-remove.js +97 -0
- package/lib/commands/secrets-set.js +30 -17
- package/lib/commands/secrets-validate.js +50 -0
- package/lib/commands/up-dataplane.js +2 -2
- package/lib/commands/up-miso.js +0 -25
- package/lib/commands/upload.js +26 -1
- package/lib/core/admin-secrets.js +96 -0
- package/lib/core/secrets-ensure.js +378 -0
- package/lib/core/secrets-env-write.js +157 -0
- package/lib/core/secrets.js +147 -81
- package/lib/datasource/field-reference-validator.js +91 -0
- package/lib/datasource/validate.js +21 -3
- package/lib/deployment/environment-config.js +137 -0
- package/lib/deployment/environment.js +21 -98
- package/lib/deployment/push.js +32 -2
- package/lib/external-system/download.js +7 -0
- package/lib/external-system/test-auth.js +7 -3
- package/lib/external-system/test.js +5 -1
- package/lib/generator/index.js +174 -25
- package/lib/generator/wizard.js +13 -1
- package/lib/infrastructure/helpers.js +103 -20
- package/lib/infrastructure/index.js +88 -10
- package/lib/infrastructure/services.js +70 -15
- package/lib/schema/application-schema.json +24 -3
- package/lib/schema/external-system.schema.json +435 -413
- package/lib/utils/api.js +3 -3
- package/lib/utils/app-register-auth.js +25 -3
- package/lib/utils/cli-utils.js +20 -0
- package/lib/utils/compose-generator.js +76 -75
- package/lib/utils/compose-handlebars-helpers.js +43 -0
- package/lib/utils/compose-vector-helper.js +18 -0
- package/lib/utils/config-paths.js +127 -2
- package/lib/utils/credential-secrets-env.js +267 -0
- package/lib/utils/dev-cert-helper.js +122 -0
- package/lib/utils/device-code-helpers.js +224 -0
- package/lib/utils/device-code.js +37 -336
- package/lib/utils/docker-build.js +40 -8
- package/lib/utils/env-copy.js +83 -13
- package/lib/utils/env-map.js +35 -5
- package/lib/utils/env-template.js +6 -5
- package/lib/utils/error-formatters/http-status-errors.js +20 -1
- package/lib/utils/help-builder.js +15 -2
- package/lib/utils/infra-status.js +30 -1
- package/lib/utils/local-secrets.js +7 -52
- package/lib/utils/mutagen-install.js +195 -0
- package/lib/utils/mutagen.js +146 -0
- package/lib/utils/paths.js +49 -33
- package/lib/utils/port-resolver.js +28 -16
- package/lib/utils/remote-dev-auth.js +38 -0
- package/lib/utils/remote-docker-env.js +43 -0
- package/lib/utils/remote-secrets-loader.js +60 -0
- package/lib/utils/secrets-generator.js +94 -6
- package/lib/utils/secrets-helpers.js +33 -25
- package/lib/utils/secrets-path.js +2 -2
- package/lib/utils/secrets-utils.js +52 -1
- package/lib/utils/secrets-validation.js +84 -0
- package/lib/utils/ssh-key-helper.js +116 -0
- package/lib/utils/token-manager-messages.js +90 -0
- package/lib/utils/token-manager.js +5 -4
- package/lib/utils/variable-transformer.js +3 -3
- package/lib/validation/validate.js +1 -1
- package/lib/validation/validator.js +65 -0
- package/package.json +4 -2
- package/scripts/install-local.js +34 -15
- package/templates/README.md +0 -1
- package/templates/applications/README.md.hbs +4 -4
- package/templates/applications/dataplane/application.yaml +5 -4
- package/templates/applications/dataplane/env.template +12 -7
- package/templates/applications/keycloak/env.template +2 -0
- package/templates/applications/miso-controller/application.yaml +1 -0
- package/templates/applications/miso-controller/env.template +11 -9
- package/templates/external-system/external-system.json.hbs +1 -16
- package/templates/python/docker-compose.hbs +49 -23
- package/templates/typescript/docker-compose.hbs +48 -22
|
@@ -54,8 +54,8 @@ async function getActualSecretsPath(secretsPath, _appName) {
|
|
|
54
54
|
};
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
-
// Cascading lookup: user's file first (
|
|
58
|
-
const userSecretsPath = path.join(paths.
|
|
57
|
+
// Cascading lookup: user's file first (primary home: AIFABRIX_HOME or ~/.aifabrix)
|
|
58
|
+
const userSecretsPath = path.join(paths.getConfigDirForPaths(), 'secrets.local.yaml');
|
|
59
59
|
|
|
60
60
|
// Check config.yaml for canonical secrets path
|
|
61
61
|
let buildSecretsPath = null;
|
|
@@ -15,6 +15,24 @@ const yaml = require('js-yaml');
|
|
|
15
15
|
const logger = require('./logger');
|
|
16
16
|
const pathsUtil = require('./paths');
|
|
17
17
|
const { getContainerPort } = require('./port-resolver');
|
|
18
|
+
const { loadYamlTolerantOfDuplicateKeys } = require('./secrets-generator');
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Parses secrets YAML content with fallback for duplicate keys.
|
|
22
|
+
* @param {string} content - Raw file content
|
|
23
|
+
* @returns {Object} Parsed secrets object
|
|
24
|
+
*/
|
|
25
|
+
function parseSecretsContent(content) {
|
|
26
|
+
try {
|
|
27
|
+
return yaml.load(content);
|
|
28
|
+
} catch (yamlErr) {
|
|
29
|
+
const msg = yamlErr.message || '';
|
|
30
|
+
if (msg.includes('duplicate') || msg.includes('duplicated mapping')) {
|
|
31
|
+
return loadYamlTolerantOfDuplicateKeys(content);
|
|
32
|
+
}
|
|
33
|
+
throw yamlErr;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
18
36
|
|
|
19
37
|
/**
|
|
20
38
|
* Loads secrets from file with cascading lookup support
|
|
@@ -45,6 +63,38 @@ async function loadSecretsFromFile(filePath) {
|
|
|
45
63
|
}
|
|
46
64
|
}
|
|
47
65
|
|
|
66
|
+
/**
|
|
67
|
+
* Loads user secrets from the primary config directory (AIFABRIX_HOME or ~/.aifabrix).
|
|
68
|
+
* Used as the master source when merging with project/public secrets: user values win,
|
|
69
|
+
* missing keys are filled from the public (aifabrix-secrets) file.
|
|
70
|
+
* Does not use config.yaml aifabrix-home so the merge always sees the actual user file.
|
|
71
|
+
*
|
|
72
|
+
* @function loadPrimaryUserSecrets
|
|
73
|
+
* @returns {Object} Loaded secrets object or empty object
|
|
74
|
+
*/
|
|
75
|
+
function loadPrimaryUserSecrets() {
|
|
76
|
+
const primaryDir = pathsUtil.getConfigDirForPaths();
|
|
77
|
+
const userSecretsPath = path.join(primaryDir, 'secrets.local.yaml');
|
|
78
|
+
if (!fs.existsSync(userSecretsPath)) {
|
|
79
|
+
return {};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
try {
|
|
83
|
+
const content = fs.readFileSync(userSecretsPath, 'utf8');
|
|
84
|
+
const secrets = parseSecretsContent(content);
|
|
85
|
+
if (!secrets || typeof secrets !== 'object') {
|
|
86
|
+
throw new Error(`Invalid secrets file format: ${userSecretsPath}`);
|
|
87
|
+
}
|
|
88
|
+
return secrets;
|
|
89
|
+
} catch (error) {
|
|
90
|
+
if (error.message.includes('Invalid secrets file format')) {
|
|
91
|
+
throw error;
|
|
92
|
+
}
|
|
93
|
+
logger.warn(`Warning: Could not read secrets file ${userSecretsPath}: ${error.message}`);
|
|
94
|
+
return {};
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
48
98
|
/**
|
|
49
99
|
* Loads user secrets from ~/.aifabrix/secrets.local.yaml
|
|
50
100
|
* Uses paths.getAifabrixHome() to respect config.yaml aifabrix-home override
|
|
@@ -59,7 +109,7 @@ function loadUserSecrets() {
|
|
|
59
109
|
|
|
60
110
|
try {
|
|
61
111
|
const content = fs.readFileSync(userSecretsPath, 'utf8');
|
|
62
|
-
const secrets =
|
|
112
|
+
const secrets = parseSecretsContent(content);
|
|
63
113
|
if (!secrets || typeof secrets !== 'object') {
|
|
64
114
|
throw new Error(`Invalid secrets file format: ${userSecretsPath}`);
|
|
65
115
|
}
|
|
@@ -157,6 +207,7 @@ function resolveUrlPort(protocol, hostname, port, urlPath, hostnameToService) {
|
|
|
157
207
|
|
|
158
208
|
module.exports = {
|
|
159
209
|
loadSecretsFromFile,
|
|
210
|
+
loadPrimaryUserSecrets,
|
|
160
211
|
loadUserSecrets,
|
|
161
212
|
loadDefaultSecrets,
|
|
162
213
|
buildHostnameToServiceMap,
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AI Fabrix Builder – Secrets file validation
|
|
3
|
+
*
|
|
4
|
+
* Validates secrets.local.yaml (or given path): valid YAML, flat key-value structure,
|
|
5
|
+
* and optional naming convention (*KeyVault suffix per keyvault.md).
|
|
6
|
+
*
|
|
7
|
+
* @fileoverview Secrets file validation for structure and naming
|
|
8
|
+
* @author AI Fabrix Team
|
|
9
|
+
* @version 2.0.0
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const fs = require('fs');
|
|
13
|
+
const path = require('path');
|
|
14
|
+
const yaml = require('js-yaml');
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Optional naming convention: keys should end with KeyVault or match known patterns.
|
|
18
|
+
* @param {string} key - Secret key
|
|
19
|
+
* @returns {boolean} True if key matches convention
|
|
20
|
+
*/
|
|
21
|
+
function keyMatchesNamingConvention(key) {
|
|
22
|
+
if (!key || typeof key !== 'string') return false;
|
|
23
|
+
if (key.endsWith('KeyVault')) return true;
|
|
24
|
+
return /^[a-z0-9-_]+KeyVault$/i.test(key);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Validate that parsed secrets is a flat object (no nested objects for values).
|
|
29
|
+
* @param {*} parsed - Parsed YAML
|
|
30
|
+
* @param {boolean} checkNaming - Whether to check key naming
|
|
31
|
+
* @returns {string[]} Errors
|
|
32
|
+
*/
|
|
33
|
+
function validateParsedSecrets(parsed, checkNaming) {
|
|
34
|
+
const errors = [];
|
|
35
|
+
if (parsed === null || parsed === undefined) return errors;
|
|
36
|
+
if (typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
37
|
+
errors.push('Secrets file must be a flat key-value object (no nested objects or arrays)');
|
|
38
|
+
return errors;
|
|
39
|
+
}
|
|
40
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
41
|
+
if (typeof value !== 'string' && typeof value !== 'number' && value !== null && value !== undefined) {
|
|
42
|
+
if (typeof value === 'object') {
|
|
43
|
+
errors.push(`Key "${key}": secret values must be strings or scalars (no nested objects)`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
if (checkNaming && !keyMatchesNamingConvention(key)) {
|
|
47
|
+
errors.push(`Key "${key}": recommended format is *KeyVault (e.g. postgres-passwordKeyVault)`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return errors;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Validate secrets file at path: YAML syntax, flat object, optional naming check.
|
|
55
|
+
*
|
|
56
|
+
* @param {string} filePath - Path to secrets file
|
|
57
|
+
* @param {Object} [options] - Options
|
|
58
|
+
* @param {boolean} [options.checkNaming=false] - Check key names against *KeyVault convention
|
|
59
|
+
* @returns {{ valid: boolean, errors: string[], path: string }}
|
|
60
|
+
*/
|
|
61
|
+
function validateSecretsFile(filePath, options = {}) {
|
|
62
|
+
const checkNaming = Boolean(options.checkNaming);
|
|
63
|
+
if (!filePath || typeof filePath !== 'string') {
|
|
64
|
+
return { valid: false, errors: ['Path is required'], path: '' };
|
|
65
|
+
}
|
|
66
|
+
const resolvedPath = path.isAbsolute(filePath) ? filePath : path.resolve(process.cwd(), filePath);
|
|
67
|
+
if (!fs.existsSync(resolvedPath)) {
|
|
68
|
+
return { valid: false, errors: [`File not found: ${resolvedPath}`], path: resolvedPath };
|
|
69
|
+
}
|
|
70
|
+
let parsed;
|
|
71
|
+
try {
|
|
72
|
+
const content = fs.readFileSync(resolvedPath, 'utf8');
|
|
73
|
+
parsed = yaml.load(content);
|
|
74
|
+
} catch (err) {
|
|
75
|
+
return { valid: false, errors: [`Invalid YAML: ${err.message}`], path: resolvedPath };
|
|
76
|
+
}
|
|
77
|
+
const errors = validateParsedSecrets(parsed, checkNaming);
|
|
78
|
+
return { valid: errors.length === 0, errors, path: resolvedPath };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
module.exports = {
|
|
82
|
+
validateSecretsFile,
|
|
83
|
+
keyMatchesNamingConvention
|
|
84
|
+
};
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Locate or generate SSH key for Mutagen sync (Windows and Mac). Prefer ed25519.
|
|
3
|
+
* @author AI Fabrix Team
|
|
4
|
+
* @version 2.0.0
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const fs = require('fs');
|
|
8
|
+
const path = require('path');
|
|
9
|
+
const os = require('os');
|
|
10
|
+
const { execSync } = require('child_process');
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Default SSH directory (user's .ssh)
|
|
14
|
+
* @returns {string} Path to .ssh directory
|
|
15
|
+
*/
|
|
16
|
+
function getDefaultSshDir() {
|
|
17
|
+
const home = os.homedir();
|
|
18
|
+
return path.join(home, '.ssh');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Path to default ed25519 public key
|
|
23
|
+
* @returns {string} Path to id_ed25519.pub
|
|
24
|
+
*/
|
|
25
|
+
function getDefaultEd25519PublicKeyPath() {
|
|
26
|
+
return path.join(getDefaultSshDir(), 'id_ed25519.pub');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Path to default ed25519 private key
|
|
31
|
+
* @returns {string} Path to id_ed25519
|
|
32
|
+
*/
|
|
33
|
+
function getDefaultEd25519PrivateKeyPath() {
|
|
34
|
+
return path.join(getDefaultSshDir(), 'id_ed25519');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Ensure .ssh directory exists
|
|
39
|
+
* @param {string} [sshDir] - SSH directory (default: user .ssh)
|
|
40
|
+
* @returns {string} Resolved SSH dir path
|
|
41
|
+
*/
|
|
42
|
+
function ensureSshDir(sshDir) {
|
|
43
|
+
const dir = sshDir || getDefaultSshDir();
|
|
44
|
+
if (!fs.existsSync(dir)) {
|
|
45
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
46
|
+
}
|
|
47
|
+
return dir;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Generate ed25519 SSH key pair if it does not exist. Idempotent.
|
|
52
|
+
* @param {string} [privateKeyPath] - Path to private key (default: ~/.ssh/id_ed25519)
|
|
53
|
+
* @returns {string} Path to public key file
|
|
54
|
+
* @throws {Error} If ssh-keygen fails
|
|
55
|
+
*/
|
|
56
|
+
function ensureEd25519Key(privateKeyPath) {
|
|
57
|
+
const privPath = privateKeyPath || getDefaultEd25519PrivateKeyPath();
|
|
58
|
+
const pubPath = privPath + '.pub';
|
|
59
|
+
if (fs.existsSync(pubPath) && fs.existsSync(privPath)) {
|
|
60
|
+
return pubPath;
|
|
61
|
+
}
|
|
62
|
+
ensureSshDir(path.dirname(privPath));
|
|
63
|
+
execSync(`ssh-keygen -t ed25519 -f "${privPath}" -N "" -C "aifabrix"`, {
|
|
64
|
+
stdio: 'pipe',
|
|
65
|
+
encoding: 'utf8'
|
|
66
|
+
});
|
|
67
|
+
return pubPath;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Read public key content (single line). Prefer ed25519, fallback to id_rsa.pub.
|
|
72
|
+
* @param {string} [sshDir] - SSH directory
|
|
73
|
+
* @returns {string} Public key line (e.g. "ssh-ed25519 AAAA... aifabrix")
|
|
74
|
+
* @throws {Error} If no key found or read fails
|
|
75
|
+
*/
|
|
76
|
+
function readPublicKeyContent(sshDir) {
|
|
77
|
+
const dir = sshDir || getDefaultSshDir();
|
|
78
|
+
const ed25519Pub = path.join(dir, 'id_ed25519.pub');
|
|
79
|
+
const rsaPub = path.join(dir, 'id_rsa.pub');
|
|
80
|
+
let pathToRead = null;
|
|
81
|
+
if (fs.existsSync(ed25519Pub)) {
|
|
82
|
+
pathToRead = ed25519Pub;
|
|
83
|
+
} else if (fs.existsSync(rsaPub)) {
|
|
84
|
+
pathToRead = rsaPub;
|
|
85
|
+
}
|
|
86
|
+
if (!pathToRead) {
|
|
87
|
+
throw new Error(
|
|
88
|
+
'No SSH public key found. Run: ssh-keygen -t ed25519 -f ~/.ssh/id_ed25519 -N "" -C "aifabrix"'
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
const content = fs.readFileSync(pathToRead, 'utf8').trim();
|
|
92
|
+
const firstLine = content.split('\n')[0];
|
|
93
|
+
if (!firstLine || !firstLine.startsWith('ssh-')) {
|
|
94
|
+
throw new Error(`Invalid SSH public key file: ${pathToRead}`);
|
|
95
|
+
}
|
|
96
|
+
return firstLine;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Get or create ed25519 key and return its public key content for POST /api/dev/users/:id/ssh-keys.
|
|
101
|
+
* @returns {string} Single-line public key content
|
|
102
|
+
*/
|
|
103
|
+
function getOrCreatePublicKeyContent() {
|
|
104
|
+
ensureEd25519Key();
|
|
105
|
+
return readPublicKeyContent();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
module.exports = {
|
|
109
|
+
getDefaultSshDir,
|
|
110
|
+
getDefaultEd25519PublicKeyPath,
|
|
111
|
+
getDefaultEd25519PrivateKeyPath,
|
|
112
|
+
ensureSshDir,
|
|
113
|
+
ensureEd25519Key,
|
|
114
|
+
readPublicKeyContent,
|
|
115
|
+
getOrCreatePublicKeyContent
|
|
116
|
+
};
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Token refresh failure message formatting and once-per-URL warning (used by token-manager).
|
|
3
|
+
* @fileoverview Token manager message helpers
|
|
4
|
+
* @author AI Fabrix Team
|
|
5
|
+
* @version 2.0.0
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const config = require('../core/config');
|
|
9
|
+
const logger = require('./logger');
|
|
10
|
+
|
|
11
|
+
/** Network-style error messages that indicate controller unreachable (not token expiry). */
|
|
12
|
+
const NETWORK_ERROR_PATTERNS = [
|
|
13
|
+
'fetch failed',
|
|
14
|
+
'econnrefused',
|
|
15
|
+
'enotfound',
|
|
16
|
+
'etimedout',
|
|
17
|
+
'network',
|
|
18
|
+
'unreachable',
|
|
19
|
+
'timed out'
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
/** Controller URLs we have already logged a refresh-failure warning for this process. */
|
|
23
|
+
const refreshFailureWarnedUrls = new Set();
|
|
24
|
+
|
|
25
|
+
/** Controller URLs we have already logged a refresh-token-expired warning for this process. */
|
|
26
|
+
const refreshTokenExpiredWarnedUrls = new Set();
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Reset warned state (for test isolation only). Not for production use.
|
|
30
|
+
*/
|
|
31
|
+
function resetRefreshWarnedUrlsForTesting() {
|
|
32
|
+
refreshFailureWarnedUrls.clear();
|
|
33
|
+
refreshTokenExpiredWarnedUrls.clear();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Log "refresh token expired" once per controller URL per process (avoids duplicate messages when auth is tried multiple times).
|
|
38
|
+
* @param {string} controllerUrl - Controller URL (for dedupe key)
|
|
39
|
+
* @param {string} errorMessage - Full error message to log
|
|
40
|
+
*/
|
|
41
|
+
function warnRefreshTokenExpiredOnce(controllerUrl, errorMessage) {
|
|
42
|
+
const key = (controllerUrl && typeof controllerUrl === 'string' && controllerUrl.trim())
|
|
43
|
+
? config.normalizeControllerUrl(controllerUrl)
|
|
44
|
+
: '__no_url__';
|
|
45
|
+
if (refreshTokenExpiredWarnedUrls.has(key)) {
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
refreshTokenExpiredWarnedUrls.add(key);
|
|
49
|
+
logger.warn(`Refresh token expired: ${errorMessage}`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Returns a user-facing message for token refresh failure; adds a hint when the error looks like a connectivity issue.
|
|
54
|
+
* @param {string} errorMessage - Raw error message
|
|
55
|
+
* @param {string} [controllerUrl] - Controller URL for the hint
|
|
56
|
+
* @returns {string} Message to log
|
|
57
|
+
*/
|
|
58
|
+
function formatRefreshFailureMessage(errorMessage, controllerUrl) {
|
|
59
|
+
const lower = (errorMessage || '').toLowerCase();
|
|
60
|
+
const isNetwork = NETWORK_ERROR_PATTERNS.some(p => lower.includes(p));
|
|
61
|
+
const hint = isNetwork
|
|
62
|
+
? (controllerUrl
|
|
63
|
+
? ` The controller at ${controllerUrl} may be unreachable—ensure it is running and try again, or run 'aifabrix login' once it is available.`
|
|
64
|
+
: ' The controller may be unreachable—ensure it is running and try again, or run \'aifabrix login\' once it is available.')
|
|
65
|
+
: '';
|
|
66
|
+
return `${errorMessage}${hint}`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Log device token refresh failure once per controller URL per process.
|
|
71
|
+
* @param {string} controllerUrl - Controller URL (for dedupe key and message)
|
|
72
|
+
* @param {string} errorMessage - Raw error message
|
|
73
|
+
*/
|
|
74
|
+
function warnRefreshFailureOnce(controllerUrl, errorMessage) {
|
|
75
|
+
const key = (controllerUrl && typeof controllerUrl === 'string' && controllerUrl.trim())
|
|
76
|
+
? config.normalizeControllerUrl(controllerUrl)
|
|
77
|
+
: '__no_url__';
|
|
78
|
+
if (refreshFailureWarnedUrls.has(key)) {
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
refreshFailureWarnedUrls.add(key);
|
|
82
|
+
logger.warn(`Failed to refresh device token: ${formatRefreshFailureMessage(errorMessage, controllerUrl)}`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
module.exports = {
|
|
86
|
+
formatRefreshFailureMessage,
|
|
87
|
+
warnRefreshFailureOnce,
|
|
88
|
+
warnRefreshTokenExpiredOnce,
|
|
89
|
+
resetRefreshWarnedUrlsForTesting
|
|
90
|
+
};
|
|
@@ -19,6 +19,7 @@ const {
|
|
|
19
19
|
refreshClientToken,
|
|
20
20
|
refreshDeviceToken
|
|
21
21
|
} = require('./token-manager-refresh');
|
|
22
|
+
const { warnRefreshFailureOnce, warnRefreshTokenExpiredOnce } = require('./token-manager-messages');
|
|
22
23
|
|
|
23
24
|
function getSecretsFilePath() {
|
|
24
25
|
return path.join(pathsUtil.getAifabrixHome(), 'secrets.local.yaml');
|
|
@@ -185,9 +186,9 @@ async function getOrRefreshDeviceToken(controllerUrl) {
|
|
|
185
186
|
// Refresh failed - check if it's a refresh token expiry
|
|
186
187
|
const errorMessage = error.message || String(error);
|
|
187
188
|
if (errorMessage.includes('Refresh token has expired')) {
|
|
188
|
-
|
|
189
|
+
warnRefreshTokenExpiredOnce(controllerUrl, errorMessage);
|
|
189
190
|
} else {
|
|
190
|
-
|
|
191
|
+
warnRefreshFailureOnce(controllerUrl, errorMessage);
|
|
191
192
|
}
|
|
192
193
|
return null;
|
|
193
194
|
}
|
|
@@ -398,9 +399,9 @@ async function forceRefreshDeviceToken(controllerUrl) {
|
|
|
398
399
|
} catch (error) {
|
|
399
400
|
const errorMessage = error.message || String(error);
|
|
400
401
|
if (errorMessage.includes('Refresh token has expired')) {
|
|
401
|
-
|
|
402
|
+
warnRefreshTokenExpiredOnce(controllerUrl, errorMessage);
|
|
402
403
|
} else {
|
|
403
|
-
|
|
404
|
+
warnRefreshFailureOnce(controllerUrl, errorMessage);
|
|
404
405
|
}
|
|
405
406
|
return null;
|
|
406
407
|
}
|
|
@@ -181,9 +181,6 @@ function validateBuildConfig(build) {
|
|
|
181
181
|
if (build.envOutputPath) {
|
|
182
182
|
buildConfig.envOutputPath = build.envOutputPath;
|
|
183
183
|
}
|
|
184
|
-
if (build.localPort) {
|
|
185
|
-
buildConfig.localPort = build.localPort;
|
|
186
|
-
}
|
|
187
184
|
if (build.language) {
|
|
188
185
|
buildConfig.language = build.language;
|
|
189
186
|
}
|
|
@@ -193,6 +190,9 @@ function validateBuildConfig(build) {
|
|
|
193
190
|
if (build.dockerfile && build.dockerfile.trim() !== '') {
|
|
194
191
|
buildConfig.dockerfile = build.dockerfile;
|
|
195
192
|
}
|
|
193
|
+
if (build.localPort !== undefined && build.localPort !== null) {
|
|
194
|
+
buildConfig.localPort = build.localPort;
|
|
195
|
+
}
|
|
196
196
|
|
|
197
197
|
return Object.keys(buildConfig).length > 0 ? buildConfig : null;
|
|
198
198
|
}
|
|
@@ -452,7 +452,7 @@ async function validateAppOrFile(appOrFile, options = {}) {
|
|
|
452
452
|
logOfflinePathWhenType(appPath);
|
|
453
453
|
|
|
454
454
|
if (isExternal) {
|
|
455
|
-
return await validateExternalSystemComplete(appName);
|
|
455
|
+
return await validateExternalSystemComplete(appName, options);
|
|
456
456
|
}
|
|
457
457
|
|
|
458
458
|
const appValidation = await validator.validateApplication(appName, options);
|
|
@@ -365,6 +365,69 @@ function validateDeploymentJson(deployment) {
|
|
|
365
365
|
};
|
|
366
366
|
}
|
|
367
367
|
|
|
368
|
+
/** Pattern matching ${VAR} style unresolved variables in strings */
|
|
369
|
+
const UNRESOLVED_VAR_REGEX = /\$\{[^}]+\}/g;
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Recursively finds all string values in obj that contain ${...} placeholders.
|
|
373
|
+
* Used to ensure deployment manifest has no unresolved variables before deploy.
|
|
374
|
+
*
|
|
375
|
+
* @function findUnresolvedVariablesInObject
|
|
376
|
+
* @param {Object} obj - Object to scan (e.g. deployment manifest)
|
|
377
|
+
* @param {string} [prefix=''] - Path prefix for error reporting
|
|
378
|
+
* @returns {string[]} List of paths with example placeholder (e.g. "port: ${PORT}")
|
|
379
|
+
*
|
|
380
|
+
* @example
|
|
381
|
+
* findUnresolvedVariablesInObject({ port: '${PORT}' }) // ['port: ${PORT}']
|
|
382
|
+
*/
|
|
383
|
+
function findUnresolvedVariablesInObject(obj, prefix = '') {
|
|
384
|
+
const found = [];
|
|
385
|
+
if (obj === null || obj === undefined) {
|
|
386
|
+
return found;
|
|
387
|
+
}
|
|
388
|
+
if (typeof obj === 'string') {
|
|
389
|
+
const matches = obj.match(UNRESOLVED_VAR_REGEX);
|
|
390
|
+
if (matches && matches.length > 0) {
|
|
391
|
+
const pathLabel = prefix || 'value';
|
|
392
|
+
found.push(`${pathLabel}: ${matches[0]}`);
|
|
393
|
+
}
|
|
394
|
+
return found;
|
|
395
|
+
}
|
|
396
|
+
if (Array.isArray(obj)) {
|
|
397
|
+
obj.forEach((item, i) => {
|
|
398
|
+
found.push(...findUnresolvedVariablesInObject(item, `${prefix}[${i}]`));
|
|
399
|
+
});
|
|
400
|
+
return found;
|
|
401
|
+
}
|
|
402
|
+
if (typeof obj === 'object') {
|
|
403
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
404
|
+
const path = prefix ? `${prefix}.${key}` : key;
|
|
405
|
+
found.push(...findUnresolvedVariablesInObject(value, path));
|
|
406
|
+
}
|
|
407
|
+
return found;
|
|
408
|
+
}
|
|
409
|
+
return found;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Validates that deployment manifest contains no unresolved ${...} variables.
|
|
414
|
+
* Throws if any are found, with a message to use secret variables or literal values.
|
|
415
|
+
*
|
|
416
|
+
* @function validateNoUnresolvedVariablesInDeployment
|
|
417
|
+
* @param {Object} deployment - Deployment manifest object
|
|
418
|
+
* @throws {Error} If any ${...} placeholders are found
|
|
419
|
+
*/
|
|
420
|
+
function validateNoUnresolvedVariablesInDeployment(deployment) {
|
|
421
|
+
const unresolved = findUnresolvedVariablesInObject(deployment);
|
|
422
|
+
if (unresolved.length > 0) {
|
|
423
|
+
const examples = [...new Set(unresolved)].slice(0, 5).join(', ');
|
|
424
|
+
throw new Error(
|
|
425
|
+
`Deployment manifest contains unresolved variables (e.g. ${examples}). ` +
|
|
426
|
+
'Use secret variables (kv://) in env.template for sensitive values, and set the application port as a number in application.yaml.'
|
|
427
|
+
);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
368
431
|
/**
|
|
369
432
|
* Validates all application configuration files
|
|
370
433
|
* Runs complete validation suite for an application
|
|
@@ -411,6 +474,8 @@ module.exports = {
|
|
|
411
474
|
validateRbac,
|
|
412
475
|
validateEnvTemplate,
|
|
413
476
|
validateDeploymentJson,
|
|
477
|
+
validateNoUnresolvedVariablesInDeployment,
|
|
478
|
+
findUnresolvedVariablesInObject,
|
|
414
479
|
validateObjectAgainstApplicationSchema,
|
|
415
480
|
checkEnvironment,
|
|
416
481
|
formatValidationErrors,
|
package/package.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aifabrix/builder",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.41.0",
|
|
4
4
|
"description": "AI Fabrix Local Fabric & Deployment SDK",
|
|
5
5
|
"main": "lib/index.js",
|
|
6
6
|
"bin": {
|
|
7
7
|
"aifabrix": "bin/aifabrix.js",
|
|
8
|
-
"
|
|
8
|
+
"af": "bin/aifabrix.js"
|
|
9
9
|
},
|
|
10
10
|
"scripts": {
|
|
11
11
|
"test": "node tests/scripts/test-wrapper.js",
|
|
@@ -16,11 +16,13 @@
|
|
|
16
16
|
"test:integration": "jest --config jest.config.integration.js --runInBand",
|
|
17
17
|
"test:integration:python": "cross-env TEST_LANGUAGE=python jest --config jest.config.integration.js --runInBand",
|
|
18
18
|
"test:integration:typescript": "cross-env TEST_LANGUAGE=typescript jest --config jest.config.integration.js --runInBand",
|
|
19
|
+
"test:manual": "jest --config jest.config.manual.js --runInBand",
|
|
19
20
|
"lint": "eslint . --ext .js",
|
|
20
21
|
"lint:fix": "eslint . --ext .js --fix",
|
|
21
22
|
"lint:ci": "eslint . --ext .js --format json --output-file eslint-report.json",
|
|
22
23
|
"dev": "node bin/aifabrix.js",
|
|
23
24
|
"build": "npm run lint && npm run test",
|
|
25
|
+
"build:ci": "npm run lint && npm run test:ci",
|
|
24
26
|
"pack": "npm run build && npm pack",
|
|
25
27
|
"validate": "npm run build",
|
|
26
28
|
"prepublishOnly": "npm run validate",
|
package/scripts/install-local.js
CHANGED
|
@@ -97,6 +97,39 @@ function displaySuccessMessage(currentVersion, newVersion) {
|
|
|
97
97
|
console.log('Run "aifabrix --version" to verify.');
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
+
/**
|
|
101
|
+
* Run pnpm link --global and npm link from project root (handles pnpm global bin not set).
|
|
102
|
+
* @param {string} projectRoot - Path to project root
|
|
103
|
+
* @returns {void}
|
|
104
|
+
* @throws {Error} If linking fails when pnpm global bin is not configured
|
|
105
|
+
*/
|
|
106
|
+
function runPnpmLink(projectRoot) {
|
|
107
|
+
let pnpmLinked = false;
|
|
108
|
+
try {
|
|
109
|
+
execSync('pnpm link --global', { stdio: 'inherit', cwd: projectRoot });
|
|
110
|
+
pnpmLinked = true;
|
|
111
|
+
} catch (pnpmErr) {
|
|
112
|
+
const msg = (pnpmErr.message || String(pnpmErr));
|
|
113
|
+
if (msg.includes('global bin directory') || msg.includes('ERR_PNPM_NO_GLOBAL_BIN_DIR')) {
|
|
114
|
+
console.log(
|
|
115
|
+
'⚠️ pnpm global bin is not set up. Run "pnpm setup" and add PNPM_HOME to PATH, or we will use npm link.\n'
|
|
116
|
+
);
|
|
117
|
+
} else {
|
|
118
|
+
throw pnpmErr;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
try {
|
|
122
|
+
execSync('npm link', { stdio: 'inherit', cwd: projectRoot });
|
|
123
|
+
} catch {
|
|
124
|
+
if (!pnpmLinked) {
|
|
125
|
+
console.error(
|
|
126
|
+
'\n💡 To fix: run "pnpm setup" and add the suggested line to your shell config, then run install:local again.'
|
|
127
|
+
);
|
|
128
|
+
throw new Error('Linking failed. pnpm global bin not configured and npm link failed.');
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
100
133
|
/**
|
|
101
134
|
* Install local package globally
|
|
102
135
|
* @returns {void}
|
|
@@ -107,31 +140,17 @@ function installLocal() {
|
|
|
107
140
|
const currentVersion = getCurrentVersion();
|
|
108
141
|
|
|
109
142
|
console.log(`Detected package manager: ${pm}\n`);
|
|
110
|
-
|
|
111
|
-
// Show version comparison
|
|
112
143
|
displayVersionInfo(currentVersion, packageVersion);
|
|
113
|
-
|
|
114
144
|
console.log('Linking @aifabrix/builder globally...\n');
|
|
115
145
|
|
|
116
146
|
try {
|
|
117
147
|
const projectRoot = path.join(__dirname, '..');
|
|
118
148
|
if (pm === 'pnpm') {
|
|
119
|
-
|
|
120
|
-
execSync('pnpm link --global', { stdio: 'inherit', cwd: projectRoot });
|
|
121
|
-
// Also run npm link so npm's global bin points here; often PATH has
|
|
122
|
-
// npm's global bin before pnpm's, so "aifabrix" would otherwise stay old.
|
|
123
|
-
try {
|
|
124
|
-
execSync('npm link', { stdio: 'inherit', cwd: projectRoot });
|
|
125
|
-
} catch {
|
|
126
|
-
// npm may not be available or may fail; pnpm link already ran
|
|
127
|
-
}
|
|
149
|
+
runPnpmLink(projectRoot);
|
|
128
150
|
} else {
|
|
129
151
|
execSync('npm link', { stdio: 'inherit', cwd: projectRoot });
|
|
130
152
|
}
|
|
131
|
-
|
|
132
|
-
// Get new version after linking
|
|
133
153
|
const newVersion = getCurrentVersion();
|
|
134
|
-
|
|
135
154
|
displaySuccessMessage(currentVersion, newVersion);
|
|
136
155
|
} catch (error) {
|
|
137
156
|
console.error('\n❌ Failed to link package:', error.message);
|
package/templates/README.md
CHANGED
|
@@ -70,7 +70,6 @@ Extra workflow steps are located in `templates/github/steps/`. When you use `--g
|
|
|
70
70
|
- `{{databases}}` - Array of database configurations
|
|
71
71
|
|
|
72
72
|
### Build Configuration
|
|
73
|
-
- `{{build.localPort}}` - Local development port (different from Docker port)
|
|
74
73
|
- `{{mountVolume}}` - Volume mount path for local development
|
|
75
74
|
|
|
76
75
|
## Usage
|
|
@@ -38,7 +38,7 @@ aifabrix resolve {{appName}}
|
|
|
38
38
|
aifabrix run {{appName}}
|
|
39
39
|
```
|
|
40
40
|
|
|
41
|
-
**Access your app:** http://localhost:{{
|
|
41
|
+
**Access your app:** http://localhost:{{port}}
|
|
42
42
|
|
|
43
43
|
**View logs:**
|
|
44
44
|
```bash
|
|
@@ -118,7 +118,7 @@ aifabrix build {{appName}} --language typescript # Override language detection
|
|
|
118
118
|
### Run Options
|
|
119
119
|
|
|
120
120
|
```bash
|
|
121
|
-
aifabrix run {{appName}} --port {{
|
|
121
|
+
aifabrix run {{appName}} --port {{port}} # Override port
|
|
122
122
|
aifabrix run {{appName}} --debug # Debug output
|
|
123
123
|
```
|
|
124
124
|
|
|
@@ -166,7 +166,7 @@ Controller URL and environment (for `deploy`, `app register`, etc.) are set via
|
|
|
166
166
|
|
|
167
167
|
- **"Docker not running"** → Start Docker Desktop
|
|
168
168
|
- **"Not logged in"** → Run `aifabrix login` first
|
|
169
|
-
- **"Port already in use"** → Use `aifabrix run {{appName}} --port <port>` or set `
|
|
169
|
+
- **"Port already in use"** → Use `aifabrix run {{appName}} --port <port>` or set `port` in `application.yaml` (default: {{port}})
|
|
170
170
|
- **"Authentication failed"** → Run `aifabrix login` again
|
|
171
171
|
- **"Build fails"** → Check Docker is running and `aifabrix-secrets` in `config.yaml` is configured correctly
|
|
172
172
|
- **"Can't connect"** → Verify infrastructure is running{{#if hasDatabase}} and PostgreSQL is accessible{{/if}}
|
|
@@ -203,4 +203,4 @@ aifabrix json {{appName}}
|
|
|
203
203
|
|
|
204
204
|
---
|
|
205
205
|
|
|
206
|
-
**Application**: {{appName}} | **Port**: {{
|
|
206
|
+
**Application**: {{appName}} | **Port**: {{port}} | **Registry**: {{registry}} | **Image**: {{imageName}}:latest
|