diff --git a/cli/.env.example b/cli/.env.example index 7fc1e246d0..4662c2871e 100644 --- a/cli/.env.example +++ b/cli/.env.example @@ -2,6 +2,8 @@ COSMO_API_KEY=cosmo_669b576aaadc10ee1ae81d9193425705 COSMO_API_URL=http://localhost:3001 CDN_URL=http://localhost:11000 PLUGIN_REGISTRY_URL= +DEFAULT_TELEMETRY_ENDPOINT=http://localhost:4318 +GRAPHQL_METRICS_COLLECTOR_ENDPOINT=http://localhost:4005 # configure running wgc behind a proxy # HTTPS_PROXY="" diff --git a/cli/src/commands/demo/api.ts b/cli/src/commands/demo/api.ts new file mode 100644 index 0000000000..7c7620fd22 --- /dev/null +++ b/cli/src/commands/demo/api.ts @@ -0,0 +1,201 @@ +import { EnumStatusCode } from '@wundergraph/cosmo-connect/dist/common/common_pb'; +import type { FederatedGraph, Subgraph } from '@wundergraph/cosmo-connect/dist/platform/v1/platform_pb'; +import type { BaseCommandOptions } from '../../core/types/types.js'; +import { getBaseHeaders } from '../../core/config.js'; + +/** + * Retrieve user information [email] and [organization name] + */ +export async function fetchUserInfo(client: BaseCommandOptions['client']) { + const response = await client.platform.whoAmI( + {}, + { + headers: getBaseHeaders(), + }, + ); + + switch (response.response?.code) { + case EnumStatusCode.OK: { + return { + userInfo: { + userEmail: response.userEmail, + organizationName: response.organizationName, + }, + error: null, + }; + } + default: { + return { + userInfo: null, + error: new Error(response.response?.details ?? 'An unknown error occured'), + }; + } + } +} + +/** + * Retrieve onboarding record. Provides information about allowed [status]: + * [error] | [not-allowed] | [ok] + * If record exists, returns [onboarding] metadata. + */ +export async function checkExistingOnboarding(client: BaseCommandOptions['client']) { + const { response, finishedAt, enabled } = await client.platform.getOnboarding( + {}, + { + headers: getBaseHeaders(), + }, + ); + + if (response?.code !== EnumStatusCode.OK) { + return { + error: new Error(response?.details ?? 'Failed to fetch onboarding metadata.'), + status: 'error', + } as const; + } + + if (!enabled) { + return { + status: 'not-allowed', + } as const; + } + + return { + onboarding: { + finishedAt, + }, + status: 'ok', + } as const; +} + +/** + * Retrieves federated graph by [name] *demo*. Missing federated graph + * is a valid state. + */ +export async function fetchFederatedGraphByName( + client: BaseCommandOptions['client'], + { name, namespace }: { name: string; namespace: string }, +) { + const { response, graph, subgraphs } = await client.platform.getFederatedGraphByName( + { + name, + namespace, + }, + { + headers: getBaseHeaders(), + }, + ); + + switch (response?.code) { + case EnumStatusCode.OK: { + return { data: { graph, subgraphs }, error: null }; + } + case EnumStatusCode.ERR_NOT_FOUND: { + return { data: null, error: null }; + } + default: { + return { + data: null, + error: new Error(response?.details ?? 'An unknown error occured'), + }; + } + } +} + +/** + * Cleans up the federated graph by [name] _demo_ and its related + * subgraphs. + */ +export async function cleanUpFederatedGraph( + client: BaseCommandOptions['client'], + graphData: { + graph: FederatedGraph; + subgraphs: Subgraph[]; + }, +) { + const subgraphDeleteResponses = await Promise.all( + graphData.subgraphs.map(({ name, namespace }) => + client.platform.deleteFederatedSubgraph( + { + namespace, + subgraphName: name, + disableResolvabilityValidation: false, + }, + { + headers: getBaseHeaders(), + }, + ), + ), + ); + + const failedSubgraphDeleteResponses = subgraphDeleteResponses.filter( + ({ response }) => response?.code !== EnumStatusCode.OK, + ); + + if (failedSubgraphDeleteResponses.length > 0) { + return { + error: new Error( + failedSubgraphDeleteResponses.map(({ response }) => response?.details ?? 'Unknown error occurred.').join('. '), + ), + }; + } + + const federatedGraphDeleteResponse = await client.platform.deleteFederatedGraph( + { + name: graphData.graph.name, + namespace: graphData.graph.namespace, + }, + { + headers: getBaseHeaders(), + }, + ); + + switch (federatedGraphDeleteResponse.response?.code) { + case EnumStatusCode.OK: { + return { + error: null, + }; + } + default: { + return { + error: new Error(federatedGraphDeleteResponse.response?.details ?? 'Unknown error occurred.'), + }; + } + } +} + +/** + * Creates federated graph using default [name] and [namespace], with pre-defined + * [labelMatcher] which identify the graph as _demo_. + */ +export async function createFederatedGraph( + client: BaseCommandOptions['client'], + options: { + name: string; + namespace: string; + labelMatcher: string; + routingUrl: URL; + }, +) { + const createFedGraphResponse = await client.platform.createFederatedGraph( + { + name: options.name, + namespace: options.namespace, + routingUrl: options.routingUrl.toString(), + labelMatchers: [options.labelMatcher], + }, + { + headers: getBaseHeaders(), + }, + ); + + switch (createFedGraphResponse.response?.code) { + case EnumStatusCode.OK: { + return { error: null }; + } + default: { + return { + error: new Error(createFedGraphResponse.response?.details ?? 'An unknown error occured'), + }; + } + } +} diff --git a/cli/src/commands/demo/command.ts b/cli/src/commands/demo/command.ts new file mode 100644 index 0000000000..8e88f2b672 --- /dev/null +++ b/cli/src/commands/demo/command.ts @@ -0,0 +1,441 @@ +import pc from 'picocolors'; +import { program } from 'commander'; +import type { FederatedGraph, Subgraph, WhoAmIResponse } from '@wundergraph/cosmo-connect/dist/platform/v1/platform_pb'; +import { config } from '../../core/config.js'; +import { createRouterToken, deleteRouterToken } from '../../core/router-token.js'; +import { BaseCommandOptions } from '../../core/types/types.js'; +import { waitForKeyPress, rainbow } from '../../utils.js'; +import type { UserInfo } from './types.js'; +import { + cleanUpFederatedGraph, + createFederatedGraph, + fetchFederatedGraphByName, + fetchUserInfo, + checkExistingOnboarding, +} from './api.js'; +import { + checkDockerReadiness, + clearScreen, + getDemoLogPath, + prepareSupportingData, + printLogo, + publishAllPlugins, + resetScreen, + runRouterContainer, + updateScreenWithUserInfo, + demoSpinner, +} from './util.js'; + +function printHello() { + printLogo(); + console.log( + `\nThank you for choosing ${rainbow('WunderGraph')} - The open-source solution to building, maintaining, and collaborating on GraphQL Federation at Scale.`, + ); + console.log('This command will guide you through the inital setup to create your first federated graph.'); +} + +async function handleGetFederatedGraphResponse( + client: BaseCommandOptions['client'], + { + onboarding, + userInfo, + }: { + onboarding: { + finishedAt?: string; + }; + userInfo: UserInfo; + }, +) { + function retryFn() { + resetScreen(userInfo); + return handleGetFederatedGraphResponse(client, { + onboarding, + userInfo, + }); + } + + const spinner = demoSpinner().start(); + const getFederatedGraphResponse = await fetchFederatedGraphByName(client, { + name: config.demoGraphName, + namespace: config.demoNamespace, + }); + + if (getFederatedGraphResponse.error) { + spinner.fail(`Failed to retrieve graph information ${getFederatedGraphResponse.error}`); + await waitForKeyPress( + { + r: retryFn, + R: retryFn, + }, + 'Hit [r] to refresh. CTRL+C to quit', + ); + return; + } + + if (getFederatedGraphResponse.data?.graph) { + spinner.succeed(`Federated graph ${pc.bold(getFederatedGraphResponse.data?.graph?.name)} exists.`); + } else { + spinner.stop(); + } + + return getFederatedGraphResponse.data; +} + +async function cleanupFederatedGraph( + client: BaseCommandOptions['client'], + { + graphData, + userInfo, + }: { + graphData: { + graph: FederatedGraph; + subgraphs: Subgraph[]; + }; + userInfo: UserInfo; + }, +) { + function retryFn() { + resetScreen(userInfo); + cleanupFederatedGraph(client, { graphData, userInfo }); + } + + const spinner = demoSpinner(`Removing federated graph ${pc.bold(graphData.graph.name)}…`).start(); + const deleteResponse = await cleanUpFederatedGraph(client, graphData); + + if (deleteResponse.error) { + spinner.fail(`Removing federated graph ${graphData.graph.name} failed.`); + console.error(deleteResponse.error.message); + + await waitForKeyPress( + { + Enter: () => undefined, + r: retryFn, + R: retryFn, + }, + `Failed to delete the federated graph ${pc.bold(graphData.graph.name)}. [ENTER] to continue, [r] to retry. CTRL+C to quit.`, + ); + } + + spinner.succeed(`Federated graph ${pc.bold(graphData.graph.name)} removed.`); +} + +async function handleCreateFederatedGraphResponse( + client: BaseCommandOptions['client'], + { + onboarding, + userInfo, + }: { + onboarding: { + finishedAt?: string; + }; + userInfo: UserInfo; + }, +) { + function retryFn() { + resetScreen(userInfo); + handleCreateFederatedGraphResponse(client, { onboarding, userInfo }); + } + + const routingUrl = new URL('graphql', 'http://localhost'); + routingUrl.port = String(config.demoRouterPort); + + const federatedGraphSpinner = demoSpinner().start(); + const createGraphResponse = await createFederatedGraph(client, { + name: config.demoGraphName, + namespace: config.demoNamespace, + labelMatcher: config.demoLabelMatcher, + routingUrl, + }); + + if (createGraphResponse.error) { + federatedGraphSpinner.fail(createGraphResponse.error.message); + + await waitForKeyPress( + { + r: retryFn, + R: retryFn, + }, + 'Hit [r] to refresh. CTRL+C to quit', + ); + return; + } + + federatedGraphSpinner.succeed(`Federated graph ${pc.bold('demo')} succesfully created.`); +} + +async function handleStep2( + opts: BaseCommandOptions, + { + onboarding, + userInfo, + supportDir, + signal, + logPath, + }: { + onboarding: { finishedAt?: string }; + userInfo: UserInfo; + supportDir: string; + signal: AbortSignal; + logPath: string; + }, +) { + function retryFn() { + resetScreen(userInfo); + return handleStep2(opts, { onboarding, userInfo, supportDir, signal, logPath }); + } + + async function publishPlugins() { + console.log(`\nPublishing plugins… ${pc.dim(`(logs: ${logPath})`)}`); + + const publishResult = await publishAllPlugins({ + client: opts.client, + supportDir, + signal, + logPath, + }); + + if (publishResult.error) { + await waitForKeyPress( + { + r: retryFn, + R: retryFn, + }, + 'Hit [r] to retry. CTRL+C to quit.', + ); + } + } + + const graphData = await handleGetFederatedGraphResponse(opts.client, { + onboarding, + userInfo, + }); + + const graph = graphData?.graph; + const subgraphs = graphData?.subgraphs ?? []; + if (graph) { + let deleted = false; + const cleanupFn = async () => { + await cleanupFederatedGraph(opts.client, { + graphData: { graph, subgraphs }, + userInfo, + }); + deleted = true; + }; + await waitForKeyPress( + { + Enter: () => undefined, + d: cleanupFn, + D: cleanupFn, + }, + 'Hit [ENTER] to continue or [d] to delete the federated graph and its subgraphs to start over. CTRL+C to quit.', + ); + if (deleted) { + console.log(pc.yellow('\nPlease restart the demo command to continue.\n')); + process.exit(0); + } + await publishPlugins(); + return { routingUrl: graph.routingURL }; + } + + await handleCreateFederatedGraphResponse(opts.client, { + onboarding, + userInfo, + }); + + const routingUrl = new URL('graphql', 'http://localhost'); + routingUrl.port = String(config.demoRouterPort); + + await publishPlugins(); + + return { routingUrl: routingUrl.toString() }; +} + +async function handleStep3( + opts: BaseCommandOptions, + { + userInfo, + routerBaseUrl, + signal, + logPath, + }: { + userInfo: UserInfo; + routerBaseUrl: string; + signal: AbortSignal; + logPath: string; + }, +) { + function retryFn() { + resetScreen(userInfo); + return handleStep3(opts, { userInfo, routerBaseUrl, signal, logPath }); + } + + const tokenParams = { + client: opts.client, + tokenName: config.demoRouterTokenName, + graphName: config.demoGraphName, + namespace: config.demoNamespace, + }; + + // Delete existing token first (idempotent — no error if missing) + const deleteResult = await deleteRouterToken(tokenParams); + if (deleteResult.error) { + console.error(`Failed to clean up existing router token: ${deleteResult.error.message}`); + await waitForKeyPress({ r: retryFn, R: retryFn }, 'Hit [r] to retry. CTRL+C to quit.'); + return; + } + + const spinner = demoSpinner('Generating router token…').start(); + const createResult = await createRouterToken(tokenParams); + + if (createResult.error) { + spinner.fail(`Failed to generate router token: ${createResult.error.message}`); + await waitForKeyPress({ r: retryFn, R: retryFn }, 'Hit [r] to retry. CTRL+C to quit.'); + return; + } + + spinner.succeed('Router token generated.'); + console.log(` ${pc.bold(createResult.token)}`); + + const sampleQuery = JSON.stringify({ + query: `query GetProductWithReviews($id: ID!) { product(id: $id) { id title price { currency amount } reviews { id author rating contents } } }`, + variables: { id: 'product-1' }, + }); + + async function fireSampleQuery() { + const querySpinner = demoSpinner('Sending sample query…').start(); + try { + const res = await fetch(`${routerBaseUrl}/graphql`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'GraphQL-Client-Name': 'wgc', + }, + body: sampleQuery, + }); + const body = await res.json(); + querySpinner.succeed('Sample query response:'); + console.log(pc.dim(JSON.stringify(body, null, 2))); + } catch (err) { + querySpinner.fail(`Sample query failed: ${err instanceof Error ? err.message : String(err)}`); + } + showQueryPrompt(); + } + + function showQueryPrompt() { + waitForKeyPress( + { r: fireSampleQuery, R: fireSampleQuery }, + 'Hit [r] to send a sample query. CTRL+C to stop the router.', + ); + } + + const routerResult = await runRouterContainer({ + routerToken: createResult.token!, + routerBaseUrl, + signal, + logPath, + onReady: showQueryPrompt, + }); + + if (routerResult.error) { + console.error(`\nRouter exited with error: ${routerResult.error.message}`); + await waitForKeyPress({ r: retryFn, R: retryFn }, 'Hit [r] to retry. CTRL+C to quit.'); + } +} + +async function handleGetOnboardingResponse(client: BaseCommandOptions['client'], userInfo: UserInfo) { + const onboardingCheck = await checkExistingOnboarding(client); + + async function retryFn() { + return await handleGetOnboardingResponse(client, userInfo); + } + + switch (onboardingCheck.status) { + case 'ok': { + return onboardingCheck.onboarding; + } + case 'not-allowed': { + program.error('Only organization owners can trigger onboarding.'); + + break; + } + case 'error': { + console.error('An issue occured while fetching the onboarding status'); + console.error(onboardingCheck.error); + + await waitForKeyPress({ Enter: retryFn }, 'Hit Enter to retry. CTRL+C to quit.'); + break; + } + default: { + program.error('Invariant'); + } + } +} + +async function handleStep1(opts: BaseCommandOptions, userInfo: UserInfo) { + return await handleGetOnboardingResponse(opts.client, userInfo); +} + +async function getUserInfo(client: BaseCommandOptions['client']) { + const spinner = demoSpinner('Retrieving information about you…').start(); + const { userInfo, error } = await fetchUserInfo(client); + + if (error) { + spinner.fail(error.message); + program.error(error.message); + } + + spinner.succeed( + `You are signed in as ${pc.bold(userInfo.userEmail)} in organization ${pc.bold(userInfo.organizationName)}.`, + ); + + return userInfo; +} + +export default function (opts: BaseCommandOptions) { + return async function handleCommand() { + const controller = new AbortController(); + + try { + clearScreen(); + printHello(); + const supportDir = await prepareSupportingData(); + await checkDockerReadiness(); + const userInfo = await getUserInfo(opts.client); + updateScreenWithUserInfo(userInfo); + + await waitForKeyPress( + { + Enter: () => undefined, + }, + `It is recommended you run this command along the onboarding wizard at ${config.baseURL}/onboarding with the same account.\nPress ENTER to continue…`, + ); + + resetScreen(userInfo); + + const onboardingCheck = await handleStep1(opts, userInfo); + + if (!onboardingCheck) { + return; + } + + const logPath = getDemoLogPath(); + + const step2Result = await handleStep2(opts, { + onboarding: onboardingCheck, + userInfo, + supportDir, + signal: controller.signal, + logPath, + }); + + if (!step2Result) { + return; + } + + const routerBaseUrl = new URL(step2Result.routingUrl).origin; + await handleStep3(opts, { userInfo, routerBaseUrl, signal: controller.signal, logPath }); + } finally { + // no-op + } + }; +} diff --git a/cli/src/commands/demo/index.ts b/cli/src/commands/demo/index.ts new file mode 100644 index 0000000000..b7274246cb --- /dev/null +++ b/cli/src/commands/demo/index.ts @@ -0,0 +1,17 @@ +import { Command } from 'commander'; +import { BaseCommandOptions } from '../../core/types/types.js'; +import { checkAuth } from '../auth/utils.js'; +import demoCommandFactory from './command.js'; + +export default (opts: BaseCommandOptions) => { + const command = new Command('demo'); + command.description('Prepares demo federated graphs and facilitates onboarding'); + + command.hook('preAction', async () => { + await checkAuth(); + }); + + command.action(demoCommandFactory(opts)); + + return command; +}; diff --git a/cli/src/commands/demo/types.ts b/cli/src/commands/demo/types.ts new file mode 100644 index 0000000000..ae5fb794be --- /dev/null +++ b/cli/src/commands/demo/types.ts @@ -0,0 +1,6 @@ +import type { WhoAmIResponse } from '@wundergraph/cosmo-connect/dist/platform/v1/platform_pb'; + +export type UserInfo = { + userEmail: WhoAmIResponse['userEmail']; + organizationName: WhoAmIResponse['organizationName']; +}; diff --git a/cli/src/commands/demo/util.ts b/cli/src/commands/demo/util.ts new file mode 100644 index 0000000000..6ea457ad7b --- /dev/null +++ b/cli/src/commands/demo/util.ts @@ -0,0 +1,489 @@ +import fs from 'node:fs/promises'; +import { createWriteStream, existsSync, mkdirSync, type WriteStream } from 'node:fs'; +import path from 'node:path'; +import { program } from 'commander'; +import { execa, type ResultPromise } from 'execa'; +import ora from 'ora'; +import pc from 'picocolors'; +import { z } from 'zod'; +import { config, cacheDir } from '../../core/config.js'; +import { getDefaultPlatforms, publishPluginPipeline, readPluginFiles } from '../../core/plugin-publish.js'; +import type { BaseCommandOptions } from '../../core/types/types.js'; +import { visibleLength } from '../../utils.js'; +import type { UserInfo } from './types.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?: string) { + return ora({ text, discardStdin: false }); +} + +/** + * Clears whole screen + */ +export function clearScreen() { + process.stdout.write('\u001Bc'); +} + +export function resetScreen(userInfo?: UserInfo) { + clearScreen(); + printLogo(userInfo); +} + +/** + * Fancy WG logo + */ +export function printLogo(userInfo?: 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: string) { + 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: 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.'); + program.error(`GitHub API error: ${treeResponse.statusText}`); + } + + const parsed = GitHubTreeSchema.safeParse(await treeResponse.json()); + if (!parsed.success) { + spinner.fail('Failed to parse repository tree.'); + program.error('Unexpected response format from GitHub API. The repository structure may have changed.'); + } + + 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) { + spinner.fail(`Failed to fetch some files from onboarding repository or store them in ${cosmoDir}.`); + program.error(failed.map((f) => ` ${f.path}: ${f.error}`).join('\n')); + } + + spinner.succeed(`Support files copied to ${pc.bold(cosmoDir)}`); + + return cosmoDir; +} + +async function isDockerAvailable(): Promise { + try { + await execa('docker', ['version', '--format', '{{.Client.Version}}']); + return true; + } catch { + return false; + } +} + +async function isBuildxAvailable(): Promise { + try { + await execa('docker', ['buildx', 'version']); + return true; + } catch { + return false; + } +} + +async function hasDockerContainerBuilder(): Promise { + 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: string): Promise { + 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(): Promise { + const spinner = demoSpinner('Checking Docker availability…').start(); + + if (!(await isDockerAvailable())) { + spinner.fail('Docker is not available.'); + 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())) { + spinner.fail('Docker Buildx is not available.'); + 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) { + spinner.fail(`Failed to create buildx builder "${config.dockerBuilderName}".`); + program.error( + `Could not create a docker-container buildx builder: ${err instanceof Error ? err.message : String(err)}\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(): string { + const cosmoDir = path.join(cacheDir, 'demo'); + if (!existsSync(cosmoDir)) { + mkdirSync(cosmoDir, { recursive: true }); + } + return path.join(cosmoDir, 'demo.log'); +} + +function pipeToLog(logStream: WriteStream, proc: ResultPromise) { + proc.stdout?.pipe(logStream, { end: false }); + proc.stderr?.pipe(logStream, { end: false }); +} + +/** + * Rewrite localhost to host.docker.internal so the container can + * reach services running on the host machine. + */ +function toDockerHost(url: string) { + 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(): Promise { + 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, +}: { + routerBaseUrl: string; + signal: AbortSignal; + intervalMs?: number; + maxAttempts?: number; +}): Promise { + 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 + } + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + } + + 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, + onReady, +}: { + routerToken: string; + routerBaseUrl: string; + signal: AbortSignal; + logPath: string; + onReady?: () => void; +}): Promise<{ error: Error | null }> { + 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: Array<[string, string | undefined]> = [ + ['CDN_URL', process.env.CDN_URL], + ['REGISTRY_URL', process.env.PLUGIN_REGISTRY_URL], + ['PLUGINS_REGISTRY_URL', process.env.PLUGIN_REGISTRY_URL], + ['CONTROLPLANE_URL', process.env.COSMO_API_URL], + ['DEFAULT_TELEMETRY_ENDPOINT', process.env.DEFAULT_TELEMETRY_ENDPOINT], + ['GRAPHQL_METRICS_COLLECTOR_ENDPOINT', process.env.GRAPHQL_METRICS_COLLECTOR_ENDPOINT], + ]; + + 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(); + + try { + const proc = execa('docker', args, { + stdio: 'pipe', + ...(signal ? { cancelSignal: signal } : {}), + }); + + pipeToLog(logStream, proc); + + // Poll readiness in parallel with the long-running docker process + waitForRouterReady({ routerBaseUrl, signal }).then((ready) => { + if (ready) { + spinner.succeed(`Router is ready on ${pc.bold(routerBaseUrl)}.`); + console.log(pc.dim(`(logs: ${logPath})`)); + onReady?.(); + } else if (!signal.aborted) { + spinner.warn('Router started but readiness check timed out. It may still be starting.'); + console.log(pc.dim(`(logs: ${logPath})`)); + } + }); + + await proc; + } catch (error) { + // Graceful abort — not an error + if (error instanceof Error && 'isCanceled' in error && (error as any).isCanceled) { + return { error: null }; + } + spinner.fail('Router failed to start.'); + return { error: error instanceof Error ? error : new Error(String(error)) }; + } finally { + 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, +}: { + client: BaseCommandOptions['client']; + supportDir: string; + signal: AbortSignal; + logPath: string; +}) { + 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 platforms = [...new Set([...getDefaultPlatforms(), 'linux/amd64', 'linux/arm64'])]; + 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, + 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 }; +} diff --git a/cli/src/commands/index.ts b/cli/src/commands/index.ts index 68a4c87a61..39bd1447e9 100644 --- a/cli/src/commands/index.ts +++ b/cli/src/commands/index.ts @@ -5,6 +5,7 @@ import { config, configDir } from '../core/config.js'; import { checkForUpdates } from '../utils.js'; import { capture } from '../core/telemetry.js'; import AuthCommands from './auth/index.js'; +import DemoCommands from './demo/index.js'; import MonographCommands from './graph/monograph/index.js'; import FederatedGraphCommands from './graph/federated-graph/index.js'; import NamespaceCommands from './namespace/index.js'; @@ -64,6 +65,11 @@ program.addCommand( client, }), ); +program.addCommand( + DemoCommands({ + client, + }), +); program.addCommand( OperationCommands({ client, diff --git a/cli/src/commands/router/commands/plugin/commands/publish.ts b/cli/src/commands/router/commands/plugin/commands/publish.ts index dba3c67ec1..3b3a23942d 100644 --- a/cli/src/commands/router/commands/plugin/commands/publish.ts +++ b/cli/src/commands/router/commands/plugin/commands/publish.ts @@ -1,62 +1,18 @@ import { existsSync } from 'node:fs'; -import { readFile } from 'node:fs/promises'; -import { arch, platform } from 'node:os'; import { EnumStatusCode } from '@wundergraph/cosmo-connect/dist/common/common_pb'; -import { SubgraphType } from '@wundergraph/cosmo-connect/dist/platform/v1/platform_pb'; -import { splitLabel } from '@wundergraph/cosmo-shared'; import Table from 'cli-table3'; import { Command, program } from 'commander'; -import { execa } from 'execa'; import ora from 'ora'; import path, { resolve } from 'pathe'; import pc from 'picocolors'; -import { config, getBaseHeaders } from '../../../../../core/config.js'; +import { + getDefaultPlatforms, + publishPluginPipeline, + readPluginFiles, + SUPPORTED_PLATFORMS, +} from '../../../../../core/plugin-publish.js'; import { BaseCommandOptions } from '../../../../../core/types/types.js'; -function getDefaultPlatforms(): string[] { - const supportedPlatforms = ['linux/amd64', 'linux/arm64', 'darwin/amd64', 'darwin/arm64', 'windows/amd64']; - const defaultPlatforms = ['linux/amd64']; - - // Get current OS and architecture - const currentPlatform = platform(); - const currentArch = arch(); - - // Map Node.js platform/arch to Docker platform format - let dockerPlatform: string | null = null; - - switch (currentPlatform) { - case 'linux': { - if (currentArch === 'x64') { - dockerPlatform = 'linux/amd64'; - } else if (currentArch === 'arm64') { - dockerPlatform = 'linux/arm64'; - } - break; - } - case 'darwin': { - if (currentArch === 'x64') { - dockerPlatform = 'darwin/amd64'; - } else if (currentArch === 'arm64') { - dockerPlatform = 'darwin/arm64'; - } - break; - } - case 'win32': { - if (currentArch === 'x64') { - dockerPlatform = 'windows/amd64'; - } - break; - } - } - - // Add user's platform to defaults if supported and not already included - if (dockerPlatform && supportedPlatforms.includes(dockerPlatform) && !defaultPlatforms.includes(dockerPlatform)) { - defaultPlatforms.push(dockerPlatform); - } - - return defaultPlatforms; -} - export default (opts: BaseCommandOptions) => { const command = new Command('publish'); command.description( @@ -100,199 +56,64 @@ export default (opts: BaseCommandOptions) => { const pluginName = options.name || path.basename(pluginDir); - const schemaFile = resolve(pluginDir, 'src', 'schema.graphql'); - const dockerFile = resolve(pluginDir, 'Dockerfile'); - const protoSchemaFile = resolve(pluginDir, 'generated', 'service.proto'); - const protoMappingFile = resolve(pluginDir, 'generated', 'mapping.json'); - const protoLockFile = resolve(pluginDir, 'generated', 'service.proto.lock.json'); - - if (!existsSync(schemaFile)) { - program.error( - pc.red( - pc.bold(`The schema file '${pc.bold(schemaFile)}' does not exist. Please check the path and try again.`), - ), - ); - } - - const schemaBuffer = await readFile(schemaFile); - const schema = new TextDecoder().decode(schemaBuffer); - if (schema.trim().length === 0) { - program.error( - pc.red(pc.bold(`The schema file '${pc.bold(schemaFile)}' is empty. Please provide a valid schema.`)), - ); - } - - if (!existsSync(dockerFile)) { - program.error( - pc.red( - pc.bold(`The docker file '${pc.bold(dockerFile)}' does not exist. Please check the path and try again.`), - ), - ); - } - - if (!existsSync(protoSchemaFile)) { - program.error( - pc.red( - pc.bold( - `The proto schema file '${pc.bold(protoSchemaFile)}' does not exist. Please check the path and try again.`, - ), - ), - ); - } - const protoSchemaBuffer = await readFile(protoSchemaFile); - const protoSchema = new TextDecoder().decode(protoSchemaBuffer); - if (protoSchema.trim().length === 0) { - program.error( - pc.red(pc.bold(`The proto schema file '${pc.bold(protoSchemaFile)}' is empty. Please provide a valid schema.`)), - ); - } - - if (!existsSync(protoMappingFile)) { - program.error( - pc.red( - pc.bold( - `The proto mapping file '${pc.bold(protoMappingFile)}' does not exist. Please check the path and try again.`, - ), - ), - ); - } - const protoMappingBuffer = await readFile(protoMappingFile); - const protoMapping = new TextDecoder().decode(protoMappingBuffer); - if (protoMapping.trim().length === 0) { - program.error( - pc.red( - pc.bold(`The proto mapping file '${pc.bold(protoMappingFile)}' is empty. Please provide a valid mapping.`), - ), - ); - } - - if (!existsSync(protoLockFile)) { - program.error( - pc.red( - pc.bold( - `The proto lock file '${pc.bold(protoLockFile)}' does not exist. Please check the path and try again.`, - ), - ), - ); - } - const protoLockBuffer = await readFile(protoLockFile); - const protoLock = new TextDecoder().decode(protoLockBuffer); - if (protoLock.trim().length === 0) { - program.error( - pc.red(pc.bold(`The proto lock file '${pc.bold(protoLockFile)}' is empty. Please provide a valid lock.`)), - ); - } - // Validate platforms - const supportedPlatforms = ['linux/amd64', 'linux/arm64', 'darwin/amd64', 'darwin/arm64', 'windows/amd64']; if (options.platform && options.platform.length > 0) { - const invalidPlatforms = options.platform.filter((platform: string) => !supportedPlatforms.includes(platform)); + const invalidPlatforms = options.platform.filter((platform: string) => !SUPPORTED_PLATFORMS.includes(platform)); if (invalidPlatforms.length > 0) { program.error( pc.red( pc.bold( - `Invalid platform(s): ${invalidPlatforms.join(', ')}. Supported platforms are: ${supportedPlatforms.join(', ')}`, + `Invalid platform(s): ${invalidPlatforms.join(', ')}. Supported platforms are: ${SUPPORTED_PLATFORMS.join(', ')}`, ), ), ); } } + // Read and validate plugin files + let files; + try { + files = await readPluginFiles(pluginDir); + } catch (error) { + program.error(pc.red(pc.bold(error instanceof Error ? error.message : String(error)))); + } + const spinner = ora('Plugin is being published...').start(); - const pluginDataResponse = await opts.client.platform.validateAndFetchPluginData( - { - name: pluginName, - namespace: options.namespace, - labels: options.label.map((label: string) => splitLabel(label)), + const result = await publishPluginPipeline({ + client: opts.client, + pluginName, + pluginDir, + namespace: options.namespace, + labels: options.label, + platforms: options.platform || [], + files, + onProcess: (proc) => { + proc.stdout?.pipe(process.stdout); + proc.stderr?.pipe(process.stderr); }, - { - headers: getBaseHeaders(), - }, - ); + }); - if (pluginDataResponse.response?.code !== EnumStatusCode.OK) { - program.error(pc.red(pc.bold(pluginDataResponse.response?.details))); + if (result.error && !result.response) { + spinner.fail(result.error.message); + program.error(pc.red(pc.bold(result.error.message))); } - const reference = pluginDataResponse.reference; - const newVersion = pluginDataResponse.newVersion; - const pushToken = pluginDataResponse.pushToken; - - // upload the docker image to the registry - const platforms = options.platform && options.platform.join(','); - const imageTag = `${config.pluginRegistryURL}/${reference}:${newVersion}`; - - try { - // Docker login - spinner.text = 'Logging into Cosmo registry...'; - await execa('docker', ['login', config.pluginRegistryURL, '-u', 'x', '--password-stdin'], { - stdio: 'pipe', - input: pushToken, - }); - - // Docker buildx build - spinner.text = 'Building and pushing Docker image...'; - await execa( - 'docker', - [ - 'buildx', - 'build', - '--sbom=false', - '--provenance=false', - '--push', - '--platform', - platforms, - '-f', - dockerFile, - '-t', - imageTag, - pluginDir, - ], - { - stdio: 'inherit', - }, - ); - - // Docker logout - spinner.text = 'Logging out of Cosmo registry...'; - await execa('docker', ['logout', config.pluginRegistryURL], { - stdio: 'pipe', - }); - - spinner.text = 'Subgraph is being published...'; - } catch (error) { - spinner.fail(`Failed to build and push Docker image: ${error instanceof Error ? error.message : String(error)}`); - program.error( - pc.red(pc.bold(`Docker operation failed: ${error instanceof Error ? error.message : String(error)}`)), - ); + if (result.error) { + spinner.fail(`Failed to publish plugin "${pluginName}".`); + if (result.response?.details) { + console.error(pc.red(pc.bold(result.response.details))); + } + process.exitCode = 1; + return; } - const resp = await opts.client.platform.publishFederatedSubgraph( - { - name: pluginName, - namespace: options.namespace, - schema, - // Optional when subgraph does not exist yet - labels: options.label.map((label: string) => splitLabel(label)), - type: SubgraphType.GRPC_PLUGIN, - proto: { - schema: protoSchema, - mappings: protoMapping, - lock: protoLock, - platforms: options.platform || [], - version: newVersion, - }, - }, - { - headers: getBaseHeaders(), - }, - ); + const resp = result.response!; - switch (resp.response?.code) { + switch (resp.code) { case EnumStatusCode.OK: { spinner.succeed( - resp?.hasChanged === false + resp.hasChanged === false ? 'No new changes to publish.' : `Plugin ${pc.bold(pluginName)} published successfully.`, ); @@ -387,8 +208,8 @@ export default (opts: BaseCommandOptions) => { } default: { spinner.fail(`Failed to publish plugin "${pluginName}".`); - if (resp.response?.details) { - console.error(pc.red(pc.bold(resp.response?.details))); + if (resp.details) { + console.error(pc.red(pc.bold(resp.details))); } process.exitCode = 1; return; diff --git a/cli/src/commands/router/commands/token/commands/create.ts b/cli/src/commands/router/commands/token/commands/create.ts index 4da5b31b09..643d694ff9 100644 --- a/cli/src/commands/router/commands/token/commands/create.ts +++ b/cli/src/commands/router/commands/token/commands/create.ts @@ -1,8 +1,7 @@ import { Command } from 'commander'; import pc from 'picocolors'; -import { EnumStatusCode } from '@wundergraph/cosmo-connect/dist/common/common_pb'; import { BaseCommandOptions } from '../../../../../core/types/types.js'; -import { getBaseHeaders } from '../../../../../core/config.js'; +import { createRouterToken } from '../../../../../core/router-token.js'; export default (opts: BaseCommandOptions) => { const command = new Command('create'); @@ -20,39 +19,34 @@ export default (opts: BaseCommandOptions) => { 'Prints the token in raw format. This is useful if you want to pipe the token into another command.', ); command.action(async (name, options) => { - const resp = await opts.client.platform.createFederatedGraphToken( - { - tokenName: name, - graphName: options.graphName, - namespace: options.namespace, - }, - { - headers: getBaseHeaders(), - }, - ); + const result = await createRouterToken({ + client: opts.client, + tokenName: name, + graphName: options.graphName, + namespace: options.namespace, + }); - if (resp.response?.code === EnumStatusCode.OK) { - if (options.raw) { - console.log(resp.token); - return; - } - - console.log(`${pc.green(`Successfully created token ${pc.bold(name)} for graph ${pc.bold(options.graphName)}`)}`); - console.log(''); - console.log(`${pc.bold(resp.token)}\n`); - console.log(pc.yellow('---')); - console.log(pc.yellow(`Please store the token in a secure place. It will not be shown again.`)); - console.log(pc.yellow(`You can use the token only to authenticate against the Cosmo Platform from the routers.`)); - console.log(pc.yellow('---')); - } else { + if (result.error) { console.log(`${pc.red('Could not create token for graph')}`); - if (resp.response?.details) { - console.log(pc.red(pc.bold(resp.response?.details))); + if (result.error.message) { + console.log(pc.red(pc.bold(result.error.message))); } process.exitCode = 1; - // eslint-disable-next-line no-useless-return return; } + + if (options.raw) { + console.log(result.token); + return; + } + + console.log(`${pc.green(`Successfully created token ${pc.bold(name)} for graph ${pc.bold(options.graphName)}`)}`); + console.log(''); + console.log(`${pc.bold(result.token)}\n`); + console.log(pc.yellow('---')); + console.log(pc.yellow(`Please store the token in a secure place. It will not be shown again.`)); + console.log(pc.yellow(`You can use the token only to authenticate against the Cosmo Platform from the routers.`)); + console.log(pc.yellow('---')); }); return command; diff --git a/cli/src/commands/router/commands/token/commands/delete.ts b/cli/src/commands/router/commands/token/commands/delete.ts index 67ebf0aeb3..78b45e65b3 100644 --- a/cli/src/commands/router/commands/token/commands/delete.ts +++ b/cli/src/commands/router/commands/token/commands/delete.ts @@ -1,9 +1,8 @@ import { Command } from 'commander'; import pc from 'picocolors'; -import { EnumStatusCode } from '@wundergraph/cosmo-connect/dist/common/common_pb'; import inquirer from 'inquirer'; import { BaseCommandOptions } from '../../../../../core/types/types.js'; -import { getBaseHeaders } from '../../../../../core/config.js'; +import { deleteRouterToken } from '../../../../../core/router-token.js'; export default (opts: BaseCommandOptions) => { const command = new Command('delete'); @@ -27,28 +26,24 @@ export default (opts: BaseCommandOptions) => { return; } } - const resp = await opts.client.platform.deleteRouterToken( - { - tokenName: name, - fedGraphName: options.graphName, - namespace: options.namespace, - }, - { - headers: getBaseHeaders(), - }, - ); - if (resp.response?.code === EnumStatusCode.OK) { - console.log(pc.dim(pc.green(`A router token called '${name}' was deleted.`))); - } else { + const result = await deleteRouterToken({ + client: opts.client, + tokenName: name, + graphName: options.graphName, + namespace: options.namespace, + }); + + if (result.error) { console.log(`Failed to delete router token ${pc.bold(name)}.`); - if (resp.response?.details) { - console.log(pc.red(pc.bold(resp.response?.details))); + if (result.error.message) { + console.log(pc.red(pc.bold(result.error.message))); } process.exitCode = 1; - // eslint-disable-next-line no-useless-return return; } + + console.log(pc.dim(pc.green(`A router token called '${name}' was deleted.`))); }); return command; diff --git a/cli/src/core/config.ts b/cli/src/core/config.ts index db0edfc7ee..c90196b21f 100644 --- a/cli/src/core/config.ts +++ b/cli/src/core/config.ts @@ -8,6 +8,7 @@ import info from '../../package.json' with { type: 'json' }; const paths = envPaths('cosmo', { suffix: '' }); export const configDir = paths.config; export const dataDir = paths.data; +export const cacheDir = paths.cache; export const configFile = join(configDir, 'config.yaml'); export const getLoginDetails = (): { accessToken: string; organizationSlug: string } | null => { @@ -35,6 +36,19 @@ export const config = { checkCommitSha: process.env.COSMO_VCS_COMMIT || '', checkBranch: process.env.COSMO_VCS_BRANCH || '', pluginRegistryURL: process.env.PLUGIN_REGISTRY_URL || 'cosmo-registry.wundergraph.com', + demoLabelMatcher: 'graph=demo' as const, + demoGraphName: 'demo' as const, + demoNamespace: 'default' as const, + demoOnboardingRepositoryName: 'wundergraph/cosmo-onboarding' as const, + demoOnboardingRepositoryBranch: 'main' as const, + dockerBuilderName: 'cosmo-builder' as const, + defaultTelemetryEndpoint: process.env.DEFAULT_TELEMETRY_ENDPOINT, + graphqlMetricsCollectorEndpoint: process.env.GRAPHQL_METRICS_COLLECTOR_ENDPOINT, + demoRouterPort: 3002 as const, + demoPluginNames: ['products', 'reviews'] as const, + demoRouterTokenName: 'demo-router-token' as const, + demoRouterImage: 'ghcr.io/wundergraph/cosmo/router:latest' as const, + demoRouterContainerName: 'cosmo-router' as const, }; export const getBaseHeaders = (): HeadersInit => { diff --git a/cli/src/core/plugin-publish.ts b/cli/src/core/plugin-publish.ts new file mode 100644 index 0000000000..b4b6bfba67 --- /dev/null +++ b/cli/src/core/plugin-publish.ts @@ -0,0 +1,257 @@ +import { readFile } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { arch, platform } from 'node:os'; +import path from 'node:path'; +import { EnumStatusCode } from '@wundergraph/cosmo-connect/dist/common/common_pb'; +import { SubgraphType } from '@wundergraph/cosmo-connect/dist/platform/v1/platform_pb'; +import { splitLabel } from '@wundergraph/cosmo-shared'; +import { execa, type ResultPromise } from 'execa'; +import { config, getBaseHeaders } from './config.js'; +import type { BaseCommandOptions } from './types/types.js'; + +export interface PluginFiles { + schema: string; + dockerFile: string; + protoSchema: string; + protoMapping: string; + protoLock: string; +} + +export interface PluginPublishParams { + client: BaseCommandOptions['client']; + pluginName: string; + pluginDir: string; + namespace: string; + labels: string[]; + platforms: string[]; + files: PluginFiles; + cancelSignal?: AbortSignal; + /** Called with each spawned execa process so the caller can pipe/inherit output. */ + onProcess?: (proc: ResultPromise) => void; +} + +export interface PluginPublishResult { + error: Error | null; + /** Raw response from publishFederatedSubgraph, available when the RPC call was reached. */ + response?: { + code?: number; + details?: string; + hasChanged?: boolean; + compositionErrors: Array<{ federatedGraphName: string; namespace: string; featureFlag: string; message: string }>; + deploymentErrors: Array<{ federatedGraphName: string; namespace: string; message: string }>; + compositionWarnings: Array<{ + federatedGraphName: string; + namespace: string; + featureFlag: string; + message: string; + }>; + proposalMatchMessage?: string; + }; +} + +export function getDefaultPlatforms(): string[] { + const supportedPlatforms = ['linux/amd64', 'linux/arm64', 'darwin/amd64', 'darwin/arm64', 'windows/amd64']; + const defaultPlatforms = ['linux/amd64']; + + const currentPlatform = platform(); + const currentArch = arch(); + + let dockerPlatform: string | null = null; + + switch (currentPlatform) { + case 'linux': { + if (currentArch === 'x64') { + dockerPlatform = 'linux/amd64'; + } else if (currentArch === 'arm64') { + dockerPlatform = 'linux/arm64'; + } + break; + } + case 'darwin': { + if (currentArch === 'x64') { + dockerPlatform = 'darwin/amd64'; + } else if (currentArch === 'arm64') { + dockerPlatform = 'darwin/arm64'; + } + break; + } + case 'win32': { + if (currentArch === 'x64') { + dockerPlatform = 'windows/amd64'; + } + break; + } + } + + if (dockerPlatform && supportedPlatforms.includes(dockerPlatform) && !defaultPlatforms.includes(dockerPlatform)) { + defaultPlatforms.push(dockerPlatform); + } + + return defaultPlatforms; +} + +export const SUPPORTED_PLATFORMS = ['linux/amd64', 'linux/arm64', 'darwin/amd64', 'darwin/arm64', 'windows/amd64']; + +/** + * Reads and validates the 5 required plugin files from a plugin directory. + * Throws on missing/empty files. + */ +export async function readPluginFiles(pluginDir: string): Promise { + const schemaFile = path.join(pluginDir, 'src', 'schema.graphql'); + const dockerFile = path.join(pluginDir, 'Dockerfile'); + const protoSchemaFile = path.join(pluginDir, 'generated', 'service.proto'); + const protoMappingFile = path.join(pluginDir, 'generated', 'mapping.json'); + const protoLockFile = path.join(pluginDir, 'generated', 'service.proto.lock.json'); + + const requiredFiles = [schemaFile, dockerFile, protoSchemaFile, protoMappingFile, protoLockFile]; + for (const f of requiredFiles) { + if (!existsSync(f)) { + throw new Error(`Required file does not exist: ${f}`); + } + } + + async function readNonEmpty(filePath: string): Promise { + const buffer = await readFile(filePath); + const content = new TextDecoder().decode(buffer); + if (content.trim().length === 0) { + throw new Error(`File is empty: ${filePath}`); + } + return content; + } + + const [schema, protoSchema, protoMapping, protoLock] = await Promise.all([ + readNonEmpty(schemaFile), + readNonEmpty(protoSchemaFile), + readNonEmpty(protoMappingFile), + readNonEmpty(protoLockFile), + ]); + + return { schema, dockerFile, protoSchema, protoMapping, protoLock }; +} + +/** + * Core plugin publish pipeline: + * 1. validateAndFetchPluginData (RPC) + * 2. Docker login → buildx build+push → logout + * 3. publishFederatedSubgraph (RPC) + * + * Returns a result object; never calls program.error() — the caller decides + * how to handle errors. + */ +export async function publishPluginPipeline(params: PluginPublishParams): Promise { + const { client, pluginName, pluginDir, namespace, labels, platforms, files, cancelSignal, onProcess } = params; + + // Step 1: Validate and fetch plugin data + const pluginDataResponse = await client.platform.validateAndFetchPluginData( + { + name: pluginName, + namespace, + labels: labels.map((label) => splitLabel(label)), + }, + { + headers: getBaseHeaders(), + }, + ); + + if (pluginDataResponse.response?.code !== EnumStatusCode.OK) { + return { error: new Error(pluginDataResponse.response?.details ?? 'Failed to validate plugin data') }; + } + + const { reference, newVersion, pushToken } = pluginDataResponse; + const imageTag = `${config.pluginRegistryURL}/${reference}:${newVersion}`; + const platformStr = platforms.join(','); + + // Step 2: Docker operations + try { + const loginProc = execa('docker', ['login', config.pluginRegistryURL, '-u', 'x', '--password-stdin'], { + stdio: 'pipe', + input: pushToken, + ...(cancelSignal ? { cancelSignal } : {}), + }); + onProcess?.(loginProc); + await loginProc; + + const buildProc = execa( + 'docker', + [ + 'buildx', + 'build', + '--sbom=false', + '--provenance=false', + '--push', + '--platform', + platformStr, + '-f', + files.dockerFile, + '-t', + imageTag, + pluginDir, + ], + { + stdio: 'pipe', + ...(cancelSignal ? { cancelSignal } : {}), + }, + ); + onProcess?.(buildProc); + await buildProc; + } catch (error) { + return { error: new Error(`Docker operation failed: ${error instanceof Error ? error.message : String(error)}`) }; + } finally { + try { + const logoutProc = execa('docker', ['logout', config.pluginRegistryURL], { stdio: 'pipe' }); + onProcess?.(logoutProc); + await logoutProc; + } catch { + // best-effort logout + } + } + + // Step 3: Publish schema + const resp = await client.platform.publishFederatedSubgraph( + { + name: pluginName, + namespace, + schema: files.schema, + labels: labels.map((label) => splitLabel(label)), + type: SubgraphType.GRPC_PLUGIN, + proto: { + schema: files.protoSchema, + mappings: files.protoMapping, + lock: files.protoLock, + platforms, + version: newVersion, + }, + }, + { + headers: getBaseHeaders(), + }, + ); + + const result: PluginPublishResult = { + error: null, + response: { + code: resp.response?.code, + details: resp.response?.details, + hasChanged: resp.hasChanged, + compositionErrors: resp.compositionErrors, + deploymentErrors: resp.deploymentErrors, + compositionWarnings: resp.compositionWarnings, + proposalMatchMessage: resp.proposalMatchMessage, + }, + }; + + switch (resp.response?.code) { + case EnumStatusCode.OK: + case EnumStatusCode.ERR_SUBGRAPH_COMPOSITION_FAILED: + case EnumStatusCode.ERR_DEPLOYMENT_FAILED: + case EnumStatusCode.ERR_SCHEMA_MISMATCH_WITH_APPROVED_PROPOSAL: { + return result; + } + default: { + return { + ...result, + error: new Error(resp.response?.details ?? 'Failed to publish plugin subgraph'), + }; + } + } +} diff --git a/cli/src/core/router-token.ts b/cli/src/core/router-token.ts new file mode 100644 index 0000000000..9c83432274 --- /dev/null +++ b/cli/src/core/router-token.ts @@ -0,0 +1,81 @@ +import { EnumStatusCode } from '@wundergraph/cosmo-connect/dist/common/common_pb'; +import { getBaseHeaders } from './config.js'; +import type { BaseCommandOptions } from './types/types.js'; + +export interface CreateRouterTokenParams { + client: BaseCommandOptions['client']; + tokenName: string; + graphName: string; + namespace?: string; +} + +export interface CreateRouterTokenResult { + error: Error | null; + token?: string; +} + +export interface DeleteRouterTokenParams { + client: BaseCommandOptions['client']; + tokenName: string; + graphName: string; + namespace?: string; +} + +export interface DeleteRouterTokenResult { + error: Error | null; +} + +/** + * Creates a router token for a federated graph. + * Never calls program.error() — caller decides how to handle errors. + */ +export async function createRouterToken(params: CreateRouterTokenParams): Promise { + const { client, tokenName, graphName, namespace } = params; + + const resp = await client.platform.createFederatedGraphToken( + { + tokenName, + graphName, + namespace, + }, + { + headers: getBaseHeaders(), + }, + ); + + if (resp.response?.code === EnumStatusCode.OK) { + return { error: null, token: resp.token }; + } + + return { error: new Error(resp.response?.details ?? 'Could not create router token') }; +} + +/** + * Deletes a router token. Idempotent — returns success if token doesn't exist. + * Never calls program.error() — caller decides how to handle errors. + */ +export async function deleteRouterToken(params: DeleteRouterTokenParams): Promise { + const { client, tokenName, graphName, namespace } = params; + + const resp = await client.platform.deleteRouterToken( + { + tokenName, + fedGraphName: graphName, + namespace, + }, + { + headers: getBaseHeaders(), + }, + ); + + if (resp.response?.code === EnumStatusCode.OK) { + return { error: null }; + } + + // Treat "doesn't exist" as success (idempotent) + if (resp.response?.details?.includes("doesn't exist")) { + return { error: null }; + } + + return { error: new Error(resp.response?.details ?? 'Could not delete router token') }; +} diff --git a/cli/src/utils.ts b/cli/src/utils.ts index b73af81943..ef31dabd1a 100644 --- a/cli/src/utils.ts +++ b/cli/src/utils.ts @@ -302,6 +302,54 @@ type PrintTruncationWarningParams = { totalErrorCounts?: SubgraphPublishStats; }; +/** + * Waits for a single keypress matching one of the keys in the provided map. + * Keys are case-sensitive strings. Use 'Enter' for the enter key. + * For non-Enter keys, the callback fires immediately on keypress (no Enter required). + * Ctrl+C always exits the process. + */ +export function waitForKeyPress( + keyMap: Record unknown | Promise) | undefined>, + message?: string, +): Promise { + const { promise, resolve } = Promise.withResolvers(); + + if (message) { + process.stdout.write(pc.dim(message)); + } + + process.stdin.setRawMode(true); + process.stdin.resume(); + + const onData = async (data: Buffer) => { + const key = data.toString(); + + // Ctrl+C + if (key === '\u0003') { + process.stdin.setRawMode(false); + process.stdin.pause(); + process.stdout.write('\n'); + process.exit(0); + } + + // Normalize Enter (\r or \n) + const normalized = key === '\r' || key === '\n' ? 'Enter' : key; + + if (normalized in keyMap) { + process.stdin.removeListener('data', onData); + process.stdin.setRawMode(false); + process.stdin.pause(); + process.stdout.write('\n'); + await keyMap[normalized]?.(); + resolve(); + } + }; + + process.stdin.on('data', onData); + + return promise; +} + export function printTruncationWarning({ displayedErrorCounts, totalErrorCounts }: PrintTruncationWarningParams) { if (!totalErrorCounts) { return; @@ -329,3 +377,55 @@ export function printTruncationWarning({ displayedErrorCounts, totalErrorCounts console.log(pc.yellow(`\nNote: Some results were truncated: ${truncatedItems.join(', ')}.`)); } } + +/** + * Prints text with rainbow-like effect. Respects NO_COLOR + */ +export function rainbow(text: string): string { + if (!pc.isColorSupported) { + return text; + } + const chars = [...text]; + return ( + chars + .map((char, i) => { + const t = chars.length > 1 ? i / (chars.length - 1) : 0; + const [r, g, b] = interpolateColor(t); + return `\u001B[38;2;${r};${g};${b}m${char}`; + }) + .join('') + '\u001B[0m' + ); +} + +/** Strips ANSI SGR escape sequences (colors, bold, dim, etc.) from a string. */ +export function stripAnsi(s: string): string { + const ESC = String.fromCodePoint(0x1b); + return s.replaceAll(new RegExp(`${ESC}\\[[\\d;]*m`, 'g'), ''); +} + +/** Returns the visible character count of a string, ignoring ANSI escape sequences. */ +export function visibleLength(s: string): number { + return stripAnsi(s).length; +} + +// Gradient color stops: pink → orange → yellow → green → cyan → blue → purple +const gradientStops: [number, number, number][] = [ + [255, 100, 150], // pink + [255, 160, 50], // orange + [255, 220, 50], // yellow + [80, 220, 100], // green + [50, 200, 220], // cyan + [80, 120, 255], // blue + [180, 100, 255], // purple +]; + +function interpolateColor(t: number): [number, number, number] { + const segment = t * (gradientStops.length - 1); + const i = Math.min(Math.floor(segment), gradientStops.length - 2); + const f = segment - i; + return [ + Math.round(gradientStops[i][0] + (gradientStops[i + 1][0] - gradientStops[i][0]) * f), + Math.round(gradientStops[i][1] + (gradientStops[i + 1][1] - gradientStops[i][1]) * f), + Math.round(gradientStops[i][2] + (gradientStops[i + 1][2] - gradientStops[i][2]) * f), + ]; +} diff --git a/controlplane/src/core/bufservices/organization/whoAmI.ts b/controlplane/src/core/bufservices/organization/whoAmI.ts index 9880c5cf52..3ce215ac3a 100644 --- a/controlplane/src/core/bufservices/organization/whoAmI.ts +++ b/controlplane/src/core/bufservices/organization/whoAmI.ts @@ -38,6 +38,7 @@ export function whoAmI( }, organizationName: organization.name, organizationSlug: organization.slug, + userEmail: authContext.userDisplayName, }; }); }