UNPKG

wgc

Version:

The official CLI tool to manage the GraphQL Federation Platform Cosmo

483 lines 18.4 kB
import fs from 'node:fs/promises'; import { createWriteStream, existsSync, mkdirSync } from 'node:fs'; import path from 'node:path'; import { program } from 'commander'; import { execa } from 'execa'; import ora from 'ora'; import pc from 'picocolors'; import { z } from 'zod'; import { config, cacheDir } from '../../core/config.js'; import { capture } from '../../core/telemetry.js'; import { getDefaultPlatforms, publishPluginPipeline, readPluginFiles } from '../../core/plugin-publish.js'; import { visibleLength } from '../../utils.js'; // TODO: ora defaults discardStdin to true which puts stdin into raw mode // and restores it to cooked mode when the spinner stops. This conflicts // with the demo command's own stdin management (enableRawModeWithCtrlC) // causing CTRL+C to stop working between prompts. export function demoSpinner(text) { return ora({ text, discardStdin: false }); } /** * Clears whole screen */ export function clearScreen() { process.stdout.write('\u001Bc'); } export function resetScreen(userInfo) { clearScreen(); printLogo(userInfo); } /** * Fancy WG logo */ export function printLogo(userInfo) { const logoLines = [ ' ▌ ▌', '▌▌▌▌▌▛▌▛▌█▌▛▘▛▌▛▘▀▌▛▌▛▌', '▚▚▘▙▌▌▌▙▌▙▖▌ ▙▌▌ █▌▙▌▌▌', ' ▄▌ ▌', ]; if (!userInfo) { console.log(`\n${logoLines.join('\n')}\n`); return; } const termWidth = process.stdout.columns || 80; const logoWidth = Math.max(...logoLines.map((l) => l.length)); const infoLines = [ `${pc.dim('email:')} ${pc.bold(pc.white(userInfo.userEmail))}`, `${pc.dim('organization:')} ${pc.bold(pc.white(userInfo.organizationName))}`, ]; const infoVisibleWidths = infoLines.map((l) => visibleLength(l)); const maxInfoWidth = Math.max(...infoVisibleWidths); // Minimum gap between logo and info const gap = 4; const totalNeeded = logoWidth + gap + maxInfoWidth; // Right-align info: compute left padding for each info line const availableWidth = Math.max(termWidth, totalNeeded); const lines = logoLines.map((line, i) => { if (i >= infoLines.length) { return line; } const infoVisibleWidth = infoVisibleWidths[i]; const padding = availableWidth - logoWidth - infoVisibleWidth; return `${line.padEnd(logoWidth)}${' '.repeat(Math.max(gap, padding))}${infoLines[i]}`; }); console.log(`\n${lines.join('\n')}\n`); } function writeEscapeSequence(s) { process.stdout.write(s); } /** * Updates the logo region at the top of the screen with userInfo * without clearing the rest of the screen content. */ export function updateScreenWithUserInfo(userInfo) { // Save cursor position, jump to top writeEscapeSequence('\u001B7'); writeEscapeSequence('\u001B[H'); // printLogo writes 6 visual lines: \n, 4 logo lines, \n // Clear those lines and reprint with userInfo // First clear the lines the logo occupies (1 blank + 4 logo + 1 blank = 6 lines) for (let i = 0; i < 6; i++) { writeEscapeSequence('\u001B[2K'); // erase line if (i < 5) { writeEscapeSequence('\u001B[B'); } // move down } // Move back to top writeEscapeSequence('\u001B[H'); // Reprint logo with userInfo (printLogo uses console.log which writes to these lines) printLogo(userInfo); // Restore cursor position writeEscapeSequence('\u001B8'); } const GitHubTreeSchema = z.object({ tree: z.array(z.object({ type: z.string(), path: z.string(), })), }); /** * Copies over support files (gRPC plugin data) from onboarding * repository and stores them in the host filesystem [cacheDir] * folder. * @returns [directory] path which contains the support data */ export async function prepareSupportingData() { const spinner = demoSpinner('Preparing supporting data…').start(); const cosmoDir = path.join(cacheDir, 'demo'); await fs.mkdir(cosmoDir, { recursive: true }); const treeResponse = await fetch(`https://api.github.com/repos/${config.demoOnboardingRepositoryName}/git/trees/${config.demoOnboardingRepositoryBranch}?recursive=1`); if (!treeResponse.ok) { spinner.fail('Failed to fetch repository tree.'); const errorText = `GitHub API error: ${treeResponse.statusText}`; captureOnboardingEvent({ name: 'onboarding_step_failed', properties: { step_name: 'init', entry_source: 'wgc', error_category: 'support_files', error_message: errorText, }, }); program.error(errorText); } const parsed = GitHubTreeSchema.safeParse(await treeResponse.json()); if (!parsed.success) { spinner.fail('Failed to parse repository tree.'); const errorText = 'Unexpected response format from GitHub API. The repository structure may have changed.'; captureOnboardingEvent({ name: 'onboarding_step_failed', properties: { step_name: 'init', entry_source: 'wgc', error_category: 'support_files', error_message: errorText, }, }); program.error(errorText); } const files = parsed.data.tree.filter((entry) => entry.type === 'blob' && entry.path.startsWith('plugins/')); const results = await Promise.all(files.map(async (file) => { const rawUrl = `https://raw.githubusercontent.com/${config.demoOnboardingRepositoryName}/${config.demoOnboardingRepositoryBranch}/${file.path}`; try { const response = await fetch(rawUrl); if (!response.ok) { return { path: file.path, error: response.statusText }; } const content = Buffer.from(await response.arrayBuffer()); const destPath = path.join(cosmoDir, file.path); await fs.mkdir(path.dirname(destPath), { recursive: true }); await fs.writeFile(destPath, content); return { path: file.path, error: null }; } catch (err) { return { path: file.path, error: err instanceof Error ? err.message : String(err), }; } })); const failed = results.filter((r) => r.error !== null); if (failed.length > 0) { const failText = `Failed to fetch ${failed.length} support files.`; const errorText = failed .map((f) => { const error = f.error.toLowerCase(); const errorCode = error.includes('fetch') || error.includes('network') ? 'NETWORK_ERROR' : 'SUPPORT_FILE_ERROR'; return ` ${path.basename(f.path)}: ${errorCode}`; }) .join('\n'); captureOnboardingEvent({ name: 'onboarding_step_failed', properties: { step_name: 'init', entry_source: 'wgc', error_category: 'support_files', error_message: `${failText}\n${errorText}`, }, }); spinner.fail(failText); program.error(errorText); } spinner.succeed(`Support files copied to ${pc.bold(cosmoDir)}`); return cosmoDir; } async function isDockerAvailable() { try { await execa('docker', ['version', '--format', '{{.Client.Version}}']); return true; } catch { return false; } } async function isBuildxAvailable() { try { await execa('docker', ['buildx', 'version']); return true; } catch { return false; } } async function hasDockerContainerBuilder() { try { const { stdout } = await execa('docker', ['buildx', 'ls']); for (const line of stdout.split('\n')) { // Builder lines start without leading whitespace; the driver follows the name if (!line.startsWith(' ') && line.includes('docker-container')) { return true; } } return false; } catch { return false; } } async function createDockerContainerBuilder(builderName) { await execa('docker', ['buildx', 'create', '--use', '--driver', 'docker-container', '--name', builderName]); await execa('docker', ['buildx', 'inspect', builderName, '--bootstrap']); } /** * Checks whether host system has [docker] installed and whether [buildx] is set up * properly. In case of failures, show prompt to install/setup. */ export async function checkDockerReadiness() { const spinner = demoSpinner('Checking Docker availability…').start(); if (!(await isDockerAvailable())) { const failText = 'Docker is not available.'; captureOnboardingEvent({ name: 'onboarding_step_failed', properties: { step_name: 'init', entry_source: 'wgc', error_category: 'docker_readiness', error_message: failText, }, }); spinner.fail(failText); program.error(`Docker CLI is not installed or the daemon is not running.\nInstall Docker: ${pc.underline('https://docs.docker.com/get-docker/')}`); } if (!(await isBuildxAvailable())) { const failText = 'Docker Buildx is not available.'; captureOnboardingEvent({ name: 'onboarding_step_failed', properties: { step_name: 'init', entry_source: 'wgc', error_category: 'docker_readiness', error_message: failText, }, }); spinner.fail(failText); program.error(`Docker Buildx plugin is required for multi-platform builds.\nSee: ${pc.underline('https://docs.docker.com/build/install-buildx/')}`); } if (await hasDockerContainerBuilder()) { spinner.succeed('Docker is ready.'); return; } spinner.text = `Creating buildx builder "${config.dockerBuilderName}"…`; try { await createDockerContainerBuilder(config.dockerBuilderName); } catch (err) { const failText = `Failed to create buildx builder "${config.dockerBuilderName}".`; const errorText = err instanceof Error ? err.message : String(err); spinner.fail(failText); captureOnboardingEvent({ name: 'onboarding_step_failed', properties: { step_name: 'init', entry_source: 'wgc', error_category: 'docker_readiness', error_message: `${failText}\n${errorText}`, }, }); program.error(`Could not create a docker-container buildx builder: ${errorText}\nYou can create one manually: docker buildx create --use --driver docker-container --name ${config.dockerBuilderName}`); } spinner.succeed('Docker is ready.'); } /** * Returns the path to the demo log file at ~/.cache/cosmo/demo/demo.log. * Creates the parent directory if needed. */ export function getDemoLogPath() { const cosmoDir = path.join(cacheDir, 'demo'); if (!existsSync(cosmoDir)) { mkdirSync(cosmoDir, { recursive: true }); } return path.join(cosmoDir, 'demo.log'); } function pipeToLog(logStream, proc) { var _a, _b; (_a = proc.stdout) === null || _a === void 0 ? void 0 : _a.pipe(logStream, { end: false }); (_b = proc.stderr) === null || _b === void 0 ? void 0 : _b.pipe(logStream, { end: false }); } /** * Rewrite localhost to host.docker.internal so the container can * reach services running on the host machine. */ function toDockerHost(url) { return url.replace(/localhost/g, 'host.docker.internal'); } /** * Best-effort removal of a potentially stale router container * from a previous crashed run. */ async function removeRouterContainer() { try { await execa('docker', ['rm', '-f', config.demoRouterContainerName]); } catch { // ignore — container may not exist } } /** * Polls the router's readiness endpoint until it responds 200 * or the signal is aborted / max attempts exceeded. */ async function waitForRouterReady({ routerBaseUrl, signal, intervalMs = 1000, maxAttempts = 60, }) { const url = `${routerBaseUrl}/health/ready`; for (let i = 0; i < maxAttempts; i++) { if (signal.aborted) { return false; } try { const res = await fetch(url, { signal }); if (res.ok) { return true; } } catch { // not up yet } // Plain setTimeout ignores the abort signal, so CTRL+C during the // sleep would leave the loop hanging until the timer fires. await new Promise((resolve) => { const timer = setTimeout(resolve, intervalMs); signal.addEventListener('abort', () => { clearTimeout(timer); resolve(); }, { once: true }); }); } return false; } /** * Runs the cosmo router as a Docker container. Shows an ora spinner * that transitions from "Starting…" to "Router is ready" once the * health endpoint responds. The process stays alive until the abort * signal fires (CTRL+C / crash) or docker exits on its own. */ export async function runRouterContainer({ routerToken, routerBaseUrl, signal, logPath, }) { await removeRouterContainer(); const port = config.demoRouterPort; const args = [ 'run', '--name', config.demoRouterContainerName, '--rm', '-p', `${port}:${port}`, '--add-host=host.docker.internal:host-gateway', '--pull', 'always', '-e', 'DEV_MODE=true', '-e', 'LOG_LEVEL=debug', '-e', `LISTEN_ADDR=0.0.0.0:${port}`, '-e', `GRAPH_API_TOKEN=${routerToken}`, '-e', 'PLUGINS_ENABLED=true', ]; // Local-dev env vars — only forwarded when set in the wgc process. const conditionalEnvs = [ ['CDN_URL', config.cdnURL], ['REGISTRY_URL', config.pluginRegistryURL], ['PLUGINS_REGISTRY_URL', config.pluginRegistryURL], ['PLUGINS_REGISTRY_INSECURE', config.pluginRegistryInsecure ? 'true' : undefined], ['CONTROLPLANE_URL', config.baseURL], ['DEFAULT_TELEMETRY_ENDPOINT', config.defaultTelemetryEndpoint], ['GRAPHQL_METRICS_COLLECTOR_ENDPOINT', config.graphqlMetricsCollectorEndpoint], ]; for (const [key, value] of conditionalEnvs) { if (value) { args.push('-e', `${key}=${toDockerHost(value)}`); } } args.push(config.demoRouterImage); const logStream = createWriteStream(logPath, { flags: 'a' }); const spinner = demoSpinner(`Starting router on ${pc.bold(routerBaseUrl)}…`).start(); // During polling there is no waitForKeyPress active, so CTRL+C sends // SIGINT instead of being handled manually. Without this the active // spinner + docker process prevent clean exit and the spinner re-renders // on each CTRL+C press. function onSigint() { spinner.stop(); process.exit(0); } process.on('SIGINT', onSigint); try { const proc = execa('docker', args, { stdio: 'pipe', ...(signal ? { cancelSignal: signal } : {}), }); pipeToLog(logStream, proc); // Poll readiness in parallel with the long-running docker process const ready = await waitForRouterReady({ routerBaseUrl, signal }); if (ready) { spinner.succeed(`Router is ready on ${pc.bold(routerBaseUrl)}.`); console.log(pc.dim(`(logs: ${logPath})`)); return { error: null }; } else if (!signal.aborted) { const warnMessage = 'Router started but readiness check timed out. It may still be starting.'; spinner.warn(warnMessage); console.log(pc.dim(`(logs: ${logPath})`)); return { error: new Error(warnMessage) }; } await proc; } catch (error) { // Graceful abort — not an error if (error instanceof Error && 'isCanceled' in error && error.isCanceled) { return { error: null }; } spinner.fail('Router failed to start.'); return { error: error instanceof Error ? error : new Error(String(error)) }; } finally { process.removeListener('SIGINT', onSigint); logStream.end(); } return { error: null }; } /** * Publishes demo plugins sequentially. * Returns [error] on first failure; spinner shows which plugin failed. */ export async function publishAllPlugins({ client, supportDir, signal, logPath, }) { const pluginNames = config.demoPluginNames; const namespace = config.demoNamespace; const labels = [config.demoLabelMatcher]; // The demo router always runs in a Linux Docker container, so we need // linux builds for both architectures regardless of the host OS. const logStream = createWriteStream(logPath, { flags: 'w' }); try { for (let i = 0; i < pluginNames.length; i++) { const pluginName = pluginNames[i]; const pluginDir = path.join(supportDir, 'plugins', pluginName); const spinner = demoSpinner(`Publishing plugin ${pc.bold(pluginName)} (${i + 1}/${pluginNames.length})…`).start(); const files = await readPluginFiles(pluginDir); const result = await publishPluginPipeline({ client, pluginDir, pluginName, namespace, labels, platforms: getDefaultPlatforms(), files, cancelSignal: signal, onProcess: (proc) => pipeToLog(logStream, proc), }); if (result.error) { spinner.fail(`Failed to publish plugin ${pc.bold(pluginName)}: ${result.error.message}`); return { error: result.error }; } spinner.succeed(`Plugin ${pc.bold(pluginName)} published.`); } } finally { logStream.end(); } return { error: null }; } export function captureOnboardingEvent({ name, properties, }) { capture(name, properties); } //# sourceMappingURL=util.js.map