A setup script is only useful if you can run it twice. Each script here reads a JSON file from config/ and makes the portal match it.
The upsert pattern
For properties the API returns 409 when one already exists, so the script catches that and sends a PATCH instead. Running the whole thing again changes nothing unless the JSON changed. Every script also takes --dry-run.
try { await api('POST', url, prop) }
catch (e) { if (e.status === 409) await api('PATCH', url + '/' + prop.name, prop) }
Ticket pipeline
The default pipeline's stages are renamed by position to New, In progress, Waiting on customer and Closed. The script never deletes a stage, because deleting one that tickets sit in is a mistake you only make once.
Forms have opinions
The Forms v3 API rejected our first attempts one error at a time. What we learned: a field group holds at most three fields, richTextType must be lowercase, a validation block is required, and timestamps such as createdAt must not be sent back on update. Hidden fields are set with a defaultValue, which turned out to be the key to the next post.