Skip to content

Commit

Permalink
Add CLI for uploading and linting template contents (#166)
Browse files Browse the repository at this point in the history
- Add upload command for uploading template contents to templates API on merges to main
- Add lint command for linting Cloudflare-specific template configuration

Other non-CLI changes:
- Add ESLint to CI checks
- Auto-generate better READMEs based on metadata in package.json
  • Loading branch information
maxwellpeterson authored Dec 3, 2024
1 parent b875a10 commit fd17a37
Show file tree
Hide file tree
Showing 31 changed files with 2,039 additions and 78 deletions.
4 changes: 4 additions & 0 deletions .github/workflows/branches.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,8 @@ jobs:
with:
node-version: "20.x"
- run: npm ci
- run: npm run build:cli
# Need to install a second time to get the CLI build linked up in the
# right place.
- run: npm ci
- run: npm run check:ci
7 changes: 7 additions & 0 deletions .github/workflows/main.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,19 @@ jobs:
env:
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
TEMPLATES_API_CLIENT_ID: ${{ secrets.TEMPLATES_API_CLIENT_ID }}
TEMPLATES_API_CLIENT_SECRET: ${{ secrets.TEMPLATES_API_CLIENT_SECRET }}
steps:
- uses: actions/checkout@v4
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: "20.x"
- run: npm ci
- run: npm run build:cli
# Need to install a second time to get the CLI build linked up in the
# right place.
- run: npm ci
- run: npm run check:ci
- run: npm run deploy
- run: npm run upload
34 changes: 34 additions & 0 deletions cli/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Templates CLI

A handy CLI for developing templates.

## Upload

The `upload` command uploads template contents to the Cloudflare Templates API for consumption by the Cloudflare dashboard and other template clients. This command runs in CI on merges to the `main` branch.

```
$ npx cli help upload
Usage: cli upload [options] [path-to-templates]
upload templates to the templates API
Arguments:
path-to-templates path to directory containing templates (default: ".")
```

## Lint

The `lint` command finds and fixes template style problems that aren't covered by Prettier or ESList. This linter focuses on Cloudflare-specific configuration and project structure.

```
$ npx cli help lint
Usage: cli lint [options] [path-to-templates]
find and fix template style problems
Arguments:
path-to-templates path to directory containing templates (default: ".")
Options:
--fix fix problems that can be automatically fixed
```
15 changes: 15 additions & 0 deletions cli/build.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import PACKAGE from "./package.json" assert { type: "json" };
import * as esbuild from "esbuild";
import fs from "node:fs";

const outfile = PACKAGE["bin"];

await esbuild.build({
entryPoints: ["src/index.ts"],
bundle: true,
sourcemap: true,
platform: "node",
outfile,
});

fs.writeFileSync(outfile, "#!/usr/bin/env node\n" + fs.readFileSync(outfile));
17 changes: 17 additions & 0 deletions cli/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"name": "cli",
"description": "A handy CLI for developing templates.",
"bin": "out/cli.js",
"dependencies": {
"commander": "12.1.0"
},
"devDependencies": {
"@types/node": "22.9.1",
"esbuild": "0.24.0",
"typescript": "5.6.3"
},
"scripts": {
"build": "node build.mjs",
"check": "tsc"
}
}
48 changes: 48 additions & 0 deletions cli/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { Command } from "commander";
import { upload } from "./upload";
import { lint } from "./lint";

const program = new Command();

program.name("cli").description("a handy CLI for developing templates");

program
.command("upload")
.description("upload templates to the templates API")
.argument(
"[path-to-templates]",
"path to directory containing templates",
".",
)
.action((templateDirectory: string) => {
const clientId = process.env.TEMPLATES_API_CLIENT_ID;
const clientSecret = process.env.TEMPLATES_API_CLIENT_SECRET;
if (!clientId || !clientSecret) {
throw new Error(
`Missing TEMPLATES_API_CLIENT_ID or TEMPLATES_API_CLIENT_SECRET`,
);
}
return upload({
templateDirectory,
api: {
endpoint: "https://integrations-platform.cfdata.org/api/v1/templates",
clientId,
clientSecret,
},
});
});

program
.command("lint")
.description("find and fix template style problems")
.argument(
"[path-to-templates]",
"path to directory containing templates",
".",
)
.option("--fix", "fix problems that can be automatically fixed")
.action((templateDirectory: string, options: { fix: boolean }) => {
lint({ templateDirectory, fix: options.fix });
});

program.parse();
68 changes: 68 additions & 0 deletions cli/src/lint.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import path from "node:path";
import { getTemplatePaths, readJSON, writeJSON } from "./util";

export type LintConfig = {
templateDirectory: string;
fix: boolean;
};

export function lint(config: LintConfig) {
const templatePaths = getTemplatePaths(config.templateDirectory);
const results = templatePaths.flatMap((templatePath) =>
lintTemplate(templatePath, config.fix),
);
if (results.length > 0) {
results.forEach(({ filePath, problems }) => {
console.error(`Problems with ${filePath}`);
problems.forEach((problem) => {
console.log(` - ${problem}`);
});
});
process.exit(1);
}
}
const CHECKS = {
"wrangler.json": [lintWrangler],
};
const TARGET_COMPATIBILITY_DATE = "2024-11-01";

type FileDiagnostic = {
filePath: string;
problems: string[];
};

function lintTemplate(templatePath: string, fix: boolean): FileDiagnostic[] {
return Object.entries(CHECKS).flatMap(([file, linters]) => {
const filePath = path.join(templatePath, file);
const problems = linters.flatMap((linter) => linter(filePath, fix));
return problems.length > 0 ? [{ filePath, problems }] : [];
});
}

function lintWrangler(filePath: string, fix: boolean): string[] {
const wrangler = readJSON(filePath) as {
compatibility_date?: string;
observability?: { enabled: boolean };
upload_source_maps?: boolean;
};
if (fix) {
wrangler.compatibility_date = TARGET_COMPATIBILITY_DATE;
wrangler.observability = { enabled: true };
wrangler.upload_source_maps = true;
writeJSON(filePath, wrangler);
return [];
}
const problems = [];
if (wrangler.compatibility_date !== TARGET_COMPATIBILITY_DATE) {
problems.push(
`"compatibility_date" should be set to "${TARGET_COMPATIBILITY_DATE}"`,
);
}
if (wrangler.observability?.enabled !== true) {
problems.push(`"observability" should be set to { "enabled": true }`);
}
if (wrangler.upload_source_maps !== true) {
problems.push(`"upload_source_maps" should be set to true`);
}
return problems;
}
77 changes: 77 additions & 0 deletions cli/src/upload.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import fs from "node:fs";
import path from "node:path";
import subprocess from "node:child_process";
import { getTemplatePaths } from "./util";

export type UploadConfig = {
templateDirectory: string;
api: {
endpoint: string;
clientId: string;
clientSecret: string;
};
};

export async function upload(config: UploadConfig) {
const templatePaths = getTemplatePaths(config.templateDirectory);
const errors = [];
for (const templatePath of templatePaths) {
try {
await uploadTemplate(templatePath, config);
} catch (e) {
errors.push(`Upload ${templatePath} failed: ${e}`);
}
}
if (errors.length > 0) {
errors.forEach((error) => {
console.error(error);
});
process.exit(1);
}
}

async function uploadTemplate(templatePath: string, config: UploadConfig) {
const files = collectTemplateFiles(templatePath);
console.info(`Uploading ${templatePath}:`);
const body = new FormData();
files.forEach((file) => {
console.info(` - ${file.name}`);
body.set(file.name, file);
});
const response = await fetch(config.api.endpoint, {
method: "POST",
headers: {
"Cf-Access-Client-Id": config.api.clientId,
"Cf-Access-Client-Secret": config.api.clientSecret,
},
body,
});
if (!response.ok) {
throw new Error(
`Error response from ${config.api.endpoint} (${response.status}): ${await response.text()}`,
);
}
}

function collectTemplateFiles(templatePath: string): File[] {
return fs
.readdirSync(templatePath, { recursive: true })
.map((file) => ({
name: file.toString(),
filePath: path.join(templatePath, file.toString()),
}))
.filter(
({ filePath }) =>
!fs.statSync(filePath).isDirectory() && !gitIgnored(filePath),
)
.map(({ name, filePath }) => new File([fs.readFileSync(filePath)], name));
}

function gitIgnored(filePath: string): boolean {
try {
subprocess.execSync(`git check-ignore ${filePath}`);
return true;
} catch {
return false;
}
}
23 changes: 23 additions & 0 deletions cli/src/util.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import fs from "node:fs";
import path from "node:path";

const TEMPLATE_DIRECTORY_SUFFIX = "-template";

export function getTemplatePaths(templateDirectory: string): string[] {
return fs
.readdirSync(templateDirectory)
.filter(
(file) =>
file.endsWith(TEMPLATE_DIRECTORY_SUFFIX) &&
fs.statSync(file).isDirectory(),
)
.map((template) => path.join(templateDirectory, template));
}

export function readJSON(filePath: string): unknown {
return JSON.parse(fs.readFileSync(filePath, { encoding: "utf-8" }));
}

export function writeJSON(filePath: string, object: unknown) {
fs.writeFileSync(filePath, JSON.stringify(object, undefined, 2) + "\n");
}
14 changes: 14 additions & 0 deletions cli/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "esnext",
"lib": ["esnext"],
"module": "nodenext",
"types": ["@types/node"],
"noEmit": true,
"isolatedModules": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"strict": true
},
"include": ["src"]
}
16 changes: 13 additions & 3 deletions d1-template/README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
# D1 Template
# Worker + D1 Database

[Visit](https://d1-template.templates.workers.dev)
Cloudflare's native serverless SQL database.

TODO
## Develop Locally

Use this template with [C3](https://developers.cloudflare.com/pages/get-started/c3/) (the `create-cloudflare` CLI):

```
npm create cloudflare@latest -- --template=cloudflare/templates/d1-template
```

## Preview Deployment

A live public deployment of this template is available at [https://d1-template.templates.workers.dev](https://d1-template.templates.workers.dev)
2 changes: 1 addition & 1 deletion d1-template/wrangler.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"compatibility_date": "2024-11-15",
"compatibility_date": "2024-11-01",
"main": "src/index.ts",
"name": "d1-template",
"upload_source_maps": true,
Expand Down
23 changes: 23 additions & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// @ts-check

import eslint from "@eslint/js";
import tseslint from "typescript-eslint";

export default tseslint.config(
eslint.configs.recommended,
...tseslint.configs.strictTypeChecked,
{
languageOptions: {
parserOptions: {
project: ["./*-template/tsconfig.json", "./cli/tsconfig.json"],
tsconfigRootDir: import.meta.dirname,
},
},
rules: {
"@typescript-eslint/restrict-template-expressions": "off",
},
},
{
ignores: ["**/*.js", "**/*.mjs"],
},
);
Loading

0 comments on commit fd17a37

Please sign in to comment.