diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..cc672fe --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +*.lockb binary diff=lockb \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..76add87 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +node_modules +dist \ No newline at end of file diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..40a498b --- /dev/null +++ b/.prettierrc @@ -0,0 +1,4 @@ +{ + "useTabs": true, + "trailingComma": "all" +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..22e43fe --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,6 @@ +{ + "editor.insertSpaces": false, + "editor.detectIndentation": false, + "editor.tabSize": 2, + "editor.defaultFormatter": "esbenp.prettier-vscode" +} \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..09d372d --- /dev/null +++ b/README.md @@ -0,0 +1,13 @@ +# CloudSpark + +> [!WARNING] +> This package is under construction and is **not** ready for production use. + +CloudSpark is a Node CLI tool that allows easy bootstrapping of a Cloudflare Developer Platform project, which may include a Worker, Pages site, or any other dependent bindings. + +## Installation + +While under development, CloudSpark is not on NPM. To install from the latest GitHub commit: +```bash +npm install -g https://github.com/Cloudflare-Community/cloudspark +``` \ No newline at end of file diff --git a/build.ts b/build.ts new file mode 100644 index 0000000..0707662 --- /dev/null +++ b/build.ts @@ -0,0 +1,15 @@ +import { build } from "esbuild"; + +await build({ + entryPoints: ["./src/index.ts"], + bundle: true, + minify: true, + sourcemap: true, + outdir: "./dist", + format: "esm", + platform: "node", + target: "esnext", + banner: { + js: `import path from "path";\nimport { fileURLToPath } from "url";\nimport { createRequire as topLevelCreateRequire } from "module";\nconst require = topLevelCreateRequire(import.meta.url);\nconst __filename = fileURLToPath(import.meta.url);\nconst __dirname = path.dirname(__filename);`, + }, +}) \ No newline at end of file diff --git a/bun.lockb b/bun.lockb new file mode 100755 index 0000000..57c3857 Binary files /dev/null and b/bun.lockb differ diff --git a/package.json b/package.json new file mode 100644 index 0000000..8f5d9ae --- /dev/null +++ b/package.json @@ -0,0 +1,28 @@ +{ + "name": "cloudspark", + "type": "module", + "bin": { + "cloudspark": "./dist/index.js" + }, + "files": [ + "./dist/*" + ], + "scripts": { + "dev": "tsx src/index.ts", + "build": "tsx build.ts" + }, + "devDependencies": { + "@clack/prompts": "^0.7.0", + "@cloudflare/workers-types": "^4.20231025.0", + "@types/argparse": "^2.0.13", + "@types/degit": "^2.8.6", + "@types/node": "^20.9.0", + "argparse": "^2.0.1", + "clack": "^0.1.0", + "commander": "^11.1.0", + "degit": "^2.8.4", + "esbuild": "^0.19.5", + "eslint": "^8.53.0", + "prettier": "^3.1.0" + } +} diff --git a/src/constants.ts b/src/constants.ts new file mode 100644 index 0000000..e784387 --- /dev/null +++ b/src/constants.ts @@ -0,0 +1 @@ +const THIS_REPO = "cloudflare/workers-sdk" \ No newline at end of file diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..8cfd572 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,25 @@ +import { normalize } from "path"; +import { intro, outro, select, spinner, text, log, isCancel, confirm } from "@clack/prompts"; +import { statSync, readdirSync, existsSync } from "fs"; +import { program } from "commander"; +import interactive from "./init/interactive"; +import express from "./init/express"; + +const cli = program + .name("cloudspark") + .description("Cloudspark CLI, your CommunityApproved™ Cloudflare Developer Platform CLI"); + +cli.command("init") + .description("Initialize a new Worker") + .argument("[repo]", "The repository to initialize.") + .argument("[folder]", "The folder to initialize to.") + .option("--force", "Force clone the template, ignoring existing files.") + .action(async (repo, folder, args) => { + if (repo == null) { + await interactive(args.force ?? false); + } else { + await express(repo, folder, args.force ?? false) + } + }) + +await cli.parseAsync(); diff --git a/src/init/clone.ts b/src/init/clone.ts new file mode 100644 index 0000000..5cdd39f --- /dev/null +++ b/src/init/clone.ts @@ -0,0 +1,26 @@ +import degit from "degit"; +import { log, outro, spinner } from "@clack/prompts"; + +export default async function clone(repo: string, target: string) { + // Start a loading spinner. + const spin = spinner(); + spin.start("Cloning template..."); + + // Create a degit emitter. + const emitter = degit(repo, { + cache: false, + force: true + }); + + // Clone the repo. + emitter.on("warn", (warning) => log.warn(warning.message)); + try { + await emitter.clone(target); + } catch (e: any) { + spin.stop(); + log.error(e.message); + outro("Cancelled. Artifacts may be present."); + process.exit(1); + } + spin.stop("Successfully cloned template."); +} \ No newline at end of file diff --git a/src/init/express.ts b/src/init/express.ts new file mode 100644 index 0000000..3eeb49a --- /dev/null +++ b/src/init/express.ts @@ -0,0 +1,42 @@ +import clone from "./clone"; +import { normalize } from "path"; +import { statSync, readdirSync, existsSync } from "fs"; +import { intro, outro, select, text, log, isCancel, confirm } from "@clack/prompts"; +import { validateFolder } from "./utils"; + +export default async (template: string, target: string | null, force: boolean) => { + // User didn't provide a repo, enter interactive mode. + intro("Cloudspark CLI ⚡️"); + + let finalTarget: string; + if (target != null) { + log.message(`Express mode active. Initializing "${template}" in folder "${target}".`); + finalTarget = target; + } else { + log.message(`Express mode active. Initializing "${template}".`); + + // Ask the user for a target directory. + const target = await text({ + message: "Enter the target directory you want to initialize into", + placeholder: `./worker`, + defaultValue: `./worker`, + }); + if (isCancel(target)) { + outro("Cancelled. No changes were made."); + process.exit(0); + } + finalTarget = target; + } + + // Normalize the path. + const normalizedTarget = normalize(finalTarget); + + // Perform validation on the path. + await validateFolder(normalizedTarget, force); + + // Clone the repo. + await clone(template, normalizedTarget); + + outro(`Done! Your Worker is ready to go at ${finalTarget}`) + process.exit(0); +} \ No newline at end of file diff --git a/src/init/interactive.ts b/src/init/interactive.ts new file mode 100644 index 0000000..09151c3 --- /dev/null +++ b/src/init/interactive.ts @@ -0,0 +1,51 @@ +import clone from "./clone"; +import { normalize } from "path"; +import { statSync, readdirSync, existsSync, rmSync } from "fs"; +import { intro, outro, select, text, log, isCancel, confirm } from "@clack/prompts"; +import { validateFolder } from "./utils"; + +export default async (force: boolean) => { + // User didn't provide a repo, enter interactive mode. + intro("Cloudspark CLI ⚡️"); + log.message("Let's get started by selecting a template."); + log.message("You can also run `cloudspark init ` to initialize a specific repository."); + + // Ask the user for a template selection. + const result: string | symbol = await select({ + message: "Select a template", + options: [ + { + label: "Hello World", + value: "hello-world", + hint: "A simple hello world Worker template" + }, + ] + }) + if (isCancel(result)) { + outro("Cancelled. No changes were made."); + process.exit(0); + } + + // Ask the user for a target directory. + const target = await text({ + message: "Enter the target directory you want to initialize in", + placeholder: `./${result}`, + defaultValue: `./${result}`, + }); + if (isCancel(target)) { + outro("Cancelled. No changes were made."); + process.exit(0); + } + + // Normalize the path. + const normalizedTarget = normalize(target); + + // Perform validation on the path. + await validateFolder(normalizedTarget, force); + + // Clone the repo. + await clone(`${THIS_REPO}/${result}`, normalizedTarget); + + outro(`Done! Your Worker is ready to go at ${target}`) + process.exit(0); +} \ No newline at end of file diff --git a/src/init/utils.ts b/src/init/utils.ts new file mode 100644 index 0000000..5131490 --- /dev/null +++ b/src/init/utils.ts @@ -0,0 +1,42 @@ +import { statSync, readdirSync, existsSync, rmSync } from "fs"; +import { intro, outro, select, text, log, isCancel, confirm } from "@clack/prompts"; + +export const validateFolder = async (target: string, force: boolean) => { + if (existsSync(target)) { + // Check whether target exists and is a file or symlink. + const statResult = statSync(target); + if (statResult.isFile() || statResult.isSymbolicLink()) { + if (force) { + log.warn("Target is a file or symlink, but force flag is set, removing file and continuing."); + } else { + log.error("Target is a file or symlink."); + const overwrite = await confirm({ + message: "Remove and continue?", + initialValue: false, + }); + if (isCancel(overwrite) || !overwrite) { + outro("Cancelled. No changes were made."); + process.exit(0); + } + } + rmSync(target); + } + + // Check whether target already has files + if (statResult.isDirectory() && readdirSync(target).length > 0) { + if (force) { + log.warn("Target directory is not empty but force flag is set, continuing.") + } else { + log.warn("Target directory is not empty."); + const overwrite = await confirm({ + message: "Continue anyway?", + initialValue: false, + }); + if (isCancel(overwrite) || !overwrite) { + outro("Cancelled. No changes were made."); + process.exit(0); + } + } + } + } +} \ No newline at end of file diff --git a/templates/hello-world/package.json b/templates/hello-world/package.json new file mode 100644 index 0000000..e1fe09c --- /dev/null +++ b/templates/hello-world/package.json @@ -0,0 +1,15 @@ +{ + "name": "hello-world", + "type": "module", + "version": "0.0.0", + "private": true, + "scripts": { + "start": "wrangler dev", + "deploy": "wrangler publish" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20231025.0", + "typescript": "^5.2.2", + "wrangler": "^3.15.0" + } +} \ No newline at end of file diff --git a/templates/hello-world/src/index.ts b/templates/hello-world/src/index.ts new file mode 100644 index 0000000..c4b579a --- /dev/null +++ b/templates/hello-world/src/index.ts @@ -0,0 +1,33 @@ +/** + * Welcome to Cloudflare Workers! This is your first worker. + * + * - Run `wrangler dev src/index.ts` in your terminal to start a development server + * - Open a browser tab at http://localhost:8787/ to see your worker in action + * - Run `wrangler publish src/index.ts --name my-worker` to publish your worker + * + * Learn more at https://developers.cloudflare.com/workers/ + */ + +export interface Env { + // Example binding to KV. Learn more at https://developers.cloudflare.com/workers/runtime-apis/kv/ + // MY_KV_NAMESPACE: KVNamespace; + // + // Example binding to Durable Object. Learn more at https://developers.cloudflare.com/workers/runtime-apis/durable-objects/ + // MY_DURABLE_OBJECT: DurableObjectNamespace; + // + // Example binding to R2. Learn more at https://developers.cloudflare.com/workers/runtime-apis/r2/ + // MY_BUCKET: R2Bucket; + // + // Example binding to a Service. Learn more at https://developers.cloudflare.com/workers/runtime-apis/service-bindings/ + // MY_SERVICE: Fetcher; +} + +export default { + async fetch( + request: Request, + env: Env, + ctx: ExecutionContext + ): Promise { + return new Response("Hello World!"); + }, +}; diff --git a/templates/hello-world/tsconfig.json b/templates/hello-world/tsconfig.json new file mode 100644 index 0000000..39a4387 --- /dev/null +++ b/templates/hello-world/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "es2021", + "lib": [ + "es2021" + ], + "jsx": "react", + "module": "es2022", + "moduleResolution": "node", + "types": [ + "@cloudflare/workers-types" + ], + "resolveJsonModule": true, + "allowJs": true, + "checkJs": false , + "noEmit": true, + "isolatedModules": true , + "allowSyntheticDefaultImports": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true + } +} \ No newline at end of file diff --git a/templates/hello-world/wrangler.toml b/templates/hello-world/wrangler.toml new file mode 100644 index 0000000..b103181 --- /dev/null +++ b/templates/hello-world/wrangler.toml @@ -0,0 +1,3 @@ +name = "hello-world" +main = "src/index.ts" +compatibility_date = "2023-11-12" \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..2e01fe6 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "esnext", + "module": "esnext", + "moduleResolution": "node", + "lib": ["esnext"], + "isolatedModules": true, + "types": ["node"], + "noEmit": true, + "strict": true, + "esModuleInterop": true, + }, + "include": [ + "src", + "types", + "build.ts" + ] +}