// stack/refresh.mjs — keep stack knowledge current. Read-only against DO + npm. // // node --env-file=/devcontainer.env stack/refresh.mjs // npm run stack:refresh (wires the env file) // // Pulls real DigitalOcean spend + inventory and the outdated-dependency set for // the deployed projects, writes a dated snapshot, and prints DELTAS vs the last // run — the actual nightly signal (spend moved, a droplet appeared, a new major // bump landed). Does NOT touch git or the poster; the poster is a curated // artifact you rebuild when folding fresh numbers in. Never prints secrets. import { readFileSync, writeFileSync, readdirSync, mkdirSync, existsSync } from "fs"; import { execFileSync } from "child_process"; import { join, dirname } from "path"; import { fileURLToPath } from "url"; const HERE = dirname(fileURLToPath(import.meta.url)); const SNAPDIR = join(HERE, "snapshots"); const ROOT = join(HERE, ".."); if (!existsSync(SNAPDIR)) mkdirSync(SNAPDIR, { recursive: true }); const token = process.env.DIGITALOCEAN_ACCESS_TOKEN || process.env.DO_TOKEN; if (!token) { console.error("no DO token in env — run via `npm run stack:refresh` or --env-file the vault"); process.exit(2); } const doApi = async (p) => { const r = await fetch(`https://api.digitalocean.com/v2${p}`, { headers: { Authorization: `Bearer ${token}` } }); return r.ok ? r.json() : { __err: `${r.status}` }; }; // ---- DigitalOcean ---- const bal = await doApi("/customers/my/balance"); const dro = await doApi("/droplets?per_page=100"); const dbs = await doApi("/databases"); const droplets = (dro.droplets || []).map((d) => ({ name: d.name, size: d.size_slug, price: d.size?.price_monthly, region: d.region?.slug, })).sort((a, b) => b.price - a.price); const dropletTotal = droplets.reduce((s, d) => s + (d.price || 0), 0); const databases = (dbs.databases || []).map((d) => ({ name: d.name, engine: `${d.engine}/${d.version}`, size: d.size, nodes: d.num_nodes })); // ---- outdated deps (network; the deployed node projects) ---- const ncu = (dir) => { try { const out = execFileSync("npx", ["--yes", "npm-check-updates", "--jsonUpgraded", "--packageFile", join(ROOT, dir, "package.json")], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 120000 }); return JSON.parse(out || "{}"); } catch { return { __err: "ncu failed" }; } }; const outdated = { system: ncu("system"), "session-server": ncu("session-server") }; // ---- assemble snapshot ---- const now = new Date(); const stamp = now.toISOString(); const day = stamp.slice(0, 10); const snap = { stamp, do: { monthToDate: bal.month_to_date_usage ?? null, balance: bal.account_balance ?? null, dropletTotalMonthly: dropletTotal, droplets, databases, }, outdated: { system: Object.keys(outdated.system).filter((k) => k !== "__err"), "session-server": Object.keys(outdated["session-server"]).filter((k) => k !== "__err"), }, }; // ---- delta vs previous snapshot ---- const prev = readdirSync(SNAPDIR).filter((f) => f.endsWith(".json")).sort().pop(); const before = prev ? JSON.parse(readFileSync(join(SNAPDIR, prev), "utf8")) : null; const deltas = []; if (before) { const dSpend = (snap.do.dropletTotalMonthly - before.do.dropletTotalMonthly); if (dSpend) deltas.push(`droplet run-rate ${dSpend > 0 ? "+" : ""}$${dSpend}/mo (now $${dropletTotal}/mo)`); const names = (x) => new Set(x.do.droplets.map((d) => d.name)); const nowN = names(snap), oldN = names(before); for (const n of nowN) if (!oldN.has(n)) deltas.push(`NEW droplet: ${n}`); for (const n of oldN) if (!nowN.has(n)) deltas.push(`REMOVED droplet: ${n}`); for (const proj of ["system", "session-server"]) { const nw = new Set(snap.outdated[proj]), od = new Set(before.outdated[proj] || []); for (const p of nw) if (!od.has(p)) deltas.push(`${proj}: newly outdated — ${p}`); } } writeFileSync(join(SNAPDIR, `${day}.json`), JSON.stringify(snap, null, 2)); // ---- human-readable latest ---- const md = `# Stack refresh — ${day} _Auto-generated by stack/refresh.mjs. Read-only pull of DigitalOcean spend + npm outdated._ ## DigitalOcean spend - month-to-date: **$${snap.do.monthToDate}** · droplet run-rate: **$${dropletTotal}/mo** · balance: $${snap.do.balance} - droplets (${droplets.length}): ${droplets.map((d) => ` - ${d.name} — ${d.size} — $${d.price}/mo (${d.region})`).join("\n")} - managed dbs: ${databases.map((d) => `${d.name} (${d.engine}, ${d.size}×${d.nodes})`).join("; ") || "none"} ## Outdated (majors+ available) - system: ${snap.outdated.system.length} packages - session-server: ${snap.outdated["session-server"].length} packages ## Changes since last run${before ? ` (${prev.replace(".json", "")})` : ""} ${deltas.length ? deltas.map((d) => `- ${d}`).join("\n") : (before ? "- no changes" : "- (first snapshot — no prior to compare)")} `; writeFileSync(join(HERE, "refresh-latest.md"), md); console.log(md); console.log(`\nsnapshot: stack/snapshots/${day}.json`); if (deltas.length) console.log(`\n⚠️ ${deltas.length} change(s) since last run — see above`);