@stacksjs/buddy 0.70.358 → 0.70.363

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
- import{execSync}from"node:child_process";import{existsSync,readdirSync,readFileSync}from"node:fs";import readline from"node:readline";import process from"node:process";import{bold,cyan,dim,green,intro,log,onUnknownSubcommand,outro,prompts,runCommand,yellow}from"@stacksjs/cli";import{homedir}from"node:os";import{dirname,join,relative}from"node:path";import{fileURLToPath}from"node:url";import{Action}from"@stacksjs/enums";import{libsPath,projectPath,stxPath}from"@stacksjs/path";import{ExitCode}from"@stacksjs/types";import{version}from"../../package.json";import{resultFailed}from"../result";const DEV_BOOT_STARTING_LINE_COUNT=3;function eraseDevBootStartingLines(){if(!process.stdout.isTTY)return;for(let i=0;i<DEV_BOOT_STARTING_LINE_COUNT;i++){readline.moveCursor(process.stdout,0,-1);readline.clearLine(process.stdout,0)}}let developmentRpx;function resolveRpxEntryPath(){return[projectPath("node_modules/@stacksjs/rpx/dist/index.js"),join(homedir(),"Code/Tools/rpx/packages/rpx/dist/index.js"),projectPath("pantry/@stacksjs/rpx/dist/src/index.js")].find((entry)=>existsSync(entry))??null}async function importDevelopmentRpx(){if(developmentRpx)return developmentRpx;const entry=resolveRpxEntryPath();developmentRpx=entry?await import(entry):await import("@stacksjs/rpx");return developmentRpx}const activeRpxRegistryIds=[];let _actions;async function actions(){if(!_actions)_actions=await import("@stacksjs/actions");return _actions}export const interactiveDevChoices=[{value:"all",title:"All"},{value:"frontend",title:"Frontend"},{value:"api",title:"Backend"},{value:"dashboard",title:"Dashboard"},{value:"desktop",title:"Desktop"},{value:"native",title:"Native App"},{value:"components",title:"Components"},{value:"docs",title:"Documentation"}];export async function dispatchInteractiveDevSelection(selection,runners){if(!Object.hasOwn(runners,selection))return!1;await runners[selection]();return!0}export function resolvePrettyDevDomain(appUrl,nativeMode=!1){if(!appUrl||nativeMode)return null;try{const hostname=new URL(/^https?:\/\//i.test(appUrl)?appUrl:`https://${appUrl}`).hostname.toLowerCase();if(hostname==="localhost"||hostname==="127.0.0.1"||hostname==="::1"||hostname==="0.0.0.0")return null;return hostname}catch{return null}}export function shouldUsePrettyDevUrls(input){return input.domain!==null&&!input.localhostOnly&&(input.proxyManagedExternally||input.systemAuthorized)}const APPLICATION_VIEW_CANDIDATES=["app/index","app","dashboard/index","dashboard","home/index","home","composer","workspace/index","workspace","feed","portal","admin","account/index","account","profile/index","profile"],SITE_LAYOUT_NAMES=new Set(["default","guest","marketing","site"]);export function normalizeDevelopmentEntryPath(path){const value=path?.trim();if(!value||value==="/")return"/";try{const parsed=new URL(value,"http://stacks.localhost");return`${parsed.pathname}${parsed.search}${parsed.hash}`}catch{return"/"}}function configuredApplicationPath(root){if(process.env.APP_PATH)return process.env.APP_PATH;try{return readFileSync(join(root,"config/app.ts"),"utf8").match(/^[ \t]*appPath\s*:\s*(['"`])([^'"`]+)\1/m)?.[2]}catch{return}}function configuredDevelopmentLaunch(root){const environmentValue=process.env.STACKS_DEV_LAUNCH?.trim().toLowerCase();if(environmentValue==="browser"||environmentValue==="native")return environmentValue;try{const value=readFileSync(join(root,"config/app.ts"),"utf8").match(/\bdevLaunch\s*:\s*(['"`])(browser|native)\1/)?.[2];return value==="browser"||value==="native"?value:void 0}catch{return}}export function resolveDevelopmentLaunch(input={}){if(input.browser||input.site)return"browser";if(input.native)return"native";const root=input.root??projectPath();return input.configuredLaunch??configuredDevelopmentLaunch(root)??"browser"}function viewSource(root,route){const base=join(root,"resources/views",route);for(const extension of["stx","vue","html"]){const path=`${base}.${extension}`;if(existsSync(path))try{return readFileSync(path,"utf8")}catch{return""}}return}function usesApplicationLayout(source){const layout=source.match(/@extends\(\s*['"]layouts\/([^'"]+)['"]\s*\)/)?.[1]??source.match(/@layout\(\s*['"]layouts\/([^'"]+)['"]\s*\)/)?.[1];if(!layout)return!1;return!SITE_LAYOUT_NAMES.has(layout.split("/").at(-1)??layout)}export function resolveDevelopmentEntryPath(input={}){if(input.site)return"/";const root=input.root??projectPath(),configuredPath=input.configuredPath??configuredApplicationPath(root);if(configuredPath)return normalizeDevelopmentEntryPath(configuredPath);const loginExists=viewSource(root,"login")!==void 0||viewSource(root,"auth/login")!==void 0,candidates=APPLICATION_VIEW_CANDIDATES.map((route)=>({route,source:viewSource(root,route)})).filter((candidate)=>candidate.source!==void 0),appCandidate=candidates.find((candidate)=>usesApplicationLayout(candidate.source))??(loginExists?candidates[0]:void 0);if(appCandidate)return normalizeDevelopmentEntryPath(`/${appCandidate.route.replace(/\/index$/,"")}`);return loginExists?"/login":"/"}export function developmentUrl(baseUrl,entryPath){const normalized=normalizeDevelopmentEntryPath(entryPath);if(normalized==="/")return baseUrl.replace(/\/$/,"");return new URL(normalized,`${baseUrl.replace(/\/$/,"")}/`).toString()}export function developmentBrowserCommand(url,platform=process.platform){if(platform==="darwin")return["open",url];if(platform==="win32")return["cmd","/c","start","",url];return["xdg-open",url]}function openDevelopmentBrowser(url){if(!process.stdout.isTTY||process.env.CI||process.env.STACKS_DEV_NO_OPEN==="1")return;try{Bun.spawn(developmentBrowserCommand(url),{stdin:"ignore",stdout:"ignore",stderr:"ignore"}).unref()}catch{}}async function canStartPrettyDevProxy(){if(await waitForHttpsProxy(443,150))return!0;try{const{authorizeSystemAccess}=await importDevelopmentRpx();return authorizeSystemAccess({interactive:!1})}catch{return!1}}export function dev(buddy){const descriptions={dev:"Start development server",frontend:"Start the frontend development server",components:"Start the Components development server",desktop:"Start the Desktop App development server",native:"Start the app in a native Craft window",dashboard:"Start the Dashboard development server",api:"Start the local API development server",docs:"Start the Documentation development server",systemTray:"Start the System Tray development server",interactive:"Get asked which development server to start",select:"Which development server are you trying to start?",withLocalhost:"Include the localhost URL in the output",browser:"Open the application in a browser instead of its configured native window",site:"Open the marketing site instead of the application",project:"Target a specific project",verbose:"Enable verbose output"};buddy.command("dev [server]",descriptions.dev).option("-f, --frontend",descriptions.frontend).option("-a, --api",descriptions.api).option("-c, --components",descriptions.components).option("-d, --dashboard",descriptions.dashboard).option("-k, --desktop",descriptions.desktop).option("-n, --native",descriptions.native).option("-o, --docs",descriptions.docs).option("-s, --system-tray",descriptions.systemTray).option("-i, --interactive",descriptions.interactive,{default:!1}).option("-l, --with-localhost",descriptions.withLocalhost,{default:!1}).option("--browser",descriptions.browser,{default:!1}).option("--site",descriptions.site,{default:!1}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(server,options)=>{const perf=Bun.nanoseconds();process.env.STACKS_DEV_ENTRY_PATH=resolveDevelopmentEntryPath({site:options.site});const target=server||(options.frontend?"frontend":void 0)||(options.api?"api":void 0)||(options.components?"components":void 0)||(options.dashboard?"dashboard":void 0)||(options.desktop?"desktop":void 0)||(options.native?"native":void 0)||(options.systemTray||options["system-tray"]?"system-tray":void 0)||(options.docs?"docs":void 0);if(target){const serverOptions={...options},a=await actions();switch(target){case"native":await startDevelopmentServer({...serverOptions,native:!0},perf);break;case"frontend":await a.runFrontendDevServer(serverOptions);break;case"api":await a.runApiDevServer(serverOptions);break;case"components":await a.runComponentsDevServer(serverOptions);break;case"dashboard":await a.runDashboardDevServer(serverOptions);break;case"desktop":await startDevelopmentServer({...serverOptions,native:!0},perf);break;case"system-tray":await a.runSystemTrayDevServer(serverOptions);break;case"docs":await a.runDocsDevServer(serverOptions);break;default:log.error(`Unknown server: ${target}`);process.exit(ExitCode.InvalidArgument)}}else if(wantsInteractive(options)){const selectedValue=(await prompts({type:"select",name:"value",message:descriptions.select,choices:interactiveDevChoices})).value,a=await actions();if(!await dispatchInteractiveDevSelection(selectedValue,{all:()=>startDevelopmentServer(options,perf),frontend:()=>a.runFrontendDevServer(options),api:()=>a.runApiDevServer(options),dashboard:()=>a.runDashboardDevServer(options),desktop:()=>startDevelopmentServer({...options,native:!0},perf),native:()=>startDevelopmentServer({...options,native:!0},perf),components:()=>a.runComponentsDevServer(options),docs:()=>a.runDocsDevServer(options)})){log.error("Invalid option during interactive mode");process.exit(ExitCode.InvalidArgument)}}else await startDevelopmentServer(options,perf);outro("Exited",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("dev:components",descriptions.components).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{const perf=await intro("buddy dev:components"),result=await runCommand("bun run dev",{cwd:libsPath("components/stx")});if(options.verbose)log.info("buddy dev:components result",result);if(resultFailed(result)){await outro("While running the dev:components command, there was an issue",{startTime:perf,useSeconds:!0},result.error);process.exit(ExitCode.FatalError)}console.log("");await outro("Exited",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("dev:docs",descriptions.docs).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{const perf=await intro("buddy dev:docs"),result=await(await actions()).runAction(Action.DevDocs,options);if(resultFailed(result)){await outro("While running the dev:docs command, there was an issue",{startTime:perf,useSeconds:!0},result.error);process.exit(ExitCode.FatalError)}console.log("");await outro("Exited",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("dev:native",descriptions.native).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{const perf=await intro("buddy dev:native");await startDevelopmentServer({...options,native:!0},perf)});buddy.command("dev:desktop",descriptions.desktop).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{const perf=await intro("buddy dev:desktop");await startDevelopmentServer({...options,native:!0},perf)});buddy.command("dev:api",descriptions.api).option("-p, --project [project]",descriptions.project,{default:!1}).option("--no-watch-types","Skip the model/config type-regeneration watcher",{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{const a=await actions();if(options.watchTypes!==!1)a.watchTypes(options).catch((err)=>{log.warn("[dev:api] type watcher exited:",err)});await a.runApiDevServer(options)});buddy.command("dev:frontend",descriptions.frontend).alias("dev:pages").alias("dev:views").option("-p, --project [project]",descriptions.project,{default:!1}).option("--site",descriptions.site,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{process.env.STACKS_DEV_ENTRY_PATH=resolveDevelopmentEntryPath({site:options.site});await(await actions()).runFrontendDevServer(options)});buddy.command("dev:dashboard",descriptions.dashboard).alias("dev:admin").option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{await(await actions()).runDashboardDevServer(options)});buddy.command("dev:system-tray",descriptions.systemTray).alias("dev:tray").option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{await(await actions()).runSystemTrayDevServer(options)});onUnknownSubcommand(buddy,"dev")}export async function startDevelopmentServer(_options,_startTime){const options=_options,startedAt=_startTime,appUrl=process.env.APP_URL??"stacks.localhost",nativeMode=resolveDevelopmentLaunch({browser:options.browser,native:options.native,site:options.site})==="native",entryPath=resolveDevelopmentEntryPath({site:options.site});process.env.STACKS_DEV_ENTRY_PATH=entryPath;const proxyManagedExternally=process.env.STACKS_PROXY_MANAGED==="1",preferredFrontendPort=Number(process.env.PORT)||3000,preferredApiPort=Number(process.env.PORT_API)||3008,preferredDocsPort=Number(process.env.PORT_DOCS)||3006,preferredDashboardPort=Number(process.env.PORT_ADMIN)||3002;if(process.env.STACKS_DEV_NO_KILL!=="1")await cleanupStaleDevProcesses([preferredFrontendPort,preferredApiPort,preferredDocsPort,preferredDashboardPort]);const portShifts=[],claimPort=async(label,preferred)=>{if(proxyManagedExternally)return preferred;const port=await findAvailablePort(preferred);if(port!==preferred)portShifts.push(`${label} :${preferred} \u2192 :${port}`);return port},frontendPort=await claimPort("Frontend",preferredFrontendPort),apiPort=await claimPort("API",preferredApiPort),docsPort=await claimPort("Docs",preferredDocsPort),dashboardPort=await claimPort("Dashboard",preferredDashboardPort);process.env.PORT=String(frontendPort);process.env.PORT_API=String(apiPort);process.env.PORT_DOCS=String(docsPort);process.env.PORT_ADMIN=String(dashboardPort);const includeDashboard=process.env.STACKS_DEV_DASHBOARD==="1",domain=resolvePrettyDevDomain(appUrl,nativeMode),appLooksCustom=domain!==null,localhostOnly=process.env.STACKS_DEV_LOCALHOST==="1",prettyUrlsRequested=appLooksCustom&&!localhostOnly,systemAuthorized=!prettyUrlsRequested||proxyManagedExternally?!0:await canStartPrettyDevProxy(),usePrettyUrls=shouldUsePrettyDevUrls({domain,localhostOnly,proxyManagedExternally,systemAuthorized}),prettySetupRequired=prettyUrlsRequested&&!usePrettyUrls,hasCustomDomain=usePrettyUrls&&!proxyManagedExternally,displayedDomain=usePrettyUrls?domain:null,dashboardDomain=displayedDomain?`dashboard.${displayedDomain}`:null,frontendBaseUrl=displayedDomain?`https://${displayedDomain}`:`http://localhost:${frontendPort}`,frontendUrl=developmentUrl(frontendBaseUrl,entryPath),apiUrl=displayedDomain?`https://${displayedDomain}/api`:`http://localhost:${apiPort}`,docsUrl=displayedDomain?`https://${displayedDomain}/docs`:`http://localhost:${docsPort}`,dashboardUrl=dashboardDomain?`https://${dashboardDomain}`:`http://localhost:${dashboardPort}`;process.env.STACKS_PROXY_MANAGED="1";process.env.STACKS_DEV_QUIET="1";console.log();if(prettySetupRequired){console.log(` ${yellow("\u26A0")} ${yellow("Pretty URL setup required")} ${dim("- using localhost for this session")}`);console.log(` ${dim(" ")}${dim("Run `./buddy setup:ssl` once, then restart `./buddy dev`.")}`);console.log()}if(portShifts.length>0){console.log(` ${yellow("\u26A0")} ${yellow("Ports in use by another process")} ${dim(`- ${portShifts.join(", ")}`)}`);console.log()}console.log(` ${bold(cyan("stacks"))} ${dim(`v${version}`)} ${dim("starting\u2026")}`);console.log();const rpxTlsPreflight=hasCustomDomain&&domain?prepareRpxTlsForDev({domain,includeDashboard,options}):Promise.resolve();if(localhostOnly&&domain&&!proxyManagedExternally)removeStalePublicDomainOverrides(domain,includeDashboard,options.verbose??!1).catch(()=>{});rpxTlsPreflight.catch(()=>{});let isExiting=!1,closeNativeApp;const SHUTDOWN_GRACE_MS=1500,cleanup=()=>{if(isExiting)return;isExiting=!0;closeNativeApp?.();const rpxTeardown=(async()=>{try{await unregisterRpxProxies(activeRpxRegistryIds);activeRpxRegistryIds.length=0;if(hasCustomDomain&&domain)await removeStalePublicDomainOverrides(domain,includeDashboard,options.verbose??!1)}catch{}})(),teardownDeadline=new Promise((resolve)=>setTimeout(resolve,SHUTDOWN_GRACE_MS));Promise.race([rpxTeardown,teardownDeadline]).finally(()=>{try{process.kill(-process.pid,"SIGTERM")}catch{try{process.kill(0,"SIGTERM")}catch{}}setTimeout(()=>{try{process.kill(0,"SIGKILL")}catch{process.exit(1)}},SHUTDOWN_GRACE_MS).unref()})};process.on("SIGINT",cleanup);process.on("SIGTERM",cleanup);process.on("SIGHUP",cleanup);const quietOpts={...options,quiet:!0},a=await actions(),ports=[{name:"API",port:apiPort}],readinessTimeoutMs=30000;let readyAnnounced=!1;const nativeUrl=developmentUrl(`http://localhost:${frontendPort}`,entryPath),nativeWindowReady=nativeMode?waitForPort(frontendPort,readinessTimeoutMs).then(async(ready)=>{if(!ready){log.warn(`Native window skipped because the frontend did not answer within ${readinessTimeoutMs/1000}s`);return}closeNativeApp=await launchNativeAppWindow(nativeUrl,options)}):Promise.resolve();Promise.all(ports.map((p)=>waitForPort(p.port,readinessTimeoutMs))).then(async(results)=>{if(readyAnnounced)return;readyAnnounced=!0;const failed=results.map((ok,i)=>ok?null:ports[i]?.name??null).filter((x)=>x!==null);let proxyReachable=!1;if(hasCustomDomain&&domain){(async()=>{try{await rpxTlsPreflight;await registerRpxProxiesForDomain({domain,frontendPort,apiPort,docsPort,dashboardPort,includeDashboard,options})}catch{}})();proxyReachable=await waitForHttpsProxy(443,16000);if(!proxyReachable){console.log(` ${yellow("\u26A0")} ${yellow("HTTPS proxy not reachable on :443")} \u2014 serving ${cyan(`http://localhost:${frontendPort}`)} instead`);console.log(` ${dim(" ")}${dim("rpx needs a valid SUDO_PASSWORD in .env to bind :443; trust the local CA, then restart `./buddy dev`.")}`);if(options.verbose)console.log(` ${dim(" ")}${dim(`Trust CA: sh ${join(RPX_SSL_DIR,"trust-rpx-cert.sh")}`)}`)}}eraseDevBootStartingLines();printDevReadyBanner({options,nativeMode,hasCustomDomain:!!appLooksCustom&&!localhostOnly,proxyReachable:proxyManagedExternally?!0:proxyReachable,frontendUrl,frontendBaseUrl,entryPath,apiUrl,docsUrl,dashboardUrl,frontendPort,apiPort,docsPort,includeDashboard,domain,dashboardPort,dashboardDomain});if(!nativeMode){const browserUrl=hasCustomDomain&&(proxyManagedExternally||proxyReachable)?frontendUrl:developmentUrl(`http://localhost:${frontendPort}`,entryPath);openDevelopmentBrowser(browserUrl)}if(startedAt){const elapsedMs=(Bun.nanoseconds()-startedAt)/1e6,summary=failed.length?`ready in ${(elapsedMs/1000).toFixed(1)}s \u2014 ${failed.join(", ")} did not bind within ${readinessTimeoutMs/1000}s`:`ready in ${(elapsedMs/1000).toFixed(1)}s`;console.log(` ${dim(summary)}`);console.log();printDevEngineNotes();console.log()}if(process.env.STACKS_PRINT_ROUTES==="1")await printRegisteredRoutes(apiPort).catch(()=>{})}).catch(()=>{});await Promise.all([a.runFrontendDevServer(quietOpts).catch((error)=>{if(options.verbose)log.error(`Frontend: ${error}`)}),a.runApiDevServer(quietOpts).catch((error)=>{if(options.verbose)log.error(`API: ${error}`)}),a.runDocsDevServer(quietOpts).catch((error)=>{if(options.verbose)log.error(`Docs: ${error}`)}),includeDashboard?a.runDashboardDevServer(quietOpts).catch((error)=>{if(options.verbose)log.error(`Dashboard: ${error}`)}):Promise.resolve(),nativeWindowReady.catch((error)=>{if(options.verbose)log.warn(`Native window: ${error}`)})])}function isComingSoonMode(){if(existsSync(projectPath("storage/framework/coming-soon")))return!0;try{const idx=projectPath("resources/views/index.stx");return existsSync(idx)&&/siteMode\s*=\s*['"]coming-soon['"]/.test(readFileSync(idx,"utf8"))}catch{return!1}}function blogIsConfigured(){try{const dir=projectPath("content/blog");return existsSync(dir)&&readdirSync(dir).some((f)=>f.endsWith(".md"))}catch{return!1}}function printDevReadyBanner(input){const{options,nativeMode,hasCustomDomain,proxyReachable,frontendUrl,frontendBaseUrl,entryPath,apiUrl,docsUrl,dashboardUrl,frontendPort,apiPort,docsPort,includeDashboard,domain,dashboardPort,dashboardDomain}=input,verbose=options.verbose??!1,useProxy=hasCustomDomain&&proxyReachable,feUrl=useProxy?frontendUrl:developmentUrl(`http://localhost:${frontendPort}`,entryPath),feBaseUrl=useProxy?frontendBaseUrl:`http://localhost:${frontendPort}`,apUrl=useProxy?apiUrl:`http://localhost:${apiPort}`,dcUrl=useProxy?docsUrl:`http://localhost:${docsPort}`,dbUrl=useProxy?dashboardUrl:`http://localhost:${dashboardPort}`,blogUrl=`${feBaseUrl}/blog`;console.log();const frontendLabel=entryPath==="/"?"Site":"App";console.log(` ${green("\u279C")} ${bold(frontendLabel.padEnd(8))}: ${cyan(feUrl)}`);if(entryPath!=="/")console.log(` ${dim("\u279C")} ${dim("Site")}: ${dim(feBaseUrl)}`);if(nativeMode)console.log(` ${green("\u279C")} ${bold("Native")}: ${cyan(`Craft \u2192 ${feUrl}`)}`);console.log(` ${green("\u279C")} ${bold("API")}: ${cyan(apUrl)}`);console.log(` ${green("\u279C")} ${bold("Docs")}: ${cyan(dcUrl)}`);if(blogIsConfigured())console.log(` ${green("\u279C")} ${bold("Blog")}: ${cyan(blogUrl)}`);if(includeDashboard)console.log(` ${green("\u279C")} ${bold("Dashboard")}: ${cyan(dbUrl)}`);if(useProxy){console.log(` ${dim("\u279C")} ${dim("Direct")}: ${dim(`${developmentUrl(`http://localhost:${frontendPort}`,entryPath)} (bypasses the proxy)`)}`);if(includeDashboard&&dashboardDomain)console.log(` ${dim("\u279C")} ${dim("Direct")}: ${dim(`http://localhost:${dashboardPort} (dashboard, bypasses the proxy)`)}`)}if(isComingSoonMode()){console.log();console.log(` ${yellow("\u25CF")} ${bold(yellow("Coming soon mode"))} ${dim("\u2014 visitors see the holding page; bypass with the coming-soon secret.")}`)}if(verbose&&domain){console.log(` ${dim("\u279C")} ${dim("Proxy")}: ${dim(`localhost:${frontendPort} \u2192 ${domain}`)}`);console.log(` ${dim("\u279C")} ${dim("Proxy")}: ${dim(`${frontendBaseUrl}/api \u2192 localhost:${apiPort}`)}`);console.log(` ${dim("\u279C")} ${dim("Proxy")}: ${dim(`${frontendBaseUrl}/docs \u2192 localhost:${docsPort}`)}`);if(includeDashboard)console.log(` ${dim("\u279C")} ${dim("Proxy")}: ${dim(`localhost:${dashboardPort} \u2192 ${dashboardDomain}`)}`)}console.log()}function printDevEngineNotes(){const routesFile=stxPath("routes.ts"),routesLabel=relative(projectPath(),routesFile),crosswindConfig=join(projectPath(),"config/crosswind.ts");if(existsSync(crosswindConfig)||existsSync(join(projectPath(),"crosswind.config.ts")))console.log(` ${green("[Crosswind]")} ${dim("CSS engine loaded")}`);if(existsSync(routesFile))try{const routeCount=(readFileSync(routesFile,"utf8").match(/pattern:/g)??[]).length;if(routeCount>0)console.log(` ${green("[stx]")} ${dim(`Generated ${routeCount} routes \u2192 ${routesLabel}`)}`)}catch{console.log(` ${green("[stx]")} ${dim(`Routes manifest \u2192 ${routesLabel}`)}`)}}async function launchNativeAppWindow(url,options){let createApp;try{createApp=(await importCraftSdk()).createApp}catch(error){if(options.verbose)log.warn(`Native window unavailable: ${error}`);console.log(` ${dim("Native window unavailable. Install craft-native or set CRAFT_BIN to a Craft binary.")}
1
+ import{execSync}from"node:child_process";import{existsSync,readdirSync,readFileSync}from"node:fs";import readline from"node:readline";import process from"node:process";import{bold,cyan,dim,green,intro,log,onUnknownSubcommand,outro,prompts,runCommand,yellow}from"@stacksjs/cli";import{homedir}from"node:os";import{dirname,join,relative}from"node:path";import{fileURLToPath}from"node:url";import{Action}from"@stacksjs/enums";import{inspectDefaultsProvenance,libsPath,projectPath,stxPath}from"@stacksjs/path";import{ExitCode}from"@stacksjs/types";import{version}from"../../package.json";import{resultFailed}from"../result";const DEV_BOOT_STARTING_LINE_COUNT=3;function eraseDevBootStartingLines(){if(!process.stdout.isTTY)return;for(let i=0;i<DEV_BOOT_STARTING_LINE_COUNT;i++){readline.moveCursor(process.stdout,0,-1);readline.clearLine(process.stdout,0)}}let developmentRpx;function resolveRpxEntryPath(){return[projectPath("node_modules/@stacksjs/rpx/dist/index.js"),join(homedir(),"Code/Tools/rpx/packages/rpx/dist/index.js"),projectPath("pantry/@stacksjs/rpx/dist/src/index.js")].find((entry)=>existsSync(entry))??null}async function importDevelopmentRpx(){if(developmentRpx)return developmentRpx;const entry=resolveRpxEntryPath();developmentRpx=entry?await import(entry):await import("@stacksjs/rpx");return developmentRpx}const activeRpxRegistryIds=[];let _actions;async function actions(){if(!_actions)_actions=await import("@stacksjs/actions");return _actions}async function warnOnStaleFrameworkDefaults(){try{const skew=inspectDefaultsProvenance(projectPath());if(skew.status==="not-applicable"||skew.status==="current")return;if(skew.status==="stale"){log.warn(`storage/framework/defaults is from ${skew.vendored}, but @stacksjs/defaults ${skew.installed} is installed.`);log.warn("Booting the installed package instead. Run `buddy upgrade` to bring the tree up to date.");return}const{measureDefaultsDrift}=await actions(),drift=measureDefaultsDrift(projectPath(),{shallow:!0});if(!drift||drift.length===0)return;log.warn(`storage/framework/defaults does not match the installed @stacksjs/defaults ${skew.installed}.`);log.warn("The app runs the vendored copy. Run `buddy upgrade` to sync it, or `buddy doctor` for the file counts.")}catch{}}export const interactiveDevChoices=[{value:"all",title:"All"},{value:"frontend",title:"Frontend"},{value:"api",title:"Backend"},{value:"dashboard",title:"Dashboard"},{value:"desktop",title:"Desktop"},{value:"native",title:"Native App"},{value:"components",title:"Components"},{value:"docs",title:"Documentation"}];export async function dispatchInteractiveDevSelection(selection,runners){if(!Object.hasOwn(runners,selection))return!1;await runners[selection]();return!0}export function resolvePrettyDevDomain(appUrl,nativeMode=!1){if(!appUrl||nativeMode)return null;try{const hostname=new URL(/^https?:\/\//i.test(appUrl)?appUrl:`https://${appUrl}`).hostname.toLowerCase();if(hostname==="localhost"||hostname==="127.0.0.1"||hostname==="::1"||hostname==="0.0.0.0")return null;return hostname}catch{return null}}export function shouldUsePrettyDevUrls(input){return input.domain!==null&&!input.localhostOnly&&(input.proxyManagedExternally||input.systemAuthorized)}const APPLICATION_VIEW_CANDIDATES=["app/index","app","dashboard/index","dashboard","home/index","home","composer","workspace/index","workspace","feed","portal","admin","account/index","account","profile/index","profile"],SITE_LAYOUT_NAMES=new Set(["default","guest","marketing","site"]);export function normalizeDevelopmentEntryPath(path){const value=path?.trim();if(!value||value==="/")return"/";try{const parsed=new URL(value,"http://stacks.localhost");return`${parsed.pathname}${parsed.search}${parsed.hash}`}catch{return"/"}}function configuredApplicationPath(root){if(process.env.APP_PATH)return process.env.APP_PATH;try{return readFileSync(join(root,"config/app.ts"),"utf8").match(/^[ \t]*appPath\s*:\s*(['"`])([^'"`]+)\1/m)?.[2]}catch{return}}function configuredDevelopmentLaunch(root){const environmentValue=process.env.STACKS_DEV_LAUNCH?.trim().toLowerCase();if(environmentValue==="browser"||environmentValue==="native")return environmentValue;try{const value=readFileSync(join(root,"config/app.ts"),"utf8").match(/\bdevLaunch\s*:\s*(['"`])(browser|native)\1/)?.[2];return value==="browser"||value==="native"?value:void 0}catch{return}}export function resolveDevelopmentLaunch(input={}){if(input.browser||input.site)return"browser";if(input.native)return"native";const root=input.root??projectPath();return input.configuredLaunch??configuredDevelopmentLaunch(root)??"browser"}function viewSource(root,route){const base=join(root,"resources/views",route);for(const extension of["stx","vue","html"]){const path=`${base}.${extension}`;if(existsSync(path))try{return readFileSync(path,"utf8")}catch{return""}}return}function usesApplicationLayout(source){const layout=source.match(/@extends\(\s*['"]layouts\/([^'"]+)['"]\s*\)/)?.[1]??source.match(/@layout\(\s*['"]layouts\/([^'"]+)['"]\s*\)/)?.[1];if(!layout)return!1;return!SITE_LAYOUT_NAMES.has(layout.split("/").at(-1)??layout)}export function resolveDevelopmentEntryPath(input={}){if(input.site)return"/";const root=input.root??projectPath(),configuredPath=input.configuredPath??configuredApplicationPath(root);if(configuredPath)return normalizeDevelopmentEntryPath(configuredPath);const loginExists=viewSource(root,"login")!==void 0||viewSource(root,"auth/login")!==void 0,candidates=APPLICATION_VIEW_CANDIDATES.map((route)=>({route,source:viewSource(root,route)})).filter((candidate)=>candidate.source!==void 0),appCandidate=candidates.find((candidate)=>usesApplicationLayout(candidate.source))??(loginExists?candidates[0]:void 0);if(appCandidate)return normalizeDevelopmentEntryPath(`/${appCandidate.route.replace(/\/index$/,"")}`);return loginExists?"/login":"/"}export function developmentUrl(baseUrl,entryPath){const normalized=normalizeDevelopmentEntryPath(entryPath);if(normalized==="/")return baseUrl.replace(/\/$/,"");return new URL(normalized,`${baseUrl.replace(/\/$/,"")}/`).toString()}export function developmentBrowserCommand(url,platform=process.platform){if(platform==="darwin")return["open",url];if(platform==="win32")return["cmd","/c","start","",url];return["xdg-open",url]}function openDevelopmentBrowser(url){if(!process.stdout.isTTY||process.env.CI||process.env.STACKS_DEV_NO_OPEN==="1")return;try{Bun.spawn(developmentBrowserCommand(url),{stdin:"ignore",stdout:"ignore",stderr:"ignore"}).unref()}catch{}}async function canStartPrettyDevProxy(){if(await waitForHttpsProxy(443,150))return!0;try{const{authorizeSystemAccess}=await importDevelopmentRpx();return authorizeSystemAccess({interactive:!1})}catch{return!1}}export function dev(buddy){const descriptions={dev:"Start development server",frontend:"Start the frontend development server",components:"Start the Components development server",desktop:"Start the Desktop App development server",native:"Start the app in a native Craft window",dashboard:"Start the Dashboard development server",api:"Start the local API development server",docs:"Start the Documentation development server",systemTray:"Start the System Tray development server",interactive:"Get asked which development server to start",select:"Which development server are you trying to start?",withLocalhost:"Include the localhost URL in the output",browser:"Open the application in a browser instead of its configured native window",site:"Open the marketing site instead of the application",project:"Target a specific project",verbose:"Enable verbose output"};buddy.command("dev [server]",descriptions.dev).option("-f, --frontend",descriptions.frontend).option("-a, --api",descriptions.api).option("-c, --components",descriptions.components).option("-d, --dashboard",descriptions.dashboard).option("-k, --desktop",descriptions.desktop).option("-n, --native",descriptions.native).option("-o, --docs",descriptions.docs).option("-s, --system-tray",descriptions.systemTray).option("-i, --interactive",descriptions.interactive,{default:!1}).option("-l, --with-localhost",descriptions.withLocalhost,{default:!1}).option("--browser",descriptions.browser,{default:!1}).option("--site",descriptions.site,{default:!1}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(server,options)=>{const perf=Bun.nanoseconds();process.env.STACKS_DEV_ENTRY_PATH=resolveDevelopmentEntryPath({site:options.site});await warnOnStaleFrameworkDefaults();const target=server||(options.frontend?"frontend":void 0)||(options.api?"api":void 0)||(options.components?"components":void 0)||(options.dashboard?"dashboard":void 0)||(options.desktop?"desktop":void 0)||(options.native?"native":void 0)||(options.systemTray||options["system-tray"]?"system-tray":void 0)||(options.docs?"docs":void 0);if(target){const serverOptions={...options},a=await actions();switch(target){case"native":await startDevelopmentServer({...serverOptions,native:!0},perf);break;case"frontend":await a.runFrontendDevServer(serverOptions);break;case"api":await a.runApiDevServer(serverOptions);break;case"components":await a.runComponentsDevServer(serverOptions);break;case"dashboard":await a.runDashboardDevServer(serverOptions);break;case"desktop":await startDevelopmentServer({...serverOptions,native:!0},perf);break;case"system-tray":await a.runSystemTrayDevServer(serverOptions);break;case"docs":await a.runDocsDevServer(serverOptions);break;default:log.error(`Unknown server: ${target}`);process.exit(ExitCode.InvalidArgument)}}else if(wantsInteractive(options)){const selectedValue=(await prompts({type:"select",name:"value",message:descriptions.select,choices:interactiveDevChoices})).value,a=await actions();if(!await dispatchInteractiveDevSelection(selectedValue,{all:()=>startDevelopmentServer(options,perf),frontend:()=>a.runFrontendDevServer(options),api:()=>a.runApiDevServer(options),dashboard:()=>a.runDashboardDevServer(options),desktop:()=>startDevelopmentServer({...options,native:!0},perf),native:()=>startDevelopmentServer({...options,native:!0},perf),components:()=>a.runComponentsDevServer(options),docs:()=>a.runDocsDevServer(options)})){log.error("Invalid option during interactive mode");process.exit(ExitCode.InvalidArgument)}}else await startDevelopmentServer(options,perf);outro("Exited",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("dev:components",descriptions.components).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{const perf=await intro("buddy dev:components"),result=await runCommand("bun run dev",{cwd:libsPath("components/stx")});if(options.verbose)log.info("buddy dev:components result",result);if(resultFailed(result)){await outro("While running the dev:components command, there was an issue",{startTime:perf,useSeconds:!0},result.error);process.exit(ExitCode.FatalError)}console.log("");await outro("Exited",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("dev:docs",descriptions.docs).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{const perf=await intro("buddy dev:docs"),result=await(await actions()).runAction(Action.DevDocs,options);if(resultFailed(result)){await outro("While running the dev:docs command, there was an issue",{startTime:perf,useSeconds:!0},result.error);process.exit(ExitCode.FatalError)}console.log("");await outro("Exited",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("dev:native",descriptions.native).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{const perf=await intro("buddy dev:native");await startDevelopmentServer({...options,native:!0},perf)});buddy.command("dev:desktop",descriptions.desktop).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{const perf=await intro("buddy dev:desktop");await startDevelopmentServer({...options,native:!0},perf)});buddy.command("dev:api",descriptions.api).option("-p, --project [project]",descriptions.project,{default:!1}).option("--no-watch-types","Skip the model/config type-regeneration watcher",{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{const a=await actions();if(options.watchTypes!==!1)a.watchTypes(options).catch((err)=>{log.warn("[dev:api] type watcher exited:",err)});await a.runApiDevServer(options)});buddy.command("dev:frontend",descriptions.frontend).alias("dev:pages").alias("dev:views").option("-p, --project [project]",descriptions.project,{default:!1}).option("--site",descriptions.site,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{process.env.STACKS_DEV_ENTRY_PATH=resolveDevelopmentEntryPath({site:options.site});await(await actions()).runFrontendDevServer(options)});buddy.command("dev:dashboard",descriptions.dashboard).alias("dev:admin").option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{await(await actions()).runDashboardDevServer(options)});buddy.command("dev:system-tray",descriptions.systemTray).alias("dev:tray").option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{await(await actions()).runSystemTrayDevServer(options)});onUnknownSubcommand(buddy,"dev")}export async function startDevelopmentServer(_options,_startTime){const options=_options,startedAt=_startTime,appUrl=process.env.APP_URL??"stacks.localhost",nativeMode=resolveDevelopmentLaunch({browser:options.browser,native:options.native,site:options.site})==="native",entryPath=resolveDevelopmentEntryPath({site:options.site});process.env.STACKS_DEV_ENTRY_PATH=entryPath;const proxyManagedExternally=process.env.STACKS_PROXY_MANAGED==="1",preferredFrontendPort=Number(process.env.PORT)||3000,preferredApiPort=Number(process.env.PORT_API)||3008,preferredDocsPort=Number(process.env.PORT_DOCS)||3006,preferredDashboardPort=Number(process.env.PORT_ADMIN)||3002;if(process.env.STACKS_DEV_NO_KILL!=="1")await cleanupStaleDevProcesses([preferredFrontendPort,preferredApiPort,preferredDocsPort,preferredDashboardPort]);const portShifts=[],claimPort=async(label,preferred)=>{if(proxyManagedExternally)return preferred;const port=await findAvailablePort(preferred);if(port!==preferred)portShifts.push(`${label} :${preferred} \u2192 :${port}`);return port},frontendPort=await claimPort("Frontend",preferredFrontendPort),apiPort=await claimPort("API",preferredApiPort),docsPort=await claimPort("Docs",preferredDocsPort),dashboardPort=await claimPort("Dashboard",preferredDashboardPort);process.env.PORT=String(frontendPort);process.env.PORT_API=String(apiPort);process.env.PORT_DOCS=String(docsPort);process.env.PORT_ADMIN=String(dashboardPort);const includeDashboard=process.env.STACKS_DEV_DASHBOARD==="1",domain=resolvePrettyDevDomain(appUrl,nativeMode),appLooksCustom=domain!==null,localhostOnly=process.env.STACKS_DEV_LOCALHOST==="1",prettyUrlsRequested=appLooksCustom&&!localhostOnly,systemAuthorized=!prettyUrlsRequested||proxyManagedExternally?!0:await canStartPrettyDevProxy(),usePrettyUrls=shouldUsePrettyDevUrls({domain,localhostOnly,proxyManagedExternally,systemAuthorized}),prettySetupRequired=prettyUrlsRequested&&!usePrettyUrls,hasCustomDomain=usePrettyUrls&&!proxyManagedExternally,displayedDomain=usePrettyUrls?domain:null,dashboardDomain=displayedDomain?`dashboard.${displayedDomain}`:null,frontendBaseUrl=displayedDomain?`https://${displayedDomain}`:`http://localhost:${frontendPort}`,frontendUrl=developmentUrl(frontendBaseUrl,entryPath),apiUrl=displayedDomain?`https://${displayedDomain}/api`:`http://localhost:${apiPort}`,docsUrl=displayedDomain?`https://${displayedDomain}/docs`:`http://localhost:${docsPort}`,dashboardUrl=dashboardDomain?`https://${dashboardDomain}`:`http://localhost:${dashboardPort}`;process.env.STACKS_PROXY_MANAGED="1";process.env.STACKS_DEV_QUIET="1";console.log();if(prettySetupRequired){console.log(` ${yellow("\u26A0")} ${yellow("Pretty URL setup required")} ${dim("- using localhost for this session")}`);console.log(` ${dim(" ")}${dim("Run `./buddy setup:ssl` once, then restart `./buddy dev`.")}`);console.log()}if(portShifts.length>0){console.log(` ${yellow("\u26A0")} ${yellow("Ports in use by another process")} ${dim(`- ${portShifts.join(", ")}`)}`);console.log()}console.log(` ${bold(cyan("stacks"))} ${dim(`v${version}`)} ${dim("starting\u2026")}`);console.log();const rpxTlsPreflight=hasCustomDomain&&domain?prepareRpxTlsForDev({domain,includeDashboard,options}):Promise.resolve();if(localhostOnly&&domain&&!proxyManagedExternally)removeStalePublicDomainOverrides(domain,includeDashboard,options.verbose??!1).catch(()=>{});rpxTlsPreflight.catch(()=>{});let isExiting=!1,closeNativeApp;const SHUTDOWN_GRACE_MS=1500,cleanup=()=>{if(isExiting)return;isExiting=!0;closeNativeApp?.();const rpxTeardown=(async()=>{try{await unregisterRpxProxies(activeRpxRegistryIds);activeRpxRegistryIds.length=0;if(hasCustomDomain&&domain)await removeStalePublicDomainOverrides(domain,includeDashboard,options.verbose??!1)}catch{}})(),teardownDeadline=new Promise((resolve)=>setTimeout(resolve,SHUTDOWN_GRACE_MS));Promise.race([rpxTeardown,teardownDeadline]).finally(()=>{try{process.kill(-process.pid,"SIGTERM")}catch{try{process.kill(0,"SIGTERM")}catch{}}setTimeout(()=>{try{process.kill(0,"SIGKILL")}catch{process.exit(1)}},SHUTDOWN_GRACE_MS).unref()})};process.on("SIGINT",cleanup);process.on("SIGTERM",cleanup);process.on("SIGHUP",cleanup);const quietOpts={...options,quiet:!0},a=await actions(),ports=[{name:"API",port:apiPort}],readinessTimeoutMs=30000;let readyAnnounced=!1;const nativeUrl=developmentUrl(`http://localhost:${frontendPort}`,entryPath),nativeWindowReady=nativeMode?waitForPort(frontendPort,readinessTimeoutMs).then(async(ready)=>{if(!ready){log.warn(`Native window skipped because the frontend did not answer within ${readinessTimeoutMs/1000}s`);return}closeNativeApp=await launchNativeAppWindow(nativeUrl,options)}):Promise.resolve();Promise.all(ports.map((p)=>waitForPort(p.port,readinessTimeoutMs))).then(async(results)=>{if(readyAnnounced)return;readyAnnounced=!0;const failed=results.map((ok,i)=>ok?null:ports[i]?.name??null).filter((x)=>x!==null);let proxyReachable=!1;if(hasCustomDomain&&domain){(async()=>{try{await rpxTlsPreflight;await registerRpxProxiesForDomain({domain,frontendPort,apiPort,docsPort,dashboardPort,includeDashboard,options})}catch{}})();proxyReachable=await waitForHttpsProxy(443,16000);if(!proxyReachable){console.log(` ${yellow("\u26A0")} ${yellow("HTTPS proxy not reachable on :443")} \u2014 serving ${cyan(`http://localhost:${frontendPort}`)} instead`);console.log(` ${dim(" ")}${dim("rpx needs a valid SUDO_PASSWORD in .env to bind :443; trust the local CA, then restart `./buddy dev`.")}`);if(options.verbose)console.log(` ${dim(" ")}${dim(`Trust CA: sh ${join(RPX_SSL_DIR,"trust-rpx-cert.sh")}`)}`)}}eraseDevBootStartingLines();printDevReadyBanner({options,nativeMode,hasCustomDomain:!!appLooksCustom&&!localhostOnly,proxyReachable:proxyManagedExternally?!0:proxyReachable,frontendUrl,frontendBaseUrl,entryPath,apiUrl,docsUrl,dashboardUrl,frontendPort,apiPort,docsPort,includeDashboard,domain,dashboardPort,dashboardDomain});if(!nativeMode){const browserUrl=hasCustomDomain&&(proxyManagedExternally||proxyReachable)?frontendUrl:developmentUrl(`http://localhost:${frontendPort}`,entryPath);openDevelopmentBrowser(browserUrl)}if(startedAt){const elapsedMs=(Bun.nanoseconds()-startedAt)/1e6,summary=failed.length?`ready in ${(elapsedMs/1000).toFixed(1)}s \u2014 ${failed.join(", ")} did not bind within ${readinessTimeoutMs/1000}s`:`ready in ${(elapsedMs/1000).toFixed(1)}s`;console.log(` ${dim(summary)}`);console.log();printDevEngineNotes();console.log()}if(process.env.STACKS_PRINT_ROUTES==="1")await printRegisteredRoutes(apiPort).catch(()=>{})}).catch(()=>{});await Promise.all([a.runFrontendDevServer(quietOpts).catch((error)=>{if(options.verbose)log.error(`Frontend: ${error}`)}),a.runApiDevServer(quietOpts).catch((error)=>{if(options.verbose)log.error(`API: ${error}`)}),a.runDocsDevServer(quietOpts).catch((error)=>{if(options.verbose)log.error(`Docs: ${error}`)}),includeDashboard?a.runDashboardDevServer(quietOpts).catch((error)=>{if(options.verbose)log.error(`Dashboard: ${error}`)}):Promise.resolve(),nativeWindowReady.catch((error)=>{if(options.verbose)log.warn(`Native window: ${error}`)})])}function isComingSoonMode(){if(existsSync(projectPath("storage/framework/coming-soon")))return!0;try{const idx=projectPath("resources/views/index.stx");return existsSync(idx)&&/siteMode\s*=\s*['"]coming-soon['"]/.test(readFileSync(idx,"utf8"))}catch{return!1}}function blogIsConfigured(){try{const dir=projectPath("content/blog");return existsSync(dir)&&readdirSync(dir).some((f)=>f.endsWith(".md"))}catch{return!1}}function printDevReadyBanner(input){const{options,nativeMode,hasCustomDomain,proxyReachable,frontendUrl,frontendBaseUrl,entryPath,apiUrl,docsUrl,dashboardUrl,frontendPort,apiPort,docsPort,includeDashboard,domain,dashboardPort,dashboardDomain}=input,verbose=options.verbose??!1,useProxy=hasCustomDomain&&proxyReachable,feUrl=useProxy?frontendUrl:developmentUrl(`http://localhost:${frontendPort}`,entryPath),feBaseUrl=useProxy?frontendBaseUrl:`http://localhost:${frontendPort}`,apUrl=useProxy?apiUrl:`http://localhost:${apiPort}`,dcUrl=useProxy?docsUrl:`http://localhost:${docsPort}`,dbUrl=useProxy?dashboardUrl:`http://localhost:${dashboardPort}`,blogUrl=`${feBaseUrl}/blog`;console.log();const frontendLabel=entryPath==="/"?"Site":"App";console.log(` ${green("\u279C")} ${bold(frontendLabel.padEnd(8))}: ${cyan(feUrl)}`);if(entryPath!=="/")console.log(` ${dim("\u279C")} ${dim("Site")}: ${dim(feBaseUrl)}`);if(nativeMode)console.log(` ${green("\u279C")} ${bold("Native")}: ${cyan(`Craft \u2192 ${feUrl}`)}`);console.log(` ${green("\u279C")} ${bold("API")}: ${cyan(apUrl)}`);console.log(` ${green("\u279C")} ${bold("Docs")}: ${cyan(dcUrl)}`);if(blogIsConfigured())console.log(` ${green("\u279C")} ${bold("Blog")}: ${cyan(blogUrl)}`);if(includeDashboard)console.log(` ${green("\u279C")} ${bold("Dashboard")}: ${cyan(dbUrl)}`);if(useProxy){console.log(` ${dim("\u279C")} ${dim("Direct")}: ${dim(`${developmentUrl(`http://localhost:${frontendPort}`,entryPath)} (bypasses the proxy)`)}`);if(includeDashboard&&dashboardDomain)console.log(` ${dim("\u279C")} ${dim("Direct")}: ${dim(`http://localhost:${dashboardPort} (dashboard, bypasses the proxy)`)}`)}if(isComingSoonMode()){console.log();console.log(` ${yellow("\u25CF")} ${bold(yellow("Coming soon mode"))} ${dim("\u2014 visitors see the holding page; bypass with the coming-soon secret.")}`)}if(verbose&&domain){console.log(` ${dim("\u279C")} ${dim("Proxy")}: ${dim(`localhost:${frontendPort} \u2192 ${domain}`)}`);console.log(` ${dim("\u279C")} ${dim("Proxy")}: ${dim(`${frontendBaseUrl}/api \u2192 localhost:${apiPort}`)}`);console.log(` ${dim("\u279C")} ${dim("Proxy")}: ${dim(`${frontendBaseUrl}/docs \u2192 localhost:${docsPort}`)}`);if(includeDashboard)console.log(` ${dim("\u279C")} ${dim("Proxy")}: ${dim(`localhost:${dashboardPort} \u2192 ${dashboardDomain}`)}`)}console.log()}function printDevEngineNotes(){const routesFile=stxPath("routes.ts"),routesLabel=relative(projectPath(),routesFile),crosswindConfig=join(projectPath(),"config/crosswind.ts");if(existsSync(crosswindConfig)||existsSync(join(projectPath(),"crosswind.config.ts")))console.log(` ${green("[Crosswind]")} ${dim("CSS engine loaded")}`);if(existsSync(routesFile))try{const routeCount=(readFileSync(routesFile,"utf8").match(/pattern:/g)??[]).length;if(routeCount>0)console.log(` ${green("[stx]")} ${dim(`Generated ${routeCount} routes \u2192 ${routesLabel}`)}`)}catch{console.log(` ${green("[stx]")} ${dim(`Routes manifest \u2192 ${routesLabel}`)}`)}}async function launchNativeAppWindow(url,options){let createApp;try{createApp=(await importCraftSdk()).createApp}catch(error){if(options.verbose)log.warn(`Native window unavailable: ${error}`);console.log(` ${dim("Native window unavailable. Install craft-native or set CRAFT_BIN to a Craft binary.")}
2
2
  `);return}if(!createApp){console.log(` ${dim("Native window unavailable. The Craft SDK did not export createApp.")}
3
3
  `);return}const craftBinaryPath=resolveCraftBinaryPath();if(!craftBinaryPath){console.log(` ${dim("Native window unavailable. Set CRAFT_BIN to a Craft binary, or install Craft in ~/Code/Tools/craft.")}
4
4
  `);return}const appIconPath=resolveNativeAppIconPath(),app=createApp({url,quiet:!options.verbose,craftPath:craftBinaryPath,window:{title:await resolveNativeAppTitle(),width:1280,height:860,titlebarHidden:!0,webSidebarMaterial:!0,webSidebarWidth:286,webSidebarMaterialOpacity:0.78,...appIconPath&&{icon:appIconPath}}});app.show().catch((error)=>{if(options.verbose)log.warn(`Native window exited: ${error}`)});return()=>app.close()}async function importCraftSdk(){const localCraftSdk=process.env.HOME?`${process.env.HOME}/Code/Tools/craft/packages/typescript/src/index.ts`:void 0;if(localCraftSdk&&existsSync(localCraftSdk))return await import(localCraftSdk);const packageNames=["craft-native","@craft-native/craft","@stacksjs/ts-craft"];let primaryError;for(const packageName of packageNames)try{return await import(packageName)}catch(error){primaryError??=error}throw primaryError}async function cleanupStaleDevProcesses(ports){const projectRoot=projectPath(),actionDevPath=projectPath("storage/framework/core/actions/src/dev/"),pids=new Set;for(const port of ports)for(const pid of await listenerPids(port)){if(pid===process.pid)continue;const command=await commandForPid(pid);if(!command.includes(projectRoot))continue;if(command.includes("storage/framework/core/buddy/src/cli.ts dev")||command.includes("storage/framework/core/actions/src/dev/")||command.includes(actionDevPath))pids.add(pid)}if(pids.size===0)return;for(const pid of pids)try{process.kill(pid,"SIGTERM")}catch{}await new Promise((r)=>setTimeout(r,250));for(const pid of pids)try{process.kill(pid,"SIGKILL")}catch{}await new Promise((r)=>setTimeout(r,150))}async function listenerPids(port){try{const proc=Bun.spawn(["lsof","-nP",`-iTCP:${port}`,"-sTCP:LISTEN","-t"],{stdout:"pipe",stderr:"ignore"}),output=await new Response(proc.stdout).text();await proc.exited;return output.split(/\s+/).map((value)=>Number(value)).filter((pid)=>Number.isInteger(pid)&&pid>0)}catch{return[]}}async function commandForPid(pid){try{const proc=Bun.spawn(["ps","-p",String(pid),"-o","command="],{stdout:"pipe",stderr:"ignore"}),output=await new Response(proc.stdout).text();await proc.exited;return output.trim()}catch{return""}}function resolveCraftBinaryPath(){const home=process.env.HOME;return[process.env.CRAFT_BIN,home?`${home}/Code/Tools/craft/craft`:void 0,home?`${home}/Code/Tools/craft/bin/craft`:void 0,home?`${home}/Code/Tools/craft/packages/zig/zig-out/bin/craft`:void 0,home?`${home}/Documents/Projects/craft/packages/zig/zig-out/bin/craft`:void 0].filter((candidate)=>Boolean(candidate)).find((candidate)=>existsSync(candidate))}function resolveNativeAppIconPath(){return[projectPath("resources/assets/images/app-icon.png"),projectPath("resources/assets/images/icon.png"),projectPath("public/icon.png")].find((candidate)=>existsSync(candidate))}async function resolveNativeAppTitle(){try{const pkg=await Bun.file(projectPath("package.json")).json(),name=typeof pkg.productName==="string"?pkg.productName:pkg.name;if(typeof name==="string"&&name.length>0)return name.split(/[-_\s]+/).filter(Boolean).map((part)=>part.charAt(0).toUpperCase()+part.slice(1)).join(" ")}catch{}return"Stacks App"}const METHOD_COLORS={GET:cyan,POST:green,PUT:cyan,PATCH:cyan,DELETE:cyan,OPTIONS:dim};async function printRegisteredRoutes(apiPort){try{const ac=new AbortController,t=setTimeout(()=>ac.abort(),1500),res=await fetch(`http://127.0.0.1:${apiPort}/__routes`,{signal:ac.signal}).catch(()=>null);clearTimeout(t);if(!res||!res.ok)return;const routes=await res.json();if(!Array.isArray(routes)||routes.length===0)return;const sorted=[...routes].sort((a,b)=>a.path.localeCompare(b.path)||a.method.localeCompare(b.method)),longestPath=Math.max(...sorted.map((r)=>r.path.length));console.log(` ${dim("Registered routes")}
@@ -1,2 +1,2 @@
1
- import{lstatSync,readlinkSync}from"node:fs";import{resolve}from"node:path";import process from"node:process";import{bold,dim,green,intro,log,onUnknownSubcommand,red,yellow}from"@stacksjs/cli";import{feature}from"@stacksjs/config";import{storage}from"@stacksjs/storage";import{isSupportedBunVersion,isVersionGreaterThanOrEqual,minimumBunVersion}from"@stacksjs/utils";import{FEATURE_NAMES,featurePathsPresent}from"./features";const minimumSqliteVersion="3.47.2";class ProbeWarning extends Error{}async function probe(checks,name,fn,timeoutMs=2000){const start=Date.now(),ac=new AbortController,timer=setTimeout(()=>ac.abort(),timeoutMs);try{const message=await Promise.race([fn(),new Promise((_,rej)=>ac.signal.addEventListener("abort",()=>rej(Error(`timed out (>${Math.round(timeoutMs/1000)}s)`))))]);checks.push({name,status:"pass",message:`${message} (${Date.now()-start}ms)`})}catch(err){checks.push({name,status:err instanceof ProbeWarning?"warn":"fail",message:err instanceof Error?err.message:String(err)})}finally{clearTimeout(timer)}}export function doctor(buddy){buddy.command("doctor","Run health checks on your Stacks installation").option("--no-fail","Print results but always exit 0 (CI ramp-up)").action(async(options)=>{log.debug("Running `buddy doctor` ...");await intro("buddy doctor");const checks=[],modulesPath=resolve(process.cwd(),"node_modules");try{if(lstatSync(modulesPath).isSymbolicLink()){const target=resolve(process.cwd(),readlinkSync(modulesPath)),insideProject=target.startsWith(`${resolve(process.cwd())}/`);checks.push({name:"Dependency tree",status:insideProject?"warn":"fail",message:insideProject?`node_modules is a symlink to ${target}`:`node_modules is a symlink to ${target}, which belongs to another project \u2014 installs here mutate it, and what runs locally is not what the lockfile resolves. Remove the link and run \`bun install\`.`})}else checks.push({name:"Dependency tree",status:"pass",message:"node_modules is this project's own"})}catch{checks.push({name:"Dependency tree",status:"warn",message:"node_modules is missing \u2014 run `bun install`"})}const bunVersion=process.versions.bun;if(bunVersion&&isSupportedBunVersion(bunVersion))checks.push({name:"Bun Runtime",status:"pass",message:`v${bunVersion}`});else if(bunVersion)checks.push({name:"Bun Runtime",status:"fail",message:`v${bunVersion} (requires v${minimumBunVersion} or later, run: bun upgrade)`});else checks.push({name:"Bun Runtime",status:"fail",message:"Not found"});const nodeVersion=process.versions.node;if(nodeVersion)if(Number.parseInt(nodeVersion.split(".")[0]||"0",10)>=18)checks.push({name:"Node.js",status:"pass",message:`v${nodeVersion}`});else checks.push({name:"Node.js",status:"warn",message:`v${nodeVersion} (v18+ recommended)`});try{const pkg=await storage.readPackageJson("./package.json");if(pkg.name)checks.push({name:"package.json",status:"pass",message:`Found: ${pkg.name}`})}catch{checks.push({name:"package.json",status:"fail",message:"Not found in current directory"})}try{await storage.readTextFile(".env");checks.push({name:".env file",status:"pass",message:"Found"})}catch{checks.push({name:".env file",status:"warn",message:"Not found (optional)"})}const appKey=process.env.APP_KEY;if(appKey&&appKey.length>0)checks.push({name:"APP_KEY",status:appKey.length>=32?"pass":"warn",message:appKey.length>=32?"Set (\u226532 chars)":`Set but short (${appKey.length} chars; \u226532 recommended)`});else checks.push({name:"APP_KEY",status:"fail",message:"Not set \u2014 features that depend on it (encrypted columns, signed URLs, env decryption) will refuse to run. Run `buddy key:generate`."});await probe(checks,"SQLite Engine",async()=>{const{Database}=await import("bun:sqlite"),memory=new Database(":memory:");let version;try{const row=memory.query("SELECT sqlite_version() AS version").get();if(typeof row?.version==="string")version=row.version}finally{memory.close()}if(!version)throw new ProbeWarning("could not read sqlite_version() (skipped)");if(!isVersionGreaterThanOrEqual(version,minimumSqliteVersion))throw Error(`v${version} is below the required v${minimumSqliteVersion} (system.sqlite in package.json); upgrade Bun for a newer bundled SQLite`);return`v${version}`});await probe(checks,"Database",async()=>{const{db}=await import("@stacksjs/database"),unsafe=db.unsafe;if(typeof unsafe!=="function")throw new ProbeWarning("driver exposes no raw query method (skipped)");const statement=unsafe("SELECT 1");if(!statement||typeof statement.then!=="function"&&typeof statement.execute!=="function")throw new ProbeWarning("driver returned a non-executable statement (skipped)");const result=typeof statement.execute==="function"?await statement.execute():await statement;if(result===void 0||result===null)throw Error("probe query returned no result");if(Array.isArray(result)&&result.length===0)throw Error("probe query returned no rows");return"Reachable"});await probe(checks,"Database FKs",async()=>{const{auditForeignKeys}=await import("@stacksjs/database"),result=await auditForeignKeys();if(result.declared.length===0)return"No belongsTo declarations";if(result.missing.length===0)return`${result.declared.length} declared FKs all present`;const sample=result.missing.slice(0,5).map((fk)=>`${fk.fromTable}.${fk.fromColumn} \u2192 ${fk.toTable}.${fk.toColumn}`).join(", "),more=result.missing.length>5?` (+${result.missing.length-5} more)`:"";throw Error(`${result.missing.length}/${result.declared.length} declared FKs missing from live schema: ${sample}${more}. Run \`buddy migrate:fresh\` (will reset data) or \`buddy migrate\` against a clean DB.`)});await probe(checks,"Unique indexes",async()=>{const{auditUniqueIndexes}=await import("@stacksjs/database"),result=await auditUniqueIndexes();if(!result.supported)return"Dialect not audited (skipped)";const skipped=result.skippedTables.length>0?`, ${result.skippedTables.length} tables skipped (not migrated)`:"";if(result.missing.length===0)return`${result.declared.length} declared unique constraints all indexed${skipped}`;const sample=result.missing.slice(0,5).map((u)=>`${u.table}.${u.columns.join("+")}`).join(", "),more=result.missing.length>5?` (+${result.missing.length-5} more)`:"",first=result.missing[0];if(!first)throw Error("Unique-index audit reported missing entries without details");const example=`CREATE UNIQUE INDEX IF NOT EXISTS "${first.table}_${first.columns.join("_")}_unique" ON "${first.table}" ("${first.columns.join('", "')}")`;throw Error(`${result.missing.length}/${result.declared.length} declared unique constraints have no UNIQUE index: ${sample}${more}. Run \`buddy migrate\` (re-queues missing unique-index migrations, #1952) \u2014 dedupe duplicate rows first or migrate hard-fails; if no migration file exists run \`buddy generate:migrations\`, or create manually: ${example}`)},1e4);await probe(checks,"Migration ledger",async()=>{const{auditMigrationLedger}=await import("@stacksjs/database"),result=await auditMigrationLedger();if(!result.supported)return"Dialect not audited (skipped)";if(result.entries.length===0)return"No migration files";const{counts,orphans}=result;if(!result.drift)return`${result.entries.length} migrations, ledger consistent with the schema`;const parts=[];if(counts.stranded>0)parts.push(`${counts.stranded} applied but unrecorded (would re-run)`);if(counts.partial>0)parts.push(`${counts.partial} half-applied`);if(counts.reverted>0)parts.push(`${counts.reverted} recorded but missing from the schema`);if(orphans.length>0)parts.push(`${orphans.length} ledger row(s) with no file on disk`);throw Error(`Migration ledger has drifted: ${parts.join(", ")}. Inspect with \`buddy migrate:status\`, repair with \`buddy migrate:status --reconcile\`.`)},1e4);await probe(checks,"FK orphans",async()=>{const{findFkOrphans}=await import("@stacksjs/database"),result=await findFkOrphans();if(!result.supported)return"Dialect not audited (skipped)";if(result.total===0)return"No orphan rows (PRAGMA foreign_key_check clean)";const sample=result.orphans.slice(0,5).map((o)=>`${o.table}.${o.column} \u2192 ${o.parent} (${o.count})`).join(", "),more=result.orphans.length>5?` (+${result.orphans.length-5} more)`:"",first=result.orphans[0];if(!first)throw Error("Foreign-key audit reported orphan rows without details");throw Error(`${result.total} orphan rows violate FKs: ${sample}${more}. Legacy rows written under foreign_keys=OFF (#1951). Review and clean manually, e.g. DELETE FROM ${first.table} WHERE ${first.column} IS NOT NULL AND ${first.column} NOT IN (SELECT id FROM ${first.parent}) \u2014 doctor never deletes data.`)},1e4);await probe(checks,"Cache",async()=>{const{cache}=await import("@stacksjs/cache"),k=`__doctor_${Date.now()}`;await cache.set(k,1,5);const v=await cache.get(k);await cache.del(k);if(v!==1)throw Error("round-trip failed");return"Round-trip ok"});await probe(checks,"Queue driver",async()=>{const{config}=await import("@stacksjs/config");return`Driver: ${config.queue?.default??"sync"}`});await probe(checks,"Mail driver",async()=>{const{config}=await import("@stacksjs/config");return`Driver: ${config.email?.default??"log"}`});{const{ensureRuntimeDirectories,projectPath}=await import("@stacksjs/path"),leftover=ensureRuntimeDirectories().filter((entry)=>!entry.cleared);checks.push(leftover.length===0?{name:"Runtime directories",status:"pass",message:"All under storage/"}:{name:"Runtime directories",status:"warn",message:`Still in the project root: ${leftover.map((entry)=>entry.legacy.slice(projectPath().length+1)).join(", ")} \u2014 remove them so state stays under storage/`})}const hasAwsCreds=Boolean(process.env.AWS_ACCESS_KEY_ID&&process.env.AWS_SECRET_ACCESS_KEY),hasAwsRole=Boolean(process.env.AWS_PROFILE)||Boolean(process.env.AWS_ROLE_ARN);if(hasAwsCreds||hasAwsRole)checks.push({name:"AWS credentials",status:"pass",message:hasAwsCreds?"Static keys in env":"IAM role configured"});else checks.push({name:"AWS credentials",status:"warn",message:"Not configured (cloud / SES / S3 commands will fail)"});await probe(checks,".env decryption",async()=>{const fs=await import("node:fs");if(!fs.existsSync(".env"))return"No .env (skipped)";const content=fs.readFileSync(".env","utf8");if(!/(?:^|=)(?:enc|encrypted):/m.test(content))return"No encrypted values";const privateKey=process.env.DOTENV_PRIVATE_KEY;if(!privateKey)throw Error("Encrypted values present but DOTENV_PRIVATE_KEY is unset");const{parse}=await import("@stacksjs/env"),{errors}=parse(content,{privateKey});if(errors.length>0)throw Error(errors.join("; "));return"All encrypted values decrypt cleanly"});try{const{filesystems}=await import("@stacksjs/config"),driver=filesystems.driver??"local";if(driver==="s3"){const bucket=process.env.S3_BUCKET??filesystems.s3?.bucket,accessKeyId=process.env.AWS_ACCESS_KEY_ID,secretAccessKey=process.env.AWS_SECRET_ACCESS_KEY,missing=[];if(!bucket)missing.push("S3_BUCKET");if(!accessKeyId)missing.push("AWS_ACCESS_KEY_ID");if(!secretAccessKey)missing.push("AWS_SECRET_ACCESS_KEY");if(missing.length>0)checks.push({name:"Storage credentials",status:"warn",message:`Default disk is 's3' but missing: ${missing.join(", ")}. Uploads will throw on first use.`});else checks.push({name:"Storage credentials",status:"pass",message:"s3 default disk has bucket + AWS credentials"})}else checks.push({name:"Storage credentials",status:"pass",message:`Default disk is '${driver}' (no remote credentials required)`})}catch(err){checks.push({name:"Storage credentials",status:"warn",message:`Could not audit storage config: ${err instanceof Error?err.message:String(err)}`})}try{if(process.platform!=="darwin")checks.push({name:"Dev domains (rpx)",status:"pass",message:"Not macOS (skipped)"});else{const fs=await import("node:fs"),os=await import("node:os"),path=await import("node:path"),isAlive=(pid)=>{try{process.kill(pid,0);return!0}catch(err){return err.code==="EPERM"}},registryDir=path.join(os.homedir(),".stacks","rpx","registry.d"),registered=new Set,deadRegistryFiles=[];if(fs.existsSync(registryDir))for(const file of fs.readdirSync(registryDir)){if(!file.endsWith(".json"))continue;try{const entry=JSON.parse(fs.readFileSync(path.join(registryDir,file),"utf8"));if(entry.to&&(entry.pid===void 0||isAlive(entry.pid)))registered.add(entry.to.toLowerCase());if(typeof entry.pid==="number"&&!isAlive(entry.pid))deadRegistryFiles.push(file)}catch{}}const staleHosts=new Set,staleResolvers=[],hostsPath="/etc/hosts",hostsLines=fs.existsSync(hostsPath)?fs.readFileSync(hostsPath,"utf8").split(`
1
+ import{lstatSync,readlinkSync}from"node:fs";import{resolve}from"node:path";import process from"node:process";import{bold,dim,green,intro,log,onUnknownSubcommand,red,yellow}from"@stacksjs/cli";import{feature}from"@stacksjs/config";import{inspectDefaultsProvenance}from"@stacksjs/path";import{storage}from"@stacksjs/storage";import{isSupportedBunVersion,isVersionGreaterThanOrEqual,minimumBunVersion}from"@stacksjs/utils";import{FEATURE_NAMES,featurePathsPresent}from"./features";const minimumSqliteVersion="3.47.2";class ProbeWarning extends Error{}async function probe(checks,name,fn,timeoutMs=2000){const start=Date.now(),ac=new AbortController,timer=setTimeout(()=>ac.abort(),timeoutMs);try{const message=await Promise.race([fn(),new Promise((_,rej)=>ac.signal.addEventListener("abort",()=>rej(Error(`timed out (>${Math.round(timeoutMs/1000)}s)`))))]);checks.push({name,status:"pass",message:`${message} (${Date.now()-start}ms)`})}catch(err){checks.push({name,status:err instanceof ProbeWarning?"warn":"fail",message:err instanceof Error?err.message:String(err)})}finally{clearTimeout(timer)}}export function doctor(buddy){buddy.command("doctor","Run health checks on your Stacks installation").option("--no-fail","Print results but always exit 0 (CI ramp-up)").action(async(options)=>{log.debug("Running `buddy doctor` ...");await intro("buddy doctor");const checks=[],modulesPath=resolve(process.cwd(),"node_modules");try{if(lstatSync(modulesPath).isSymbolicLink()){const target=resolve(process.cwd(),readlinkSync(modulesPath)),insideProject=target.startsWith(`${resolve(process.cwd())}/`);checks.push({name:"Dependency tree",status:insideProject?"warn":"fail",message:insideProject?`node_modules is a symlink to ${target}`:`node_modules is a symlink to ${target}, which belongs to another project \u2014 installs here mutate it, and what runs locally is not what the lockfile resolves. Remove the link and run \`bun install\`.`})}else checks.push({name:"Dependency tree",status:"pass",message:"node_modules is this project's own"})}catch{checks.push({name:"Dependency tree",status:"warn",message:"node_modules is missing \u2014 run `bun install`"})}await probe(checks,"Framework defaults",async()=>{const{measureDefaultsDrift,summarizeStructureChanges}=await import("@stacksjs/actions"),root=resolve(process.cwd()),skew=inspectDefaultsProvenance(root);if(skew.status==="not-applicable")return"Not a package-managed project (nothing to compare)";const drift=measureDefaultsDrift(root)??[],synced=skew.syncedAt?`, synced ${skew.syncedAt.slice(0,10)}`:"";if(drift.length===0)return`Vendored tree matches @stacksjs/defaults ${skew.installed}${synced}`;const counts=summarizeStructureChanges(drift);if(skew.status==="stale")throw new ProbeWarning(`storage/framework/defaults is from ${skew.vendored} while @stacksjs/defaults ${skew.installed} is installed (${counts}). Boot resolves the package, so the tree on disk is not what runs. Run \`buddy upgrade\` to sync it.`);throw new ProbeWarning(`storage/framework/defaults differs from the installed @stacksjs/defaults ${skew.installed} (${counts}), and carries no record of what it was synced from. The app runs the vendored copy. Run \`buddy upgrade\` to sync it.`)},8000);const bunVersion=process.versions.bun;if(bunVersion&&isSupportedBunVersion(bunVersion))checks.push({name:"Bun Runtime",status:"pass",message:`v${bunVersion}`});else if(bunVersion)checks.push({name:"Bun Runtime",status:"fail",message:`v${bunVersion} (requires v${minimumBunVersion} or later, run: bun upgrade)`});else checks.push({name:"Bun Runtime",status:"fail",message:"Not found"});const nodeVersion=process.versions.node;if(nodeVersion)if(Number.parseInt(nodeVersion.split(".")[0]||"0",10)>=18)checks.push({name:"Node.js",status:"pass",message:`v${nodeVersion}`});else checks.push({name:"Node.js",status:"warn",message:`v${nodeVersion} (v18+ recommended)`});try{const pkg=await storage.readPackageJson("./package.json");if(pkg.name)checks.push({name:"package.json",status:"pass",message:`Found: ${pkg.name}`})}catch{checks.push({name:"package.json",status:"fail",message:"Not found in current directory"})}try{await storage.readTextFile(".env");checks.push({name:".env file",status:"pass",message:"Found"})}catch{checks.push({name:".env file",status:"warn",message:"Not found (optional)"})}const appKey=process.env.APP_KEY;if(appKey&&appKey.length>0)checks.push({name:"APP_KEY",status:appKey.length>=32?"pass":"warn",message:appKey.length>=32?"Set (\u226532 chars)":`Set but short (${appKey.length} chars; \u226532 recommended)`});else checks.push({name:"APP_KEY",status:"fail",message:"Not set \u2014 features that depend on it (encrypted columns, signed URLs, env decryption) will refuse to run. Run `buddy key:generate`."});await probe(checks,"SQLite Engine",async()=>{const{Database}=await import("bun:sqlite"),memory=new Database(":memory:");let version;try{const row=memory.query("SELECT sqlite_version() AS version").get();if(typeof row?.version==="string")version=row.version}finally{memory.close()}if(!version)throw new ProbeWarning("could not read sqlite_version() (skipped)");if(!isVersionGreaterThanOrEqual(version,minimumSqliteVersion))throw Error(`v${version} is below the required v${minimumSqliteVersion} (system.sqlite in package.json); upgrade Bun for a newer bundled SQLite`);return`v${version}`});await probe(checks,"Database",async()=>{const{db}=await import("@stacksjs/database"),unsafe=db.unsafe;if(typeof unsafe!=="function")throw new ProbeWarning("driver exposes no raw query method (skipped)");const statement=unsafe("SELECT 1");if(!statement||typeof statement.then!=="function"&&typeof statement.execute!=="function")throw new ProbeWarning("driver returned a non-executable statement (skipped)");const result=typeof statement.execute==="function"?await statement.execute():await statement;if(result===void 0||result===null)throw Error("probe query returned no result");if(Array.isArray(result)&&result.length===0)throw Error("probe query returned no rows");return"Reachable"});await probe(checks,"Database FKs",async()=>{const{auditForeignKeys}=await import("@stacksjs/database"),result=await auditForeignKeys();if(result.declared.length===0)return"No belongsTo declarations";if(result.missing.length===0)return`${result.declared.length} declared FKs all present`;const sample=result.missing.slice(0,5).map((fk)=>`${fk.fromTable}.${fk.fromColumn} \u2192 ${fk.toTable}.${fk.toColumn}`).join(", "),more=result.missing.length>5?` (+${result.missing.length-5} more)`:"";throw Error(`${result.missing.length}/${result.declared.length} declared FKs missing from live schema: ${sample}${more}. Run \`buddy migrate:fresh\` (will reset data) or \`buddy migrate\` against a clean DB.`)});await probe(checks,"Unique indexes",async()=>{const{auditUniqueIndexes}=await import("@stacksjs/database"),result=await auditUniqueIndexes();if(!result.supported)return"Dialect not audited (skipped)";const skipped=result.skippedTables.length>0?`, ${result.skippedTables.length} tables skipped (not migrated)`:"";if(result.missing.length===0)return`${result.declared.length} declared unique constraints all indexed${skipped}`;const sample=result.missing.slice(0,5).map((u)=>`${u.table}.${u.columns.join("+")}`).join(", "),more=result.missing.length>5?` (+${result.missing.length-5} more)`:"",first=result.missing[0];if(!first)throw Error("Unique-index audit reported missing entries without details");const example=`CREATE UNIQUE INDEX IF NOT EXISTS "${first.table}_${first.columns.join("_")}_unique" ON "${first.table}" ("${first.columns.join('", "')}")`;throw Error(`${result.missing.length}/${result.declared.length} declared unique constraints have no UNIQUE index: ${sample}${more}. Run \`buddy migrate\` (re-queues missing unique-index migrations, #1952) \u2014 dedupe duplicate rows first or migrate hard-fails; if no migration file exists run \`buddy generate:migrations\`, or create manually: ${example}`)},1e4);await probe(checks,"Migration ledger",async()=>{const{auditMigrationLedger}=await import("@stacksjs/database"),result=await auditMigrationLedger();if(!result.supported)return"Dialect not audited (skipped)";if(result.entries.length===0)return"No migration files";const{counts,orphans}=result;if(!result.drift)return`${result.entries.length} migrations, ledger consistent with the schema`;const parts=[];if(counts.stranded>0)parts.push(`${counts.stranded} applied but unrecorded (would re-run)`);if(counts.partial>0)parts.push(`${counts.partial} half-applied`);if(counts.reverted>0)parts.push(`${counts.reverted} recorded but missing from the schema`);if(orphans.length>0)parts.push(`${orphans.length} ledger row(s) with no file on disk`);throw Error(`Migration ledger has drifted: ${parts.join(", ")}. Inspect with \`buddy migrate:status\`, repair with \`buddy migrate:status --reconcile\`.`)},1e4);await probe(checks,"FK orphans",async()=>{const{findFkOrphans}=await import("@stacksjs/database"),result=await findFkOrphans();if(!result.supported)return"Dialect not audited (skipped)";if(result.total===0)return"No orphan rows (PRAGMA foreign_key_check clean)";const sample=result.orphans.slice(0,5).map((o)=>`${o.table}.${o.column} \u2192 ${o.parent} (${o.count})`).join(", "),more=result.orphans.length>5?` (+${result.orphans.length-5} more)`:"",first=result.orphans[0];if(!first)throw Error("Foreign-key audit reported orphan rows without details");throw Error(`${result.total} orphan rows violate FKs: ${sample}${more}. Legacy rows written under foreign_keys=OFF (#1951). Review and clean manually, e.g. DELETE FROM ${first.table} WHERE ${first.column} IS NOT NULL AND ${first.column} NOT IN (SELECT id FROM ${first.parent}) \u2014 doctor never deletes data.`)},1e4);await probe(checks,"Cache",async()=>{const{cache}=await import("@stacksjs/cache"),k=`__doctor_${Date.now()}`;await cache.set(k,1,5);const v=await cache.get(k);await cache.del(k);if(v!==1)throw Error("round-trip failed");return"Round-trip ok"});await probe(checks,"Queue driver",async()=>{const{config}=await import("@stacksjs/config");return`Driver: ${config.queue?.default??"sync"}`});await probe(checks,"Mail driver",async()=>{const{config}=await import("@stacksjs/config");return`Driver: ${config.email?.default??"log"}`});{const{ensureRuntimeDirectories,projectPath}=await import("@stacksjs/path"),leftover=ensureRuntimeDirectories().filter((entry)=>!entry.cleared);checks.push(leftover.length===0?{name:"Runtime directories",status:"pass",message:"All under storage/"}:{name:"Runtime directories",status:"warn",message:`Still in the project root: ${leftover.map((entry)=>entry.legacy.slice(projectPath().length+1)).join(", ")} \u2014 remove them so state stays under storage/`})}const hasAwsCreds=Boolean(process.env.AWS_ACCESS_KEY_ID&&process.env.AWS_SECRET_ACCESS_KEY),hasAwsRole=Boolean(process.env.AWS_PROFILE)||Boolean(process.env.AWS_ROLE_ARN);if(hasAwsCreds||hasAwsRole)checks.push({name:"AWS credentials",status:"pass",message:hasAwsCreds?"Static keys in env":"IAM role configured"});else checks.push({name:"AWS credentials",status:"warn",message:"Not configured (cloud / SES / S3 commands will fail)"});await probe(checks,".env decryption",async()=>{const fs=await import("node:fs");if(!fs.existsSync(".env"))return"No .env (skipped)";const content=fs.readFileSync(".env","utf8");if(!/(?:^|=)(?:enc|encrypted):/m.test(content))return"No encrypted values";const privateKey=process.env.DOTENV_PRIVATE_KEY;if(!privateKey)throw Error("Encrypted values present but DOTENV_PRIVATE_KEY is unset");const{parse}=await import("@stacksjs/env"),{errors}=parse(content,{privateKey});if(errors.length>0)throw Error(errors.join("; "));return"All encrypted values decrypt cleanly"});try{const{filesystems}=await import("@stacksjs/config"),driver=filesystems.driver??"local";if(driver==="s3"){const bucket=process.env.S3_BUCKET??filesystems.s3?.bucket,accessKeyId=process.env.AWS_ACCESS_KEY_ID,secretAccessKey=process.env.AWS_SECRET_ACCESS_KEY,missing=[];if(!bucket)missing.push("S3_BUCKET");if(!accessKeyId)missing.push("AWS_ACCESS_KEY_ID");if(!secretAccessKey)missing.push("AWS_SECRET_ACCESS_KEY");if(missing.length>0)checks.push({name:"Storage credentials",status:"warn",message:`Default disk is 's3' but missing: ${missing.join(", ")}. Uploads will throw on first use.`});else checks.push({name:"Storage credentials",status:"pass",message:"s3 default disk has bucket + AWS credentials"})}else checks.push({name:"Storage credentials",status:"pass",message:`Default disk is '${driver}' (no remote credentials required)`})}catch(err){checks.push({name:"Storage credentials",status:"warn",message:`Could not audit storage config: ${err instanceof Error?err.message:String(err)}`})}try{if(process.platform!=="darwin")checks.push({name:"Dev domains (rpx)",status:"pass",message:"Not macOS (skipped)"});else{const fs=await import("node:fs"),os=await import("node:os"),path=await import("node:path"),isAlive=(pid)=>{try{process.kill(pid,0);return!0}catch(err){return err.code==="EPERM"}},registryDir=path.join(os.homedir(),".stacks","rpx","registry.d"),registered=new Set,deadRegistryFiles=[];if(fs.existsSync(registryDir))for(const file of fs.readdirSync(registryDir)){if(!file.endsWith(".json"))continue;try{const entry=JSON.parse(fs.readFileSync(path.join(registryDir,file),"utf8"));if(entry.to&&(entry.pid===void 0||isAlive(entry.pid)))registered.add(entry.to.toLowerCase());if(typeof entry.pid==="number"&&!isAlive(entry.pid))deadRegistryFiles.push(file)}catch{}}const staleHosts=new Set,staleResolvers=[],hostsPath="/etc/hosts",hostsLines=fs.existsSync(hostsPath)?fs.readFileSync(hostsPath,"utf8").split(`
2
2
  `):[];for(let i=0;i<hostsLines.length;i++){const line=hostsLines[i];if(line.trim()==="# Added by rpx"){for(let j=i+1;j<hostsLines.length;j++){const blockLine=hostsLines[j].trim();if(blockLine===""||blockLine.startsWith("#"))break;const names=blockLine.split("#")[0]?.trim().split(/\s+/).slice(1)??[];for(const name of names)if(!registered.has(name.toLowerCase()))staleHosts.add(name)}continue}const hash=line.indexOf("#");if(hash===-1)continue;const marker=/^rpx(?::pid=(\d+))?$/.exec(line.slice(hash+1).trim());if(!marker)continue;const names=line.slice(0,hash).trim().split(/\s+/).slice(1),pid=marker[1]?Number.parseInt(marker[1],10):null;if(pid!==null?!isAlive(pid):names.every((n)=>!registered.has(n.toLowerCase())))for(const name of names)staleHosts.add(name)}const resolverDir="/etc/resolver";if(fs.existsSync(resolverDir))for(const file of fs.readdirSync(resolverDir))try{const content=fs.readFileSync(path.join(resolverDir,file),"utf8");if(!content.includes("127.0.0.1")||!content.includes("15353"))continue;const domain=file.toLowerCase();if(![...registered].some((host)=>host===domain||host.endsWith(`.${domain}`)))staleResolvers.push(file)}catch{}if(staleHosts.size>0||staleResolvers.length>0||deadRegistryFiles.length>0){const parts=[];if(staleHosts.size>0)parts.push(`hosts(${[...staleHosts].join(", ")})`);if(staleResolvers.length>0)parts.push(`resolver(${staleResolvers.join(", ")})`);if(deadRegistryFiles.length>0)parts.push(`registry(${deadRegistryFiles.join(", ")})`);checks.push({name:"Dev domains (rpx)",status:"warn",message:`Stale loopback overrides from dead dev sessions: ${parts.join(" ")}. These keep pointing the domain at 127.0.0.1. Remove with: sudo nano /etc/hosts; sudo rm /etc/resolver/<name>; rm ~/.stacks/rpx/registry.d/<file>. Updating @stacksjs/rpx lets the daemon sweep pid-stamped entries automatically.`})}else checks.push({name:"Dev domains (rpx)",status:"pass",message:"No stale dev-domain overrides"})}}catch(err){checks.push({name:"Dev domains (rpx)",status:"warn",message:`Could not audit dev-domain overrides: ${err instanceof Error?err.message:String(err)}`})}await probe(checks,"Dev ports",async()=>{const net=await import("node:net"),{config}=await import("@stacksjs/config"),configured=config.ports??{},targets=[{name:"frontend",key:"frontend",envVar:"PORT",fallback:3000},{name:"api",key:"api",envVar:"PORT_API",fallback:3008},{name:"docs",key:"docs",envVar:"PORT_DOCS",fallback:3006},{name:"dashboard",key:"admin",envVar:"PORT_ADMIN",fallback:3002}].map((t)=>({...t,port:Number(configured[t.key])||t.fallback})),canConnect=(port,host)=>new Promise((resolve)=>{const socket=net.createConnection({port,host});socket.setTimeout(400);const done=(occupied)=>{socket.destroy();resolve(occupied)};socket.once("connect",()=>done(!0));socket.once("timeout",()=>done(!1));socket.once("error",()=>done(!1))}),occupied=new Set;await Promise.all([...new Set(targets.map((t)=>t.port))].map(async(port)=>{if(await canConnect(port,"127.0.0.1")||await canConnect(port,"::1"))occupied.add(port)}));const busy=targets.filter((t)=>occupied.has(t.port));if(busy.length>0){const list=busy.map((t)=>`${t.name} :${t.port} (${t.envVar})`).join(", ");throw new ProbeWarning(`in use: ${list}. buddy dev will fail to bind; stop the process holding the port or set the override env var`)}return`All free: ${targets.map((t)=>`${t.name} :${t.port}`).join(", ")}`});try{const orphans=[];for(const name of FEATURE_NAMES){if(feature(name))continue;const present=featurePathsPresent(name);if(present.length>0)orphans.push({feature:name,count:present.length})}if(orphans.length>0){const summary=orphans.map((o)=>`${o.feature} (${o.count} path${o.count===1?"":"s"})`).join(", ");checks.push({name:"Feature scaffolding",status:"warn",message:`Stamped files remain for disabled features: ${summary}. Run \`./buddy <feature>:uninstall\` to remove or \`<feature>:install\` to re-enable.`})}else checks.push({name:"Feature scaffolding",status:"pass",message:"No orphan files for disabled features"})}catch(err){checks.push({name:"Feature scaffolding",status:"warn",message:`Could not audit feature scaffolding: ${err instanceof Error?err.message:String(err)}`})}log.info("");log.info(bold("Health Check Results:"));log.info(dim("\u2500".repeat(60)));log.info("");let hasFailures=!1,hasWarnings=!1;for(const check of checks){let statusIcon="",statusColor=(text)=>text;if(check.status==="pass"){statusIcon="\u2713";statusColor=green}else if(check.status==="warn"){statusIcon="\u26A0";statusColor=yellow;hasWarnings=!0}else{statusIcon="\u2717";statusColor=red;hasFailures=!0}log.info(`${statusColor(statusIcon)} ${bold(check.name.padEnd(20))} ${dim(check.message)}`)}log.info("");log.info(dim("\u2500".repeat(60)));log.info("");if(hasFailures){log.error("Some critical checks failed. Please address the issues above.");if(options?.fail!==!1){await log.flush();process.exit(1)}}else if(hasWarnings)log.info(yellow("Some checks have warnings. Your system should work but may have issues."));else log.success(green("All checks passed! Your Stacks installation looks healthy."));log.info("")});onUnknownSubcommand(buddy,"doctor")}
@@ -1,8 +1,9 @@
1
- import{existsSync,mkdirSync}from"node:fs";import{cp,readdir,stat}from"node:fs/promises";import{homedir}from"node:os";import{dirname,join,resolve}from"node:path";import process from"node:process";import{italic,log,onUnknownSubcommand}from"@stacksjs/cli";import{path}from"@stacksjs/path";import{fs,globSync}from"@stacksjs/storage";import{ExitCode}from"@stacksjs/types";export function publish(buddy){const descriptions={command:"Publish a Stacks default into your userland (app/) directory so you can customize it",model:"Publish a default model from storage/framework/defaults/app/Models/ to app/Models/",controller:"Publish a default controller from storage/framework/defaults/app/Controllers/ to app/Controllers/",middleware:"Publish a default middleware from storage/framework/defaults/app/Middleware/ to app/Middleware/",action:"Publish a default action from storage/framework/defaults/app/Actions/ to app/Actions/",core:"Publish a framework package source from node_modules/@stacksjs/<pkg>/ into storage/framework/core/<pkg>/ for editing",unpublishCore:"Drop a vendored storage/framework/core/<pkg>/ and go back to the installed @stacksjs/<pkg>",all:"Unvendor the whole framework: remove storage/framework/core and resolve every @stacksjs package from the `stacks` dependency in package.json",publishAll:"Vendor the whole framework: copy storage/framework/core out of a local Stacks checkout and wire it up as a Bun workspace",frameworkPath:"The Stacks checkout to vendor from (defaults to $STACKS_FRAMEWORK_PATH, ../stacks, then ~/Code/stacks)",coreStatus:"Report whether this project runs on a vendored storage/framework/core or on the published packages",name:"The name of the resource to publish (e.g. Cart, User)",pkg:"The name of the framework package (e.g. router, orm, faker \u2014 without @stacksjs/ prefix)",force:"Overwrite an existing userland file",forceUnpublish:"Delete the vendored source even when it has uncommitted changes",verbose:"Enable verbose output"};buddy.command("publish:model <name>",descriptions.model).option("--force",descriptions.force,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(name,options)=>{await publishResource({kind:"model",name,defaultsDir:path.frameworkPath("defaults/app/Models"),userDir:path.userModelsPath(),force:!!options.force})});buddy.command("publish:controller <name>",descriptions.controller).option("--force",descriptions.force,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(name,options)=>{await publishResource({kind:"controller",name,defaultsDir:path.frameworkPath("defaults/app/Controllers"),userDir:path.userControllersPath(),force:!!options.force})});buddy.command("publish:middleware <name>",descriptions.middleware).option("--force",descriptions.force,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(name,options)=>{await publishResource({kind:"middleware",name,defaultsDir:path.frameworkPath("defaults/app/Middleware"),userDir:path.userMiddlewarePath(),force:!!options.force})});buddy.command("publish:action <name>",descriptions.action).option("--force",descriptions.force,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(name,options)=>{await publishResource({kind:"action",name,defaultsDir:path.frameworkPath("defaults/app/Actions"),userDir:path.userActionsPath(),force:!!options.force})});buddy.command("publish:core [pkg]",descriptions.core).option("--all",descriptions.publishAll,{default:!1}).option("--path <path>",descriptions.frameworkPath).option("--force",descriptions.force,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).example("buddy publish:core router").example("buddy publish:core --all").example("buddy publish:core --all --path ../stacks").action(async(pkg,options)=>{if(options.all){await vendorFramework(options.path,!!options.force);return}if(!pkg){log.error("Usage: buddy publish:core <pkg> (or --all to vendor the whole framework as a workspace)");process.exit(ExitCode.FatalError)}await publishCorePackage(pkg,!!options.force)});buddy.command("core:status",descriptions.coreStatus).alias("publish:core:status").option("--verbose",descriptions.verbose,{default:!1}).example("buddy core:status").action(async()=>{await reportCoreStatus()});buddy.command("unpublish:core [pkg]",descriptions.unpublishCore).option("--all",descriptions.all,{default:!1}).option("--force",descriptions.forceUnpublish,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(pkg,options)=>{if(options.all){await unvendorFramework(!!options.force);return}if(!pkg){log.error("Usage: buddy unpublish:core <pkg> (or --all to move the whole framework to node_modules)");process.exit(ExitCode.FatalError)}await unpublishCorePackage(pkg,!!options.force)});buddy.command("publish [resource] [name]",descriptions.command).option("--force",descriptions.force,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(resource,name,options)=>{if(!resource||!name){log.error("Usage: buddy publish:<resource> <Name> (e.g. buddy publish:model Cart)");process.exit(ExitCode.FatalError)}const handler={model:()=>publishResource({kind:"model",name,defaultsDir:path.frameworkPath("defaults/app/Models"),userDir:path.userModelsPath(),force:!!options.force}),controller:()=>publishResource({kind:"controller",name,defaultsDir:path.frameworkPath("defaults/app/Controllers"),userDir:path.userControllersPath(),force:!!options.force}),middleware:()=>publishResource({kind:"middleware",name,defaultsDir:path.frameworkPath("defaults/app/Middleware"),userDir:path.userMiddlewarePath(),force:!!options.force}),action:()=>publishResource({kind:"action",name,defaultsDir:path.frameworkPath("defaults/app/Actions"),userDir:path.userActionsPath(),force:!!options.force}),core:()=>publishCorePackage(name,!!options.force)}[resource.toLowerCase()];if(!handler){log.error(`Unknown publishable resource: ${italic(resource)}`);log.info("Available: model, controller, middleware, action, core");process.exit(ExitCode.FatalError)}await handler()});onUnknownSubcommand(buddy,"publish")}async function publishCorePackage(pkg,force){const shortName=pkg.replace(/^@stacksjs\//,"").replace(/^core\//,""),fail=(msg,hint)=>{process.stderr.write(`${msg}
1
+ import{existsSync,mkdirSync}from"node:fs";import{cp,readdir,stat}from"node:fs/promises";import{homedir}from"node:os";import{dirname,join,resolve}from"node:path";import process from"node:process";import{italic,log,onUnknownSubcommand}from"@stacksjs/cli";import{path}from"@stacksjs/path";import{fs,globSync}from"@stacksjs/storage";import{pruneVendoredCoreFromWorkflows,splitFrameworkTypecheckScript}from"../workflow-prune";import{ExitCode}from"@stacksjs/types";export function publish(buddy){const descriptions={command:"Publish a Stacks default into your userland (app/) directory so you can customize it",model:"Publish a default model from storage/framework/defaults/app/Models/ to app/Models/",controller:"Publish a default controller from storage/framework/defaults/app/Controllers/ to app/Controllers/",middleware:"Publish a default middleware from storage/framework/defaults/app/Middleware/ to app/Middleware/",action:"Publish a default action from storage/framework/defaults/app/Actions/ to app/Actions/",core:"Publish a framework package source from node_modules/@stacksjs/<pkg>/ into storage/framework/core/<pkg>/ for editing",unpublishCore:"Drop a vendored storage/framework/core/<pkg>/ and go back to the installed @stacksjs/<pkg>",all:"Unvendor the whole framework: remove storage/framework/core and resolve every @stacksjs package from the `stacks` dependency in package.json",publishAll:"Vendor the whole framework: copy storage/framework/core out of a local Stacks checkout and wire it up as a Bun workspace",frameworkPath:"The Stacks checkout to vendor from (defaults to $STACKS_FRAMEWORK_PATH, ../stacks, then ~/Code/stacks)",coreStatus:"Report whether this project runs on a vendored storage/framework/core or on the published packages",name:"The name of the resource to publish (e.g. Cart, User)",pkg:"The name of the framework package (e.g. router, orm, faker \u2014 without @stacksjs/ prefix)",force:"Overwrite an existing userland file",forceUnpublish:"Delete the vendored source even when it has uncommitted changes",verbose:"Enable verbose output"};buddy.command("publish:model <name>",descriptions.model).option("--force",descriptions.force,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(name,options)=>{await publishResource({kind:"model",name,defaultsDir:path.frameworkPath("defaults/app/Models"),userDir:path.userModelsPath(),force:!!options.force})});buddy.command("publish:controller <name>",descriptions.controller).option("--force",descriptions.force,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(name,options)=>{await publishResource({kind:"controller",name,defaultsDir:path.frameworkPath("defaults/app/Controllers"),userDir:path.userControllersPath(),force:!!options.force})});buddy.command("publish:middleware <name>",descriptions.middleware).option("--force",descriptions.force,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(name,options)=>{await publishResource({kind:"middleware",name,defaultsDir:path.frameworkPath("defaults/app/Middleware"),userDir:path.userMiddlewarePath(),force:!!options.force})});buddy.command("publish:action <name>",descriptions.action).option("--force",descriptions.force,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(name,options)=>{await publishResource({kind:"action",name,defaultsDir:path.frameworkPath("defaults/app/Actions"),userDir:path.userActionsPath(),force:!!options.force})});buddy.command("publish:core [pkg]",descriptions.core).option("--all",descriptions.publishAll,{default:!1}).option("--path <path>",descriptions.frameworkPath).option("--force",descriptions.force,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).example("buddy publish:core router").example("buddy publish:core --all").example("buddy publish:core --all --path ../stacks").action(async(pkg,options)=>{if(options.all){await vendorFramework(options.path,!!options.force);return}if(!pkg){log.error("Usage: buddy publish:core <pkg> (or --all to vendor the whole framework as a workspace)");process.exit(ExitCode.FatalError)}await publishCorePackage(pkg,!!options.force)});buddy.command("core:status",descriptions.coreStatus).alias("publish:core:status").option("--verbose",descriptions.verbose,{default:!1}).example("buddy core:status").action(async()=>{await reportCoreStatus()});buddy.command("unpublish:core [pkg]",descriptions.unpublishCore).option("--all",descriptions.all,{default:!1}).option("--force",descriptions.forceUnpublish,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(pkg,options)=>{if(options.all){await unvendorFramework(!!options.force);return}if(!pkg){log.error("Usage: buddy unpublish:core <pkg> (or --all to move the whole framework to node_modules)");process.exit(ExitCode.FatalError)}await unpublishCorePackage(pkg,!!options.force)});buddy.command("publish [resource] [name]",descriptions.command).option("--force",descriptions.force,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(resource,name,options)=>{if(!resource||!name){log.error("Usage: buddy publish:<resource> <Name> (e.g. buddy publish:model Cart)");process.exit(ExitCode.FatalError)}const handler={model:()=>publishResource({kind:"model",name,defaultsDir:path.frameworkPath("defaults/app/Models"),userDir:path.userModelsPath(),force:!!options.force}),controller:()=>publishResource({kind:"controller",name,defaultsDir:path.frameworkPath("defaults/app/Controllers"),userDir:path.userControllersPath(),force:!!options.force}),middleware:()=>publishResource({kind:"middleware",name,defaultsDir:path.frameworkPath("defaults/app/Middleware"),userDir:path.userMiddlewarePath(),force:!!options.force}),action:()=>publishResource({kind:"action",name,defaultsDir:path.frameworkPath("defaults/app/Actions"),userDir:path.userActionsPath(),force:!!options.force}),core:()=>publishCorePackage(name,!!options.force)}[resource.toLowerCase()];if(!handler){log.error(`Unknown publishable resource: ${italic(resource)}`);log.info("Available: model, controller, middleware, action, core");process.exit(ExitCode.FatalError)}await handler()});onUnknownSubcommand(buddy,"publish")}async function publishCorePackage(pkg,force){const shortName=pkg.replace(/^@stacksjs\//,"").replace(/^core\//,""),fail=(msg,hint)=>{process.stderr.write(`${msg}
2
2
  `);if(hint)process.stderr.write(` ${hint}
3
3
  `);process.exit(ExitCode.FatalError)};if(!shortName||shortName.includes("/")||shortName.includes(".."))fail(`Invalid package name: ${pkg}`,"Use a short name like `router` or the fully qualified `@stacksjs/router`.");const sourceDir=resolve(process.cwd(),"node_modules","@stacksjs",shortName),targetDir=path.frameworkPath(`core/${shortName}`);try{if(!(await stat(sourceDir)).isDirectory())fail(`${sourceDir} is not a directory.`)}catch{fail(`Could not find @stacksjs/${shortName} in node_modules.`,"Run `bun install` first, or check the package name.")}if(existsSync(targetDir)&&!force)fail(`Already published: ${targetDir.replace(`${process.cwd()}/`,"")}`,"Pass --force to overwrite.");mkdirSync(dirname(targetDir),{recursive:!0});const SKIP=new Set(["node_modules","dist",".bun",".cache","tsconfig.tsbuildinfo"]);let copied=0;await copyTreeFiltered(sourceDir,targetDir,SKIP,()=>copied++);log.success(`Published @stacksjs/${shortName} \u2192 ${italic(targetDir.replace(`${process.cwd()}/`,""))} (${copied} files)`);log.info("Edit freely \u2014 local changes win over the installed package.")}async function copyTreeFiltered(sourceDir,targetDir,skip,onFile){mkdirSync(targetDir,{recursive:!0});const entries=await readdir(sourceDir,{withFileTypes:!0});for(const entry of entries){if(skip.has(entry.name))continue;const src=`${sourceDir}/${entry.name}`,dst=`${targetDir}/${entry.name}`;if(entry.isDirectory()){await copyTreeFiltered(src,dst,skip,onFile);continue}await cp(src,dst,{force:!0,dereference:!0});onFile()}}async function publishResource(ctx){const{kind,name,defaultsDir,userDir,force}=ctx,fileName=name.endsWith(".ts")?name:`${name}.ts`,matches=globSync([`${defaultsDir}/**/${fileName}`],{absolute:!0});if(!matches.length){log.error(`Could not find default ${kind}: ${italic(fileName)}`);log.info(`Looked under: ${italic(defaultsDir)}`);process.exit(ExitCode.FatalError)}if(matches.length>1){log.warn(`Multiple defaults match ${italic(fileName)}; using the first:`);for(const m of matches)log.info(` ${m}`)}const sourcePath=matches[0];if(!sourcePath)throw Error(`Could not resolve default ${kind}: ${fileName}`);const targetPath=`${userDir.replace(/\/$/,"")}/${fileName}`;if(existsSync(targetPath)&&!force){log.error(`Already exists: ${italic(targetPath)}`);log.info("Pass --force to overwrite.");process.exit(ExitCode.FatalError)}mkdirSync(dirname(targetPath),{recursive:!0});await fs.promises.copyFile(sourcePath,targetPath);log.success(`Published ${kind} ${italic(name)} \u2192 ${italic(targetPath.replace(`${process.cwd()}/`,""))}`)}async function vendorFramework(explicitPath,force){const coreDir=path.frameworkPath("core"),rel=(p)=>p.replace(`${process.cwd()}/`,"");if(existsSync(coreDir)&&!force){log.info(`Already vendored: ${italic(rel(coreDir))}`);log.info("Pass --force to replace it with a fresh copy of the checkout.");return}const framework=resolveFrameworkCheckout(explicitPath);if(!framework){log.error("No Stacks checkout found to vendor from.");log.info("Pass one with `--path <dir>`, or set STACKS_FRAMEWORK_PATH.");log.info("A checkout is required: the published packages ship `dist` only, so a copy of them would not be editable.");process.exit(ExitCode.FatalError)}const sourceCore=join(framework,"storage/framework/core"),corePkgPath=resolve(sourceCore,"package.json");if(!existsSync(corePkgPath)){log.error(`${sourceCore} has no package.json \u2014 that does not look like a Stacks checkout.`);process.exit(ExitCode.FatalError)}const depName=JSON.parse(await fs.promises.readFile(corePkgPath,"utf-8")).name??"stacks";log.info(`Vendoring the framework from ${italic(framework)}...`);if(existsSync(coreDir))await fs.promises.rm(coreDir,{recursive:!0,force:!0});const SKIP=new Set(["node_modules",".bun",".cache","tsconfig.tsbuildinfo"]);let copied=0;await copyTreeFiltered(sourceCore,coreDir,SKIP,()=>copied++);const rootPkgPath=resolve(process.cwd(),"package.json"),rootPkg=JSON.parse(await fs.promises.readFile(rootPkgPath,"utf-8")),provided=new Set([depName]);for(const entry of await readdir(coreDir,{withFileTypes:!0})){if(!entry.isDirectory())continue;if(existsSync(resolve(coreDir,entry.name,"package.json")))provided.add(`@stacksjs/${entry.name}`)}let repointed=0;for(const field of["dependencies","devDependencies"]){const deps=rootPkg[field];if(!deps)continue;for(const name of Object.keys(deps)){if(!provided.has(name)||deps[name].startsWith("workspace:"))continue;deps[name]="workspace:*";repointed++}}if(!rootPkg.dependencies?.[depName]&&!rootPkg.devDependencies?.[depName])rootPkg.dependencies={...rootPkg.dependencies,[depName]:"workspace:*"};const workspaces=rootPkg.workspaces??[];let addedGlobs=0;for(const glob of["storage/framework/core","storage/framework/core/*"]){if(workspaces.some((existing)=>existing.replace(/^\.\//,"").replace(/\/$/,"")===glob))continue;workspaces.push(glob);addedGlobs++}rootPkg.workspaces=workspaces;await fs.promises.writeFile(rootPkgPath,`${JSON.stringify(rootPkg,null,2)}
4
4
  `);const bunfigPath=resolve(process.cwd(),"bunfig.toml");let rewrittenPreloads=0;if(existsSync(bunfigPath)){const bunfig=await fs.promises.readFile(bunfigPath,"utf-8"),next=bunfig.replace(/(["'])@stacksjs\/([\w-]+)\/([^"']+?)\.js\1/g,(match,quote,pkgName,subpath)=>{if(!provided.has(`@stacksjs/${pkgName}`))return match;rewrittenPreloads++;return`${quote}./storage/framework/core/${pkgName}/${subpath}.ts${quote}`});if(next!==bunfig)await fs.promises.writeFile(bunfigPath,next)}const tsconfigPath=resolve(process.cwd(),"tsconfig.json");let rewroteTsconfig=!1;if(existsSync(tsconfigPath)){const raw=await fs.promises.readFile(tsconfigPath,"utf-8"),next=raw.replace(/"extends"\s*:\s*"\.\/storage\/framework\/tsconfig\.app\.json"/,'"extends": "./storage/framework/core/tsconfig.json"');if(next!==raw){await fs.promises.writeFile(tsconfigPath,next);rewroteTsconfig=!0}}log.success(`Vendored ${copied} files into ${italic(rel(coreDir))}`);log.info(`package.json now depends on ${depName}@workspace:*`);if(repointed>0)log.info(`Repointed ${repointed} version range${repointed===1?"":"s"} to workspace:*`);if(addedGlobs>0)log.info(`Added ${addedGlobs} workspace glob${addedGlobs===1?"":"s"} for the vendored packages`);if(rewrittenPreloads>0)log.info(`Rewrote ${rewrittenPreloads} bunfig.toml preload path${rewrittenPreloads===1?"":"s"} to the vendored source`);if(rewroteTsconfig)log.info("tsconfig.json now extends storage/framework/core/tsconfig.json");log.info("Linking the workspace...");if(await Bun.spawn(["bun","install"],{cwd:process.cwd(),stdout:"inherit",stderr:"inherit"}).exited!==0){log.error("`bun install` failed. The files are in place; re-run the install once the failure is resolved.");process.exit(ExitCode.FatalError)}log.success("This project now runs on the vendored framework \u2014 edits under storage/framework/core are live.");log.info("Go back to the published packages any time with `buddy unpublish:core --all`.")}function resolveFrameworkCheckout(explicit){const candidates=[explicit,process.env.STACKS_FRAMEWORK_PATH,resolve(process.cwd(),"../stacks"),join(homedir(),"Code/stacks")].filter(Boolean);for(const candidate of candidates){const full=resolve(candidate);if(full===resolve(process.cwd()))continue;if(existsSync(join(full,"storage/framework/core/package.json")))return full}return null}async function reportCoreStatus(){const coreDir=path.frameworkPath("core"),rel=(p)=>p.replace(`${process.cwd()}/`,""),vendored=existsSync(resolve(coreDir,"package.json")),rootPkgPath=resolve(process.cwd(),"package.json"),rootPkg=existsSync(rootPkgPath)?JSON.parse(await fs.promises.readFile(rootPkgPath,"utf-8")):{},declared=rootPkg.dependencies?.stacks??rootPkg.devDependencies?.stacks;if(vendored){const corePkg=JSON.parse(await fs.promises.readFile(resolve(coreDir,"package.json"),"utf-8")),packages=(await readdir(coreDir,{withFileTypes:!0})).filter((entry)=>entry.isDirectory()&&existsSync(resolve(coreDir,entry.name,"package.json")));log.info(`Layout: vendored \u2014 ${italic(rel(coreDir))} (${packages.length} packages, v${corePkg.version??"unknown"})`);log.info(`Declared: stacks@${declared??"(not declared)"}`);if(declared&&!declared.startsWith("workspace:"))log.warn(`The vendored copy is not linked: stacks is declared as ${declared}, not workspace:*. Run \`buddy publish:core --all --force\` to relink, or \`buddy unpublish:core --all\` to remove it.`);log.info("Move to the published packages with `buddy unpublish:core --all`.");return}const installed=resolve(process.cwd(),"node_modules/@stacksjs/buddy/package.json"),installedVersion=existsSync(installed)?JSON.parse(await fs.promises.readFile(installed,"utf-8")).version:void 0;log.info("Layout: published packages \u2014 no storage/framework/core in this project");log.info(`Declared: stacks@${declared??"(not declared)"}`);log.info(`Installed: ${installedVersion?`v${installedVersion}`:"(run bun install)"}`);log.info("Vendor the framework for local development with `buddy publish:core --all`.")}async function unpublishCorePackage(pkg,force){const shortName=normalizeCoreName(pkg),targetDir=path.frameworkPath(`core/${shortName}`),rel=(p)=>p.replace(`${process.cwd()}/`,"");if(!existsSync(targetDir)){log.info(`Not vendored: ${italic(rel(targetDir))} \u2014 nothing to do.`);return}const installed=resolve(process.cwd(),"node_modules","@stacksjs",shortName);if(!existsSync(installed)&&!force){log.error(`@stacksjs/${shortName} is not installed, so removing the vendored copy would leave nothing to resolve.`);log.info(`Run \`bun add @stacksjs/${shortName}\` first, or pass --force to remove it anyway.`);process.exit(ExitCode.FatalError)}await assertNoUncommittedChanges(targetDir,force);await fs.promises.rm(targetDir,{recursive:!0,force:!0});log.success(`Unpublished ${italic(rel(targetDir))} \u2014 @stacksjs/${shortName} now resolves from node_modules.`)}async function unvendorFramework(force){const coreDir=path.frameworkPath("core"),rel=(p)=>p.replace(`${process.cwd()}/`,"");if(!existsSync(coreDir)){log.info("No storage/framework/core in this project \u2014 already on the installed packages.");return}const corePkgPath=resolve(coreDir,"package.json");if(!existsSync(corePkgPath)){log.error(`${rel(coreDir)} has no package.json, so its version cannot be determined.`);log.info("Unvendor the packages individually with `buddy unpublish:core <pkg>` instead.");process.exit(ExitCode.FatalError)}const corePkg=JSON.parse(await fs.promises.readFile(corePkgPath,"utf-8")),version=corePkg.version;if(!version){log.error(`${rel(corePkgPath)} has no version field.`);process.exit(ExitCode.FatalError)}await assertNoUncommittedChanges(coreDir,force);const depName=corePkg.name??"stacks",range=`^${await resolvePublishedVersion(depName,version)}`,rootPkgPath=resolve(process.cwd(),"package.json"),rootPkgRaw=await fs.promises.readFile(rootPkgPath,"utf-8"),rootPkg=JSON.parse(rootPkgRaw),provided=new Set([depName,...Object.keys(corePkg.dependencies??{}).filter((name)=>name.startsWith("@stacksjs/"))]);for(const entry of await readdir(coreDir,{withFileTypes:!0}))if(entry.isDirectory())provided.add(`@stacksjs/${entry.name}`);let repointed=0;const repointWorkspaceRanges=(pkg)=>{let touched=!1;for(const field of["dependencies","devDependencies","peerDependencies"]){const deps=pkg[field];if(!deps)continue;for(const[name,spec]of Object.entries(deps)){if(!spec.startsWith("workspace:")||!provided.has(name))continue;deps[name]=range;touched=!0;repointed++}}return touched};repointWorkspaceRanges(rootPkg);if(!rootPkg.dependencies?.[depName]&&!rootPkg.devDependencies?.[depName])rootPkg.dependencies={...rootPkg.dependencies,[depName]:range};let rewrittenScripts=0;for(const[name,script]of Object.entries(rootPkg.scripts??{})){if(!/storage\/framework\/core\/buddy\/src\/cli\.ts/.test(script))continue;rootPkg.scripts[name]=script.replace(/\bbunx?\s+(?:--bun\s+)?\.?\/?storage\/framework\/core\/buddy\/src\/cli\.ts/g,"./buddy");rewrittenScripts++}if(Array.isArray(rootPkg.workspaces)){rootPkg.workspaces=rootPkg.workspaces.filter((glob)=>!isCoreWorkspaceGlob(glob));if(rootPkg.workspaces.length===0)delete rootPkg.workspaces}await fs.promises.writeFile(rootPkgPath,`${JSON.stringify(rootPkg,null,2)}
5
5
  `);for(const glob of rootPkg.workspaces??[])for(const memberPkgPath of globSync(`${glob.replace(/\/$/,"")}/package.json`,{cwd:process.cwd(),absolute:!0})){const raw=await fs.promises.readFile(memberPkgPath,"utf-8"),memberPkg=JSON.parse(raw);if(repointWorkspaceRanges(memberPkg))await fs.promises.writeFile(memberPkgPath,`${JSON.stringify(memberPkg,null,2)}
6
- `)}const bunfigPath=resolve(process.cwd(),"bunfig.toml");let rewrittenPreloads=0;if(existsSync(bunfigPath)){const bunfig=await fs.promises.readFile(bunfigPath,"utf-8"),next=bunfig.replace(/(["'])\.?\/?storage\/framework\/core\/([\w-]+)\/([^"']+?)\.ts\1/g,(_match,quote,pkgName,subpath)=>{rewrittenPreloads++;return`${quote}@stacksjs/${pkgName}/${subpath}.js${quote}`});if(next!==bunfig)await fs.promises.writeFile(bunfigPath,next)}const tsconfigPath=resolve(process.cwd(),"tsconfig.json");let rewroteTsconfig=!1;if(existsSync(tsconfigPath)){const raw=await fs.promises.readFile(tsconfigPath,"utf-8"),next=raw.replace(/"extends"\s*:\s*"\.\/storage\/framework\/core\/tsconfig\.json"/,'"extends": "./storage/framework/tsconfig.app.json"');if(next!==raw){await fs.promises.writeFile(tsconfigPath,next);rewroteTsconfig=!0}}await fs.promises.rm(coreDir,{recursive:!0,force:!0});const scopedDir=resolve(process.cwd(),"node_modules/@stacksjs");let danglingRemoved=0;if(existsSync(scopedDir))for(const entry of await readdir(scopedDir,{withFileTypes:!0})){if(!entry.isSymbolicLink())continue;const link=resolve(scopedDir,entry.name);if(existsSync(link))continue;await fs.promises.rm(link,{force:!0});danglingRemoved++}log.success(`Removed ${italic(rel(coreDir))}`);log.info(`package.json now depends on ${depName}@${range}`);if(repointed>0)log.info(`Repointed ${repointed} workspace: range${repointed===1?"":"s"} to ${range}`);if(danglingRemoved>0)log.info(`Removed ${danglingRemoved} node_modules symlink${danglingRemoved===1?"":"s"} left pointing into it`);if(rewrittenScripts>0)log.info(`Repointed ${rewrittenScripts} package.json script${rewrittenScripts===1?"":"s"} to ./buddy`);if(rewrittenPreloads>0)log.info(`Rewrote ${rewrittenPreloads} bunfig.toml preload path${rewrittenPreloads===1?"":"s"} to package specifiers`);if(rewroteTsconfig)log.info("tsconfig.json now extends storage/framework/tsconfig.app.json");log.info("Installing the published packages...");if(await Bun.spawn(["bun","install"],{cwd:process.cwd(),stdout:"inherit",stderr:"inherit"}).exited!==0){log.error("`bun install` failed. package.json and bunfig.toml were updated; re-run the install once the failure is resolved.");process.exit(ExitCode.FatalError)}log.success("This project now runs on the published Stacks packages.");log.info("Vendor an individual package again any time with `buddy publish:core <pkg>`.")}async function resolvePublishedVersion(depName,vendored){let published;try{published=await fetchPublishedVersions(depName)}catch(error){log.warn(`Could not reach the npm registry to check ${depName} versions (${error instanceof Error?error.message:String(error)}).`);log.info(`Pinning the vendored version, ${depName}@^${vendored}.`);return vendored}if(published.versions.has(vendored))return vendored;if(!published.latest){log.warn(`${depName}@${vendored} is not published and the registry reports no latest version.`);return vendored}log.warn(`${depName}@${vendored} is not published yet \u2014 the vendored copy is ahead of npm.`);log.info(`Pinning the newest published version instead, ${depName}@^${published.latest}.`);return published.latest}async function fetchPublishedVersions(depName){const response=await fetch(`https://registry.npmjs.org/${depName.replace("/","%2F")}`,{headers:{accept:"application/vnd.npm.install-v1+json"}});if(!response.ok)throw Error(`registry responded ${response.status}`);const packument=await response.json();return{latest:packument["dist-tags"]?.latest,versions:new Set(Object.keys(packument.versions??{}))}}function normalizeCoreName(pkg){const shortName=pkg.replace(/^@stacksjs\//,"").replace(/^core\//,"");if(!shortName||shortName.includes("/")||shortName.includes("..")){process.stderr.write(`Invalid package name: ${pkg}
6
+ `)}const bunfigPath=resolve(process.cwd(),"bunfig.toml");let rewrittenPreloads=0;if(existsSync(bunfigPath)){const bunfig=await fs.promises.readFile(bunfigPath,"utf-8"),next=bunfig.replace(/(["'])\.?\/?storage\/framework\/core\/([\w-]+)\/([^"']+?)\.ts\1/g,(_match,quote,pkgName,subpath)=>{rewrittenPreloads++;return`${quote}@stacksjs/${pkgName}/${subpath}.js${quote}`});if(next!==bunfig)await fs.promises.writeFile(bunfigPath,next)}const tsconfigPath=resolve(process.cwd(),"tsconfig.json");let rewroteTsconfig=!1,rewroteTypecheck=!1;if(existsSync(tsconfigPath)){const raw=await fs.promises.readFile(tsconfigPath,"utf-8"),next=raw.replace(/"extends"\s*:\s*"\.\/storage\/framework\/core\/tsconfig\.json"/,'"extends": "./storage/framework/tsconfig.app.json"');if(next!==raw){await fs.promises.writeFile(tsconfigPath,next);rewroteTsconfig=!0}}const splitTypecheck=splitFrameworkTypecheckScript(rootPkg.scripts??{});if(splitTypecheck){rootPkg.scripts=splitTypecheck;rewroteTypecheck=!0;await fs.promises.writeFile(rootPkgPath,`${JSON.stringify(rootPkg,null,2)}
7
+ `)}const prunedWorkflows=await pruneVendoredCoreFromWorkflows(process.cwd());await fs.promises.rm(coreDir,{recursive:!0,force:!0});const scopedDir=resolve(process.cwd(),"node_modules/@stacksjs");let danglingRemoved=0;if(existsSync(scopedDir))for(const entry of await readdir(scopedDir,{withFileTypes:!0})){if(!entry.isSymbolicLink())continue;const link=resolve(scopedDir,entry.name);if(existsSync(link))continue;await fs.promises.rm(link,{force:!0});danglingRemoved++}log.success(`Removed ${italic(rel(coreDir))}`);log.info(`package.json now depends on ${depName}@${range}`);if(repointed>0)log.info(`Repointed ${repointed} workspace: range${repointed===1?"":"s"} to ${range}`);if(danglingRemoved>0)log.info(`Removed ${danglingRemoved} node_modules symlink${danglingRemoved===1?"":"s"} left pointing into it`);if(rewrittenScripts>0)log.info(`Repointed ${rewrittenScripts} package.json script${rewrittenScripts===1?"":"s"} to ./buddy`);if(rewrittenPreloads>0)log.info(`Rewrote ${rewrittenPreloads} bunfig.toml preload path${rewrittenPreloads===1?"":"s"} to package specifiers`);if(rewroteTsconfig)log.info("tsconfig.json now extends storage/framework/tsconfig.app.json");if(rewroteTypecheck)log.info("`typecheck` now checks this app as well as the framework files it still ships");for(const pruned of prunedWorkflows){const parts=[pruned.removedJobs.length>0?`${pruned.removedJobs.length} job${pruned.removedJobs.length===1?"":"s"} (${pruned.removedJobs.join(", ")})`:"",pruned.removedSteps>0?`${pruned.removedSteps} step${pruned.removedSteps===1?"":"s"}`:""].filter(Boolean);log.info(`${pruned.file}: removed ${parts.join(" and ")} that ran against the vendored core`)}log.info("Installing the published packages...");if(await Bun.spawn(["bun","install"],{cwd:process.cwd(),stdout:"inherit",stderr:"inherit"}).exited!==0){log.error("`bun install` failed. package.json and bunfig.toml were updated; re-run the install once the failure is resolved.");process.exit(ExitCode.FatalError)}log.success("This project now runs on the published Stacks packages.");log.info("Vendor an individual package again any time with `buddy publish:core <pkg>`.")}async function resolvePublishedVersion(depName,vendored){let published;try{published=await fetchPublishedVersions(depName)}catch(error){log.warn(`Could not reach the npm registry to check ${depName} versions (${error instanceof Error?error.message:String(error)}).`);log.info(`Pinning the vendored version, ${depName}@^${vendored}.`);return vendored}if(published.versions.has(vendored))return vendored;if(!published.latest){log.warn(`${depName}@${vendored} is not published and the registry reports no latest version.`);return vendored}log.warn(`${depName}@${vendored} is not published yet \u2014 the vendored copy is ahead of npm.`);log.info(`Pinning the newest published version instead, ${depName}@^${published.latest}.`);return published.latest}async function fetchPublishedVersions(depName){const response=await fetch(`https://registry.npmjs.org/${depName.replace("/","%2F")}`,{headers:{accept:"application/vnd.npm.install-v1+json"}});if(!response.ok)throw Error(`registry responded ${response.status}`);const packument=await response.json();return{latest:packument["dist-tags"]?.latest,versions:new Set(Object.keys(packument.versions??{}))}}function normalizeCoreName(pkg){const shortName=pkg.replace(/^@stacksjs\//,"").replace(/^core\//,"");if(!shortName||shortName.includes("/")||shortName.includes("..")){process.stderr.write(`Invalid package name: ${pkg}
7
8
  `);process.stderr.write(" Use a short name like `router` or the fully qualified `@stacksjs/router`.\n");process.exit(ExitCode.FatalError)}return shortName}function isCoreWorkspaceGlob(glob){const normalized=glob.replace(/^\.\//,"").replace(/\/$/,"");return normalized==="storage/framework/core"||normalized.startsWith("storage/framework/core/")}async function assertNoUncommittedChanges(dir,force){if(force)return;try{const proc=Bun.spawn(["git","status","--porcelain","--",dir],{cwd:process.cwd(),stdout:"pipe",stderr:"ignore"}),output=await new Response(proc.stdout).text();if(await proc.exited!==0)return;const changed=output.split(`
8
9
  `).filter(Boolean);if(changed.length===0)return;log.error(`${changed.length} uncommitted change${changed.length===1?"":"s"} under ${italic(dir.replace(`${process.cwd()}/`,""))}:`);for(const line of changed.slice(0,10))log.info(` ${line}`);if(changed.length>10)log.info(` ... and ${changed.length-10} more`);log.info("Commit or stash them first, or pass --force to delete them anyway.");process.exit(ExitCode.FatalError)}catch{}}
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Remove every step that runs against the vendored core, and every job left
3
+ * with nothing to do — then repair the `needs:` lists that named them.
4
+ *
5
+ * A job is dropped whole when all of its steps referenced core: what is left is
6
+ * a checkout and an install that assert nothing.
7
+ */
8
+ export declare function pruneVendoredCoreFromWorkflow(source: string): { yaml: string, removedJobs: string[], removedSteps: number };
9
+ /**
10
+ * Apply {@link pruneVendoredCoreFromWorkflow} to every workflow in a project,
11
+ * writing back only the files that actually changed.
12
+ *
13
+ * Best-effort by design: an unreadable or unusual workflow is left exactly as
14
+ * it is. This runs at the end of an unvendor that has already rewritten
15
+ * package.json and deleted the source tree, and a project whose CI is untidy is
16
+ * a much smaller problem than one whose unvendor died halfway through.
17
+ */
18
+ export declare function pruneVendoredCoreFromWorkflows(cwd: string): Promise<WorkflowPrune[]>;
19
+ /**` and deliberately excludes `app/`, `config/`,
20
+ * `resources/` and `routes/` — those belong to the root project, checked
21
+ * separately. In the framework repository that split is right. In an app it
22
+ * means the one command anybody runs, and the one CI calls, checks everything
23
+ * except the code they actually write. Silently, and forever: it reports zero
24
+ * errors on a file it never opened.
25
+ *
26
+ * Returns a new scripts object, or null when there is nothing to change.
27
+ */
28
+ export declare function splitFrameworkTypecheckScript(scripts: Record<string, string>): Record<string, string> | null;
29
+ /**
30
+ * Strip the framework's own CI from a project that no longer vendors it.
31
+ *
32
+ * `buddy new` scaffolds a project that IS the framework — the whole source under
33
+ * `storage/framework/core`, and a CI file with jobs that build every package,
34
+ * run every package's tests, and compile the CLI binary. `unpublish:core --all`
35
+ * removes that directory, and those jobs then fail by construction: a loop over
36
+ * the per-package test directories has nothing to expand, so the glob is passed
37
+ * through literally and the job reports `Failing core packages: *` — an
38
+ * unexpanded asterisk, printed as if it were the name of a package.
39
+ *
40
+ * Nobody connects that to an unvendor that happened weeks earlier, so the
41
+ * pipeline just stays red, and a red pipeline says nothing about the change that
42
+ * just landed. One project ran that way for its entire history.
43
+ *
44
+ * Line-based rather than parse-and-reserialize: a workflow is full of comments
45
+ * explaining why each job exists, and a YAML round trip drops every one of them.
46
+ */
47
+ /** A workflow edit, for the summary the command prints. */
48
+ export declare interface WorkflowPrune {
49
+ file: string
50
+ removedJobs: string[]
51
+ removedSteps: number
52
+ }
@@ -0,0 +1,3 @@
1
+ const CORE_PATH=/storage\/framework\/core/;function referencesCore(line){if(line.trim().startsWith("#"))return!1;return CORE_PATH.test(line.replace(/\s#.*$/,""))}function jobStarts(lines){const out=[];let inJobs=!1;for(const[at,line]of lines.entries()){if(/^jobs:\s*$/.test(line)){inJobs=!0;continue}if(!inJobs)continue;if(/^\S/.test(line)&&!/^\s*#/.test(line))break;const match=line.match(/^ {2}([A-Za-z_][\w-]*):\s*$/);if(match)out.push({name:match[1],at})}return out}function blockEnd(lines,from,indent){const sibling=new RegExp(`^ {${indent}}(?:- |[A-Za-z_"'])`);for(let at=from+1;at<lines.length;at++){const line=lines[at];if(line.trim()==="")continue;const leading=line.length-line.trimStart().length;if(leading<indent)return at;if(leading===indent&&sibling.test(line))return at}return lines.length}const SETUP_STEP=[/uses:\s*actions\/checkout/,/uses:\s*actions\/cache/,/uses:\s*actions\/setup-/,/uses:\s*pantry-pm\/pantry/,/run:\s*(?:bun|pantry|npm|pnpm|yarn)\s+(?:install|ci)\b/];function isSetupStep(step){const code=step.filter((line)=>!line.trim().startsWith("#"));return SETUP_STEP.some((pattern)=>code.some((line)=>pattern.test(line)))}function withLeadingComments(lines,at){let start=at;while(start-1>=0){const previous=lines[start-1].trim();if(previous.startsWith("#")||previous==="")start--;else break}while(start<at&&lines[start].trim()==="")start++;return start}export function pruneVendoredCoreFromWorkflow(source){let lines=source.split(`
2
+ `);const removedJobs=[];let removedSteps=0;for(const job of jobStarts(lines).reverse()){const end=blockEnd(lines,job.at,2),body=lines.slice(job.at,end),steps=[];for(const[offset,line]of body.entries()){if(!/^ {6}- /.test(line))continue;const at=job.at+offset,stepEnd=blockEnd(lines,at,6),step=lines.slice(at,stepEnd);steps.push({at,end:stepEnd,core:step.some(referencesCore),setup:isSetupStep(step)})}if(steps.length===0||!steps.some((step)=>step.core))continue;if(steps.filter((step)=>!step.setup).every((step)=>step.core)){lines.splice(withLeadingComments(lines,job.at),end-withLeadingComments(lines,job.at));removedJobs.push(job.name);continue}for(const step of[...steps].reverse()){if(!step.core)continue;const from=withLeadingComments(lines,step.at);lines.splice(from,step.end-from);removedSteps++}}if(removedJobs.length>0)lines=lines.map((line)=>{const inline=line.match(/^(\s*needs:\s*)\[([^\]]*)\]\s*$/);if(inline){const kept=inline[2].split(",").map((name)=>name.trim()).filter((name)=>name&&!removedJobs.includes(name));return kept.length>0?`${inline[1]}[${kept.join(", ")}]`:""}const scalar=line.match(/^(\s*)needs:\s*([A-Za-z_][\w-]*)\s*$/);if(scalar&&removedJobs.includes(scalar[2]))return"";return line}).filter((line,at,all)=>!(line===""&&all[at-1]===""&&all[at+1]===""));return{yaml:lines.join(`
3
+ `),removedJobs,removedSteps}}export async function pruneVendoredCoreFromWorkflows(cwd){const{readdir,readFile,writeFile}=await import("node:fs/promises"),{join}=await import("node:path"),dir=join(cwd,".github","workflows"),pruned=[];let entries;try{entries=await readdir(dir)}catch{return pruned}for(const entry of entries.sort()){if(!/\.ya?ml$/.test(entry))continue;const file=join(dir,entry);try{const source=await readFile(file,"utf-8"),result=pruneVendoredCoreFromWorkflow(source);if(result.yaml===source)continue;await writeFile(file,result.yaml);pruned.push({file:`.github/workflows/${entry}`,removedJobs:result.removedJobs,removedSteps:result.removedSteps})}catch{continue}}return pruned}export function splitFrameworkTypecheckScript(scripts){if(!scripts.typecheck?.includes("tsconfig.framework.json")||!scripts["typecheck:app"])return null;return Object.fromEntries(Object.entries(scripts).flatMap(([name,script])=>name==="typecheck"?[["typecheck","bun run typecheck:app && bun run typecheck:framework"],["typecheck:framework",script]]:[[name,script]]))}
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@stacksjs/buddy",
3
3
  "type": "module",
4
4
  "sideEffects": false,
5
- "version": "0.70.358",
5
+ "version": "0.70.363",
6
6
  "description": "Meet Buddy. The Stacks runtime.",
7
7
  "author": "Chris Breuer",
8
8
  "contributors": [
@@ -95,53 +95,53 @@
95
95
  "prepublishOnly": "bun run build"
96
96
  },
97
97
  "dependencies": {
98
- "@stacksjs/actions": "^0.70.358",
99
- "@stacksjs/ai": "^0.70.358",
100
- "@stacksjs/alias": "^0.70.358",
101
- "@stacksjs/arrays": "^0.70.358",
102
- "@stacksjs/auth": "^0.70.358",
103
- "@stacksjs/build": "^0.70.358",
104
- "@stacksjs/cache": "^0.70.358",
105
- "@stacksjs/cli": "^0.70.358",
98
+ "@stacksjs/actions": "^0.70.363",
99
+ "@stacksjs/ai": "^0.70.363",
100
+ "@stacksjs/alias": "^0.70.363",
101
+ "@stacksjs/arrays": "^0.70.363",
102
+ "@stacksjs/auth": "^0.70.363",
103
+ "@stacksjs/build": "^0.70.363",
104
+ "@stacksjs/cache": "^0.70.363",
105
+ "@stacksjs/cli": "^0.70.363",
106
106
  "@stacksjs/clapp": "^0.2.12",
107
- "@stacksjs/cloud": "^0.70.358",
108
- "@stacksjs/collections": "^0.70.358",
109
- "@stacksjs/config": "^0.70.358",
110
- "@stacksjs/database": "^0.70.358",
111
- "@stacksjs/desktop-build": "^0.70.358",
112
- "@stacksjs/dns": "^0.70.358",
113
- "@stacksjs/email": "^0.70.358",
114
- "@stacksjs/enums": "^0.70.358",
115
- "@stacksjs/error-handling": "^0.70.358",
116
- "@stacksjs/events": "^0.70.358",
117
- "@stacksjs/git": "^0.70.358",
107
+ "@stacksjs/cloud": "^0.70.363",
108
+ "@stacksjs/collections": "^0.70.363",
109
+ "@stacksjs/config": "^0.70.363",
110
+ "@stacksjs/database": "^0.70.363",
111
+ "@stacksjs/desktop-build": "^0.70.363",
112
+ "@stacksjs/dns": "^0.70.363",
113
+ "@stacksjs/email": "^0.70.363",
114
+ "@stacksjs/enums": "^0.70.363",
115
+ "@stacksjs/error-handling": "^0.70.363",
116
+ "@stacksjs/events": "^0.70.363",
117
+ "@stacksjs/git": "^0.70.363",
118
118
  "@stacksjs/gitit": "^0.2.5",
119
- "@stacksjs/health": "^0.70.358",
119
+ "@stacksjs/health": "^0.70.363",
120
120
  "@stacksjs/dnsx": "^0.2.3",
121
121
  "@stacksjs/httx": "^0.1.10",
122
- "@stacksjs/image": "^0.70.358",
123
- "@stacksjs/lint": "^0.70.358",
124
- "@stacksjs/logging": "^0.70.358",
125
- "@stacksjs/notifications": "^0.70.358",
126
- "@stacksjs/objects": "^0.70.358",
127
- "@stacksjs/orm": "^0.70.358",
128
- "@stacksjs/path": "^0.70.358",
129
- "@stacksjs/skills": "^0.70.358",
130
- "@stacksjs/payments": "^0.70.358",
131
- "@stacksjs/realtime": "^0.70.358",
132
- "@stacksjs/router": "^0.70.358",
122
+ "@stacksjs/image": "^0.70.363",
123
+ "@stacksjs/lint": "^0.70.363",
124
+ "@stacksjs/logging": "^0.70.363",
125
+ "@stacksjs/notifications": "^0.70.363",
126
+ "@stacksjs/objects": "^0.70.363",
127
+ "@stacksjs/orm": "^0.70.363",
128
+ "@stacksjs/path": "^0.70.363",
129
+ "@stacksjs/skills": "^0.70.363",
130
+ "@stacksjs/payments": "^0.70.363",
131
+ "@stacksjs/realtime": "^0.70.363",
132
+ "@stacksjs/router": "^0.70.363",
133
133
  "@stacksjs/rpx": "^0.11.42",
134
- "@stacksjs/search-engine": "^0.70.358",
135
- "@stacksjs/security": "^0.70.358",
136
- "@stacksjs/server": "^0.70.358",
137
- "@stacksjs/storage": "^0.70.358",
138
- "@stacksjs/strings": "^0.70.358",
139
- "@stacksjs/testing": "^0.70.358",
140
- "@stacksjs/tunnel": "^0.70.358",
141
- "@stacksjs/types": "^0.70.358",
142
- "@stacksjs/ui": "^0.70.358",
143
- "@stacksjs/utils": "^0.70.358",
144
- "@stacksjs/validation": "^0.70.358",
134
+ "@stacksjs/search-engine": "^0.70.363",
135
+ "@stacksjs/security": "^0.70.363",
136
+ "@stacksjs/server": "^0.70.363",
137
+ "@stacksjs/storage": "^0.70.363",
138
+ "@stacksjs/strings": "^0.70.363",
139
+ "@stacksjs/testing": "^0.70.363",
140
+ "@stacksjs/tunnel": "^0.70.363",
141
+ "@stacksjs/types": "^0.70.363",
142
+ "@stacksjs/ui": "^0.70.363",
143
+ "@stacksjs/utils": "^0.70.363",
144
+ "@stacksjs/validation": "^0.70.363",
145
145
  "@stacksjs/ts-cloud": "^0.7.103",
146
146
  "ajv": "^8.20.0",
147
147
  "ajv-formats": "^3.0.1",