@aifabrix/builder 2.57.0 → 2.59.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/lib/agent-kit/codex-app-server.js +44 -0
- package/lib/agent-kit/init.js +4 -17
- package/lib/agent-kit/session-report.js +15 -10
- package/lib/agent-kit/setup-auth.js +131 -0
- package/lib/agent-kit/setup-context.js +118 -0
- package/lib/agent-kit/setup-github.js +70 -0
- package/lib/agent-kit/setup-managed.js +79 -0
- package/lib/agent-kit/setup-process.js +42 -0
- package/lib/agent-kit/setup-session.js +53 -0
- package/lib/agent-kit/setup-state.js +52 -0
- package/lib/agent-kit/setup-workspace.js +113 -0
- package/lib/agent-kit/setup.js +84 -0
- package/lib/agent-kit/start.js +4 -3
- package/lib/agent-kit/tmux-sessions.js +20 -3
- package/lib/api/dev.api.js +1 -1
- package/lib/api/miso-health.api.js +26 -0
- package/lib/api/types/dev.types.js +3 -3
- package/lib/app/deploy.js +0 -102
- package/lib/app/push.js +98 -41
- package/lib/build/standard-docker-build.js +13 -11
- package/lib/cli/index.js +4 -0
- package/lib/cli/setup-app.js +16 -5
- package/lib/cli/setup-dev-path-commands.js +53 -2
- package/lib/cli/setup-dev.js +7 -4
- package/lib/cli/setup-environment.js +17 -4
- package/lib/cli/setup-help.js +86 -0
- package/lib/cli/setup-onboarding.js +37 -0
- package/lib/cli/setup-utility-repair.js +7 -5
- package/lib/commands/agent-kit-help.js +21 -7
- package/lib/commands/agent-kit-sessions.js +126 -0
- package/lib/commands/agent-kit-setup.js +44 -0
- package/lib/commands/agent-kit.js +5 -79
- package/lib/commands/dev-init.js +21 -23
- package/lib/commands/dev-refresh.js +2 -2
- package/lib/commands/dev-registry.js +155 -0
- package/lib/commands/dev-show-display.js +10 -2
- package/lib/commands/governance-verify-external.js +12 -5
- package/lib/commands/repair-datasource.js +9 -9
- package/lib/commands/repair.js +45 -8
- package/lib/commands/setup-image-refresh.js +26 -0
- package/lib/commands/upload-qualification-client.js +56 -11
- package/lib/commands/upload-qualification-poll.js +51 -22
- package/lib/commands/wizard-dataplane.js +16 -52
- package/lib/core/config-attach-extensions.js +4 -1
- package/lib/datasource/datasource-validate-summary.js +8 -5
- package/lib/datasource/sync-defaults.js +53 -0
- package/lib/deployment/installation/azure-infra-stage.js +50 -9
- package/lib/deployment/installation/azure-preflight.js +63 -7
- package/lib/deployment/installation/azure-readiness.js +59 -7
- package/lib/deployment/installation/environment-auth.js +60 -20
- package/lib/deployment/installation/environment-stage.js +9 -5
- package/lib/deployment/installation/infra-catalog.js +57 -5
- package/lib/deployment/installation/infra-manifest.js +22 -2
- package/lib/deployment/installation/infra-projection.js +14 -3
- package/lib/deployment/installation/registry-auth-mode.js +121 -0
- package/lib/deployment/installation/stage-input.js +21 -6
- package/lib/deployment/marketplace-environment.js +26 -1
- package/lib/generator/builders.js +8 -9
- package/lib/generator/index.js +23 -12
- package/lib/integration-definition/apply.js +2 -2
- package/lib/internal/fs-real-sync.js +4 -1
- package/lib/programmatic/bearer-auth.js +3 -62
- package/lib/programmatic/builder-api-startup.js +51 -0
- package/lib/programmatic/builder-miso-runtime.js +167 -0
- package/lib/programmatic/openapi-descriptions.js +3 -3
- package/lib/programmatic/openapi-schema-descriptions-core.js +3 -3
- package/lib/programmatic/openapi-spec.js +2 -1
- package/lib/programmatic/route-handler-map.js +3 -2
- package/lib/schema/environment-deploy-request.schema.json +1 -1
- package/lib/schema/external-datasource.schema.json +20 -5
- package/lib/schema/infra-parameter.schema.json +1 -1
- package/lib/schema/infra.parameter.yaml +2 -1
- package/lib/schema/infrastructure-schema.json +200 -3
- package/lib/schema/installation-environment.schema.json +1 -1
- package/lib/schema/wizard-config.schema.json +1 -1
- package/lib/utils/app-register-config.js +2 -4
- package/lib/utils/build-resolve-image.js +2 -21
- package/lib/utils/cli-utils.js +8 -1
- package/lib/utils/config-paths.js +24 -29
- package/lib/utils/config-registry-preference.js +113 -0
- package/lib/utils/dev-init-ssh-merge.js +5 -4
- package/lib/utils/dev-user-groups.js +2 -1
- package/lib/utils/docker-build.js +53 -19
- package/lib/utils/docker-daemon-tls-ca.js +2 -2
- package/lib/utils/env-map.js +23 -6
- package/lib/utils/error-formatters/validation-errors.js +25 -5
- package/lib/utils/help-builder.js +4 -3
- package/lib/utils/image-tags.js +88 -0
- package/lib/utils/promote-dev-docker-image.js +6 -0
- package/lib/utils/registry-credentials.js +236 -0
- package/lib/utils/resolve-docker-image-ref.js +78 -9
- package/lib/utils/secrets-helpers.js +9 -1
- package/lib/utils/token-manager.js +29 -36
- package/lib/utils/variable-transformer.js +2 -4
- package/package.json +4 -2
- package/templates/agent-kit/instructions/AGENTKIT.md +39 -2
- package/templates/agent-kit/instructions/root.AGENTS.md +40 -1
- package/templates/agent-kit/skills/shared/hosts.md +23 -5
- package/templates/agent-kit/workspace/README.md +14 -1
- package/templates/applications/builder-api/application.yaml +1 -1
- package/templates/applications/dataplane/application.yaml +1 -1
- package/templates/applications/dataplane/env.template +1 -1
- package/templates/applications/miso-controller/application.yaml +1 -1
- package/templates/applications/miso-controller/env.template +1 -1
- package/templates/external-system/external-datasource.yaml.hbs +7 -6
- package/templates/marketplace/main.json +440 -82
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Image tag lists for build and push: comma-separated tags, exact `{version}` expansion from the
|
|
3
|
+
* application's `app.version`, Docker tag validation, and order-preserving de-duplication.
|
|
4
|
+
* All validation happens before any Docker call.
|
|
5
|
+
*
|
|
6
|
+
* @fileoverview Shared image tag resolution
|
|
7
|
+
* @author AI Fabrix Team
|
|
8
|
+
* @version 1.0.0
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
'use strict';
|
|
12
|
+
|
|
13
|
+
const { assertSafeTag } = require('./resolve-docker-image-ref');
|
|
14
|
+
|
|
15
|
+
const DEFAULT_TAG = 'latest';
|
|
16
|
+
const VERSION_PLACEHOLDER = '{version}';
|
|
17
|
+
/** Tags that are floating aliases themselves (a single explicit one gets no extra `latest`). */
|
|
18
|
+
const FLOATING_TAGS = Object.freeze(['latest', 'main']);
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @param {Object} [appConfig] - Application manifest (possibly wrapped in `variables`)
|
|
22
|
+
* @returns {string} Trimmed `app.version` or ''
|
|
23
|
+
*/
|
|
24
|
+
function readAppVersion(appConfig) {
|
|
25
|
+
const cfg = appConfig?.variables?.app ? appConfig.variables : appConfig;
|
|
26
|
+
const raw = cfg?.app?.version;
|
|
27
|
+
return raw === undefined || raw === null ? '' : String(raw).trim();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function expandTag(entry, version) {
|
|
31
|
+
if (entry === VERSION_PLACEHOLDER) {
|
|
32
|
+
if (!version) {
|
|
33
|
+
throw new Error('Tag {version} requires a non-empty app.version in the application manifest');
|
|
34
|
+
}
|
|
35
|
+
return version;
|
|
36
|
+
}
|
|
37
|
+
if (entry.includes('{') || entry.includes('}')) {
|
|
38
|
+
throw new Error(`Unsupported tag placeholder "${entry}"; only the exact token {version} is supported`);
|
|
39
|
+
}
|
|
40
|
+
return entry;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Resolves a tag option into a validated, de-duplicated list (first occurrence wins).
|
|
45
|
+
* @param {string|undefined} tagInput - `--tag` value (omitted means `latest`)
|
|
46
|
+
* @param {string} appVersion - Application `app.version`
|
|
47
|
+
* @returns {string[]}
|
|
48
|
+
* @throws {Error} On blank entries, unsupported placeholders, missing version or invalid tags
|
|
49
|
+
*/
|
|
50
|
+
function resolveImageTags(tagInput, appVersion) {
|
|
51
|
+
if (tagInput === undefined || tagInput === null || String(tagInput).trim() === '') {
|
|
52
|
+
return [DEFAULT_TAG];
|
|
53
|
+
}
|
|
54
|
+
const entries = String(tagInput).split(',').map((entry) => entry.trim());
|
|
55
|
+
if (entries.some((entry) => entry === '')) {
|
|
56
|
+
throw new Error(`Invalid tag list "${tagInput}": remove empty entries between commas`);
|
|
57
|
+
}
|
|
58
|
+
const resolved = [];
|
|
59
|
+
for (const entry of entries) {
|
|
60
|
+
const tag = assertSafeTag(expandTag(entry, String(appVersion || '').trim()));
|
|
61
|
+
if (!resolved.includes(tag)) resolved.push(tag);
|
|
62
|
+
}
|
|
63
|
+
return resolved;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Build plan: the first tag is the Docker build target; the rest are aliases of the same image.
|
|
68
|
+
* A single explicit non-floating tag keeps its compatibility `latest` alias.
|
|
69
|
+
* @param {string|undefined} tagInput - `--tag` value
|
|
70
|
+
* @param {string} appVersion - Application `app.version`
|
|
71
|
+
* @returns {{ primary: string, aliases: string[], tags: string[] }}
|
|
72
|
+
*/
|
|
73
|
+
function resolveBuildTagPlan(tagInput, appVersion) {
|
|
74
|
+
const tags = resolveImageTags(tagInput, appVersion);
|
|
75
|
+
const explicitSingle = tags.length === 1 && tagInput !== undefined && tagInput !== null && String(tagInput).trim() !== '';
|
|
76
|
+
if (explicitSingle && !FLOATING_TAGS.includes(tags[0])) {
|
|
77
|
+
tags.push(DEFAULT_TAG);
|
|
78
|
+
}
|
|
79
|
+
return { primary: tags[0], aliases: tags.slice(1), tags };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
module.exports = {
|
|
83
|
+
DEFAULT_TAG,
|
|
84
|
+
VERSION_PLACEHOLDER,
|
|
85
|
+
readAppVersion,
|
|
86
|
+
resolveImageTags,
|
|
87
|
+
resolveBuildTagPlan
|
|
88
|
+
};
|
|
@@ -88,6 +88,12 @@ function resolvePromoteVersionTag(options = {}) {
|
|
|
88
88
|
* @returns {string[]}
|
|
89
89
|
*/
|
|
90
90
|
function resolvePromoteBaseTags(options = {}) {
|
|
91
|
+
if (options.tagPlan && Array.isArray(options.tagPlan.tags) && options.tagPlan.tags.length > 0) {
|
|
92
|
+
// Shared build tag plan (comma list / {version}); every tag becomes a base alias.
|
|
93
|
+
const tags = [...options.tagPlan.tags];
|
|
94
|
+
if (!tags.includes(PROMOTE_FLOATING_TAG)) tags.push(PROMOTE_FLOATING_TAG);
|
|
95
|
+
return tags;
|
|
96
|
+
}
|
|
91
97
|
const versionTag = resolvePromoteVersionTag(options);
|
|
92
98
|
const tags = [versionTag];
|
|
93
99
|
if (versionTag !== PROMOTE_FLOATING_TAG) {
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host-scoped container registry credentials in the primary user secrets file.
|
|
3
|
+
*
|
|
4
|
+
* Keys are exactly `<registry>-username` and `<registry>-password` in the user's
|
|
5
|
+
* `secrets.local.yaml` (never the shared store). Values are encrypted through the existing
|
|
6
|
+
* local-secret writer and reach Docker only via `docker login --password-stdin`.
|
|
7
|
+
*
|
|
8
|
+
* @fileoverview Registry credential storage, lookup, removal, and per-command Docker login
|
|
9
|
+
* @author AI Fabrix Team
|
|
10
|
+
* @version 1.0.0
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
'use strict';
|
|
14
|
+
|
|
15
|
+
const fs = require('fs');
|
|
16
|
+
const yaml = require('js-yaml');
|
|
17
|
+
const config = require('../core/config');
|
|
18
|
+
const pathsUtil = require('./paths');
|
|
19
|
+
const { loadExistingSecrets, mergeSecretsIntoFile } = require('./secrets-generator');
|
|
20
|
+
const { saveLocalSecret } = require('./local-secrets');
|
|
21
|
+
const { decryptSecret, isEncrypted } = require('./secrets-encryption');
|
|
22
|
+
const { normalizeRegistryHost } = require('./config-registry-preference');
|
|
23
|
+
|
|
24
|
+
/** Hosts already authenticated during this CLI process. */
|
|
25
|
+
const authenticatedHosts = new Set();
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @param {string} registry - Registry host
|
|
29
|
+
* @returns {{ host: string, usernameKey: string, passwordKey: string }}
|
|
30
|
+
*/
|
|
31
|
+
function registryCredentialKeys(registry) {
|
|
32
|
+
const host = normalizeRegistryHost(registry);
|
|
33
|
+
return { host, usernameKey: `${host}-username`, passwordKey: `${host}-password` };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function hasValue(value) {
|
|
37
|
+
return typeof value === 'string' ? value.trim() !== '' : value !== undefined && value !== null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function readRawPair(keys) {
|
|
41
|
+
const file = pathsUtil.getPrimaryUserSecretsLocalPath();
|
|
42
|
+
const raw = loadExistingSecrets(file);
|
|
43
|
+
return { file, username: raw[keys.usernameKey], password: raw[keys.passwordKey] };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function partialPairError(keys) {
|
|
47
|
+
return new Error(
|
|
48
|
+
`Registry credentials for ${keys.host} are incomplete: both ${keys.usernameKey} and ${keys.passwordKey} ` +
|
|
49
|
+
`must be set. Run "aifabrix dev set-registry ${keys.host} --username <username> --password-stdin" ` +
|
|
50
|
+
`or "aifabrix dev set-registry ${keys.host} --clear-credentials".`
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Credential presence for a registry without decrypting anything.
|
|
56
|
+
* @param {string} registry - Registry host
|
|
57
|
+
* @returns {'complete'|'absent'|'partial'}
|
|
58
|
+
*/
|
|
59
|
+
function getRegistryCredentialStatus(registry) {
|
|
60
|
+
const keys = registryCredentialKeys(registry);
|
|
61
|
+
const pair = readRawPair(keys);
|
|
62
|
+
const count = [pair.username, pair.password].filter(hasValue).length;
|
|
63
|
+
if (count === 2) return 'complete';
|
|
64
|
+
return count === 0 ? 'absent' : 'partial';
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function decryptStoredValue(value, label) {
|
|
68
|
+
const text = String(value);
|
|
69
|
+
if (!isEncrypted(text)) return text;
|
|
70
|
+
const key = await config.getSecretsEncryptionKey();
|
|
71
|
+
if (!key) {
|
|
72
|
+
throw new Error(`Cannot decrypt the stored ${label}: the local secrets encryption key is not configured`);
|
|
73
|
+
}
|
|
74
|
+
return decryptSecret(text, key);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Loads a complete credential pair for the registry.
|
|
79
|
+
* @param {string} registry - Registry host
|
|
80
|
+
* @returns {Promise<{ username: string, password: string }|null>} null when no pair is stored
|
|
81
|
+
* @throws {Error} When only one of the two keys is stored
|
|
82
|
+
*/
|
|
83
|
+
async function loadRegistryCredentials(registry) {
|
|
84
|
+
const keys = registryCredentialKeys(registry);
|
|
85
|
+
const pair = readRawPair(keys);
|
|
86
|
+
const present = [pair.username, pair.password].filter(hasValue).length;
|
|
87
|
+
if (present === 0) return null;
|
|
88
|
+
if (present === 1) throw partialPairError(keys);
|
|
89
|
+
return {
|
|
90
|
+
username: await decryptStoredValue(pair.username, `${keys.host} username`),
|
|
91
|
+
password: await decryptStoredValue(pair.password, `${keys.host} password`)
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function removeKeysFromFile(file, keyNames) {
|
|
96
|
+
if (!fs.existsSync(file)) return false;
|
|
97
|
+
const data = loadExistingSecrets(file);
|
|
98
|
+
const present = keyNames.filter((key) => Object.prototype.hasOwnProperty.call(data, key));
|
|
99
|
+
if (present.length === 0) return false;
|
|
100
|
+
for (const key of present) delete data[key];
|
|
101
|
+
const { assertWritableSecretsPathForTests } = require('./aifabrix-test-runtime-guard');
|
|
102
|
+
assertWritableSecretsPathForTests(file);
|
|
103
|
+
fs.writeFileSync(file, yaml.dump(data, { indent: 2, lineWidth: -1, noRefs: true, sortKeys: false }), { mode: 0o600 });
|
|
104
|
+
// `mode` applies only on creation; keep an existing file owner-only too.
|
|
105
|
+
fs.chmodSync(file, 0o600);
|
|
106
|
+
return true;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function restorePair(keys, snapshot) {
|
|
110
|
+
const restore = {};
|
|
111
|
+
const remove = [];
|
|
112
|
+
for (const [key, value] of [[keys.usernameKey, snapshot.username], [keys.passwordKey, snapshot.password]]) {
|
|
113
|
+
if (value === undefined) remove.push(key);
|
|
114
|
+
else restore[key] = value;
|
|
115
|
+
}
|
|
116
|
+
removeKeysFromFile(snapshot.file, remove);
|
|
117
|
+
mergeSecretsIntoFile(snapshot.file, restore);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Saves a complete pair. On any write failure the previous entries are restored.
|
|
122
|
+
* @param {string} registry - Registry host
|
|
123
|
+
* @param {string} username - Registry username
|
|
124
|
+
* @param {string} password - Registry password or token
|
|
125
|
+
* @returns {Promise<{ host: string, rollback: Function }>} rollback restores the prior entries
|
|
126
|
+
*/
|
|
127
|
+
async function saveRegistryCredentials(registry, username, password) {
|
|
128
|
+
const keys = registryCredentialKeys(registry);
|
|
129
|
+
if (!hasValue(username) || !hasValue(password)) {
|
|
130
|
+
throw new Error('Registry username and password must both be non-empty');
|
|
131
|
+
}
|
|
132
|
+
const snapshot = readRawPair(keys);
|
|
133
|
+
await config.ensureSecretsEncryptionKey();
|
|
134
|
+
try {
|
|
135
|
+
await saveLocalSecret(keys.usernameKey, String(username));
|
|
136
|
+
await saveLocalSecret(keys.passwordKey, String(password));
|
|
137
|
+
} catch (error) {
|
|
138
|
+
restorePair(keys, snapshot);
|
|
139
|
+
throw new Error(`Could not save registry credentials for ${keys.host}; previous values were restored (${error.message})`);
|
|
140
|
+
}
|
|
141
|
+
authenticatedHosts.delete(keys.host);
|
|
142
|
+
return { host: keys.host, rollback: () => restorePair(keys, snapshot) };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Removes both credential keys for the registry (idempotent).
|
|
147
|
+
* @param {string} registry - Registry host
|
|
148
|
+
* @returns {boolean} true when at least one key was removed
|
|
149
|
+
*/
|
|
150
|
+
function removeRegistryCredentials(registry) {
|
|
151
|
+
const keys = registryCredentialKeys(registry);
|
|
152
|
+
authenticatedHosts.delete(keys.host);
|
|
153
|
+
return removeKeysFromFile(pathsUtil.getPrimaryUserSecretsLocalPath(), [keys.usernameKey, keys.passwordKey]);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Authenticates Docker to the registry once per process: stored pair first, then the optional
|
|
158
|
+
* fallback (for example Azure CLI for ACR). The password is sent only on stdin.
|
|
159
|
+
* @param {string} registry - Registry host
|
|
160
|
+
* @param {Object} [options] - Options
|
|
161
|
+
* @param {Function} [options.fallback] - async (host) => void when no pair is stored
|
|
162
|
+
* @param {Function} [options.login] - async (host, username, password) => void (tests)
|
|
163
|
+
* @returns {Promise<'cached'|'credentials'|'fallback'|'none'>}
|
|
164
|
+
*/
|
|
165
|
+
async function ensureRegistryLogin(registry, options = {}) {
|
|
166
|
+
const host = normalizeRegistryHost(registry);
|
|
167
|
+
if (authenticatedHosts.has(host)) return 'cached';
|
|
168
|
+
const credentials = await loadRegistryCredentials(host);
|
|
169
|
+
if (credentials) {
|
|
170
|
+
const login = options.login || require('../deployment/push').authenticateExternalRegistry;
|
|
171
|
+
await login(host, credentials.username, credentials.password);
|
|
172
|
+
authenticatedHosts.add(host);
|
|
173
|
+
return 'credentials';
|
|
174
|
+
}
|
|
175
|
+
if (typeof options.fallback === 'function') {
|
|
176
|
+
await options.fallback(host);
|
|
177
|
+
authenticatedHosts.add(host);
|
|
178
|
+
return 'fallback';
|
|
179
|
+
}
|
|
180
|
+
return 'none';
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Registry host of an image reference, or '' for Docker Hub style refs.
|
|
185
|
+
* @param {string} imageRef - Image reference (`host/repo:tag`, `repo:tag`, ...)
|
|
186
|
+
* @returns {string}
|
|
187
|
+
*/
|
|
188
|
+
function registryHostFromImageRef(imageRef) {
|
|
189
|
+
const ref = String(imageRef || '').trim();
|
|
190
|
+
const slash = ref.indexOf('/');
|
|
191
|
+
if (slash <= 0) return '';
|
|
192
|
+
const first = ref.slice(0, slash);
|
|
193
|
+
if (!(first.includes('.') || first.includes(':') || first === 'localhost')) return '';
|
|
194
|
+
try {
|
|
195
|
+
return normalizeRegistryHost(first);
|
|
196
|
+
} catch {
|
|
197
|
+
return '';
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Logs in once per distinct registry host of the given image refs when a stored pair exists.
|
|
203
|
+
* Hosts without stored credentials are pulled anonymously (or with the user's own Docker login).
|
|
204
|
+
* @param {string[]} imageRefs - Image references
|
|
205
|
+
* @returns {Promise<string[]>} Hosts logged in with stored credentials
|
|
206
|
+
*/
|
|
207
|
+
async function ensureLoginForImageRefs(imageRefs) {
|
|
208
|
+
const hosts = [...new Set((imageRefs || []).map(registryHostFromImageRef).filter(Boolean))];
|
|
209
|
+
const used = [];
|
|
210
|
+
for (const host of hosts) {
|
|
211
|
+
const outcome = await ensureRegistryLogin(host);
|
|
212
|
+
if (outcome === 'credentials') used.push(host);
|
|
213
|
+
}
|
|
214
|
+
return used;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Forgets cached logins (used before re-authentication and in tests).
|
|
219
|
+
* @param {string} [registry] - Host to forget; all when omitted
|
|
220
|
+
*/
|
|
221
|
+
function resetRegistryLoginCache(registry) {
|
|
222
|
+
if (registry) authenticatedHosts.delete(normalizeRegistryHost(registry));
|
|
223
|
+
else authenticatedHosts.clear();
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
module.exports = {
|
|
227
|
+
registryCredentialKeys,
|
|
228
|
+
getRegistryCredentialStatus,
|
|
229
|
+
loadRegistryCredentials,
|
|
230
|
+
saveRegistryCredentials,
|
|
231
|
+
removeRegistryCredentials,
|
|
232
|
+
ensureRegistryLogin,
|
|
233
|
+
ensureLoginForImageRefs,
|
|
234
|
+
registryHostFromImageRef,
|
|
235
|
+
resetRegistryLoginCache
|
|
236
|
+
};
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Resolve Docker repository path and tag from application config and optional CLI overrides.
|
|
3
|
-
* Precedence: --image (full ref), --registry CLI,
|
|
3
|
+
* Precedence: --image (full ref), --registry CLI, installation `registry` in config.yaml,
|
|
4
|
+
* image.registry in manifest, else unqualified name.
|
|
4
5
|
*
|
|
5
6
|
* For refs like localhost:5000/repo without an explicit tag, prefer --image with :tag (parse ambiguity).
|
|
6
7
|
*
|
|
@@ -12,6 +13,7 @@
|
|
|
12
13
|
'use strict';
|
|
13
14
|
|
|
14
15
|
const { parseImageOverride } = require('./parse-image-ref');
|
|
16
|
+
const { readInstallationRegistrySync } = require('./config-registry-preference');
|
|
15
17
|
const DOCKER_REPOSITORY_RE = /^[a-z0-9][a-z0-9._/-]*$/i;
|
|
16
18
|
const DOCKER_TAG_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
17
19
|
|
|
@@ -92,6 +94,73 @@ function normalizeDockerRegistryPrefix(registry) {
|
|
|
92
94
|
return t.slice(0, end);
|
|
93
95
|
}
|
|
94
96
|
|
|
97
|
+
/**
|
|
98
|
+
* Remove a registry host prefix from a Docker repository path.
|
|
99
|
+
* The first segment is a host when it contains `.` or `:` or equals `localhost`.
|
|
100
|
+
* @param {string} repository
|
|
101
|
+
* @returns {string}
|
|
102
|
+
*/
|
|
103
|
+
function stripRegistryHostFromRepository(repository) {
|
|
104
|
+
if (!repository || typeof repository !== 'string') {
|
|
105
|
+
return repository;
|
|
106
|
+
}
|
|
107
|
+
const parts = repository.split('/');
|
|
108
|
+
if (parts.length <= 1) {
|
|
109
|
+
return repository;
|
|
110
|
+
}
|
|
111
|
+
const host = parts[0];
|
|
112
|
+
if (host.includes('.') || host.includes(':') || host === 'localhost') {
|
|
113
|
+
return parts.slice(1).join('/');
|
|
114
|
+
}
|
|
115
|
+
return repository;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Effective registry for an application image.
|
|
120
|
+
* Precedence: `options.registry` (CLI) > installation `registry` > manifest `image.registry`.
|
|
121
|
+
* @param {Object} [appConfig] - Application manifest
|
|
122
|
+
* @param {Object} [options] - `registry` (CLI) and optional `installationRegistry` (preloaded; '' = none)
|
|
123
|
+
* @returns {{ registry: string, source: 'cli'|'installation'|'manifest'|'none' }}
|
|
124
|
+
*/
|
|
125
|
+
function resolveEffectiveRegistry(appConfig, options = {}) {
|
|
126
|
+
const opts = options || {};
|
|
127
|
+
const cli = normalizeDockerRegistryPrefix(opts.registry);
|
|
128
|
+
if (cli) return { registry: cli, source: 'cli' };
|
|
129
|
+
const installation = opts.installationRegistry !== undefined
|
|
130
|
+
? normalizeDockerRegistryPrefix(opts.installationRegistry)
|
|
131
|
+
: readInstallationRegistrySync();
|
|
132
|
+
if (installation) return { registry: installation, source: 'installation' };
|
|
133
|
+
const manifest = normalizeDockerRegistryPrefix(appConfig?.image?.registry ?? '');
|
|
134
|
+
if (manifest) return { registry: manifest, source: 'manifest' };
|
|
135
|
+
return { registry: '', source: 'none' };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Prefix a repository with a registry exactly once (replacing any existing host).
|
|
140
|
+
* @param {string} registry - Registry prefix ('' keeps the repository unchanged)
|
|
141
|
+
* @param {string} repository - Repository path, possibly already qualified
|
|
142
|
+
* @returns {string}
|
|
143
|
+
*/
|
|
144
|
+
function qualifyRepository(registry, repository) {
|
|
145
|
+
const prefix = normalizeDockerRegistryPrefix(registry);
|
|
146
|
+
if (!prefix) return repository;
|
|
147
|
+
return `${prefix}/${stripRegistryHostFromRepository(repository)}`;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Deployment/registration image reference (`registry/name:tag`) using the shared precedence.
|
|
152
|
+
* @param {Object} variables - Application manifest
|
|
153
|
+
* @param {string} fallbackName - Name when the manifest has no image name or app key
|
|
154
|
+
* @param {Object} [options] - Same options as {@link resolveEffectiveRegistry}
|
|
155
|
+
* @returns {string}
|
|
156
|
+
*/
|
|
157
|
+
function buildManifestImageReference(variables, fallbackName, options = {}) {
|
|
158
|
+
const imageName = variables?.image?.name || variables?.app?.key || fallbackName;
|
|
159
|
+
const tag = variables?.image?.tag || 'latest';
|
|
160
|
+
const { registry } = resolveEffectiveRegistry(variables, options);
|
|
161
|
+
return `${qualifyRepository(registry, imageName)}:${tag}`;
|
|
162
|
+
}
|
|
163
|
+
|
|
95
164
|
/**
|
|
96
165
|
* Effective image repository (may include registry prefix) and tag for Docker.
|
|
97
166
|
* @param {string} appName - Application name
|
|
@@ -118,13 +187,8 @@ function resolveDockerImageRef(appName, appConfig, runOptions = {}) {
|
|
|
118
187
|
|
|
119
188
|
const baseRepo = assertSafeRepository(getRepositoryPathFromConfig(appConfig, appName));
|
|
120
189
|
const imageTag = assertSafeTag(tagFromOpts || imageTagFromConfig(appConfig));
|
|
121
|
-
const
|
|
122
|
-
|
|
123
|
-
normalizeDockerRegistryPrefix(appConfig?.image?.registry ?? '');
|
|
124
|
-
if (prefix) {
|
|
125
|
-
return { imageName: assertSafeRepository(`${prefix}/${baseRepo}`), imageTag };
|
|
126
|
-
}
|
|
127
|
-
return { imageName: baseRepo, imageTag };
|
|
190
|
+
const { registry } = resolveEffectiveRegistry(appConfig, opts);
|
|
191
|
+
return { imageName: assertSafeRepository(qualifyRepository(registry, baseRepo)), imageTag };
|
|
128
192
|
}
|
|
129
193
|
|
|
130
194
|
/**
|
|
@@ -137,7 +201,7 @@ function resolveDockerImageRef(appName, appConfig, runOptions = {}) {
|
|
|
137
201
|
function resolveComposeImageOverrideString(appName, appConfig, options = {}) {
|
|
138
202
|
if (options.image) return options.image;
|
|
139
203
|
if (options.imageOverride) return options.imageOverride;
|
|
140
|
-
const runOpts = { registry: options.registry, image: undefined };
|
|
204
|
+
const runOpts = { registry: options.registry, installationRegistry: options.installationRegistry, image: undefined };
|
|
141
205
|
if (options.tag) {
|
|
142
206
|
const { imageName } = resolveDockerImageRef(appName, appConfig, runOpts);
|
|
143
207
|
return `${imageName}:${options.tag}`;
|
|
@@ -154,6 +218,11 @@ function resolveComposeImageOverrideString(appName, appConfig, options = {}) {
|
|
|
154
218
|
module.exports = {
|
|
155
219
|
resolveDockerImageRef,
|
|
156
220
|
resolveComposeImageOverrideString,
|
|
221
|
+
resolveEffectiveRegistry,
|
|
222
|
+
qualifyRepository,
|
|
223
|
+
buildManifestImageReference,
|
|
224
|
+
stripRegistryHostFromRepository,
|
|
225
|
+
assertSafeTag,
|
|
157
226
|
normalizeDockerRegistryPrefix,
|
|
158
227
|
getRepositoryPathFromConfig,
|
|
159
228
|
imageTagFromConfig
|
|
@@ -29,7 +29,15 @@ const { collectUniqueKvPathStrings, isCommentOrEmptyLine, activeEnvLineForKvScan
|
|
|
29
29
|
* @returns {string} Interpolated content
|
|
30
30
|
*/
|
|
31
31
|
function interpolateEnvVars(content, envVars) {
|
|
32
|
-
|
|
32
|
+
const hasOwn = (key) => Object.prototype.hasOwnProperty.call(envVars, key);
|
|
33
|
+
const interpolated = content.replace(
|
|
34
|
+
/\$\{DEV_USERNAME\}\.\$\{REMOTE_HOST\}/g,
|
|
35
|
+
(match) => {
|
|
36
|
+
if (!hasOwn('DEV_USERNAME') || !hasOwn('REMOTE_HOST')) return match;
|
|
37
|
+
return [envVars.DEV_USERNAME, envVars.REMOTE_HOST].filter(Boolean).join('.');
|
|
38
|
+
}
|
|
39
|
+
);
|
|
40
|
+
return interpolated.replace(/\$\{([A-Z_]+)\}/g, (match, envVar) => {
|
|
33
41
|
return envVars[envVar] || match;
|
|
34
42
|
});
|
|
35
43
|
}
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
|
|
12
12
|
const config = require('../core/config');
|
|
13
13
|
const logger = require('./logger');
|
|
14
|
+
const { maskSensitiveData } = require('./log-redaction');
|
|
14
15
|
const {
|
|
15
16
|
refreshClientToken,
|
|
16
17
|
refreshDeviceToken
|
|
@@ -99,55 +100,47 @@ async function getOrRefreshClientToken(environment, appName, controllerUrl) {
|
|
|
99
100
|
* @returns {Promise<{token: string, controller: string}|null>} Token and controller URL, or null if not available
|
|
100
101
|
*/
|
|
101
102
|
async function getOrRefreshDeviceToken(controllerUrl) {
|
|
102
|
-
// Try to get existing token
|
|
103
103
|
const tokenInfo = await getDeviceToken(controllerUrl);
|
|
104
|
-
|
|
105
|
-
if (!tokenInfo) {
|
|
106
|
-
return
|
|
104
|
+
if (!tokenInfo) return null;
|
|
105
|
+
if (reusableDeviceToken(tokenInfo) && !shouldRefreshToken(tokenInfo.expiresAt)) {
|
|
106
|
+
return reusableDeviceToken(tokenInfo);
|
|
107
107
|
}
|
|
108
|
-
|
|
109
|
-
// Check if token should be refreshed proactively (within 15 minutes of expiry)
|
|
110
|
-
// This ensures we refresh before Keycloak's SSO Session Idle timeout (30 minutes)
|
|
111
|
-
const needsRefresh = shouldRefreshToken(tokenInfo.expiresAt);
|
|
112
|
-
|
|
113
|
-
if (!needsRefresh) {
|
|
114
|
-
// Token is valid and doesn't need refresh yet
|
|
115
|
-
return {
|
|
116
|
-
token: tokenInfo.token,
|
|
117
|
-
controller: tokenInfo.controller
|
|
118
|
-
};
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
// Token needs refresh (expired or within 15 minutes of expiry)
|
|
122
|
-
// Try to refresh if refresh token exists
|
|
123
108
|
if (!tokenInfo.refreshToken) {
|
|
124
|
-
const
|
|
125
|
-
if (!
|
|
126
|
-
|
|
127
|
-
'Access token expired and no refresh token available. Please login again using: aifabrix login'
|
|
128
|
-
);
|
|
109
|
+
const usable = reusableDeviceToken(tokenInfo);
|
|
110
|
+
if (!usable) {
|
|
111
|
+
warnRefreshFailureOnce(controllerUrl, 'No usable access token and no refresh token available. Please login again using: aifabrix login');
|
|
129
112
|
}
|
|
130
|
-
return
|
|
113
|
+
return usable;
|
|
131
114
|
}
|
|
132
|
-
|
|
133
115
|
try {
|
|
134
116
|
const refreshed = await refreshDeviceToken(controllerUrl, tokenInfo.refreshToken);
|
|
135
|
-
return {
|
|
136
|
-
token: refreshed.token,
|
|
137
|
-
controller: controllerUrl
|
|
138
|
-
};
|
|
117
|
+
return { token: refreshed.token, controller: controllerUrl };
|
|
139
118
|
} catch (error) {
|
|
140
|
-
|
|
141
|
-
const
|
|
142
|
-
|
|
143
|
-
|
|
119
|
+
let message = error.message || String(error);
|
|
120
|
+
for (const secret of [tokenInfo.token, tokenInfo.refreshToken]) {
|
|
121
|
+
if (secret) message = message.split(secret).join('***');
|
|
122
|
+
}
|
|
123
|
+
message = maskSensitiveData(message);
|
|
124
|
+
if (message.includes('Refresh token has expired')) {
|
|
125
|
+
warnRefreshTokenExpiredOnce(controllerUrl, message);
|
|
144
126
|
} else {
|
|
145
|
-
warnRefreshFailureOnce(controllerUrl,
|
|
127
|
+
warnRefreshFailureOnce(controllerUrl, message);
|
|
146
128
|
}
|
|
147
|
-
return
|
|
129
|
+
return reusableDeviceToken(tokenInfo);
|
|
148
130
|
}
|
|
149
131
|
}
|
|
150
132
|
|
|
133
|
+
/**
|
|
134
|
+
* Reuse only a token whose actual expiry is known and still in the future.
|
|
135
|
+
* @param {Object} tokenInfo - Stored device token
|
|
136
|
+
* @returns {Object|null} Usable token, without refresh credentials
|
|
137
|
+
*/
|
|
138
|
+
function reusableDeviceToken(tokenInfo) {
|
|
139
|
+
const expiry = typeof tokenInfo.expiresAt === 'string' ? Date.parse(tokenInfo.expiresAt) : NaN;
|
|
140
|
+
return tokenInfo.token && Number.isFinite(expiry) && expiry > Date.now()
|
|
141
|
+
? { token: tokenInfo.token, controller: tokenInfo.controller } : null;
|
|
142
|
+
}
|
|
143
|
+
|
|
151
144
|
/**
|
|
152
145
|
* Validates deployment auth parameters
|
|
153
146
|
* @function validateDeploymentAuthParams
|
|
@@ -126,10 +126,8 @@ function transformFlatStructure(variables, appName) {
|
|
|
126
126
|
* @returns {string} Image reference string
|
|
127
127
|
*/
|
|
128
128
|
function buildImageReference(variables, appName) {
|
|
129
|
-
const
|
|
130
|
-
|
|
131
|
-
const tag = variables.image?.tag || 'latest';
|
|
132
|
-
return registry ? `${registry}/${imageName}:${tag}` : `${imageName}:${tag}`;
|
|
129
|
+
const { buildManifestImageReference } = require('./resolve-docker-image-ref');
|
|
130
|
+
return buildManifestImageReference(variables, appName);
|
|
133
131
|
}
|
|
134
132
|
|
|
135
133
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aifabrix/builder",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.59.0",
|
|
4
4
|
"description": "AI Fabrix Builder — CLI and developer scripts (pnpm + af)",
|
|
5
5
|
"main": "lib/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
"agent-skills:check": "node scripts/pnpm/agent-skills-sync.mjs --check",
|
|
29
29
|
"agent-skills:sync:codex": "node scripts/pnpm/agent-skills-sync.mjs --codex-home",
|
|
30
30
|
"agent-skills:test": "node --test scripts/pnpm/agent-skills-sync.test.mjs",
|
|
31
|
+
"dev-deploy:test": "node --test scripts/pnpm/dev-deploy.test.mjs",
|
|
31
32
|
"check": "npm run lint && npm run test",
|
|
32
33
|
"lint": "eslint . --ext .js",
|
|
33
34
|
"lint:fix": "eslint . --ext .js --fix",
|
|
@@ -130,7 +131,8 @@
|
|
|
130
131
|
}
|
|
131
132
|
},
|
|
132
133
|
"dependencies": {
|
|
133
|
-
"@aifabrix/miso-client": "4.
|
|
134
|
+
"@aifabrix/miso-client": "4.23.1",
|
|
135
|
+
"@azure/identity": "4.11.1",
|
|
134
136
|
"adm-zip": "^0.6.0",
|
|
135
137
|
"ajv": "^8.20.0",
|
|
136
138
|
"ajv-formats": "^3.0.1",
|
|
@@ -2,6 +2,35 @@
|
|
|
2
2
|
|
|
3
3
|
Builder-owned. `aifabrix agent-kit update` refreshes this file. If workspace `README.md` is missing, install/update creates it. An existing README is never overwritten.
|
|
4
4
|
|
|
5
|
+
## Onboard
|
|
6
|
+
|
|
7
|
+
On an organisation-managed SSH workspace with a provisioned `config.yaml`:
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
af onboarding
|
|
11
|
+
af onboarding --coding claude # or codex or cursor for this run
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
For Claude, onboarding starts or reuses a detached tmux session and prints
|
|
15
|
+
its attach command. For Codex or Cursor, open the printed workspace in the
|
|
16
|
+
selected coding client over SSH, then say “Hi”. `--coding` selects a platform
|
|
17
|
+
for one run without changing the provisioned default.
|
|
18
|
+
The agent reads this workspace's `AGENTS.md` and `README.md`, checks which
|
|
19
|
+
packages already exist, and guides you to a first delivery task.
|
|
20
|
+
|
|
21
|
+
On a Linux or macOS terminal with Builder, Git, and tmux:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
aifabrix agent-kit setup https://controller.example.com
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
This prepares a local workspace under the configured AI Fabrix work directory and starts
|
|
28
|
+
Codex. Select Claude with `--code claude`. GitHub is optional: add `--repo owner/name`
|
|
29
|
+
to reuse a matching local checkout, clone a remote, or create a private repository.
|
|
30
|
+
The repository name becomes the folder name; unresolved access stops safely.
|
|
31
|
+
Setup preserves existing agent permissions. Change environment afterward with
|
|
32
|
+
`aifabrix auth --set-environment <environment>`.
|
|
33
|
+
|
|
5
34
|
## Install
|
|
6
35
|
|
|
7
36
|
From this git repository (Builder CLI on PATH):
|
|
@@ -57,7 +86,12 @@ curl -fsSL https://claude.ai/install.sh | bash
|
|
|
57
86
|
claude auth login
|
|
58
87
|
```
|
|
59
88
|
|
|
60
|
-
**
|
|
89
|
+
**Optional terminal session in tmux:** run from this workspace so closing the
|
|
90
|
+
terminal does not kill Claude. The command enables Remote Control and this
|
|
91
|
+
workspace's Claude auto-approval by default. Remote Control is the **same**
|
|
92
|
+
session on this PC, [claude.ai/code](https://claude.ai/code), and the Claude
|
|
93
|
+
mobile app (Code). That is not `claude --cloud` (a separate cloud VM). Claude
|
|
94
|
+
`--tmux` is for worktrees only.
|
|
61
95
|
|
|
62
96
|
```bash
|
|
63
97
|
aifabrix agent-kit start claude
|
|
@@ -76,7 +110,10 @@ npm install -g @openai/codex
|
|
|
76
110
|
codex login
|
|
77
111
|
```
|
|
78
112
|
|
|
79
|
-
**
|
|
113
|
+
**Optional terminal session in tmux:** run from this workspace so a terminal
|
|
114
|
+
Codex session stays after SSH disconnects. This session is separate from a
|
|
115
|
+
Codex app chat opened over SSH. The command requests native Codex Remote
|
|
116
|
+
Control and writes workspace auto-approval by default:
|
|
80
117
|
|
|
81
118
|
```bash
|
|
82
119
|
aifabrix agent-kit start codex
|