@stacksjs/buddy 0.72.38 → 0.72.39
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.
- package/dist/cli.js +1 -1
- package/dist/commands/cd.js +1 -1
- package/dist/commands/config-migrate.js +1 -1
- package/dist/commands/create.js +1 -1
- package/dist/commands/deploy.js +6 -6
- package/dist/commands/dev.js +3 -3
- package/dist/commands/dns.js +1 -1
- package/dist/commands/docs/buddy-commands.js +1 -1
- package/dist/commands/doctor.js +1 -1
- package/dist/commands/email.js +1 -1
- package/dist/commands/env.js +1 -1
- package/dist/commands/extension.js +2 -2
- package/dist/commands/features.js +2 -2
- package/dist/commands/link.js +1 -1
- package/dist/commands/lint.js +1 -1
- package/dist/commands/mail.js +2 -2
- package/dist/commands/migrate-project.js +1 -1
- package/dist/commands/migrate.js +10 -10
- package/dist/commands/publish.js +4 -4
- package/dist/commands/queue.js +1 -1
- package/dist/commands/seed.js +1 -1
- package/dist/commands/setup.js +2 -2
- package/dist/deploy-notify.js +2 -2
- package/dist/migrators/index.js +1 -1
- package/dist/migrators/laravel/index.js +1 -1
- package/dist/migrators/laravel/models.js +3 -3
- package/dist/migrators/rails/index.js +1 -1
- package/dist/production-server.js +1 -1
- package/package.json +46 -46
package/dist/commands/dev.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
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.")}
|
|
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")} - 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 - ${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("- 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")}
|
|
5
|
-
`);for(const r of sorted){const method=(METHOD_COLORS[r.method.toUpperCase()]??dim)(r.method.toUpperCase().padEnd(6)),path=r.path.padEnd(longestPath),named=r.name?` ${dim(`(${r.name})`)}`:"";console.log(` ${method} ${path}${named}`)}console.log()}catch{}}async function probeDevServerHttp(port){for(const path of["/__health","/"])if(await new Promise((resolve)=>{const ac=new AbortController,t=setTimeout(()=>ac.abort(),path==="/"?8000:800);fetch(`http://127.0.0.1:${port}${path}`,{signal:ac.signal,redirect:"manual"}).then(()=>{clearTimeout(t);resolve(!0)}).catch(()=>{clearTimeout(t);resolve(!1)})}))return!0;return!1}async function findAvailablePort(preferred,attempts=20){for(let port=preferred;port<preferred+attempts;port++)if(await portIsFree(port))return port;return preferred}async function portIsFree(port){for(const host of["127.0.0.1","::1"])if(await someoneIsListening(port,host))return!1;let server;try{server=Bun.serve({port,hostname:"0.0.0.0",reusePort:!1,fetch:()=>new Response})}catch{return!1}server.stop(!0);return!0}async function someoneIsListening(port,host){const{connect}=await import("node:net");return new Promise((resolve)=>{const socket=connect({port,host}),finish=(listening)=>{socket.destroy();resolve(listening)};socket.setTimeout(300,()=>finish(!1));socket.once("connect",()=>finish(!0));socket.once("error",()=>finish(!1))})}async function waitForPort(port,timeoutMs){const deadline=Date.now()+timeoutMs;while(Date.now()<deadline){if(await probeDevServerHttp(port))return!0;await new Promise((r)=>setTimeout(r,50))}return!1}async function waitForHttpsProxy(port,timeoutMs){const{connect}=await import("node:net"),deadline=Date.now()+timeoutMs;while(Date.now()<deadline){if(await new Promise((resolve)=>{const socket=connect({port,host:"127.0.0.1"}),finish=(v)=>{try{socket.destroy()}catch{}resolve(v)};socket.once("connect",()=>finish(!0));socket.once("error",()=>finish(!1));setTimeout(()=>finish(!1),800).unref?.()}))return!0;await new Promise((r)=>setTimeout(r,200))}return!1}const RPX_SSL_DIR=join(homedir(),".stacks","ssl"),RPX_ROOT_CA_PATH=join(RPX_SSL_DIR,"rpx-root-ca.crt"),RPX_HOST_CERT_PATH=join(RPX_SSL_DIR,"rpx.localhost.crt");function buildDevelopmentTlsHostnames(domain,includeDashboard){return[domain,...includeDashboard?[`dashboard.${domain}`]:[],"rpx.localhost"]}async function ensureRpxDevelopmentHttps(domain,options,includeDashboard,trustCertificate=!0){const verbose=options.verbose??!1,{buildRegistryTlsProxyOptions,certIncludesSanHostnames,checkExistingCertificates,clearSslConfigCache,forceTrustCertificate,generateCertificate,getRegistryDir,isDaemonRunning,isRootCaTrustedForSsl,readAll,readCertCommonName,stopDaemon,trustRootCaForBrowsers,verifyHttpsChain}=await importDevelopmentRpx(),hostnames=buildDevelopmentTlsHostnames(domain,includeDashboard),entries=await readAll(getRegistryDir(),!1).catch(()=>[]);for(const entry of entries){const host=entry.to?.trim();if(host&&!hostnames.includes(host))hostnames.push(host)}const tlsOptions=buildRegistryTlsProxyOptions(hostnames,domain,verbose),hostnameInCert=certIncludesSanHostnames(RPX_HOST_CERT_PATH,hostnames),cnMatchesApp=readCertCommonName(RPX_HOST_CERT_PATH)===domain;let certRegenerated=!1;if(!await checkExistingCertificates(tlsOptions)||!hostnameInCert||!cnMatchesApp){clearSslConfigCache();await generateCertificate({...tlsOptions,forceRegenerate:!0});certRegenerated=!0}let trusted=isRootCaTrustedForSsl(RPX_ROOT_CA_PATH,domain,{verbose});if(!trusted&&trustCertificate){trusted=trustRootCaForBrowsers(RPX_ROOT_CA_PATH,{serverName:domain,verbose})||await forceTrustCertificate(RPX_ROOT_CA_PATH,{serverName:domain,verbose})||isRootCaTrustedForSsl(RPX_ROOT_CA_PATH,domain,{verbose});if(trusted)console.log(` ${green("\u2713")} ${dim("HTTPS")}: ${dim("Local CA trusted
|
|
6
|
-
' "$SUDO_PASSWORD" | sudo -S rm -f ${quotedPath}`:`sudo -n rm -f ${quotedPath}`;try{execSync(rm,{stdio:["pipe","ignore","ignore"]});log.info(`Removed stale rpx DNS resolver ${resolverPath} (left by a previous dev session)`);execSync("sudo -n dscacheutil -flushcache; sudo -n killall -HUP mDNSResponder; true",{stdio:["pipe","ignore","ignore"]})}catch{log.warn(`A stale rpx DNS resolver still hijacks ${domain}. Remove it with: sudo rm ${resolverPath}`)}}async function prepareRpxTlsForDev(input){const{domain,includeDashboard,options,skipHosts=!1,trustCertificate=!0}=input,verbose=options.verbose??!1,hosts=[domain,...includeDashboard?[`dashboard.${domain}`]:[]],hostsNeedingFile=hosts.filter((host)=>{const h=host.trim().toLowerCase();return h!=="localhost"&&!h.endsWith(".localhost")&&!h.endsWith(".localhost.")}),{addHosts,setupDevelopmentDns}=await importDevelopmentRpx(),dnsDomains=!skipHosts&&hostsNeedingFile.length>0?hosts:[];if(dnsDomains.length>0){if(!await setupDevelopmentDns({domains:dnsDomains,verbose}).catch(()=>!1)&&verbose)log.warn(`Dev DNS not configured for ${dnsDomains.join(", ")}
|
|
5
|
+
`);for(const r of sorted){const method=(METHOD_COLORS[r.method.toUpperCase()]??dim)(r.method.toUpperCase().padEnd(6)),path=r.path.padEnd(longestPath),named=r.name?` ${dim(`(${r.name})`)}`:"";console.log(` ${method} ${path}${named}`)}console.log()}catch{}}async function probeDevServerHttp(port){for(const path of["/__health","/"])if(await new Promise((resolve)=>{const ac=new AbortController,t=setTimeout(()=>ac.abort(),path==="/"?8000:800);fetch(`http://127.0.0.1:${port}${path}`,{signal:ac.signal,redirect:"manual"}).then(()=>{clearTimeout(t);resolve(!0)}).catch(()=>{clearTimeout(t);resolve(!1)})}))return!0;return!1}async function findAvailablePort(preferred,attempts=20){for(let port=preferred;port<preferred+attempts;port++)if(await portIsFree(port))return port;return preferred}async function portIsFree(port){for(const host of["127.0.0.1","::1"])if(await someoneIsListening(port,host))return!1;let server;try{server=Bun.serve({port,hostname:"0.0.0.0",reusePort:!1,fetch:()=>new Response})}catch{return!1}server.stop(!0);return!0}async function someoneIsListening(port,host){const{connect}=await import("node:net");return new Promise((resolve)=>{const socket=connect({port,host}),finish=(listening)=>{socket.destroy();resolve(listening)};socket.setTimeout(300,()=>finish(!1));socket.once("connect",()=>finish(!0));socket.once("error",()=>finish(!1))})}async function waitForPort(port,timeoutMs){const deadline=Date.now()+timeoutMs;while(Date.now()<deadline){if(await probeDevServerHttp(port))return!0;await new Promise((r)=>setTimeout(r,50))}return!1}async function waitForHttpsProxy(port,timeoutMs){const{connect}=await import("node:net"),deadline=Date.now()+timeoutMs;while(Date.now()<deadline){if(await new Promise((resolve)=>{const socket=connect({port,host:"127.0.0.1"}),finish=(v)=>{try{socket.destroy()}catch{}resolve(v)};socket.once("connect",()=>finish(!0));socket.once("error",()=>finish(!1));setTimeout(()=>finish(!1),800).unref?.()}))return!0;await new Promise((r)=>setTimeout(r,200))}return!1}const RPX_SSL_DIR=join(homedir(),".stacks","ssl"),RPX_ROOT_CA_PATH=join(RPX_SSL_DIR,"rpx-root-ca.crt"),RPX_HOST_CERT_PATH=join(RPX_SSL_DIR,"rpx.localhost.crt");function buildDevelopmentTlsHostnames(domain,includeDashboard){return[domain,...includeDashboard?[`dashboard.${domain}`]:[],"rpx.localhost"]}async function ensureRpxDevelopmentHttps(domain,options,includeDashboard,trustCertificate=!0){const verbose=options.verbose??!1,{buildRegistryTlsProxyOptions,certIncludesSanHostnames,checkExistingCertificates,clearSslConfigCache,forceTrustCertificate,generateCertificate,getRegistryDir,isDaemonRunning,isRootCaTrustedForSsl,readAll,readCertCommonName,stopDaemon,trustRootCaForBrowsers,verifyHttpsChain}=await importDevelopmentRpx(),hostnames=buildDevelopmentTlsHostnames(domain,includeDashboard),entries=await readAll(getRegistryDir(),!1).catch(()=>[]);for(const entry of entries){const host=entry.to?.trim();if(host&&!hostnames.includes(host))hostnames.push(host)}const tlsOptions=buildRegistryTlsProxyOptions(hostnames,domain,verbose),hostnameInCert=certIncludesSanHostnames(RPX_HOST_CERT_PATH,hostnames),cnMatchesApp=readCertCommonName(RPX_HOST_CERT_PATH)===domain;let certRegenerated=!1;if(!await checkExistingCertificates(tlsOptions)||!hostnameInCert||!cnMatchesApp){clearSslConfigCache();await generateCertificate({...tlsOptions,forceRegenerate:!0});certRegenerated=!0}let trusted=isRootCaTrustedForSsl(RPX_ROOT_CA_PATH,domain,{verbose});if(!trusted&&trustCertificate){trusted=trustRootCaForBrowsers(RPX_ROOT_CA_PATH,{serverName:domain,verbose})||await forceTrustCertificate(RPX_ROOT_CA_PATH,{serverName:domain,verbose})||isRootCaTrustedForSsl(RPX_ROOT_CA_PATH,domain,{verbose});if(trusted)console.log(` ${green("\u2713")} ${dim("HTTPS")}: ${dim("Local CA trusted - reload the browser if you still see a warning")}`);else console.log(` ${yellow("\u26A0")} ${yellow("HTTPS")}: ${yellow(`Local CA not trusted \u2014 run: sh ${join(RPX_SSL_DIR,"trust-rpx-cert.sh")}`)}`)}else if(verbose)console.log(` ${green("\u2713")} ${dim("HTTPS")}: ${dim("Local certificate trusted for SSL")}`);const chainOk=existsSync(RPX_ROOT_CA_PATH)?await verifyHttpsChain(domain,RPX_ROOT_CA_PATH):!1;if((certRegenerated||!hostnameInCert||!cnMatchesApp)&&await isDaemonRunning()){await stopDaemon({timeoutMs:8000}).catch(()=>{});if(verbose)log.info("Restarting rpx daemon to load TLS certificates for this app")}else if(!chainOk&&verbose)console.log(` ${dim(" ")}${dim("HTTPS chain not yet valid on :443 - daemon will (re)start after backends are ready")}`)}function resolveRpxDaemonSpawnCwd(){const fromEnv=process.env.RPX_BIN||process.env.STACKS_RPX_BIN;if(fromEnv&&existsSync(fromEnv))return dirname(fromEnv);const bootstrap=join(dirname(fileURLToPath(import.meta.url)),"../../scripts/rpx-daemon-bootstrap.js");if(existsSync(bootstrap))return dirname(bootstrap);return homedir()}async function resolveRpxDaemonSpawnCommand(){const bootstrap=join(dirname(fileURLToPath(import.meta.url)),"../../scripts/rpx-daemon-bootstrap.js");if(existsSync(bootstrap))return[process.execPath,bootstrap];const fromEnv=process.env.RPX_BIN||process.env.STACKS_RPX_BIN;if(fromEnv&&existsSync(fromEnv))return[fromEnv,"daemon:start"];const pkgUrl=import.meta.resolve("@stacksjs/rpx/package.json"),cli=join(dirname(fileURLToPath(pkgUrl)),"dist/bin/cli.js");return[process.execPath,cli,"daemon:start"]}function buildRpxProxySpecs(input){const{domain,frontendPort,apiPort,docsPort,dashboardPort,includeDashboard}=input,dashboardDomain=`dashboard.${domain}`;return[{id:`${domain}-frontend`,from:`localhost:${frontendPort}`,to:domain,pathRewrites:[{from:"/api",to:`localhost:${apiPort}`,stripPrefix:!1},{from:"/docs",to:`localhost:${docsPort}`,stripPrefix:!0}]},...includeDashboard?[{id:`${domain}-dashboard`,from:`localhost:${dashboardPort}`,to:dashboardDomain}]:[]]}async function unregisterRpxProxies(ids){if(ids.length===0)return;try{const{removeEntry}=await importDevelopmentRpx();for(const id of ids)await removeEntry(id).catch(()=>{})}catch{}}async function removeStalePublicDomainOverrides(domain,includeDashboard,verbose){const{checkHosts,removeHosts,resolverFilePath,contentLooksLikeRpxResolver}=await importDevelopmentRpx(),hosts=[domain,...includeDashboard?[`dashboard.${domain}`]:[]],present=await checkHosts(hosts,verbose).catch(()=>hosts.map(()=>!1)),staleHosts=hosts.filter((_,i)=>present[i]===!0);if(staleHosts.length>0){await removeHosts(staleHosts,verbose).catch(()=>{});if((await checkHosts(staleHosts,verbose).catch(()=>staleHosts.map(()=>!0))).some(Boolean))log.warn(`Stale /etc/hosts entries still point ${staleHosts.join(", ")} at loopback. Remove them with: sudo nano /etc/hosts`);else log.info(`Removed stale /etc/hosts entries for ${staleHosts.join(", ")} (left by a previous dev session)`)}if(process.platform!=="darwin")return;const labels=domain.split("."),basename=labels.length>=2?labels.slice(-2).join("."):domain,resolverPath=resolverFilePath(basename);let isRpxManaged=!1;try{isRpxManaged=contentLooksLikeRpxResolver(readFileSync(resolverPath,"utf8"))}catch{}if(!isRpxManaged)return;const quotedPath=`'${resolverPath.replace(/'/g,"'\\''")}'`,rm=process.env.SUDO_PASSWORD?`printf '%s
|
|
6
|
+
' "$SUDO_PASSWORD" | sudo -S rm -f ${quotedPath}`:`sudo -n rm -f ${quotedPath}`;try{execSync(rm,{stdio:["pipe","ignore","ignore"]});log.info(`Removed stale rpx DNS resolver ${resolverPath} (left by a previous dev session)`);execSync("sudo -n dscacheutil -flushcache; sudo -n killall -HUP mDNSResponder; true",{stdio:["pipe","ignore","ignore"]})}catch{log.warn(`A stale rpx DNS resolver still hijacks ${domain}. Remove it with: sudo rm ${resolverPath}`)}}async function prepareRpxTlsForDev(input){const{domain,includeDashboard,options,skipHosts=!1,trustCertificate=!0}=input,verbose=options.verbose??!1,hosts=[domain,...includeDashboard?[`dashboard.${domain}`]:[]],hostsNeedingFile=hosts.filter((host)=>{const h=host.trim().toLowerCase();return h!=="localhost"&&!h.endsWith(".localhost")&&!h.endsWith(".localhost.")}),{addHosts,setupDevelopmentDns}=await importDevelopmentRpx(),dnsDomains=!skipHosts&&hostsNeedingFile.length>0?hosts:[];if(dnsDomains.length>0){if(!await setupDevelopmentDns({domains:dnsDomains,verbose}).catch(()=>!1)&&verbose)log.warn(`Dev DNS not configured for ${dnsDomains.join(", ")} - falling back to /etc/hosts`)}if(!skipHosts&&hostsNeedingFile.length>0)await addHosts(hostsNeedingFile,verbose).catch((err)=>{log.warn(`Could not update /etc/hosts for ${hostsNeedingFile.join(", ")}: ${err.message}`);log.warn("Add 127.0.0.1 entries manually or set SUDO_PASSWORD in .env")});await ensureRpxDevelopmentHttps(domain,options,includeDashboard,trustCertificate)}export async function setupPrettyDevEnvironment(input){const domain=resolvePrettyDevDomain(input.domain??process.env.APP_URL??"stacks.localhost");if(!domain){log.info("APP_URL already uses localhost; no pretty URL setup is needed");return!0}const rpx=await importDevelopmentRpx();if(!rpx.authorizeSystemAccess({interactive:process.stdin.isTTY===!0&&process.stdout.isTTY===!0})){log.error("Pretty URL setup needs administrator authorization to bind ports 80 and 443 and trust the local CA");return!1}await prepareRpxTlsForDev({domain,includeDashboard:process.env.STACKS_DEV_DASHBOARD==="1",options:{verbose:input.verbose},skipHosts:input.skipHosts,trustCertificate:!input.skipTrust});const spawnEnv={};if(process.env.SUDO_PASSWORD)spawnEnv.SUDO_PASSWORD=process.env.SUDO_PASSWORD;if(input.verbose)spawnEnv.RPX_VERBOSE="1";const rpxEntry=resolveRpxEntryPath();if(rpxEntry)spawnEnv.RPX_MODULE=rpxEntry;await startRpxDaemonIfNeeded({spawnCommand:await resolveRpxDaemonSpawnCommand(),spawnEnv,verbose:input.verbose??!1,stopRpx:rpx.stopDaemon});const ready=await waitForHttpsProxy(443,5000);if(ready)log.success(`Pretty URLs are ready at https://${domain}`);else log.error("rpx did not become reachable on port 443");return ready}async function startRpxDaemonIfNeeded(input){const{ensureDaemonRunning,isDaemonRunning:isRpxUp}=await importDevelopmentRpx();if(await isRpxUp()){if(await waitForHttpsProxy(443,1500))return;await input.stopRpx({timeoutMs:5000}).catch(()=>{})}const spawnOpts={spawnCommand:input.spawnCommand,spawnCwd:resolveRpxDaemonSpawnCwd(),startupTimeoutMs:45000,spawnEnv:input.spawnEnv,verbose:input.verbose};try{await ensureDaemonRunning(spawnOpts)}catch(firstError){await input.stopRpx({timeoutMs:5000}).catch(()=>{});await ensureDaemonRunning(spawnOpts).catch(()=>{throw firstError})}}async function registerRpxProxiesForDomain(input){const{domain,frontendPort,apiPort,docsPort,dashboardPort,includeDashboard,options}=input,verbose=options.verbose??!1,proxies=buildRpxProxySpecs({domain,frontendPort,apiPort,docsPort,dashboardPort,includeDashboard});await unregisterRpxProxies([`${domain}-api`,`${domain}-docs`]);const{getRegistryDir,readAll,stopDaemon:stopRpx,syncDevelopmentDnsFromRegistry,writeEntry}=await importDevelopmentRpx(),spawnCommand=await resolveRpxDaemonSpawnCommand(),spawnEnv={};if(process.env.SUDO_PASSWORD)spawnEnv.SUDO_PASSWORD=process.env.SUDO_PASSWORD;if(verbose)spawnEnv.RPX_VERBOSE="1";const rpxEntry=resolveRpxEntryPath();if(rpxEntry)spawnEnv.RPX_MODULE=rpxEntry;try{await startRpxDaemonIfNeeded({spawnCommand,spawnEnv,verbose,stopRpx})}catch(error){const{isDaemonRunning}=await importDevelopmentRpx();if(!await isDaemonRunning())throw error;if(verbose){const message=error instanceof Error?error.message:String(error);console.log(` ${dim(" ")}${dim(`rpx daemon spawn skipped (${message}); reusing existing daemon`)}`)}}const createdAt=new Date().toISOString();for(const proxy of proxies){await writeEntry({id:proxy.id,from:proxy.from,to:proxy.to,cwd:process.cwd(),createdAt,pid:process.pid,pathRewrites:proxy.pathRewrites},void 0,verbose);if(!activeRpxRegistryIds.includes(proxy.id))activeRpxRegistryIds.push(proxy.id);if(verbose)console.log(` ${dim("\u279C")} ${dim("Proxy")}: ${dim(`https://${proxy.to} \u2192 ${proxy.from}`)}`)}const registryDir=getRegistryDir(),entries=await readAll(registryDir,verbose).catch(()=>[]);await syncDevelopmentDnsFromRegistry(entries,{verbose,ownerPid:process.pid}).catch((err)=>{if(verbose){const message=err instanceof Error?err.message:String(err);console.log(` ${dim(" ")}${dim(`Dev DNS sync: ${message}`)}`)}})}function wantsInteractive(options){return options.interactive}
|
package/dist/commands/dns.js
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
import process from"node:process";import{log,onUnknownSubcommand}from"@stacksjs/cli";import{config}from"@stacksjs/config";import{renderDnsConfig,resolveLiveRecords,syncDnsConfig}from"@stacksjs/dns";import{ExitCode}from"@stacksjs/types";import{loadProjectDnsConfig}from"../config";export function dns(buddy){const descriptions={dns:"Lists the DNS records for a domain",query:"Host name or IP address to query",type:"Type of the DNS record being queried (A, MX, NS\u2026)",nameserver:"Address of the nameserver to send packets to",class:"Network class of the DNS record being queried (IN, CH, HS)",udp:"Use the DNS protocol over UDP",tcp:"Use the DNS protocol over TCP",tls:"Use the DNS-over-TLS protocol",https:"Use the DNS-over-HTTPS protocol",short:"Short mode: display nothing but the first result",json:"Display the output as JSON",project:"Target a specific project",verbose:"Enable verbose output"};buddy.command("dns [domain]",descriptions.dns).option("-q, --query <query>",descriptions.query).option("-t, --type <type>",descriptions.type,{default:"A"}).option("-n, --nameserver <nameserver>",descriptions.nameserver).option("--class <class>",descriptions.class).option("-U, --udp",descriptions.udp).option("-T, --tcp",descriptions.tcp).option("-S, --tls",descriptions.tls).option("-H, --https",descriptions.https).option("-1, --short",descriptions.short,{default:!1}).option("-J, --json",descriptions.json,{default:!1}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(domain,options)=>{log.debug("Running `buddy dns [domain]` ...",options);const targetDomain=domain||config.app.url;let DnsClient,formatOutput;try{const dnsx=await import("@stacksjs/dnsx");DnsClient=dnsx.DnsClient;formatOutput=dnsx.formatOutput}catch(err){log.error("`buddy dns` needs the @stacksjs/dnsx runtime, but only the type declarations are currently published. Install a build with the JS runtime (or wait for the next dnsx release) and re-run.");log.debug(`[dns] import failure: ${err instanceof Error?err.message:String(err)}`);process.exit(ExitCode.FatalError)}try{const client=new DnsClient({domains:[targetDomain],type:options.type,nameserver:options.nameserver,class:options.class,udp:options.udp,tcp:options.tcp,tls:options.tls,https:options.https,short:options.short,json:options.json,verbose:options.verbose}),startTime=performance.now(),responses=await client.query(),duration=performance.now()-startTime,output=formatOutput(responses,{json:options.json??!1,short:options.short??!1,showDuration:duration,colors:{enabled:!0},rawSeconds:!1});console.log(output)}catch(error){log.error(`DNS query failed: ${error instanceof Error?error.message:String(error)}`);process.exit(ExitCode.FatalError)}process.exit(ExitCode.Success)});const bareDomain=(input)=>(input||config.app.url||"").replace(/^[a-z]+:\/\//i,"").replace(/[/:].*$/,"");buddy.command("dns:pull [domain]","Print a domain's live DNS records as a config/dns.ts block").option("--verbose",descriptions.verbose,{default:!1}).action(async(domain,options)=>{const target=bareDomain(domain);log.debug(`Running \`buddy dns:pull ${target}\` ...`,options);const records=await resolveLiveRecords(target);if(!records.length){log.error(`No DNS records resolved for ${target}.`);process.exit(ExitCode.FatalError)}console.log(renderDnsConfig(target,records));process.exit(ExitCode.Success)});buddy.command("dns:diff [domain]","Show which config/dns.ts records are missing from the live zone").option("--verbose",descriptions.verbose,{default:!1}).action(async(domain,options)=>{const target=bareDomain(domain);log.debug(`Running \`buddy dns:diff ${target}\` ...`,options);const dnsConfig=await loadProjectDnsConfig(config.dns),{plan,provider}=await syncDnsConfig(target,dnsConfig,{dryRun:!0});for(const item of plan.items){const detail=item.record.type==="TXT"||item.record.type==="MX"?` ${item.record.content}`:` \u2192 ${item.record.content}`;console.log(` ${item.action==="create"?"+ create":" keep "} ${item.record.type.padEnd(5)} ${item.record.name}${detail}`)}console.log(`
|
|
2
|
-
${plan.create.length} to create, ${plan.keep.length} already present (${provider?`registrar: ${provider}`:"public DNS"})`);process.exit(ExitCode.Success)});buddy.command("dns:sync [domain]","Additively sync config/dns.ts to the registrar (creates missing records; never deletes or overwrites)").option("--dry-run","Show the plan without writing any records",{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(domain,options)=>{const target=bareDomain(domain);log.debug(`Running \`buddy dns:sync ${target}\` ...`,options);const dnsConfig=await loadProjectDnsConfig(config.dns),result=await syncDnsConfig(target,dnsConfig,{dryRun:options.dryRun});if(!result.provider&&!options.dryRun){log.warn(`No DNS provider credentials found (e.g. PORKBUN_API_KEY / PORKBUN_SECRET_KEY)
|
|
2
|
+
${plan.create.length} to create, ${plan.keep.length} already present (${provider?`registrar: ${provider}`:"public DNS"})`);process.exit(ExitCode.Success)});buddy.command("dns:sync [domain]","Additively sync config/dns.ts to the registrar (creates missing records; never deletes or overwrites)").option("--dry-run","Show the plan without writing any records",{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(domain,options)=>{const target=bareDomain(domain);log.debug(`Running \`buddy dns:sync ${target}\` ...`,options);const dnsConfig=await loadProjectDnsConfig(config.dns),result=await syncDnsConfig(target,dnsConfig,{dryRun:options.dryRun});if(!result.provider&&!options.dryRun){log.warn(`No DNS provider credentials found (e.g. PORKBUN_API_KEY / PORKBUN_SECRET_KEY) - nothing was synced. ${result.plan.create.length} record(s) would be created.`);process.exit(ExitCode.Success)}for(const record of result.plan.create)console.log(` ${result.applied?"created":"would create"} ${record.type.padEnd(5)} ${record.name} \u2192 ${record.content}`);const verb=result.applied?"created":"to create",count=result.applied?result.created:result.plan.create.length;console.log(`
|
|
3
3
|
dns:sync ${target}: ${count} ${verb}, ${result.kept} kept${result.failed?`, ${result.failed} failed`:""}${result.provider?` (${result.provider})`:""}`);process.exit(result.failed>0?ExitCode.FatalError:ExitCode.Success)});onUnknownSubcommand(buddy,"dns")}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import{assertFrameworkRepo}from"./framework-repo";import{existsSync,mkdirSync,readFileSync,writeFileSync}from"node:fs";import{dirname,resolve}from"node:path";const root=resolve(import.meta.dir,"../../../../../../.."),outputPath=resolve(root,"docs/guide/buddy/commands.md"),cliPath=resolve(root,"storage/framework/core/buddy/src/cli.ts");function markdown(value){return String(value).replaceAll("\\","\\\\").replaceAll("|","\\|").replaceAll(`
|
|
2
|
-
`,"<br>")}function code(value){return`\`${String(value).replaceAll("`","\\`")}\``}function stablePath(value){if(typeof value!=="string"||!value.includes(root))return value;return value.replaceAll(root,"<project root>")}function optionFlags(option){return option.flags.map((flag)=>code(`${flag.length===1?"-":"--"}${flag}`)).join(", ")}function optionKind(option){return[option.boolean?"boolean":"value",option.required?"required":"optional",...option.negated?["negated"]:[]].join(", ")}function argumentSyntax(argument){const value=`${argument.name}${argument.variadic?"...":""}`;return code(argument.required?`<${value}>`:`[${value}]`)}function commandSection(command){const lines=[`### ${code(command.name)}`,"",command.description||"No description provided.","",`- Usage: ${code(command.usage)}`,`- Namespace: ${command.namespace?code(command.namespace):"none"}`,`- Aliases: ${command.aliases.length?command.aliases.map(code).join(", "):"none"}`,`- Arguments: ${command.arguments.length?command.arguments.map(argumentSyntax).join(", "):"none"}`];if(command.options.length)lines.push("","| Option | Description | Contract | Default |","| --- | --- | --- | --- |",...command.options.map((option)=>`| ${optionFlags(option)} | ${markdown(option.description||"
|
|
2
|
+
`,"<br>")}function code(value){return`\`${String(value).replaceAll("`","\\`")}\``}function stablePath(value){if(typeof value!=="string"||!value.includes(root))return value;return value.replaceAll(root,"<project root>")}function optionFlags(option){return option.flags.map((flag)=>code(`${flag.length===1?"-":"--"}${flag}`)).join(", ")}function optionKind(option){return[option.boolean?"boolean":"value",option.required?"required":"optional",...option.negated?["negated"]:[]].join(", ")}function argumentSyntax(argument){const value=`${argument.name}${argument.variadic?"...":""}`;return code(argument.required?`<${value}>`:`[${value}]`)}function commandSection(command){const lines=[`### ${code(command.name)}`,"",command.description||"No description provided.","",`- Usage: ${code(command.usage)}`,`- Namespace: ${command.namespace?code(command.namespace):"none"}`,`- Aliases: ${command.aliases.length?command.aliases.map(code).join(", "):"none"}`,`- Arguments: ${command.arguments.length?command.arguments.map(argumentSyntax).join(", "):"none"}`];if(command.options.length)lines.push("","| Option | Description | Contract | Default |","| --- | --- | --- | --- |",...command.options.map((option)=>`| ${optionFlags(option)} | ${markdown(option.description||"-")} | ${optionKind(option)} | ${option.default===void 0?"-":code(JSON.stringify(stablePath(option.default)))} |`));if(command.examples.length)lines.push("","Examples:","","```bash",...command.examples,"```");return lines.join(`
|
|
3
3
|
`)}export function renderBuddyCommandReference(inventory){const commands=[...inventory.commands].sort((a,b)=>a.name.localeCompare(b.name));if(inventory.total!==commands.length)throw Error(`Buddy inventory total ${inventory.total} does not match ${commands.length} commands`);if(new Set(commands.map((command)=>command.name)).size!==commands.length)throw Error("Buddy inventory contains duplicate canonical command names");const groups=new Map;for(const command of commands){const group=command.namespace||(command.name.includes(":")?command.name.split(":")[0]:"general"),entries=groups.get(group)||[];entries.push(command);groups.set(group,entries)}const groupSummary=[...groups.entries()].sort(([a],[b])=>a.localeCompare(b)).map(([group,entries])=>`| ${code(group)} | ${entries.length} |`);return`${["---","title: Buddy Command Reference","description: Generated reference for every Buddy command, argument, option, alias, default, and example.","---","<!-- Generated by storage/framework/core/buddy/src/commands/docs/buddy-commands.ts. Do not edit by hand. -->","","# Buddy Command Reference","",`This reference is generated from Buddy's runtime command registry and currently contains **${commands.length} commands**. Run \`bun run docs:buddy\` after changing the registry; CI rejects stale output.`,"","## Command groups","","| Group | Commands |","| --- | ---: |",...groupSummary,"","## Commands","",...commands.flatMap((command)=>[commandSection(command),""])].join(`
|
|
4
4
|
`).split(`
|
|
5
5
|
`).map((line)=>line.trimEnd()).join(`
|
package/dist/commands/doctor.js
CHANGED
|
@@ -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{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,"Database backups",async()=>{const fs=await import("node:fs"),cloudConfig=resolve(process.cwd(),"config/cloud.ts");if(!fs.existsSync(cloudConfig))return"No config/cloud.ts (skipped)";const{findUnbackedManagedServices,unbackedDataMessage}=await import("../unbacked-data");let tsCloud;try{tsCloud=(await import(cloudConfig)).tsCloud}catch{throw new ProbeWarning("could not read config/cloud.ts (skipped)")}const unbacked=findUnbackedManagedServices(tsCloud);if(unbacked.length===0)return"No unbacked managed data services";throw new ProbeWarning(unbackedDataMessage(unbacked))});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 - 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 - 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 - 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) - 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}) - 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(", ")} - 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,"Database backups",async()=>{const fs=await import("node:fs"),cloudConfig=resolve(process.cwd(),"config/cloud.ts");if(!fs.existsSync(cloudConfig))return"No config/cloud.ts (skipped)";const{findUnbackedManagedServices,unbackedDataMessage}=await import("../unbacked-data");let tsCloud;try{tsCloud=(await import(cloudConfig)).tsCloud}catch{throw new ProbeWarning("could not read config/cloud.ts (skipped)")}const unbacked=findUnbackedManagedServices(tsCloud);if(unbacked.length===0)return"No unbacked managed data services";throw new ProbeWarning(unbackedDataMessage(unbacked))});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")}
|
package/dist/commands/email.js
CHANGED
|
@@ -14,7 +14,7 @@ DKIM Records needed:`);for(const token of identity.DkimAttributes.Tokens){consol
|
|
|
14
14
|
<p>If you received this, your mail setup is working correctly!</p>
|
|
15
15
|
<hr><p style="color:#666;font-size:12px;">Sent from ${from.address} via the '${driver}' mailer.</p>
|
|
16
16
|
</body></html>`});if(result?.success===!1)throw Error(result?.message||"send returned success=false");console.log(`\u2705 Test email sent via '${driver}'!`);if(result?.messageId)console.log(` Message ID: ${result.messageId}`);if(driver==="log"||driver==="capture")console.log(`
|
|
17
|
-
\uD83D\uDCA1 The '${driver}' mailer does not deliver mail
|
|
17
|
+
\uD83D\uDCA1 The '${driver}' mailer does not deliver mail - set MAIL_MAILER=smtp (or ses) to send for real.`)}catch(error){console.error(`
|
|
18
18
|
\u274C Error sending test email via '${driver}':`,getErrorMessage(error));if(driver==="smtp")console.log(`
|
|
19
19
|
\uD83D\uDCA1 Check MAIL_HOST/MAIL_PORT/MAIL_USERNAME/MAIL_PASSWORD/MAIL_ENCRYPTION in your .env.`);else if(getErrorMessage(error).includes("not verified"))console.log("\n\uD83D\uDCA1 Tip: make sure your domain is verified in SES (`buddy email:verify`).")}process.exit(0)});buddy.command("email:list",descriptions.list).action(async()=>{console.log(`
|
|
20
20
|
\uD83D\uDCE7 Configured Mailboxes
|
package/dist/commands/env.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import process from"node:process";import{log,onUnknownSubcommand}from"@stacksjs/cli";import{decryptEnv,encryptEnv,getEnv,getKeypair,resolveEnvFile,rotateKeypair,setEnv}from"@stacksjs/env";import{ExitCode}from"@stacksjs/types";export function env(buddy){const descriptions={env:"Interact with your environment variables",get:"Get an environment variable",set:"Set an environment variable",encrypt:"Encrypt a value",decrypt:"Decrypt a value",check:"Check environment configuration and validate setup",pretty:"Pretty print the result",stdout:"Output the result to stdout",keypair:"Generate a keypair",fileKeys:"The path to the file containing the keys",excludeKey:"The key to exclude from encryption",rotate:"Rotate a keypair",format:"The format to output the result (json, shell, eval)",all:"Get all environment variables",file:"The environment file to use",project:"Target a specific project",verbose:"Enable verbose output"};buddy.command("env:get [key]",descriptions.get).option("-f, --file [file]",descriptions.file,{default:""}).option("--format [format]",descriptions.format,{default:"json"}).option("-a, --all",descriptions.all,{default:!1}).option("-p, --pretty",descriptions.pretty,{default:!1}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).example("buddy env:get SECRET").example("buddy env:get SECRET --file .env.production").example("buddy env:get --all --pretty").example("buddy env:get --format shell").example("buddy env:get --format eval").example("buddy env:get --format json").action(async(key,options)=>{log.debug("Running `buddy env:get` ...",options);const result=getEnv(key,{file:resolveEnvFile(options.file,options.env),keysFile:options.fileKeys,all:options.all,format:options.format,prettyPrint:options.pretty});if(result.success){console.log(result.output);process.exit(ExitCode.Success)}else{console.error(result.error);process.exit(ExitCode.FatalError)}});buddy.command("env:set [key] [value]",descriptions.set).option("-f, --file [file]",descriptions.file,{default:""}).option("-fk, --file-keys [fileKeys]",descriptions.fileKeys,{default:""}).option("--plain","Don't encrypt the value",{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).example("buddy env:set SECRET=value").example("buddy env:set SECRET value").example("buddy env:set SECRET value --file .env.production").example("buddy env:set SECRET value --plain").action(async(key,value,options)=>{log.debug("Running `buddy env:set` ...",options);if(!key){console.error("A key is required. Pass an empty value to clear it: `buddy env:set KEY ''`");process.exit(ExitCode.FatalError)}if(value===void 0){console.error(`No value given for ${key}. Pass one, or an empty string to clear it: \`buddy env:set ${key} ''\``);process.exit(ExitCode.FatalError)}const result=setEnv(key,value,{file:resolveEnvFile(options.file,options.env),keysFile:options.fileKeys,plain:options.plain});if(result.success){console.log(result.output);process.exit(ExitCode.Success)}else{console.error(result.error);process.exit(ExitCode.FatalError)}});buddy.command("env:encrypt [key]",descriptions.encrypt).option("-f, --file [file]",descriptions.file,{default:""}).option("-fk, --file-keys [fileKeys]",descriptions.fileKeys,{default:""}).option("-o, --stdout",descriptions.stdout,{default:!1}).option("-ek, --exclude-key [excludeKey]",descriptions.excludeKey,{default:""}).example("buddy env:encrypt").example("buddy env:encrypt --file .env.production").example('buddy env:encrypt -k "SECRET_*"').action(async(key,options)=>{log.debug("Running `buddy env:encrypt` ...",options);const result=encryptEnv({file:resolveEnvFile(options.file,options.env),keysFile:options.fileKeys,key,excludeKey:options.excludeKey,stdout:options.stdout});if(result.success){console.log(result.output);process.exit(ExitCode.Success)}else{console.error(result.error);process.exit(ExitCode.FatalError)}});buddy.command("env:decrypt [key]",descriptions.decrypt).option("-f, --file [file]",descriptions.file,{default:""}).option("-fk, --file-keys [fileKeys]",descriptions.fileKeys,{default:""}).option("-o, --stdout",descriptions.stdout,{default:!1}).example("buddy env:decrypt").example("buddy env:decrypt --file .env.production").example('buddy env:decrypt -k "SECRET_*"').action(async(key,options)=>{log.debug("Running `buddy env:decrypt` ...",options);const result=decryptEnv({file:resolveEnvFile(options.file,options.env),keysFile:options.fileKeys,key,stdout:options.stdout});if(result.success){console.log(result.output);process.exit(ExitCode.Success)}else{console.error(result.error);process.exit(ExitCode.FatalError)}});buddy.command("env:keypair [key]",descriptions.keypair).option("-f, --file [file]",descriptions.file,{default:""}).option("-fk, --file-keys [fileKeys]",descriptions.fileKeys,{default:""}).option("--format [format]",descriptions.format,{default:"json"}).example("buddy env:keypair").example("buddy env:keypair --file .env.production").example("buddy env:keypair DOTENV_PRIVATE_KEY").action(async(key,options)=>{log.debug("Running `buddy env:keypair` ...",options);const result=getKeypair(key,{file:resolveEnvFile(options.file,options.env),keysFile:options.fileKeys,format:options.format});if(result.success){console.log(result.output);process.exit(ExitCode.Success)}else{console.error(result.error);process.exit(ExitCode.FatalError)}});buddy.command("env:rotate [key]",descriptions.rotate).option("-f, --file [file]",descriptions.file,{default:""}).option("-fk, --file-keys [fileKeys]",descriptions.fileKeys,{default:""}).option("-o, --stdout",descriptions.stdout,{default:!1}).option("-ek, --exclude-key [excludeKey]",descriptions.excludeKey,{default:""}).example("buddy env:rotate").example("buddy env:rotate --file .env.production").action(async(key,options)=>{log.debug("Running `buddy env:rotate` ...",options);const result=rotateKeypair({file:resolveEnvFile(options.file,options.env),keysFile:options.fileKeys,key,excludeKey:options.excludeKey,stdout:options.stdout});if(result.success){console.log(result.output);process.exit(ExitCode.Success)}else{console.error(result.error);process.exit(ExitCode.FatalError)}});buddy.command("env:check",descriptions.check).option("-f, --file [file]",descriptions.file,{default:""}).option("--strict","Require every value in a committed env file to be encrypted, not just secret-shaped ones",{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).example("buddy env:check").example("buddy env:check --file .env.production").example("buddy env:check --file .env.production --strict").action(async(options)=>{log.debug("Running `buddy env:check` ...",options);const{bold,dim,green,red,yellow,intro}=await import("@stacksjs/cli"),{storage}=await import("@stacksjs/storage"),{existsSync}=await import("node:fs"),{resolve}=await import("node:path");await intro("buddy env:check");const checks=[],envFile=options.file||".env",envPath=resolve(process.cwd(),envFile);if(existsSync(envPath)){checks.push({name:`${envFile} file`,status:"pass",message:"Found"});try{const envContent=await storage.readTextFile(envPath),contentStr=typeof envContent==="string"?envContent:envContent.data,values=parseEnvAssignments(contentStr),keys=Object.keys(values),varCount=keys.length;checks.push({name:"Environment variables",status:"pass",message:`${varCount} variables defined`});if("APP_KEY"in values)if((values.APP_KEY??"").length>0)checks.push({name:"APP_KEY",status:"pass",message:"Set"});else checks.push({name:"APP_KEY",status:"warn",message:"Empty (run: buddy key:generate)"});else checks.push({name:"APP_KEY",status:"warn",message:"Not found (run: buddy key:generate)"});const hasPublicKey=keys.some((key)=>key.startsWith("DOTENV_PUBLIC_KEY")),hasPrivateKey=keys.some((key)=>key.startsWith("DOTENV_PRIVATE_KEY"))||existsSync(resolve(process.cwd(),".env.keys"));if(hasPublicKey&&hasPrivateKey)checks.push({name:"Encryption keys",status:"pass",message:"Public and private keys configured"});else if(hasPublicKey||hasPrivateKey)checks.push({name:"Encryption keys",status:"warn",message:"Incomplete keypair (run: buddy env:keypair)"});else checks.push({name:"Encryption keys",status:"warn",message:"Not configured (optional)"});const declaredTenants=await resolveDeclaredTenants();if(declaredTenants.tenants.length===0)checks.push({name:"Tenant isolation",status:"pass",message:"No tenants declared (cloud.tenants)"});else{const{foreignTenantKeys,partitionTenantEnv}=await import("@stacksjs/env"),foreign=foreignTenantKeys(partitionTenantEnv(values,declaredTenants)),isShipped=/^\.env\.[a-z]+$/.test(envFile),total=foreign.reduce((sum,entry)=>sum+entry.keys.length,0);if(foreign.length===0)checks.push({name:"Tenant isolation",status:"pass",message:`No foreign keys (checked ${declaredTenants.tenants.join(", ")})`});else if(!isShipped)checks.push({name:"Tenant isolation",status:"pass",message:`${total} archived key(s) \u2014 ${envFile} is local-only and never deployed`});else{checks.push({name:"Tenant isolation",status:"warn",message:`${total} key(s) belong to another tenant \u2014 move them to .env`});for(const{tenant,keys}of foreign)checks.push({name:` ${tenant}`,status:"warn",message:keys.join(", ")})}}const{plaintextSecrets,trackedEnvFiles}=await import("@stacksjs/env");if(!trackedEnvFiles(gitLsFiles()).includes(envFile))checks.push({name:"Committed secrets",status:"pass",message:`${envFile} is not committed; plaintext here stays local`});else{let placeholders={};try{const examplePath=resolve(process.cwd(),".env.example");if(existsSync(examplePath))placeholders=parseEnvAssignments(await storage.readTextFile(examplePath).then((f)=>f.data))}catch{}const leaked=plaintextSecrets(values,{placeholders,strict:options.strict});if(leaked.length===0)checks.push({name:"Committed secrets",status:"pass",message:`No unencrypted secrets in ${envFile}`});else checks.push({name:"Committed secrets",status:"fail",message:`${leaked.length} unencrypted secret${leaked.length===1?"":"s"} in committed ${envFile}: ${leaked.map((f)=>f.key).join(", ")}. Run \`buddy env:encrypt\`, then rotate them \u2014 they are in git history.`})}}catch(error){checks.push({name:`${envFile} content`,status:"fail",message:`Cannot read file: ${error}`})}}else checks.push({name:`${envFile} file`,status:"fail",message:"Not found"});const keysPath=resolve(process.cwd(),".env.keys");if(existsSync(keysPath))checks.push({name:".env.keys file",status:"pass",message:"Found"});else checks.push({name:".env.keys file",status:"warn",message:"Not found (optional for encryption)"});console.log("");console.log(bold("Environment Configuration Check:"));console.log(dim("\u2500".repeat(60)));console.log("");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}console.log(`${statusColor(statusIcon)} ${bold(check.name.padEnd(25))} ${dim(check.message)}`)}console.log("");console.log(dim("\u2500".repeat(60)));console.log("");if(hasFailures){console.log(red("\u2717 Some critical checks failed. Please address the issues above."));process.exit(ExitCode.FatalError)}else if(hasWarnings){console.log(yellow("\u26A0 Some checks have warnings. Your environment should work but may have issues."));process.exit(ExitCode.Success)}else{console.log(green("\u2713 All checks passed! Your environment configuration looks healthy."));process.exit(ExitCode.Success)}});onUnknownSubcommand(buddy,"env")}async function resolveDeclaredTenants(){try{const{config}=await import("@stacksjs/config"),cloud=config.cloud,app=config.app,tenants=Array.isArray(cloud?.tenants)?cloud.tenants.filter((slug)=>typeof slug==="string"):[];return{self:app?.name,tenants}}catch{return{tenants:[]}}}function gitLsFiles(){try{const result=Bun.spawnSync(["git","ls-files"],{cwd:process.cwd(),stdout:"pipe",stderr:"ignore"});return result.success?result.stdout.toString():""}catch{return""}}export function parseEnvAssignments(content){const values={},assignment=/^\s*(?:export\s+)?([A-Z_][A-Z0-9_]*)\s*=\s*(.*)$/i,lines=content.split(/\r?\n/);for(let i=0;i<lines.length;i++){const line=lines[i];if(!line.trim()||line.trimStart().startsWith("#"))continue;const match=line.match(assignment);if(!match)continue;const[,key,first]=match;let raw=first.trim();const quote=raw[0]==='"'||raw[0]==="'"?raw[0]:void 0;if(quote){raw=raw.slice(1);while(!raw.endsWith(quote)&&i+1<lines.length){i++;raw+=lines[i]}raw=raw.endsWith(quote)?raw.slice(0,-1):raw}else raw=raw.replace(/\s+#.*$/,"").trim();values[key]=raw}return values}
|
|
1
|
+
import process from"node:process";import{log,onUnknownSubcommand}from"@stacksjs/cli";import{decryptEnv,encryptEnv,getEnv,getKeypair,resolveEnvFile,rotateKeypair,setEnv}from"@stacksjs/env";import{ExitCode}from"@stacksjs/types";export function env(buddy){const descriptions={env:"Interact with your environment variables",get:"Get an environment variable",set:"Set an environment variable",encrypt:"Encrypt a value",decrypt:"Decrypt a value",check:"Check environment configuration and validate setup",pretty:"Pretty print the result",stdout:"Output the result to stdout",keypair:"Generate a keypair",fileKeys:"The path to the file containing the keys",excludeKey:"The key to exclude from encryption",rotate:"Rotate a keypair",format:"The format to output the result (json, shell, eval)",all:"Get all environment variables",file:"The environment file to use",project:"Target a specific project",verbose:"Enable verbose output"};buddy.command("env:get [key]",descriptions.get).option("-f, --file [file]",descriptions.file,{default:""}).option("--format [format]",descriptions.format,{default:"json"}).option("-a, --all",descriptions.all,{default:!1}).option("-p, --pretty",descriptions.pretty,{default:!1}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).example("buddy env:get SECRET").example("buddy env:get SECRET --file .env.production").example("buddy env:get --all --pretty").example("buddy env:get --format shell").example("buddy env:get --format eval").example("buddy env:get --format json").action(async(key,options)=>{log.debug("Running `buddy env:get` ...",options);const result=getEnv(key,{file:resolveEnvFile(options.file,options.env),keysFile:options.fileKeys,all:options.all,format:options.format,prettyPrint:options.pretty});if(result.success){console.log(result.output);process.exit(ExitCode.Success)}else{console.error(result.error);process.exit(ExitCode.FatalError)}});buddy.command("env:set [key] [value]",descriptions.set).option("-f, --file [file]",descriptions.file,{default:""}).option("-fk, --file-keys [fileKeys]",descriptions.fileKeys,{default:""}).option("--plain","Don't encrypt the value",{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).example("buddy env:set SECRET=value").example("buddy env:set SECRET value").example("buddy env:set SECRET value --file .env.production").example("buddy env:set SECRET value --plain").action(async(key,value,options)=>{log.debug("Running `buddy env:set` ...",options);if(!key){console.error("A key is required. Pass an empty value to clear it: `buddy env:set KEY ''`");process.exit(ExitCode.FatalError)}if(value===void 0){console.error(`No value given for ${key}. Pass one, or an empty string to clear it: \`buddy env:set ${key} ''\``);process.exit(ExitCode.FatalError)}const result=setEnv(key,value,{file:resolveEnvFile(options.file,options.env),keysFile:options.fileKeys,plain:options.plain});if(result.success){console.log(result.output);process.exit(ExitCode.Success)}else{console.error(result.error);process.exit(ExitCode.FatalError)}});buddy.command("env:encrypt [key]",descriptions.encrypt).option("-f, --file [file]",descriptions.file,{default:""}).option("-fk, --file-keys [fileKeys]",descriptions.fileKeys,{default:""}).option("-o, --stdout",descriptions.stdout,{default:!1}).option("-ek, --exclude-key [excludeKey]",descriptions.excludeKey,{default:""}).example("buddy env:encrypt").example("buddy env:encrypt --file .env.production").example('buddy env:encrypt -k "SECRET_*"').action(async(key,options)=>{log.debug("Running `buddy env:encrypt` ...",options);const result=encryptEnv({file:resolveEnvFile(options.file,options.env),keysFile:options.fileKeys,key,excludeKey:options.excludeKey,stdout:options.stdout});if(result.success){console.log(result.output);process.exit(ExitCode.Success)}else{console.error(result.error);process.exit(ExitCode.FatalError)}});buddy.command("env:decrypt [key]",descriptions.decrypt).option("-f, --file [file]",descriptions.file,{default:""}).option("-fk, --file-keys [fileKeys]",descriptions.fileKeys,{default:""}).option("-o, --stdout",descriptions.stdout,{default:!1}).example("buddy env:decrypt").example("buddy env:decrypt --file .env.production").example('buddy env:decrypt -k "SECRET_*"').action(async(key,options)=>{log.debug("Running `buddy env:decrypt` ...",options);const result=decryptEnv({file:resolveEnvFile(options.file,options.env),keysFile:options.fileKeys,key,stdout:options.stdout});if(result.success){console.log(result.output);process.exit(ExitCode.Success)}else{console.error(result.error);process.exit(ExitCode.FatalError)}});buddy.command("env:keypair [key]",descriptions.keypair).option("-f, --file [file]",descriptions.file,{default:""}).option("-fk, --file-keys [fileKeys]",descriptions.fileKeys,{default:""}).option("--format [format]",descriptions.format,{default:"json"}).example("buddy env:keypair").example("buddy env:keypair --file .env.production").example("buddy env:keypair DOTENV_PRIVATE_KEY").action(async(key,options)=>{log.debug("Running `buddy env:keypair` ...",options);const result=getKeypair(key,{file:resolveEnvFile(options.file,options.env),keysFile:options.fileKeys,format:options.format});if(result.success){console.log(result.output);process.exit(ExitCode.Success)}else{console.error(result.error);process.exit(ExitCode.FatalError)}});buddy.command("env:rotate [key]",descriptions.rotate).option("-f, --file [file]",descriptions.file,{default:""}).option("-fk, --file-keys [fileKeys]",descriptions.fileKeys,{default:""}).option("-o, --stdout",descriptions.stdout,{default:!1}).option("-ek, --exclude-key [excludeKey]",descriptions.excludeKey,{default:""}).example("buddy env:rotate").example("buddy env:rotate --file .env.production").action(async(key,options)=>{log.debug("Running `buddy env:rotate` ...",options);const result=rotateKeypair({file:resolveEnvFile(options.file,options.env),keysFile:options.fileKeys,key,excludeKey:options.excludeKey,stdout:options.stdout});if(result.success){console.log(result.output);process.exit(ExitCode.Success)}else{console.error(result.error);process.exit(ExitCode.FatalError)}});buddy.command("env:check",descriptions.check).option("-f, --file [file]",descriptions.file,{default:""}).option("--strict","Require every value in a committed env file to be encrypted, not just secret-shaped ones",{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).example("buddy env:check").example("buddy env:check --file .env.production").example("buddy env:check --file .env.production --strict").action(async(options)=>{log.debug("Running `buddy env:check` ...",options);const{bold,dim,green,red,yellow,intro}=await import("@stacksjs/cli"),{storage}=await import("@stacksjs/storage"),{existsSync}=await import("node:fs"),{resolve}=await import("node:path");await intro("buddy env:check");const checks=[],envFile=options.file||".env",envPath=resolve(process.cwd(),envFile);if(existsSync(envPath)){checks.push({name:`${envFile} file`,status:"pass",message:"Found"});try{const envContent=await storage.readTextFile(envPath),contentStr=typeof envContent==="string"?envContent:envContent.data,values=parseEnvAssignments(contentStr),keys=Object.keys(values),varCount=keys.length;checks.push({name:"Environment variables",status:"pass",message:`${varCount} variables defined`});if("APP_KEY"in values)if((values.APP_KEY??"").length>0)checks.push({name:"APP_KEY",status:"pass",message:"Set"});else checks.push({name:"APP_KEY",status:"warn",message:"Empty (run: buddy key:generate)"});else checks.push({name:"APP_KEY",status:"warn",message:"Not found (run: buddy key:generate)"});const hasPublicKey=keys.some((key)=>key.startsWith("DOTENV_PUBLIC_KEY")),hasPrivateKey=keys.some((key)=>key.startsWith("DOTENV_PRIVATE_KEY"))||existsSync(resolve(process.cwd(),".env.keys"));if(hasPublicKey&&hasPrivateKey)checks.push({name:"Encryption keys",status:"pass",message:"Public and private keys configured"});else if(hasPublicKey||hasPrivateKey)checks.push({name:"Encryption keys",status:"warn",message:"Incomplete keypair (run: buddy env:keypair)"});else checks.push({name:"Encryption keys",status:"warn",message:"Not configured (optional)"});const declaredTenants=await resolveDeclaredTenants();if(declaredTenants.tenants.length===0)checks.push({name:"Tenant isolation",status:"pass",message:"No tenants declared (cloud.tenants)"});else{const{foreignTenantKeys,partitionTenantEnv}=await import("@stacksjs/env"),foreign=foreignTenantKeys(partitionTenantEnv(values,declaredTenants)),isShipped=/^\.env\.[a-z]+$/.test(envFile),total=foreign.reduce((sum,entry)=>sum+entry.keys.length,0);if(foreign.length===0)checks.push({name:"Tenant isolation",status:"pass",message:`No foreign keys (checked ${declaredTenants.tenants.join(", ")})`});else if(!isShipped)checks.push({name:"Tenant isolation",status:"pass",message:`${total} archived key(s) - ${envFile} is local-only and never deployed`});else{checks.push({name:"Tenant isolation",status:"warn",message:`${total} key(s) belong to another tenant - move them to .env`});for(const{tenant,keys}of foreign)checks.push({name:` ${tenant}`,status:"warn",message:keys.join(", ")})}}const{plaintextSecrets,trackedEnvFiles}=await import("@stacksjs/env");if(!trackedEnvFiles(gitLsFiles()).includes(envFile))checks.push({name:"Committed secrets",status:"pass",message:`${envFile} is not committed; plaintext here stays local`});else{let placeholders={};try{const examplePath=resolve(process.cwd(),".env.example");if(existsSync(examplePath))placeholders=parseEnvAssignments(await storage.readTextFile(examplePath).then((f)=>f.data))}catch{}const leaked=plaintextSecrets(values,{placeholders,strict:options.strict});if(leaked.length===0)checks.push({name:"Committed secrets",status:"pass",message:`No unencrypted secrets in ${envFile}`});else checks.push({name:"Committed secrets",status:"fail",message:`${leaked.length} unencrypted secret${leaked.length===1?"":"s"} in committed ${envFile}: ${leaked.map((f)=>f.key).join(", ")}. Run \`buddy env:encrypt\`, then rotate them - they are in git history.`})}}catch(error){checks.push({name:`${envFile} content`,status:"fail",message:`Cannot read file: ${error}`})}}else checks.push({name:`${envFile} file`,status:"fail",message:"Not found"});const keysPath=resolve(process.cwd(),".env.keys");if(existsSync(keysPath))checks.push({name:".env.keys file",status:"pass",message:"Found"});else checks.push({name:".env.keys file",status:"warn",message:"Not found (optional for encryption)"});console.log("");console.log(bold("Environment Configuration Check:"));console.log(dim("\u2500".repeat(60)));console.log("");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}console.log(`${statusColor(statusIcon)} ${bold(check.name.padEnd(25))} ${dim(check.message)}`)}console.log("");console.log(dim("\u2500".repeat(60)));console.log("");if(hasFailures){console.log(red("\u2717 Some critical checks failed. Please address the issues above."));process.exit(ExitCode.FatalError)}else if(hasWarnings){console.log(yellow("\u26A0 Some checks have warnings. Your environment should work but may have issues."));process.exit(ExitCode.Success)}else{console.log(green("\u2713 All checks passed! Your environment configuration looks healthy."));process.exit(ExitCode.Success)}});onUnknownSubcommand(buddy,"env")}async function resolveDeclaredTenants(){try{const{config}=await import("@stacksjs/config"),cloud=config.cloud,app=config.app,tenants=Array.isArray(cloud?.tenants)?cloud.tenants.filter((slug)=>typeof slug==="string"):[];return{self:app?.name,tenants}}catch{return{tenants:[]}}}function gitLsFiles(){try{const result=Bun.spawnSync(["git","ls-files"],{cwd:process.cwd(),stdout:"pipe",stderr:"ignore"});return result.success?result.stdout.toString():""}catch{return""}}export function parseEnvAssignments(content){const values={},assignment=/^\s*(?:export\s+)?([A-Z_][A-Z0-9_]*)\s*=\s*(.*)$/i,lines=content.split(/\r?\n/);for(let i=0;i<lines.length;i++){const line=lines[i];if(!line.trim()||line.trimStart().startsWith("#"))continue;const match=line.match(assignment);if(!match)continue;const[,key,first]=match;let raw=first.trim();const quote=raw[0]==='"'||raw[0]==="'"?raw[0]:void 0;if(quote){raw=raw.slice(1);while(!raw.endsWith(quote)&&i+1<lines.length){i++;raw+=lines[i]}raw=raw.endsWith(quote)?raw.slice(0,-1):raw}else raw=raw.replace(/\s+#.*$/,"").trim();values[key]=raw}return values}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import process from"node:process";import{log}from"@stacksjs/cli";function parseSafariPlatforms(platform){if(!platform)return;if(platform==="all")return["macos","ios"];if(platform==="macos"||platform==="ios")return[platform];throw Error(`Invalid Safari platform ${platform}; use macos, ios, or all`)}async function refreshAppStoreScreenshots(){try{const{generateProjectImages}=await import("@stacksjs/actions");await generateProjectImages({only:["app-store"]})}catch(error){log.warn(`[extension:publish] using the committed screenshots
|
|
1
|
+
import process from"node:process";import{log}from"@stacksjs/cli";function parseSafariPlatforms(platform){if(!platform)return;if(platform==="all")return["macos","ios"];if(platform==="macos"||platform==="ios")return[platform];throw Error(`Invalid Safari platform ${platform}; use macos, ios, or all`)}async function refreshAppStoreScreenshots(){try{const{generateProjectImages}=await import("@stacksjs/actions");await generateProjectImages({only:["app-store"]})}catch(error){log.warn(`[extension:publish] using the committed screenshots - could not regenerate: ${error.message}`)}}async function syncFirefoxListing(config,credentials){if(!config.firefoxAddons?.screenshots?.length)return;try{const{syncFirefoxPreviews}=await import("@stacksjs/browser-extension"),previews=await syncFirefoxPreviews(config,credentials);if(previews.unchanged)log.info("Firefox listing screenshots already match");else log.success(`Synced ${previews.uploaded.length} Firefox listing screenshot(s), removed ${previews.removed.length}`)}catch(error){log.warn(`[extension:publish] Firefox listing screenshots not synced: ${error.message}`)}}export function extension(buddy){const load=async()=>{const{loadExtensionConfig}=await import("@stacksjs/browser-extension"),config=await loadExtensionConfig(process.cwd());if(!config){log.error("No extension config found. Create `config/extension.ts` exporting `defineExtension({ \u2026 })`.");process.exit(1)}const pkg=await Bun.file(`${process.cwd()}/package.json`).json().catch(()=>({}));return{config,version:pkg.version??"0.0.0"}};buddy.command("extension:build","Build the browser extension (Chrome + Firefox + Safari) from config/extension.ts").option("--target <target>","Build a single target (chrome | firefox | safari); omit to build all").option("--version <version>","Override the extension version (defaults to package.json)").action(async(options)=>{const{buildExtension,buildAllTargets}=await import("@stacksjs/browser-extension"),{config,version}=await load(),v=options.version??version;if(options.target){const{outdir}=await buildExtension(config,{target:options.target,version:v});log.success(`Built ${config.name} ${v} (${options.target}) \u2192 ${outdir}`)}else{await buildAllTargets(config,{version:v});log.success(`Built ${config.name} ${v} for ${(config.targets??["chrome","firefox"]).join(", ")}`)}});buddy.command("extension:init","Scaffold a Chrome, Firefox, or Safari extension, including the Safari Xcode app").option("--name <name>","Extension display name").option("--target <target>","Scaffold chrome, firefox, safari, or all (default all)").option("--bundle-id <id>","Safari container bundle identifier").option("--team-id <id>","Apple Developer team used for Safari signing").option("--platform <platform>","Safari platform: macos, ios, or all (default all)").option("--force","Overwrite existing starter and Safari scaffold files").action(async(options)=>{const target=options.target??"all";if(!["chrome","firefox","safari","all"].includes(target))throw Error(`Invalid extension target ${target}; use chrome, firefox, safari, or all`);const{scaffoldExtensionProject}=await import("@stacksjs/browser-extension"),result=await scaffoldExtensionProject({name:options.name,target,bundleId:options.bundleId,teamId:options.teamId,platforms:parseSafariPlatforms(options.platform)??["macos","ios"],force:Boolean(options.force)});for(const file of result.written)log.success(`created ${file}`);for(const file of result.skipped)log.info(`skip (exists): ${file}`);if(result.safari)log.success(`Scaffolded the Safari container app \u2192 ${result.safari.dir}`);log.info("Next: add icons under public/icons, then run `buddy extension:build`.")});buddy.command("extension:package","Build + zip the browser extension into store-ready archives").option("--target <target>","Package a single target (chrome | firefox | safari); omit to package all").option("--version <version>","Override the extension version (defaults to package.json)").action(async(options)=>{const{packageExtension}=await import("@stacksjs/browser-extension"),{config,version}=await load(),v=options.version??version,targets=options.target?[options.target]:config.targets??["chrome","firefox"];for(const target of targets){const out=await packageExtension(config,{target,version:v});log.success(`Packaged ${config.name} (${target}) \u2192 ${out}`)}});buddy.command("extension:publish","Publish to every store this project is set up for - the release-tag entry point").option("--version <version>","Override the extension version (defaults to package.json)").option("--targets <targets>","Comma-separated subset of chrome,firefox,safari").option("--dry-run","Report the publish plan without uploading anything").action(async(options)=>{const{formatPublishPlan,planExtensionPublish,publishChromeExtension,publishFirefoxExtension,publishSafariApp}=await import("@stacksjs/browser-extension"),{config,version}=await load(),requested=options.targets?.split(",").map((value)=>value.trim()).filter(Boolean),plan=planExtensionPublish(config,process.env,requested?.length?requested:void 0);log.info(`Extension publish plan for v${options.version??version}:
|
|
2
2
|
${formatPublishPlan(plan)}`);const publishing=plan.filter((decision)=>decision.publish);if(options.dryRun||!publishing.length){if(!publishing.length)log.warn("No store is both configured and credentialed, so nothing was published.");return}const failures=[];for(const{target}of publishing)try{if(target==="chrome"){const result=await publishChromeExtension(config,{version:options.version??version,blockOnWarnings:!0});if(result.deferred)log.warn(`${result.deferred.reason}. Version ${options.version??version} remains queued for the next automated retry.`);else if(result.alreadyPublished)log.success(result.alreadyPublished.reason);else log.success(`Submitted Chrome Web Store item ${result.publish?.itemId??""}: ${result.publish?.state??"uploaded"}`)}else if(target==="firefox"){const result=await publishFirefoxExtension(config,{version:options.version??version});log.success(`Submitted Firefox extension (${result.channel}) \u2192 ${result.artifactsDir}`);await syncFirefoxListing(config,{})}else{await refreshAppStoreScreenshots();await publishSafariApp(config,{version:options.version??version});log.success("Uploaded Safari app to App Store Connect")}}catch(error){failures.push(`${target}: ${error.message}`);log.error(`[extension:publish] ${target} failed: ${error.message}`)}if(failures.length){log.error(`Failed to publish ${failures.length} of ${publishing.length} store(s):
|
|
3
3
|
${failures.join(`
|
|
4
|
-
`)}`);process.exit(1)}});buddy.command("extension:chrome:status","Fetch the Chrome Web Store item status").option("--service-account-path <path>","Google service-account JSON key path").option("--access-token <token>","Short-lived Chrome Web Store OAuth access token").action(async(options)=>{const{ChromeWebStoreClient}=await import("@stacksjs/browser-extension"),{config}=await load();if(!config.chromeWebStore)throw Error("Chrome status needs chromeWebStore.publisherId and chromeWebStore.itemId in config/extension.ts");const status=await new ChromeWebStoreClient(options).fetchStatus(config.chromeWebStore);log.info(`Chrome Web Store item ${status.itemId}`);log.info(`published: ${status.publishedItemRevisionStatus?.state??"none"}`);log.info(`submitted: ${status.submittedItemRevisionStatus?.state??"none"}`);if(status.warned)log.warn("Chrome has warned this item for a policy violation.");if(status.takenDown)log.error("Chrome has taken this item down for a policy violation.")});buddy.command("extension:chrome:publish","Build, upload, and submit the Chrome extension through Web Store API v2").option("--version <version>","Override the extension version (defaults to package.json)").option("--service-account-path <path>","Google service-account JSON key path").option("--access-token <token>","Short-lived Chrome Web Store OAuth access token").option("--upload-only","Upload without submitting the item for review").option("--allow-warnings","Submit even when Chrome reports validation warnings").action(async(options)=>{const{publishChromeExtension}=await import("@stacksjs/browser-extension"),{config,version}=await load(),result=await publishChromeExtension(config,{version:options.version??version,serviceAccountPath:options.serviceAccountPath,accessToken:options.accessToken,uploadOnly:Boolean(options.uploadOnly),blockOnWarnings:!options.allowWarnings});if(result.deferred){log.warn(`${result.deferred.reason}. Version ${options.version??version} remains queued for the next automated retry.`);return}if(result.alreadyPublished){log.success(result.alreadyPublished.reason);return}log.success(`Uploaded Chrome package ${result.packagePath} (${result.upload?.crxVersion??"processing complete"})`);if(result.publish)log.success(`Submitted Chrome Web Store item ${result.publish.itemId}: ${result.publish.state}`)});buddy.command("extension:firefox:previews","Sync the Firefox listing screenshots declared in config/extension.ts").option("--api-key <issuer>","AMO JWT issuer").option("--api-secret <secret>","AMO JWT secret").option("--dry-run","Report what would change without touching the listing").action(async(options)=>{const{syncFirefoxPreviews}=await import("@stacksjs/browser-extension"),{config}=await load(),result=await syncFirefoxPreviews(config,{issuer:options.apiKey,secret:options.apiSecret,dryRun:options.dryRun});if(result.unchanged)log.info("Firefox listing screenshots already match config/extension.ts");else if(options.dryRun)log.info(`Would replace ${result.removed.length} Firefox listing screenshot(s)`);else log.success(`Synced ${result.uploaded.length} Firefox listing screenshot(s), removed ${result.removed.length}`)});buddy.command("extension:firefox:publish","Build and submit the Firefox extension through Mozilla Add-ons").option("--version <version>","Override the extension version (defaults to package.json)").option("--api-key <issuer>","AMO JWT issuer").option("--api-secret <secret>","AMO JWT secret").option("--source-code <path>","Human-readable source archive for AMO review").option("--approval-timeout <milliseconds>","How long to wait for human approval (default 0)").action(async(options)=>{const{publishFirefoxExtension}=await import("@stacksjs/browser-extension"),{config,version}=await load(),result=await publishFirefoxExtension(config,{version:options.version??version,issuer:options.apiKey,secret:options.apiSecret,sourceCodePath:options.sourceCode,approvalTimeout:options.approvalTimeout===void 0?void 0:Number(options.approvalTimeout)});log.success(`Submitted Firefox extension (${result.channel}) \u2192 ${result.artifactsDir}`);if(config.firefoxAddons?.screenshots?.length)try{const{syncFirefoxPreviews}=await import("@stacksjs/browser-extension"),previews=await syncFirefoxPreviews(config,{issuer:options.apiKey,secret:options.apiSecret});if(previews.unchanged)log.info("Firefox listing screenshots already match");else log.success(`Synced ${previews.uploaded.length} Firefox listing screenshot(s), removed ${previews.removed.length}`)}catch(error){log.warn(`[extension:firefox:publish] listing screenshots left as they were: ${error.message}`)}if(result.artifacts.length)log.info(`new artifacts: ${result.artifacts.join(", ")}`)});buddy.command("extension:safari:provision","Register Safari Bundle IDs and check the App Store Connect app record").option("--api-key-id <id>","App Store Connect API key ID").option("--api-issuer-id <id>","App Store Connect API issuer ID").option("--api-key-path <path>","Path to the App Store Connect AuthKey_*.p8 file").option("--check","Report missing resources without creating Bundle IDs").option("--version <version>","Create or align App Store versions (defaults to package.json)").option("--platform <platform>","Provision macos, ios, or all (defaults to config safariPlatforms)").action(async(options)=>{const{provisionSafariApp}=await import("@stacksjs/browser-extension"),{config,version}=await load(),result=await provisionSafariApp(config,{keyId:options.apiKeyId,issuerId:options.apiIssuerId,keyPath:options.apiKeyPath,checkOnly:Boolean(options.check),version:options.version??version,platforms:parseSafariPlatforms(options.platform)});for(const resource of[result.container,result.extension])if(resource.created)log.success(`Registered Bundle ID ${resource.identifier}`);else if(resource.exists)log.success(`Bundle ID exists: ${resource.identifier}`);else log.warn(`Bundle ID is missing: ${resource.identifier}`);if(result.appRecord.exists)log.success(`App Store Connect app record exists (${result.appRecord.id})`);else log.warn("App Store Connect app record is missing. Apple requires creating it in the App Store Connect website.");for(const appStoreVersion of result.appStoreVersions){const action=appStoreVersion.created?"Created":appStoreVersion.updated?"Updated":"Ready";log.success(`${action} Safari ${appStoreVersion.platform} App Store version ${appStoreVersion.version}`)}});buddy.command("extension:safari:init","Scaffold the Safari container app (Xcode project) from the template").option("--bundle-id <id>","Base bundle identifier (defaults to config safariBundleId)").option("--dir <dir>","Output directory for the Xcode project (default safari)").option("--force","Overwrite existing scaffold files").option("--team-id <id>","Apple Developer team used for signing").action(async(options)=>{const{scaffoldSafariApp}=await import("@stacksjs/browser-extension"),{config}=await load(),{dir,written,skipped}=await scaffoldSafariApp(config,{bundleId:options.bundleId,dir:options.dir,force:Boolean(options.force),teamId:options.teamId});log.success(`Scaffolded the Safari container app \u2192 ${dir} (${written.length} files)`);if(skipped.length)log.info(`kept ${skipped.length} existing files (use --force to overwrite)`)});buddy.command("extension:safari:app","Build the extension and its macOS, iPhone, and iPad Safari container apps").option("--release","Build the Release configuration (default Debug)").option("--signed","Sign locally against the Apple ID in Xcode (local builds only \u2014 see below)").option("--skip-xcodebuild","Only build + sync the extension payload").option("--version <version>","Override the extension version (defaults to package.json)").option("--platform <platform>","Build macos, ios, or all (defaults to config safariPlatforms)").action(async(options)=>{const{buildSafariApp,buildSafariUniversalApp}=await import("@stacksjs/browser-extension"),{config,version}=await load(),platforms=parseSafariPlatforms(options.platform)??config.safariPlatforms??["macos"];if(platforms.includes("ios")){const result=await buildSafariUniversalApp(config,{version:options.version??version,release:Boolean(options.release),signed:Boolean(options.signed),skipXcodebuild:Boolean(options.skipXcodebuild),platforms});for(const platform of platforms){const appPath=result.appPaths[platform];if(appPath)log.success(`Built Safari ${platform} app ${appPath}`)}if(options.skipXcodebuild)log.success(`Generated universal Safari project \u2192 ${result.project}`);if(result.appPaths.macos)log.info("Open the macOS app once, then enable the extension in Safari > Settings > Extensions.");if(result.appPaths.ios)log.info("Install the iOS app on an iPhone, iPad, or Simulator, then enable it in Settings > Apps > Safari > Extensions.");return}const{appPath,resources}=await buildSafariApp(config,{version:options.version??version,release:Boolean(options.release),signed:Boolean(options.signed),skipXcodebuild:Boolean(options.skipXcodebuild)});if(appPath){log.success(`Built ${appPath}`);log.info("Open the app once, then enable the extension in Safari > Settings > Extensions.")}else log.success(`Extension payload synced \u2192 ${resources}`)});buddy.command("extension:safari:publish","Archive and validate or upload the Safari app to App Store Connect").option("--version <version>","Override the marketing version (defaults to package.json)").option("--build-number <number>","CFBundleVersion (defaults to GITHUB_RUN_NUMBER or Unix time)").option("--team-id <id>","Apple Developer team (defaults to config safariTeamId)").option("--api-key-id <id>","App Store Connect API key ID").option("--api-issuer-id <id>","App Store Connect API issuer ID").option("--api-key-path <path>","Path to the App Store Connect AuthKey_*.p8 file").option("--validate-only","Create and validate the archive without uploading it").option("--platform <platform>","Publish macos, ios, or all (defaults to config safariPlatforms)").option("--skip-screenshots","Do not regenerate the App Store screenshot set before publishing").action(async(options)=>{const{publishSafariApp}=await import("@stacksjs/browser-extension"),{config,version}=await load();if(!options.skipScreenshots)try{const{generateProjectImages}=await import("@stacksjs/actions");await generateProjectImages({only:["app-store"]})}catch(error){log.warn(`[extension:safari:publish] using the committed screenshots \u2014 could not regenerate: ${error.message}`)}const result=await publishSafariApp(config,{version:options.version??version,buildNumber:options.buildNumber,teamId:options.teamId,keyId:options.apiKeyId,issuerId:options.apiIssuerId,keyPath:options.apiKeyPath,validateOnly:Boolean(options.validateOnly),platforms:parseSafariPlatforms(options.platform)});for(const deferred of result.deferred)log.warn(`${deferred.reason}. Version ${deferred.version} remains queued for the next automated retry.`);for(const published of result.alreadyPublished)log.success(`Safari ${published.platform} version ${published.version} is already published${published.state?` (${published.state})`:""}`);if(result.artifacts.length)log.success(options.validateOnly?`Validated Safari ${result.artifacts.map((artifact)=>artifact.platform).join(" + ")} archives (build ${result.buildNumber})`:`Uploaded and selected Safari ${result.attachments.map((attachment)=>attachment.platform).join(" + ")} build ${result.buildNumber} in App Store Connect`);if(result.appStoreSubmission?.reviewSubmissionIds.length)log.success(`Submitted ${result.appStoreSubmission.reviewSubmissionIds.length} Safari version(s) to App Review`)});buddy.command("extension:safari:submit","Synchronize metadata and submit an existing Safari version to App Review").option("--version <version>","Marketing version to submit (defaults to package.json)").option("--api-key-id <id>","App Store Connect API key ID").option("--api-issuer-id <id>","App Store Connect API issuer ID").option("--api-key-path <path>","Path to the App Store Connect AuthKey_*.p8 file").option("--platform <platform>","Submit macos, ios, or all (defaults to config safariPlatforms)").option("--prepare-only","Synchronize the listing without submitting it for review").action(async(options)=>{const{submitSafariAppStore}=await import("@stacksjs/browser-extension"),{config,version}=await load(),result=await submitSafariAppStore(config,{version:options.version??version,keyId:options.apiKeyId,issuerId:options.apiIssuerId,keyPath:options.apiKeyPath,platforms:parseSafariPlatforms(options.platform),submit:!options.prepareOnly});log.success(`Synchronized ${result.versions.map((item)=>item.platform).join(" + ")} App Store listings`);if(result.reviewSubmissionIds.length)log.success(`Submitted ${result.reviewSubmissionIds.length} Safari version(s) to App Review`)})}
|
|
4
|
+
`)}`);process.exit(1)}});buddy.command("extension:chrome:status","Fetch the Chrome Web Store item status").option("--service-account-path <path>","Google service-account JSON key path").option("--access-token <token>","Short-lived Chrome Web Store OAuth access token").action(async(options)=>{const{ChromeWebStoreClient}=await import("@stacksjs/browser-extension"),{config}=await load();if(!config.chromeWebStore)throw Error("Chrome status needs chromeWebStore.publisherId and chromeWebStore.itemId in config/extension.ts");const status=await new ChromeWebStoreClient(options).fetchStatus(config.chromeWebStore);log.info(`Chrome Web Store item ${status.itemId}`);log.info(`published: ${status.publishedItemRevisionStatus?.state??"none"}`);log.info(`submitted: ${status.submittedItemRevisionStatus?.state??"none"}`);if(status.warned)log.warn("Chrome has warned this item for a policy violation.");if(status.takenDown)log.error("Chrome has taken this item down for a policy violation.")});buddy.command("extension:chrome:publish","Build, upload, and submit the Chrome extension through Web Store API v2").option("--version <version>","Override the extension version (defaults to package.json)").option("--service-account-path <path>","Google service-account JSON key path").option("--access-token <token>","Short-lived Chrome Web Store OAuth access token").option("--upload-only","Upload without submitting the item for review").option("--allow-warnings","Submit even when Chrome reports validation warnings").action(async(options)=>{const{publishChromeExtension}=await import("@stacksjs/browser-extension"),{config,version}=await load(),result=await publishChromeExtension(config,{version:options.version??version,serviceAccountPath:options.serviceAccountPath,accessToken:options.accessToken,uploadOnly:Boolean(options.uploadOnly),blockOnWarnings:!options.allowWarnings});if(result.deferred){log.warn(`${result.deferred.reason}. Version ${options.version??version} remains queued for the next automated retry.`);return}if(result.alreadyPublished){log.success(result.alreadyPublished.reason);return}log.success(`Uploaded Chrome package ${result.packagePath} (${result.upload?.crxVersion??"processing complete"})`);if(result.publish)log.success(`Submitted Chrome Web Store item ${result.publish.itemId}: ${result.publish.state}`)});buddy.command("extension:firefox:previews","Sync the Firefox listing screenshots declared in config/extension.ts").option("--api-key <issuer>","AMO JWT issuer").option("--api-secret <secret>","AMO JWT secret").option("--dry-run","Report what would change without touching the listing").action(async(options)=>{const{syncFirefoxPreviews}=await import("@stacksjs/browser-extension"),{config}=await load(),result=await syncFirefoxPreviews(config,{issuer:options.apiKey,secret:options.apiSecret,dryRun:options.dryRun});if(result.unchanged)log.info("Firefox listing screenshots already match config/extension.ts");else if(options.dryRun)log.info(`Would replace ${result.removed.length} Firefox listing screenshot(s)`);else log.success(`Synced ${result.uploaded.length} Firefox listing screenshot(s), removed ${result.removed.length}`)});buddy.command("extension:firefox:publish","Build and submit the Firefox extension through Mozilla Add-ons").option("--version <version>","Override the extension version (defaults to package.json)").option("--api-key <issuer>","AMO JWT issuer").option("--api-secret <secret>","AMO JWT secret").option("--source-code <path>","Human-readable source archive for AMO review").option("--approval-timeout <milliseconds>","How long to wait for human approval (default 0)").action(async(options)=>{const{publishFirefoxExtension}=await import("@stacksjs/browser-extension"),{config,version}=await load(),result=await publishFirefoxExtension(config,{version:options.version??version,issuer:options.apiKey,secret:options.apiSecret,sourceCodePath:options.sourceCode,approvalTimeout:options.approvalTimeout===void 0?void 0:Number(options.approvalTimeout)});log.success(`Submitted Firefox extension (${result.channel}) \u2192 ${result.artifactsDir}`);if(config.firefoxAddons?.screenshots?.length)try{const{syncFirefoxPreviews}=await import("@stacksjs/browser-extension"),previews=await syncFirefoxPreviews(config,{issuer:options.apiKey,secret:options.apiSecret});if(previews.unchanged)log.info("Firefox listing screenshots already match");else log.success(`Synced ${previews.uploaded.length} Firefox listing screenshot(s), removed ${previews.removed.length}`)}catch(error){log.warn(`[extension:firefox:publish] listing screenshots left as they were: ${error.message}`)}if(result.artifacts.length)log.info(`new artifacts: ${result.artifacts.join(", ")}`)});buddy.command("extension:safari:provision","Register Safari Bundle IDs and check the App Store Connect app record").option("--api-key-id <id>","App Store Connect API key ID").option("--api-issuer-id <id>","App Store Connect API issuer ID").option("--api-key-path <path>","Path to the App Store Connect AuthKey_*.p8 file").option("--check","Report missing resources without creating Bundle IDs").option("--version <version>","Create or align App Store versions (defaults to package.json)").option("--platform <platform>","Provision macos, ios, or all (defaults to config safariPlatforms)").action(async(options)=>{const{provisionSafariApp}=await import("@stacksjs/browser-extension"),{config,version}=await load(),result=await provisionSafariApp(config,{keyId:options.apiKeyId,issuerId:options.apiIssuerId,keyPath:options.apiKeyPath,checkOnly:Boolean(options.check),version:options.version??version,platforms:parseSafariPlatforms(options.platform)});for(const resource of[result.container,result.extension])if(resource.created)log.success(`Registered Bundle ID ${resource.identifier}`);else if(resource.exists)log.success(`Bundle ID exists: ${resource.identifier}`);else log.warn(`Bundle ID is missing: ${resource.identifier}`);if(result.appRecord.exists)log.success(`App Store Connect app record exists (${result.appRecord.id})`);else log.warn("App Store Connect app record is missing. Apple requires creating it in the App Store Connect website.");for(const appStoreVersion of result.appStoreVersions){const action=appStoreVersion.created?"Created":appStoreVersion.updated?"Updated":"Ready";log.success(`${action} Safari ${appStoreVersion.platform} App Store version ${appStoreVersion.version}`)}});buddy.command("extension:safari:init","Scaffold the Safari container app (Xcode project) from the template").option("--bundle-id <id>","Base bundle identifier (defaults to config safariBundleId)").option("--dir <dir>","Output directory for the Xcode project (default safari)").option("--force","Overwrite existing scaffold files").option("--team-id <id>","Apple Developer team used for signing").action(async(options)=>{const{scaffoldSafariApp}=await import("@stacksjs/browser-extension"),{config}=await load(),{dir,written,skipped}=await scaffoldSafariApp(config,{bundleId:options.bundleId,dir:options.dir,force:Boolean(options.force),teamId:options.teamId});log.success(`Scaffolded the Safari container app \u2192 ${dir} (${written.length} files)`);if(skipped.length)log.info(`kept ${skipped.length} existing files (use --force to overwrite)`)});buddy.command("extension:safari:app","Build the extension and its macOS, iPhone, and iPad Safari container apps").option("--release","Build the Release configuration (default Debug)").option("--signed","Sign locally against the Apple ID in Xcode (local builds only - see below)").option("--skip-xcodebuild","Only build + sync the extension payload").option("--version <version>","Override the extension version (defaults to package.json)").option("--platform <platform>","Build macos, ios, or all (defaults to config safariPlatforms)").action(async(options)=>{const{buildSafariApp,buildSafariUniversalApp}=await import("@stacksjs/browser-extension"),{config,version}=await load(),platforms=parseSafariPlatforms(options.platform)??config.safariPlatforms??["macos"];if(platforms.includes("ios")){const result=await buildSafariUniversalApp(config,{version:options.version??version,release:Boolean(options.release),signed:Boolean(options.signed),skipXcodebuild:Boolean(options.skipXcodebuild),platforms});for(const platform of platforms){const appPath=result.appPaths[platform];if(appPath)log.success(`Built Safari ${platform} app ${appPath}`)}if(options.skipXcodebuild)log.success(`Generated universal Safari project \u2192 ${result.project}`);if(result.appPaths.macos)log.info("Open the macOS app once, then enable the extension in Safari > Settings > Extensions.");if(result.appPaths.ios)log.info("Install the iOS app on an iPhone, iPad, or Simulator, then enable it in Settings > Apps > Safari > Extensions.");return}const{appPath,resources}=await buildSafariApp(config,{version:options.version??version,release:Boolean(options.release),signed:Boolean(options.signed),skipXcodebuild:Boolean(options.skipXcodebuild)});if(appPath){log.success(`Built ${appPath}`);log.info("Open the app once, then enable the extension in Safari > Settings > Extensions.")}else log.success(`Extension payload synced \u2192 ${resources}`)});buddy.command("extension:safari:publish","Archive and validate or upload the Safari app to App Store Connect").option("--version <version>","Override the marketing version (defaults to package.json)").option("--build-number <number>","CFBundleVersion (defaults to GITHUB_RUN_NUMBER or Unix time)").option("--team-id <id>","Apple Developer team (defaults to config safariTeamId)").option("--api-key-id <id>","App Store Connect API key ID").option("--api-issuer-id <id>","App Store Connect API issuer ID").option("--api-key-path <path>","Path to the App Store Connect AuthKey_*.p8 file").option("--validate-only","Create and validate the archive without uploading it").option("--platform <platform>","Publish macos, ios, or all (defaults to config safariPlatforms)").option("--skip-screenshots","Do not regenerate the App Store screenshot set before publishing").action(async(options)=>{const{publishSafariApp}=await import("@stacksjs/browser-extension"),{config,version}=await load();if(!options.skipScreenshots)try{const{generateProjectImages}=await import("@stacksjs/actions");await generateProjectImages({only:["app-store"]})}catch(error){log.warn(`[extension:safari:publish] using the committed screenshots - could not regenerate: ${error.message}`)}const result=await publishSafariApp(config,{version:options.version??version,buildNumber:options.buildNumber,teamId:options.teamId,keyId:options.apiKeyId,issuerId:options.apiIssuerId,keyPath:options.apiKeyPath,validateOnly:Boolean(options.validateOnly),platforms:parseSafariPlatforms(options.platform)});for(const deferred of result.deferred)log.warn(`${deferred.reason}. Version ${deferred.version} remains queued for the next automated retry.`);for(const published of result.alreadyPublished)log.success(`Safari ${published.platform} version ${published.version} is already published${published.state?` (${published.state})`:""}`);if(result.artifacts.length)log.success(options.validateOnly?`Validated Safari ${result.artifacts.map((artifact)=>artifact.platform).join(" + ")} archives (build ${result.buildNumber})`:`Uploaded and selected Safari ${result.attachments.map((attachment)=>attachment.platform).join(" + ")} build ${result.buildNumber} in App Store Connect`);if(result.appStoreSubmission?.reviewSubmissionIds.length)log.success(`Submitted ${result.appStoreSubmission.reviewSubmissionIds.length} Safari version(s) to App Review`)});buddy.command("extension:safari:submit","Synchronize metadata and submit an existing Safari version to App Review").option("--version <version>","Marketing version to submit (defaults to package.json)").option("--api-key-id <id>","App Store Connect API key ID").option("--api-issuer-id <id>","App Store Connect API issuer ID").option("--api-key-path <path>","Path to the App Store Connect AuthKey_*.p8 file").option("--platform <platform>","Submit macos, ios, or all (defaults to config safariPlatforms)").option("--prepare-only","Synchronize the listing without submitting it for review").action(async(options)=>{const{submitSafariAppStore}=await import("@stacksjs/browser-extension"),{config,version}=await load(),result=await submitSafariAppStore(config,{version:options.version??version,keyId:options.apiKeyId,issuerId:options.apiIssuerId,keyPath:options.apiKeyPath,platforms:parseSafariPlatforms(options.platform),submit:!options.prepareOnly});log.success(`Synchronized ${result.versions.map((item)=>item.platform).join(" + ")} App Store listings`);if(result.reviewSubmissionIds.length)log.success(`Submitted ${result.reviewSubmissionIds.length} Safari version(s) to App Review`)})}
|
|
@@ -126,5 +126,5 @@ export default {
|
|
|
126
126
|
table: 'failed_jobs',
|
|
127
127
|
},
|
|
128
128
|
} satisfies QueueConfig
|
|
129
|
-
`};export async function setFeatureEnabled(feature,enabled,options){const path=options.root?join(options.root,`config/${feature}.ts`):projectPath(`config/${feature}.ts`),file=Bun.file(path);if(!await file.exists()){if(!options.createIfMissing)return"missing";await Bun.write(path,STARTER_TEMPLATES[feature]);return"created"}const src=await file.text(),enabledRegex=/(^|\{)(\s*)enabled\s*:\s*(true|false)(\s*,?)/m;if(enabledRegex.test(src)){const replaced=src.replace(enabledRegex,(_full,anchor,ws,current,trailing)=>{if(current===String(enabled))return`${anchor}${ws}enabled: ${current}${trailing}`;return`${anchor}${ws}enabled: ${enabled}${trailing}`});if(replaced===src)return"unchanged";await Bun.write(path,replaced);return"flipped"}const insertRegex=/(export default\s*\{)/;if(!insertRegex.test(src))throw Error(`Could not locate \`export default {\` in ${path}
|
|
130
|
-
enabled: ${enabled},`);await Bun.write(path,next);return"flipped"}export async function uninstallAllFeatures(options={}){const root=options.root??projectPath(),results=[];for(const feature of FEATURE_NAMES){const configOutcome=await setFeatureEnabled(feature,!1,{createIfMissing:!1,root}),filesRemoved=await deleteFeatureFiles(feature,root);results.push({feature,configOutcome,filesRemoved})}return results}function registerInstallPair(buddy,feature){const desc=FEATURE_DESCRIPTIONS[feature],configRel=`config/${feature}.ts`;buddy.command(`${feature}:install`,`Activate the ${feature} feature bundle. ${desc}`).option("--force",`Overwrite any existing ${feature} files in the project (default skips existing paths so the install is idempotent).`).action(async(options)=>{try{switch(await setFeatureEnabled(feature,!0,{createIfMissing:!0})){case"created":console.log(`\u2713 Created ${configRel} with '${feature}' enabled.`);break;case"flipped":console.log(`\u2713 Enabled '${feature}' in ${configRel}.`);break;case"unchanged":console.log(`\u2713 '${feature}' is already enabled in ${configRel}.`);break}const{copied}=await copyFeatureFiles(feature,{force:options.force===!0});if(copied.length>0){console.log(`\u2713 Copied ${copied.length} stamped path(s) from framework defaults:`);for(const path of copied)console.log(` - ${path}`)}else if(featurePathsPresent(feature).length>0)console.log(` \u2192 ${feature} scaffolding already in place
|
|
129
|
+
`};export async function setFeatureEnabled(feature,enabled,options){const path=options.root?join(options.root,`config/${feature}.ts`):projectPath(`config/${feature}.ts`),file=Bun.file(path);if(!await file.exists()){if(!options.createIfMissing)return"missing";await Bun.write(path,STARTER_TEMPLATES[feature]);return"created"}const src=await file.text(),enabledRegex=/(^|\{)(\s*)enabled\s*:\s*(true|false)(\s*,?)/m;if(enabledRegex.test(src)){const replaced=src.replace(enabledRegex,(_full,anchor,ws,current,trailing)=>{if(current===String(enabled))return`${anchor}${ws}enabled: ${current}${trailing}`;return`${anchor}${ws}enabled: ${enabled}${trailing}`});if(replaced===src)return"unchanged";await Bun.write(path,replaced);return"flipped"}const insertRegex=/(export default\s*\{)/;if(!insertRegex.test(src))throw Error(`Could not locate \`export default {\` in ${path} - please add \`enabled: ${enabled}\` manually.`);const next=src.replace(insertRegex,`$1
|
|
130
|
+
enabled: ${enabled},`);await Bun.write(path,next);return"flipped"}export async function uninstallAllFeatures(options={}){const root=options.root??projectPath(),results=[];for(const feature of FEATURE_NAMES){const configOutcome=await setFeatureEnabled(feature,!1,{createIfMissing:!1,root}),filesRemoved=await deleteFeatureFiles(feature,root);results.push({feature,configOutcome,filesRemoved})}return results}function registerInstallPair(buddy,feature){const desc=FEATURE_DESCRIPTIONS[feature],configRel=`config/${feature}.ts`;buddy.command(`${feature}:install`,`Activate the ${feature} feature bundle. ${desc}`).option("--force",`Overwrite any existing ${feature} files in the project (default skips existing paths so the install is idempotent).`).action(async(options)=>{try{switch(await setFeatureEnabled(feature,!0,{createIfMissing:!0})){case"created":console.log(`\u2713 Created ${configRel} with '${feature}' enabled.`);break;case"flipped":console.log(`\u2713 Enabled '${feature}' in ${configRel}.`);break;case"unchanged":console.log(`\u2713 '${feature}' is already enabled in ${configRel}.`);break}const{copied}=await copyFeatureFiles(feature,{force:options.force===!0});if(copied.length>0){console.log(`\u2713 Copied ${copied.length} stamped path(s) from framework defaults:`);for(const path of copied)console.log(` - ${path}`)}else if(featurePathsPresent(feature).length>0)console.log(` \u2192 ${feature} scaffolding already in place - use --force to overwrite.`);console.log(` \u2192 next ./buddy dev will boot with ${feature} loaded.`);process.exit(ExitCode.Success)}catch(err){console.error(`\u2717 Failed to install ${feature}:`,err);process.exit(ExitCode.FatalError)}});buddy.command(`${feature}:uninstall`,`Deactivate the ${feature} feature bundle.`).option("--keep-files",`Don't delete the ${feature} scaffolding (action/model/view files). Flip the flag only.`).action(async(options)=>{try{switch(await setFeatureEnabled(feature,!1,{createIfMissing:!1})){case"missing":console.log(`\u2713 '${feature}' is already disabled (${configRel} is absent).`);break;case"flipped":console.log(`\u2713 Disabled '${feature}' in ${configRel}. Custom config preserved.`);break;case"unchanged":console.log(`\u2713 '${feature}' is already disabled in ${configRel}.`);break;case"created":console.log(`\u2717 Unexpected create on uninstall for ${feature}; please re-run with --force or report this bug.`);break}if(options.keepFiles){const stillPresent=featurePathsPresent(feature);if(stillPresent.length>0)console.log(` \u2192 ${stillPresent.length} stamped path(s) preserved (--keep-files).`)}else{const removed=await deleteFeatureFiles(feature);if(removed.length>0){console.log(`\u2713 Removed ${removed.length} stamped path(s):`);for(const path of removed)console.log(` - ${path}`)}}console.log(` \u2192 next ./buddy dev will boot without ${feature}.`);process.exit(ExitCode.Success)}catch(err){console.error(`\u2717 Failed to uninstall ${feature}:`,err);process.exit(ExitCode.FatalError)}})}export function features(buddy){for(const feature of FEATURE_NAMES)registerInstallPair(buddy,feature)}
|