Skip to content
This repository was archived by the owner on Jul 29, 2026. It is now read-only.

Commit 3344aba

Browse files
committed
Move CLIs into repository
1 parent 0c95a3f commit 3344aba

40 files changed

Lines changed: 3261 additions & 0 deletions

File tree

cloudflare-cli/.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
node_modules/
2+
npm-debug.log*
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
#!/usr/bin/env node
2+
3+
import { main } from "../src/cli.js";
4+
5+
main(process.argv.slice(2)).catch((error) => {
6+
const message = error instanceof Error ? error.message : String(error);
7+
console.error(message);
8+
process.exitCode = 1;
9+
});

cloudflare-cli/package.json

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{
2+
"name": "cloudflare-cli",
3+
"version": "0.1.0",
4+
"private": true,
5+
"type": "module",
6+
"bin": {
7+
"cloudflare-cli": "./bin/cloudflare-cli.js"
8+
},
9+
"scripts": {
10+
"test": "node --test"
11+
},
12+
"engines": {
13+
"node": ">=18"
14+
}
15+
}

cloudflare-cli/src/cli.js

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import {
2+
CONFIG_FILE_PATH,
3+
expandPurgeTargets,
4+
loadConfig,
5+
purgeCache,
6+
saveConfig,
7+
validateCredentials,
8+
} from "./cloudflare.js";
9+
import { promptForConfig } from "./prompt.js";
10+
11+
export async function main(args) {
12+
const [command, ...rest] = args;
13+
14+
if (!command || command === "--help" || command === "-h") {
15+
printHelp();
16+
return;
17+
}
18+
19+
if (command === "login") {
20+
await runLogin(rest);
21+
return;
22+
}
23+
24+
if (command === "purge") {
25+
await runPurge(rest);
26+
return;
27+
}
28+
29+
throw new Error(`Unknown command: ${command}`);
30+
}
31+
32+
async function runLogin(args) {
33+
const parsed = parseFlags(args);
34+
const config = await promptForConfig({
35+
apiToken: parsed.options["api-token"],
36+
zoneId: parsed.options["zone-id"],
37+
domain: parsed.options.domain,
38+
});
39+
40+
await validateCredentials(config);
41+
await saveConfig(config);
42+
43+
console.log(`Saved Cloudflare credentials to ${CONFIG_FILE_PATH}`);
44+
}
45+
46+
async function runPurge(args) {
47+
const parsed = parseFlags(args);
48+
const everything = Boolean(parsed.options.everything);
49+
const config = await loadConfig();
50+
51+
if (!config) {
52+
throw new Error("No Cloudflare login found. Run `cloudflare-cli login` first.");
53+
}
54+
55+
if (everything && parsed.positionals.length > 0) {
56+
throw new Error("Use either --everything or explicit paths/URLs, not both.");
57+
}
58+
59+
if (!everything && parsed.positionals.length === 0) {
60+
throw new Error("Provide one or more paths/URLs to purge, or use --everything.");
61+
}
62+
63+
const domain = parsed.options.domain || config.domain;
64+
const request = everything
65+
? { purge_everything: true }
66+
: { files: expandPurgeTargets(parsed.positionals, domain) };
67+
68+
await purgeCache(config, request);
69+
70+
if (everything) {
71+
console.log(`Purged Cloudflare cache for zone ${config.zoneId}`);
72+
} else {
73+
console.log(`Purged ${request.files.length} Cloudflare cache entr${request.files.length === 1 ? "y" : "ies"}`);
74+
}
75+
}
76+
77+
function parseFlags(args) {
78+
const options = {};
79+
const positionals = [];
80+
81+
for (let index = 0; index < args.length; index += 1) {
82+
const current = args[index];
83+
if (current === "--everything") {
84+
options.everything = true;
85+
continue;
86+
}
87+
if (current.startsWith("--")) {
88+
const key = current.slice(2);
89+
const value = args[index + 1];
90+
if (!value || value.startsWith("--")) {
91+
throw new Error(`Missing value for ${current}`);
92+
}
93+
options[key] = value;
94+
index += 1;
95+
continue;
96+
}
97+
positionals.push(current);
98+
}
99+
100+
return { options, positionals };
101+
}
102+
103+
function printHelp() {
104+
console.log(`cloudflare-cli
105+
106+
Usage:
107+
cloudflare-cli login [--api-token TOKEN] [--zone-id ZONE_ID] [--domain DOMAIN]
108+
cloudflare-cli purge --everything
109+
cloudflare-cli purge [--domain DOMAIN] /path https://example.com/path
110+
`);
111+
}

cloudflare-cli/src/cloudflare.js

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
2+
import os from "node:os";
3+
import path from "node:path";
4+
5+
const CONFIG_DIR = process.env.XDG_CONFIG_HOME
6+
? path.join(process.env.XDG_CONFIG_HOME, "cloudflare-cli")
7+
: path.join(os.homedir(), ".config", "cloudflare-cli");
8+
9+
export const CONFIG_FILE_PATH = path.join(CONFIG_DIR, "config.json");
10+
11+
export async function loadConfig() {
12+
try {
13+
const raw = await readFile(CONFIG_FILE_PATH, "utf8");
14+
const parsed = JSON.parse(raw);
15+
if (!parsed.apiToken || !parsed.zoneId || !parsed.domain) {
16+
return null;
17+
}
18+
return parsed;
19+
} catch (error) {
20+
if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
21+
return null;
22+
}
23+
throw error;
24+
}
25+
}
26+
27+
export async function saveConfig(config) {
28+
await mkdir(CONFIG_DIR, { recursive: true });
29+
await writeFile(CONFIG_FILE_PATH, `${JSON.stringify(config, null, 2)}\n`, "utf8");
30+
await chmod(CONFIG_FILE_PATH, 0o600).catch(() => {});
31+
}
32+
33+
export async function validateCredentials(config) {
34+
const response = await fetch(`https://api.cloudflare.com/client/v4/zones/${config.zoneId}`, {
35+
headers: {
36+
Authorization: `Bearer ${config.apiToken}`,
37+
"Content-Type": "application/json",
38+
},
39+
});
40+
41+
const body = await response.json();
42+
if (!response.ok || body.success !== true) {
43+
throw new Error("Cloudflare login failed. Check your API token and zone id.");
44+
}
45+
}
46+
47+
export async function purgeCache(config, requestBody) {
48+
const response = await fetch(`https://api.cloudflare.com/client/v4/zones/${config.zoneId}/purge_cache`, {
49+
method: "POST",
50+
headers: {
51+
Authorization: `Bearer ${config.apiToken}`,
52+
"Content-Type": "application/json",
53+
},
54+
body: JSON.stringify(requestBody),
55+
});
56+
57+
const body = await response.json();
58+
if (!response.ok || body.success !== true) {
59+
throw new Error(`Cloudflare purge failed: ${JSON.stringify(body)}`);
60+
}
61+
}
62+
63+
export function expandPurgeTargets(targets, domain) {
64+
return targets.map((target) => normalizeTarget(target, domain));
65+
}
66+
67+
export function normalizeTarget(target, domain) {
68+
if (target.startsWith("https://") || target.startsWith("http://")) {
69+
return target;
70+
}
71+
if (target.startsWith("/")) {
72+
return `https://${domain}${target}`;
73+
}
74+
throw new Error(`Expected a full URL or absolute path, but got \`${target}\`.`);
75+
}

cloudflare-cli/src/prompt.js

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { createInterface } from "node:readline/promises";
2+
import { stdin as input, stdout as output } from "node:process";
3+
4+
export async function promptForConfig(initialValues = {}) {
5+
const rl = createInterface({ input, output });
6+
7+
try {
8+
const apiToken = await promptValue(rl, "Cloudflare API token", initialValues.apiToken);
9+
const zoneId = await promptValue(rl, "Cloudflare zone id", initialValues.zoneId);
10+
const domain = await promptValue(rl, "Default domain", initialValues.domain);
11+
12+
return {
13+
apiToken,
14+
zoneId,
15+
domain,
16+
};
17+
} finally {
18+
rl.close();
19+
}
20+
}
21+
22+
async function promptValue(rl, label, defaultValue = "") {
23+
const suffix = defaultValue ? ` [${defaultValue}]` : "";
24+
const answer = (await rl.question(`${label}${suffix}: `)).trim();
25+
return answer || defaultValue;
26+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import test from "node:test";
2+
import assert from "node:assert/strict";
3+
4+
import { expandPurgeTargets, normalizeTarget } from "../src/cloudflare.js";
5+
6+
test("normalizeTarget keeps full URLs", () => {
7+
assert.equal(
8+
normalizeTarget("https://composables.com/ui", "ignored.com"),
9+
"https://composables.com/ui",
10+
);
11+
});
12+
13+
test("normalizeTarget expands absolute paths with the configured domain", () => {
14+
assert.equal(
15+
normalizeTarget("/ui", "composables.com"),
16+
"https://composables.com/ui",
17+
);
18+
});
19+
20+
test("expandPurgeTargets expands multiple absolute paths", () => {
21+
assert.deepEqual(
22+
expandPurgeTargets(["/ui", "/compose-unstyled"], "composables.com"),
23+
[
24+
"https://composables.com/ui",
25+
"https://composables.com/compose-unstyled",
26+
],
27+
);
28+
});
29+
30+
test("normalizeTarget rejects relative paths", () => {
31+
assert.throws(
32+
() => normalizeTarget("ui", "composables.com"),
33+
/Expected a full URL or absolute path/,
34+
);
35+
});

discord-post-cli/.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
.DS_Store
2+
/discord-post
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"os"
6+
7+
"github.com/alexstyl/discord-post-cli/internal/app"
8+
"github.com/alexstyl/discord-post-cli/internal/config"
9+
"github.com/alexstyl/discord-post-cli/internal/discord"
10+
)
11+
12+
func main() {
13+
runner := app.Runner{
14+
Store: config.NewStore(),
15+
Client: discord.NewClient(),
16+
Stdin: os.Stdin,
17+
Stdout: os.Stdout,
18+
Stderr: os.Stderr,
19+
}
20+
21+
os.Exit(runner.Run(context.Background(), os.Args[1:]))
22+
}

discord-post-cli/go.mod

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
module github.com/alexstyl/discord-post-cli
2+
3+
go 1.26

0 commit comments

Comments
 (0)