@mesh-tech/mesh-cli
Version:
CLI for Mesh platform development utilities
1,012 lines (1,011 loc) • 41.7 kB
JavaScript
import * as http from "http";
import * as crypto from "crypto";
import * as fs from "fs";
import * as path from "path";
import { execFileSync } from "child_process";
import { logError, logInfo, logSuccess, logWarn } from "../utils/log.js";
import { MeshCliError } from "../utils/errors.js";
import { assumeRoleCredentials, assumeRoleWithWebIdentity, describeAssumeFailure, probeAwsIdentity, renderCredentialProcessProfile, tokenIssuer, resolveStableMeshBin, selectRoleForCaller, toCredentialProcessJson, upsertManagedAwsConfigSection, } from "../utils/aws-auth.js";
import { firstPartyDomainFor } from "../utils/first-party-contexts.js";
import { tailscaleBackendState } from "./vpn/index.js";
const CONFIG_DIR = path.join(process.env.XDG_CONFIG_HOME ?? path.join(process.env.HOME ?? "~", ".config"), "mesh");
const CONFIG_FILE = path.join(CONFIG_DIR, "config.json");
const CREDENTIALS_FILE = path.join(CONFIG_DIR, "credentials.json");
const REDIRECT_PORT = 9876;
const REDIRECT_URI = `http://localhost:${REDIRECT_PORT}/callback`;
const SCOPES = "openid email profile offline_access urn:zitadel:iam:org:project:id:zitadel:aud";
function readConfig() {
if (!fs.existsSync(CONFIG_FILE))
return {};
try {
return JSON.parse(fs.readFileSync(CONFIG_FILE, "utf-8"));
}
catch {
return {};
}
}
export function writeContextConfig(context, config) {
const existing = readConfig();
existing[context] = config;
fs.mkdirSync(CONFIG_DIR, { recursive: true });
fs.writeFileSync(CONFIG_FILE, JSON.stringify(existing, null, 2));
}
export function clearContextConfig(context) {
const existing = readConfig();
if (!(context in existing))
return;
delete existing[context];
fs.mkdirSync(CONFIG_DIR, { recursive: true });
fs.writeFileSync(CONFIG_FILE, JSON.stringify(existing, null, 2));
}
function getContextConfig(context) {
const config = readConfig();
return config[context] ?? null;
}
function parseTenantEnv(context) {
const parts = context.split(".");
if (parts.length !== 2 || !parts[0] || !parts[1])
return null;
return { tenant: parts[0], env: parts[1] };
}
async function discoverConfigFromSsm(context) {
const parsed = parseTenantEnv(context);
if (!parsed)
return null;
const { tenant, env } = parsed;
const ssmPath = `/mesh-platform/${tenant}/${env}/platform/zitadel`;
logInfo(`Attempting SSM discovery from ${ssmPath}...`);
try {
const { SSMClient, GetParameterCommand } = await import("@aws-sdk/client-ssm");
const region = process.env.AWS_REGION ?? process.env.AWS_DEFAULT_REGION ?? "us-east-2";
const ssm = new SSMClient({ region });
const resp = await ssm.send(new GetParameterCommand({ Name: ssmPath }));
const raw = resp.Parameter?.Value;
if (!raw) {
logWarn(`SSM parameter ${ssmPath} has no value`);
return null;
}
const data = JSON.parse(raw);
const issuer = data.endpoint;
const clientId = data.cliClientId;
if (typeof issuer !== "string" || !issuer) {
logWarn("SSM zitadel entry missing 'endpoint' field");
return null;
}
if (typeof clientId !== "string" || !clientId) {
logWarn("SSM zitadel entry missing 'cliClientId' field.\n" +
" The platform needs to be deployed with an updated ZitadelPlatformIdentity\n" +
" that exports cliClientId. Until then, contact your platform admin.");
return null;
}
const config = { issuer, clientId };
if (typeof data.vpn === "string")
config.vpn = data.vpn;
if (typeof data.vpnJoinBroker === "string")
config.vpnJoinBroker = data.vpnJoinBroker;
if (typeof data.registryBroker === "string")
config.registryBroker = data.registryBroker;
writeContextConfig(context, config);
logSuccess(`Discovered platform configuration for ${context} via SSM`);
return config;
}
catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (message.includes("ExpiredToken") || message.includes("credentials")) {
logWarn(`SSM discovery failed: AWS credentials expired or unavailable.`);
if (firstPartyDomainFor(context) || context.split(".").length - 1 >= 2) {
logInfo(` Falling back to anonymous HTTPS discovery — no AWS needed.`);
}
else {
logInfo(` If you have an AWS account: aws sso login --profile <profile>`);
logInfo(` Otherwise use the platform's full domain, e.g. mesh login dev.<tenant>.meshtech.io`);
}
}
else if (message.includes("ParameterNotFound")) {
logWarn(`SSM parameter not found: ${ssmPath}`);
logInfo(" This platform context may not be deployed.");
}
else {
logWarn(`SSM discovery failed: ${message}`);
}
return null;
}
}
async function discoverConfig(domain, contextKey = domain, opts = {}) {
const url = `https://cli.${domain}/.well-known/mesh.json`;
if (!opts.quiet)
logInfo(`Attempting discovery from ${url}...`);
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
const resp = await fetch(url, { signal: controller.signal });
clearTimeout(timeout);
if (!resp.ok) {
logWarn(`Discovery endpoint returned ${resp.status}`);
return null;
}
const data = (await resp.json());
const issuer = data.issuer;
const clientId = data.clientId;
if (typeof issuer !== "string" || !issuer || typeof clientId !== "string" || !clientId) {
logWarn("Discovery endpoint returned invalid config (missing issuer or clientId)");
return null;
}
const config = { issuer, clientId };
if (typeof data.vpn === "string")
config.vpn = data.vpn;
if (typeof data.vpnJoinBroker === "string")
config.vpnJoinBroker = data.vpnJoinBroker;
if (typeof data.registryBroker === "string")
config.registryBroker = data.registryBroker;
if (!opts.quiet) {
writeContextConfig(contextKey, config);
logSuccess(`Discovered platform configuration for ${contextKey}`);
}
return config;
}
catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (message.includes("abort")) {
logWarn("Discovery timed out");
}
else {
logWarn(`Discovery failed: ${message}`);
}
return null;
}
}
export function discoverConfigFromWellKnown(domain, contextKey = domain, opts = {}) {
return discoverConfig(domain, contextKey, opts);
}
function readAllCredentials() {
if (!fs.existsSync(CREDENTIALS_FILE))
return {};
try {
return JSON.parse(fs.readFileSync(CREDENTIALS_FILE, "utf-8"));
}
catch {
return {};
}
}
function readCredentials(context) {
return readAllCredentials()[context] ?? null;
}
let atomicWriteCounter = 0;
export function atomicWriteFileSync(path, data, mode) {
const tmpPath = `${path}.${process.pid}.${atomicWriteCounter++}.tmp`;
try {
fs.writeFileSync(tmpPath, data, { mode });
fs.renameSync(tmpPath, path);
}
catch (err) {
try {
fs.unlinkSync(tmpPath);
}
catch {
}
throw err;
}
}
function writeCredentials(context, creds) {
fs.mkdirSync(CONFIG_DIR, { recursive: true });
const all = readAllCredentials();
all[context] = creds;
atomicWriteFileSync(CREDENTIALS_FILE, JSON.stringify(all, null, 2), 0o600);
}
function clearCredentials(context) {
const all = readAllCredentials();
delete all[context];
fs.mkdirSync(CONFIG_DIR, { recursive: true });
fs.writeFileSync(CREDENTIALS_FILE, JSON.stringify(all, null, 2), {
mode: 0o600,
});
}
function base64url(buffer) {
return buffer.toString("base64url");
}
function generateCodeVerifier() {
return base64url(crypto.randomBytes(32));
}
function generateCodeChallenge(verifier) {
return base64url(crypto.createHash("sha256").update(verifier).digest());
}
function decodeJwtPayload(token) {
const parts = token.split(".");
if (parts.length !== 3)
throw new Error("Invalid JWT");
return JSON.parse(Buffer.from(parts[1], "base64url").toString());
}
async function exchangeCode(issuer, clientId, code, codeVerifier) {
const body = new URLSearchParams({
grant_type: "authorization_code",
code,
redirect_uri: REDIRECT_URI,
client_id: clientId,
code_verifier: codeVerifier,
});
const resp = await fetch(`${issuer}/oauth/v2/token`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: body.toString(),
});
if (!resp.ok) {
const text = await resp.text();
throw new Error(`Token exchange failed (${resp.status}): ${text}`);
}
return resp.json();
}
async function refreshTokens(issuer, clientId, refreshToken) {
const body = new URLSearchParams({
grant_type: "refresh_token",
refresh_token: refreshToken,
client_id: clientId,
});
const resp = await fetch(`${issuer}/oauth/v2/token`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: body.toString(),
});
if (!resp.ok) {
throw new Error(`Token refresh failed (${resp.status})`);
}
return resp.json();
}
function login(context, config) {
return new Promise((resolve, reject) => {
const codeVerifier = generateCodeVerifier();
const codeChallenge = generateCodeChallenge(codeVerifier);
const state = base64url(crypto.randomBytes(16));
const authUrl = new URL(`${config.issuer}/oauth/v2/authorize`);
authUrl.searchParams.set("client_id", config.clientId);
authUrl.searchParams.set("redirect_uri", REDIRECT_URI);
authUrl.searchParams.set("response_type", "code");
authUrl.searchParams.set("scope", SCOPES);
authUrl.searchParams.set("code_challenge", codeChallenge);
authUrl.searchParams.set("code_challenge_method", "S256");
authUrl.searchParams.set("state", state);
let timeoutId;
const server = http.createServer(async (req, res) => {
try {
const url = new URL(req.url ?? "/", `http://localhost:${REDIRECT_PORT}`);
if (url.pathname !== "/callback") {
res.writeHead(404);
res.end("Not found");
return;
}
const error = url.searchParams.get("error");
if (error) {
const desc = url.searchParams.get("error_description") ?? error;
const safeDesc = desc.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" });
res.end(`<html><body><h2>Login failed</h2><p>${safeDesc}</p></body></html>`);
teardown();
reject(new Error(desc));
return;
}
const returnedState = url.searchParams.get("state");
if (returnedState !== state) {
res.writeHead(400);
res.end("State mismatch");
teardown();
reject(new Error("State mismatch"));
return;
}
const code = url.searchParams.get("code");
if (!code) {
res.writeHead(400);
res.end("No code");
teardown();
reject(new Error("No authorization code received"));
return;
}
const tokens = await exchangeCode(config.issuer, config.clientId, code, codeVerifier);
const idPayload = decodeJwtPayload(tokens.id_token);
const email = idPayload.email ??
idPayload.preferred_username ??
"unknown";
const sub = idPayload.sub;
let tenants = [];
try {
const accessPayload = decodeJwtPayload(tokens.access_token);
tenants =
accessPayload["urn:mesh:tenants"] ?? [];
}
catch {
}
writeCredentials(context, {
idToken: tokens.id_token,
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
expiresAt: new Date(Date.now() + tokens.expires_in * 1000).toISOString(),
email,
sub,
});
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
res.end(`<html><body><h2>✅ Logged in to ${context}!</h2><p>You can close this tab.</p></body></html>`);
logSuccess(`Logged in as ${email} (${context})`);
if (tenants.length > 0) {
logInfo(`Deployable tenants: ${tenants.join(", ")}`);
}
hintVpnIfDisconnected(context);
teardown();
resolve();
}
catch (err) {
teardown();
reject(err);
}
});
const teardown = () => {
if (timeoutId)
clearTimeout(timeoutId);
server.closeAllConnections?.();
server.close();
};
server.listen(REDIRECT_PORT, () => {
logInfo(`Opening browser for authentication (${context})...`);
const url = authUrl.toString();
try {
if (process.platform === "darwin") {
execFileSync("open", [url], { stdio: "ignore" });
}
else if (process.platform === "linux") {
execFileSync("xdg-open", [url], { stdio: "ignore" });
}
else {
logInfo(`Open this URL in your browser:\n${url}`);
}
}
catch {
logInfo(`Open this URL in your browser:\n${url}`);
}
});
timeoutId = setTimeout(() => {
teardown();
reject(new Error(loginTimeoutMessage(context)));
}, 120_000);
});
}
const MAX_DEVICE_CODES = 3;
export async function deviceLoginWithReissue(attempt, maxCodes = MAX_DEVICE_CODES) {
for (let n = 1; n <= maxCodes; n++) {
if (n > 1)
logWarn(`That code expired — issuing a fresh one (${n}/${maxCodes})…`);
if ((await attempt(n)) === "success")
return;
}
throw new Error(`Device login not completed after ${maxCodes} codes. Re-run the command when you're ready to authorize.`);
}
async function deviceCodeLogin(context, config) {
await deviceLoginWithReissue(() => attemptDeviceCode(context, config));
}
async function attemptDeviceCode(context, config) {
const resp = await fetch(`${config.issuer}/oauth/v2/device_authorization`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
client_id: config.clientId,
scope: SCOPES,
}).toString(),
});
if (!resp.ok) {
const text = await resp.text();
throw new Error(`Device authorization request failed (${resp.status}): ${text}`);
}
const deviceAuth = await resp.json();
const openUrl = deviceAuth.verification_uri_complete ?? deviceAuth.verification_uri;
console.log();
logInfo(`Open this URL in your browser:\n`);
logInfo(` ${openUrl}\n`);
logInfo(`Code: ${deviceAuth.user_code}`);
console.log();
logInfo("Waiting for authorization...");
try {
if (process.platform === "darwin") {
execFileSync("open", [openUrl], { stdio: "ignore" });
}
else if (process.platform === "linux") {
execFileSync("xdg-open", [openUrl], { stdio: "ignore" });
}
}
catch { }
const deadline = Date.now() + deviceAuth.expires_in * 1000;
let interval = deviceAuth.interval * 1000;
while (Date.now() < deadline) {
await new Promise((r) => setTimeout(r, interval));
const tokenResp = await fetch(`${config.issuer}/oauth/v2/token`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
client_id: config.clientId,
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
device_code: deviceAuth.device_code,
}).toString(),
});
if (tokenResp.ok) {
const tokens = await tokenResp.json();
const idPayload = decodeJwtPayload(tokens.id_token);
const email = idPayload.email ?? idPayload.preferred_username ?? "unknown";
const sub = idPayload.sub;
let tenants = [];
try {
const accessPayload = decodeJwtPayload(tokens.access_token);
tenants = accessPayload["urn:mesh:tenants"] ?? [];
}
catch { }
writeCredentials(context, {
idToken: tokens.id_token,
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
expiresAt: new Date(Date.now() + tokens.expires_in * 1000).toISOString(),
email,
sub,
});
logSuccess(`Logged in as ${email} (${context})`);
if (tenants.length > 0) {
logInfo(`Deployable tenants: ${tenants.join(", ")}`);
}
hintVpnIfDisconnected(context);
return "success";
}
const error = await tokenResp.json();
switch (error.error) {
case "authorization_pending":
continue;
case "slow_down":
interval += 1000;
continue;
case "expired_token":
return "expired";
case "access_denied":
throw new Error("Authorization denied by user.");
default:
throw new Error(`Token exchange failed: ${error.error} — ${error.error_description ?? ""}`);
}
}
return "expired";
}
export function loginTimeoutMessage(context) {
const base = "Login timed out (2 minutes)";
if (context !== "local")
return base;
return (`${base}. The local platform already has a signed-up user — sign in as dev@local.mesh / LocalDev1! instead of registering. ` +
"If you did register, its confirmation email is in the local mailbox at http://localhost:8025; confirm it there and run the command again.");
}
export function isRemoteEnvironment() {
if (process.env.REMOTE_CONTAINERS || process.env.CODESPACES)
return true;
if (fs.existsSync("/.dockerenv"))
return true;
if (process.env.SSH_CLIENT || process.env.SSH_TTY)
return true;
if (!process.stdout.isTTY || !process.stdin.isTTY)
return true;
return false;
}
function hintVpnIfDisconnected(context) {
if (context === RESERVED_REGISTRY_CONTEXT)
return;
try {
const state = tailscaleBackendState();
if (state !== "Running") {
console.log("");
logInfo(`VPN not connected. To access dev services, run:`);
logInfo(` mesh vpn connect ${context}`);
}
}
catch {
}
}
export function accountIdFromRoleArn(roleArn) {
return roleArn?.match(/^arn:aws:iam::(\d{12}):/)?.[1];
}
export function renderAwsFallbackStatus(context, reason, identity, expectedAccountId) {
if (!identity) {
return {
lines: [
{
level: "warn",
text: `${reason}, and no working AWS credentials were found either.`,
},
{ level: "info", text: "Authenticate with whichever you use:" },
{ level: "info", text: ` mesh login ${context} (Zitadel SSO)` },
{
level: "info",
text: " aws sso login --profile … (AWS SSO profile)",
},
{
level: "info",
text: " export AWS_PROFILE=… (profile with a key/secret pair)",
},
],
exitCode: 1,
};
}
const lines = [
{
level: "success",
text: `AWS credentials are working (${identity.source})`,
},
{ level: "info", text: `Identity: ${identity.arn}` },
{ level: "info", text: `Account: ${identity.accountId}` },
];
if (expectedAccountId && identity.accountId !== expectedAccountId) {
lines.push({
level: "warn",
text: `This account does not match ${context}'s configured role account ` +
`(${expectedAccountId}) — commands against ${context} will likely ` +
`fail with AccessDenied. Check AWS_PROFILE / your exported credentials.`,
});
}
else if (expectedAccountId) {
lines.push({
level: "info",
text: `Account matches ${context}'s configured role account.`,
});
}
else {
lines.push({
level: "info",
text: `This is a working AWS identity, not a verified connection to ` +
`${context} — nothing here checked that this account is ${context}'s.`,
});
}
lines.push({
level: "info",
text: `${reason} — that only matters for the commands that need a *user* ` +
`identity (Hub SSO, \`mesh dev\` test users, zero-touch VPN join). ` +
`Deploys, registry auth and secrets work off these credentials.`,
});
return { lines, exitCode: 0 };
}
async function showAwsCredentialStatus(context, reason, config) {
const identity = await probeAwsIdentity();
const { lines, exitCode } = renderAwsFallbackStatus(context, reason, identity, accountIdFromRoleArn(config.defaultRole ?? config.adminRole));
for (const line of lines) {
if (line.level === "success")
logSuccess(line.text);
else if (line.level === "warn")
logWarn(line.text);
else
logInfo(line.text);
}
if (exitCode !== 0)
process.exit(exitCode);
}
async function showStatus(context, config) {
const creds = readCredentials(context);
if (!creds) {
await showAwsCredentialStatus(context, "No cached Zitadel session", config);
return;
}
const expired = new Date(creds.expiresAt) < new Date();
if (expired && creds.refreshToken) {
logInfo("Token expired, attempting refresh...");
try {
const tokens = await refreshTokens(config.issuer, config.clientId, creds.refreshToken);
const idPayload = decodeJwtPayload(tokens.id_token);
writeCredentials(context, {
...creds,
idToken: tokens.id_token,
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token ?? creds.refreshToken,
expiresAt: new Date(Date.now() + tokens.expires_in * 1000).toISOString(),
email: idPayload.email ?? creds.email,
});
logSuccess(`Token refreshed for ${creds.email ?? "unknown"}`);
return;
}
catch {
await showAwsCredentialStatus(context, "Zitadel token expired and refresh failed", config);
return;
}
}
if (expired) {
await showAwsCredentialStatus(context, `Zitadel token expired at ${creds.expiresAt}`, config);
return;
}
let tenants = [];
try {
const accessPayload = decodeJwtPayload(creds.accessToken);
tenants = accessPayload["urn:mesh:tenants"] ?? [];
}
catch {
}
logSuccess(`Logged in as ${creds.email ?? "unknown"} (${context})`);
logInfo(`Subject: ${creds.sub}`);
logInfo(`Expires: ${creds.expiresAt}`);
if (tenants.length > 0) {
logInfo(`Deployable tenants: ${tenants.join(", ")}`);
}
}
export function registerLoginCommand(program) {
program
.command("login")
.description("Authenticate with Zitadel (OIDC PKCE or Device Code)")
.argument("<context>", 'Platform context (e.g., "mesh.dev")')
.option("--status", "Show current authentication status — the Zitadel session if one is cached, otherwise the AWS identity the credential chain resolves (SSO profile or key pair). Exits non-zero only when neither works.")
.option("--device", "Force device code flow (no callback server needed)")
.option("--export", "After login, print a self-refreshing AWS credential_process profile as shell `export` statements (use with `eval`). The resulting shell auto-refreshes credentials via `mesh login`. Requires --role or MESH_AWS_ROLE.")
.option("--static", "With --export: print raw temporary AWS credentials (a frozen ~1h triple) instead of the default self-refreshing credential_process profile")
.option("--credential-process", "Print AWS credential_process JSON (used by mesh dev's temp profile); refreshes the Zitadel token as needed. Requires --role or a cached defaultRole.")
.option("--role <arn>", "IAM role ARN to assume via web identity (used with --export). Saved as the default for this context so future --export runs can omit it. Resolution order: --role > MESH_AWS_ROLE > cached defaultRole.")
.option("--region <region>", "AWS region to include in the exported AWS_REGION. Defaults to AWS_REGION env or us-east-2.")
.option("--no-registry", "Deprecated and ignored — mesh login no longer touches the package registry (that is `mesh registry login`)")
.action(async (context, opts) => {
refuseReservedRegistryContext(context, "login");
let config = getContextConfig(context);
if (!config) {
config = await discoverConfigGuarded(context);
if (!config) {
logError(renderNoConfigHelp(context));
process.exit(1);
}
}
if (opts.status) {
await showStatus(context, config);
return;
}
if (opts.export) {
await exportAwsCredentials(context, config, opts);
return;
}
if (opts.credentialProcess) {
await credentialProcessAwsCredentials(context, config, opts);
return;
}
if (opts.registry === false) {
logWarn(NO_REGISTRY_DEPRECATED);
}
try {
await runLoginFlow(context, config, opts);
}
catch (err) {
logError(`Login failed: ${err.message}`);
process.exit(1);
}
await hintRegistryIfMissing();
});
program
.command("logout")
.description("Clear cached Zitadel credentials")
.argument("<context>", 'Platform context (e.g., "mesh.dev")')
.action((context) => {
refuseReservedRegistryContext(context, "logout");
clearCredentials(context);
logSuccess(`Logged out of ${context}. Credentials cleared.`);
});
}
export const RESERVED_REGISTRY_CONTEXT = "registry";
export const NO_REGISTRY_DEPRECATED = "--no-registry is deprecated and ignored: mesh login no longer touches the registry.";
export const REGISTRY_SEPARATE_HINT = "Package registry access is separate from platform sign-in — run: mesh registry login";
function refuseReservedRegistryContext(context, verb) {
if (context !== RESERVED_REGISTRY_CONTEXT)
return;
throw new MeshCliError(`"registry" is the package registry's own session, not a platform context.`, { remediation: { command: `mesh registry ${verb}` } });
}
async function hintRegistryIfMissing() {
try {
const { readRegistrySession } = await import("../utils/registry-identity.js");
if (readRegistrySession())
return;
const { probeRegistryToken } = await import("../utils/auth-preflight.js");
const probe = await probeRegistryToken();
if (probe.state === "missing" || probe.state === "expired") {
logInfo(REGISTRY_SEPARATE_HINT);
}
}
catch {
}
}
export function renderNoConfigHelp(context) {
const pinned = firstPartyDomainFor(context);
return (`No configuration found for "${context}".\n\n` +
`Tried:\n` +
` 1. SSM: /mesh-platform/${context.split(".")[0]}/${context.split(".")[1]}/platform/zitadel\n` +
` (requires AWS SSO login + read access)\n` +
(pinned
? ` 2. HTTPS: https://cli.${pinned}/.well-known/mesh.json (pinned first-party domain)\n\n`
: ` 2. HTTPS: https://cli.${context}/.well-known/mesh.json\n` +
` (only attempted for a full domain, or a known first-party context)\n\n`) +
`To fix:\n` +
` - Use the platform's full domain: mesh login <env>.<tenant>.meshtech.io\n` +
` - Or, if you have AWS access, ensure SSO is active: aws sso login --profile <profile>\n` +
` - Or add config manually to ${CONFIG_FILE}:\n\n` +
` {\n` +
` "${context}": {\n` +
` "issuer": "https://identity.<env>.<your-platform-domain>",\n` +
` "clientId": "<cli-oidc-client-id>"\n` +
` }\n` +
` }\n`);
}
export function tokenStillValid(expiresAt, marginMs = 0, now = Date.now()) {
return new Date(expiresAt).getTime() - marginMs > now;
}
async function getValidToken(context, opts = {}) {
const config = getContextConfig(context);
if (!config)
return null;
const creds = readCredentials(context);
if (!creds)
return null;
if (tokenStillValid(creds.expiresAt, opts.marginMs ?? 0)) {
return creds.idToken;
}
return remintToken(context, config, creds);
}
async function remintToken(context, config, creds) {
if (!creds.refreshToken)
return null;
let tokens;
try {
tokens = await refreshTokens(config.issuer, config.clientId, creds.refreshToken);
}
catch {
return null;
}
let email = creds.email;
try {
email = decodeJwtPayload(tokens.id_token).email ?? creds.email;
}
catch {
}
try {
writeCredentials(context, {
...creds,
idToken: tokens.id_token,
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token ?? creds.refreshToken,
expiresAt: new Date(Date.now() + tokens.expires_in * 1000).toISOString(),
email,
});
}
catch (err) {
logWarn(`The refreshed ${context} session could not be cached (${err instanceof Error ? err.message : String(err)}) — ` +
`this run continues on the new token, but the next one may need a fresh sign-in.`);
}
return tokens.id_token;
}
export async function forceRefreshToken(context) {
const config = getContextConfig(context);
if (!config)
return null;
const creds = readCredentials(context);
if (!creds)
return null;
return remintToken(context, config, creds);
}
export async function probeCredentials(context, roleArn) {
const cached = readCredentials(context);
if (!cached)
return { state: "no-session" };
if (process.env.AWS_ACCESS_KEY_ID && process.env.AWS_SESSION_TOKEN) {
return { state: "stale-env-override" };
}
const token = await getValidToken(context);
if (!token)
return { state: "expired-session" };
const sessionName = (cached.email ?? "mesh-cli-doctor")
.replace(/[^a-zA-Z0-9=,.@-]/g, "_")
.slice(0, 64);
const sts = await assumeRoleCredentials(roleArn, token, sessionName);
if (!sts) {
return {
state: "assume-denied",
detail: describeAssumeFailure({ roleArn, context, issuer: tokenIssuer(token) }, "inline"),
};
}
const ttlSeconds = Math.max(0, Math.round((Date.parse(sts.Expiration) - Date.now()) / 1000));
return { state: "ok", ttlSeconds, expiresAt: sts.Expiration, email: cached.email };
}
function shellSingleQuote(value) {
return `'${value.replace(/'/g, "'\\''")}'`;
}
export function renderExportStaticLines(args) {
return (`export AWS_ACCESS_KEY_ID=${shellSingleQuote(args.accessKey)}\n` +
`export AWS_SECRET_ACCESS_KEY=${shellSingleQuote(args.secretKey)}\n` +
`export AWS_SESSION_TOKEN=${shellSingleQuote(args.sessionToken)}\n` +
`export AWS_REGION=${shellSingleQuote(args.region)}\n`);
}
export function renderExportProfileLines(args) {
return (`unset AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SESSION_TOKEN\n` +
`export AWS_PROFILE=${shellSingleQuote(args.profileName)}\n` +
`export AWS_REGION=${shellSingleQuote(args.region)}\n`);
}
export function resolveUserAwsConfigPath(env = process.env) {
return env.AWS_CONFIG_FILE ?? path.join(env.HOME ?? "~", ".aws", "config");
}
function canWriteDir(dir) {
try {
fs.mkdirSync(dir, { recursive: true });
fs.accessSync(dir, fs.constants.W_OK);
return true;
}
catch {
return false;
}
}
export function resolveAwsConfigTarget(env = process.env) {
if (env.AWS_CONFIG_FILE)
return { configPath: env.AWS_CONFIG_FILE, redirected: false };
const real = path.join(env.HOME ?? "~", ".aws", "config");
if (canWriteDir(path.dirname(real)))
return { configPath: real, redirected: false };
return { configPath: path.join(CONFIG_DIR, "aws-config"), redirected: true };
}
export function resolveRoleOrExplain(opts, config) {
const role = opts.role ?? process.env.MESH_AWS_ROLE ?? config.defaultRole;
if (!role) {
return {
error: "No IAM role available: pass --role <arn>, set MESH_AWS_ROLE, or run " +
"`mesh login <context> --export --role <arn>` once to cache a default role for this context.",
};
}
return { role };
}
export async function runLoginFlow(context, config, opts = {}) {
const useDevice = opts.device || isRemoteEnvironment();
if (useDevice) {
logInfo("Using device code flow");
await deviceCodeLogin(context, config);
}
else {
await login(context, config);
}
}
async function ensureValidToken(context, config, opts) {
let token = await getValidToken(context);
if (token)
return token;
logInfo(`No valid Zitadel session for ${context} — running login flow`);
try {
await runLoginFlow(context, config, opts);
}
catch (err) {
logError(`Login failed: ${err.message}`);
return null;
}
return await getValidToken(context);
}
export async function ensureLogin(context, opts = {}) {
const existing = readCredentials(context);
if (existing && new Date(existing.expiresAt) > new Date())
return existing;
if (opts.interactive === false)
return null;
const config = getContextConfig(context) ?? (await discoverConfigGuarded(context));
if (!config) {
logWarn(`No login config for "${context}". Run: mesh login ${context}`);
return null;
}
const token = await ensureValidToken(context, config, opts);
if (!token)
return null;
return readCredentials(context);
}
async function discoverConfigGuarded(context) {
let config = null;
if (parseTenantEnv(context)) {
config = await discoverConfigFromSsm(context);
}
if (!config) {
const pinned = firstPartyDomainFor(context);
if (pinned) {
logInfo(`"${context}" is a known Mesh platform — resolving via ${pinned} (no AWS needed)`);
config = await discoverConfig(pinned, context);
}
}
if (!config && context.split(".").length - 1 >= 2) {
config = await discoverConfig(context);
}
return config;
}
async function exportAwsCredentials(context, config, opts) {
const token = await ensureValidToken(context, config, opts);
if (!token) {
process.exit(1);
}
const resolvedRole = resolveRoleOrExplain(opts, config);
if ("error" in resolvedRole) {
logError("--export requires --role <arn> (or MESH_AWS_ROLE env var, or a cached defaultRole).\n" +
" Example: mesh login mesh.dev --export --role arn:aws:iam::123456789012:role/mesh-developer\n" +
" After the first run, the role is saved to ~/.config/mesh/config.json and --role can be omitted.");
process.exit(1);
}
let roleArn = resolvedRole.role;
if (!opts.role && !process.env.MESH_AWS_ROLE && config.defaultRole) {
roleArn = selectRoleForCaller(token, {
defaultRole: config.defaultRole,
adminRole: config.adminRole,
adminClaimRoles: config.adminClaimRoles,
});
}
if (config.adminRole && roleArn === config.adminRole && !opts.role) {
logInfo(`Caller has admin Zitadel role — assuming ${roleArn.split("/").pop()} (admin variant)`);
}
if (opts.role && opts.role !== config.defaultRole) {
writeContextConfig(context, { ...config, defaultRole: opts.role });
logInfo(`Saved default role for ${context}`);
}
const region = opts.region ??
process.env.AWS_REGION ??
process.env.AWS_DEFAULT_REGION ??
"us-east-2";
const roleName = roleArn.split("/").pop() ?? roleArn;
if (opts.static) {
const creds = readCredentials(context);
const sessionName = (creds?.email ?? "mesh-cli-export")
.replace(/[^a-zA-Z0-9=,.@-]/g, "_")
.slice(0, 64);
const env = await assumeRoleWithWebIdentity(roleArn, token, sessionName);
if (!env) {
logError(describeAssumeFailure({ roleArn, context, issuer: tokenIssuer(token) }));
process.exit(1);
}
const accessKey = env.AWS_ACCESS_KEY_ID;
const secretKey = env.AWS_SECRET_ACCESS_KEY;
const sessionToken = env.AWS_SESSION_TOKEN;
if (!accessKey || !secretKey || !sessionToken) {
logError("AWS STS returned incomplete credentials");
process.exit(1);
}
process.stdout.write(renderExportStaticLines({ accessKey, secretKey, sessionToken, region }));
logSuccess(`Exported static AWS credentials for ${roleName} in ${region}`);
return;
}
const meshBin = resolveStableMeshBin(process.argv[1]);
const sanitized = context.replace(/[^A-Za-z0-9_-]/g, "-");
const profileName = `mesh-${sanitized}`;
const { configPath, redirected } = resolveAwsConfigTarget();
fs.mkdirSync(path.dirname(configPath), { recursive: true });
const existedBefore = fs.existsSync(configPath);
const existing = existedBefore ? fs.readFileSync(configPath, "utf-8") : "";
const mode = existedBefore ? fs.statSync(configPath).mode & 0o777 : 0o600;
atomicWriteFileSync(configPath, upsertManagedAwsConfigSection(existing, context, renderCredentialProcessProfile({
profileName,
context,
roleArn,
region,
meshBin,
})), mode);
process.stdout.write(renderExportProfileLines({ profileName, region }));
if (redirected) {
process.stdout.write(`export AWS_CONFIG_FILE=${shellSingleQuote(configPath)}\n`);
}
logSuccess(`Exported self-refreshing AWS profile ${profileName} for ${roleName} in ${region} ` +
`(managed section in ${configPath})`);
}
async function credentialProcessAwsCredentials(context, config, opts) {
const token = await getValidToken(context);
if (!token) {
logError(`No valid Zitadel session for ${context} (missing, expired, or refresh failed). Run: mesh login ${context}`);
process.exit(1);
}
const resolvedRole = resolveRoleOrExplain(opts, config);
if ("error" in resolvedRole) {
logError(resolvedRole.error);
process.exit(1);
}
const creds = readCredentials(context);
const sessionName = (creds?.email ?? "mesh-cli-credential-process")
.replace(/[^a-zA-Z0-9=,.@-]/g, "_")
.slice(0, 64);
const stsCreds = await assumeRoleCredentials(resolvedRole.role, token, sessionName);
if (!stsCreds) {
logError(describeAssumeFailure({
roleArn: resolvedRole.role,
context,
issuer: tokenIssuer(token),
}));
process.exit(1);
}
process.stdout.write(toCredentialProcessJson(stsCreds) + "\n");
}
export { CONFIG_DIR, CONFIG_FILE, CREDENTIALS_FILE };
export function readAllContextConfigs() {
return readConfig();
}
export async function discoverConfigForContext(context) {
return getContextConfig(context) ?? (await discoverConfigGuarded(context));
}
export { readCredentials, readAllCredentials, getContextConfig, decodeJwtPayload, getValidToken, clearCredentials };