@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.
Files changed (103) hide show
  1. package/README.md +6 -4
  2. package/integration/hubspot/test.js +1 -1
  3. package/lib/api/credential.api.js +40 -0
  4. package/lib/api/dev.api.js +423 -0
  5. package/lib/api/types/credential.types.js +23 -0
  6. package/lib/api/types/dev.types.js +140 -0
  7. package/lib/app/config.js +21 -0
  8. package/lib/app/down.js +2 -1
  9. package/lib/app/index.js +9 -0
  10. package/lib/app/push.js +36 -12
  11. package/lib/app/readme.js +1 -3
  12. package/lib/app/run-env-compose.js +201 -0
  13. package/lib/app/run-helpers.js +121 -118
  14. package/lib/app/run.js +148 -28
  15. package/lib/app/show.js +5 -2
  16. package/lib/build/index.js +11 -3
  17. package/lib/cli/setup-app.js +140 -14
  18. package/lib/cli/setup-dev.js +180 -17
  19. package/lib/cli/setup-environment.js +4 -2
  20. package/lib/cli/setup-external-system.js +71 -21
  21. package/lib/cli/setup-infra.js +29 -2
  22. package/lib/cli/setup-secrets.js +52 -5
  23. package/lib/cli/setup-utility.js +12 -3
  24. package/lib/commands/app-install.js +172 -0
  25. package/lib/commands/app-shell.js +75 -0
  26. package/lib/commands/app-test.js +282 -0
  27. package/lib/commands/app.js +1 -1
  28. package/lib/commands/dev-cli-handlers.js +141 -0
  29. package/lib/commands/dev-down.js +114 -0
  30. package/lib/commands/dev-init.js +309 -0
  31. package/lib/commands/secrets-list.js +118 -0
  32. package/lib/commands/secrets-remove.js +97 -0
  33. package/lib/commands/secrets-set.js +30 -17
  34. package/lib/commands/secrets-validate.js +50 -0
  35. package/lib/commands/up-dataplane.js +2 -2
  36. package/lib/commands/up-miso.js +0 -25
  37. package/lib/commands/upload.js +26 -1
  38. package/lib/core/admin-secrets.js +96 -0
  39. package/lib/core/secrets-ensure.js +378 -0
  40. package/lib/core/secrets-env-write.js +157 -0
  41. package/lib/core/secrets.js +147 -81
  42. package/lib/datasource/field-reference-validator.js +91 -0
  43. package/lib/datasource/validate.js +21 -3
  44. package/lib/deployment/environment-config.js +137 -0
  45. package/lib/deployment/environment.js +21 -98
  46. package/lib/deployment/push.js +32 -2
  47. package/lib/external-system/download.js +7 -0
  48. package/lib/external-system/test-auth.js +7 -3
  49. package/lib/external-system/test.js +5 -1
  50. package/lib/generator/index.js +174 -25
  51. package/lib/generator/wizard.js +8 -0
  52. package/lib/infrastructure/helpers.js +103 -20
  53. package/lib/infrastructure/index.js +88 -10
  54. package/lib/infrastructure/services.js +70 -15
  55. package/lib/schema/application-schema.json +24 -3
  56. package/lib/schema/external-system.schema.json +435 -413
  57. package/lib/utils/api.js +3 -3
  58. package/lib/utils/app-register-auth.js +25 -3
  59. package/lib/utils/cli-utils.js +20 -0
  60. package/lib/utils/compose-generator.js +76 -75
  61. package/lib/utils/compose-handlebars-helpers.js +43 -0
  62. package/lib/utils/compose-vector-helper.js +18 -0
  63. package/lib/utils/config-paths.js +127 -2
  64. package/lib/utils/credential-secrets-env.js +267 -0
  65. package/lib/utils/dev-cert-helper.js +122 -0
  66. package/lib/utils/device-code-helpers.js +224 -0
  67. package/lib/utils/device-code.js +37 -336
  68. package/lib/utils/docker-build.js +40 -8
  69. package/lib/utils/env-copy.js +83 -13
  70. package/lib/utils/env-map.js +35 -5
  71. package/lib/utils/env-template.js +6 -5
  72. package/lib/utils/error-formatters/http-status-errors.js +20 -1
  73. package/lib/utils/help-builder.js +15 -2
  74. package/lib/utils/infra-status.js +30 -1
  75. package/lib/utils/local-secrets.js +7 -52
  76. package/lib/utils/mutagen-install.js +195 -0
  77. package/lib/utils/mutagen.js +146 -0
  78. package/lib/utils/paths.js +43 -33
  79. package/lib/utils/port-resolver.js +28 -16
  80. package/lib/utils/remote-dev-auth.js +38 -0
  81. package/lib/utils/remote-docker-env.js +43 -0
  82. package/lib/utils/remote-secrets-loader.js +60 -0
  83. package/lib/utils/secrets-generator.js +94 -6
  84. package/lib/utils/secrets-helpers.js +33 -25
  85. package/lib/utils/secrets-path.js +2 -2
  86. package/lib/utils/secrets-utils.js +52 -1
  87. package/lib/utils/secrets-validation.js +84 -0
  88. package/lib/utils/ssh-key-helper.js +116 -0
  89. package/lib/utils/token-manager-messages.js +90 -0
  90. package/lib/utils/token-manager.js +5 -4
  91. package/lib/utils/variable-transformer.js +3 -3
  92. package/lib/validation/validator.js +65 -0
  93. package/package.json +2 -2
  94. package/scripts/install-local.js +34 -15
  95. package/templates/README.md +0 -1
  96. package/templates/applications/README.md.hbs +4 -4
  97. package/templates/applications/dataplane/application.yaml +5 -4
  98. package/templates/applications/dataplane/env.template +12 -7
  99. package/templates/applications/keycloak/env.template +2 -0
  100. package/templates/applications/miso-controller/application.yaml +1 -0
  101. package/templates/applications/miso-controller/env.template +11 -9
  102. package/templates/python/docker-compose.hbs +49 -23
  103. package/templates/typescript/docker-compose.hbs +48 -22
@@ -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 entries
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
- if (entries.hasControllerUrl) {
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
- * Adds authentication guidance
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: 'secrets' },
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
- * Merges with existing secrets without overwriting other keys
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
- const secretsDir = path.dirname(secretsPath);
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
- * Merges with existing secrets without overwriting other keys
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 loadExistingSecrets
102
+ * @function _loadExistingSecrets
138
103
  * @param {string} resolvedPath - Resolved secrets path
139
104
  * @returns {Object} Existing secrets object
140
105
  */
141
- function loadExistingSecrets(resolvedPath) {
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
- const existingSecrets = loadExistingSecrets(resolvedPath);
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
+ };