UNPKG

zephyr-agent

Version:
446 lines (445 loc) • 16.7 kB
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