zephyr-agent
Version:
Zephyr plugin agent
446 lines (445 loc) • 16.7 kB
JavaScript
import "node:module";
import { isCI } from "ci-info";
import { execFile } from "node:child_process";
import { sep } from "node:path";
import { promisify } from "node:util";
import { ZE_API_ENDPOINT, ze_api_gateway } from "zephyr-edge-contract";
import { TOKEN_EXPIRY } from "../auth/auth-flags.mjs";
import { checkAuth, isTokenStillValid } from "../auth/login.mjs";
import { ZeErrors, ZephyrError } from "../errors/index.mjs";
import { makeRequest } from "../http/http-request.mjs";
import { ze_log } from "../logging/index.mjs";
import { logFn } from "../logging/ze-log-event.mjs";
import { hasSecretToken as secret_token_mjs_hasSecretToken } from "../node-persist/secret-token.mjs";
import { getToken } from "../node-persist/token.mjs";
import { detectCIBranch } from "./ci-branch-detection.mjs";
import { getGitProviderInfo } from "./git-provider-utils.mjs";
import { detectMonorepo, getMonorepoRootPackageJson } from "./detect-monorepo.mjs";
import { getPackageJson } from "./ze-util-read-package-json.mjs";
import { getZephyrConfig, resolveZephyrContextDirectory } from "./zephyr-config.mjs";
const ze_util_get_git_info_execFile = promisify(execFile);
async function runGit(args, context) {
try {
const { stdout } = await ze_util_get_git_info_execFile('git', args, {
cwd: context
});
const trimmed = stdout.trim();
return '' === trimmed ? null : trimmed;
} catch {
return null;
}
}
function generateBranchName(context, userId) {
const timestamp = new Date().toISOString().replace(/\D/g, '');
const userSuffix = userId ? `-${userId.slice(0, 8)}` : '';
return `${context}${userSuffix}-${timestamp}`;
}
async function getGitInfo(context, zephyrConfig = getZephyrConfig(context)) {
try {
return await gatherGitInfo(resolveZephyrContextDirectory(context), zephyrConfig);
} catch (error) {
throw ZephyrError.withContext(error, 'resolve-git-metadata');
}
}
async function gatherGitInfo(context, zephyrConfig) {
const hasToken = secret_token_mjs_hasSecretToken();
try {
const { name, email, remoteOrigin, branch, commit, tags, stdout } = await loadGitInfo(hasToken, context);
if (!hasToken && (!name || !email)) throw new ZephyrError(ZeErrors.ERR_NO_GIT_USERNAME_EMAIL, {
data: {
stdout
}
});
const app = resolveApplicationIdentity(remoteOrigin, stdout, zephyrConfig);
const gitInfo = {
git: {
name,
email,
branch,
commit,
tags
},
app
};
ze_log.git('Loaded: git info', gitInfo);
return gitInfo;
} catch (error) {
if (isCI) throw new ZephyrError(ZeErrors.ERR_NO_GIT_INFO, {
message: 'Stable Git branch and commit information is required in CI environments',
cause: error
});
ze_log.git('Git repository not found, falling back to global git config');
try {
const globalGitInfo = await loadGlobalGitInfo(context, zephyrConfig);
return globalGitInfo;
} catch {
logFn('warn', 'Global git config not found, checking authenticated user metadata');
ze_log.git('Global git config not found, checking authenticated user metadata');
const fallbackInfo = await getFallbackGitInfo(context, zephyrConfig);
return fallbackInfo;
}
}
}
const NO_GIT_COMMIT = 'no-git-commit';
async function loadGitInfo(hasSecretToken, context) {
const automated = isCI || hasSecretToken;
const [name, email, remoteOrigin, gitBranch, rawCommit, tags] = await Promise.all([
automated ? runGit([
'log',
'-1',
'--pretty=format:%an'
], context) : runGit([
'config',
'user.name'
], context),
automated ? runGit([
'log',
'-1',
'--pretty=format:%ae'
], context) : runGit([
'config',
'user.email'
], context),
runGit([
'config',
'--get',
'remote.origin.url'
], context),
loadGitBranch(context),
runGit([
'rev-parse',
'HEAD'
], context),
loadGitTags(context)
]);
if (null === name && null === email && null === gitBranch && null === rawCommit) throw new ZephyrError(ZeErrors.ERR_NO_GIT_INFO, {
message: 'Not a git repository',
data: {
context
}
});
const commit = rawCommit ?? NO_GIT_COMMIT;
const stdout = [
name,
email,
remoteOrigin,
gitBranch,
commit
].join('\n');
let branch = gitBranch;
if (isCI && ('HEAD' === gitBranch || !gitBranch)) {
const ciInfo = detectCIBranch();
if (ciInfo.branch) {
branch = ciInfo.branch;
ze_log.git(`Detected branch from ${ciInfo.platform} environment variables: ${branch}`, {
platform: ciInfo.platform,
branch,
isPR: ciInfo.isPR,
gitBranch
});
} else ze_log.git(`Branch detection in CI failed. Git returned: "${gitBranch}", no CI env vars available`, {
platform: ciInfo.platform,
gitBranch
});
}
if (isCI && (!branch || 'HEAD' === branch)) throw new ZephyrError(ZeErrors.ERR_NO_GIT_INFO, {
message: 'Git branch is required in CI environments. Configure the CI provider branch variables or check out a named branch.'
});
if (isCI && (!commit || commit === NO_GIT_COMMIT)) throw new ZephyrError(ZeErrors.ERR_NO_GIT_INFO, {
message: 'Git commit hash is required in CI environments. Ensure this repository has commit history.'
});
return {
name: name ?? '',
email: email ?? '',
remoteOrigin: remoteOrigin ?? '',
branch: branch ?? '',
commit,
tags,
stdout
};
}
async function loadGitBranch(context) {
const symbolic = await runGit([
'symbolic-ref',
'--short',
'HEAD'
], context);
if (symbolic) return symbolic;
return runGit([
'rev-parse',
'--abbrev-ref',
'HEAD'
], context);
}
async function loadGitTags(context) {
const stdout = await runGit([
'tag',
'--points-at',
'HEAD'
], context);
return stdout ? stdout.split('\n').filter(Boolean) : [];
}
function hasConfiguredApp(config) {
return Boolean(config.org && config.project);
}
function resolveApplicationIdentity(remoteOrigin, stdout, config) {
if (hasConfiguredApp(config)) return {
org: config.org,
project: config.project
};
const inferred = parseGitUrl(remoteOrigin, stdout);
return {
org: config.org ?? inferred.org,
project: config.project ?? inferred.project
};
}
function applyConfiguredApp(app, config) {
return {
org: config.org ?? app.org,
project: config.project ?? app.project
};
}
function parseGitUrl(remoteOrigin, stdout) {
if (!remoteOrigin) throw new ZephyrError(ZeErrors.ERR_GIT_REMOTE_ORIGIN, {
data: {
stdout
}
});
try {
const gitInfo = getGitProviderInfo(remoteOrigin);
ze_log.git(`Git provider detected: ${gitInfo.provider}`, {
provider: gitInfo.provider,
owner: gitInfo.owner,
project: gitInfo.project,
isEnterprise: gitInfo.isEnterprise
});
return {
org: gitInfo.owner,
project: gitInfo.project
};
} catch (cause) {
throw new ZephyrError(ZeErrors.ERR_NO_GIT_INFO, {
message: stdout,
cause,
data: {
stdout
}
});
}
}
async function loadGlobalGitInfo(context, zephyrConfig) {
try {
const [globalName, globalEmail] = await Promise.all([
runGit([
'config',
'--global',
'user.name'
], context),
runGit([
'config',
'--global',
'user.email'
], context)
]);
const name = globalName ?? '';
const email = globalEmail ?? '';
if (!name && !email) {
logFn('warn', 'Global git config incomplete: both name and email are missing. Falling back to API user info.');
throw new ZephyrError(ZeErrors.ERR_NO_GIT_INFO, {
message: 'Global git config incomplete: both name and email are missing'
});
}
if (!name) {
logFn('warn', 'Global git config incomplete: user name is missing. Run: git config --global user.name "Your Name". Falling back to API user info.');
throw new ZephyrError(ZeErrors.ERR_NO_GIT_INFO, {
message: 'Global git config incomplete: user name is missing'
});
}
if (!email) {
logFn('warn', 'Global git config incomplete: user email is missing. Run: git config --global user.email "your@email.com". Falling back to API user info.');
throw new ZephyrError(ZeErrors.ERR_NO_GIT_INFO, {
message: 'Global git config incomplete: user email is missing'
});
}
const app = hasConfiguredApp(zephyrConfig) ? {
org: zephyrConfig.org,
project: zephyrConfig.project
} : applyConfiguredApp(await getAppNamingFromPackageJson(name, context, zephyrConfig), zephyrConfig);
const gitInfo = {
git: {
name,
email,
branch: generateBranchName('global-git'),
commit: NO_GIT_COMMIT,
tags: []
},
app
};
ze_log.git('Using global git config (no local repository)', gitInfo);
if (!hasConfiguredApp(zephyrConfig)) {
logFn('warn', 'Configuration accepted for THIS BUILD ONLY.');
logFn('warn', 'This manual configuration will NOT work for production deployments.');
logFn('warn', 'Zephyr REQUIRES a proper git repository with remote origin to function correctly.');
logFn('warn', 'Please set up git before your next deployment: https://docs.zephyr-cloud.io');
}
return gitInfo;
} catch (error) {
throw new ZephyrError(ZeErrors.ERR_NO_GIT_INFO, {
message: 'Failed to load global git config',
cause: error
});
}
}
async function getUserInfoFromAPI() {
try {
let authenticationAttempted = false;
const authenticate = async ()=>{
authenticationAttempted = true;
await checkAuth();
};
while(true){
const token = await getToken();
if (!token || !isTokenStillValid(token, TOKEN_EXPIRY.SHORT_VALIDITY_CHECK_SEC)) {
if (authenticationAttempted) throw new ZephyrError(ZeErrors.ERR_AUTH_ERROR, {
message: 'No valid authentication token was available after login.'
});
await authenticate();
continue;
}
const [ok, cause, response] = await makeRequest({
path: ze_api_gateway.user_info,
base: ZE_API_ENDPOINT(),
query: {}
}, {
headers: {
Authorization: `Bearer ${token}`
},
credentialToken: token
});
if (!ok) {
if (!authenticationAttempted && !secret_token_mjs_hasSecretToken() && ZephyrError.is(cause, ZeErrors.ERR_AUTH_ERROR)) {
await authenticate();
continue;
}
throw cause;
}
const userData = response.value;
ze_log.git('Retrieved user info from API:', {
name: userData.name,
email: userData.email
});
return userData;
}
} catch (error) {
throw ZephyrError.withContext(error, 'get-user-info');
}
}
async function getFallbackGitInfo(context, zephyrConfig) {
let userInfo;
try {
userInfo = await getUserInfoFromAPI();
} catch (error) {
ze_log.git('Failed to get user info from API:', error);
throw error;
}
const gitName = userInfo.name;
const gitEmail = userInfo.email;
if (isCI && !hasConfiguredApp(zephyrConfig)) throw new ZephyrError(ZeErrors.ERR_NO_GIT_INFO, {
message: 'Git repository information is required in CI environments. Please ensure your CI workflow includes git repository with proper remote origin.'
});
const app = hasConfiguredApp(zephyrConfig) ? {
org: zephyrConfig.org,
project: zephyrConfig.project
} : applyConfiguredApp(await getAppNamingFromPackageJson(userInfo.name, context, zephyrConfig), zephyrConfig);
const gitInfo = {
git: {
name: gitName,
email: gitEmail,
branch: generateBranchName('no-git', userInfo.id),
commit: `fallback-deployment-${Date.now()}`,
tags: [
`deployed-by:${userInfo.id}`
]
},
app
};
ze_log.git('Using fallback git info (git not available)', gitInfo);
if (!hasConfiguredApp(zephyrConfig)) {
logFn('warn', `Using organization "${app.org}" from authenticated user.`);
logFn('warn', 'Configuration accepted for THIS BUILD ONLY.');
logFn('warn', 'This manual configuration will NOT work for production deployments.');
logFn('warn', 'Zephyr REQUIRES a proper git repository with remote origin to function correctly.');
logFn('warn', 'Please set up git before your next deployment: https://docs.zephyr-cloud.io');
}
return gitInfo;
}
function sanitizeName(name) {
return name.replace(/[^a-zA-Z0-9-]/g, '-').toLowerCase();
}
function getCurrentDirectoryName(context) {
const dirName = context.split(sep).pop() || 'untitled-project';
return sanitizeName(dirName);
}
async function getAppNamingFromPackageJson(tokenUserName, context, zephyrConfig) {
try {
const packageJson = await getPackageJson(context, zephyrConfig);
const packageName = packageJson.name;
if (!tokenUserName) throw new ZephyrError(ZeErrors.ERR_NO_GIT_INFO, {
message: 'Unable to determine organization: no authenticated user found. Please ensure you are logged in with a valid token.'
});
const org = sanitizeName(tokenUserName);
if (packageName.includes('@')) {
const [scope, name] = packageName.split('/');
return {
org,
project: scope.replace('@', ''),
app: name
};
}
const monorepoInfo = await detectMonorepo(context);
if ('none' !== monorepoInfo.type) {
const rootPackageJson = await getMonorepoRootPackageJson(monorepoInfo.root);
if (rootPackageJson && rootPackageJson['name']) {
const rootName = rootPackageJson['name'];
if (rootName.includes('@')) {
const [scope] = rootName.split('/');
return {
org,
project: scope.replace('@', ''),
app: packageName
};
}
return {
org,
project: sanitizeName(rootName),
app: packageName
};
}
{
const rootDirName = monorepoInfo.root.split(sep).pop() || 'monorepo';
return {
org,
project: sanitizeName(rootDirName),
app: packageName
};
}
}
return {
org,
project: packageName,
app: packageName
};
} catch {
const dirName = getCurrentDirectoryName(context);
if (!tokenUserName) throw new ZephyrError(ZeErrors.ERR_NO_GIT_INFO, {
message: 'Unable to determine organization: no authenticated user found and no package.json available. Please ensure you are logged in with a valid token.'
});
return {
org: sanitizeName(tokenUserName),
project: dirName,
app: dirName
};
}
}
export { getGitInfo };
//# sourceMappingURL=ze-util-get-git-info.mjs.map