@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
@@ -0,0 +1,141 @@
1
+ /**
2
+ * @fileoverview CLI action handlers for dev list/add/update/pin/delete (remote Builder Server)
3
+ * @author AI Fabrix Team
4
+ * @version 2.0.0
5
+ */
6
+
7
+ const chalk = require('chalk');
8
+ const config = require('../core/config');
9
+ const logger = require('../utils/logger');
10
+ const devApi = require('../api/dev.api');
11
+ const { getRemoteDevAuth } = require('../utils/remote-dev-auth');
12
+
13
+ const REMOTE_NOT_CONFIGURED_MSG = 'Remote server is not configured. Set remote-server and run "aifabrix dev init" first.';
14
+
15
+ const ID_WIDTH = 8;
16
+ const NAME_WIDTH = 25;
17
+ const EMAIL_WIDTH = 30;
18
+ const CERT_WIDTH = 10;
19
+ const UNTIL_WIDTH = 22; // 2026-03-20T17:31:14
20
+ const TABLE_SEPARATOR_LENGTH = 130;
21
+
22
+ /**
23
+ * Format certificate validity end for display (e.g. 2026-03-20T17:31:14.000Z -> 2026-03-20T17:31:14).
24
+ * @param {string} iso - ISO 8601 date string
25
+ * @returns {string} Truncated datetime without milliseconds and Z
26
+ */
27
+ function formatCertUntil(iso) {
28
+ if (!iso || typeof iso !== 'string') return '?';
29
+ return iso.replace(/\.\d{3}Z?$/i, '').trim();
30
+ }
31
+
32
+ /**
33
+ * Handle dev list – list developer users (remote only). Table format, sorted by name.
34
+ * @returns {Promise<void>}
35
+ */
36
+ async function handleDevList() {
37
+ const auth = await getRemoteDevAuth();
38
+ if (!auth) {
39
+ logger.log(chalk.yellow(REMOTE_NOT_CONFIGURED_MSG));
40
+ return;
41
+ }
42
+ const users = await devApi.listUsers(auth.serverUrl, auth.clientCertPem);
43
+ if (users.length === 0) {
44
+ logger.log(chalk.gray('No developers registered.'));
45
+ return;
46
+ }
47
+ logger.log(chalk.bold('\nšŸ“‹ Developers:\n'));
48
+ logger.log(chalk.gray('ID'.padEnd(ID_WIDTH) + 'Name'.padEnd(NAME_WIDTH) + 'Email'.padEnd(EMAIL_WIDTH) + 'Cert'.padEnd(CERT_WIDTH) + 'Until'.padEnd(UNTIL_WIDTH) + 'Groups'));
49
+ logger.log(chalk.gray('-'.repeat(TABLE_SEPARATOR_LENGTH)));
50
+ const sorted = [...users].sort((a, b) => (a.name || '').localeCompare(b.name || '', undefined, { sensitivity: 'base' }));
51
+ sorted.forEach(u => {
52
+ const certLabel = u.certificateIssued ? 'yes' : 'no cert';
53
+ const untilRaw = u.certificateIssued && u.certificateValidNotAfter ? u.certificateValidNotAfter : null;
54
+ const untilStr = untilRaw ? formatCertUntil(untilRaw) : '-';
55
+ const id = String(u.id ?? '').padEnd(ID_WIDTH);
56
+ const name = (u.name || 'N/A').padEnd(NAME_WIDTH);
57
+ const email = (u.email || 'N/A').padEnd(EMAIL_WIDTH);
58
+ const cert = certLabel.padEnd(CERT_WIDTH);
59
+ const until = untilStr.padEnd(UNTIL_WIDTH);
60
+ const groups = (u.groups || []).join(', ');
61
+ logger.log(`${id}${name}${email}${cert}${until}${groups}`);
62
+ });
63
+ logger.log('');
64
+ }
65
+
66
+ /**
67
+ * Handle dev add – create developer (remote only).
68
+ * @param {Object} options - Commander options (developerId, name, email, groups)
69
+ * @returns {Promise<void>}
70
+ */
71
+ async function handleDevAdd(options) {
72
+ const auth = await getRemoteDevAuth();
73
+ if (!auth) throw new Error(REMOTE_NOT_CONFIGURED_MSG);
74
+ const groups = (options.groups || 'developer').split(',').map(s => s.trim()).filter(Boolean);
75
+ const user = await devApi.createUser(auth.serverUrl, auth.clientCertPem, {
76
+ developerId: options.developerId,
77
+ name: options.name,
78
+ email: options.email,
79
+ groups: groups.length ? groups : ['developer']
80
+ });
81
+ logger.log(chalk.green(`āœ“ Developer ${user.id} created. Use "aifabrix dev pin ${user.id}" to create a PIN for onboarding.`));
82
+ }
83
+
84
+ /**
85
+ * Handle dev update – update developer (remote only).
86
+ * @param {string} developerId - Developer ID
87
+ * @param {Object} options - Commander options (name, email, groups)
88
+ * @returns {Promise<void>}
89
+ */
90
+ async function handleDevUpdate(developerId, options) {
91
+ const auth = await getRemoteDevAuth();
92
+ if (!auth) throw new Error(REMOTE_NOT_CONFIGURED_MSG);
93
+ const id = options.developerId || options['developer-id'] || developerId;
94
+ if (!id) throw new Error('Developer ID is required (--developer-id or positional argument).');
95
+ const body = {};
96
+ if (options.name) body.name = options.name;
97
+ if (options.email) body.email = options.email;
98
+ if (options.groups) body.groups = options.groups.split(',').map(s => s.trim()).filter(Boolean);
99
+ if (Object.keys(body).length === 0) {
100
+ throw new Error('Provide at least one of --name, --email, --groups');
101
+ }
102
+ await devApi.updateUser(auth.serverUrl, auth.clientCertPem, id, body);
103
+ logger.log(chalk.green(`āœ“ Developer ${id} updated.`));
104
+ }
105
+
106
+ /**
107
+ * Handle dev pin – create/regenerate PIN (remote only).
108
+ * @param {string} [developerId] - Developer ID (optional; uses config if omitted)
109
+ * @returns {Promise<void>}
110
+ */
111
+ async function handleDevPin(developerId) {
112
+ const auth = await getRemoteDevAuth();
113
+ if (!auth) throw new Error(REMOTE_NOT_CONFIGURED_MSG);
114
+ const id = developerId || await config.getDeveloperId();
115
+ if (!id) throw new Error('developerId is required (argument or set developer-id in config)');
116
+ const res = await devApi.createPin(auth.serverUrl, auth.clientCertPem, id);
117
+ logger.log(chalk.green(`āœ“ PIN created for ${id}, expires ${res.expiresAt}.`));
118
+ logger.log(chalk.yellow(` Give this PIN once to the developer for: aifabrix dev init --developer-id ${id} --server ${auth.serverUrl} --pin ${res.pin}`));
119
+ }
120
+
121
+ /**
122
+ * Handle dev delete – remove developer (remote only).
123
+ * @param {string} developerId - Developer ID
124
+ * @returns {Promise<void>}
125
+ */
126
+ async function handleDevDelete(developerId) {
127
+ const auth = await getRemoteDevAuth();
128
+ if (!auth) throw new Error(REMOTE_NOT_CONFIGURED_MSG);
129
+ const id = developerId;
130
+ if (!id) throw new Error('Developer ID is required (positional argument or --developer-id).');
131
+ await devApi.deleteUser(auth.serverUrl, auth.clientCertPem, id);
132
+ logger.log(chalk.green(`āœ“ Developer ${id} removed.`));
133
+ }
134
+
135
+ module.exports = {
136
+ handleDevList,
137
+ handleDevAdd,
138
+ handleDevUpdate,
139
+ handleDevPin,
140
+ handleDevDelete
141
+ };
@@ -0,0 +1,114 @@
1
+ /**
2
+ * dev down – stop Mutagen sync sessions and optionally app containers for this developer.
3
+ *
4
+ * @fileoverview Dev down command (plan 65: stop sync sessions; optional stop apps)
5
+ * @author AI Fabrix Team
6
+ * @version 2.0.0
7
+ */
8
+
9
+ const { exec } = require('child_process');
10
+ const { promisify } = require('util');
11
+ const chalk = require('chalk');
12
+ const logger = require('../utils/logger');
13
+ const config = require('../core/config');
14
+ const appLib = require('../app');
15
+
16
+ const execAsync = promisify(exec);
17
+
18
+ /**
19
+ * Stop Mutagen sync sessions for this developer (session names aifabrix-<dev-id>-*).
20
+ * When Mutagen is not available or no sessions exist, no-op.
21
+ * @param {string} developerId - Developer ID
22
+ * @returns {Promise<void>}
23
+ */
24
+ async function stopMutagenSessions(developerId) {
25
+ const { getMutagenPath } = require('../utils/mutagen');
26
+ const mutagenPath = await getMutagenPath();
27
+ if (!mutagenPath) {
28
+ logger.log(chalk.gray('Mutagen not installed; no sync sessions to stop.'));
29
+ return;
30
+ }
31
+ try {
32
+ const { stdout } = await execAsync(`"${mutagenPath}" sync list --template '{{.Name}}'`, {
33
+ encoding: 'utf8',
34
+ timeout: 5000
35
+ });
36
+ const sessions = (stdout || '').trim().split('\n').filter(Boolean);
37
+ const prefix = `aifabrix-${developerId}-`;
38
+ const toTerminate = sessions.filter(name => name.startsWith(prefix));
39
+ for (const name of toTerminate) {
40
+ await execAsync(`"${mutagenPath}" sync terminate "${name}"`, { timeout: 5000 });
41
+ logger.log(chalk.green(` āœ“ Stopped sync session: ${name}`));
42
+ }
43
+ if (toTerminate.length === 0 && sessions.length > 0) {
44
+ logger.log(chalk.gray('No sync sessions for this developer.'));
45
+ } else if (toTerminate.length === 0) {
46
+ logger.log(chalk.gray('No Mutagen sync sessions to stop.'));
47
+ }
48
+ } catch (err) {
49
+ logger.log(chalk.gray('No Mutagen sync sessions to stop.'));
50
+ }
51
+ }
52
+
53
+ const INFRA_SUFFIXES = ['-postgres', '-redis', '-pgadmin', '-redis-commander', '-traefik', '-db-init'];
54
+
55
+ /**
56
+ * List running app container names for this developer (excludes infra containers).
57
+ * @param {string} developerId - Developer ID
58
+ * @returns {Promise<string[]>} Container names (e.g. aifabrix-dev1-myapp)
59
+ */
60
+ async function listAppContainersForDeveloper(developerId) {
61
+ const idNum = parseInt(developerId, 10);
62
+ const filter = idNum === 0 ? 'aifabrix-' : `aifabrix-dev${developerId}-`;
63
+ const { stdout } = await execAsync(
64
+ `docker ps --filter "name=${filter}" --format "{{.Names}}"`,
65
+ { encoding: 'utf8' }
66
+ );
67
+ const names = (stdout || '').trim().split('\n').filter(Boolean);
68
+ return names.filter(n => !INFRA_SUFFIXES.some(s => n.endsWith(s)));
69
+ }
70
+
71
+ /**
72
+ * Extract app name from container name (aifabrix-dev1-myapp -> myapp, aifabrix-myapp -> myapp).
73
+ * @param {string} containerName - Container name
74
+ * @param {string} developerId - Developer ID
75
+ * @returns {string} App name
76
+ */
77
+ function appNameFromContainer(containerName, developerId) {
78
+ const idNum = parseInt(developerId, 10);
79
+ const prefix = idNum === 0 ? 'aifabrix-' : `aifabrix-dev${developerId}-`;
80
+ return containerName.startsWith(prefix) ? containerName.slice(prefix.length) : containerName;
81
+ }
82
+
83
+ /**
84
+ * Handle dev down: stop Mutagen sessions; optionally stop app containers.
85
+ * @param {Object} options - { apps: boolean }
86
+ * @returns {Promise<void>}
87
+ */
88
+ async function handleDevDown(options = {}) {
89
+ const developerId = await config.getDeveloperId();
90
+ logger.log(chalk.blue('\nStopping dev resources for developer ' + developerId + '...\n'));
91
+
92
+ await stopMutagenSessions(developerId);
93
+
94
+ if (options.apps) {
95
+ const containers = await listAppContainersForDeveloper(developerId);
96
+ for (const containerName of containers) {
97
+ const appName = appNameFromContainer(containerName, developerId);
98
+ if (!appName) continue;
99
+ try {
100
+ await appLib.downApp(appName, {});
101
+ logger.log(chalk.green(` āœ“ Stopped app: ${appName}`));
102
+ } catch (err) {
103
+ logger.log(chalk.yellow(` ⚠ Could not stop ${appName}: ${err.message}`));
104
+ }
105
+ }
106
+ if (containers.length === 0) {
107
+ logger.log(chalk.gray('No running app containers for this developer.'));
108
+ }
109
+ }
110
+
111
+ logger.log(chalk.green('\nāœ“ dev down complete.\n'));
112
+ }
113
+
114
+ module.exports = { handleDevDown };
@@ -0,0 +1,309 @@
1
+ /**
2
+ * @fileoverview aifabrix dev init – onboard with Builder Server (issue-cert, save cert, get settings, add SSH key).
3
+ * Auth: first call (issue-cert) uses no client cert; all other calls (getSettings, addSshKey, and every other dev API) send the client certificate.
4
+ * @author AI Fabrix Team
5
+ * @version 2.0.0
6
+ */
7
+
8
+ const fs = require('fs').promises;
9
+ const path = require('path');
10
+ const chalk = require('chalk');
11
+ const config = require('../core/config');
12
+ const { getConfigDirForPaths } = require('../utils/paths');
13
+ const { generateCSR, getCertDir, readClientCertPem, readClientKeyPem, getCertValidNotAfter } = require('../utils/dev-cert-helper');
14
+ const { getOrCreatePublicKeyContent } = require('../utils/ssh-key-helper');
15
+ const devApi = require('../api/dev.api');
16
+ const logger = require('../utils/logger');
17
+
18
+ /**
19
+ * Validate init options and return normalized baseUrl and devId.
20
+ * @param {Object} options - Commander options
21
+ * @returns {{ baseUrl: string, devId: string }}
22
+ */
23
+ function validateInitOptions(options) {
24
+ const devId = options.developerId || options['developer-id'];
25
+ const server = options.server;
26
+ const pin = options.pin;
27
+
28
+ if (!devId || typeof devId !== 'string' || !/^[0-9]+$/.test(devId)) {
29
+ throw new Error('--developer-id is required and must be a non-empty digit string (e.g. 01)');
30
+ }
31
+ if (!server || typeof server !== 'string' || !server.trim()) {
32
+ throw new Error('--server is required and must be the Builder Server base URL (e.g. https://dev.aifabrix.dev)');
33
+ }
34
+ if (!pin || typeof pin !== 'string' || !pin.trim()) {
35
+ throw new Error('--pin is required (one-time PIN from your admin)');
36
+ }
37
+ return { baseUrl: server.trim().replace(/\/+$/, ''), devId };
38
+ }
39
+
40
+ /**
41
+ * Request certificate from Builder Server; map API errors to user messages.
42
+ * @param {string} baseUrl - Builder Server base URL
43
+ * @param {string} devId - Developer ID
44
+ * @param {string} pin - One-time PIN
45
+ * @param {string} csrPem - PEM CSR
46
+ * @returns {Promise<Object>} IssueCertResponseDto
47
+ */
48
+ async function requestCertificate(baseUrl, devId, pin, csrPem) {
49
+ try {
50
+ return await devApi.issueCert(baseUrl, {
51
+ developerId: devId,
52
+ pin: pin.trim(),
53
+ csr: csrPem
54
+ });
55
+ } catch (err) {
56
+ if (err.status === 401) {
57
+ throw new Error('Invalid or expired PIN. Ask your admin for a new PIN (aifabrix dev pin <developerId>).');
58
+ }
59
+ if (err.status === 404) {
60
+ throw new Error(`Developer ${devId} not found on the server.`);
61
+ }
62
+ if (err.status === 503) {
63
+ throw new Error('Certificate signing is temporarily unavailable. Try again later.');
64
+ }
65
+ throw err;
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Normalize PEM string: turn literal \n (backslash-n) into real newlines so Docker/OpenSSL accept it.
71
+ * Some servers return JSON with escaped newlines in the PEM string.
72
+ * @param {string} pem - PEM string (certificate or CA)
73
+ * @returns {string} PEM with real newlines
74
+ */
75
+ function normalizePemNewlines(pem) {
76
+ if (typeof pem !== 'string') return pem;
77
+ return pem.replace(/\\n/g, '\n');
78
+ }
79
+
80
+ /**
81
+ * Save certificate, key, and optional CA to cert dir; set developer-id in config.
82
+ * Remote Docker requires ca.pem in the cert dir; if the server provides it (e.g. issue-cert
83
+ * response caCertificate or ca), it is saved so DOCKER_CERT_PATH works.
84
+ * @param {string} configDir - Config directory
85
+ * @param {string} devId - Developer ID
86
+ * @param {string} certificatePem - Issued certificate PEM
87
+ * @param {string} keyPem - Private key PEM
88
+ * @param {string} [caPem] - Optional CA certificate PEM (for remote Docker TLS)
89
+ */
90
+ async function saveCertAndConfig(configDir, devId, certificatePem, keyPem, caPem) {
91
+ const certDir = getCertDir(configDir, devId);
92
+ await fs.mkdir(certDir, { recursive: true });
93
+ const certNormalized = normalizePemNewlines(certificatePem);
94
+ const keyNormalized = normalizePemNewlines(keyPem);
95
+ await fs.writeFile(path.join(certDir, 'cert.pem'), certNormalized, { mode: 0o600 });
96
+ await fs.writeFile(path.join(certDir, 'key.pem'), keyNormalized, { mode: 0o600 });
97
+ if (caPem && typeof caPem === 'string' && caPem.trim()) {
98
+ const caNormalized = normalizePemNewlines(caPem.trim());
99
+ await fs.writeFile(path.join(certDir, 'ca.pem'), caNormalized, { mode: 0o600 });
100
+ logger.log(chalk.green(' āœ“ Certificate and CA saved to ') + chalk.cyan(path.join(certDir, 'cert.pem')));
101
+ } else {
102
+ logger.log(chalk.green(' āœ“ Certificate saved to ') + chalk.cyan(path.join(certDir, 'cert.pem')));
103
+ }
104
+ await config.setDeveloperId(devId);
105
+ logger.log(chalk.green(' āœ“ Developer ID set to ') + chalk.cyan(devId));
106
+ }
107
+
108
+ /**
109
+ * Message for 400 Bad Request: nginx often forwards X-Client-Cert with literal newlines.
110
+ * @returns {string} Hint for server-side nginx fix
111
+ */
112
+ function getBadRequestHint() {
113
+ return 'Bad Request (400) often means the server\'s nginx is forwarding the client certificate with literal newlines in X-Client-Cert. On the server, use nginx njs to escape newlines (see .cursor/plans/builder-cli.md §5).';
114
+ }
115
+
116
+ /**
117
+ * Log a one-line hint for cert troubleshooting (curl test and docs).
118
+ * @param {string} configDir - Config directory
119
+ * @param {string} devId - Developer ID
120
+ * @param {string} baseUrl - Builder Server base URL
121
+ */
122
+ function logCertTroubleshootingHint(configDir, devId, baseUrl) {
123
+ const certDir = getCertDir(configDir, devId);
124
+ const certPath = path.join(certDir, 'cert.pem');
125
+ const keyPath = path.join(certDir, 'key.pem');
126
+ logger.log(chalk.gray(` Test with: curl -v --cert ${certPath} --key ${keyPath} ${baseUrl}/api/dev/settings`));
127
+ logger.log(chalk.gray(' See .cursor/plans/builder-cli.md §5 for 200 vs 401 vs 400 and nginx/server fix.'));
128
+ }
129
+
130
+ /**
131
+ * Register SSH public key with Builder Server for Mutagen sync.
132
+ * @param {string} baseUrl - Builder Server base URL
133
+ * @param {string} clientCertPem - Client certificate PEM
134
+ * @param {string} clientKeyPem - Client private key PEM (for mTLS)
135
+ * @param {string} devId - Developer ID
136
+ */
137
+ async function registerSshKey(baseUrl, clientCertPem, clientKeyPem, devId) {
138
+ const publicKey = getOrCreatePublicKeyContent();
139
+ try {
140
+ await devApi.addSshKey(baseUrl, clientCertPem, devId, {
141
+ publicKey,
142
+ label: 'aifabrix-init'
143
+ }, clientKeyPem);
144
+ logger.log(chalk.green(' āœ“ SSH key registered'));
145
+ } catch (err) {
146
+ if (err.status === 409) {
147
+ logger.log(chalk.yellow(' ⚠ SSH key already registered'));
148
+ } else {
149
+ throw err;
150
+ }
151
+ }
152
+ }
153
+
154
+ /**
155
+ * Run SSH key registration step (log, register, handle 400 hint).
156
+ * @param {string} baseUrl - Builder Server base URL
157
+ * @param {Object} issueResponse - IssueCert response (certificate)
158
+ * @param {string} keyPem - Client key PEM
159
+ * @param {string} configDir - Config directory
160
+ * @param {string} devId - Developer ID
161
+ * @private
162
+ */
163
+ async function _runSshKeyRegistrationStep(baseUrl, issueResponse, keyPem, configDir, devId) {
164
+ logger.log(chalk.gray(' Registering SSH key for Mutagen sync...'));
165
+ try {
166
+ if (keyPem && typeof keyPem === 'string') {
167
+ logger.log(chalk.gray(' Using client certificate for TLS'));
168
+ }
169
+ await registerSshKey(baseUrl, issueResponse.certificate, keyPem, devId);
170
+ } catch (err) {
171
+ const msg = err.status === 400 ? getBadRequestHint() : (err.message || String(err));
172
+ logger.log(chalk.yellow(' ⚠ Could not register SSH key: ' + msg));
173
+ logCertTroubleshootingHint(configDir, devId, baseUrl);
174
+ }
175
+ }
176
+
177
+ /**
178
+ * Apply settings from issue-cert response or fetch via getSettings; merge into config.
179
+ * @param {string} baseUrl - Builder Server base URL
180
+ * @param {string} devId - Developer ID
181
+ * @param {Object} issueResponse - IssueCert response (certificate, settings)
182
+ * @param {string} keyPem - Client key PEM
183
+ */
184
+ async function applySettingsFromServer(baseUrl, devId, issueResponse, keyPem) {
185
+ const configDir = getConfigDirForPaths();
186
+ if (issueResponse.settings && typeof issueResponse.settings === 'object') {
187
+ await config.mergeRemoteSettings(issueResponse.settings);
188
+ logger.log(chalk.green(' āœ“ Config updated from server (issue-cert response)'));
189
+ return;
190
+ }
191
+ logger.log(chalk.gray(' Fetching settings...'));
192
+ try {
193
+ if (keyPem && typeof keyPem === 'string') {
194
+ logger.log(chalk.gray(' Using client certificate for TLS'));
195
+ }
196
+ const settings = await devApi.getSettings(baseUrl, issueResponse.certificate, keyPem);
197
+ await config.mergeRemoteSettings(settings);
198
+ logger.log(chalk.green(' āœ“ Config updated from server'));
199
+ } catch (err) {
200
+ const msg = err.status === 400 ? getBadRequestHint() : (err.message || String(err));
201
+ logger.log(chalk.yellow(' ⚠ Could not fetch settings (server may not support cert yet): ' + msg));
202
+ logCertTroubleshootingHint(configDir, devId, baseUrl);
203
+ }
204
+ }
205
+
206
+ /**
207
+ * Run dev init: validate PIN via issue-cert, save certificate, fetch settings, add SSH key.
208
+ * @param {Object} options - Commander options (devId, server, pin)
209
+ * @returns {Promise<void>}
210
+ */
211
+ async function runDevInit(options) {
212
+ const { baseUrl, devId } = validateInitOptions(options);
213
+ logger.log(chalk.blue('\nšŸ” Onboarding with Builder Server...\n'));
214
+
215
+ try {
216
+ await devApi.getHealth(baseUrl);
217
+ } catch (err) {
218
+ throw new Error(`Cannot reach Builder Server at ${baseUrl}. Check URL and network. ${err.message}`);
219
+ }
220
+
221
+ logger.log(chalk.gray(' Generating certificate request...'));
222
+ const { csrPem, keyPem } = generateCSR(devId);
223
+
224
+ logger.log(chalk.gray(' Requesting certificate (issue-cert)...'));
225
+ const issueResponse = await requestCertificate(baseUrl, devId, options.pin, csrPem);
226
+
227
+ const configDir = getConfigDirForPaths();
228
+ const caPem = issueResponse.caCertificate || issueResponse.ca;
229
+ await saveCertAndConfig(configDir, devId, issueResponse.certificate, keyPem, caPem);
230
+
231
+ await config.setRemoteServer(baseUrl);
232
+
233
+ await applySettingsFromServer(baseUrl, devId, issueResponse, keyPem);
234
+ await _runSshKeyRegistrationStep(baseUrl, issueResponse, keyPem, configDir, devId);
235
+ logger.log(chalk.green('\nāœ“ Onboarding complete. You can use remote Docker and Mutagen sync.\n'));
236
+ }
237
+
238
+ /** Days before cert expiry at which we auto-refresh on dev refresh. */
239
+ const CERT_REFRESH_DAYS = 14;
240
+
241
+ /**
242
+ * True if the cert in certDir expires within CERT_REFRESH_DAYS (or we cannot read expiry).
243
+ * @param {string} certDir - Certificate directory
244
+ * @returns {boolean}
245
+ */
246
+ function shouldRefreshDevCert(certDir) {
247
+ const validNotAfter = getCertValidNotAfter(certDir);
248
+ if (!validNotAfter) return true;
249
+ const now = Date.now();
250
+ const threshold = now + CERT_REFRESH_DAYS * 24 * 60 * 60 * 1000;
251
+ return validNotAfter.getTime() < threshold;
252
+ }
253
+
254
+ /**
255
+ * Refresh developer certificate: create PIN (with current cert), issue new cert, save and apply settings.
256
+ * @param {{ serverUrl: string, clientCertPem: string }} auth - Current auth from getRemoteDevAuth
257
+ * @returns {Promise<void>}
258
+ */
259
+ async function runCertificateRefresh(auth) {
260
+ const devId = await config.getDeveloperId();
261
+ if (!devId) throw new Error('developer-id not set in config.');
262
+ const configDir = getConfigDirForPaths();
263
+ logger.log(chalk.blue('\nšŸ”„ Refreshing certificate (create PIN + issue-cert)...\n'));
264
+ const pinRes = await devApi.createPin(auth.serverUrl, auth.clientCertPem, devId);
265
+ const pin = pinRes.pin;
266
+ if (!pin || typeof pin !== 'string') throw new Error('Server did not return a PIN.');
267
+ logger.log(chalk.gray(' Generating new certificate request...'));
268
+ const { csrPem, keyPem } = generateCSR(devId);
269
+ logger.log(chalk.gray(' Requesting new certificate (issue-cert)...'));
270
+ const issueResponse = await requestCertificate(auth.serverUrl, devId, pin, csrPem);
271
+ const caPem = issueResponse.caCertificate || issueResponse.ca;
272
+ await saveCertAndConfig(configDir, devId, issueResponse.certificate, keyPem, caPem);
273
+ await applySettingsFromServer(auth.serverUrl, devId, issueResponse, keyPem);
274
+ logger.log(chalk.green('āœ“ Certificate refreshed and config updated from server.\n'));
275
+ }
276
+
277
+ /**
278
+ * Fetch settings from Builder Server and merge into config (GET /api/dev/settings).
279
+ * If certificate expires within CERT_REFRESH_DAYS (or --cert), refresh cert first (create PIN + issue-cert).
280
+ * @param {Object} [options] - Commander options; options.cert = true forces cert refresh
281
+ * @returns {Promise<void>}
282
+ * @throws {Error} If remote server or certificate not configured, or getSettings fails
283
+ */
284
+ async function runDevRefresh(options = {}) {
285
+ const { getRemoteDevAuth } = require('../utils/remote-dev-auth');
286
+ const auth = await getRemoteDevAuth();
287
+ if (!auth) {
288
+ throw new Error('Remote server is not configured. Set remote-server and run "aifabrix dev init" first.');
289
+ }
290
+ const devId = await config.getDeveloperId();
291
+ const configDir = getConfigDirForPaths();
292
+ const certDir = getCertDir(configDir, devId);
293
+ const clientCertPem = readClientCertPem(certDir);
294
+ const clientKeyPem = readClientKeyPem(certDir);
295
+ if (!clientCertPem) {
296
+ throw new Error('Client certificate not found. Run "aifabrix dev init" first.');
297
+ }
298
+ const forceCertRefresh = Boolean(options.cert);
299
+ if (forceCertRefresh || shouldRefreshDevCert(certDir)) {
300
+ await runCertificateRefresh(auth);
301
+ return;
302
+ }
303
+ logger.log(chalk.blue('\nšŸ”„ Fetching settings from Builder Server...\n'));
304
+ const settings = await devApi.getSettings(auth.serverUrl, clientCertPem, clientKeyPem || undefined);
305
+ await config.mergeRemoteSettings(settings);
306
+ logger.log(chalk.green('āœ“ Config updated from server. Run "aifabrix dev config" to verify.\n'));
307
+ }
308
+
309
+ module.exports = { runDevInit, runDevRefresh };
@@ -0,0 +1,118 @@
1
+ /**
2
+ * @fileoverview aifabrix secret list – list secret keys and values (user file, shared file, or remote API)
3
+ * @author AI Fabrix Team
4
+ * @version 2.0.0
5
+ */
6
+
7
+ const path = require('path');
8
+ const fs = require('fs');
9
+ const yaml = require('js-yaml');
10
+ const chalk = require('chalk');
11
+ const logger = require('../utils/logger');
12
+ const { getAifabrixSecretsPath } = require('../core/config');
13
+ const pathsUtil = require('../utils/paths');
14
+ const { isRemoteSecretsUrl, getRemoteDevAuth } = require('../utils/remote-dev-auth');
15
+ const devApi = require('../api/dev.api');
16
+
17
+ const REMOTE_NOT_CONFIGURED_MSG = 'Remote server is not configured. Set remote-server and run "aifabrix dev init" first.';
18
+
19
+ /**
20
+ * List secret keys and values from a YAML file.
21
+ * @param {string} filePath - Absolute path to secrets file
22
+ * @returns {Array<{ key: string, value: string }>} Key-value pairs (value stringified)
23
+ */
24
+ function listKeysAndValuesFromFile(filePath) {
25
+ if (!fs.existsSync(filePath)) {
26
+ return [];
27
+ }
28
+ try {
29
+ const content = fs.readFileSync(filePath, 'utf8');
30
+ const data = yaml.load(content) || {};
31
+ if (typeof data !== 'object' || Array.isArray(data)) return [];
32
+ return Object.entries(data).map(([key, val]) => ({
33
+ key,
34
+ value: (val !== null && val !== undefined) ? String(val) : ''
35
+ }));
36
+ } catch {
37
+ return [];
38
+ }
39
+ }
40
+
41
+ const KEY_COL_WIDTH = 45;
42
+ const TABLE_SEPARATOR_LENGTH = 120;
43
+
44
+ /**
45
+ * Log a list of secret keys and values as a table (header, column headers, separator, rows).
46
+ * Keys are sorted alphabetically. Matches datasource list style.
47
+ * @param {string} emptyMessage - Message when items.length === 0
48
+ * @param {string} title - Table title (e.g. "User secrets")
49
+ * @param {Array<{ key: string, value: string }>} items - Key-value pairs
50
+ */
51
+ function logKeyValueList(emptyMessage, title, items) {
52
+ if (items.length === 0) {
53
+ logger.log(chalk.gray(emptyMessage));
54
+ return;
55
+ }
56
+ logger.log(chalk.bold(`\nšŸ“‹ ${title}:\n`));
57
+ logger.log(chalk.gray('Key'.padEnd(KEY_COL_WIDTH) + 'Value'));
58
+ logger.log(chalk.gray('-'.repeat(TABLE_SEPARATOR_LENGTH)));
59
+ const sorted = [...items].sort((a, b) => a.key.localeCompare(b.key, undefined, { sensitivity: 'base' }));
60
+ sorted.forEach(({ key, value }) => {
61
+ const keyCol = key.padEnd(KEY_COL_WIDTH);
62
+ logger.log(`${keyCol}${value}`);
63
+ });
64
+ logger.log('');
65
+ }
66
+
67
+ /**
68
+ * List shared secrets (remote API or file) and log key and value.
69
+ * @param {string} generalSecretsPath - Path or URL for shared secrets
70
+ * @returns {Promise<void>}
71
+ */
72
+ async function listSharedSecrets(generalSecretsPath) {
73
+ if (isRemoteSecretsUrl(generalSecretsPath)) {
74
+ const auth = await getRemoteDevAuth();
75
+ if (!auth) {
76
+ throw new Error(REMOTE_NOT_CONFIGURED_MSG);
77
+ }
78
+ const items = await devApi.listSecrets(auth.serverUrl, auth.clientCertPem);
79
+ const keyValues = items.map(i => ({ key: i.name || i.key || '', value: (i.value !== null && i.value !== undefined) ? String(i.value) : '' }));
80
+ logKeyValueList('No shared secrets (remote).', 'Shared secrets (remote)', keyValues);
81
+ return;
82
+ }
83
+ const resolvedPath = path.isAbsolute(generalSecretsPath)
84
+ ? generalSecretsPath
85
+ : path.resolve(process.cwd(), generalSecretsPath);
86
+ const keyValues = listKeysAndValuesFromFile(resolvedPath);
87
+ const fileTitle = `Shared secrets (file: ${resolvedPath})`;
88
+ logKeyValueList('No shared secrets in file.', fileTitle, keyValues);
89
+ }
90
+
91
+ /** List user secrets and log key and value. */
92
+ function listUserSecrets() {
93
+ const userSecretsPath = path.join(pathsUtil.getAifabrixHome(), 'secrets.local.yaml');
94
+ const keyValues = listKeysAndValuesFromFile(userSecretsPath);
95
+ logKeyValueList('No user secrets.', 'User secrets', keyValues);
96
+ }
97
+
98
+ /**
99
+ * Handle secret list command. Lists key and value for each secret.
100
+ * @param {Object} options - Command options
101
+ * @param {boolean} [options.shared] - If true, list shared secrets (file or remote API)
102
+ * @returns {Promise<void>}
103
+ */
104
+ async function handleSecretsList(options) {
105
+ const isShared = options.shared || options['shared'] || false;
106
+
107
+ if (isShared) {
108
+ const generalSecretsPath = await getAifabrixSecretsPath();
109
+ if (!generalSecretsPath) {
110
+ throw new Error('Shared secrets not configured. Set aifabrix-secrets in config.yaml.');
111
+ }
112
+ await listSharedSecrets(generalSecretsPath);
113
+ } else {
114
+ listUserSecrets();
115
+ }
116
+ }
117
+
118
+ module.exports = { handleSecretsList };