| 1 | #!/usr/bin/env node |
| 2 | // Uses sudo (https://sudo.g1t.sh) without a browser, through the Access |
| 3 | // service token in .credentials/sudo-service-token.json. sudo records it |
| 4 | // as claude@service.g1t.sh (apps/sudo/README.md, "Service tokens"). |
| 5 | // |
| 6 | // node scripts/ops/sudo.mjs get /costs |
| 7 | // node scripts/ops/sudo.mjs post /costs intent=run |
| 8 | // node scripts/ops/sudo.mjs get /users/g1t-reviewer --html |
| 9 | // |
| 10 | // Prints the status, where a redirect goes, and the page as text (or the |
| 11 | // raw HTML with --html). POSTs are form-encoded, the way sudo's pages send |
| 12 | // them, with sudo's own Origin. The secret is never printed. |
| 13 | import { readFileSync } from "node:fs"; |
| 14 | |
| 15 | const SUDO = "https://sudo.g1t.sh"; |
| 16 | const CREDENTIALS = new URL("../../.credentials/sudo-service-token.json", import.meta.url); |
| 17 | |
| 18 | /** The headers that get a request past Access. */ |
| 19 | export function accessHeaders() { |
| 20 | const { client_id, client_secret } = JSON.parse(readFileSync(CREDENTIALS, "utf8")); |
| 21 | if (!client_id || !client_secret) throw new Error("sudo-service-token.json needs client_id and client_secret"); |
| 22 | return { "CF-Access-Client-Id": client_id, "CF-Access-Client-Secret": client_secret }; |
| 23 | } |
| 24 | |
| 25 | /** A page's readable text: no scripts, styles or tags, one line per block. */ |
| 26 | export function pageText(html) { |
| 27 | return html |
| 28 | .replace(/<(script|style)\b[\s\S]*?<\/\1>/gi, "") |
| 29 | .replace(/<(br|\/p|\/div|\/li|\/tr|\/h[1-6]|\/section|\/summary)\b[^>]*>/gi, "\n") |
| 30 | .replace(/<\/t[dh]>/gi, "\t") |
| 31 | .replace(/<[^>]+>/g, "") |
| 32 | .replace(/ /g, " ") |
| 33 | .replace(/&/g, "&") |
| 34 | .replace(/</g, "<") |
| 35 | .replace(/>/g, ">") |
| 36 | .replace(/"/g, '"') |
| 37 | .replace(/'/g, "'") |
| 38 | .split("\n") |
| 39 | .map((line) => line.replace(/[ \t]+/g, " ").trim()) |
| 40 | .filter(Boolean) |
| 41 | .join("\n"); |
| 42 | } |
| 43 | |
| 44 | /** GETs or POSTs `path`; `fields` are the form's name=value pairs. */ |
| 45 | export async function sudo(method, path, fields = []) { |
| 46 | const headers = { ...accessHeaders() }; |
| 47 | let body; |
| 48 | if (method === "POST") { |
| 49 | headers.origin = SUDO; |
| 50 | headers["content-type"] = "application/x-www-form-urlencoded"; |
| 51 | body = new URLSearchParams(fields.map((field) => field.split(/=(.*)/s).slice(0, 2))).toString(); |
| 52 | } |
| 53 | const response = await fetch(new URL(path, SUDO), { method, headers, body, redirect: "manual" }); |
| 54 | return { status: response.status, location: response.headers.get("location"), html: await response.text() }; |
| 55 | } |
| 56 | |
| 57 | if (process.argv[1]?.replace(/\\/g, "/").endsWith("scripts/ops/sudo.mjs")) { |
| 58 | const [verb = "get", path = "/", ...rest] = process.argv.slice(2); |
| 59 | const html = rest.includes("--html"); |
| 60 | const fields = rest.filter((arg) => arg !== "--html"); |
| 61 | const result = await sudo(verb.toUpperCase() === "POST" ? "POST" : "GET", path, fields); |
| 62 | console.log(`${result.status}${result.location ? ` -> ${result.location}` : ""}`); |
| 63 | if (result.location?.includes("cloudflareaccess.com")) { |
| 64 | console.log("Access did not accept the service token: the sudo application needs a Service Auth policy that includes it."); |
| 65 | } else { |
| 66 | console.log(html ? result.html : pageText(result.html)); |
| 67 | } |
| 68 | } |