Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add CLI for uploading and linting template contents #166

Merged
merged 1 commit into from
Dec 3, 2024
Merged
Show file tree
Hide file tree
Changes from all 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
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