Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
"@connectrpc/connect-node": "^1.4.0",
"@graphql-tools/utils": "^10.2.2",
"@modelcontextprotocol/sdk": "^1.9.0",
"@octokit/rest": "^22.0.0",
"@wundergraph/composition": "workspace:*",
"@wundergraph/cosmo-connect": "workspace:*",
"@wundergraph/cosmo-shared": "workspace:*",
Expand All @@ -58,6 +59,7 @@
"env-ci": "^11.1.0",
"env-paths": "^3.0.0",
"execa": "^9.5.2",
"fs-extra": "^11.3.0",
"graphql": "^16.9.0",
"https-proxy-agent": "^7.0.5",
"inquirer": "^9.2.7",
Expand All @@ -74,6 +76,7 @@
"prompts": "^2.4.2",
"pupa": "^3.1.0",
"semver": "^7.7.1",
"tar": "^7.4.3",
"trieve-ts-sdk": "^0.0.80",
"undici": "^6.21.1",
"zod": "^3.22.4"
Expand All @@ -84,12 +87,14 @@
"@types/cli-table": "^0.3.1",
"@types/decompress": "^4.2.7",
"@types/env-ci": "^3.1.4",
"@types/fs-extra": "^11.0.4",
"@types/inquirer": "^9.0.3",
"@types/js-yaml": "^4.0.5",
"@types/lodash-es": "4.17.12",
"@types/node": "^20.3.1",
"@types/prompts": "^2.4.9",
"@types/semver": "^7.7.0",
"@types/tar": "^6.1.13",
"del-cli": "^5.0.0",
"eslint": "^8.57.1",
"eslint-config-unjs": "^0.2.1",
Expand All @@ -100,4 +105,4 @@
"vitest": "^3.1.2"
},
"gitHead": "c37aed755e1b19ed91d30f9b5f7041e15c56901a"
}
}
7 changes: 4 additions & 3 deletions cli/src/commands/grpc-service/commands/generate.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { readFile, writeFile } from 'node:fs/promises';
import { readFile, writeFile, mkdir } from 'node:fs/promises';
import { existsSync, lstatSync } from 'node:fs';
import { resolve, dirname } from 'pathe';
import { resolve } from 'pathe';
import Spinner from 'ora';
import { Command, program } from 'commander';
import { compileGraphQLToMapping, compileGraphQLToProto, ProtoLock } from '@wundergraph/protographic';
Expand Down Expand Up @@ -38,8 +38,9 @@ async function generateCommandAction(name: string, options: any) {
try {
const inputFile = resolve(options.input);

// Ensure output directory exists
if (!existsSync(options.output)) {
program.error(`Output directory ${options.output} does not exist`);
await mkdir(options.output, { recursive: true });
}

if (!lstatSync(options.output).isDirectory()) {
Expand Down
178 changes: 178 additions & 0 deletions cli/src/commands/grpc-service/commands/init.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
import { existsSync, readdirSync, mkdirSync } from 'node:fs';
import os from 'node:os';
import { Command, program } from 'commander';
import { resolve, join } from 'pathe';
import Spinner from 'ora';
import { Octokit } from '@octokit/rest';
import { extract, t } from 'tar';
import fs from 'fs-extra';
import pc from 'picocolors';
import { BaseCommandOptions } from '../../../core/types/types.js';
import { fetchAvailableTemplates } from './list-templates.js';

async function checkTemplateExists(template: string): Promise<boolean> {
const octokit = new Octokit({
log: {
debug: () => {},
info: () => {},
warn: () => {},
error: () => {},
},
});
const owner = 'wundergraph';
const repo = 'cosmo-templates';
const path = `grpc-service/${template}`;
try {
const res = await octokit.repos.getContent({ owner, repo, path });
// If it's a directory, res.data will be an array
return Array.isArray(res.data);
} catch {
return false;
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

async function downloadAndExtractTemplate(template: string, outputDir: string, spinner: any) {
const octokit = new Octokit();
const owner = 'wundergraph';
const repo = 'cosmo-templates';
const ref = 'main'; // You may want to make this configurable
const tempTarPath = join(os.tmpdir(), `cosmo-templates-${Date.now()}.tar.gz`);
const tempExtractDir = join(os.tmpdir(), `cosmo-templates-extract-${Date.now()}`);

let topLevelDir = '';
try {
await fs.ensureDir(tempExtractDir);

spinner.text = 'Downloading template from GitHub...';
let response;
try {
response = await octokit.repos.downloadTarballArchive({ owner, repo, ref });
} catch (err: any) {
throw new Error(`Failed to download template tarball from GitHub: ${err.message || err}`);
}
if (response.data instanceof ArrayBuffer) {
try {
await fs.writeFile(tempTarPath, new Uint8Array(Buffer.from(response.data)));
} catch (err: any) {
throw new Error(`Failed to write tarball to disk: ${err.message || err}`);
}
} else {
throw new TypeError('Unexpected tarball response type');
}

spinner.text = 'Extracting template files...';
try {
await t({
file: tempTarPath,
onentry: (entry: { path: string }) => {
if (!topLevelDir && entry.path.includes('/')) {
topLevelDir = entry.path.split('/')[0];
}
},
});
} catch (err: any) {
throw new Error(`Failed to inspect tarball for top-level directory: ${err.message || err}`);
}
const templatePathInTar = `${topLevelDir}/grpc-service/${template}`;
let extracted = false;
try {
await extract({
file: tempTarPath,
cwd: tempExtractDir,
filter: (p: string) => p.startsWith(templatePathInTar + '/'),
strip: templatePathInTar.split('/').length,
});
// Validate extraction
const extractedTemplateDir = join(tempExtractDir);
const files = await fs.readdir(extractedTemplateDir);
if (!files || files.length === 0) {
throw new Error('Extracted template directory is empty. The template may not exist or is misconfigured.');
}
extracted = true;
} catch (err: any) {
throw new Error(`Failed to extract template files: ${err.message || err}`);
}

// Copy extracted files to outputDir
try {
await fs.copy(tempExtractDir, outputDir, { overwrite: true });
} catch (err: any) {
throw new Error(`Failed to copy extracted files to output directory: ${err.message || err}`);
}
Comment thread
jensneuse marked this conversation as resolved.
Outdated
} catch (error: any) {
spinner.fail(pc.red('Error during template extraction.'));
throw error;
} finally {
// Cleanup
try {
await fs.remove(tempTarPath);
} catch {}
try {
await fs.remove(tempExtractDir);
} catch {}
Comment thread
jensneuse marked this conversation as resolved.
Outdated
}
}
Comment thread
jensneuse marked this conversation as resolved.

export default (opts: BaseCommandOptions) => {
const command = new Command();
command
.name('init')
.description('Scaffold a new gRPC service project from a template')
.option('-t, --template <template>', 'Template to use', 'typescript-connect-rpc-fastify')
.option('-d, --directory <directory>', 'Output directory', '.')
.action(async (options: { template: string; directory: string }) => {
const spinner = Spinner();
const template = options.template || 'typescript-connect-rpc-fastify';
const outputDir = resolve(process.cwd(), options.directory || '.');

spinner.start(`Checking if template '${template}' exists...`);
const templateExists = await checkTemplateExists(template);
if (!templateExists) {
spinner.start('Fetching available templates...');
const templates = await fetchAvailableTemplates();
spinner.stop();
if (templates.length > 0) {
console.log(pc.yellow('Available templates:'));
for (const t of templates) {
console.log(` - ${t}`);
}
console.log('');
console.log(
`\n${pc.yellow('To use a template, run:')}\n wgc grpc-service init --template ${templates[0]} --directory ./output\n`,
);
} else {
console.log(pc.red('No templates found in wundergraph/cosmo-templates under grpc-service.'));
}
program.error(
`Template '${template}' does not exist in wundergraph/cosmo-templates under grpc-service. Please check the template name and try again.`,
);
}
Comment thread
jensneuse marked this conversation as resolved.

spinner.text = `Scaffolding gRPC service using template '${template}'...`;

try {
if (existsSync(outputDir)) {
const files = readdirSync(outputDir);
if (files.length > 0) {
spinner.fail(pc.red('Output directory is not empty.'));
program.error(
`The directory '${outputDir}' is not empty. Please use the --directory argument to specify an empty or new directory.`,
);
}
} else {
mkdirSync(outputDir, { recursive: true });
}
await downloadAndExtractTemplate(template, outputDir, spinner);
spinner.succeed(pc.green(`gRPC service scaffolded in ${outputDir}`));
console.log('');
console.log(
` Checkout the ${pc.bold(pc.italic('README.md'))} file for instructions on how to use your service.`,
);
console.log('');
} catch (error: any) {
spinner.fail(pc.red('Failed to scaffold gRPC service'));
program.error(error.message || String(error));
}
});
return command;
};
45 changes: 45 additions & 0 deletions cli/src/commands/grpc-service/commands/list-templates.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { Command } from 'commander';
import pc from 'picocolors';
import Spinner from 'ora';
import { Octokit } from '@octokit/rest';
import { BaseCommandOptions } from '../../../core/types/types.js';

export async function fetchAvailableTemplates(): Promise<string[]> {
const octokit = new Octokit();
const owner = 'wundergraph';
const repo = 'cosmo-templates';
const path = 'grpc-service';

try {
const res = await octokit.repos.getContent({ owner, repo, path });
if (Array.isArray(res.data)) {
return res.data.filter((item: any) => item.type === 'dir').map((item: any) => item.name);
}
} catch {
console.error('Error listing templates from https://github.com/wundergraph/cosmo-templates/');
}
return [];
}
Comment thread
jensneuse marked this conversation as resolved.

export default (_opts: BaseCommandOptions) => {
const command = new Command('list-templates');
command.description('List all available gRPC service templates');
command.action(async () => {
const spinner = Spinner('Fetching available templates...').start();
const templates = await fetchAvailableTemplates();
spinner.stop();
Comment thread
jensneuse marked this conversation as resolved.
Outdated
if (templates.length > 0) {
console.log(pc.yellow('Available templates:'));
for (const t of templates) {
console.log(` - ${t}`);
}
console.log('');
console.log(pc.yellow('To use a template, run:'));
console.log(` wgc grpc-service init --template ${templates[0]} --directory ./output`);
console.log('');
} else {
console.log(pc.red('No templates found in wundergraph/cosmo-templates under grpc-service.'));
}
});
return command;
};
4 changes: 4 additions & 0 deletions cli/src/commands/grpc-service/index.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
import { Command } from 'commander';
import { BaseCommandOptions } from '../../core/types/types.js';
import generateCommand from './commands/generate.js';
import initCommand from './commands/init.js';
import listTemplatesCommand from './commands/list-templates.js';

export default (opts: BaseCommandOptions) => {
const command = new Command('grpc-service');
command.description('Manage protobuf schemas for remote gRPC services');
command.addCommand(generateCommand(opts));
command.addCommand(initCommand(opts));
command.addCommand(listTemplatesCommand(opts));

return command;
};
Loading
Loading