// Sage, 2026-10-11: Q61a. The pm key installs the adapter's five buckets on #19's kanban (view 76) by // renaming the populated buckets whose meaning is plain, adding in-review, and moving the default to // todo. Every task stays in its bucket; nothing is deleted. The board must still match the // before-state file, or the script refuses before any write. Each write is logged with its inverse // as it happens, so a partial run leaves a rollback list. The sync key does every read, as the // adapter's install check does; the pm key only writes. Prints no secret. // Usage: node install-19.mjs https://tasks.woltje.com PM_TOKEN_FILE SYNC_TOKEN_FILE BEFORE_JSON OUT_JSON import { readFileSync, writeFileSync, existsSync } from "node:fs"; const [ORIGIN, KEY, SYNC, BEFORE, 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 the reads under /api/v2 and the bucket and view writes under /api/v1 (GET /api/v2/routes). const BASE = ORIGIN + "/api/v2", WRITE = ORIGIN + "/api/v1", P = 19, V = 76; const RENAME = { 159: ["To Do", "todo"], 161: ["In Progress", "in-progress"], 162: ["Blocked", "blocked"], 57: ["Done", "done"] }; const ADD = { title: "in-review", after: 161, before: 162 }; const key = readFileSync(KEY, "utf8").trim(), sync = readFileSync(SYNC, "utf8").trim(); const before = JSON.parse(readFileSync(BEFORE, "utf8")); async function api(method, path, body, auth = key) { const r = await fetch((method === "GET" ? BASE : WRITE) + 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 must = (r, l) => { if (r.status >= 300) throw new Error(`${l}: ${r.status} ${r.json?.code ?? ""} ${r.json?.message ?? ""}`); return r.json; }; const list = (j) => j?.items ?? (Array.isArray(j) ? j : []); async function board() { const view = list(must(await api("GET", `/projects/${P}/views?per_page=50`, undefined, sync), "views")).find((v) => v.id === V); if (!view) throw new Error("view 76 not listed"); const buckets = list(must(await api("GET", `/projects/${P}/views/${V}/buckets?per_page=50`, undefined, sync), "buckets")); const tasks = Object.fromEntries(buckets.map((b) => [b.id, []])); for (let page = 1; page < 100; page++) { let more = false; for (const b of list(must(await api("GET", `/projects/${P}/views/${V}/buckets/tasks?per_page=50&page=${page}`, undefined, sync), "board"))) { (tasks[b.id] ??= []).push(...(b.tasks ?? []).map((t) => t.id)); if (Number.isSafeInteger(b.count) ? b.count > page * 50 : (b.tasks ?? []).length >= 50) more = true; } if (!more) break; } for (const id of Object.keys(tasks)) tasks[id].sort((a, b) => a - b); return { view, buckets, tasks }; } const rec = { at: new Date().toISOString(), project: P, view: V, writes: [], 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); }; // Precondition: the board is the before-state board, bucket for bucket and task for task. const kb = before.views.find((v) => v.id === V); const now = await board(); const was = Object.fromEntries(kb.buckets.map((b) => [b.id, { title: b.title, tasks: [...b.tasks].sort((a, c) => a - c) }])); const is = Object.fromEntries(now.buckets.map((b) => [b.id, { title: b.title, tasks: now.tasks[b.id] ?? [] }])); if (JSON.stringify(was) !== JSON.stringify(is)) throw new Error("board differs from the before-state; refusing"); if (now.view.done_bucket_id !== 57 || now.view.default_bucket_id !== 55 || now.view.bucket_configuration_mode !== "manual") throw new Error("view 76 differs from the before-state; refusing"); writeFileSync(OUT, "", { flag: "wx" }); try { for (const [id, [from, to]] of Object.entries(RENAME)) { const b = now.buckets.find((x) => x.id === Number(id)); if (b.title !== from) throw new Error(`bucket ${id} is ${JSON.stringify(b.title)}, not ${JSON.stringify(from)}`); rec.writes.push({ op: "rename bucket", id: b.id, from, to, inverse: `POST /api/v1/projects/${P}/views/${V}/buckets/${b.id} title ${JSON.stringify(from)}` }); save(); const r = must(await api("POST", `/projects/${P}/views/${V}/buckets/${b.id}`, { ...b, title: to }), `rename ${id}`); rec.writes.at(-1).result = { title: r.title, position: r.position, limit: r.limit }; if (r.title !== to || r.position !== b.position || r.limit !== b.limit) rec.problems.push(`bucket ${id} came back ${JSON.stringify(rec.writes.at(-1).result)}`); } const pos = (id) => now.buckets.find((x) => x.id === id).position; const position = (pos(ADD.after) + pos(ADD.before)) / 2; rec.writes.push({ op: "add bucket", title: ADD.title, position }); save(); const added = must(await api("PUT", `/projects/${P}/views/${V}/buckets`, { title: ADD.title, position, project_view_id: V }), "add in-review"); Object.assign(rec.writes.at(-1), { id: added.id, inverse: `Jason deletes bucket ${added.id} (no bot holds bucket delete)` }); const view = now.view; rec.writes.push({ op: "view default bucket", from: view.default_bucket_id, to: 159, inverse: `POST /api/v1/projects/${P}/views/${V} default_bucket_id ${view.default_bucket_id}` }); save(); const v = must(await api("POST", `/projects/${P}/views/${V}`, { ...view, default_bucket_id: 159 }), "view default"); rec.writes.at(-1).result = { default: v.default_bucket_id, done: v.done_bucket_id, mode: v.bucket_configuration_mode, filter: v.filter ?? null }; } catch (e) { rec.problems.push(`stopped: ${e.message}`); save(); process.stdout.write(JSON.stringify(rec, null, 1) + "\n"); process.exit(1); } // Read back: the adapter's install rule, and every task still in the bucket it started in. const after = await board(); const titles = ["todo", "in-progress", "in-review", "blocked", "done"]; const ids = Object.fromEntries(titles.map((t) => [t, after.buckets.filter((b) => b.title === t).map((b) => b.id)])); rec.after = { done: after.view.done_bucket_id, default: after.view.default_bucket_id, mode: after.view.bucket_configuration_mode, filter: after.view.filter ?? null, buckets: after.buckets.map((b) => ({ id: b.id, title: b.title, position: b.position, tasks: (after.tasks[b.id] ?? []).length })) }; for (const t of titles) if (ids[t].length !== 1) rec.problems.push(`${t} occurs ${ids[t].length} times`); if (after.view.done_bucket_id !== ids.done[0] || after.view.default_bucket_id !== ids.todo[0]) rec.problems.push("done or default not wired to done and todo"); if (JSON.stringify(after.view.filter ?? null) !== JSON.stringify(now.view.filter ?? null)) rec.problems.push("view filter changed"); for (const [id, w] of Object.entries(was)) if (JSON.stringify(after.tasks[id] ?? []) !== JSON.stringify(w.tasks)) rec.problems.push(`bucket ${id} tasks changed`); save(); process.stdout.write(JSON.stringify(rec, null, 1) + "\n"); if (rec.problems.length) process.exit(2);