1#!/usr/bin/env node 2// Run by `nix run .#deploy` after release-check.mjs and before `wrangler 3// deploy`: the Worker's storage, made to exist and brought up to date first. 4// 5// resources.mjs <wrangler> <worker dir> 6// 7// <wrangler> is the deploy's 1Password-fed wrangler (`jevstrudel-wrangler 8// <dir> <args…>`); <worker dir> is the deploy's temporary copy of worker/, 9// whose wrangler.json this rewrites. For the production config's (top level) 10// 11// - `d1_databases` without a `database_id`: finds the database by 12// `database_name`, creating it if the account has none, and writes its id 13// into the copy. Then applies its migrations (`d1 migrations apply 14// --remote`), so the Worker about to be published never runs against a 15// schema older than its code. 16// - `kv_namespaces` without an `id`: finds the namespace by title, 17// `<worker name>-<binding, lowercased, _ as ->` (the title wrangler's own 18// automatic provisioning gives it, so either finds the other's), creating 19// it if missing, and writes its id into the copy. 20// - `r2_buckets`: an R2 binding names its bucket (`bucket_name`, a name, 21// not an account's id), so there is no id to write; the bucket is 22// created if the account has none by that name, since `wrangler deploy` 23// refuses a binding to a bucket that does not exist. 24// 25// Everything is looked up by name each run, so it is idempotent, and the 26// repo's wrangler.json holds no account's ids: a first deploy to an empty 27// account creates what it needs, and every later one finds it. wrangler's 28// automatic provisioning at deploy time is not enough by itself: it 29// creates a database during `wrangler deploy`, after which it is too late 30// to migrate it; and a KV title it created in a deploy that then failed is 31// taken (KV titles are unique per account) but bound to no Worker, which 32// its next attempt does not look for. 33// 34// Nothing here prints a credential: the wrapper reads the token itself, 35// and only ids and names pass through this process. 36import { execFileSync } from 'node:child_process'; 37import { readFileSync, writeFileSync } from 'node:fs'; 38import { join } from 'node:path'; 39import { pathToFileURL } from 'node:url'; 40 41// wrangler's own automatic-provisioning title (autoProvisionedResourceName). 42export const kvTitle = (worker, binding) => `${worker}-${binding.toLowerCase().replaceAll('_', '-')}`; 43 44// The JSON array a list command printed, from the first line that opens 45// one (past any banner; a "[WARNING]" line goes to stderr, but is not taken 46// for the list even if it did not). 47export function parseList(out, what) { 48 const start = out.search(/^\[\s*(\{|\])/m); 49 if (start < 0) throw new Error(`${what}: no JSON list in wrangler's output`); 50 const list = JSON.parse(out.slice(start)); 51 if (!Array.isArray(list)) throw new Error(`${what}: not a list`); 52 return list; 53} 54 55// The bucket names `wrangler r2 bucket list` printed. It has no --json 56// (wrangler 4.132.0): each bucket is a block of `label: value` lines, the 57// first `name:`. 58export function bucketNames(out) { 59 return [...out.matchAll(/^name:\s+(\S+)\s*$/gm)].map((m) => m[1]); 60} 61 62// `wrangler(args, { capture })` runs the deploy's wrangler in the copy and 63// returns its stdout when capturing. `config` is the copy's wrangler.json, 64// parsed; it is updated in place and returned. 65export function provision(config, wrangler, log = console.error) { 66 const databases = config.d1_databases ?? []; 67 const pendingD1 = databases.filter((db) => !db.database_id); 68 if (pendingD1.length) { 69 const list = () => 70 parseList(wrangler(['d1', 'list', '--json'], { capture: true }), 'd1 list').filter( 71 (d) => typeof d?.name === 'string' && typeof d?.uuid === 'string', 72 ); 73 let existing = list(); 74 for (const db of pendingD1) { 75 if (!existing.some((d) => d.name === db.database_name)) { 76 log(`resources: creating the D1 database ${db.database_name}`); 77 wrangler(['d1', 'create', db.database_name, '--update-config=false']); 78 existing = list(); 79 } 80 const found = existing.find((d) => d.name === db.database_name); 81 if (!found) throw new Error(`D1 database ${db.database_name} is still missing after creating it`); 82 db.database_id = found.uuid; 83 log(`resources: ${db.binding} is D1 ${db.database_name} (${found.uuid})`); 84 } 85 } 86 87 const pendingKV = (config.kv_namespaces ?? []).filter((ns) => !ns.id); 88 if (pendingKV.length) { 89 const list = () => 90 parseList(wrangler(['kv', 'namespace', 'list'], { capture: true }), 'kv namespace list').filter( 91 (n) => typeof n?.title === 'string' && typeof n?.id === 'string', 92 ); 93 let existing = list(); 94 for (const ns of pendingKV) { 95 const title = kvTitle(config.name, ns.binding); 96 if (!existing.some((n) => n.title === title)) { 97 log(`resources: creating the KV namespace ${title}`); 98 wrangler(['kv', 'namespace', 'create', title, '--update-config=false']); 99 existing = list(); 100 } 101 const found = existing.find((n) => n.title === title); 102 if (!found) throw new Error(`KV namespace ${title} is still missing after creating it`); 103 ns.id = found.id; 104 log(`resources: ${ns.binding} is KV ${title} (${found.id})`); 105 } 106 } 107 108 const buckets = (config.r2_buckets ?? []).filter((b) => b.bucket_name); 109 if (buckets.length) { 110 let existing = bucketNames(wrangler(['r2', 'bucket', 'list'], { capture: true })); 111 for (const b of buckets) { 112 if (!existing.includes(b.bucket_name)) { 113 log(`resources: creating the R2 bucket ${b.bucket_name}`); 114 wrangler(['r2', 'bucket', 'create', b.bucket_name, '--update-config=false']); 115 existing = bucketNames(wrangler(['r2', 'bucket', 'list'], { capture: true })); 116 } 117 if (!existing.includes(b.bucket_name)) throw new Error(`R2 bucket ${b.bucket_name} is still missing after creating it`); 118 log(`resources: ${b.binding} is R2 ${b.bucket_name}`); 119 } 120 } 121 return config; 122} 123 124// After the ids are in the copy's config: each database's pending 125// migrations, on the real database. wrangler lists them and asks before 126// applying when run from a terminal. 127export function migrate(config, wrangler) { 128 for (const db of config.d1_databases ?? []) { 129 wrangler(['d1', 'migrations', 'apply', db.database_name, '--remote', '--env=']); 130 } 131} 132 133if (import.meta.url === pathToFileURL(process.argv[1]).href) { 134 const [bin, dir] = process.argv.slice(2); 135 if (!bin || !dir) { 136 console.error('usage: resources.mjs <wrangler> <worker dir>'); 137 process.exit(2); 138 } 139 const path = join(dir, 'wrangler.json'); 140 const wrangler = (args, { capture = false } = {}) => 141 execFileSync(bin, [dir, ...args], { 142 encoding: 'utf8', 143 stdio: ['inherit', capture ? 'pipe' : 'inherit', 'inherit'], 144 env: { ...process.env, WRANGLER_SEND_METRICS: 'false' }, 145 }); 146 const config = provision(JSON.parse(readFileSync(path, 'utf8')), wrangler); 147 writeFileSync(path, JSON.stringify(config, null, 2)); 148 migrate(config, wrangler); 149}