#!/usr/bin/env bun /** * Scaffold a new @suaveplan/audio-fx-plugin- package by: * 1. Copying every template under ./template/, replacing {PLUGIN_NAME} markers. * 2. git-mv'ing the source folder from packages/audio/audio-plugins-fx/src/plugins// → packages/audio-fx/audio-fx-plugin-/src/. * 3. Rewriting relative imports inside the moved sources to point at @suaveplan/audio-fx-plugin-base/* barrels. * 4. Rewriting @suaveplan/audio-plugins-fx/ doc references to @suaveplan/audio-fx-plugin-. * * Usage: * bun scripts/split-audio-fx-plugin/scaffold.ts --name compressor * bun scripts/split-audio-fx-plugin/scaffold.ts --name game-audio --source-dir game * * After scaffold: * bunx turbo lint typecheck test --filter=@suaveplan/audio-fx-plugin- * bunx biome check --write src/ # may need formatting fix */ import { spawnSync } from "node:child_process"; import { existsSync, mkdirSync, readdirSync, readFileSync, rmdirSync, statSync, writeFileSync, } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; const SLUG_RE = /^[a-z][a-z0-9-]*$/; const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)); const TEMPLATE_DIR = join(SCRIPT_DIR, "template"); const REPO_ROOT = join(SCRIPT_DIR, "..", ".."); const SOURCE_PARENT = join( REPO_ROOT, "packages", "audio", "audio-plugins-fx", "src", "plugins" ); const DEST_PARENT = join(REPO_ROOT, "packages", "audio"); interface Args { name: string; sourceDir: string; description: string; } function parseArgs(): Args { const argv = process.argv.slice(2); let name = ""; let sourceDir = ""; let description = ""; for (let i = 0; i < argv.length; i++) { const flag = argv[i]; const next = argv[i + 1]; if (!next) continue; if (flag === "--name") { name = next; i++; } else if (flag === "--source-dir") { sourceDir = next; i++; } else if (flag === "--description") { description = next; i++; } } if (!name || !SLUG_RE.test(name)) { throw new Error( `--name must match /^[a-z][a-z0-9-]*$/ (got: ${JSON.stringify(name)})` ); } if (!sourceDir) sourceDir = name; if (!description) { description = `${name} plugin for the @suaveplan/audio-fx-plugin-* family.`; } return { name, sourceDir, description }; } function gitMv(from: string, to: string): void { const result = spawnSync("git", ["mv", from, to], { cwd: REPO_ROOT, stdio: "inherit", }); if (result.status !== 0) { throw new Error( `git mv ${from} ${to} failed with status ${result.status}` ); } } function walkFiles(dir: string, out: string[] = []): string[] { for (const entry of readdirSync(dir)) { const full = join(dir, entry); const st = statSync(full); if (st.isDirectory()) walkFiles(full, out); else out.push(full); } return out; } function substitute(content: string, args: Args): string { return content .replaceAll("{PLUGIN_NAME}", args.name) .replaceAll("{PLUGIN_DESCRIPTION}", args.description); } function copyTemplates(destPkg: string, args: Args): void { for (const entry of readdirSync(TEMPLATE_DIR)) { const src = join(TEMPLATE_DIR, entry); const destName = entry.endsWith(".template") ? entry.slice(0, -".template".length) : entry; const dest = join(destPkg, destName); const content = readFileSync(src, "utf8"); writeFileSync(dest, substitute(content, args)); } } function moveSource(args: Args, destPkg: string): void { const srcDir = join(SOURCE_PARENT, args.sourceDir); if (!existsSync(srcDir)) { throw new Error(`source dir not found: ${srcDir}`); } const destSrc = join(destPkg, "src"); for (const child of readdirSync(srcDir)) { gitMv(join(srcDir, child), join(destSrc, child)); } try { rmdirSync(srcDir); } catch { // Non-fatal: if non-empty, leave for manual inspection. } } const RELATIVE_IMPORT_REWRITES: Array<[RegExp, string]> = [ // `base/.js` → base barrel [ /from "\.\.\/\.\.\/base\/[a-z0-9./-]+\.js"/g, 'from "@suaveplan/audio-fx-plugin-base/base"', ], // `chain/.js` (including nested context/, event-bus/, etc.) → chain barrel [ /from "\.\.\/\.\.\/chain\/[a-z0-9./-]+\.js"/g, 'from "@suaveplan/audio-fx-plugin-base/chain"', ], // `registry/.js` → registry barrel [ /from "\.\.\/\.\.\/registry\/[a-z0-9./-]+\.js"/g, 'from "@suaveplan/audio-fx-plugin-base/registry"', ], // root-level error-codes [ /from "\.\.\/\.\.\/error-codes\.js"/g, 'from "@suaveplan/audio-fx-plugin-base/error-codes"', ], ]; const CROSS_PLUGIN_RE = /from "\.\.\/[a-z][a-z0-9-]*\//; function rewriteImports(destPkg: string, args: Args): void { const srcDir = join(destPkg, "src"); for (const file of walkFiles(srcDir)) { if (!file.endsWith(".ts") && !file.endsWith(".md")) continue; let content = readFileSync(file, "utf8"); const before = content; for (const [pattern, replacement] of RELATIVE_IMPORT_REWRITES) { content = content.replace(pattern, replacement); } content = content.replaceAll( `@suaveplan/audio-plugins-fx/${args.name}`, `@suaveplan/audio-fx-plugin-${args.name}` ); const lines = content.split("\n"); for (const line of lines) { if ( CROSS_PLUGIN_RE.test(line) && !line.includes("audio-fx-plugin-base") ) { throw new Error( `Cross-plugin import detected in ${file}: ${line.trim()} — abort. ` + "Fix the moved source manually, then re-run." ); } } if (content !== before) writeFileSync(file, content); } } function main(): void { const args = parseArgs(); const destPkg = join(DEST_PARENT, `audio-fx-plugin-${args.name}`); if (existsSync(destPkg)) { throw new Error(`destination already exists: ${destPkg}`); } mkdirSync(join(destPkg, "src"), { recursive: true }); copyTemplates(destPkg, args); moveSource(args, destPkg); rewriteImports(destPkg, args); console.log( `scaffolded @suaveplan/audio-fx-plugin-${args.name} at ${destPkg}\n` + `next: bunx turbo lint typecheck test --filter=@suaveplan/audio-fx-plugin-${args.name}` ); } main();