// Sage, 2026-10-11: Q61a, the first real work. The pm key creates "SetSpark" and "Launchpad" under // #33 "Business", with no tasks. Refuses if #33 already has a child by either title. The sync key // reads each new project back. Rollback is an archive with the pm key; deletion is Jason's or // ops-01's. Prints no secret. // Usage: node create-33.mjs https://tasks.woltje.com PM_TOKEN_FILE SYNC_TOKEN_FILE OUT_JSON import { readFileSync, writeFileSync, existsSync } from "node:fs"; const [ORIGIN, KEY, SYNC, OUT] = process.argv.slice(2); if (!/^https:\/\/tasks\.woltje\.com$/.test(ORIGIN)) throw new Error("tasks.woltje.com only"); if (!OUT || existsSync(OUT)) throw new Error("OUT_JSON missing or exists; refusing"); // v2.7.0 serves reads under /api/v2; project create is PUT /api/v1/projects (GET /api/v2/routes). const PARENT = 33, TITLES = ["SetSpark", "Launchpad"]; const key = readFileSync(KEY, "utf8").trim(), sync = readFileSync(SYNC, "utf8").trim(); async function api(method, path, body, auth) { const r = await fetch(ORIGIN + path, { method, headers: { Authorization: `Bearer ${auth}`, "Content-Type": "application/json" }, body: body === undefined ? undefined : JSON.stringify(body), redirect: "error" }); const t = await r.text(); let json = null; try { json = JSON.parse(t); } catch {} return { status: r.status, json }; } const list = (j) => j?.items ?? (Array.isArray(j) ? j : []); async function projects() { const out = []; for (let page = 1; page < 20; page++) { const r = await api("GET", `/api/v2/projects?per_page=50&page=${page}&is_archived=true`, undefined, sync); if (r.status !== 200) throw new Error(`list projects: ${r.status}`); out.push(...list(r.json)); if (list(r.json).length < 50) return out; } throw new Error("more than 19 pages"); } const rec = { at: new Date().toISOString(), parent: PARENT, created: [], problems: [] }; const save = () => { const out = JSON.stringify(rec, null, 1) + "\n"; if (out.includes(key) || out.includes(sync)) throw new Error("secret in the record; not written"); writeFileSync(OUT, out); }; const before = await projects(); if (!before.some((p) => p.id === PARENT)) throw new Error(`#${PARENT} not visible`); const clash = before.filter((p) => TITLES.includes(p.title)); if (clash.length) throw new Error(`a project titled ${clash.map((p) => `${p.title} (#${p.id}, parent ${p.parent_project_id})`).join(", ")} exists; refusing`); writeFileSync(OUT, "", { flag: "wx" }); for (const title of TITLES) { rec.created.push({ title }); save(); const r = await api("PUT", "/api/v1/projects", { title, parent_project_id: PARENT }, key); if (r.status >= 300 || !Number.isSafeInteger(r.json?.id)) { rec.problems.push(`create ${title}: ${r.status} ${r.json?.code ?? ""} ${r.json?.message ?? ""}`); break; } Object.assign(rec.created.at(-1), { id: r.json.id, parent: r.json.parent_project_id, inverse: `archive: POST /api/v1/projects/${r.json.id} is_archived true (pm); delete is Jason's or ops-01's` }); const back = await api("GET", `/api/v2/projects/${r.json.id}`, undefined, sync); const tasks = await api("GET", `/api/v2/projects/${r.json.id}/tasks?per_page=1`, undefined, sync); Object.assign(rec.created.at(-1), { readBack: back.status, title_back: back.json?.title, parent_back: back.json?.parent_project_id, archived: back.json?.is_archived, tasks: tasks.status === 200 ? list(tasks.json).length : `status ${tasks.status}` }); if (back.status !== 200 || back.json?.title !== title || back.json?.parent_project_id !== PARENT || back.json?.is_archived) rec.problems.push(`${title} read-back differs`); if (tasks.status !== 200 || list(tasks.json).length !== 0) rec.problems.push(`${title} task read not empty or failed`); } save(); process.stdout.write(JSON.stringify(rec, null, 1) + "\n"); if (rec.problems.length) process.exit(2);