-
Notifications
You must be signed in to change notification settings - Fork 12
Add boxel consolidate-workspaces command (CS-10632) #4780
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
FadhlanR
merged 2 commits into
main
from
cs-10632-reimplement-boxel-consolidate-workspaces-command
May 13, 2026
Merged
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
104 changes: 104 additions & 0 deletions
104
packages/boxel-cli/src/commands/consolidate-workspaces.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,104 @@ | ||
| import * as fs from 'fs'; | ||
| import * as path from 'path'; | ||
| import type { Command } from 'commander'; | ||
| import { findMisplacedLocalRealmDirs } from '../lib/realm-local-paths'; | ||
|
|
||
| export interface ConsolidateWorkspacesOptions { | ||
| dryRun?: boolean; | ||
| } | ||
|
|
||
| function ensureDir(dirPath: string): void { | ||
| if (!fs.existsSync(dirPath)) { | ||
| fs.mkdirSync(dirPath, { recursive: true }); | ||
| } | ||
| } | ||
|
|
||
| function moveDir(from: string, to: string): void { | ||
| try { | ||
| fs.renameSync(from, to); | ||
| } catch (error) { | ||
| const err = error as NodeJS.ErrnoException; | ||
| if (err.code !== 'EXDEV') { | ||
| throw err; | ||
| } | ||
| fs.cpSync(from, to, { recursive: true }); | ||
| fs.rmSync(from, { recursive: true, force: true }); | ||
| } | ||
| } | ||
|
|
||
| export async function consolidateWorkspacesCommand( | ||
| rootDirInput: string | undefined, | ||
| options: ConsolidateWorkspacesOptions, | ||
| ): Promise<void> { | ||
| const rootDir = path.resolve(rootDirInput || '.'); | ||
| const entries = findMisplacedLocalRealmDirs(rootDir); | ||
|
|
||
| if (entries.length === 0) { | ||
| console.log(`No misplaced local realm paths found under ${rootDir}`); | ||
| return; | ||
| } | ||
|
|
||
| console.log(`Found ${entries.length} misplaced local realm path(s):\n`); | ||
|
|
||
| let moved = 0; | ||
| let skipped = 0; | ||
|
|
||
| for (const entry of entries) { | ||
| const from = path.relative(rootDir, entry.currentDir) || '.'; | ||
| const to = path.relative(rootDir, entry.expectedDir) || '.'; | ||
| console.log(`- ${from} -> ${to}`); | ||
|
|
||
| if (options.dryRun) { | ||
| continue; | ||
| } | ||
|
|
||
| if (fs.existsSync(entry.expectedDir)) { | ||
| console.warn(' Skipping: target path already exists'); | ||
| skipped += 1; | ||
| continue; | ||
| } | ||
|
|
||
| ensureDir(path.dirname(entry.expectedDir)); | ||
| try { | ||
| moveDir(entry.currentDir, entry.expectedDir); | ||
| moved += 1; | ||
| } catch (error) { | ||
| const message = error instanceof Error ? error.message : String(error); | ||
| console.warn(` Skipping: failed to move (${message})`); | ||
| skipped += 1; | ||
| } | ||
| } | ||
|
|
||
| if (options.dryRun) { | ||
| console.log('\n[DRY RUN] No directories moved.'); | ||
| return; | ||
| } | ||
|
|
||
| console.log(`\nMoved ${moved} director${moved === 1 ? 'y' : 'ies'}.`); | ||
| if (skipped > 0) { | ||
| console.log( | ||
| `Skipped ${skipped} due to existing target paths or move failures.`, | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| export function registerConsolidateWorkspacesCommand(program: Command): void { | ||
| program | ||
| .command('consolidate-workspaces') | ||
| .description( | ||
| 'Move local realm mirror directories into the canonical <root>/<domain>/<owner>/<realm> layout', | ||
| ) | ||
| .argument( | ||
| '[root-dir]', | ||
| 'Root directory to scan (default: current directory)', | ||
| ) | ||
| .option('--dry-run', 'Preview without moving anything') | ||
| .action( | ||
| async ( | ||
| rootDir: string | undefined, | ||
| opts: ConsolidateWorkspacesOptions, | ||
| ) => { | ||
| await consolidateWorkspacesCommand(rootDir, opts); | ||
| }, | ||
| ); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,243 @@ | ||
| import * as fs from 'fs'; | ||
| import * as path from 'path'; | ||
| import { isQuiet } from './cli-log'; | ||
|
|
||
| export interface MisplacedLocalRealmEntry { | ||
| manifestPath: string; | ||
| currentDir: string; | ||
| expectedDir: string; | ||
| realmUrl: string; | ||
| } | ||
|
|
||
| interface MinimalManifest { | ||
| realmUrl: string; | ||
| } | ||
|
|
||
| let didWarnInProcess = false; | ||
|
|
||
| const SKIPPABLE_DIR_NAMES = new Set([ | ||
| '.git', | ||
| 'node_modules', | ||
| 'dist', | ||
| '.boxel-history', | ||
| '.claude', | ||
| ]); | ||
|
|
||
| function isSkippableDir(dirName: string): boolean { | ||
| return SKIPPABLE_DIR_NAMES.has(dirName); | ||
| } | ||
|
|
||
| function canonicalDomainFromHost(hostname: string): string { | ||
| if (hostname === 'stack.cards' || hostname.endsWith('.stack.cards')) { | ||
| return 'stack.cards'; | ||
| } | ||
| if (hostname === 'boxel.ai' || hostname.endsWith('.boxel.ai')) { | ||
| return 'boxel.ai'; | ||
| } | ||
| return hostname; | ||
| } | ||
|
|
||
| // Reject segments that would let a crafted realmUrl escape the rootDir tree | ||
| // (`.`, `..`, anything containing a path separator or NUL) — defence in depth | ||
| // against malformed manifests; `findMisplacedLocalRealmDirs` also re-checks | ||
| // containment after `path.resolve` collapses any `..` it didn't catch. | ||
| function isSafePathSegment(segment: string): boolean { | ||
| if (!segment) return false; | ||
| let decoded: string; | ||
| try { | ||
| decoded = decodeURIComponent(segment); | ||
| } catch { | ||
| return false; | ||
| } | ||
| if (decoded === '.' || decoded === '..') return false; | ||
| if ( | ||
| decoded.includes('/') || | ||
| decoded.includes('\\') || | ||
| decoded.includes('\0') | ||
| ) { | ||
| return false; | ||
| } | ||
| return true; | ||
| } | ||
|
|
||
| export function relativeStructuredPathForRealmUrl( | ||
| realmUrl: string, | ||
| ): string | null { | ||
| let url: URL; | ||
| try { | ||
| url = new URL(realmUrl); | ||
| } catch { | ||
| return null; | ||
| } | ||
| const domain = canonicalDomainFromHost(url.hostname); | ||
| if (!isSafePathSegment(domain)) return null; | ||
| const parts = url.pathname | ||
| .replace(/^\/|\/$/g, '') | ||
| .split('/') | ||
| .filter(Boolean); | ||
| const owner = parts[0] ?? 'unknown-owner'; | ||
| const realm = parts[1] ?? parts[0] ?? 'workspace'; | ||
| if (!isSafePathSegment(owner) || !isSafePathSegment(realm)) return null; | ||
| return path.join(domain, owner, realm); | ||
| } | ||
|
|
||
| export function absoluteStructuredPathForRealmUrl( | ||
| realmUrl: string, | ||
| rootDir: string, | ||
| ): string | null { | ||
| const rel = relativeStructuredPathForRealmUrl(realmUrl); | ||
| if (rel === null) return null; | ||
| return path.resolve(rootDir, rel); | ||
| } | ||
|
|
||
| function tryReadRealmUrl(manifestPath: string): MinimalManifest | null { | ||
| let content: string; | ||
| try { | ||
| content = fs.readFileSync(manifestPath, 'utf-8'); | ||
| } catch { | ||
| return null; | ||
| } | ||
| let parsed: unknown; | ||
| try { | ||
| parsed = JSON.parse(content); | ||
| } catch { | ||
| return null; | ||
| } | ||
| if (typeof parsed !== 'object' || parsed === null) { | ||
| return null; | ||
| } | ||
| const candidate = (parsed as Record<string, unknown>).realmUrl; | ||
| if (typeof candidate !== 'string' || candidate === '') { | ||
| return null; | ||
| } | ||
| return { realmUrl: candidate }; | ||
| } | ||
|
|
||
| function addManifestIfExists(dir: string, manifests: string[]): void { | ||
| const manifestPath = path.join(dir, '.boxel-sync.json'); | ||
| if (fs.existsSync(manifestPath)) { | ||
| manifests.push(manifestPath); | ||
| } | ||
| } | ||
|
|
||
| function listSubdirs(dir: string): string[] { | ||
| try { | ||
| return fs | ||
| .readdirSync(dir, { withFileTypes: true }) | ||
| .filter((entry) => entry.isDirectory() && !isSkippableDir(entry.name)) | ||
| .map((entry) => path.join(dir, entry.name)); | ||
| } catch { | ||
| return []; | ||
| } | ||
| } | ||
|
|
||
| function findManifestPaths(rootDir: string): string[] { | ||
| const manifests: string[] = []; | ||
| const absoluteRoot = path.resolve(rootDir); | ||
|
|
||
| // Legacy layout: <root>/<realm>/.boxel-sync.json | ||
| for (const childDir of listSubdirs(absoluteRoot)) { | ||
| addManifestIfExists(childDir, manifests); | ||
| } | ||
|
|
||
| // Canonical layout: <root>/<domain>/<owner>/<realm>/.boxel-sync.json | ||
| for (const domainDir of listSubdirs(absoluteRoot)) { | ||
| for (const ownerDir of listSubdirs(domainDir)) { | ||
| for (const realmDir of listSubdirs(ownerDir)) { | ||
| addManifestIfExists(realmDir, manifests); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| return manifests; | ||
| } | ||
|
|
||
| // True iff `child` is `root` or a descendant of `root`. Belt-and-suspenders | ||
| // containment check after `path.resolve` — even if a crafted realmUrl made | ||
| // it past `isSafePathSegment`, the resolved path must stay inside rootDir | ||
| // before we move anything. | ||
| function isWithin(root: string, child: string): boolean { | ||
| const rel = path.relative(root, child); | ||
| if (rel === '') return true; | ||
| if (rel.startsWith('..')) return false; | ||
| return !path.isAbsolute(rel); | ||
| } | ||
|
|
||
| export function findMisplacedLocalRealmDirs( | ||
| rootDir: string, | ||
| ): MisplacedLocalRealmEntry[] { | ||
| const absoluteRoot = path.resolve(rootDir); | ||
| const manifestPaths = findManifestPaths(absoluteRoot); | ||
|
|
||
| const seenManifestPaths = new Set<string>(); | ||
| const entries: MisplacedLocalRealmEntry[] = []; | ||
| for (const manifestPath of manifestPaths) { | ||
| if (seenManifestPaths.has(manifestPath)) { | ||
| continue; | ||
| } | ||
| seenManifestPaths.add(manifestPath); | ||
|
|
||
| const manifest = tryReadRealmUrl(manifestPath); | ||
| if (!manifest) { | ||
| continue; | ||
| } | ||
|
|
||
| const expectedDir = absoluteStructuredPathForRealmUrl( | ||
| manifest.realmUrl, | ||
| absoluteRoot, | ||
| ); | ||
| if (expectedDir === null) { | ||
| continue; | ||
| } | ||
| if (!isWithin(absoluteRoot, expectedDir)) { | ||
| continue; | ||
| } | ||
|
|
||
| const currentDir = path.dirname(manifestPath); | ||
| if (path.resolve(currentDir) !== path.resolve(expectedDir)) { | ||
| entries.push({ | ||
| manifestPath, | ||
| currentDir, | ||
| expectedDir, | ||
| realmUrl: manifest.realmUrl, | ||
| }); | ||
| } | ||
| } | ||
|
|
||
| return entries; | ||
| } | ||
|
|
||
| export function warnIfMisplacedLocalRealmDirs(rootDir: string): void { | ||
| if (didWarnInProcess) return; | ||
| if (process.env.BOXEL_DISABLE_PATH_WARNING === '1') return; | ||
| if (isQuiet()) return; | ||
|
|
||
| const entries = findMisplacedLocalRealmDirs(rootDir); | ||
| if (entries.length === 0) return; | ||
|
|
||
| didWarnInProcess = true; | ||
|
|
||
| console.warn('\n⚠️ Detected local realm directories at legacy local paths:'); | ||
| const absoluteRoot = path.resolve(rootDir); | ||
| for (const entry of entries.slice(0, 5)) { | ||
| const from = path.relative(absoluteRoot, entry.currentDir) || '.'; | ||
| const to = path.relative(absoluteRoot, entry.expectedDir) || '.'; | ||
| console.warn(` - ${from} -> ${to}`); | ||
| } | ||
| if (entries.length > 5) { | ||
| console.warn(` ...and ${entries.length - 5} more`); | ||
| } | ||
| console.warn('\nRun to preview:'); | ||
| console.warn(' boxel consolidate-workspaces . --dry-run'); | ||
| console.warn('Then apply:'); | ||
| console.warn(' boxel consolidate-workspaces .\n'); | ||
| } | ||
|
|
||
| /** | ||
| * Test-only escape hatch — resets the once-per-process warning latch so tests | ||
| * can exercise `warnIfMisplacedLocalRealmDirs` repeatedly within a single | ||
| * Node process. | ||
| */ | ||
| export function resetWarnedFlagForTests(): void { | ||
| didWarnInProcess = false; | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Addressed in bf38b9b with defence in depth:
isSafePathSegmentrejects any segment whose decoded form contains/,\, or NUL, or is exactly.or..— applied to the domain, owner, and realm segments.findMisplacedLocalRealmDirshas a newisWithin(absoluteRoot, expectedDir)containment check that drops any resolved destination not strictly insiderootDir.Note: WHATWG URL parsing already normalizes unencoded
..traversal (e.g.https://stack.cards/../../etc/passwdresolves to/etc/passwdunderstack.cards), so the practical attack surface is segments with percent-encoded separators like%2F,%5C,%00. Tests cover both branches: encoded-separator segments → null; traversal-shaped URLs → result stays inside rootDir.