diff --git a/packages/cli/lang/en.json b/packages/cli/lang/en.json index 2c1c787772..0a14454218 100644 --- a/packages/cli/lang/en.json +++ b/packages/cli/lang/en.json @@ -111,12 +111,16 @@ "commands_create_error_outputDirMissingPath": "{option} option missing {argument} argument", "commands_create_error_unrecognizedCommand": "Unrecognized command", "commands_create_error_unrecognizedLanguage": "Unrecognized language", + "commands_create_error_badUrl": "URL {url} uses an invalid format. Valid URL formats: {formats}", "commands_create_options_command": "command", "commands_create_options_commands": "Commands", - "commands_create_options_createApp": "Create a Polywrap application", - "commands_create_options_createPlugin": "Create a Polywrap plugin", - "commands_create_options_createProject": "Create a Polywrap wasm wrapper", + "commands_create_options_createApp": "Create a Polywrap application.", + "commands_create_options_createPlugin": "Create a Polywrap plugin.", + "commands_create_options_createProject": "Create a Polywrap wasm wrapper.", "commands_create_options_h": "Show usage information", + "commands_create_options_t": "Download template from a URL.", + "commands_create_options_t_url": "URL", + "commands_create_options_formats": "formats", "commands_create_options_lang": "lang", "commands_create_options_langs": "langs", "commands_create_options_o": "Output directory for the new project", @@ -124,9 +128,7 @@ "commands_create_options_projectName": "project-name", "commands_create_overwritePrompt": "Do you want to overwrite this directory?", "commands_create_overwriting": "Overwriting {dir}...", - "commands_create_readyApp": "You are ready to build an app using Polywrap", - "commands_create_readyPlugin": "You are ready to build a plugin into a Polywrap", - "commands_create_readyProtocol": "You are ready to turn your protocol into a Polywrap", + "commands_create_ready": "You are ready to build with Polywrap", "commands_create_settingUp": "Setting everything up...", "commands_test_options_workflow": "workflow", "commands_test_options_workflowScript": "Path to workflow script", diff --git a/packages/cli/lang/es.json b/packages/cli/lang/es.json index 2c1c787772..9493b2aa00 100644 --- a/packages/cli/lang/es.json +++ b/packages/cli/lang/es.json @@ -111,12 +111,16 @@ "commands_create_error_outputDirMissingPath": "{option} option missing {argument} argument", "commands_create_error_unrecognizedCommand": "Unrecognized command", "commands_create_error_unrecognizedLanguage": "Unrecognized language", + "commands_create_error_badUrl": "URL {url} uses an invalid format. Valid URL formats: {formats}", "commands_create_options_command": "command", "commands_create_options_commands": "Commands", "commands_create_options_createApp": "Create a Polywrap application", "commands_create_options_createPlugin": "Create a Polywrap plugin", "commands_create_options_createProject": "Create a Polywrap wasm wrapper", "commands_create_options_h": "Show usage information", + "commands_create_options_t": "Download template from a .git URL", + "commands_create_options_t_url": "URL", + "commands_create_options_formats": "formats", "commands_create_options_lang": "lang", "commands_create_options_langs": "langs", "commands_create_options_o": "Output directory for the new project", @@ -124,9 +128,7 @@ "commands_create_options_projectName": "project-name", "commands_create_overwritePrompt": "Do you want to overwrite this directory?", "commands_create_overwriting": "Overwriting {dir}...", - "commands_create_readyApp": "You are ready to build an app using Polywrap", - "commands_create_readyPlugin": "You are ready to build a plugin into a Polywrap", - "commands_create_readyProtocol": "You are ready to turn your protocol into a Polywrap", + "commands_create_ready": "You are ready to build with Polywrap", "commands_create_settingUp": "Setting everything up...", "commands_test_options_workflow": "workflow", "commands_test_options_workflowScript": "Path to workflow script", diff --git a/packages/cli/src/__tests__/e2e/create.spec.ts b/packages/cli/src/__tests__/e2e/create.spec.ts index 66e537eb86..8ee9a97e86 100644 --- a/packages/cli/src/__tests__/e2e/create.spec.ts +++ b/packages/cli/src/__tests__/e2e/create.spec.ts @@ -3,6 +3,7 @@ import { clearStyle, polywrapCli } from "./utils"; import { runCLI } from "@polywrap/test-env-js"; import rimraf from "rimraf"; import { ProjectType, supportedLangs } from "../../commands"; +import { UrlFormat } from "../../lib"; const HELP = `Usage: polywrap create|c [options] [command] @@ -12,15 +13,24 @@ Options: -h, --help display help for command Commands: - wasm [options] Create a Polywrap wasm wrapper langs: + wasm [options] Create a Polywrap wasm wrapper. langs: assemblyscript, rust, interface - app [options] Create a Polywrap application langs: + app [options] Create a Polywrap application. langs: typescript - plugin [options] Create a Polywrap plugin langs: + plugin [options] Create a Polywrap plugin. langs: typescript + template [options] Download template from a URL. formats: + .git help [command] display help for command `; +const urlExamples = (format: UrlFormat): string => { + if (format === UrlFormat.git) { + return "https://github.com/polywrap/logging.git"; + } + throw Error("This should never happen"); +} + describe("e2e tests for create command", () => { it("Should show help text", async () => { const { exitCode: code, stdout: output, stderr: error } = await runCLI({ @@ -64,7 +74,7 @@ describe("e2e tests for create command", () => { }); expect(code).toEqual(1); - expect(error).toContain("error: missing required argument 'language"); + expect(error).toContain("error: missing required argument 'language'"); expect(output).toBe(""); }); @@ -134,4 +144,82 @@ describe("e2e tests for create command", () => { } }); } + + describe("template", () => { + it("Should throw error for missing required argument - url", async () => { + const { exitCode: code, stdout: output, stderr: error } = await runCLI({ + args: ["create", "template"], + cli: polywrapCli, + }); + + expect(code).toEqual(1); + expect(error).toContain("error: missing required argument 'url'"); + expect(output).toBe(""); + }); + + it("Should throw error for missing required argument - name", async () => { + const { exitCode: code, stdout: output, stderr: error } = await runCLI({ + args: ["create", "template", "lang"], + cli: polywrapCli, + }); + + expect(code).toEqual(1); + expect(error).toContain("error: missing required argument 'name'"); + expect(output).toBe(""); + }); + + it("Should throw error for invalid url parameter", async () => { + const { exitCode: code, stdout: output, stderr: error } = await runCLI({ + args: ["create", "template", "lang", "demo"], + cli: polywrapCli, + }); + + expect(code).toEqual(1); + expect(error).toContain(`URL 'lang' uses an invalid format. Valid URL formats: ${Object.values(UrlFormat).join(", ")}`); + expect(output).toBe(""); + }); + + for (const format of Object.values(UrlFormat)) { + const url = urlExamples(format); + + describe(format, () => { + it("Should throw error for missing path argument for --output-dir option", async () => { + const { exitCode: code, stdout: output, stderr: error } = await runCLI({ + args: ["create", "template", url, "name", "-o"], + cli: polywrapCli, + }); + + expect(code).toEqual(1); + expect(error).toContain( + "error: option '-o, --output-dir ' argument missing" + ); + expect(output).toBe(""); + }); + + it("Should successfully generate project", async () => { + rimraf.sync(`${__dirname}/test`); + + const { exitCode: code, stdout: output } = await runCLI({ + args: [ + "create", + "template", + url, + "test", + "-o", + `${__dirname}/test`, + ], + cwd: __dirname, + cli: polywrapCli, + }); + + expect(code).toEqual(0); + expect(clearStyle(output)).toContain( + "🔥 You are ready " + ); + + rimraf.sync(`${__dirname}/test`); + }, 60000); + }) + } + }); }); diff --git a/packages/cli/src/commands/create.ts b/packages/cli/src/commands/create.ts index e46fa94e9b..f1bf0d4c80 100644 --- a/packages/cli/src/commands/create.ts +++ b/packages/cli/src/commands/create.ts @@ -1,6 +1,13 @@ import { Command, Program, BaseCommandOptions } from "./types"; import { createLogger } from "./utils/createLogger"; -import { generateProjectTemplate, intlMsg, parseLogFileOption } from "../lib"; +import { + downloadProjectTemplate, + generateProjectTemplate, + intlMsg, + parseLogFileOption, + parseUrlFormat, + UrlFormat, +} from "../lib"; import fse from "fs-extra"; import path from "path"; @@ -11,10 +18,13 @@ import { Argument } from "commander"; const nameStr = intlMsg.commands_create_options_projectName(); const langStr = intlMsg.commands_create_options_lang(); const langsStr = intlMsg.commands_create_options_langs(); +const formatsStr = intlMsg.commands_create_options_formats(); const createProjStr = intlMsg.commands_create_options_createProject(); const createAppStr = intlMsg.commands_create_options_createApp(); const createPluginStr = intlMsg.commands_create_options_createPlugin(); +const createTemplateStr = intlMsg.commands_create_options_t(); const pathStr = intlMsg.commands_create_options_o_path(); +const urlStr = intlMsg.commands_create_options_t_url(); export const supportedLangs = { wasm: ["assemblyscript", "rust", "interface"] as const, @@ -137,18 +147,57 @@ export const create: Command = { }); } ); + + createCommand + .command("template") + .description( + `${createTemplateStr} ${formatsStr}: ${Object.values(UrlFormat).join( + ", " + )}` + ) + .addArgument(new Argument("", urlStr).argRequired()) + .addArgument(new Argument("", nameStr)) + .option( + `-o, --output-dir <${pathStr}>`, + `${intlMsg.commands_create_options_o()}` + ) + .option("-v, --verbose", intlMsg.commands_common_options_verbose()) + .option("-q, --quiet", intlMsg.commands_common_options_quiet()) + .option( + `-l, --log-file [${pathStr}]`, + `${intlMsg.commands_build_options_l()}` + ) + .action(async (url, name, options: Partial) => { + await run("template", url, name, { + outputDir: options.outputDir || false, + verbose: options.verbose || false, + quiet: options.quiet || false, + logFile: parseLogFileOption(options.logFile), + }); + }); }, }; async function run( - command: ProjectType, - language: SupportedLangs, + command: ProjectType | "template", + languageOrUrl: SupportedLangs | string, name: string, options: Required ) { const { outputDir, verbose, quiet, logFile } = options; const logger = createLogger({ verbose, quiet, logFile }); + // if using custom template, check url validity before creating project dir + let urlFormat: UrlFormat | undefined; + if (command === "template") { + try { + urlFormat = parseUrlFormat(languageOrUrl); + } catch (e) { + logger.error(e.message); + process.exit(1); + } + } + const projectDir = path.resolve(outputDir ? `${outputDir}/${name}` : name); // check if project already exists @@ -175,16 +224,17 @@ async function run( } try { - await generateProjectTemplate(command, language, projectDir); - let readyMessage; - if (command === "wasm") { - readyMessage = intlMsg.commands_create_readyProtocol(); - } else if (command === "app") { - readyMessage = intlMsg.commands_create_readyApp(); - } else if (command === "plugin") { - readyMessage = intlMsg.commands_create_readyPlugin(); + if (command === "template") { + await downloadProjectTemplate( + languageOrUrl, + projectDir, + logger, + urlFormat + ); + } else { + await generateProjectTemplate(command, languageOrUrl, projectDir); } - logger.info(`🔥 ${readyMessage} 🔥`); + logger.info(`🔥 ${intlMsg.commands_create_ready()} 🔥`); process.exit(0); } catch (err) { const commandFailError = intlMsg.commands_create_error_commandFail({ diff --git a/packages/cli/src/commands/index.ts b/packages/cli/src/commands/index.ts index 768450707f..5d44c81f67 100644 --- a/packages/cli/src/commands/index.ts +++ b/packages/cli/src/commands/index.ts @@ -42,6 +42,10 @@ export interface CommandTypings { options: CreateCommandOptions; arguments: [language: SupportedWasmLangs, name: string]; }; + template: { + options: CreateCommandOptions; + arguments: [url: string, name: string]; + }; }; deploy: DeployCommandOptions; docgen: { diff --git a/packages/cli/src/lib/CacheDirectory.ts b/packages/cli/src/lib/CacheDirectory.ts index e4722cb341..3940c97cde 100644 --- a/packages/cli/src/lib/CacheDirectory.ts +++ b/packages/cli/src/lib/CacheDirectory.ts @@ -3,6 +3,12 @@ import path from "path"; import rimraf from "rimraf"; import copyfiles from "copyfiles"; import { writeFileSync } from "@polywrap/os-js"; +import fse from "fs-extra"; + +export const globalCacheRoot: string = + process.platform == "darwin" + ? process.env.HOME + "/Library/Preferences/polywrap/cache" + : process.env.HOME + "/.local/share/polywrap/cache"; export interface CacheDirectoryConfig { rootDir: string; @@ -23,6 +29,14 @@ export class CacheDirectory { ); } + public initCache(): this { + const cacheDir = this.getCacheDir(); + if (!fs.existsSync(cacheDir)) { + fs.mkdirSync(cacheDir, { recursive: true }); + } + return this; + } + public resetCache(): void { rimraf.sync(this.getCacheDir()); } @@ -83,4 +97,19 @@ export class CacheDirectory { }); }); } + + public async copyFromCache( + sourceSubDir: string, + destDir: string + ): Promise { + const source = this.getCachePath(sourceSubDir); + + if (!fs.existsSync(destDir)) { + fs.mkdirSync(destDir, { recursive: true }); + } + + await fse.copy(source, destDir, { + overwrite: true, + }); + } } diff --git a/packages/cli/src/lib/project/templates/downloadProjectTemplate.ts b/packages/cli/src/lib/project/templates/downloadProjectTemplate.ts new file mode 100644 index 0000000000..af8b97b556 --- /dev/null +++ b/packages/cli/src/lib/project/templates/downloadProjectTemplate.ts @@ -0,0 +1,75 @@ +import { createUUID } from "../../helpers"; +import { runCommand } from "../../system"; +import { Logger } from "../../logging"; +import { intlMsg } from "../../intl"; +import { CacheDirectory, globalCacheRoot } from "../../CacheDirectory"; + +import path from "path"; + +export enum UrlFormat { + git = ".git", +} + +export function parseUrlFormat(url: string): UrlFormat { + if (url.startsWith("http") && url.endsWith(".git")) { + return UrlFormat.git; + } else { + const formats = Object.values(UrlFormat).join(", "); + const message = intlMsg.commands_create_error_badUrl({ + url: `'${url}'`, + formats, + }); + throw Error(message); + } +} + +async function downloadGitTemplate( + url: string, + projectDir: string, + logger: Logger, + cacheDir: CacheDirectory +): Promise { + const command = "git clone"; + const args = ["--depth", "1", "--single-branch", url]; + const repoName = path.basename(url, ".git"); + const dotGitSubPath = path.join(repoName, "/.git/"); + + try { + // clone repo + await runCommand(command, args, logger, undefined, cacheDir.getCacheDir()); + // remove .git data + cacheDir.removeCacheDir(dotGitSubPath); + // copy files from cache to project dir + await cacheDir.copyFromCache(repoName, projectDir); + } catch (e) { + // this is safe because removeCacheDir and copyFromCache should not throw + throw { + command: "git clone " + args.join(", "), + }; + } finally { + // clear cache + cacheDir.resetCache(); + } +} + +export const downloadProjectTemplate = async ( + url: string, + projectDir: string, + logger: Logger, + urlFormat?: UrlFormat +): Promise => { + urlFormat = urlFormat ?? parseUrlFormat(url); + const cacheDir = new CacheDirectory( + { + rootDir: globalCacheRoot, + subDir: createUUID(), + }, + "templates" + ).initCache(); + + if (urlFormat === UrlFormat.git) { + return downloadGitTemplate(url, projectDir, logger, cacheDir); + } else { + throw Error("This should never happen"); + } +}; diff --git a/packages/cli/src/lib/project/templates/generateProjectTemplate.ts b/packages/cli/src/lib/project/templates/generateProjectTemplate.ts new file mode 100644 index 0000000000..438681dcee --- /dev/null +++ b/packages/cli/src/lib/project/templates/generateProjectTemplate.ts @@ -0,0 +1,155 @@ +import { intlMsg } from "../../"; + +import { execSync, spawn } from "child_process"; +import fs from "fs"; +import fse from "fs-extra"; +import dns from "dns"; +import url from "url"; +import chalk from "chalk"; +import path from "path"; + +function shouldUseYarn(): boolean { + try { + execSync("yarnpkg --version", { stdio: "ignore" }); + return true; + } catch (e) { + return false; + } +} + +function getProxy() { + if (process.env.https_proxy) { + return process.env.https_proxy; + } else { + try { + // Trying to read https-proxy from .npmrc + const httpsProxy = execSync("npm config get https-proxy") + .toString() + .trim(); + return httpsProxy !== "null" ? httpsProxy : undefined; + } catch (e) { + return undefined; + } + } +} + +function checkIfOnline(useYarn: boolean) { + if (!useYarn) { + // Don't ping the Yarn registry. + // We'll just assume the best case. + return Promise.resolve(true); + } + + return new Promise((resolve) => { + dns.lookup("registry.yarnpkg.com", (err) => { + let proxy; + if (err != null && (proxy = getProxy())) { + // If a proxy is defined, we likely can't resolve external hostnames. + // Try to resolve the proxy name as an indication of a connection. + dns.lookup(url.parse(proxy).hostname || "", (proxyErr) => { + resolve(proxyErr == null); + }); + } else { + resolve(err == null); + } + }); + }); +} + +const executeCommand = ( + command: string, + args: string[], + root: string +): Promise => { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { + stdio: "inherit", + cwd: root, + shell: process.platform == "win32", + }); + child.on("close", (code: number) => { + if (code !== 0) { + // Return the failed command + reject({ + command: `${command} ${args.join(" ")}`, + }); + return; + } + + resolve(true); + }); + }); +}; + +export const generateProjectTemplate = async ( + type: string, + lang: string, + projectDir: string +): Promise => { + let command; + let args: string[]; + + const useYarn = shouldUseYarn(); + const isOnline = checkIfOnline(useYarn); + + const root = path.resolve(projectDir); + const dependencies: string[] = ["@polywrap/templates"]; + + fs.writeFileSync( + path.join(root, "package.json"), + ` +{ + "name": "template" +} + ` + ); + + if (useYarn) { + command = "yarnpkg"; + args = ["add", "--exact"]; + + if (!isOnline) { + args.push("--offline"); + } + + args.push(...dependencies); + + // Explicitly set cwd() to work around issues like + // https://github.com/facebook/create-react-app/issues/3326. + // Unfortunately we can only do this for Yarn because npm support for + // equivalent --prefix flag doesn't help with this issue. + // This is why for npm, we run checkThatNpmCanReadCwd() early instead. + args.push("--cwd"); + args.push(root); + + if (!isOnline) { + const offlineMessage = intlMsg.lib_generators_projectGenerator_offline(); + const fallbackMessage = intlMsg.lib_generators_projectGenerator_fallback(); + console.log(chalk.yellow(offlineMessage)); + console.log(chalk.yellow(fallbackMessage)); + console.log(); + } + } else { + command = "npm"; + args = ["install", "--save", "--save-exact", "--loglevel", "error"].concat( + dependencies + ); + } + + await executeCommand(command, args, root); + + try { + await fse.copy( + `${root}/node_modules/@polywrap/templates/${type}/${lang}`, + `${root}`, + { + overwrite: true, + } + ); + return true; + } catch (e) { + throw { + command: `copy ${root}/node_modules/@polywrap/templates/${type}/${lang} ${root}`, + }; + } +}; diff --git a/packages/cli/src/lib/project/templates/index.ts b/packages/cli/src/lib/project/templates/index.ts index 570796ceb1..e00fc64e9a 100644 --- a/packages/cli/src/lib/project/templates/index.ts +++ b/packages/cli/src/lib/project/templates/index.ts @@ -1,159 +1,2 @@ -import { intlMsg } from "../../"; - -import { execSync, spawn } from "child_process"; -import fs from "fs"; -import fse from "fs-extra"; -import dns from "dns"; -import url from "url"; -import chalk from "chalk"; -import path from "path"; - -function shouldUseYarn(): boolean { - try { - execSync("yarnpkg --version", { stdio: "ignore" }); - return true; - } catch (e) { - return false; - } -} - -function getProxy() { - if (process.env.https_proxy) { - return process.env.https_proxy; - } else { - try { - // Trying to read https-proxy from .npmrc - const httpsProxy = execSync("npm config get https-proxy") - .toString() - .trim(); - return httpsProxy !== "null" ? httpsProxy : undefined; - } catch (e) { - return undefined; - } - } -} - -function checkIfOnline(useYarn: boolean) { - if (!useYarn) { - // Don't ping the Yarn registry. - // We'll just assume the best case. - return Promise.resolve(true); - } - - return new Promise((resolve) => { - dns.lookup("registry.yarnpkg.com", (err) => { - let proxy; - if (err != null && (proxy = getProxy())) { - // If a proxy is defined, we likely can't resolve external hostnames. - // Try to resolve the proxy name as an indication of a connection. - dns.lookup(url.parse(proxy).hostname || "", (proxyErr) => { - resolve(proxyErr == null); - }); - } else { - resolve(err == null); - } - }); - }); -} - -const executeCommand = ( - command: string, - args: string[], - root: string -): Promise => { - return new Promise((resolve, reject) => { - const child = spawn(command, args, { - stdio: "inherit", - cwd: root, - shell: process.platform == "win32", - }); - child.on("close", (code: number) => { - if (code !== 0) { - // Return the failed command - reject({ - command: `${command} ${args.join(" ")}`, - }); - return; - } - - resolve(true); - }); - }); -}; - -export const generateProjectTemplate = async ( - type: string, - lang: string, - projectDir: string -): Promise => { - let command = ""; - let args: string[] = []; - - const useYarn = shouldUseYarn(); - const isOnline = checkIfOnline(useYarn); - - const root = path.resolve(projectDir); - const dependencies: string[] = ["@polywrap/templates"]; - - fs.writeFileSync( - path.join(root, "package.json"), - ` -{ - "name": "template" -} - ` - ); - - if (useYarn) { - command = "yarnpkg"; - args = ["add", "--exact"]; - - if (!isOnline) { - args.push("--offline"); - } - - args.push(...dependencies); - - // Explicitly set cwd() to work around issues like - // https://github.com/facebook/create-react-app/issues/3326. - // Unfortunately we can only do this for Yarn because npm support for - // equivalent --prefix flag doesn't help with this issue. - // This is why for npm, we run checkThatNpmCanReadCwd() early instead. - args.push("--cwd"); - args.push(root); - - if (!isOnline) { - const offlineMessage = intlMsg.lib_generators_projectGenerator_offline(); - const fallbackMessage = intlMsg.lib_generators_projectGenerator_fallback(); - console.log(chalk.yellow(offlineMessage)); - console.log(chalk.yellow(fallbackMessage)); - console.log(); - } - } else { - command = "npm"; - args = ["install", "--save", "--save-exact", "--loglevel", "error"].concat( - dependencies - ); - } - - try { - await executeCommand(command, args, root); - } catch (e) { - return e; - } - - try { - await fse.copy( - `${root}/node_modules/@polywrap/templates/${type}/${lang}`, - `${root}`, - { - overwrite: true, - } - ); - return true; - } catch (e) { - return { - command: `copy ${root}/node_modules/@polywrap/templates/${type}/${lang} ${root}`, - }; - } -}; +export * from "./generateProjectTemplate"; +export * from "./downloadProjectTemplate"; diff --git a/packages/js/cli/src/__tests__/commands.spec.ts b/packages/js/cli/src/__tests__/commands.spec.ts index c245b7e91c..535c3cf2f8 100644 --- a/packages/js/cli/src/__tests__/commands.spec.ts +++ b/packages/js/cli/src/__tests__/commands.spec.ts @@ -133,6 +133,19 @@ const testData: CommandTestCaseData = { expect(fs.existsSync(packagePath)).toBeTruthy(); clearDir(test.cwd); } + }], + template: [{ + cwd: fs.mkdtempSync(path.join(os.tmpdir(), "cli-js-create-test")), + arguments: ["https://github.com/polywrap/logging.git", "test-template"], + after: (test, stdout, stderr, exitCode) => { + if (!test.cwd) + throw Error("This shouldn't happen"); + const outputDir = path.join(test.cwd, "test-template"); + const packagePath = path.join(outputDir, "README.md"); + expect(fs.existsSync(outputDir)).toBeTruthy(); + expect(fs.existsSync(packagePath)).toBeTruthy(); + clearDir(test.cwd); + } }] }, deploy: [{ diff --git a/packages/js/cli/src/commands/index.ts b/packages/js/cli/src/commands/index.ts index 06ff32f5de..9b36467ba1 100644 --- a/packages/js/cli/src/commands/index.ts +++ b/packages/js/cli/src/commands/index.ts @@ -38,6 +38,9 @@ export const commands: CommandFns = { wasm: execCommandWithArgsFn( "create wasm" ), + template: execCommandWithArgsFn( + "create template" + ), }, deploy: execCommandFn("deploy"), docgen: execCommandWithArgsFn("docgen"),