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..395f804d46 --- /dev/null +++ b/cli/src/commands/demo/api.ts @@ -0,0 +1,205 @@ +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']) { + try { + const response = await client.platform.whoAmI( + {}, + { + headers: getBaseHeaders(), + }, + ); + + switch (response.response?.code) { + case EnumStatusCode.OK: { + return { + userInfo: response, + error: null, + }; + } + default: { + return { + userInfo: null, + error: new Error(response.response?.details ?? 'An unknown error occurred.'), + }; + } + } + } catch (err) { + return { + userInfo: null, + error: err instanceof Error ? err : new Error('An unknown error occurred.'), + }; + } +} + +/** + * 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..6eb2b39c1e --- /dev/null +++ b/cli/src/commands/demo/command.ts @@ -0,0 +1,463 @@ +import pc from 'picocolors'; +import { program } from 'commander'; +import open from 'open'; +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}`); + return await waitForKeyPress( + { + r: retryFn, + R: retryFn, + }, + 'Hit [r] to refresh. CTRL+C to quit', + ); + } + + 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; + }, +) { + let deleted = false; + + 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) { + deleted = false; + spinner.fail(`Removing federated graph ${graphData.graph.name} failed.`); + console.error(deleteResponse.error.message); + + return 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.`, + ); + } else { + deleted = true; + } + + if (deleted) { + 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, + }); + + 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.'); + } else { + showQueryPrompt(); + } +} + +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); + } else if (!userInfo) { + spinner.fail('Could not retrieve information about your account.'); + program.error('Failed to retrieve user information.'); + } + + 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); + + const onboardingUrl = `${config.webURL}/onboarding`; + + async function openOnboardingUrl() { + const process = await open(onboardingUrl); + process.on('error', (error) => { + console.log(pc.yellow(`\nCouldn't open browser: ${error.message}`)); + }); + } + + await waitForKeyPress( + { + Enter: () => undefined, + s: { callback: openOnboardingUrl, persistent: true }, + S: { callback: openOnboardingUrl, persistent: true }, + }, + `It is recommended you run this command along the onboarding wizard at ${onboardingUrl} with the same account.\nPress [s] to open it in your browser, or [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..3cc47c4fc6 --- /dev/null +++ b/cli/src/commands/demo/types.ts @@ -0,0 +1,3 @@ +import type { WhoAmIResponse } from '@wundergraph/cosmo-connect/dist/platform/v1/platform_pb'; + +export type UserInfo = WhoAmIResponse; diff --git a/cli/src/commands/demo/util.ts b/cli/src/commands/demo/util.ts new file mode 100644 index 0000000000..b180e58592 --- /dev/null +++ b/cli/src/commands/demo/util.ts @@ -0,0 +1,513 @@ +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 + } + // 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, +}: { + routerToken: string; + routerBaseUrl: string; + signal: AbortSignal; + logPath: string; +}): 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', config.cdnURL], + ['REGISTRY_URL', config.pluginRegistryURL], + ['PLUGINS_REGISTRY_URL', config.pluginRegistryURL], + ['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 as any).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, +}: { + 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 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 }; +} diff --git a/cli/src/commands/index.ts b/cli/src/commands/index.ts index cb7a7175e3..c74e98d79c 100644 --- a/cli/src/commands/index.ts +++ b/cli/src/commands/index.ts @@ -4,6 +4,7 @@ import { config } 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'; @@ -63,6 +64,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..45652e410b 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-demo-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..8c48922c26 --- /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', 'linux/arm64']; + + 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 51acab671c..d63055cace 100644 --- a/cli/src/utils.ts +++ b/cli/src/utils.ts @@ -183,7 +183,11 @@ export const introspectSubgraph = async ({ */ export function composeSubgraphs(subgraphs: Subgraph[], options?: CompositionOptions): FederationResult { // @TODO get router compatibility version programmatically - return federateSubgraphs({ options, subgraphs, version: ROUTER_COMPATIBILITY_VERSION_ONE }); + return federateSubgraphs({ + options, + subgraphs, + version: ROUTER_COMPATIBILITY_VERSION_ONE, + }); } export type ConfigData = Partial; @@ -304,6 +308,73 @@ type PrintTruncationWarningParams = { totalErrorCounts?: SubgraphPublishStats; }; +type KeyPressCallback = () => unknown | Promise; + +/** + * 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. + * Each entry is either a callback function or a descriptor `{ callback, persistent }`. + * When `persistent` is true the callback fires but the prompt keeps listening, + * useful for side-effect actions (e.g. opening a URL) alongside a terminating key. + */ +export function waitForKeyPress( + keyMap: Record, + 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)) { + return; + } + + const entry = keyMap[normalized]; + if (!entry) { + return; + } + + const isDescriptor = typeof entry !== 'function'; + const callback = isDescriptor ? entry.callback : entry; + const persistent = isDescriptor ? entry.persistent : false; + + if (persistent) { + await callback(); + return; + } + + process.stdin.removeListener('data', onData); + process.stdin.setRawMode(false); + process.stdin.pause(); + process.stdout.write('\n'); + await callback(); + resolve(); + }; + + process.stdin.on('data', onData); + + return promise; +} + export function printTruncationWarning({ displayedErrorCounts, totalErrorCounts }: PrintTruncationWarningParams) { if (!totalErrorCounts) { return; @@ -331,3 +402,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/cli/test/demo/command.test.ts b/cli/test/demo/command.test.ts new file mode 100644 index 0000000000..93c2892f12 --- /dev/null +++ b/cli/test/demo/command.test.ts @@ -0,0 +1,269 @@ +import { Command } from 'commander'; +import { beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest'; +import { createPromiseClient, createRouterTransport, type ServiceImpl } from '@connectrpc/connect'; +import { PlatformService } from '@wundergraph/cosmo-connect/dist/platform/v1/platform_connect'; +import { FederatedGraph, Subgraph } from '@wundergraph/cosmo-connect/dist/platform/v1/platform_pb'; +import { EnumStatusCode } from '@wundergraph/cosmo-connect/dist/common/common_pb'; +import { Client } from '../../src/core/client/client.js'; +import DemoCommand from '../../src/commands/demo/index.js'; +import { waitForKeyPress } from '../../src/utils.js'; +import * as demoUtil from '../../src/commands/demo/util.js'; + +vi.mock('../../src/commands/auth/utils.js', async (importOriginal) => { + const mod = await importOriginal(); + return { ...mod, checkAuth: vi.fn().mockResolvedValue(undefined) }; +}); + +vi.mock('../../src/commands/demo/util.js', async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + prepareSupportingData: vi.fn(), + checkDockerReadiness: vi.fn(), + publishAllPlugins: vi.fn(), + runRouterContainer: vi.fn(), + getDemoLogPath: vi.fn(), + }; +}); + +vi.mock('../../src/utils.js', async (importOriginal) => { + const mod = await importOriginal(); + return { ...mod, waitForKeyPress: vi.fn() }; +}); + +type PlatformOverrides = Partial>; + +function createMockTransport(overrides: PlatformOverrides = {}) { + return createRouterTransport(({ service }) => { + service(PlatformService, { + whoAmI: () => ({ + response: { code: EnumStatusCode.OK }, + userEmail: 'test@example.com', + organizationName: 'TestOrg', + }), + getOnboarding: () => ({ + response: { code: EnumStatusCode.OK }, + enabled: true, + }), + getFederatedGraphByName: () => ({ + response: { code: EnumStatusCode.ERR_NOT_FOUND, details: 'not found' }, + }), + createFederatedGraph: () => ({ + response: { code: EnumStatusCode.OK }, + }), + deleteFederatedGraph: () => ({ + response: { code: EnumStatusCode.OK }, + }), + deleteFederatedSubgraph: () => ({ + response: { code: EnumStatusCode.OK }, + }), + createFederatedGraphToken: () => ({ + response: { code: EnumStatusCode.OK }, + token: 'test-token', + }), + deleteRouterToken: () => ({ + response: { code: EnumStatusCode.OK }, + }), + ...overrides, + }); + }); +} + +function runDemo(overrides: PlatformOverrides = {}) { + const client: Client = { + platform: createPromiseClient(PlatformService, createMockTransport(overrides)), + }; + const program = new Command(); + program.addCommand(DemoCommand({ client })); + return program.parseAsync(['demo'], { from: 'user' }); +} + +// Queues responses for upcoming waitForKeyPress calls. Call these in the order the command will prompt. +const keys = { + enter: () => vi.mocked(waitForKeyPress).mockResolvedValueOnce(undefined), + press: (key: string) => + vi.mocked(waitForKeyPress).mockImplementationOnce(async (keyMap) => { + const entry = keyMap[key]; + if (typeof entry !== 'function') { + throw new TypeError(`waitForKeyPress was not given a function handler for '${key}'`); + } + await entry(); + }), +}; + +describe('Demo command', () => { + let exitSpy: MockInstance; + + beforeEach(() => { + // Silence the demo command's logo, welcome banner, and spinner output so CI logs stay readable. + vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit'); + }); + + vi.mocked(demoUtil.prepareSupportingData).mockResolvedValue('/tmp/cosmo-demo'); + vi.mocked(demoUtil.checkDockerReadiness).mockResolvedValue(undefined); + vi.mocked(demoUtil.publishAllPlugins).mockResolvedValue({ error: null }); + vi.mocked(demoUtil.runRouterContainer).mockResolvedValue({ error: null }); + vi.mocked(demoUtil.getDemoLogPath).mockReturnValue('/tmp/demo.log'); + }); + + describe('happy path', () => { + it('fresh setup: creates graph, publishes plugins, starts router', async () => { + keys.enter(); + + await runDemo(); + + expect(demoUtil.prepareSupportingData).toHaveBeenCalledOnce(); + expect(demoUtil.checkDockerReadiness).toHaveBeenCalledOnce(); + expect(demoUtil.publishAllPlugins).toHaveBeenCalledOnce(); + expect(demoUtil.runRouterContainer).toHaveBeenCalledOnce(); + expect(demoUtil.runRouterContainer).toHaveBeenCalledWith(expect.objectContaining({ routerToken: 'test-token' })); + }); + + it('existing graph: continues with existing graph', async () => { + const overrides: PlatformOverrides = { + getFederatedGraphByName: () => ({ + response: { code: EnumStatusCode.OK }, + graph: new FederatedGraph({ + name: 'demo', + namespace: 'default', + routingURL: 'http://localhost:3002/graphql', + }), + subgraphs: [], + }), + }; + + keys.enter(); + keys.enter(); + + await runDemo(overrides); + + expect(demoUtil.publishAllPlugins).toHaveBeenCalledOnce(); + expect(demoUtil.runRouterContainer).toHaveBeenCalledOnce(); + }); + + it('existing graph: deletes graph and exits', async () => { + const overrides: PlatformOverrides = { + getFederatedGraphByName: () => ({ + response: { code: EnumStatusCode.OK }, + graph: new FederatedGraph({ + name: 'demo', + namespace: 'default', + routingURL: 'http://localhost:3002/graphql', + }), + subgraphs: [new Subgraph({ name: 'products', namespace: 'default' })], + }), + }; + + keys.enter(); + keys.press('d'); + + await expect(runDemo(overrides)).rejects.toThrow('process.exit'); + + expect(exitSpy).toHaveBeenCalledWith(0); + expect(demoUtil.publishAllPlugins).not.toHaveBeenCalled(); + expect(demoUtil.runRouterContainer).not.toHaveBeenCalled(); + }); + }); + + describe('non-recoverable errors', () => { + it('exits when whoAmI RPC fails', async () => { + const overrides: PlatformOverrides = { + whoAmI: () => ({ + response: { code: EnumStatusCode.ERR, details: 'Unauthorized' }, + }), + }; + + await expect(runDemo(overrides)).rejects.toThrow('process.exit'); + + expect(exitSpy).toHaveBeenCalledWith(1); + expect(demoUtil.publishAllPlugins).not.toHaveBeenCalled(); + }); + + it('exits when user is not an organization owner', async () => { + const overrides: PlatformOverrides = { + getOnboarding: () => ({ + response: { code: EnumStatusCode.OK }, + enabled: false, + }), + }; + + keys.enter(); + + await expect(runDemo(overrides)).rejects.toThrow('process.exit'); + + expect(exitSpy).toHaveBeenCalledWith(1); + expect(demoUtil.publishAllPlugins).not.toHaveBeenCalled(); + }); + + it('exits when docker is unavailable', async () => { + vi.mocked(demoUtil.checkDockerReadiness).mockImplementationOnce(() => { + process.exit(1); + }); + + await expect(runDemo()).rejects.toThrow('process.exit'); + + expect(exitSpy).toHaveBeenCalledWith(1); + expect(demoUtil.publishAllPlugins).not.toHaveBeenCalled(); + }); + + it('exits when github fetch fails', async () => { + vi.mocked(demoUtil.prepareSupportingData).mockImplementationOnce(() => { + process.exit(1); + }); + + await expect(runDemo()).rejects.toThrow('process.exit'); + + expect(exitSpy).toHaveBeenCalledWith(1); + expect(demoUtil.checkDockerReadiness).not.toHaveBeenCalled(); + }); + }); + + describe('retrying failures', () => { + it('user retries after plugin publishing fails', async () => { + vi.mocked(demoUtil.publishAllPlugins) + .mockResolvedValueOnce({ error: new Error('build failed') }) + .mockResolvedValueOnce({ error: null }); + + keys.enter(); + keys.press('r'); + + await runDemo(); + + expect(demoUtil.publishAllPlugins).toHaveBeenCalledTimes(2); + expect(demoUtil.runRouterContainer).toHaveBeenCalledOnce(); + }); + + it('user retries after router fails to start', async () => { + vi.mocked(demoUtil.runRouterContainer) + .mockResolvedValueOnce({ error: new Error('container exited') }) + .mockResolvedValueOnce({ error: null }); + + keys.enter(); + keys.press('r'); + + await runDemo(); + + expect(demoUtil.runRouterContainer).toHaveBeenCalledTimes(2); + }); + + it('user retries after graph lookup fails', async () => { + const getGraphFn = vi + .fn() + .mockReturnValueOnce({ response: { code: EnumStatusCode.ERR, details: 'service unavailable' } }) + .mockReturnValue({ response: { code: EnumStatusCode.ERR_NOT_FOUND, details: 'not found' } }); + + keys.enter(); + keys.press('r'); + + await runDemo({ getFederatedGraphByName: getGraphFn }); + + expect(getGraphFn).toHaveBeenCalledTimes(2); + expect(demoUtil.runRouterContainer).toHaveBeenCalledOnce(); + }); + }); +}); diff --git a/cli/test/demo/util.test.ts b/cli/test/demo/util.test.ts new file mode 100644 index 0000000000..75e557f687 --- /dev/null +++ b/cli/test/demo/util.test.ts @@ -0,0 +1,395 @@ +import { existsSync, mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { createPromiseClient, createRouterTransport } from '@connectrpc/connect'; +import { PlatformService } from '@wundergraph/cosmo-connect/dist/platform/v1/platform_connect'; +import { execa, type Result, type ResultPromise } from 'execa'; +import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest'; +import { + checkDockerReadiness, + getDemoLogPath, + prepareSupportingData, + publishAllPlugins, + runRouterContainer, +} from '../../src/commands/demo/util.js'; +import { publishPluginPipeline, readPluginFiles } from '../../src/core/plugin-publish.js'; +import { config } from '../../src/core/config.js'; + +const mocks = vi.hoisted(() => ({ cacheDir: '' })); + +vi.mock('execa'); +vi.stubGlobal('fetch', vi.fn()); + +// runRouterContainer and publishAllPlugins pipe long-running docker output into a write stream. +// Real streams open asynchronously and would race against the tmpdir cleanup in afterEach. +vi.mock('node:fs', async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + createWriteStream: vi.fn(() => ({ + write: vi.fn(), + end: vi.fn(), + on: vi.fn(), + once: vi.fn(), + })), + }; +}); + +vi.mock('../../src/core/config.js', async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + get cacheDir() { + return mocks.cacheDir; + }, + }; +}); + +vi.mock('../../src/core/plugin-publish.js', async (importOriginal) => { + const mod = await importOriginal(); + return { ...mod, publishPluginPipeline: vi.fn(), readPluginFiles: vi.fn() }; +}); + +// Suppress the logo, spinner output, and commander error banners so CI logs stay readable. +function silenceOutput() { + vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + vi.spyOn(process.stdout, 'write').mockImplementation(() => true); +} + +function spyOnExit(): MockInstance { + return vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit'); + }); +} + +// Build a fully-typed execa Result so tests can hand mockResolvedValueOnce a value that satisfies +// the declared return type of `execa(...)` without `as never` at every call site. Only `stdout` +// is typically relevant for the code under test; the rest are neutral defaults. +// The error-related fields (name/message/stack/cause/shortMessage/originalMessage/code) are +// typed as `never` on a successful Result — execa uses this as a structural signal that these +// only exist on ExecaError. We set them to `undefined as never` so the literal satisfies the type. +function execaResult(overrides: Partial = {}): Result { + return { + stdout: '', + stderr: '', + all: undefined, + stdio: [undefined, '', ''], + ipcOutput: [], + pipedFrom: [], + command: '', + escapedCommand: '', + cwd: '', + durationMs: 0, + failed: false, + timedOut: false, + isCanceled: false, + isGracefullyCanceled: false, + isMaxBuffer: false, + isTerminated: false, + isForcefullyTerminated: false, + name: undefined as never, + message: undefined as never, + stack: undefined as never, + cause: undefined as never, + shortMessage: undefined as never, + originalMessage: undefined as never, + code: undefined as never, + ...overrides, + }; +} + +function createMockTransport() { + return createRouterTransport(({ service }) => { + service(PlatformService, {}); + }); +} + +describe('prepareSupportingData', () => { + let tmpDir: string; + let exitSpy: MockInstance; + + beforeEach(() => { + tmpDir = mkdtempSync(path.join(tmpdir(), 'demo-prep-')); + mocks.cacheDir = tmpDir; + silenceOutput(); + exitSpy = spyOnExit(); + }); + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('downloads plugin files from github into cacheDir', async () => { + vi.mocked(fetch) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + tree: [ + { type: 'blob', path: 'plugins/products/schema.graphql' }, + { type: 'blob', path: 'plugins/reviews/Dockerfile' }, + ], + }), + ), + ) + .mockResolvedValueOnce(new Response(new TextEncoder().encode('schema content'))) + .mockResolvedValueOnce(new Response(new TextEncoder().encode('docker content'))); + + const result = await prepareSupportingData(); + + expect(result).toBe(path.join(tmpDir, 'demo')); + expect(existsSync(path.join(tmpDir, 'demo', 'plugins/products/schema.graphql'))).toBe(true); + expect(existsSync(path.join(tmpDir, 'demo', 'plugins/reviews/Dockerfile'))).toBe(true); + }); + + it('exits when github tree api fails', async () => { + vi.mocked(fetch).mockResolvedValueOnce(new Response(null, { status: 502, statusText: 'Bad Gateway' })); + + await expect(prepareSupportingData()).rejects.toThrow('process.exit'); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it('exits when tree response is malformed', async () => { + vi.mocked(fetch).mockResolvedValueOnce(new Response(JSON.stringify({ invalid: 'shape' }))); + + await expect(prepareSupportingData()).rejects.toThrow('process.exit'); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it('exits when a file fetch fails', async () => { + vi.mocked(fetch) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + tree: [{ type: 'blob', path: 'plugins/products/schema.graphql' }], + }), + ), + ) + .mockResolvedValueOnce(new Response(null, { status: 404, statusText: 'Not Found' })); + + await expect(prepareSupportingData()).rejects.toThrow('process.exit'); + expect(exitSpy).toHaveBeenCalledWith(1); + }); +}); + +describe('checkDockerReadiness', () => { + let exitSpy: MockInstance; + + beforeEach(() => { + silenceOutput(); + exitSpy = spyOnExit(); + }); + + it('succeeds when docker, buildx, and the docker-container builder are all present', async () => { + vi.mocked(execa) + .mockResolvedValueOnce(execaResult({ stdout: '25.0.0' })) + .mockResolvedValueOnce(execaResult({ stdout: 'v0.12.0' })) + .mockResolvedValueOnce( + execaResult({ + stdout: 'NAME STATUS\ndefault docker\ncosmo-builder docker-container running', + }), + ); + + await expect(checkDockerReadiness()).resolves.toBeUndefined(); + }); + + it('exits when docker is not available', async () => { + vi.mocked(execa).mockRejectedValueOnce(new Error('docker not found')); + + await expect(checkDockerReadiness()).rejects.toThrow('process.exit'); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it('exits when buildx is not available', async () => { + vi.mocked(execa) + .mockResolvedValueOnce(execaResult({ stdout: '25.0.0' })) + .mockRejectedValueOnce(new Error('buildx not installed')); + + await expect(checkDockerReadiness()).rejects.toThrow('process.exit'); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it('creates docker-container builder when missing', async () => { + vi.mocked(execa) + .mockResolvedValueOnce(execaResult({ stdout: '25.0.0' })) + .mockResolvedValueOnce(execaResult({ stdout: 'v0.12.0' })) + .mockResolvedValueOnce(execaResult({ stdout: 'NAME\ndefault docker' })) + .mockResolvedValueOnce(execaResult()) + .mockResolvedValueOnce(execaResult()); + + await expect(checkDockerReadiness()).resolves.toBeUndefined(); + expect(execa).toHaveBeenCalledWith( + 'docker', + expect.arrayContaining(['buildx', 'create', '--use', '--driver', 'docker-container']), + ); + }); + + it('exits when builder creation fails', async () => { + vi.mocked(execa) + .mockResolvedValueOnce(execaResult({ stdout: '25.0.0' })) + .mockResolvedValueOnce(execaResult({ stdout: 'v0.12.0' })) + .mockResolvedValueOnce(execaResult({ stdout: 'NAME\ndefault docker' })) + .mockRejectedValueOnce(new Error('permission denied')); + + await expect(checkDockerReadiness()).rejects.toThrow('process.exit'); + expect(exitSpy).toHaveBeenCalledWith(1); + }); +}); + +// The long-running docker process is simulated with a Promise that never resolves so it stays +// alive while readiness polling runs; pipeToLog safely no-ops on null streams. +function mockDockerProc(): ResultPromise { + const proc = Object.assign(new Promise(() => {}), { + stdout: null, + stderr: null, + }); + return proc as unknown as ResultPromise; +} + +describe('runRouterContainer', () => { + let tmpDir: string; + let logPath: string; + + beforeEach(() => { + tmpDir = mkdtempSync(path.join(tmpdir(), 'demo-router-')); + logPath = path.join(tmpDir, 'demo.log'); + silenceOutput(); + }); + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('returns success when the router becomes ready', async () => { + vi.mocked(execa).mockResolvedValueOnce(execaResult()).mockReturnValueOnce(mockDockerProc()); + + vi.mocked(fetch).mockResolvedValueOnce(new Response(null, { status: 200 })); + + const controller = new AbortController(); + const result = await runRouterContainer({ + routerToken: 'test-token', + routerBaseUrl: 'http://localhost:3002', + signal: controller.signal, + logPath, + }); + + expect(result).toEqual({ error: null }); + expect(fetch).toHaveBeenCalledWith( + 'http://localhost:3002/health/ready', + expect.objectContaining({ signal: controller.signal }), + ); + }); + + it('returns error when readiness check times out', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); + + vi.mocked(execa).mockResolvedValueOnce(execaResult()).mockReturnValueOnce(mockDockerProc()); + + vi.mocked(fetch).mockResolvedValue(new Response(null, { status: 503 })); + + const controller = new AbortController(); + const promise = runRouterContainer({ + routerToken: 'test-token', + routerBaseUrl: 'http://localhost:3002', + signal: controller.signal, + logPath, + }); + + await vi.advanceTimersByTimeAsync(65_000); + const result = await promise; + + expect(result.error?.message).toContain('timed out'); + + vi.useRealTimers(); + }); + + it('removes any stale router container before starting a new one', async () => { + vi.mocked(execa).mockResolvedValueOnce(execaResult()).mockReturnValueOnce(mockDockerProc()); + + vi.mocked(fetch).mockResolvedValueOnce(new Response(null, { status: 200 })); + + const controller = new AbortController(); + await runRouterContainer({ + routerToken: 'test-token', + routerBaseUrl: 'http://localhost:3002', + signal: controller.signal, + logPath, + }); + + expect(execa).toHaveBeenCalledWith('docker', ['rm', '-f', config.demoRouterContainerName]); + }); +}); + +describe('publishAllPlugins', () => { + let tmpDir: string; + let logPath: string; + + beforeEach(() => { + tmpDir = mkdtempSync(path.join(tmpdir(), 'demo-publish-')); + logPath = path.join(tmpDir, 'demo.log'); + silenceOutput(); + }); + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('publishes all configured plugins sequentially', async () => { + vi.mocked(readPluginFiles).mockResolvedValue({} as never); + vi.mocked(publishPluginPipeline).mockResolvedValue({ error: null }); + + const result = await publishAllPlugins({ + client: { + platform: createPromiseClient(PlatformService, createMockTransport()), + }, + supportDir: tmpDir, + signal: new AbortController().signal, + logPath, + }); + + expect(result).toEqual({ error: null }); + expect(publishPluginPipeline).toHaveBeenCalledTimes(2); + }); + + it('stops and returns the error when the first plugin fails', async () => { + vi.mocked(readPluginFiles).mockResolvedValue({} as never); + vi.mocked(publishPluginPipeline).mockResolvedValueOnce({ + error: new Error('build failed'), + }); + + const result = await publishAllPlugins({ + client: { + platform: createPromiseClient(PlatformService, createMockTransport()), + }, + supportDir: tmpDir, + signal: new AbortController().signal, + logPath, + }); + + expect(result.error?.message).toBe('build failed'); + expect(publishPluginPipeline).toHaveBeenCalledTimes(1); + }); +}); + +describe('getDemoLogPath', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = mkdtempSync(path.join(tmpdir(), 'demo-log-')); + mocks.cacheDir = tmpDir; + }); + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('returns the demo.log path under cacheDir', () => { + expect(getDemoLogPath()).toBe(path.join(tmpDir, 'demo', 'demo.log')); + }); + + it('creates the demo directory when missing', () => { + const result = getDemoLogPath(); + expect(existsSync(path.dirname(result))).toBe(true); + }); +}); diff --git a/docs-website/cli/demo.mdx b/docs-website/cli/demo.mdx new file mode 100644 index 0000000000..446882b4a1 --- /dev/null +++ b/docs-website/cli/demo.mdx @@ -0,0 +1,39 @@ +--- +title: "Demo" +icon: rocket-launch +description: "Run the interactive Cosmo demo that creates a local federated graph onboarding setup." +--- + +## Overview + +`wgc demo` runs an interactive onboarding flow for a local Cosmo demo environment. + +![Terminal running demo command](../images/cli-demo.png) + +It is intended for first-time setup. The command prepares a demo federated graph, publishes the required demo plugins, creates a router token, and starts a local router so you can query the graph immediately. + +```bash +wgc demo +``` + +## Requirements + +Before you run the command: + +* Authenticate with the CLI. + +* Use an organization account that is allowed to run onboarding. The command currently requires organization owner access. + +* Install Docker and make sure the Docker daemon is running. + +* Install Docker Buildx. If no `docker-container` builder exists, the command creates one automatically. + +## What The Command Does + +The command prepares a ready-to-use local demo setup for the onboarding flow. It creates or reuses a demo federated graph in the `default` namespace, configures the required demo components, starts a local router on `http://localhost:3002`, and leaves you with a working graph that you can query immediately. If a previous demo setup already exists, you can continue with it or delete it and start over. During execution, the command also prints the path to the local log file so you can inspect router and publishing output if needed. + +## Notes + +`wgc demo` is intentionally guided and uses predefined values. It is meant as a tutorial entry point, not as a general-purpose graph provisioning command. + +For manual graph management, use the standard CLI commands such as [`wgc federated-graph`](/cli/federated-graph), [`wgc subgraph`](/cli/subgraph), and [`wgc router token`](/cli/router/token). diff --git a/docs-website/docs.json b/docs-website/docs.json index 6abd3fa225..fc6b4f6d4e 100644 --- a/docs-website/docs.json +++ b/docs-website/docs.json @@ -466,6 +466,7 @@ "pages": [ "cli/intro", "cli/essentials", + "cli/demo", "cli/api-keys", { "group": "Namespace", diff --git a/docs-website/images/cli-demo.png b/docs-website/images/cli-demo.png new file mode 100644 index 0000000000..d40dd61a57 Binary files /dev/null and b/docs-website/images/cli-demo.png differ