@aifabrix/builder 2.40.2 → 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 +6 -4
- package/integration/hubspot/test.js +1 -1
- 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-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 +12 -3
- 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/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 +8 -0
- 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 +43 -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/validator.js +65 -0
- package/package.json +2 -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/python/docker-compose.hbs +49 -23
- package/templates/typescript/docker-compose.hbs +48 -22
package/lib/utils/env-map.js
CHANGED
|
@@ -144,6 +144,27 @@ function handlePlainValue(result, key, rawVal, options) {
|
|
|
144
144
|
result[key] = val;
|
|
145
145
|
}
|
|
146
146
|
|
|
147
|
+
/** Placeholder in env-config values; replaced with application or default port */
|
|
148
|
+
const PORT_PLACEHOLDER = '${PORT}';
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Replaces ${PORT} in all string values of env object (in-place).
|
|
152
|
+
* Used so env-config.yaml docker/local values resolve correctly (e.g. PORT: ${PORT} -> application port).
|
|
153
|
+
*
|
|
154
|
+
* @param {Object} envObj - Flat key-value object (e.g. merged env-config environments.docker)
|
|
155
|
+
* @param {number} [portNumber=3000] - Port to substitute when value contains ${PORT}
|
|
156
|
+
*/
|
|
157
|
+
function resolvePortInEnvValues(envObj, portNumber = 3000) {
|
|
158
|
+
if (!envObj || typeof envObj !== 'object') return;
|
|
159
|
+
const portStr = String(portNumber);
|
|
160
|
+
for (const key of Object.keys(envObj)) {
|
|
161
|
+
const val = envObj[key];
|
|
162
|
+
if (typeof val === 'string' && val.includes(PORT_PLACEHOLDER)) {
|
|
163
|
+
envObj[key] = val.split(PORT_PLACEHOLDER).join(portStr);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
147
168
|
/**
|
|
148
169
|
* Normalize environment variable map by splitting host:port values
|
|
149
170
|
* @function normalizeEnvVars
|
|
@@ -270,30 +291,38 @@ function calculateDockerPublicPorts(result, devIdNum, schemaBaseVars = {}) {
|
|
|
270
291
|
/**
|
|
271
292
|
* Build environment variable map for interpolation based on env-config.yaml
|
|
272
293
|
* - Supports values like "host:port" by splitting into *_HOST (host) and *_PORT (port)
|
|
294
|
+
* - Resolves ${PORT} in env-config values using options.appPort or default 3000 (so docker/local values are correct)
|
|
273
295
|
* - Merges overrides from ~/.aifabrix/config.yaml under environments.{env}
|
|
274
296
|
* - Applies aifabrix-localhost override for local context if configured
|
|
275
297
|
* - Applies developer-id adjustment to port variables for local context
|
|
276
|
-
* - Calculates *_PUBLIC_PORT for docker context (basePort + developer-id * 100)
|
|
298
|
+
* - Calculates *_PUBLIC_PORT for both local and docker context (basePort + developer-id * 100)
|
|
277
299
|
* @async
|
|
278
300
|
* @function buildEnvVarMap
|
|
279
301
|
* @param {'docker'|'local'} context - Environment context
|
|
280
302
|
* @param {Object} [osModule] - Optional os module (for testing). If not provided, requires 'os'
|
|
281
303
|
* @param {number|null} [developerId] - Optional developer ID for port adjustment. If not provided, will be fetched from config for local context.
|
|
304
|
+
* @param {Object} [options] - Optional options
|
|
305
|
+
* @param {number} [options.appPort] - Port to use when resolving ${PORT} in env-config values (e.g. from application.yaml)
|
|
282
306
|
* @returns {Promise<Object>} Map of variables for interpolation
|
|
283
307
|
*/
|
|
284
|
-
async function buildEnvVarMap(context, osModule = null, developerId = null) {
|
|
308
|
+
async function buildEnvVarMap(context, osModule = null, developerId = null, options = null) {
|
|
285
309
|
const baseVars = await loadBaseVars(context);
|
|
286
310
|
const os = osModule || require('os');
|
|
287
311
|
const overrideVars = loadOverrideVars(context, os);
|
|
288
312
|
const localhostOverride = context === 'local' ? getLocalhostOverride(os) : null;
|
|
289
313
|
const merged = { ...baseVars, ...overrideVars };
|
|
314
|
+
const appPort = (options && options.appPort !== undefined && options.appPort !== null && Number.isFinite(Number(options.appPort)))
|
|
315
|
+
? Number(options.appPort) : 3000;
|
|
316
|
+
resolvePortInEnvValues(merged, appPort);
|
|
290
317
|
const result = normalizeEnvVars(merged, context, localhostOverride);
|
|
291
318
|
|
|
319
|
+
const devIdNum = await getDeveloperIdNumber(developerId);
|
|
292
320
|
if (context === 'local') {
|
|
293
|
-
const devIdNum = await getDeveloperIdNumber(developerId);
|
|
294
321
|
applyLocalPortAdjustment(result, devIdNum);
|
|
322
|
+
const schemaCfg = loadSchemaEnvConfig();
|
|
323
|
+
const schemaBaseVars = (schemaCfg && schemaCfg.environments && schemaCfg.environments.local) ? schemaCfg.environments.local : {};
|
|
324
|
+
calculateDockerPublicPorts(result, devIdNum, schemaBaseVars);
|
|
295
325
|
} else if (context === 'docker') {
|
|
296
|
-
const devIdNum = await getDeveloperIdNumber(developerId);
|
|
297
326
|
const schemaCfg = loadSchemaEnvConfig();
|
|
298
327
|
const schemaBaseVars = (schemaCfg && schemaCfg.environments && schemaCfg.environments[context]) ? schemaCfg.environments[context] : {};
|
|
299
328
|
calculateDockerPublicPorts(result, devIdNum, schemaBaseVars);
|
|
@@ -304,6 +333,7 @@ async function buildEnvVarMap(context, osModule = null, developerId = null) {
|
|
|
304
333
|
|
|
305
334
|
module.exports = {
|
|
306
335
|
buildEnvVarMap,
|
|
307
|
-
getDeveloperIdNumber
|
|
336
|
+
getDeveloperIdNumber,
|
|
337
|
+
resolvePortInEnvValues
|
|
308
338
|
};
|
|
309
339
|
|
|
@@ -15,7 +15,8 @@ const chalk = require('chalk');
|
|
|
15
15
|
const logger = require('../utils/logger');
|
|
16
16
|
|
|
17
17
|
/**
|
|
18
|
-
* Updates env.template to add MISO_CLIENTID, MISO_CLIENTSECRET, and MISO_CONTROLLER_URL
|
|
18
|
+
* Updates env.template to add MISO_CLIENTID, MISO_CLIENTSECRET, and optionally MISO_CONTROLLER_URL.
|
|
19
|
+
* When MISO_CONTROLLER_URL already exists, its value is left unchanged (e.g. http://${MISO_HOST}:${MISO_PORT}).
|
|
19
20
|
* @async
|
|
20
21
|
* @param {string} appKey - Application key
|
|
21
22
|
* @param {string} clientIdKey - Secret key for client ID (e.g., 'myapp-client-idKeyVault')
|
|
@@ -39,7 +40,9 @@ function checkMisoEntries(content) {
|
|
|
39
40
|
}
|
|
40
41
|
|
|
41
42
|
/**
|
|
42
|
-
* Updates existing MISO entries
|
|
43
|
+
* Updates existing MISO entries.
|
|
44
|
+
* MISO_CONTROLLER_URL is never overwritten when present so that template form
|
|
45
|
+
* (e.g. http://${MISO_HOST}:${MISO_PORT}) or custom URLs are preserved.
|
|
43
46
|
* @function updateExistingMisoEntries
|
|
44
47
|
* @param {string} content - File content
|
|
45
48
|
* @param {string} clientIdKey - Client ID key
|
|
@@ -54,9 +57,7 @@ function updateExistingMisoEntries(content, clientIdKey, clientSecretKey, entrie
|
|
|
54
57
|
if (entries.hasClientSecret) {
|
|
55
58
|
content = content.replace(/^MISO_CLIENTSECRET\s*=.*$/m, `MISO_CLIENTSECRET=kv://${clientSecretKey}`);
|
|
56
59
|
}
|
|
57
|
-
|
|
58
|
-
content = content.replace(/^MISO_CONTROLLER_URL\s*=.*$/m, 'MISO_CONTROLLER_URL=http://${MISO_HOST}:${MISO_PORT}');
|
|
59
|
-
}
|
|
60
|
+
// Do not change existing MISO_CONTROLLER_URL (preserve template or custom value)
|
|
60
61
|
return content;
|
|
61
62
|
}
|
|
62
63
|
|
|
@@ -79,12 +79,31 @@ function addErrorMessageIfNotGeneric(lines, errorData) {
|
|
|
79
79
|
}
|
|
80
80
|
|
|
81
81
|
/**
|
|
82
|
-
*
|
|
82
|
+
* Returns true when the error is about Builder Server client certificate (not Controller token).
|
|
83
|
+
* @param {Object} errorData - Error data
|
|
84
|
+
* @returns {boolean}
|
|
85
|
+
*/
|
|
86
|
+
function isBuilderServerCertError(errorData) {
|
|
87
|
+
const msg = (errorData.message || errorData.error || errorData.detail || '').toLowerCase();
|
|
88
|
+
return msg.includes('client certificate') || msg.includes('issue-cert') || msg.includes('x-client-cert');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Adds authentication guidance (Controller token login). Skipped for Builder Server cert errors.
|
|
83
93
|
* @function addAuthenticationGuidance
|
|
84
94
|
* @param {string[]} lines - Error message lines
|
|
85
95
|
* @param {Object} errorData - Error data
|
|
86
96
|
*/
|
|
87
97
|
function addAuthenticationGuidance(lines, errorData) {
|
|
98
|
+
if (isBuilderServerCertError(errorData)) {
|
|
99
|
+
lines.push(chalk.gray('Use a certificate from: aifabrix dev init --developer-id <id> --server <url> --pin <pin>'));
|
|
100
|
+
if (errorData.correlationId) {
|
|
101
|
+
lines.push('');
|
|
102
|
+
lines.push(chalk.gray(`Correlation ID: ${errorData.correlationId}`));
|
|
103
|
+
}
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
|
|
88
107
|
lines.push(chalk.gray('Your authentication token is invalid or has expired.'));
|
|
89
108
|
lines.push('');
|
|
90
109
|
lines.push(chalk.gray('To authenticate, run:'));
|
|
@@ -45,6 +45,13 @@ const CATEGORIES = [
|
|
|
45
45
|
{ name: 'wizard' },
|
|
46
46
|
{ name: 'build', term: 'build <app>' },
|
|
47
47
|
{ name: 'run', term: 'run <app>' },
|
|
48
|
+
{ name: 'shell', term: 'shell <app>' },
|
|
49
|
+
{ name: 'test', term: 'test <app>' },
|
|
50
|
+
{ name: 'install', term: 'install <app>' },
|
|
51
|
+
{ name: 'test-e2e', term: 'test-e2e <app>' },
|
|
52
|
+
{ name: 'lint', term: 'lint <app>' },
|
|
53
|
+
{ name: 'logs', term: 'logs <app>' },
|
|
54
|
+
{ name: 'stop', term: 'stop <app>' },
|
|
48
55
|
{ name: 'dockerfile', term: 'dockerfile <app>' }
|
|
49
56
|
]
|
|
50
57
|
},
|
|
@@ -66,7 +73,10 @@ const CATEGORIES = [
|
|
|
66
73
|
name: 'Application & Datasource Management',
|
|
67
74
|
commands: [
|
|
68
75
|
{ name: 'app' },
|
|
69
|
-
{ name: 'datasource' }
|
|
76
|
+
{ name: 'datasource' },
|
|
77
|
+
{ name: 'credential' },
|
|
78
|
+
{ name: 'deployment' },
|
|
79
|
+
{ name: 'service-user' }
|
|
70
80
|
]
|
|
71
81
|
},
|
|
72
82
|
{
|
|
@@ -75,6 +85,8 @@ const CATEGORIES = [
|
|
|
75
85
|
{ name: 'resolve', term: 'resolve <app>' },
|
|
76
86
|
{ name: 'json', term: 'json <app>' },
|
|
77
87
|
{ name: 'split-json', term: 'split-json <app>' },
|
|
88
|
+
{ name: 'convert', term: 'convert <app>' },
|
|
89
|
+
{ name: 'show', term: 'show <appKey>' },
|
|
78
90
|
{ name: 'validate', term: 'validate <appOrFile>' },
|
|
79
91
|
{ name: 'diff', term: 'diff <file1> <file2>' }
|
|
80
92
|
]
|
|
@@ -83,6 +95,7 @@ const CATEGORIES = [
|
|
|
83
95
|
name: 'External Systems',
|
|
84
96
|
commands: [
|
|
85
97
|
{ name: 'download', term: 'download <system-key>' },
|
|
98
|
+
{ name: 'upload', term: 'upload <system-key>' },
|
|
86
99
|
{ name: 'delete', term: 'delete <system-key>' },
|
|
87
100
|
{ name: 'test', term: 'test <app>' },
|
|
88
101
|
{ name: 'test-integration', term: 'test-integration <app>' }
|
|
@@ -92,7 +105,7 @@ const CATEGORIES = [
|
|
|
92
105
|
name: 'Developer & Secrets',
|
|
93
106
|
commands: [
|
|
94
107
|
{ name: 'dev' },
|
|
95
|
-
{ name: '
|
|
108
|
+
{ name: 'secret' },
|
|
96
109
|
{ name: 'secure' }
|
|
97
110
|
]
|
|
98
111
|
}
|
|
@@ -186,8 +186,37 @@ async function getAppStatus() {
|
|
|
186
186
|
return apps;
|
|
187
187
|
}
|
|
188
188
|
|
|
189
|
+
/**
|
|
190
|
+
* Lists app container names for a developer (excludes infra containers).
|
|
191
|
+
* Used by down-infra to stop/remove all app-related containers on the same network.
|
|
192
|
+
* When includeExited is true, includes stopped/exited containers (e.g. db-init one-offs).
|
|
193
|
+
*
|
|
194
|
+
* @async
|
|
195
|
+
* @function listAppContainerNamesForDeveloper
|
|
196
|
+
* @param {string} devId - Developer ID
|
|
197
|
+
* @param {Object} [options] - Options
|
|
198
|
+
* @param {boolean} [options.includeExited=false] - If true, use docker ps -a to include exited containers
|
|
199
|
+
* @returns {Promise<string[]>} Container names (e.g. aifabrix-myapp, aifabrix-keycloak-db-init)
|
|
200
|
+
*/
|
|
201
|
+
async function listAppContainerNamesForDeveloper(devId, options = {}) {
|
|
202
|
+
const devIdNum = parseInt(devId, 10);
|
|
203
|
+
const filterPattern = devIdNum === 0 ? 'aifabrix-' : `aifabrix-dev${devId}-`;
|
|
204
|
+
const infraContainers = getInfraContainerNames(devIdNum, devId);
|
|
205
|
+
const includeExited = !!options.includeExited;
|
|
206
|
+
try {
|
|
207
|
+
const allFlag = includeExited ? ' -a' : '';
|
|
208
|
+
const { stdout } = await execAsync(`docker ps${allFlag} --filter "name=${filterPattern}" --format "{{.Names}}"`);
|
|
209
|
+
const names = (stdout || '').trim().split('\n').filter(Boolean);
|
|
210
|
+
return names.filter(n => !infraContainers.includes(n));
|
|
211
|
+
} catch {
|
|
212
|
+
return [];
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
189
216
|
module.exports = {
|
|
190
217
|
getInfraStatus,
|
|
191
|
-
getAppStatus
|
|
218
|
+
getAppStatus,
|
|
219
|
+
extractAppName,
|
|
220
|
+
listAppContainerNamesForDeveloper
|
|
192
221
|
};
|
|
193
222
|
|
|
@@ -13,11 +13,12 @@ const path = require('path');
|
|
|
13
13
|
const yaml = require('js-yaml');
|
|
14
14
|
const logger = require('../utils/logger');
|
|
15
15
|
const pathsUtil = require('./paths');
|
|
16
|
+
const { appendSecretsToFile } = require('./secrets-generator');
|
|
16
17
|
|
|
17
18
|
/**
|
|
18
19
|
* Saves a secret to ~/.aifabrix/secrets.local.yaml
|
|
19
20
|
* Uses paths.getAifabrixHome() to respect config.yaml aifabrix-home override
|
|
20
|
-
*
|
|
21
|
+
* Appends the key to the end of the file without changing existing content (preserves comments and structure)
|
|
21
22
|
*
|
|
22
23
|
* @async
|
|
23
24
|
* @function saveLocalSecret
|
|
@@ -39,48 +40,12 @@ async function saveLocalSecret(key, value) {
|
|
|
39
40
|
}
|
|
40
41
|
|
|
41
42
|
const secretsPath = path.join(pathsUtil.getAifabrixHome(), 'secrets.local.yaml');
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
// Create directory if needed
|
|
45
|
-
if (!fs.existsSync(secretsDir)) {
|
|
46
|
-
fs.mkdirSync(secretsDir, { recursive: true, mode: 0o700 });
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
// Load existing secrets
|
|
50
|
-
let existingSecrets = {};
|
|
51
|
-
if (fs.existsSync(secretsPath)) {
|
|
52
|
-
try {
|
|
53
|
-
const content = fs.readFileSync(secretsPath, 'utf8');
|
|
54
|
-
existingSecrets = yaml.load(content) || {};
|
|
55
|
-
if (typeof existingSecrets !== 'object') {
|
|
56
|
-
existingSecrets = {};
|
|
57
|
-
}
|
|
58
|
-
} catch (error) {
|
|
59
|
-
logger.warn(`Warning: Could not read existing secrets file: ${error.message}`);
|
|
60
|
-
existingSecrets = {};
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
// Merge with new secret
|
|
65
|
-
const updatedSecrets = {
|
|
66
|
-
...existingSecrets,
|
|
67
|
-
[key]: value
|
|
68
|
-
};
|
|
69
|
-
|
|
70
|
-
// Save to file
|
|
71
|
-
const yamlContent = yaml.dump(updatedSecrets, {
|
|
72
|
-
indent: 2,
|
|
73
|
-
lineWidth: 120,
|
|
74
|
-
noRefs: true,
|
|
75
|
-
sortKeys: false
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
fs.writeFileSync(secretsPath, yamlContent, { mode: 0o600 });
|
|
43
|
+
appendSecretsToFile(secretsPath, { [key]: value });
|
|
79
44
|
}
|
|
80
45
|
|
|
81
46
|
/**
|
|
82
47
|
* Saves a secret to a specified secrets file path
|
|
83
|
-
*
|
|
48
|
+
* Appends the key to the end of the file without changing existing content (preserves comments and structure)
|
|
84
49
|
*
|
|
85
50
|
* @async
|
|
86
51
|
* @function saveSecret
|
|
@@ -134,11 +99,11 @@ function resolveAndPrepareSecretsPath(secretsPath) {
|
|
|
134
99
|
|
|
135
100
|
/**
|
|
136
101
|
* Loads existing secrets from file
|
|
137
|
-
* @function
|
|
102
|
+
* @function _loadExistingSecrets
|
|
138
103
|
* @param {string} resolvedPath - Resolved secrets path
|
|
139
104
|
* @returns {Object} Existing secrets object
|
|
140
105
|
*/
|
|
141
|
-
function
|
|
106
|
+
function _loadExistingSecrets(resolvedPath) {
|
|
142
107
|
if (!fs.existsSync(resolvedPath)) {
|
|
143
108
|
return {};
|
|
144
109
|
}
|
|
@@ -157,17 +122,7 @@ async function saveSecret(key, value, secretsPath) {
|
|
|
157
122
|
validateSaveSecretParams(key, value, secretsPath);
|
|
158
123
|
|
|
159
124
|
const resolvedPath = resolveAndPrepareSecretsPath(secretsPath);
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
const updatedSecrets = { ...existingSecrets, [key]: value };
|
|
163
|
-
const yamlContent = yaml.dump(updatedSecrets, {
|
|
164
|
-
indent: 2,
|
|
165
|
-
lineWidth: 120,
|
|
166
|
-
noRefs: true,
|
|
167
|
-
sortKeys: false
|
|
168
|
-
});
|
|
169
|
-
|
|
170
|
-
fs.writeFileSync(resolvedPath, yamlContent, { mode: 0o600 });
|
|
125
|
+
appendSecretsToFile(resolvedPath, { [key]: value });
|
|
171
126
|
}
|
|
172
127
|
|
|
173
128
|
/**
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mutagen binary auto-install: download from GitHub releases into ~/.aifabrix/bin/.
|
|
3
|
+
* Per remote-docker.md: CLI installs Mutagen when missing; never rely on system PATH.
|
|
4
|
+
*
|
|
5
|
+
* @fileoverview Download and install Mutagen to AI Fabrix bin directory
|
|
6
|
+
* @author AI Fabrix Team
|
|
7
|
+
* @version 2.0.0
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const path = require('path');
|
|
11
|
+
const fs = require('fs');
|
|
12
|
+
const https = require('https');
|
|
13
|
+
const { getAifabrixHome } = require('./paths');
|
|
14
|
+
const { exec } = require('child_process');
|
|
15
|
+
const { promisify } = require('util');
|
|
16
|
+
|
|
17
|
+
const execAsync = promisify(exec);
|
|
18
|
+
const fsPromises = fs.promises;
|
|
19
|
+
|
|
20
|
+
const MUTAGEN_RELEASE_API = 'https://api.github.com/repos/mutagen-io/mutagen/releases/latest';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Platform/arch to Mutagen asset basename (e.g. mutagen_linux_amd64).
|
|
24
|
+
* @returns {string|null} Basename without version or extension, or null if unsupported
|
|
25
|
+
*/
|
|
26
|
+
function getPlatformAssetBasename() {
|
|
27
|
+
const platform = process.platform;
|
|
28
|
+
const arch = process.arch;
|
|
29
|
+
if (platform === 'win32') {
|
|
30
|
+
return arch === 'x64' ? 'mutagen_windows_amd64' : arch === 'arm64' ? 'mutagen_windows_arm64' : null;
|
|
31
|
+
}
|
|
32
|
+
if (platform === 'darwin') {
|
|
33
|
+
return arch === 'x64' ? 'mutagen_darwin_amd64' : arch === 'arm64' ? 'mutagen_darwin_arm64' : null;
|
|
34
|
+
}
|
|
35
|
+
if (platform === 'linux') {
|
|
36
|
+
if (arch === 'x64') return 'mutagen_linux_amd64';
|
|
37
|
+
if (arch === 'arm64') return 'mutagen_linux_arm64';
|
|
38
|
+
if (arch === 'arm') return 'mutagen_linux_arm';
|
|
39
|
+
if (arch === 'ia32') return 'mutagen_linux_386';
|
|
40
|
+
}
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Fetch latest release info from GitHub API.
|
|
46
|
+
* @returns {Promise<{ tagName: string, assets: Array<{ name: string, browser_download_url: string }> }>}
|
|
47
|
+
* @throws {Error} If request fails or response is invalid
|
|
48
|
+
*/
|
|
49
|
+
function fetchLatestRelease() {
|
|
50
|
+
return new Promise((resolve, reject) => {
|
|
51
|
+
const req = https.get(MUTAGEN_RELEASE_API, {
|
|
52
|
+
headers: { 'User-Agent': 'aifabrix-builder-cli' }
|
|
53
|
+
}, (res) => {
|
|
54
|
+
if (res.statusCode !== 200) {
|
|
55
|
+
reject(new Error(`GitHub API returned ${res.statusCode}`));
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
let body = '';
|
|
59
|
+
res.on('data', chunk => {
|
|
60
|
+
body += chunk;
|
|
61
|
+
});
|
|
62
|
+
res.on('end', () => {
|
|
63
|
+
try {
|
|
64
|
+
const data = JSON.parse(body);
|
|
65
|
+
const tagName = data.tag_name;
|
|
66
|
+
const assets = (data.assets || []).map(a => ({ name: a.name, browser_download_url: a.browser_download_url }));
|
|
67
|
+
if (!tagName || !Array.isArray(assets)) {
|
|
68
|
+
reject(new Error('Invalid GitHub release response'));
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
resolve({ tagName, assets });
|
|
72
|
+
} catch (e) {
|
|
73
|
+
reject(e);
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
req.on('error', reject);
|
|
78
|
+
req.setTimeout(15000, () => {
|
|
79
|
+
req.destroy(); reject(new Error('Request timeout'));
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Download URL to a file.
|
|
86
|
+
* @param {string} url - Download URL
|
|
87
|
+
* @param {string} destPath - Full path to write file
|
|
88
|
+
* @param {(msg: string) => void} [log] - Optional progress logger
|
|
89
|
+
* @returns {Promise<void>}
|
|
90
|
+
*/
|
|
91
|
+
function downloadToFile(url, destPath, log) {
|
|
92
|
+
return new Promise((resolve, reject) => {
|
|
93
|
+
const file = fs.createWriteStream(destPath, { flags: 'w' });
|
|
94
|
+
const req = https.get(url, { headers: { 'User-Agent': 'aifabrix-builder-cli' } }, (res) => {
|
|
95
|
+
if (res.statusCode !== 200) {
|
|
96
|
+
file.close();
|
|
97
|
+
fs.unlink(destPath, () => {});
|
|
98
|
+
reject(new Error(`Download returned ${res.statusCode}`));
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
res.pipe(file);
|
|
102
|
+
file.on('finish', () => {
|
|
103
|
+
file.close(() => resolve());
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
req.on('error', (err) => {
|
|
107
|
+
file.close();
|
|
108
|
+
fs.unlink(destPath, () => {});
|
|
109
|
+
reject(err);
|
|
110
|
+
});
|
|
111
|
+
req.setTimeout(120000, () => {
|
|
112
|
+
req.destroy(); reject(new Error('Download timeout'));
|
|
113
|
+
});
|
|
114
|
+
if (typeof log === 'function') log('Downloading Mutagen...');
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Extract .tar.gz using system tar; find binary and copy to destPath.
|
|
120
|
+
* Mutagen tarballs may have binary at root or inside a single top-level directory.
|
|
121
|
+
* @param {string} archivePath - Path to .tar.gz
|
|
122
|
+
* @param {string} destPath - Final binary path
|
|
123
|
+
* @param {string} binaryName - mutagen or mutagen.exe
|
|
124
|
+
*/
|
|
125
|
+
async function extractAndInstall(archivePath, destPath, binaryName) {
|
|
126
|
+
const tmpDir = path.join(path.dirname(archivePath), `mutagen-extract-${Date.now()}`);
|
|
127
|
+
await fsPromises.mkdir(tmpDir, { recursive: true });
|
|
128
|
+
try {
|
|
129
|
+
await execAsync(`tar -xzf "${archivePath}" -C "${tmpDir}"`, { timeout: 60000 });
|
|
130
|
+
let sourcePath = path.join(tmpDir, binaryName);
|
|
131
|
+
if (!fs.existsSync(sourcePath)) {
|
|
132
|
+
const entries = await fsPromises.readdir(tmpDir, { withFileTypes: true });
|
|
133
|
+
const sub = entries.length === 1 && entries[0].isDirectory() ? path.join(tmpDir, entries[0].name) : tmpDir;
|
|
134
|
+
sourcePath = path.join(sub, binaryName);
|
|
135
|
+
if (!fs.existsSync(sourcePath)) {
|
|
136
|
+
throw new Error(`Binary ${binaryName} not found in archive`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
await fsPromises.copyFile(sourcePath, destPath);
|
|
140
|
+
if (process.platform !== 'win32') {
|
|
141
|
+
await fsPromises.chmod(destPath, 0o755);
|
|
142
|
+
}
|
|
143
|
+
} finally {
|
|
144
|
+
await fsPromises.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Resolve install paths: bin dir, binary name, dest path, and archive path.
|
|
150
|
+
* @returns {{ binDir: string, binaryName: string, destPath: string, archivePath: string }}
|
|
151
|
+
*/
|
|
152
|
+
function getInstallPaths() {
|
|
153
|
+
const home = getAifabrixHome();
|
|
154
|
+
const binDir = path.join(home, 'bin');
|
|
155
|
+
const binaryName = process.platform === 'win32' ? 'mutagen.exe' : 'mutagen';
|
|
156
|
+
const destPath = path.join(binDir, binaryName);
|
|
157
|
+
const archivePath = path.join(binDir, `mutagen-dl-${Date.now()}.tar.gz`);
|
|
158
|
+
return { binDir, binaryName, destPath, archivePath };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Download and install Mutagen to ~/.aifabrix/bin/. Uses internal path only (no PATH).
|
|
163
|
+
* @param {(msg: string) => void} [log] - Optional progress logger
|
|
164
|
+
* @returns {Promise<string>} Path to installed binary
|
|
165
|
+
* @throws {Error} If platform unsupported, download fails, or install fails
|
|
166
|
+
*/
|
|
167
|
+
async function installMutagen(log) {
|
|
168
|
+
const basename = getPlatformAssetBasename();
|
|
169
|
+
if (!basename) {
|
|
170
|
+
throw new Error(`Mutagen does not provide a binary for ${process.platform}/${process.arch}. Install manually to ~/.aifabrix/bin/.`);
|
|
171
|
+
}
|
|
172
|
+
const { tagName, assets } = await fetchLatestRelease();
|
|
173
|
+
const version = tagName.replace(/^v/, '');
|
|
174
|
+
const assetName = `${basename}_v${version}.tar.gz`;
|
|
175
|
+
const asset = assets.find(a => a.name === assetName);
|
|
176
|
+
if (!asset) {
|
|
177
|
+
throw new Error(`Mutagen release ${tagName} has no asset ${assetName}. Install manually to ~/.aifabrix/bin/.`);
|
|
178
|
+
}
|
|
179
|
+
const { binDir, binaryName, destPath, archivePath } = getInstallPaths();
|
|
180
|
+
await fsPromises.mkdir(binDir, { recursive: true });
|
|
181
|
+
try {
|
|
182
|
+
await downloadToFile(asset.browser_download_url, archivePath, log);
|
|
183
|
+
if (typeof log === 'function') log('Installing Mutagen...');
|
|
184
|
+
await extractAndInstall(archivePath, destPath, binaryName);
|
|
185
|
+
} finally {
|
|
186
|
+
await fsPromises.unlink(archivePath).catch(() => {});
|
|
187
|
+
}
|
|
188
|
+
return destPath;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
module.exports = {
|
|
192
|
+
getPlatformAssetBasename,
|
|
193
|
+
fetchLatestRelease,
|
|
194
|
+
installMutagen
|
|
195
|
+
};
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mutagen sync – binary path and session helpers (plan 65: sync for dev).
|
|
3
|
+
*
|
|
4
|
+
* @fileoverview Mutagen binary resolution; session create/resume/terminate
|
|
5
|
+
* @author AI Fabrix Team
|
|
6
|
+
* @version 2.0.0
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const path = require('path');
|
|
10
|
+
const fs = require('fs');
|
|
11
|
+
const { getAifabrixHome } = require('./paths');
|
|
12
|
+
const { exec } = require('child_process');
|
|
13
|
+
const { promisify } = require('util');
|
|
14
|
+
|
|
15
|
+
const execAsync = promisify(exec);
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Name of the Mutagen binary (platform-specific).
|
|
19
|
+
* @returns {string} mutagen or mutagen.exe
|
|
20
|
+
*/
|
|
21
|
+
function getMutagenBinaryName() {
|
|
22
|
+
return process.platform === 'win32' ? 'mutagen.exe' : 'mutagen';
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Preferred path for Mutagen binary (~/.aifabrix/bin/mutagen or mutagen.exe).
|
|
27
|
+
* @returns {string} Absolute path
|
|
28
|
+
*/
|
|
29
|
+
function getMutagenBinPath() {
|
|
30
|
+
const home = getAifabrixHome();
|
|
31
|
+
return path.join(home, 'bin', getMutagenBinaryName());
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Resolve path to Mutagen binary. Uses only ~/.aifabrix/bin/ (never system PATH).
|
|
36
|
+
* @returns {Promise<string|null>} Path to binary or null if not installed
|
|
37
|
+
*/
|
|
38
|
+
async function getMutagenPath() {
|
|
39
|
+
const preferred = getMutagenBinPath();
|
|
40
|
+
return fs.existsSync(preferred) ? preferred : null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Ensure Mutagen is available: return path if already installed, otherwise download and install
|
|
45
|
+
* to ~/.aifabrix/bin/ then return that path. Per remote-docker.md: CLI installs when missing.
|
|
46
|
+
* @param {(msg: string) => void} [log] - Optional progress logger (e.g. logger.log)
|
|
47
|
+
* @returns {Promise<string>} Path to Mutagen binary
|
|
48
|
+
* @throws {Error} If install fails (unsupported platform, network, etc.)
|
|
49
|
+
*/
|
|
50
|
+
async function ensureMutagenPath(log) {
|
|
51
|
+
const existing = await getMutagenPath();
|
|
52
|
+
if (existing) return existing;
|
|
53
|
+
const installMutagen = require('./mutagen-install').installMutagen;
|
|
54
|
+
await installMutagen(log);
|
|
55
|
+
const pathAfter = getMutagenBinPath();
|
|
56
|
+
if (!fs.existsSync(pathAfter)) {
|
|
57
|
+
throw new Error('Mutagen install did not create binary at ' + pathAfter);
|
|
58
|
+
}
|
|
59
|
+
return pathAfter;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Session name for app: aifabrix-<dev-id>-<app-key>
|
|
64
|
+
* @param {string} developerId - Developer ID
|
|
65
|
+
* @param {string} appKey - App key (e.g. app name)
|
|
66
|
+
* @returns {string}
|
|
67
|
+
*/
|
|
68
|
+
function getSessionName(developerId, appKey) {
|
|
69
|
+
return `aifabrix-${developerId}-${appKey}`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Remote path for sync and Docker -v: user-mutagen-folder + '/' + relative path.
|
|
74
|
+
* Relative path is remoteSyncPath (normalized) when set, else 'dev/' + appKey.
|
|
75
|
+
* @param {string} userMutagenFolder - From config (no trailing slash)
|
|
76
|
+
* @param {string} appKey - App key (used when relativePathOverride is unset)
|
|
77
|
+
* @param {string} [relativePathOverride] - Optional; when non-empty, used as relative path under user-mutagen-folder (leading slashes stripped)
|
|
78
|
+
* @returns {string}
|
|
79
|
+
*/
|
|
80
|
+
function getRemotePath(userMutagenFolder, appKey, relativePathOverride) {
|
|
81
|
+
const base = (userMutagenFolder || '').trim().replace(/\/+$/, '');
|
|
82
|
+
if (!base) return '';
|
|
83
|
+
const raw = typeof relativePathOverride === 'string' ? relativePathOverride.trim() : '';
|
|
84
|
+
const relative = raw ? raw.replace(/^\/+/, '') : '';
|
|
85
|
+
if (relative) return `${base}/${relative}`;
|
|
86
|
+
return `${base}/dev/${appKey}`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* SSH URL for Mutagen: sync-ssh-user@sync-ssh-host:remote_path
|
|
91
|
+
* @param {string} syncSshUser - SSH user
|
|
92
|
+
* @param {string} syncSshHost - SSH host
|
|
93
|
+
* @param {string} remotePath - Remote path
|
|
94
|
+
* @returns {string}
|
|
95
|
+
*/
|
|
96
|
+
function getSyncSshUrl(syncSshUser, syncSshHost, remotePath) {
|
|
97
|
+
if (!syncSshUser || !syncSshHost || !remotePath) return '';
|
|
98
|
+
return `${syncSshUser}@${syncSshHost}:${remotePath}`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* List sync session names (one per line).
|
|
103
|
+
* @param {string} mutagenPath - Path to mutagen binary
|
|
104
|
+
* @returns {Promise<string[]>}
|
|
105
|
+
*/
|
|
106
|
+
async function listSyncSessionNames(mutagenPath) {
|
|
107
|
+
const { stdout } = await execAsync(`"${mutagenPath}" sync list --template '{{.Name}}'`, {
|
|
108
|
+
encoding: 'utf8',
|
|
109
|
+
timeout: 5000
|
|
110
|
+
});
|
|
111
|
+
return (stdout || '').trim().split('\n').filter(Boolean);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Ensure a sync session exists: create or resume. Idempotent.
|
|
116
|
+
* @param {string} mutagenPath - Path to mutagen binary
|
|
117
|
+
* @param {string} sessionName - Session name (e.g. aifabrix-01-myapp)
|
|
118
|
+
* @param {string} localPath - Local app directory (absolute)
|
|
119
|
+
* @param {string} sshUrl - Remote SSH URL (user@host:path)
|
|
120
|
+
* @returns {Promise<void>}
|
|
121
|
+
* @throws {Error} If create or resume fails
|
|
122
|
+
*/
|
|
123
|
+
async function ensureSyncSession(mutagenPath, sessionName, localPath, sshUrl) {
|
|
124
|
+
const sessions = await listSyncSessionNames(mutagenPath);
|
|
125
|
+
if (sessions.includes(sessionName)) {
|
|
126
|
+
await execAsync(`"${mutagenPath}" sync resume "${sessionName}"`, { timeout: 10000 });
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
const local = path.resolve(localPath).replace(/\\/g, '/');
|
|
130
|
+
await execAsync(
|
|
131
|
+
`"${mutagenPath}" sync create "${local}" "${sshUrl}" --name "${sessionName}" --sync-mode two-way-resolved`,
|
|
132
|
+
{ timeout: 15000 }
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
module.exports = {
|
|
137
|
+
getMutagenPath,
|
|
138
|
+
ensureMutagenPath,
|
|
139
|
+
getMutagenBinaryName,
|
|
140
|
+
getMutagenBinPath,
|
|
141
|
+
getSessionName,
|
|
142
|
+
getRemotePath,
|
|
143
|
+
getSyncSshUrl,
|
|
144
|
+
listSyncSessionNames,
|
|
145
|
+
ensureSyncSession
|
|
146
|
+
};
|