// export-vals.ts: copy vals' code out of Val Town with its REST API. // // deno run -A export-vals.ts every val your API key can list // deno run -A export-vals.ts user/val ... just these (public ones need no key) // // The key is read from VAL_TOWN_API_KEY or ~/.valtown-api-key. Each val's // files (main branch) go to ~/valtown-export//, and // ~/valtown-export/vals.json records every file's trigger type, its // val.run or email address, and the names (not values) of the val's // environment variables. Nothing on Val Town changes. const API = "https://api.val.town"; const HOME = Deno.env.get("HOME")!; const OUT = Deno.env.get("EXPORT_DIR") ?? `${HOME}/valtown-export`; let key = Deno.env.get("VAL_TOWN_API_KEY"); if (!key) { try { key = (await Deno.readTextFile(`${HOME}/.valtown-api-key`)).trim(); } catch { /* public vals only */ } } async function api(path: string, raw = false) { const res = await fetch(path.startsWith("http") ? path : API + path, { headers: key ? { Authorization: `Bearer ${key}` } : {}, }); if (!res.ok) throw new Error(`${res.status} ${path}: ${await res.text()}`); return raw ? res.text() : res.json(); } // Follow links.next through a paginated list. async function all(path: string) { const items = []; let next: string | undefined = path; while (next) { const page = await api(next); items.push(...page.data); next = page.links?.next; } return items; } let vals; if (Deno.args.length) { vals = []; for (const a of Deno.args) { const [user, name] = a.replace(/^.*val\.town\/x\//, "").split("/"); vals.push(await api(`/v2/alias/vals/${user}/${name}`)); } } else { if (!key) throw new Error("Set VAL_TOWN_API_KEY or write it to ~/.valtown-api-key"); vals = await all("/v2/me/vals?offset=0&limit=100"); } await Deno.mkdir(OUT, { recursive: true }); const seen = new Set(); const report = []; for (const val of vals) { let dir = val.name; if (seen.has(dir)) dir = `${val.author.username}-${val.name}`; seen.add(dir); const files = await all( `/v2/vals/${val.id}/files?path=&recursive=true&offset=0&limit=100`, ); for (const f of files) { if (f.type === "directory") continue; const text = await api( `/v2/vals/${val.id}/files/content?path=${encodeURIComponent(f.path)}`, true, ); const path = `${OUT}/${dir}/${f.path}`; await Deno.mkdir(path.slice(0, path.lastIndexOf("/")), { recursive: true }); await Deno.writeTextFile(path, text); } let env: string[] = []; if (key && !Deno.args.length) { try { env = (await all(`/v2/vals/${val.id}/environment_variables/?offset=0&limit=100`)) .map((e) => e.key); } catch { /* not allowed for this val */ } } const triggers = files .filter((f) => ["http", "interval", "email"].includes(f.type)) .map((f) => ({ file: f.path, type: f.type === "interval" ? "cron" : f.type, address: f.links.endpoint ?? f.links.email, })); report.push({ dir, name: `${val.author.username}/${val.name}`, privacy: val.privacy, url: val.links.html, files: files.filter((f) => f.type !== "directory").length, triggers, env, }); console.log(`${dir}: ${report.at(-1)!.files} files`); for (const t of triggers) console.log(` ${t.type.padEnd(5)} ${t.file} ${t.address ?? ""}`); if (env.length) console.log(` env ${env.join(" ")}`); } await Deno.writeTextFile(`${OUT}/vals.json`, JSON.stringify(report, null, 2) + "\n"); console.log(`\n${report.length} vals in ${OUT}; summary in ${OUT}/vals.json`);