diff --git a/docs/superpowers/plans/2026-04-30-admin-customizations.md b/docs/superpowers/plans/2026-04-30-admin-customizations.md new file mode 100644 index 0000000..7bc2349 --- /dev/null +++ b/docs/superpowers/plans/2026-04-30-admin-customizations.md @@ -0,0 +1,327 @@ +# Customizations Page — Plan 5a + +> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. + +**Goal:** Replace the `customizations` route stub with a real page that lists every file under `custom/` and shows whether each one shadows a built-in upstream file. This is the OSS-self-hosted "what have I overridden?" view. + +**Architecture:** Add `listCustomizations()` to a new `src/customizations.ts` module (server-only — pure filesystem read). Add `GET /api/customizations` route. Add `src/admin-ui/pages/customizations.ts`. Remove the `customizations` stub. + +**Categorization:** the resolver in `src/pipeline/resolve-module.ts` recognizes three categories under `custom/`: +- `custom/pipelines/*.yml` shadows `pipelines/*.yml` +- `custom/steps/.ts|js|mjs` shadows `src/pipeline/steps/.ts` (if it exists) +- `custom/providers/.ts|js|mjs` shadows a (currently nonexistent) built-in provider + +For each `custom/`, we report whether the corresponding upstream file exists (if so → `isShadow: true`). + +**Out of scope:** +- Inline diff view (would need both files read + a diff library; defer). +- Edit / delete buttons (writes are explicit user action — defer until there's demand). +- Drift detection from a specific upstream version (we don't ship version metadata yet). + +**Branching:** `admin-overhaul-5a-customizations` off `admin-overhaul`. PR back to `admin-overhaul`. + +--- + +## Endpoint contract + +`GET /api/customizations` (auth-protected). Response 200: + +```ts +{ + customizations: Array<{ + relativePath: string; // e.g. "pipelines/autonomous.yml" or "steps/hello.ts" + customPath: string; // "custom/pipelines/autonomous.yml" + category: 'pipeline' | 'step' | 'provider' | 'other'; + upstreamPath: string | null; // "pipelines/autonomous.yml" or "src/pipeline/steps/hello.ts" or null + isShadow: boolean; // upstreamPath exists on disk + customSize: number; // bytes + customMtime: number; // ms epoch + }>; + customRoot: string; // absolute path of the custom/ directory the server scanned +} +``` + +Sorted by `category` then `relativePath`. + +Files to skip when walking `custom/`: `README.md`, `.gitkeep`, anything starting with `.`. Anything else is reported even if not in a recognized category (use `category: 'other'`). + +--- + +## File Structure + +``` +src/customizations.ts — NEW. listCustomizations() helper. +src/__tests__/customizations.test.ts — NEW. Unit test on a temp dir. +src/admin.ts — MODIFIED. Add GET /api/customizations. +src/__tests__/admin.test.ts — MODIFIED. 401 + 200-shape tests. +src/admin-ui/pages/customizations.ts — NEW. +src/admin-ui/pages/stubs.ts — MODIFIED. Remove "customizations" entry. +src/admin-ui/index.ts — MODIFIED. Inject + script. +src/admin-ui/__tests__/customizations.test.ts — NEW. Structural tests. +``` + +--- + +## Task 1: `listCustomizations()` helper + +**File:** `src/customizations.ts` + +```ts +import fs from "node:fs"; +import path from "node:path"; + +export interface CustomizationEntry { + relativePath: string; + customPath: string; + category: "pipeline" | "step" | "provider" | "other"; + upstreamPath: string | null; + isShadow: boolean; + customSize: number; + customMtime: number; +} + +const SKIPPED_FILES = new Set(["README.md", ".gitkeep"]); + +function categorize(relativePath: string): { category: CustomizationEntry["category"]; upstream: string | null } { + if (relativePath.startsWith("pipelines/")) { + return { category: "pipeline", upstream: relativePath }; + } + if (relativePath.startsWith("steps/")) { + const base = relativePath.replace(/^steps\//, "").replace(/\.(ts|js|mjs)$/, ""); + return { category: "step", upstream: `src/pipeline/steps/${base}.ts` }; + } + if (relativePath.startsWith("providers/")) { + const base = relativePath.replace(/^providers\//, "").replace(/\.(ts|js|mjs)$/, ""); + return { category: "provider", upstream: `src/pipeline/providers/${base}.ts` }; + } + return { category: "other", upstream: null }; +} + +function walk(root: string, prefix: string, out: string[]): void { + for (const ent of fs.readdirSync(path.join(root, prefix), { withFileTypes: true })) { + if (ent.name.startsWith(".")) continue; + if (SKIPPED_FILES.has(ent.name)) continue; + const rel = prefix ? `${prefix}/${ent.name}` : ent.name; + if (ent.isDirectory()) walk(root, rel, out); + else if (ent.isFile()) out.push(rel); + } +} + +export function listCustomizations(opts?: { customRoot?: string; cwd?: string }): { + customRoot: string; + customizations: CustomizationEntry[]; +} { + const cwd = opts?.cwd ?? process.cwd(); + const customRoot = opts?.customRoot ?? path.join(cwd, "custom"); + + if (!fs.existsSync(customRoot)) { + return { customRoot, customizations: [] }; + } + + const files: string[] = []; + walk(customRoot, "", files); + + const entries: CustomizationEntry[] = files.map((relativePath) => { + const customPath = path.posix.join("custom", relativePath); + const absCustom = path.join(customRoot, relativePath); + const stat = fs.statSync(absCustom); + const { category, upstream } = categorize(relativePath); + let upstreamPath: string | null = null; + let isShadow = false; + if (upstream) { + const absUpstream = path.join(cwd, upstream); + if (fs.existsSync(absUpstream)) { + upstreamPath = upstream; + isShadow = true; + } else { + upstreamPath = upstream; + isShadow = false; + } + } + return { + relativePath, + customPath, + category, + upstreamPath, + isShadow, + customSize: stat.size, + customMtime: stat.mtimeMs, + }; + }); + + entries.sort((a, b) => + a.category.localeCompare(b.category) || + a.relativePath.localeCompare(b.relativePath), + ); + + return { customRoot, customizations: entries }; +} +``` + +### Tests (`src/__tests__/customizations.test.ts`) + +Use `os.tmpdir()` to create a sandbox. Three tests: + +1. **Returns empty when `custom/` doesn't exist.** Pass a `customRoot` pointing at a non-existent dir; expect `customizations: []`. + +2. **Categorizes a pipeline override and detects upstream.** Build a temp tree: + ``` + tmp/ + pipelines/autonomous.yml + custom/pipelines/autonomous.yml + ``` + Pass `cwd: tmp`. Expect one entry with `category: 'pipeline'`, `isShadow: true`, `upstreamPath: 'pipelines/autonomous.yml'`. + +3. **Step override without upstream → `isShadow: false`.** Build: + ``` + tmp/ + custom/steps/hello.ts + ``` + (no `src/pipeline/steps/hello.ts`). Expect `category: 'step'`, `isShadow: false`, `upstreamPath: 'src/pipeline/steps/hello.ts'`. + +4. **Skips `README.md` and `.gitkeep`.** Expect those to be absent from results. + +5. **`other` category for unrecognized path.** A file at `custom/notes.md` reports `category: 'other'`, `upstreamPath: null`, `isShadow: false`. + +Commit: `feat(customizations): add listCustomizations helper`. + +--- + +## Task 2: `/api/customizations` endpoint + +**Files:** +- Modify: `src/admin.ts` +- Modify: `src/__tests__/admin.test.ts` + +Tests: 401 without auth; 200 returns `{ customRoot, customizations }`. + +Wire the route: +```ts +if (url === "/api/customizations" && method === "GET") { + return json(res, 200, listCustomizations()); +} +``` + +Add the import. + +Commit: `feat(admin): add /api/customizations endpoint`. + +--- + +## Task 3: Page module + +**File:** `src/admin-ui/pages/customizations.ts` + +```html + +``` + +Script (IIFE): + +- `function fmtSize(bytes)`: pretty-print bytes (`<1k → 'N B'`, `<1m → 'N.NN k'`, else `M.MM MB`). +- `function fmtAgo(ms)`: same as elsewhere. +- `async function loadCustomizations()`: fetch, render. On error: error alert + clear body + hide empty. +- `renderRows(items)`: + - Each row: + - Path: `${customPath}`. + - Category: `${label}` where `pipeline→info/Pipeline`, `step→success/Step`, `provider→warn/Provider`, `other→neutral/Other`. + - Status: if `isShadow` → `Override`; else if `category === 'other'` → ``; else → `Additive`. + - Upstream: `${upstreamPath ?? '—'}`. + - Size: right-aligned `mono text-tertiary` with `fmtSize`. + - Modified: right-aligned `mono text-tertiary` with `fmtAgo`. +- Subtitle: `${count} customization(s)` where count is non-`other`; e.g., `"3 customizations · 2 overrides"` if 2 are shadows. +- Set `#customizations-root` to the `customRoot` (truncate or display in mono). +- 60s auto-refresh. +- Window: `loadCustomizations`. + +`const`/`let` only. `window.api`/`window.esc` only. + +Commit: `feat(admin): add customizations page module`. + +--- + +## Task 4: Wire + remove stub + +- Modify `index.ts` to import + inject. +- Modify `stubs.ts` to remove the `customizations` entry. + +Commit: `feat(admin): wire customizations page, remove its stub`. + +--- + +## Task 5: Structural tests + +```ts +// src/admin-ui/__tests__/customizations.test.ts +import { describe, expect, it } from "vitest"; +import { customizationsHtml, customizationsScript } from "../pages/customizations.js"; + +describe("customizations page", () => { + it("declares the expected ids", () => { + for (const id of ["customizations-subtitle", "customizations-error", "customizations-root", "customizations-body", "customizations-empty"]) { + expect(customizationsHtml).toContain(`id="${id}"`); + } + }); + it("registers route + exposes loadCustomizations", () => { + expect(customizationsScript).toContain("window.registerPage('customizations'"); + expect(customizationsScript).toContain("window.loadCustomizations = loadCustomizations"); + }); + it("calls /api/customizations", () => { + expect(customizationsScript).toContain("/api/customizations"); + }); + it("uses window.api/window.esc only", () => { + const stripped = customizationsScript.replace(/window\.api\(/g, "").replace(/window\.esc\(/g, ""); + expect(stripped).not.toMatch(/\bapi\(/); + expect(stripped).not.toMatch(/\besc\(/); + }); + it("uses const/let, not var", () => { + expect(customizationsScript).not.toMatch(/\bvar\s+\w/); + }); +}); +``` + +Commit: `test(admin): structural tests for customizations page module`. + +--- + +## Risks + +- **Symlink loops:** `walk()` uses `readdirSync` and `isDirectory()`. A symlink loop under `custom/` would recurse forever. Acceptable for now — `custom/` is operator-controlled. Future polish: use `withFileTypes` and skip symlinks. +- **Permissions:** if any file under `custom/` is unreadable, `statSync` throws. The current implementation propagates the error → 500. Acceptable; the operator controls what's there. +- **No upstream pipeline path for non-`autonomous.yml` overrides:** the `pipelines/` directory in the repo only has `autonomous.yml` today. A custom override at `custom/pipelines/something-else.yml` reports `isShadow: false` and `upstreamPath: 'pipelines/something-else.yml'` (which doesn't exist). That's correct — additive overrides aren't shadows. diff --git a/src/__tests__/admin.test.ts b/src/__tests__/admin.test.ts index dab9159..10d6b87 100644 --- a/src/__tests__/admin.test.ts +++ b/src/__tests__/admin.test.ts @@ -1108,3 +1108,19 @@ describe("admin blockers endpoint", () => { expect(body.totals.issues).toBe(1); }); }); + +describe("admin customizations endpoint", () => { + it("returns 401 without auth token", async () => { + const res = await request("/api/customizations", "GET", "secret"); + expect(res.statusCode).toBe(401); + }); + + it("returns 200 with shape", async () => { + const token = await login("secret"); + const res = await request("/api/customizations", "GET", "secret", undefined, token); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(typeof body.customRoot).toBe("string"); + expect(Array.isArray(body.customizations)).toBe(true); + }); +}); diff --git a/src/__tests__/customizations.test.ts b/src/__tests__/customizations.test.ts new file mode 100644 index 0000000..34b6f55 --- /dev/null +++ b/src/__tests__/customizations.test.ts @@ -0,0 +1,72 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { listCustomizations } from "../customizations.js"; + +let tempDir: string; + +beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "customizations-test-")); +}); + +afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); +}); + +describe("listCustomizations", () => { + it("returns empty when custom/ doesn't exist", () => { + const result = listCustomizations({ cwd: tempDir }); + expect(result.customizations).toEqual([]); + expect(result.customRoot).toBe(path.join(tempDir, "custom")); + }); + + it("pipeline override + upstream exists → isShadow: true", () => { + fs.mkdirSync(path.join(tempDir, "pipelines"), { recursive: true }); + fs.writeFileSync(path.join(tempDir, "pipelines", "autonomous.yml"), "steps: []"); + fs.mkdirSync(path.join(tempDir, "custom", "pipelines"), { recursive: true }); + fs.writeFileSync(path.join(tempDir, "custom", "pipelines", "autonomous.yml"), "steps: []"); + + const result = listCustomizations({ cwd: tempDir }); + expect(result.customizations).toHaveLength(1); + const entry = result.customizations[0]; + expect(entry.category).toBe("pipeline"); + expect(entry.isShadow).toBe(true); + expect(entry.upstreamPath).toBe("pipelines/autonomous.yml"); + }); + + it("step override without upstream → isShadow: false", () => { + fs.mkdirSync(path.join(tempDir, "custom", "steps"), { recursive: true }); + fs.writeFileSync(path.join(tempDir, "custom", "steps", "hello.ts"), "export default {};"); + + const result = listCustomizations({ cwd: tempDir }); + expect(result.customizations).toHaveLength(1); + const entry = result.customizations[0]; + expect(entry.category).toBe("step"); + expect(entry.isShadow).toBe(false); + expect(entry.upstreamPath).toBe("src/pipeline/steps/hello.ts"); + }); + + it("README.md and .gitkeep are skipped", () => { + fs.mkdirSync(path.join(tempDir, "custom", "pipelines"), { recursive: true }); + fs.writeFileSync(path.join(tempDir, "custom", "README.md"), "# readme"); + fs.writeFileSync(path.join(tempDir, "custom", "pipelines", ".gitkeep"), ""); + fs.writeFileSync(path.join(tempDir, "custom", "pipelines", "foo.yml"), "steps: []"); + + const result = listCustomizations({ cwd: tempDir }); + expect(result.customizations).toHaveLength(1); + expect(result.customizations[0].relativePath).toBe("pipelines/foo.yml"); + }); + + it("other category for unrecognized path", () => { + fs.mkdirSync(path.join(tempDir, "custom"), { recursive: true }); + fs.writeFileSync(path.join(tempDir, "custom", "notes.md"), "some notes"); + + const result = listCustomizations({ cwd: tempDir }); + expect(result.customizations).toHaveLength(1); + const entry = result.customizations[0]; + expect(entry.category).toBe("other"); + expect(entry.upstreamPath).toBeNull(); + expect(entry.isShadow).toBe(false); + }); +}); diff --git a/src/admin-ui/__tests__/customizations.test.ts b/src/admin-ui/__tests__/customizations.test.ts new file mode 100644 index 0000000..6763bda --- /dev/null +++ b/src/admin-ui/__tests__/customizations.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { customizationsHtml, customizationsScript } from "../pages/customizations.js"; + +describe("customizations page", () => { + it("declares the expected ids", () => { + for (const id of ["customizations-subtitle", "customizations-error", "customizations-root", "customizations-body", "customizations-empty"]) { + expect(customizationsHtml).toContain(`id="${id}"`); + } + }); + + it("registers route + exposes loadCustomizations", () => { + expect(customizationsScript).toContain("window.registerPage('customizations'"); + expect(customizationsScript).toContain("window.loadCustomizations = loadCustomizations"); + }); + + it("calls /api/customizations", () => { + expect(customizationsScript).toContain("/api/customizations"); + }); + + it("uses window.api/window.esc only", () => { + const stripped = customizationsScript.replace(/window\.api\(/g, "").replace(/window\.esc\(/g, ""); + expect(stripped).not.toMatch(/\bapi\(/); + expect(stripped).not.toMatch(/\besc\(/); + }); + + it("uses const/let, not var", () => { + expect(customizationsScript).not.toMatch(/\bvar\s+\w/); + }); +}); diff --git a/src/admin-ui/index.ts b/src/admin-ui/index.ts index 90f3fea..4c8ce7e 100644 --- a/src/admin-ui/index.ts +++ b/src/admin-ui/index.ts @@ -14,6 +14,7 @@ import { auditHtml, auditScript } from "./pages/audit.js"; import { issuesHtml, issuesScript } from "./pages/issues.js"; import { pullsHtml, pullsScript } from "./pages/pulls.js"; import { blockersHtml, blockersScript } from "./pages/blockers.js"; +import { customizationsHtml, customizationsScript } from "./pages/customizations.js"; import { stubsHtml } from "./pages/stubs.js"; import { drawerHtml, drawerScript } from "./drawer.js"; import { stepperHtml, stepperScript } from "./stepper.js"; @@ -43,6 +44,7 @@ const shell = ``; @@ -59,7 +61,7 @@ const body = ` ${shell} ${drawerHtml} ${stepperHtml} - + `; export const adminHtml = head + body; diff --git a/src/admin-ui/pages/customizations.ts b/src/admin-ui/pages/customizations.ts new file mode 100644 index 0000000..74191f8 --- /dev/null +++ b/src/admin-ui/pages/customizations.ts @@ -0,0 +1,151 @@ +export const customizationsHtml = ` + +`; + +export const customizationsScript = ` +(function () { + function fmtSize(bytes) { + if (bytes < 1024) return bytes + ' B'; + if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'; + return (bytes / 1024 / 1024).toFixed(1) + ' MB'; + } + + function fmtAgo(ms) { + const diff = Date.now() - ms; + const s = Math.floor(diff / 1000); + if (s < 60) return s + 's ago'; + const m = Math.floor(s / 60); + if (m < 60) return m + 'm ago'; + const h = Math.floor(m / 60); + if (h < 24) return h + 'h ago'; + const d = Math.floor(h / 24); + return d + 'd ago'; + } + + function renderRows(items) { + const tbody = document.getElementById('customizations-body'); + const emptyEl = document.getElementById('customizations-empty'); + tbody.innerHTML = ''; + if (items.length === 0) { + emptyEl.classList.remove('hidden'); + return; + } + emptyEl.classList.add('hidden'); + for (const item of items) { + const { customPath, category, isShadow, upstreamPath, customSize, customMtime } = item; + + let categoryBadge; + if (category === 'pipeline') { + categoryBadge = 'Pipeline'; + } else if (category === 'step') { + categoryBadge = 'Step'; + } else if (category === 'provider') { + categoryBadge = 'Provider'; + } else { + categoryBadge = 'Other'; + } + + let statusCell; + if (isShadow) { + statusCell = 'Override'; + } else if (category === 'other') { + statusCell = ''; + } else { + statusCell = 'Additive'; + } + + const tr = document.createElement('tr'); + tr.innerHTML = '' + window.esc(customPath) + '' + + '' + categoryBadge + '' + + '' + statusCell + '' + + '' + window.esc(upstreamPath ?? '—') + '' + + '' + fmtSize(customSize) + '' + + '' + fmtAgo(customMtime) + ''; + tbody.appendChild(tr); + } + } + + function renderSubtitle(items) { + const subtitle = document.getElementById('customizations-subtitle'); + if (!subtitle) return; + const total = items.length; + if (total === 0) { + subtitle.textContent = '—'; + return; + } + const overrides = items.filter(function (i) { return i.isShadow; }).length; + subtitle.textContent = total + ' file' + (total === 1 ? '' : 's') + + (overrides > 0 ? ' · ' + overrides + ' override' + (overrides === 1 ? '' : 's') : ''); + } + + async function loadCustomizations() { + const errorEl = document.getElementById('customizations-error'); + const subtitle = document.getElementById('customizations-subtitle'); + errorEl.hidden = true; + const res = await window.api('/api/customizations'); + if (!res.ok) { + let errorMsg = 'Unknown error'; + try { + const data = await res.json(); + errorMsg = data.error || errorMsg; + } catch (_) { /* ignore */ } + errorEl.hidden = false; + errorEl.innerHTML = '
Failed to load customizations
' + window.esc(errorMsg) + '
'; + const emptyEl = document.getElementById('customizations-empty'); + if (emptyEl) emptyEl.classList.add('hidden'); + const tbody = document.getElementById('customizations-body'); + if (tbody) tbody.innerHTML = ''; + if (subtitle) subtitle.textContent = '—'; + return; + } + const data = await res.json(); + const items = data.customizations || []; + const rootEl = document.getElementById('customizations-root'); + if (rootEl) rootEl.textContent = data.customRoot || ''; + renderRows(items); + renderSubtitle(items); + } + + window.loadCustomizations = loadCustomizations; + window.registerPage('customizations', function () { + loadCustomizations(); + setInterval(loadCustomizations, 60000); + }); +})(); +`; diff --git a/src/admin-ui/pages/stubs.ts b/src/admin-ui/pages/stubs.ts index 75d22da..15b3dce 100644 --- a/src/admin-ui/pages/stubs.ts +++ b/src/admin-ui/pages/stubs.ts @@ -27,6 +27,5 @@ export const stubsHtml = [ stubPage("secrets", "Secrets", "Encrypted store, scoped per project", "Plan 5", "Rotation tracking; complements global secrets on Settings."), stubPage("mcp", "MCP server", "Claude as the primary interface", "Plan 5", "Phase 1 read-only → Phase 3 orchestration."), stubPage("webhooks", "Webhooks", "Inbound endpoints + outbound delivery log", "Plan 5", "Signed payloads, retry counters."), - stubPage("customizations", "Customizations", "Files in custom/ that override or extend upstream", "Plan 5", "Show what's overridden, last edit, drift from upstream."), stubPage("updates", "Updates", "Tracks upstream releases, opens upgrade PRs", "Plan 5", "Operationalizes the §3.14 automated upgrade PR model."), ].join(""); diff --git a/src/admin.ts b/src/admin.ts index cd7fb2a..8de3275 100644 --- a/src/admin.ts +++ b/src/admin.ts @@ -32,6 +32,7 @@ import { removeAIWorkingLabel, fetchAIImplementIssueSnapshot, type LinearIssue } import { selectBlockers } from "./poll-selection.js"; import { adminHtml } from "./admin-html.js"; import { getOrchestratorSettings, setOrchestratorSetting } from "./orchestrator-settings.js"; +import { listCustomizations } from "./customizations.js"; const SESSION_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours @@ -274,6 +275,11 @@ export function handleAdminRequest( return true; } + if (url === "/api/customizations" && method === "GET") { + json(res, 200, listCustomizations()); + return true; + } + json(res, 404, { error: "Not found" }); return true; } diff --git a/src/customizations.ts b/src/customizations.ts new file mode 100644 index 0000000..ab993c6 --- /dev/null +++ b/src/customizations.ts @@ -0,0 +1,84 @@ +import fs from "node:fs"; +import path from "node:path"; + +export interface CustomizationEntry { + relativePath: string; + customPath: string; + category: "pipeline" | "step" | "provider" | "other"; + upstreamPath: string | null; + isShadow: boolean; + customSize: number; + customMtime: number; +} + +const SKIPPED_FILES = new Set(["README.md", ".gitkeep"]); + +function categorize(relativePath: string): { category: CustomizationEntry["category"]; upstream: string | null } { + if (relativePath.startsWith("pipelines/")) { + return { category: "pipeline", upstream: relativePath }; + } + if (relativePath.startsWith("steps/")) { + const base = relativePath.replace(/^steps\//, "").replace(/\.(ts|js|mjs)$/, ""); + return { category: "step", upstream: `src/pipeline/steps/${base}.ts` }; + } + if (relativePath.startsWith("providers/")) { + const base = relativePath.replace(/^providers\//, "").replace(/\.(ts|js|mjs)$/, ""); + return { category: "provider", upstream: `src/pipeline/providers/${base}.ts` }; + } + return { category: "other", upstream: null }; +} + +function walk(root: string, prefix: string, out: string[]): void { + const dir = prefix ? path.join(root, prefix) : root; + for (const ent of fs.readdirSync(dir, { withFileTypes: true })) { + if (ent.name.startsWith(".")) continue; + if (SKIPPED_FILES.has(ent.name)) continue; + const rel = prefix ? `${prefix}/${ent.name}` : ent.name; + if (ent.isDirectory()) walk(root, rel, out); + else if (ent.isFile()) out.push(rel); + } +} + +export function listCustomizations(opts?: { customRoot?: string; cwd?: string }): { + customRoot: string; + customizations: CustomizationEntry[]; +} { + const cwd = opts?.cwd ?? process.cwd(); + const customRoot = opts?.customRoot ?? path.join(cwd, "custom"); + + if (!fs.existsSync(customRoot)) { + return { customRoot, customizations: [] }; + } + + const files: string[] = []; + walk(customRoot, "", files); + + const entries: CustomizationEntry[] = files.map((relativePath) => { + const customPath = path.posix.join("custom", relativePath); + const absCustom = path.join(customRoot, relativePath); + const stat = fs.statSync(absCustom); + const { category, upstream } = categorize(relativePath); + let upstreamPath: string | null = null; + let isShadow = false; + if (upstream) { + upstreamPath = upstream; + isShadow = fs.existsSync(path.join(cwd, upstream)); + } + return { + relativePath, + customPath, + category, + upstreamPath, + isShadow, + customSize: stat.size, + customMtime: stat.mtimeMs, + }; + }); + + entries.sort((a, b) => + a.category.localeCompare(b.category) || + a.relativePath.localeCompare(b.relativePath), + ); + + return { customRoot, customizations: entries }; +}