Initial Commit
This commit is contained in:
690
scripts/folder-reorg/assignment.yml
Normal file
690
scripts/folder-reorg/assignment.yml
Normal file
@@ -0,0 +1,690 @@
|
||||
# Category -> plugin -> package assignment manifest.
|
||||
#
|
||||
# Layout: `packages/<category>/<plugin>/<pkg>/` — three levels under packages/.
|
||||
#
|
||||
# Each top-level key (other than the `__categories__` block and `__retired*__`
|
||||
# bookkeeping keys) is a plugin name. Each plugin's value is the list of
|
||||
# packages that live in `packages/<category>/<plugin>/<pkg>/`, where
|
||||
# <category> is the super-category assigned to that plugin in
|
||||
# `__categories__:` below.
|
||||
#
|
||||
# Rules enforced by tooling:
|
||||
# 1. Every package referenced here must be unique across all plugins.
|
||||
# 2. Every plugin must appear in exactly one super-category in __categories__.
|
||||
# 3. The 18 super-category vocabulary below is closed — changing it requires
|
||||
# an OpenSpec change against the monorepo-layout capability.
|
||||
#
|
||||
# Source of truth: FOLDERS.md.
|
||||
|
||||
# 18 super-categories. Each maps to the list of plugin folders that live
|
||||
# directly under packages/<category>/. Adding or removing a super-category
|
||||
# requires an OpenSpec change.
|
||||
__categories__:
|
||||
foundation: [core, data, random, timing, bitpacker, barcode, flow, etl, feature-flags]
|
||||
persistence: [database, storage, cache, search]
|
||||
networking: [network, drive, events]
|
||||
communication: [messaging, emails, webhook-client]
|
||||
api-stack: [api]
|
||||
identity: [auth]
|
||||
security-stack: [security, crypto]
|
||||
observability: [monitor]
|
||||
media: [audio, audio-fx, audio-ir, image, fonts, video]
|
||||
ui: [react, forms]
|
||||
threejs: [r3f]
|
||||
localization: [i18n]
|
||||
geospatial: [geo, logistics]
|
||||
money: [finance, payment]
|
||||
commerce: [booking, contacts]
|
||||
testkit: [testing]
|
||||
devtools: [tooling, docs, docker]
|
||||
gaming: [games]
|
||||
|
||||
__retired__: []
|
||||
|
||||
__retired_categories__:
|
||||
- codes
|
||||
- config
|
||||
- control-flow
|
||||
- dev
|
||||
- email
|
||||
- media
|
||||
- observability
|
||||
- primitives
|
||||
- search
|
||||
- streams
|
||||
- svg
|
||||
- third-party
|
||||
- validation
|
||||
- vault
|
||||
- workflow
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
api:
|
||||
- api-schemas
|
||||
- api-types
|
||||
- api-manifest
|
||||
- api
|
||||
- create-suaveplan-plugin
|
||||
- api-gateway
|
||||
- api-worker-rpc
|
||||
- api-logger-worker-proxy
|
||||
- api-plugin-admin
|
||||
- api-plugin-analytics
|
||||
- api-plugin-audit
|
||||
- api-plugin-auth
|
||||
- api-plugin-campaigns
|
||||
- api-plugin-cms
|
||||
- api-plugin-database
|
||||
- api-plugin-events
|
||||
- api-plugin-export
|
||||
- api-plugin-file-upload
|
||||
- api-plugin-graphql
|
||||
- api-plugin-import
|
||||
- api-plugin-rate-limit
|
||||
- api-plugin-search
|
||||
- api-plugin-site
|
||||
- api-plugin-social
|
||||
- api-plugin-sse
|
||||
- api-plugin-tenant
|
||||
- api-plugin-user
|
||||
- api-plugin-webhook
|
||||
- api-shared
|
||||
- api-2pc
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
audio:
|
||||
- audio-analysis-loudness
|
||||
- audio-codecs
|
||||
- audio-core
|
||||
- audio-metadata
|
||||
- voice
|
||||
- audio-controllers
|
||||
- audio-engine
|
||||
- audio-engine-browser
|
||||
- audio-fx-plugin-base
|
||||
- audio-fx-plugin-audio-analyzer
|
||||
- audio-fx-plugin-audio-player
|
||||
- audio-fx-plugin-auto-filter
|
||||
- audio-fx-plugin-chorus
|
||||
- audio-fx-plugin-compressor
|
||||
- audio-fx-plugin-delay
|
||||
- audio-fx-plugin-distortion
|
||||
- audio-fx-plugin-eq
|
||||
- audio-fx-plugin-filter
|
||||
- audio-fx-plugin-flanger
|
||||
- audio-fx-plugin-game-audio
|
||||
- audio-fx-plugin-gate
|
||||
- audio-fx-plugin-glados
|
||||
- audio-fx-plugin-limiter
|
||||
- audio-fx-plugin-microphone
|
||||
- audio-fx-plugin-mixer
|
||||
- audio-fx-plugin-phaser
|
||||
- audio-fx-plugin-pitch-shifter
|
||||
- audio-fx-plugin-reverb
|
||||
- audio-fx-plugin-sampler
|
||||
- audio-fx-plugin-spatial
|
||||
- audio-fx-plugin-synthesizer
|
||||
- audio-fx-plugin-tremolo
|
||||
- audio-fx-plugin-widener
|
||||
- audio-sprite-packer
|
||||
- dmx
|
||||
- python-audio-fingerprint
|
||||
- r3f-audio
|
||||
- r3f-dmx
|
||||
- react-audio
|
||||
- react-audio-fingerprint
|
||||
- react-audio-player
|
||||
- react-audio-stream
|
||||
- react-midi
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
auth:
|
||||
- auth
|
||||
- jwt-tenant
|
||||
- macos-keychain
|
||||
- oauth-client
|
||||
- permissions
|
||||
- webauthn
|
||||
- react-auth
|
||||
- react-auth-whitelabel
|
||||
- react-permissions
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
barcode:
|
||||
- barcode
|
||||
- qrcode
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
bitpacker:
|
||||
- bitpacker
|
||||
- bitpacker-compressed
|
||||
- bitpacker-crypto
|
||||
- bitpacker-delta
|
||||
- bitpacker-game
|
||||
- bitpacker-network
|
||||
- bitpacker-schema
|
||||
- bitpacker-strategies
|
||||
- bitpacker-streaming
|
||||
- bitpacker-validation
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
commerce:
|
||||
- api-plugin-asset-register
|
||||
- api-plugin-bank
|
||||
- api-plugin-banking
|
||||
- api-plugin-payment
|
||||
- bank
|
||||
- banking
|
||||
- booking
|
||||
- currency
|
||||
- currency-provider-binance
|
||||
- currency-provider-coingecko
|
||||
- currency-provider-fixer
|
||||
- currency-providers
|
||||
- money
|
||||
- react-bank
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
payment:
|
||||
- payment
|
||||
- payment-providers
|
||||
- payment-provider-adyen
|
||||
- payment-provider-mollie
|
||||
- payment-provider-paypal
|
||||
- payment-provider-square
|
||||
- payment-provider-stripe
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
contacts:
|
||||
- api-plugin-contacts
|
||||
- contacts
|
||||
- macos-contacts
|
||||
- react-contacts
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
core:
|
||||
- config
|
||||
- env
|
||||
- error
|
||||
- schema-registry
|
||||
- types
|
||||
- typescript-config
|
||||
- utils
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
crypto:
|
||||
- crypto
|
||||
- crypto-bin
|
||||
- crypto-browser
|
||||
- crypto-cipher
|
||||
- crypto-cli
|
||||
- crypto-core
|
||||
- crypto-curve
|
||||
- crypto-hash
|
||||
- crypto-rsa
|
||||
- crypto-sodium
|
||||
- crypto-utils
|
||||
- crypto-vault
|
||||
- api-plugin-vault
|
||||
- cryptocurrency
|
||||
- keys
|
||||
- react-crypto
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
data:
|
||||
- bytes
|
||||
- clipboard
|
||||
- data-masking
|
||||
- dedup
|
||||
- diff
|
||||
- json
|
||||
- media
|
||||
- mime
|
||||
- packer
|
||||
- packer-browser
|
||||
- pagination
|
||||
- pwa
|
||||
- react-packer
|
||||
- serialize
|
||||
- templates
|
||||
- validation
|
||||
- web-workers
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
database:
|
||||
- db
|
||||
- db-sqlite
|
||||
- migration
|
||||
- surrealdb
|
||||
- seed
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
docs:
|
||||
- compress-svg
|
||||
- sitemap
|
||||
- api-docs
|
||||
- docs
|
||||
- pdf
|
||||
- react-docs
|
||||
- react-pdf
|
||||
- react-rich-text
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
drive:
|
||||
- drive
|
||||
- drive-mesh
|
||||
- drive-mesh-protocol
|
||||
- drive-plugin-chunks
|
||||
- drive-react-sharing
|
||||
- drive-sharing
|
||||
- drive-template
|
||||
- drive-transport-https
|
||||
- drive-transport-webrtc
|
||||
- drive-webrtc
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
emails:
|
||||
- dmarc
|
||||
- domain-normalizer
|
||||
- suppression
|
||||
- email-processing
|
||||
- email-templates
|
||||
- emailer
|
||||
- campaigns
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
events:
|
||||
- event-sourcing
|
||||
- events
|
||||
- events-metrics
|
||||
- events-otel
|
||||
- events-schema
|
||||
- events-transport
|
||||
- broadcast-channel
|
||||
- message-bridge
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
cache:
|
||||
- cache
|
||||
- cache-redis
|
||||
- cache-storage
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
flow:
|
||||
- cbor
|
||||
- crdt
|
||||
- idempotency
|
||||
- job-scheduler
|
||||
- queue
|
||||
- queue-distribution
|
||||
- queue-distribution-http
|
||||
- queue-distribution-worker-thread
|
||||
- rate-limiter
|
||||
- retry
|
||||
- rules
|
||||
- rules-hono
|
||||
- streams
|
||||
- undo
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
fonts:
|
||||
- font-compress
|
||||
- font-convert
|
||||
- font-subset
|
||||
- font-decompress
|
||||
- react-font-decompress
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
forms:
|
||||
- react-forms
|
||||
- form-builder
|
||||
- react-admin-forms
|
||||
- react-calendar
|
||||
- react-color-picker
|
||||
- react-file-upload
|
||||
- react-wizard
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
games:
|
||||
- animation-events
|
||||
- api-plugin-anticheat
|
||||
- api-plugin-game-persistence
|
||||
- api-plugin-game-server
|
||||
- api-plugin-matchmaking
|
||||
- behavior-tree
|
||||
- bone-mapping
|
||||
- building
|
||||
- character-physics
|
||||
- character-vehicle-picker
|
||||
- chat
|
||||
- combat
|
||||
- controller
|
||||
- crafting
|
||||
- day-night
|
||||
- destructibles
|
||||
- dialogue
|
||||
- economy
|
||||
- ecs
|
||||
- emotes
|
||||
- fauna
|
||||
- fruit-machine
|
||||
- game-loop
|
||||
- game-persistence
|
||||
- game-server
|
||||
- game-state-schemas
|
||||
- heightmap-tiles
|
||||
- hitbox
|
||||
- ik-solver
|
||||
- leaderboard
|
||||
- lobby
|
||||
- mixamo
|
||||
- npc
|
||||
- osm-buildings
|
||||
- party
|
||||
- progression
|
||||
- quest
|
||||
- react-controller
|
||||
- react-crafting
|
||||
- react-dialogue
|
||||
- react-game-hud
|
||||
- react-game-map
|
||||
- react-game-settings
|
||||
- react-social-game
|
||||
- vehicle-physics
|
||||
- weapons
|
||||
- weather
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
geo:
|
||||
- geo
|
||||
- geo-earth
|
||||
- geo-planet
|
||||
- solar-system
|
||||
- stars
|
||||
- tides
|
||||
- universe
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
gtap:
|
||||
- api-plugin-gtap
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
i18n:
|
||||
- i18n
|
||||
- i18n-backend-chained
|
||||
- i18n-backend-fs-multifile
|
||||
- i18n-backend-http
|
||||
- i18n-backend-memory
|
||||
- i18n-backend-storage
|
||||
- i18n-detector
|
||||
- i18n-formatter-collator
|
||||
- i18n-formatter-currency
|
||||
- i18n-formatter-date
|
||||
- i18n-formatter-display-name
|
||||
- i18n-formatter-list
|
||||
- i18n-formatter-number
|
||||
- i18n-formatter-relative-time
|
||||
- i18n-formatter-segmenter
|
||||
- i18n-icu-parser
|
||||
- i18n-shared
|
||||
- locale-data
|
||||
- react-i18n
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
image:
|
||||
- compress-image
|
||||
- exif
|
||||
- caddy-imgproxy
|
||||
- emblem-solver
|
||||
- image
|
||||
- image-quantisation
|
||||
- react-image
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
logistics:
|
||||
- api-plugin-customs
|
||||
- api-plugin-logistics
|
||||
- api-plugin-port-tracking
|
||||
- api-plugin-procurement
|
||||
- api-plugin-warehouse-ops
|
||||
- customs
|
||||
- delivery-tracking
|
||||
- logistics
|
||||
- port-tracking
|
||||
- ports
|
||||
- procurement
|
||||
- react-port-tracking
|
||||
- warehouse-ops
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
messaging:
|
||||
- elevenlabs
|
||||
- api-plugin-messaging
|
||||
- api-plugin-notifications
|
||||
- discord-client
|
||||
- imessage-adapter
|
||||
- react-notifications
|
||||
- slack-client
|
||||
- sms
|
||||
- sms-providers
|
||||
- telegram-client
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
monitor:
|
||||
- analytics-browser
|
||||
- audit-logging
|
||||
- health-check
|
||||
- logger
|
||||
- opentelemetry
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
network:
|
||||
- network
|
||||
- network-bootstrap
|
||||
- network-discovery-crdt
|
||||
- network-discovery-gundb
|
||||
- network-discovery-hybrid
|
||||
- network-discovery-peerjs
|
||||
- network-discovery-trystero
|
||||
- network-discovery-webtorrent
|
||||
- network-gundb-server
|
||||
- network-libp2p-server
|
||||
- network-plugin-assignment
|
||||
- network-plugin-audit-mesh
|
||||
- network-plugin-auth
|
||||
- network-plugin-circuit-breaker
|
||||
- network-plugin-content-delivery
|
||||
- network-plugin-crypto-messages
|
||||
- network-plugin-devtools
|
||||
- network-plugin-failover
|
||||
- network-plugin-governance
|
||||
- network-plugin-intra-relay
|
||||
- network-plugin-middleware
|
||||
- network-plugin-migration
|
||||
- network-plugin-monitor
|
||||
- network-plugin-partition
|
||||
- network-plugin-protocol
|
||||
- network-plugin-qos
|
||||
- network-plugin-registry
|
||||
- network-plugin-relay
|
||||
- network-plugin-server-bridge
|
||||
- network-plugin-session
|
||||
- network-plugin-simulation
|
||||
- network-plugin-state
|
||||
- network-plugin-topology
|
||||
- network-plugin-transport-manager
|
||||
- network-testing
|
||||
- network-transport-hyperswarm
|
||||
- network-transport-libp2p
|
||||
- network-transport-standby
|
||||
- network-transport-webrtc
|
||||
- network-transport-websockets
|
||||
- network-trystero-server
|
||||
- react-network-libp2p-demo
|
||||
- service-discovery
|
||||
- websocket-gateway
|
||||
- network-observability
|
||||
- network-quality
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
platform:
|
||||
- etl
|
||||
- feature-flags
|
||||
- search
|
||||
- webhook-client
|
||||
- audience-rules
|
||||
- notification-channel
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
r3f:
|
||||
- r3f-networking
|
||||
- r3f-animation
|
||||
- r3f-booking
|
||||
- r3f-building
|
||||
- r3f-buildings
|
||||
- r3f-camera
|
||||
- r3f-character
|
||||
- r3f-character-vehicle
|
||||
- r3f-character-vehicle-picker
|
||||
- r3f-combat
|
||||
- r3f-controller
|
||||
- r3f-earth
|
||||
- r3f-emulator
|
||||
- r3f-environment
|
||||
- r3f-lod
|
||||
- r3f-minimap
|
||||
- r3f-npc
|
||||
- r3f-number-plate
|
||||
- r3f-parallax
|
||||
- r3f-particles
|
||||
- r3f-pathfinding
|
||||
- r3f-performance
|
||||
- r3f-physics-cascade
|
||||
- r3f-physics-core
|
||||
- r3f-physics-rapier
|
||||
- r3f-shaders
|
||||
- r3f-terrain
|
||||
- r3f-ui
|
||||
- r3f-vegetation
|
||||
- r3f-vehicle
|
||||
- r3f-water
|
||||
- r3f-weather
|
||||
- r3f-world
|
||||
- r3f-xr
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
random:
|
||||
- fsm
|
||||
- noise
|
||||
- pseudo-random
|
||||
- random
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
react:
|
||||
- react-ui
|
||||
- react-campaigns
|
||||
- react-charts
|
||||
- react-command-palette
|
||||
- react-consent-overlay
|
||||
- react-data-table
|
||||
- react-dnd
|
||||
- react-error-boundary
|
||||
- react-hotkeys
|
||||
- react-infinite-scroll
|
||||
- react-iphone
|
||||
- react-kanban
|
||||
- react-loading
|
||||
- react-modal
|
||||
- react-skeleton
|
||||
- react-social
|
||||
- react-timeline
|
||||
- react-tour
|
||||
- react-tree-view
|
||||
- react-ui-social
|
||||
- react-virtual
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
security:
|
||||
- hibp
|
||||
- jws
|
||||
- otp
|
||||
- password-formats
|
||||
- password-generator
|
||||
- password-strength
|
||||
- security-codes
|
||||
- slsa
|
||||
- srp
|
||||
- tpm-struct
|
||||
- vault
|
||||
- vault-sync
|
||||
- x509
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
storage:
|
||||
- storage
|
||||
- storage-browser-indexeddb
|
||||
- storage-browser-localstorage
|
||||
- storage-browser-opfs
|
||||
- storage-file
|
||||
- storage-s3
|
||||
- storage-uri-plugin-fallback
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
store:
|
||||
- api-plugin-inventory
|
||||
- api-plugin-store
|
||||
- api-plugin-subscription
|
||||
- api-plugin-virtual-currency
|
||||
- r3f-inventory
|
||||
- react-store
|
||||
- store
|
||||
- subscription
|
||||
- virtual-currency
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
testing:
|
||||
- ab-testing
|
||||
- testing
|
||||
- testing-dom
|
||||
- testing-e2e
|
||||
- testing-integration
|
||||
- testing-integration-garage-s3
|
||||
- testing-integration-mailpit
|
||||
- testing-integration-postgres
|
||||
- testing-integration-redis
|
||||
- testing-integration-step-ca
|
||||
- testing-integration-surrealdb
|
||||
- testing-integration-typesense
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
timing:
|
||||
- cron
|
||||
- datetime
|
||||
- timing
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
tooling:
|
||||
- cli
|
||||
- install-agent-skills
|
||||
- installer
|
||||
- mcp
|
||||
- readline
|
||||
- electrobun
|
||||
- license
|
||||
- linter
|
||||
- overview
|
||||
- profiler
|
||||
- sandbox
|
||||
- server-keys
|
||||
- test-vite
|
||||
- wip
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
vatdash:
|
||||
- api-plugin-vatdash-bridge
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
video:
|
||||
- react-video
|
||||
- video
|
||||
- video-thumbnails
|
||||
361
scripts/folder-reorg/generate-manifest.ts
Normal file
361
scripts/folder-reorg/generate-manifest.ts
Normal file
@@ -0,0 +1,361 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Generates MANIFEST.md at repo root from on-disk packages/<super>/<plugin>/<pkg>/package.json.
|
||||
*
|
||||
* Replaces @suaveplan/overview. Sections emitted:
|
||||
* 1. Plugins table — plugin | package count | total workspace deps
|
||||
* 2. Packages table — npm name | plugin | tier | version | workspace deps
|
||||
* 3. Tier overlay — packages grouped by inferred tier
|
||||
* 4. Dependency graph — DOT block for Graphviz
|
||||
*
|
||||
* Usage:
|
||||
* bun scripts/folder-reorg/generate-manifest.ts # write MANIFEST.md
|
||||
* bun scripts/folder-reorg/generate-manifest.ts --check # exit 1 if drift
|
||||
* bun scripts/folder-reorg/generate-manifest.ts --stdout # print only
|
||||
*
|
||||
* Tier inference priority (matches openspec/specs/monorepo-structure/spec.md):
|
||||
* python → r3f → react → browser → server → universal
|
||||
*/
|
||||
|
||||
import {
|
||||
existsSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
statSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = resolve(here, "..", "..");
|
||||
const manifestPath = join(repoRoot, "MANIFEST.md");
|
||||
const packagesRoot = join(repoRoot, "packages");
|
||||
|
||||
type Tier = "python" | "r3f" | "react" | "browser" | "server" | "universal";
|
||||
|
||||
interface PkgJson {
|
||||
name: string;
|
||||
version: string;
|
||||
description?: string;
|
||||
dependencies?: Record<string, string>;
|
||||
devDependencies?: Record<string, string>;
|
||||
peerDependencies?: Record<string, string>;
|
||||
optionalDependencies?: Record<string, string>;
|
||||
browser?: unknown;
|
||||
bin?: unknown;
|
||||
engines?: Record<string, string>;
|
||||
python?: unknown;
|
||||
}
|
||||
|
||||
interface PkgRecord {
|
||||
name: string; // @suaveplan/<x>
|
||||
short: string; // <x>
|
||||
category: string; // top-level super-category (e.g. foundation, threejs)
|
||||
plugin: string; // mid-level plugin folder (e.g. core, r3f)
|
||||
dirName: string;
|
||||
version: string;
|
||||
description: string;
|
||||
tier: Tier;
|
||||
workspaceDeps: string[]; // sorted, dedup
|
||||
}
|
||||
|
||||
const SERVER_DEP_HINTS = new Set([
|
||||
"bun:sqlite",
|
||||
"node:fs",
|
||||
"node:net",
|
||||
"node:cluster",
|
||||
"node:dgram",
|
||||
"node:tls",
|
||||
"node:dns",
|
||||
"node:child_process",
|
||||
"pg",
|
||||
"ioredis",
|
||||
"better-sqlite3",
|
||||
"@sqlite.org/sqlite-wasm-node",
|
||||
]);
|
||||
|
||||
function readPkgJson(absDir: string): PkgJson | null {
|
||||
const f = join(absDir, "package.json");
|
||||
if (!existsSync(f)) return null;
|
||||
try {
|
||||
return JSON.parse(readFileSync(f, "utf8")) as PkgJson;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function inferTier(short: string, p: PkgJson): Tier {
|
||||
if (p.python !== undefined || short.startsWith("python-")) return "python";
|
||||
|
||||
const peerKeys = Object.keys(p.peerDependencies ?? {});
|
||||
if (
|
||||
peerKeys.includes("@react-three/fiber") ||
|
||||
peerKeys.includes("three") ||
|
||||
short.startsWith("r3f-")
|
||||
)
|
||||
return "r3f";
|
||||
if (peerKeys.includes("react") || short.startsWith("react-"))
|
||||
return "react";
|
||||
|
||||
if (p.browser !== undefined) return "browser";
|
||||
const enginesKeys = Object.keys(p.engines ?? {});
|
||||
if (
|
||||
enginesKeys.length > 0 &&
|
||||
!enginesKeys.includes("node") &&
|
||||
!enginesKeys.includes("bun")
|
||||
)
|
||||
return "browser";
|
||||
|
||||
if (p.bin !== undefined) return "server";
|
||||
const allDeps = { ...p.dependencies, ...p.devDependencies };
|
||||
for (const key of Object.keys(allDeps)) {
|
||||
if (SERVER_DEP_HINTS.has(key)) return "server";
|
||||
}
|
||||
|
||||
return "universal";
|
||||
}
|
||||
|
||||
function collectWorkspaceDeps(p: PkgJson): string[] {
|
||||
const out = new Set<string>();
|
||||
for (const map of [
|
||||
p.dependencies,
|
||||
p.peerDependencies,
|
||||
p.optionalDependencies,
|
||||
]) {
|
||||
if (!map) continue;
|
||||
for (const [name, range] of Object.entries(map)) {
|
||||
if (
|
||||
typeof range === "string" &&
|
||||
range.startsWith("workspace:") &&
|
||||
name.startsWith("@suaveplan/")
|
||||
) {
|
||||
out.add(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...out].sort();
|
||||
}
|
||||
|
||||
function discoverPackages(): PkgRecord[] {
|
||||
if (!existsSync(packagesRoot)) return [];
|
||||
const records: PkgRecord[] = [];
|
||||
for (const category of readdirSync(packagesRoot)) {
|
||||
const categoryPath = join(packagesRoot, category);
|
||||
if (!statSync(categoryPath).isDirectory()) continue;
|
||||
for (const plugin of readdirSync(categoryPath)) {
|
||||
const pluginPath = join(categoryPath, plugin);
|
||||
if (!statSync(pluginPath).isDirectory()) continue;
|
||||
for (const pkgDir of readdirSync(pluginPath)) {
|
||||
const pkgPath = join(pluginPath, pkgDir);
|
||||
if (!statSync(pkgPath).isDirectory()) continue;
|
||||
const pj = readPkgJson(pkgPath);
|
||||
if (pj === null) continue;
|
||||
const short = pj.name.replace(/^@suaveplan\//, "");
|
||||
records.push({
|
||||
name: pj.name,
|
||||
short,
|
||||
category,
|
||||
plugin,
|
||||
dirName: pkgDir,
|
||||
version: pj.version ?? "0.1.0",
|
||||
description: pj.description ?? "",
|
||||
tier: inferTier(short, pj),
|
||||
workspaceDeps: collectWorkspaceDeps(pj),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
records.sort((a, b) => a.name.localeCompare(b.name));
|
||||
return records;
|
||||
}
|
||||
|
||||
function truncate(s: string, max: number): string {
|
||||
if (s.length <= max) return s;
|
||||
return `${s.slice(0, max - 1).trimEnd()}…`;
|
||||
}
|
||||
|
||||
function emitPluginsSection(records: PkgRecord[]): string {
|
||||
const counts = new Map<
|
||||
string,
|
||||
{ category: string; pkgs: number; deps: number }
|
||||
>();
|
||||
for (const r of records) {
|
||||
const key = `${r.category}/${r.plugin}`;
|
||||
const cur = counts.get(key) ?? {
|
||||
category: r.category,
|
||||
pkgs: 0,
|
||||
deps: 0,
|
||||
};
|
||||
cur.pkgs += 1;
|
||||
cur.deps += r.workspaceDeps.length;
|
||||
counts.set(key, cur);
|
||||
}
|
||||
const lines: string[] = [];
|
||||
lines.push("## Plugins");
|
||||
lines.push("");
|
||||
lines.push("| Category | Plugin | Packages | Workspace deps |");
|
||||
lines.push("|---|---|---:|---:|");
|
||||
for (const [key, c] of [...counts.entries()].sort()) {
|
||||
const plugin = key.split("/")[1];
|
||||
lines.push(
|
||||
`| \`${c.category}\` | \`${plugin}\` | ${c.pkgs} | ${c.deps} |`
|
||||
);
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function emitPackagesSection(records: PkgRecord[]): string {
|
||||
const lines: string[] = [];
|
||||
lines.push("## Packages");
|
||||
lines.push("");
|
||||
lines.push(
|
||||
"| Package | Category | Plugin | Tier | Version | Deps | Description |"
|
||||
);
|
||||
lines.push("|---|---|---|---|---|---:|---|");
|
||||
for (const r of records) {
|
||||
lines.push(
|
||||
`| \`${r.name}\` | \`${r.category}\` | \`${r.plugin}\` | ${r.tier} | ${r.version} | ${r.workspaceDeps.length} | ${truncate(r.description, 80)} |`
|
||||
);
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function emitTierOverlaySection(records: PkgRecord[]): string {
|
||||
const byTier = new Map<Tier, PkgRecord[]>();
|
||||
const tierOrder: Tier[] = [
|
||||
"universal",
|
||||
"browser",
|
||||
"react",
|
||||
"r3f",
|
||||
"server",
|
||||
"python",
|
||||
];
|
||||
for (const t of tierOrder) byTier.set(t, []);
|
||||
for (const r of records) byTier.get(r.tier)?.push(r);
|
||||
|
||||
const lines: string[] = [];
|
||||
lines.push("## Tier overlay");
|
||||
lines.push("");
|
||||
lines.push(
|
||||
"Tier is inferred per `openspec/specs/monorepo-structure/spec.md` priority order: python → r3f → react → browser → server → universal."
|
||||
);
|
||||
lines.push("");
|
||||
for (const tier of tierOrder) {
|
||||
const list = byTier.get(tier) ?? [];
|
||||
if (list.length === 0) continue;
|
||||
lines.push(`### ${tier} (${list.length})`);
|
||||
lines.push("");
|
||||
for (const r of list)
|
||||
lines.push(`- \`${r.name}\` (\`${r.category}/${r.plugin}\`)`);
|
||||
lines.push("");
|
||||
}
|
||||
return lines.join("\n").trimEnd();
|
||||
}
|
||||
|
||||
function emitDependencyGraphSection(records: PkgRecord[]): string {
|
||||
const known = new Set(records.map((r) => r.name));
|
||||
const lines: string[] = [];
|
||||
lines.push("## Dependency graph");
|
||||
lines.push("");
|
||||
lines.push(
|
||||
"Workspace edges only. Pipe to Graphviz: `awk '/^```dot/,/^```$/' MANIFEST.md | sed '1d;$d' | dot -Tsvg > graph.svg`."
|
||||
);
|
||||
lines.push("");
|
||||
lines.push("```dot");
|
||||
lines.push("digraph workspace {");
|
||||
lines.push(" rankdir=LR;");
|
||||
lines.push(' node [shape=box, fontname="monospace", fontsize=10];');
|
||||
for (const r of records) {
|
||||
for (const dep of r.workspaceDeps) {
|
||||
if (!known.has(dep)) continue;
|
||||
lines.push(` "${r.name}" -> "${dep}";`);
|
||||
}
|
||||
}
|
||||
lines.push("}");
|
||||
lines.push("```");
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function emitHeader(records: PkgRecord[]): string {
|
||||
const tierCounts = new Map<Tier, number>();
|
||||
for (const r of records)
|
||||
tierCounts.set(r.tier, (tierCounts.get(r.tier) ?? 0) + 1);
|
||||
const tiers = [...tierCounts.entries()]
|
||||
.map(([t, c]) => `${t}: ${c}`)
|
||||
.sort()
|
||||
.join(", ");
|
||||
const plugins = new Set(records.map((r) => `${r.category}/${r.plugin}`))
|
||||
.size;
|
||||
const categories = new Set(records.map((r) => r.category)).size;
|
||||
|
||||
const lines: string[] = [];
|
||||
lines.push("# Suaveplan Monorepo Manifest");
|
||||
lines.push("");
|
||||
lines.push(
|
||||
"> **Auto-generated by `scripts/folder-reorg/generate-manifest.ts`. Do not edit by hand — run the script after package changes.**"
|
||||
);
|
||||
lines.push("");
|
||||
lines.push(
|
||||
`Packages: **${records.length}** across **${plugins}** plugins in **${categories}** categories. Tiers: ${tiers || "—"}.`
|
||||
);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function buildManifest(records: PkgRecord[]): string {
|
||||
if (records.length === 0) {
|
||||
return [
|
||||
"# Suaveplan Monorepo Manifest",
|
||||
"",
|
||||
"> **Auto-generated by `scripts/folder-reorg/generate-manifest.ts`.**",
|
||||
"",
|
||||
"_No packages discovered under `packages/`. Run the folder reorg migration first._",
|
||||
"",
|
||||
].join("\n");
|
||||
}
|
||||
const sections = [
|
||||
emitHeader(records),
|
||||
emitPluginsSection(records),
|
||||
emitPackagesSection(records),
|
||||
emitTierOverlaySection(records),
|
||||
emitDependencyGraphSection(records),
|
||||
];
|
||||
return `${sections.join("\n\n")}\n`;
|
||||
}
|
||||
|
||||
function main(): void {
|
||||
const argv = process.argv.slice(2);
|
||||
const checkMode = argv.includes("--check");
|
||||
const stdoutMode = argv.includes("--stdout");
|
||||
|
||||
const records = discoverPackages();
|
||||
const generated = buildManifest(records);
|
||||
|
||||
if (stdoutMode) {
|
||||
process.stdout.write(generated);
|
||||
return;
|
||||
}
|
||||
|
||||
if (checkMode) {
|
||||
const onDisk = existsSync(manifestPath)
|
||||
? readFileSync(manifestPath, "utf8")
|
||||
: "";
|
||||
if (onDisk === generated) {
|
||||
console.log(
|
||||
`✓ MANIFEST.md is up to date (${records.length} packages).`
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
console.error(
|
||||
"✗ MANIFEST.md is stale. Re-run: bun scripts/folder-reorg/generate-manifest.ts"
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
writeFileSync(manifestPath, generated);
|
||||
console.log(
|
||||
`✓ Wrote ${manifestPath} (${records.length} packages, ${new Set(records.map((r) => `${r.category}/${r.plugin}`)).size} plugins, ${new Set(records.map((r) => r.category)).size} categories).`
|
||||
);
|
||||
}
|
||||
|
||||
main();
|
||||
410
scripts/folder-reorg/migrate-folders.ts
Normal file
410
scripts/folder-reorg/migrate-folders.ts
Normal file
@@ -0,0 +1,410 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Folder reorganisation migration script.
|
||||
*
|
||||
* Reads scripts/folder-reorg/assignment.yml and moves every package currently
|
||||
* under universal/<domain>/<pkg>/ to packages/<plugin>/<pkg>/ via `git mv`.
|
||||
*
|
||||
* Run modes:
|
||||
* bun scripts/folder-reorg/migrate-folders.ts --dry-run # plan only
|
||||
* bun scripts/folder-reorg/migrate-folders.ts # execute
|
||||
* bun scripts/folder-reorg/migrate-folders.ts --check # validate manifest only
|
||||
*
|
||||
* Exits non-zero on:
|
||||
* - duplicate package across plugins
|
||||
* - on-disk package not present in any plugin's list
|
||||
* - destination path already exists
|
||||
* - git working tree dirty (execute mode only)
|
||||
*/
|
||||
|
||||
import { execFileSync } from "node:child_process";
|
||||
import {
|
||||
cpSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
rmdirSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
} from "node:fs";
|
||||
import { dirname, join, relative, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { parse as parseYaml } from "yaml";
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = resolve(here, "..", "..");
|
||||
const assignmentPath = join(here, "assignment.yml");
|
||||
|
||||
const LEGACY_ROOTS = [
|
||||
"universal",
|
||||
"browser",
|
||||
"react",
|
||||
"r3f",
|
||||
"server",
|
||||
"finance",
|
||||
"mcp",
|
||||
"python",
|
||||
"gaming",
|
||||
"api",
|
||||
] as const;
|
||||
|
||||
type Mode = "dry-run" | "execute" | "check";
|
||||
|
||||
interface Assignment {
|
||||
[plugin: string]: string[];
|
||||
}
|
||||
|
||||
interface PlannedMove {
|
||||
pkg: string;
|
||||
plugin: string;
|
||||
from: string; // absolute
|
||||
to: string; // absolute
|
||||
}
|
||||
|
||||
const log = (...args: unknown[]) => console.log(...args);
|
||||
const warn = (...args: unknown[]) => console.warn("⚠ ", ...args);
|
||||
const fail = (msg: string): never => {
|
||||
console.error(`✗ ${msg}`);
|
||||
process.exit(1);
|
||||
};
|
||||
|
||||
function git(args: string[], cwd: string = repoRoot): string {
|
||||
return execFileSync("git", args, { cwd, encoding: "utf8" });
|
||||
}
|
||||
|
||||
function parseMode(argv: string[]): Mode {
|
||||
if (argv.includes("--dry-run")) return "dry-run";
|
||||
if (argv.includes("--check")) return "check";
|
||||
return "execute";
|
||||
}
|
||||
|
||||
function loadAssignment(): Assignment {
|
||||
if (!existsSync(assignmentPath)) {
|
||||
fail(`Assignment manifest not found: ${assignmentPath}`);
|
||||
}
|
||||
const raw = readFileSync(assignmentPath, "utf8");
|
||||
const parsed = parseYaml(raw) as unknown;
|
||||
if (typeof parsed !== "object" || parsed === null) {
|
||||
fail("assignment.yml did not parse to an object");
|
||||
}
|
||||
const a = parsed as Record<string, unknown>;
|
||||
for (const [plugin, pkgs] of Object.entries(a)) {
|
||||
if (!Array.isArray(pkgs)) fail(`Plugin "${plugin}" is not a list`);
|
||||
for (const p of pkgs) {
|
||||
if (typeof p !== "string")
|
||||
fail(`Plugin "${plugin}" has non-string entry`);
|
||||
}
|
||||
}
|
||||
return a as Assignment;
|
||||
}
|
||||
|
||||
interface ParsedAssignment {
|
||||
pkgToPlugin: Map<string, string>;
|
||||
retired: Set<string>;
|
||||
}
|
||||
|
||||
function validateUniqueness(assignment: Assignment): ParsedAssignment {
|
||||
const pkgToPlugin = new Map<string, string>();
|
||||
const retired = new Set<string>(assignment.__retired__ ?? []);
|
||||
const duplicates: string[] = [];
|
||||
for (const [plugin, pkgs] of Object.entries(assignment)) {
|
||||
if (plugin === "__retired__") continue;
|
||||
for (const pkg of pkgs) {
|
||||
if (retired.has(pkg)) {
|
||||
duplicates.push(
|
||||
` ${pkg}: listed in plugin "${plugin}" AND in __retired__`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const prior = pkgToPlugin.get(pkg);
|
||||
if (prior !== undefined) {
|
||||
duplicates.push(` ${pkg}: in "${prior}" AND "${plugin}"`);
|
||||
} else {
|
||||
pkgToPlugin.set(pkg, plugin);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (duplicates.length > 0) {
|
||||
fail(`Duplicate package assignments found:\n${duplicates.join("\n")}`);
|
||||
}
|
||||
return { pkgToPlugin, retired };
|
||||
}
|
||||
|
||||
function discoverOnDiskPackages(): Map<string, string> {
|
||||
const result = new Map<string, string>();
|
||||
for (const root of LEGACY_ROOTS) {
|
||||
const rootPath = join(repoRoot, root);
|
||||
if (!existsSync(rootPath) || !statSync(rootPath).isDirectory())
|
||||
continue;
|
||||
for (const domain of readdirSync(rootPath)) {
|
||||
const domainPath = join(rootPath, domain);
|
||||
if (!statSync(domainPath).isDirectory()) continue;
|
||||
for (const pkg of readdirSync(domainPath)) {
|
||||
const pkgPath = join(domainPath, pkg);
|
||||
if (!statSync(pkgPath).isDirectory()) continue;
|
||||
if (!existsSync(join(pkgPath, "package.json"))) continue;
|
||||
if (result.has(pkg)) {
|
||||
fail(
|
||||
`On-disk duplicate package directory: "${pkg}" exists in both ${result.get(pkg)} and ${pkgPath}`
|
||||
);
|
||||
}
|
||||
result.set(pkg, pkgPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function planMoves(
|
||||
parsed: ParsedAssignment,
|
||||
onDisk: Map<string, string>
|
||||
): { moves: PlannedMove[]; orphans: string[]; retiredOnDisk: string[] } {
|
||||
const moves: PlannedMove[] = [];
|
||||
const orphans: string[] = [];
|
||||
const retiredOnDisk: string[] = [];
|
||||
for (const [pkg, fromAbs] of onDisk) {
|
||||
if (parsed.retired.has(pkg)) {
|
||||
retiredOnDisk.push(pkg);
|
||||
continue;
|
||||
}
|
||||
const plugin = parsed.pkgToPlugin.get(pkg);
|
||||
if (plugin === undefined) {
|
||||
orphans.push(pkg);
|
||||
continue;
|
||||
}
|
||||
const toAbs = join(repoRoot, "packages", plugin, pkg);
|
||||
moves.push({ pkg, plugin, from: fromAbs, to: toAbs });
|
||||
}
|
||||
moves.sort((a, b) =>
|
||||
a.plugin === b.plugin
|
||||
? a.pkg.localeCompare(b.pkg)
|
||||
: a.plugin.localeCompare(b.plugin)
|
||||
);
|
||||
return { moves, orphans, retiredOnDisk };
|
||||
}
|
||||
|
||||
function checkGitClean(): void {
|
||||
const out = git(["status", "--porcelain"]);
|
||||
if (out.trim().length > 0) {
|
||||
fail(
|
||||
`Working tree is dirty — commit or stash before migrating.\n\n${out}\n` +
|
||||
"If you really want to proceed with uncommitted changes, run with --dry-run first to verify the plan."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function ensureDestinationsClear(moves: PlannedMove[]): void {
|
||||
const collisions: string[] = [];
|
||||
for (const m of moves) {
|
||||
if (existsSync(m.to)) collisions.push(` ${relative(repoRoot, m.to)}`);
|
||||
}
|
||||
if (collisions.length > 0) {
|
||||
fail(`Destination paths already exist:\n${collisions.join("\n")}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Skip these from `cp -R` — build/test artifacts + dependency trees, regenerated
|
||||
// by `bun install` post-migration. Skipping keeps moves under 1s per package
|
||||
// instead of minutes (a single testing-integration package node_modules is ~500MB).
|
||||
const COPY_SKIP = new Set([
|
||||
"node_modules",
|
||||
"dist",
|
||||
".turbo",
|
||||
"coverage",
|
||||
".bd_modules",
|
||||
".tsbuildinfo",
|
||||
]);
|
||||
|
||||
function copyTrackedOnly(fromAbs: string, toAbs: string): void {
|
||||
cpSync(fromAbs, toAbs, {
|
||||
recursive: true,
|
||||
filter: (src) => {
|
||||
const base = src.split("/").pop() ?? "";
|
||||
if (COPY_SKIP.has(base)) return false;
|
||||
if (base.endsWith(".tsbuildinfo")) return false;
|
||||
return true;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function moveOne(fromRel: string, toRel: string): "git-mv" | "cp-rm" {
|
||||
// Try `git mv` first — preserves rename in the index cleanly.
|
||||
try {
|
||||
git(["mv", fromRel, toRel]);
|
||||
return "git-mv";
|
||||
} catch (err) {
|
||||
const msg = (err as Error).message;
|
||||
if (!msg.includes("Permission denied")) throw err;
|
||||
}
|
||||
// WSL2/DrvFs fallback: rename syscall blocked by Windows-side handle.
|
||||
// Copy tracked files only (skip node_modules etc), remove source, restage.
|
||||
// Git's rename detection still works for `log --follow` on similar files.
|
||||
const fromAbs = join(repoRoot, fromRel);
|
||||
const toAbs = join(repoRoot, toRel);
|
||||
copyTrackedOnly(fromAbs, toAbs);
|
||||
rmSync(fromAbs, { recursive: true, force: true });
|
||||
git(["add", toRel]);
|
||||
git(["rm", "-r", "--quiet", fromRel]);
|
||||
return "cp-rm";
|
||||
}
|
||||
|
||||
function executeMoves(moves: PlannedMove[]): void {
|
||||
const seenPluginDirs = new Set<string>();
|
||||
let cpRmCount = 0;
|
||||
for (const m of moves) {
|
||||
const pluginDir = join(repoRoot, "packages", m.plugin);
|
||||
if (!seenPluginDirs.has(pluginDir)) {
|
||||
mkdirSync(pluginDir, { recursive: true });
|
||||
seenPluginDirs.add(pluginDir);
|
||||
}
|
||||
const fromRel = relative(repoRoot, m.from);
|
||||
const toRel = relative(repoRoot, m.to);
|
||||
const method = moveOne(fromRel, toRel);
|
||||
const tag = method === "cp-rm" ? " cp+rm " : " git mv ";
|
||||
log(`${tag}${fromRel} → ${toRel}`);
|
||||
if (method === "cp-rm") cpRmCount += 1;
|
||||
}
|
||||
if (cpRmCount > 0) {
|
||||
log("");
|
||||
log(
|
||||
`(${cpRmCount}/${moves.length} moves used cp+rm fallback due to WSL2 rename block.)`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function pruneEmptyDir(absPath: string): boolean {
|
||||
// Returns true if the directory was removed.
|
||||
if (!existsSync(absPath) || !statSync(absPath).isDirectory()) return false;
|
||||
const entries = readdirSync(absPath);
|
||||
let allRemoved = true;
|
||||
for (const entry of entries) {
|
||||
const child = join(absPath, entry);
|
||||
if (statSync(child).isDirectory()) {
|
||||
if (!pruneEmptyDir(child)) allRemoved = false;
|
||||
} else {
|
||||
allRemoved = false;
|
||||
}
|
||||
}
|
||||
if (allRemoved && readdirSync(absPath).length === 0) {
|
||||
rmdirSync(absPath);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function pruneEmptyLegacyDirs(): void {
|
||||
for (const root of LEGACY_ROOTS) {
|
||||
const rootPath = join(repoRoot, root);
|
||||
if (pruneEmptyDir(rootPath)) {
|
||||
log(` rmdir ${root}/`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function summarise(
|
||||
moves: PlannedMove[],
|
||||
orphans: string[],
|
||||
retiredOnDisk: string[],
|
||||
assignment: Assignment,
|
||||
onDisk: Map<string, string>,
|
||||
mode: Mode
|
||||
): void {
|
||||
const pluginCounts = new Map<string, number>();
|
||||
for (const m of moves)
|
||||
pluginCounts.set(m.plugin, (pluginCounts.get(m.plugin) ?? 0) + 1);
|
||||
const pluginKeys = Object.keys(assignment).filter(
|
||||
(k) => k !== "__retired__"
|
||||
);
|
||||
const totalAssigned = pluginKeys.reduce(
|
||||
(n, k) => n + (assignment[k]?.length ?? 0),
|
||||
0
|
||||
);
|
||||
|
||||
log("");
|
||||
log(`Mode: ${mode}`);
|
||||
log(`Plugins defined: ${pluginKeys.length}`);
|
||||
log(`Packages assigned: ${totalAssigned}`);
|
||||
log(`Packages retired: ${assignment.__retired__?.length ?? 0}`);
|
||||
log(`Packages on disk: ${onDisk.size}`);
|
||||
log(`Packages to migrate: ${moves.length}`);
|
||||
log(`Packages on disk (retired, skipped): ${retiredOnDisk.length}`);
|
||||
log(`Packages future-only: ${totalAssigned - moves.length}`);
|
||||
log("");
|
||||
log("Per-plugin migration counts:");
|
||||
for (const [plugin, count] of [...pluginCounts.entries()].sort()) {
|
||||
log(` ${plugin.padEnd(24)} ${count}`);
|
||||
}
|
||||
if (retiredOnDisk.length > 0) {
|
||||
log("");
|
||||
log("Retired packages on disk (NOT migrated; remove via tasks.md §8):");
|
||||
for (const p of retiredOnDisk)
|
||||
log(` ${p} (${relative(repoRoot, onDisk.get(p) ?? "")})`);
|
||||
}
|
||||
if (orphans.length > 0) {
|
||||
log("");
|
||||
warn("On-disk packages not assigned to any plugin:");
|
||||
for (const p of orphans) warn(` ${p}`);
|
||||
fail(
|
||||
"Refusing to proceed — every on-disk package must appear in assignment.yml (or in __retired__)."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function main(): void {
|
||||
const mode = parseMode(process.argv.slice(2));
|
||||
log(`folder-reorg/migrate-folders.ts — mode: ${mode}`);
|
||||
|
||||
const assignment = loadAssignment();
|
||||
const parsed = validateUniqueness(assignment);
|
||||
|
||||
if (mode === "check") {
|
||||
const pluginCount = Object.keys(assignment).filter(
|
||||
(k) => k !== "__retired__"
|
||||
).length;
|
||||
log(
|
||||
`✓ Manifest valid: ${parsed.pkgToPlugin.size} unique packages across ${pluginCount} plugins, ${parsed.retired.size} retired.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const onDisk = discoverOnDiskPackages();
|
||||
const { moves, orphans, retiredOnDisk } = planMoves(parsed, onDisk);
|
||||
|
||||
summarise(moves, orphans, retiredOnDisk, assignment, onDisk, mode);
|
||||
|
||||
if (mode === "dry-run") {
|
||||
log("");
|
||||
log("Planned moves (first 10):");
|
||||
for (const m of moves.slice(0, 10)) {
|
||||
log(
|
||||
` ${relative(repoRoot, m.from)} → ${relative(repoRoot, m.to)}`
|
||||
);
|
||||
}
|
||||
if (moves.length > 10) log(` ...and ${moves.length - 10} more`);
|
||||
log("");
|
||||
log("✓ Dry run complete. Re-run without --dry-run to execute.");
|
||||
return;
|
||||
}
|
||||
|
||||
checkGitClean();
|
||||
ensureDestinationsClear(moves);
|
||||
|
||||
log("");
|
||||
log("Executing moves:");
|
||||
executeMoves(moves);
|
||||
pruneEmptyLegacyDirs();
|
||||
|
||||
log("");
|
||||
log(`✓ Migrated ${moves.length} packages.`);
|
||||
log("");
|
||||
log("Next steps:");
|
||||
log(' 1. Update root package.json workspaces to: "packages/*/*"');
|
||||
log(" 2. Run: rm -rf node_modules bun.lockb && bun install --force");
|
||||
log(" 3. Run: bunx turbo lint typecheck test");
|
||||
log(
|
||||
" 4. Commit: chore(structure): move all packages to domain-first layout"
|
||||
);
|
||||
}
|
||||
|
||||
main();
|
||||
409
scripts/folder-reorg/realign-to-categories.ts
Normal file
409
scripts/folder-reorg/realign-to-categories.ts
Normal file
@@ -0,0 +1,409 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Category realignment migration script.
|
||||
*
|
||||
* Reads scripts/folder-reorg/assignment.yml and moves every package currently
|
||||
* under packages/<old-category>/<pkg>/ to packages/<new-category>/<pkg>/
|
||||
* via `git mv`.
|
||||
*
|
||||
* Run modes:
|
||||
* bun scripts/folder-reorg/realign-to-categories.ts --dry-run # plan only
|
||||
* bun scripts/folder-reorg/realign-to-categories.ts # execute
|
||||
* bun scripts/folder-reorg/realign-to-categories.ts --check # validate manifest only
|
||||
*
|
||||
* Exits non-zero on:
|
||||
* - duplicate package across categories
|
||||
* - on-disk package not present in any category's list
|
||||
* - destination path already exists
|
||||
* - git working tree dirty (execute mode only)
|
||||
*/
|
||||
|
||||
import { execFileSync } from "node:child_process";
|
||||
import {
|
||||
cpSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
rmdirSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
} from "node:fs";
|
||||
import { dirname, join, relative, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { parse as parseYaml } from "yaml";
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = resolve(here, "..", "..");
|
||||
const assignmentPath = join(here, "assignment.yml");
|
||||
const packagesRoot = join(repoRoot, "packages");
|
||||
|
||||
type Mode = "dry-run" | "execute" | "check";
|
||||
|
||||
interface Assignment {
|
||||
[category: string]: string[];
|
||||
}
|
||||
|
||||
interface PlannedMove {
|
||||
pkg: string;
|
||||
category: string;
|
||||
from: string; // absolute
|
||||
to: string; // absolute
|
||||
}
|
||||
|
||||
const log = (...args: unknown[]) => console.log(...args);
|
||||
const warn = (...args: unknown[]) => console.warn("⚠ ", ...args);
|
||||
const fail = (msg: string): never => {
|
||||
console.error(`✗ ${msg}`);
|
||||
process.exit(1);
|
||||
};
|
||||
|
||||
function git(args: string[], cwd: string = repoRoot): string {
|
||||
return execFileSync("git", args, { cwd, encoding: "utf8" });
|
||||
}
|
||||
|
||||
function parseMode(argv: string[]): Mode {
|
||||
if (argv.includes("--dry-run")) return "dry-run";
|
||||
if (argv.includes("--check")) return "check";
|
||||
return "execute";
|
||||
}
|
||||
|
||||
function loadAssignment(): Assignment {
|
||||
if (!existsSync(assignmentPath)) {
|
||||
fail(`Assignment manifest not found: ${assignmentPath}`);
|
||||
}
|
||||
const raw = readFileSync(assignmentPath, "utf8");
|
||||
const parsed = parseYaml(raw) as unknown;
|
||||
if (typeof parsed !== "object" || parsed === null) {
|
||||
fail("assignment.yml did not parse to an object");
|
||||
}
|
||||
const a = parsed as Record<string, unknown>;
|
||||
for (const [category, pkgs] of Object.entries(a)) {
|
||||
if (!Array.isArray(pkgs)) fail(`Category "${category}" is not a list`);
|
||||
for (const p of pkgs) {
|
||||
if (typeof p !== "string")
|
||||
fail(`Category "${category}" has non-string entry`);
|
||||
}
|
||||
}
|
||||
return a as Assignment;
|
||||
}
|
||||
|
||||
interface ParsedAssignment {
|
||||
pkgToCategory: Map<string, string>;
|
||||
retiredPackages: Set<string>;
|
||||
retiredCategories: Set<string>;
|
||||
}
|
||||
|
||||
function validateUniqueness(assignment: Assignment): ParsedAssignment {
|
||||
const pkgToCategory = new Map<string, string>();
|
||||
const retiredPackages = new Set<string>(assignment.__retired__ ?? []);
|
||||
const retiredCategories = new Set<string>(
|
||||
Array.isArray(assignment.__retired_categories__)
|
||||
? assignment.__retired_categories__
|
||||
: []
|
||||
);
|
||||
const duplicates: string[] = [];
|
||||
for (const [category, pkgs] of Object.entries(assignment)) {
|
||||
if (category === "__retired__" || category === "__retired_categories__")
|
||||
continue;
|
||||
for (const pkg of pkgs) {
|
||||
if (retiredPackages.has(pkg)) {
|
||||
duplicates.push(
|
||||
` ${pkg}: listed in category "${category}" AND in __retired__`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const prior = pkgToCategory.get(pkg);
|
||||
if (prior !== undefined) {
|
||||
duplicates.push(` ${pkg}: in "${prior}" AND "${category}"`);
|
||||
} else {
|
||||
pkgToCategory.set(pkg, category);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (duplicates.length > 0) {
|
||||
fail(`Duplicate package assignments found:\n${duplicates.join("\n")}`);
|
||||
}
|
||||
return { pkgToCategory, retiredPackages, retiredCategories };
|
||||
}
|
||||
|
||||
function discoverOnDiskPackages(): Map<string, string> {
|
||||
const result = new Map<string, string>();
|
||||
if (!existsSync(packagesRoot) || !statSync(packagesRoot).isDirectory()) {
|
||||
fail(`Packages root does not exist: ${packagesRoot}`);
|
||||
}
|
||||
for (const category of readdirSync(packagesRoot)) {
|
||||
const categoryPath = join(packagesRoot, category);
|
||||
if (!statSync(categoryPath).isDirectory()) continue;
|
||||
for (const pkg of readdirSync(categoryPath)) {
|
||||
const pkgPath = join(categoryPath, pkg);
|
||||
if (!statSync(pkgPath).isDirectory()) continue;
|
||||
if (!existsSync(join(pkgPath, "package.json"))) continue;
|
||||
if (result.has(pkg)) {
|
||||
fail(
|
||||
`On-disk duplicate package directory: "${pkg}" exists in both ${result.get(pkg)} and ${pkgPath}`
|
||||
);
|
||||
}
|
||||
result.set(pkg, pkgPath);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function planMoves(
|
||||
parsed: ParsedAssignment,
|
||||
onDisk: Map<string, string>
|
||||
): { moves: PlannedMove[]; orphans: string[]; retiredOnDisk: string[] } {
|
||||
const moves: PlannedMove[] = [];
|
||||
const orphans: string[] = [];
|
||||
const retiredOnDisk: string[] = [];
|
||||
for (const [pkg, fromAbs] of onDisk) {
|
||||
if (parsed.retiredPackages.has(pkg)) {
|
||||
retiredOnDisk.push(pkg);
|
||||
continue;
|
||||
}
|
||||
const category = parsed.pkgToCategory.get(pkg);
|
||||
if (category === undefined) {
|
||||
orphans.push(pkg);
|
||||
continue;
|
||||
}
|
||||
const toAbs = join(packagesRoot, category, pkg);
|
||||
if (toAbs !== fromAbs) {
|
||||
moves.push({ pkg, category, from: fromAbs, to: toAbs });
|
||||
}
|
||||
}
|
||||
moves.sort((a, b) =>
|
||||
a.category === b.category
|
||||
? a.pkg.localeCompare(b.pkg)
|
||||
: a.category.localeCompare(b.category)
|
||||
);
|
||||
return { moves, orphans, retiredOnDisk };
|
||||
}
|
||||
|
||||
function checkGitClean(): void {
|
||||
const out = git(["status", "--porcelain"]);
|
||||
if (out.trim().length > 0) {
|
||||
fail(
|
||||
`Working tree is dirty — commit or stash before migrating.\n\n${out}\n` +
|
||||
"If you really want to proceed with uncommitted changes, run with --dry-run first to verify the plan."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function ensureDestinationsClear(moves: PlannedMove[]): void {
|
||||
const collisions: string[] = [];
|
||||
for (const m of moves) {
|
||||
if (existsSync(m.to)) collisions.push(` ${relative(repoRoot, m.to)}`);
|
||||
}
|
||||
if (collisions.length > 0) {
|
||||
fail(`Destination paths already exist:\n${collisions.join("\n")}`);
|
||||
}
|
||||
}
|
||||
|
||||
const COPY_SKIP = new Set([
|
||||
"node_modules",
|
||||
"dist",
|
||||
".turbo",
|
||||
"coverage",
|
||||
".bd_modules",
|
||||
".tsbuildinfo",
|
||||
]);
|
||||
|
||||
function copyTrackedOnly(fromAbs: string, toAbs: string): void {
|
||||
cpSync(fromAbs, toAbs, {
|
||||
recursive: true,
|
||||
filter: (src) => {
|
||||
const base = src.split("/").pop() ?? "";
|
||||
if (COPY_SKIP.has(base)) return false;
|
||||
if (base.endsWith(".tsbuildinfo")) return false;
|
||||
return true;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function moveOne(fromRel: string, toRel: string): "git-mv" | "cp-rm" {
|
||||
try {
|
||||
git(["mv", fromRel, toRel]);
|
||||
return "git-mv";
|
||||
} catch (err) {
|
||||
const msg = (err as Error).message;
|
||||
if (!msg.includes("Permission denied")) throw err;
|
||||
}
|
||||
const fromAbs = join(repoRoot, fromRel);
|
||||
const toAbs = join(repoRoot, toRel);
|
||||
copyTrackedOnly(fromAbs, toAbs);
|
||||
rmSync(fromAbs, { recursive: true, force: true });
|
||||
git(["add", toRel]);
|
||||
git(["rm", "-r", "--quiet", fromRel]);
|
||||
return "cp-rm";
|
||||
}
|
||||
|
||||
function executeMoves(moves: PlannedMove[]): void {
|
||||
const seenCategoryDirs = new Set<string>();
|
||||
let cpRmCount = 0;
|
||||
for (const m of moves) {
|
||||
const categoryDir = join(packagesRoot, m.category);
|
||||
if (!seenCategoryDirs.has(categoryDir)) {
|
||||
mkdirSync(categoryDir, { recursive: true });
|
||||
seenCategoryDirs.add(categoryDir);
|
||||
}
|
||||
const fromRel = relative(repoRoot, m.from);
|
||||
const toRel = relative(repoRoot, m.to);
|
||||
const method = moveOne(fromRel, toRel);
|
||||
const tag = method === "cp-rm" ? " cp+rm " : " git mv ";
|
||||
log(`${tag}${fromRel} → ${toRel}`);
|
||||
if (method === "cp-rm") cpRmCount += 1;
|
||||
}
|
||||
if (cpRmCount > 0) {
|
||||
log("");
|
||||
log(
|
||||
`(${cpRmCount}/${moves.length} moves used cp+rm fallback due to WSL2 rename block.)`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function pruneEmptyDir(absPath: string): boolean {
|
||||
if (!existsSync(absPath) || !statSync(absPath).isDirectory()) return false;
|
||||
const entries = readdirSync(absPath);
|
||||
let allRemoved = true;
|
||||
for (const entry of entries) {
|
||||
const child = join(absPath, entry);
|
||||
if (statSync(child).isDirectory()) {
|
||||
if (!pruneEmptyDir(child)) allRemoved = false;
|
||||
} else {
|
||||
allRemoved = false;
|
||||
}
|
||||
}
|
||||
if (allRemoved && readdirSync(absPath).length === 0) {
|
||||
rmdirSync(absPath);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function pruneRetiredCategories(retiredCategories: Set<string>): void {
|
||||
for (const category of retiredCategories) {
|
||||
const categoryPath = join(packagesRoot, category);
|
||||
if (pruneEmptyDir(categoryPath)) {
|
||||
log(` rmdir packages/${category}/`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function summarise(
|
||||
moves: PlannedMove[],
|
||||
orphans: string[],
|
||||
retiredOnDisk: string[],
|
||||
assignment: Assignment,
|
||||
onDisk: Map<string, string>,
|
||||
mode: Mode
|
||||
): void {
|
||||
const categoryCounts = new Map<string, number>();
|
||||
for (const m of moves)
|
||||
categoryCounts.set(
|
||||
m.category,
|
||||
(categoryCounts.get(m.category) ?? 0) + 1
|
||||
);
|
||||
const categoryKeys = Object.keys(assignment).filter(
|
||||
(k) => k !== "__retired__"
|
||||
);
|
||||
const totalAssigned = categoryKeys.reduce(
|
||||
(n, k) => n + (assignment[k]?.length ?? 0),
|
||||
0
|
||||
);
|
||||
|
||||
log("");
|
||||
log(`Mode: ${mode}`);
|
||||
log(`Categories defined: ${categoryKeys.length}`);
|
||||
log(`Packages assigned: ${totalAssigned}`);
|
||||
log(`Packages retired: ${assignment.__retired__?.length ?? 0}`);
|
||||
log(
|
||||
`Categories retired: ${assignment.__retired_categories__?.length ?? 0}`
|
||||
);
|
||||
log(`Packages on disk: ${onDisk.size}`);
|
||||
log(`Packages to migrate: ${moves.length}`);
|
||||
log(
|
||||
`Packages already correct: ${onDisk.size - moves.length - orphans.length - retiredOnDisk.length}`
|
||||
);
|
||||
log(`Packages on disk (retired, skipped): ${retiredOnDisk.length}`);
|
||||
log(`Packages future-only: ${totalAssigned - onDisk.size}`);
|
||||
log("");
|
||||
log("Per-category migration counts:");
|
||||
for (const [category, count] of [...categoryCounts.entries()].sort()) {
|
||||
log(` ${category.padEnd(24)} ${count}`);
|
||||
}
|
||||
if (retiredOnDisk.length > 0) {
|
||||
log("");
|
||||
log("Retired packages on disk (NOT migrated; remove via tasks.md §8):");
|
||||
for (const p of retiredOnDisk)
|
||||
log(` ${p} (${relative(repoRoot, onDisk.get(p) ?? "")})`);
|
||||
}
|
||||
if (orphans.length > 0) {
|
||||
log("");
|
||||
warn("On-disk packages not assigned to any category:");
|
||||
for (const p of orphans) warn(` ${p}`);
|
||||
fail(
|
||||
"Refusing to proceed — every on-disk package must appear in assignment.yml (or in __retired__)."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function main(): void {
|
||||
const mode = parseMode(process.argv.slice(2));
|
||||
log(`folder-reorg/realign-to-categories.ts — mode: ${mode}`);
|
||||
|
||||
const assignment = loadAssignment();
|
||||
const parsed = validateUniqueness(assignment);
|
||||
|
||||
if (mode === "check") {
|
||||
const categoryCount = Object.keys(assignment).filter(
|
||||
(k) => k !== "__retired__" && k !== "__retired_categories__"
|
||||
).length;
|
||||
log(
|
||||
`✓ Manifest valid: ${parsed.pkgToCategory.size} unique packages across ${categoryCount} categories, ${parsed.retiredPackages.size} retired.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const onDisk = discoverOnDiskPackages();
|
||||
const { moves, orphans, retiredOnDisk } = planMoves(parsed, onDisk);
|
||||
|
||||
summarise(moves, orphans, retiredOnDisk, assignment, onDisk, mode);
|
||||
|
||||
if (mode === "dry-run") {
|
||||
log("");
|
||||
log("Planned moves (first 10):");
|
||||
for (const m of moves.slice(0, 10)) {
|
||||
log(
|
||||
` ${relative(repoRoot, m.from)} → ${relative(repoRoot, m.to)}`
|
||||
);
|
||||
}
|
||||
if (moves.length > 10) log(` ...and ${moves.length - 10} more`);
|
||||
log("");
|
||||
log("✓ Dry run complete. Re-run without --dry-run to execute.");
|
||||
return;
|
||||
}
|
||||
|
||||
checkGitClean();
|
||||
ensureDestinationsClear(moves);
|
||||
|
||||
log("");
|
||||
log("Executing moves:");
|
||||
executeMoves(moves);
|
||||
pruneRetiredCategories(parsed.retiredCategories);
|
||||
|
||||
log("");
|
||||
log(`✓ Migrated ${moves.length} packages.`);
|
||||
log("");
|
||||
log("Next steps:");
|
||||
log(
|
||||
' 1. Run: rm -rf node_modules bun.lockb && find . -type d -name "node_modules" -exec rm -rf {} + && bun install --force'
|
||||
);
|
||||
log(" 2. Run: bunx turbo lint typecheck test");
|
||||
log(" 3. Run: bun scripts/folder-reorg/generate-manifest.ts");
|
||||
log(
|
||||
" 4. Commit: chore(structure): realign packages to FOLDERS.md categories"
|
||||
);
|
||||
}
|
||||
|
||||
main();
|
||||
Reference in New Issue
Block a user