@stacksjs/buddy 0.72.31 → 0.72.33

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.
@@ -243,6 +243,28 @@ export declare function declaresScheduledWork(schedulerFile: string): boolean;
243
243
  * it off has said that too.
244
244
  */
245
245
  export declare function applyScheduledWork(sites: Record<string, any>, schedulerFile: string): Record<string, any>;
246
+ /**` would answer 502.
247
+ *
248
+ * Stacks serves the API as its own process: the page server proxies same-origin
249
+ * `/api` requests to it, and `resolveApiBase` deliberately returns null on a
250
+ * deployed box unless the operator has said where it is - proxying into
251
+ * whatever else happens to hold port 3008 on a shared box is worse than
252
+ * failing. So a working deploy needs *two* declarations that nothing checks
253
+ * were both made: an `api` site, and `PORT_API` (or `API_URL`) in the page
254
+ * server's environment.
255
+ *
256
+ * Make one and not the other and the deploy succeeds, the site serves, and
257
+ * every `/api` request answers 502 - health checks, the OpenAPI document, the
258
+ * MCP endpoint, on-demand social cards, and every `fetch('/api/...')` the app
259
+ * makes. reviewos.org shipped in that state and nobody noticed until somebody
260
+ * asked why a link preview was blank: the front page was 200 throughout, which
261
+ * is what a deploy's own verification looks at.
262
+ *
263
+ * This is the check that was missing. A project with no API surface is
264
+ * untouched - the routes file is the signal, and an app without one has
265
+ * nothing to serve.
266
+ */
267
+ export declare function apiDeploymentProblem(sites: Record<string, any>, hasApiRoutes: boolean): string | undefined;
246
268
  /**
247
269
  * The way THIS site invokes buddy, taken from its own migrate step: everything
248
270
  * ahead of the migrate subcommand, when what it lands on is a buddy entrypoint.
@@ -10,7 +10,8 @@ cloud-init may have failed: SSH in and check /var/log/cloud-init-output.log. Rai
10
10
  echo "$p \${unit:-unknown}"
11
11
  done`,encoding:"utf8",stdio:["pipe","pipe","pipe"]})}catch{return}const clashes=[];for(const line of listing.split(`
12
12
  `)){const[portText,unit="unknown"]=line.trim().split(/\s+/),port=Number(portText);if(!Number.isFinite(port)||!wanted.has(port))continue;if(unit.startsWith(`${slug}-`))continue;clashes.push(` ${port} (site '${wanted.get(port)}') is held by ${unit}`)}if(clashes.length===0)return;log.error("Another service on the box is already listening on a port this project wants:");for(const clash of clashes)log.error(clash);log.error("Two services on one port do not error: the kernel load-balances, and each domain serves the other's site about half the time.");log.info("Pick free ports in config/cloud.ts. `ss -lntp` on the box lists what is taken.");process.exit(ExitCode.FatalError)}export async function resolveDeployEnvValues(environment,tsCloudConfig){const fileName=environment==="production"?".env.production":environment==="staging"?".env.staging":existsSync(p.projectPath(".env.development"))?".env.development":".env",filePath=p.projectPath(fileName);if(!existsSync(filePath))return{};try{const{getEnv}=await import("@stacksjs/env"),result=getEnv(void 0,{file:fileName,format:"json"});if(!result.success||!result.output){log.debug(`[deploy] Could not read ${fileName} for site env merging: ${result.error??"unknown error"}`);return{}}const parsed=JSON.parse(result.output),values={},undecrypted=[];for(const[key,value]of Object.entries(parsed)){if(/^DOTENV_(PUBLIC|PRIVATE)_KEY/.test(key))continue;values[key]=String(value);if(/^(?:encrypted|enc):/.test(values[key]))undecrypted.push(key)}if(undecrypted.length>0){log.error(`${undecrypted.length} value(s) in ${fileName} could not be decrypted: ${undecrypted.join(", ")}`);log.info(`The private key for this environment lives in .env.keys, which is not committed. Restore it, or set DOTENV_PRIVATE_KEY_${environment.toUpperCase()} in the environment running the deploy.`);process.exit(ExitCode.FatalError)}const{foreignTenantKeys,partitionTenantEnv}=await import("@stacksjs/env"),partition=partitionTenantEnv(values,{self:tsCloudConfig?.project?.slug,tenants:await resolveDeclaredTenants()});for(const{tenant,keys}of foreignTenantKeys(partition))log.warn(`[deploy] Skipping ${keys.length} '${tenant}' key(s) in ${fileName} \u2014 they belong to that tenant's own `+`repository, and shipping them writes its secrets into this project's site .env files. Remove them with: buddy env:check --file ${fileName}. Keys: ${keys.join(", ")}`);return partition.own}catch(error){log.debug(`[deploy] Failed to resolve ${fileName} for site env merging:`,error);return{}}}export function mergeSiteDeployEnv(sites,resolvedDeployEnv){return Object.fromEntries(Object.entries(sites).map(([siteName,site])=>{if(!site)return[siteName,site];const base={...resolvedDeployEnv};if(site.port!==void 0)delete base.PORT;return[siteName,{...site,env:{...base,...site.env||{}}}]}))}function looksLikeSqliteFile(value){return typeof value==="string"&&/\.(?:sqlite3?|db)$/i.test(value.trim())}export function siteSqlitePath(siteEnv={}){if(String(siteEnv.DB_CONNECTION??"sqlite").trim().toLowerCase()!=="sqlite")return null;const configured=typeof siteEnv.DB_DATABASE_PATH==="string"&&siteEnv.DB_DATABASE_PATH.trim()?siteEnv.DB_DATABASE_PATH:looksLikeSqliteFile(siteEnv.DB_DATABASE)?siteEnv.DB_DATABASE:"database/stacks.sqlite",path=String(configured).trim().replace(/^\.\//,"");if(!path||path.startsWith("/")||path.startsWith("~")||path.split("/").includes(".."))return null;return path}export function tsCloudPersistentStateSupport(buildSiteDeployScript){const missing=[];if(typeof buildSiteDeployScript!=="function")return{ok:!1,missing:["buildSiteDeployScript"]};const target="/var/www/probe-shared/database/probe.sqlite";let script="";try{script=buildSiteDeployScript({siteName:"probe",slug:"probe",appDir:"/var/www/probe-probe",artifactFetch:[],releaseId:"probe",execStart:"/bin/true",envEntries:{},port:3000,sharedPaths:[{path:"database/probe.sqlite",target,seed:!0}]}).join(`
13
- `)}catch{return{ok:!1,missing:["shared paths with an explicit target"]}}if(!(script.includes("cp -a")&&script.includes("/current/")))missing.push("adoption of existing state into shared/");if(!script.includes(`ln -sfn ${target} `))missing.push("shared paths with an explicit target");return{ok:missing.length===0,missing}}export function projectDatabaseTarget(slug,relativePath){return`/var/www/${slug}-shared/${relativePath}`}function migrateIndex(site){if(!Array.isArray(site?.preStart))return-1;return site.preStart.findIndex((cmd)=>typeof cmd==="string"&&migratesDatabase(cmd))}function migratesDatabase(command){const withoutMessages=command.replace(/'[^']*'/g,"").replace(/"[^"]*"/g,"");if(/\bmigrate\b/.test(withoutMessages))return!0;return!/^\s*(?:echo|printf)\b/.test(command)&&/\bmigrate\b/.test(command)}function runsMigrations(site){return migrateIndex(site)!==-1}export function applyPersistentStatePaths(sites,slug){const isServerApp=(site)=>!!site&&typeof site.start==="string",appSites=Object.entries(sites).filter(([,site])=>isServerApp(site)),owner=(appSites.find(([,site])=>runsMigrations(site))??appSites[0])?.[0],out={};for(const[name,site]of Object.entries(sites)){if(!isServerApp(site)){out[name]=site;continue}const sqlite=siteSqlitePath(site.env||{}),framework=["storage/logs"];if(sqlite)framework.unshift({path:sqlite,target:projectDatabaseTarget(slug,sqlite),seed:name===owner});const declared=Array.isArray(site.sharedPaths)?site.sharedPaths.filter(Boolean):[],byPath=new Map;for(const entry of[...declared,...framework])byPath.set(typeof entry==="string"?entry:entry.path,entry);out[name]={...site,sharedPaths:[...byPath.values()]}}return out}export function encryptedEnvFileNames(projectRoot){try{return readdirSync(projectRoot).filter((name)=>/^\.env\.[\w-]+$/.test(name)&&name!==".env.example")}catch{return[]}}export function declaresScheduledWork(schedulerFile){if(!existsSync(schedulerFile))return!1;const source=readFileSync(schedulerFile,"utf8").replace(/\/\*[\s\S]*?\*\//g,"").replace(/(^|[^:])\/\/.*$/gm,"$1");return/\bschedule\s*\.\s*(?:job|action|command|call|exec)\b/.test(source)}export function applyScheduledWork(sites,schedulerFile){if(Object.values(sites).some((site)=>site?.scheduler!==void 0))return sites;if(!declaresScheduledWork(schedulerFile))return sites;const appSites=Object.entries(sites).filter(([,site])=>typeof site?.start==="string"),owner=(appSites.find(([,site])=>runsMigrations(site))??appSites[0])?.[0];if(!owner)return sites;return{...sites,[owner]:{...sites[owner],scheduler:!0}}}const buddyEntrypoint=/(?:^|\/)(?:cli\.[cm]?[jt]s|buddy|bud|stacks)$/,migrateToken=/(?:^|:)migrate(?::|$)/;export function buddyInvocationFrom(migrateCommand){if(typeof migrateCommand!=="string")return;const tokens=migrateCommand.trim().split(/\s+/),at=tokens.findIndex((token)=>migrateToken.test(token));if(at<1)return;const invocation=tokens.slice(0,at),entrypoint=invocation[invocation.length-1];if(!entrypoint||!buddyEntrypoint.test(entrypoint))return;return invocation.join(" ")}export function preMigrationBackupCommand(backupsDir,migrateCommand){const invocation=buddyInvocationFrom(migrateCommand);if(!invocation)return;return`${invocation} db:backup --before-migrations --out ${backupsDir}`}export function applyPreMigrationBackup(sites,backupsDir){const out={};for(const[name,site]of Object.entries(sites)){const at=migrateIndex(site);if(at===-1){out[name]=site;continue}const preStart=[...site.preStart];if(preStart.some((cmd)=>typeof cmd==="string"&&/\bdb:backup\b/.test(cmd))){out[name]=site;continue}const backup=preMigrationBackupCommand(backupsDir,preStart[at]);if(!backup){log.warn(`No pre-migration backup for "${name}": its migrate step (${String(preStart[at])}) is not a buddy invocation this can reuse. Add a \`db:backup\` command to its preStart to take one.`);out[name]=site;continue}preStart.splice(at,0,backup);out[name]={...site,preStart}}return out}function prefixHostForEnv(host,prefix){if(host.startsWith(`${prefix}.`)||host.startsWith(`www.${prefix}.`))return host;if(host.startsWith("www."))return`www.${prefix}.${host.slice(4)}`;return`${prefix}.${host}`}export function applyEnvironmentToSites(sites,environment,config){const prefix=config?.environments?.[environment]?.domainPrefix;if(!prefix||environment==="production")return sites;const allHosts=[];for(const site of Object.values(sites)){const d=site?.domain;if(typeof d==="string")allHosts.push(d);else if(Array.isArray(d)){for(const x of d)if(typeof x==="string")allHosts.push(x)}}allHosts.sort((a,b)=>b.length-a.length);const rewrite=(val)=>{let r=val;for(const h of allHosts){const esc=h.replace(/[.]/g,"\\.");r=r.replace(new RegExp(`//${esc}(?=[/:?#]|$)`,"g"),`//${prefixHostForEnv(h,prefix)}`)}return r},out={};for(const[name,site]of Object.entries(sites)){if(!site){out[name]=site;continue}const s={...site};if(typeof s.domain==="string")s.domain=prefixHostForEnv(s.domain,prefix);else if(Array.isArray(s.domain))s.domain=s.domain.map((d)=>typeof d==="string"?prefixHostForEnv(d,prefix):d);if(typeof s.redirect==="string")s.redirect=rewrite(s.redirect);if(s.env&&typeof s.env==="object"){const e={...s.env};for(const k of Object.keys(e))if(typeof e[k]==="string")e[k]=rewrite(e[k]);s.env=e}out[name]=s}return out}async function deployToHetzner(tsCloudConfig,deployEnv,options){const verbose=options.verbose===!0,environment=deployEnv==="prod"?"production":deployEnv,apiToken=resolveHetznerApiToken(tsCloudConfig),persistedAttachBox=resolvePersistedAttachTargetBox(tsCloudConfig,environment);if(!apiToken&&!persistedAttachBox){log.error("No Hetzner API token found. Set HCLOUD_TOKEN in your .env (or hetzner.apiToken in config/cloud.ts).");process.exit(ExitCode.FatalError)}const sshPubKey=join(homedir(),".ssh","id_ed25519.pub");if(!existsSync(sshPubKey)){log.error(`SSH public key not found at ${sshPubKey}.`);log.info("ts-cloud deploys to Hetzner over SSH and registers this key on the server.");log.info("Generate one with: ssh-keygen -t ed25519");process.exit(ExitCode.FatalError)}const{createCloudDriver,deployAllComputeSites,ensureManagementDashboard,resolveSiteKind,buildSiteDeployScript}=await loadTsCloudDeployApi(),support=tsCloudPersistentStateSupport(buildSiteDeployScript);if(!support.ok){log.error("This ts-cloud cannot keep your database across deploys, and deploying anyway would destroy it.");log.error(`Missing: ${support.missing.join(", ")}.`);log.error("Upgrade @stacksjs/ts-cloud (bun update @stacksjs/ts-cloud), or point TS_CLOUD_MODULE at a build that has it.");process.exit(ExitCode.FatalError)}const unbacked=findUnbackedManagedServices(tsCloudConfig);if(unbacked.length>0)log.warn(`[deploy] ${unbackedDataMessage(unbacked)}`);try{await runHetznerDeploy({tsCloudConfig,environment,verbose,docker:options.docker===!0,createCloudDriver,deployAllComputeSites,ensureManagementDashboard,resolveSiteKind,onlySite:options.site||void 0,persistedAttachBox})}catch(err){log.error("Hetzner deploy failed:");console.error(err instanceof Error?err.stack||err.message:err);throw err}}export function resolveProjectTsCloudModule(projectRoot=process.cwd()){const manifestPath=join(projectRoot,"node_modules","@stacksjs","ts-cloud","package.json");if(!existsSync(manifestPath))return;const manifest=JSON.parse(readFileSync(manifestPath,"utf8")),rootExport=manifest.exports?.["."],entry=manifest.module??(typeof rootExport==="string"?rootExport:rootExport?.import);if(!entry)return;const modulePath=resolve(dirname(manifestPath),entry);return existsSync(modulePath)?modulePath:void 0}export async function loadTsCloudDeployApi(){const requested=process.env.TS_CLOUD_MODULE?.trim();if(!requested){const projectModule=resolveProjectTsCloudModule();return projectModule?import(pathToFileURL(projectModule).href):import("@stacksjs/ts-cloud")}const modulePath=isAbsolute(requested)?requested:resolve(process.cwd(),requested);if(!existsSync(modulePath))throw Error(`TS_CLOUD_MODULE points to a missing module: ${modulePath}`);return import(pathToFileURL(modulePath).href)}export function shouldInjectManagementDashboard(tsCloudConfig){return!tsCloudConfig.cloud?.attachTo}export function reconcilePartialDeployManagementDashboards(tsCloudConfig,livePorts){const sites=tsCloudConfig.sites,preserved=[],removed=[];if(!sites)return{preserved,removed};for(const[siteName,site]of Object.entries(sites)){if(siteName!=="dashboard"&&!siteName.startsWith("dashboard-"))continue;const livePort=livePorts[siteName];if(typeof livePort!=="number"||!Number.isInteger(livePort)||livePort<1||livePort>65535){delete sites[siteName];removed.push(siteName);continue}const next={...site,port:livePort};if(typeof next.start==="string")next.start=next.start.replace(/(--port(?:=|\s+))\d+/,`$1${livePort}`);sites[siteName]=next;preserved.push(siteName)}return{preserved,removed}}async function reconcilePartialDeployManagementDashboardsWithLiveBox(tsCloudConfig,ip){const slug=String(tsCloudConfig.project?.slug||"app").replace(/[^a-z0-9._-]+/gi,"-"),siteNames=Object.keys(tsCloudConfig.sites??{}).filter((name)=>name==="dashboard"||name.startsWith("dashboard-"));if(siteNames.length===0)return;const units=siteNames.map((siteName)=>({siteName,unit:`${slug}-${siteName}.service`})),probe=`
13
+ `)}catch{return{ok:!1,missing:["shared paths with an explicit target"]}}if(!(script.includes("cp -a")&&script.includes("/current/")))missing.push("adoption of existing state into shared/");if(!script.includes(`ln -sfn ${target} `))missing.push("shared paths with an explicit target");return{ok:missing.length===0,missing}}export function projectDatabaseTarget(slug,relativePath){return`/var/www/${slug}-shared/${relativePath}`}function migrateIndex(site){if(!Array.isArray(site?.preStart))return-1;return site.preStart.findIndex((cmd)=>typeof cmd==="string"&&migratesDatabase(cmd))}function migratesDatabase(command){const withoutMessages=command.replace(/'[^']*'/g,"").replace(/"[^"]*"/g,"");if(/\bmigrate\b/.test(withoutMessages))return!0;return!/^\s*(?:echo|printf)\b/.test(command)&&/\bmigrate\b/.test(command)}function runsMigrations(site){return migrateIndex(site)!==-1}export function applyPersistentStatePaths(sites,slug){const isServerApp=(site)=>!!site&&typeof site.start==="string",appSites=Object.entries(sites).filter(([,site])=>isServerApp(site)),owner=(appSites.find(([,site])=>runsMigrations(site))??appSites[0])?.[0],out={};for(const[name,site]of Object.entries(sites)){if(!isServerApp(site)){out[name]=site;continue}const sqlite=siteSqlitePath(site.env||{}),framework=["storage/logs"];if(sqlite)framework.unshift({path:sqlite,target:projectDatabaseTarget(slug,sqlite),seed:name===owner});const declared=Array.isArray(site.sharedPaths)?site.sharedPaths.filter(Boolean):[],byPath=new Map;for(const entry of[...declared,...framework])byPath.set(typeof entry==="string"?entry:entry.path,entry);out[name]={...site,sharedPaths:[...byPath.values()]}}return out}export function encryptedEnvFileNames(projectRoot){try{return readdirSync(projectRoot).filter((name)=>/^\.env\.[\w-]+$/.test(name)&&name!==".env.example")}catch{return[]}}export function declaresScheduledWork(schedulerFile){if(!existsSync(schedulerFile))return!1;const source=readFileSync(schedulerFile,"utf8").replace(/\/\*[\s\S]*?\*\//g,"").replace(/(^|[^:])\/\/.*$/gm,"$1");return/\bschedule\s*\.\s*(?:job|action|command|call|exec)\b/.test(source)}export function applyScheduledWork(sites,schedulerFile){if(Object.values(sites).some((site)=>site?.scheduler!==void 0))return sites;if(!declaresScheduledWork(schedulerFile))return sites;const appSites=Object.entries(sites).filter(([,site])=>typeof site?.start==="string"),owner=(appSites.find(([,site])=>runsMigrations(site))??appSites[0])?.[0];if(!owner)return sites;return{...sites,[owner]:{...sites[owner],scheduler:!0}}}function servesApi(name,site){return name==="api"||typeof site?.start==="string"&&/\bserve:api\b/.test(site.start)}export function apiDeploymentProblem(sites,hasApiRoutes){if(!hasApiRoutes)return;const entries=Object.entries(sites),appSites=entries.filter(([,site])=>typeof site?.start==="string");if(appSites.length===0)return;const api=entries.find(([name,site])=>servesApi(name,site)),pages=appSites.filter(([name,site])=>!servesApi(name,site)),configured=(site)=>{const env=site?.env??{};return Boolean(env.API_URL||env.PORT_API)};if(!api){if(pages.every(([,site])=>configured(site)))return;return"This project declares API routes and no site serves them. `/api/**` will answer 502 on every request.\nAdd an `api` site to config/cloud.ts running `buddy serve:api` on its own port, and set `PORT_API` on the site that serves the pages so its proxy can find it.\nSet `API_URL` on the page site instead when the API lives on another host."}const unwired=pages.filter(([,site])=>!configured(site));if(unwired.length===0)return;const port=api[1]?.port;return`The \`${api[0]}\` site serves the API, but ${unwired.map(([name])=>`\`${name}\``).join(", ")} will not proxy to it: neither \`PORT_API\` nor \`API_URL\` is set in its environment, so \`/api/**\` answers 502.
14
+ Set \`PORT_API: '${port??"<the api site's port>"}'\` in that site's \`env\` in config/cloud.ts.`}const buddyEntrypoint=/(?:^|\/)(?:cli\.[cm]?[jt]s|buddy|bud|stacks)$/,migrateToken=/(?:^|:)migrate(?::|$)/;export function buddyInvocationFrom(migrateCommand){if(typeof migrateCommand!=="string")return;const tokens=migrateCommand.trim().split(/\s+/),at=tokens.findIndex((token)=>migrateToken.test(token));if(at<1)return;const invocation=tokens.slice(0,at),entrypoint=invocation[invocation.length-1];if(!entrypoint||!buddyEntrypoint.test(entrypoint))return;return invocation.join(" ")}export function preMigrationBackupCommand(backupsDir,migrateCommand){const invocation=buddyInvocationFrom(migrateCommand);if(!invocation)return;return`${invocation} db:backup --before-migrations --out ${backupsDir}`}export function applyPreMigrationBackup(sites,backupsDir){const out={};for(const[name,site]of Object.entries(sites)){const at=migrateIndex(site);if(at===-1){out[name]=site;continue}const preStart=[...site.preStart];if(preStart.some((cmd)=>typeof cmd==="string"&&/\bdb:backup\b/.test(cmd))){out[name]=site;continue}const backup=preMigrationBackupCommand(backupsDir,preStart[at]);if(!backup){log.warn(`No pre-migration backup for "${name}": its migrate step (${String(preStart[at])}) is not a buddy invocation this can reuse. Add a \`db:backup\` command to its preStart to take one.`);out[name]=site;continue}preStart.splice(at,0,backup);out[name]={...site,preStart}}return out}function prefixHostForEnv(host,prefix){if(host.startsWith(`${prefix}.`)||host.startsWith(`www.${prefix}.`))return host;if(host.startsWith("www."))return`www.${prefix}.${host.slice(4)}`;return`${prefix}.${host}`}export function applyEnvironmentToSites(sites,environment,config){const prefix=config?.environments?.[environment]?.domainPrefix;if(!prefix||environment==="production")return sites;const allHosts=[];for(const site of Object.values(sites)){const d=site?.domain;if(typeof d==="string")allHosts.push(d);else if(Array.isArray(d)){for(const x of d)if(typeof x==="string")allHosts.push(x)}}allHosts.sort((a,b)=>b.length-a.length);const rewrite=(val)=>{let r=val;for(const h of allHosts){const esc=h.replace(/[.]/g,"\\.");r=r.replace(new RegExp(`//${esc}(?=[/:?#]|$)`,"g"),`//${prefixHostForEnv(h,prefix)}`)}return r},out={};for(const[name,site]of Object.entries(sites)){if(!site){out[name]=site;continue}const s={...site};if(typeof s.domain==="string")s.domain=prefixHostForEnv(s.domain,prefix);else if(Array.isArray(s.domain))s.domain=s.domain.map((d)=>typeof d==="string"?prefixHostForEnv(d,prefix):d);if(typeof s.redirect==="string")s.redirect=rewrite(s.redirect);if(s.env&&typeof s.env==="object"){const e={...s.env};for(const k of Object.keys(e))if(typeof e[k]==="string")e[k]=rewrite(e[k]);s.env=e}out[name]=s}return out}async function deployToHetzner(tsCloudConfig,deployEnv,options){const verbose=options.verbose===!0,environment=deployEnv==="prod"?"production":deployEnv,apiToken=resolveHetznerApiToken(tsCloudConfig),persistedAttachBox=resolvePersistedAttachTargetBox(tsCloudConfig,environment);if(!apiToken&&!persistedAttachBox){log.error("No Hetzner API token found. Set HCLOUD_TOKEN in your .env (or hetzner.apiToken in config/cloud.ts).");process.exit(ExitCode.FatalError)}const sshPubKey=join(homedir(),".ssh","id_ed25519.pub");if(!existsSync(sshPubKey)){log.error(`SSH public key not found at ${sshPubKey}.`);log.info("ts-cloud deploys to Hetzner over SSH and registers this key on the server.");log.info("Generate one with: ssh-keygen -t ed25519");process.exit(ExitCode.FatalError)}const{createCloudDriver,deployAllComputeSites,ensureManagementDashboard,resolveSiteKind,buildSiteDeployScript}=await loadTsCloudDeployApi(),support=tsCloudPersistentStateSupport(buildSiteDeployScript);if(!support.ok){log.error("This ts-cloud cannot keep your database across deploys, and deploying anyway would destroy it.");log.error(`Missing: ${support.missing.join(", ")}.`);log.error("Upgrade @stacksjs/ts-cloud (bun update @stacksjs/ts-cloud), or point TS_CLOUD_MODULE at a build that has it.");process.exit(ExitCode.FatalError)}const unbacked=findUnbackedManagedServices(tsCloudConfig);if(unbacked.length>0)log.warn(`[deploy] ${unbackedDataMessage(unbacked)}`);try{await runHetznerDeploy({tsCloudConfig,environment,verbose,docker:options.docker===!0,createCloudDriver,deployAllComputeSites,ensureManagementDashboard,resolveSiteKind,onlySite:options.site||void 0,persistedAttachBox})}catch(err){log.error("Hetzner deploy failed:");console.error(err instanceof Error?err.stack||err.message:err);throw err}}export function resolveProjectTsCloudModule(projectRoot=process.cwd()){const manifestPath=join(projectRoot,"node_modules","@stacksjs","ts-cloud","package.json");if(!existsSync(manifestPath))return;const manifest=JSON.parse(readFileSync(manifestPath,"utf8")),rootExport=manifest.exports?.["."],entry=manifest.module??(typeof rootExport==="string"?rootExport:rootExport?.import);if(!entry)return;const modulePath=resolve(dirname(manifestPath),entry);return existsSync(modulePath)?modulePath:void 0}export async function loadTsCloudDeployApi(){const requested=process.env.TS_CLOUD_MODULE?.trim();if(!requested){const projectModule=resolveProjectTsCloudModule();return projectModule?import(pathToFileURL(projectModule).href):import("@stacksjs/ts-cloud")}const modulePath=isAbsolute(requested)?requested:resolve(process.cwd(),requested);if(!existsSync(modulePath))throw Error(`TS_CLOUD_MODULE points to a missing module: ${modulePath}`);return import(pathToFileURL(modulePath).href)}export function shouldInjectManagementDashboard(tsCloudConfig){return!tsCloudConfig.cloud?.attachTo}export function reconcilePartialDeployManagementDashboards(tsCloudConfig,livePorts){const sites=tsCloudConfig.sites,preserved=[],removed=[];if(!sites)return{preserved,removed};for(const[siteName,site]of Object.entries(sites)){if(siteName!=="dashboard"&&!siteName.startsWith("dashboard-"))continue;const livePort=livePorts[siteName];if(typeof livePort!=="number"||!Number.isInteger(livePort)||livePort<1||livePort>65535){delete sites[siteName];removed.push(siteName);continue}const next={...site,port:livePort};if(typeof next.start==="string")next.start=next.start.replace(/(--port(?:=|\s+))\d+/,`$1${livePort}`);sites[siteName]=next;preserved.push(siteName)}return{preserved,removed}}async function reconcilePartialDeployManagementDashboardsWithLiveBox(tsCloudConfig,ip){const slug=String(tsCloudConfig.project?.slug||"app").replace(/[^a-z0-9._-]+/gi,"-"),siteNames=Object.keys(tsCloudConfig.sites??{}).filter((name)=>name==="dashboard"||name.startsWith("dashboard-"));if(siteNames.length===0)return;const units=siteNames.map((siteName)=>({siteName,unit:`${slug}-${siteName}.service`})),probe=`
14
15
  const units = ${JSON.stringify(units)}
15
16
  const text = bytes => new TextDecoder().decode(bytes).trim()
16
17
  const run = args => text(Bun.spawnSync(args).stdout)
@@ -26,7 +27,7 @@ for (const entry of units) {
26
27
  console.log(JSON.stringify(ports))
27
28
  `.trim(),encoded=Buffer.from(probe).toString("base64"),{sshExecOrThrow}=await import("@stacksjs/ts-cloud"),line=(await sshExecOrThrow(ip,`/usr/local/bin/bun -e "eval(Buffer.from('${encoded}','base64').toString())"`,{user:"root",connectTimeoutSec:10})).trim().split(`
28
29
  `).at(-1)||"{}",livePorts=JSON.parse(line),result=reconcilePartialDeployManagementDashboards(tsCloudConfig,livePorts);for(const siteName of result.preserved)log.info(`Partial deploy: preserving the live management dashboard service '${siteName}' on port ${livePorts[siteName]}`);for(const siteName of result.removed)log.info(`Partial deploy: omitting management dashboard route '${siteName}' because no active service owns it`)}export function resolvePersistedAttachTargetBox(tsCloudConfig,environment,cwd=process.cwd()){const owner=tsCloudConfig.cloud?.attachTo;if(!owner)return null;const stackName=tsCloudConfig.project?.stackName||`${tsCloudConfig.project?.slug||"app"}-${environment}`,statePath=join(cwd,"storage","cloud","state",`${stackName}.json`);if(!existsSync(statePath))return null;try{const state=JSON.parse(readFileSync(statePath,"utf8"));if(state.stackName!==stackName||typeof state.serverId!=="number"||typeof state.publicIp!=="string"||!state.publicIp.trim())return null;return{serverId:state.serverId,serverName:typeof state.serverName==="string"&&state.serverName?state.serverName:`${owner}-${environment}-app`,publicIp:state.publicIp,publicIpv6:typeof state.publicIpv6==="string"?state.publicIpv6:void 0}}catch{return null}}export function scrubLoopbackSitePortsForFirewall(tsCloudConfig){const sites=tsCloudConfig?.sites;if(!sites)return tsCloudConfig;const loopbackHosts=new Set(["127.0.0.1","::1","localhost"]),scrubbed={};for(const[siteName,site]of Object.entries(sites)){const host=String(site?.env?.HOST??"").toLowerCase();if(site&&typeof site.port==="number"&&!site.domain&&loopbackHosts.has(host)){const rest={...site};delete rest.port;scrubbed[siteName]=rest}else scrubbed[siteName]=site}return{...tsCloudConfig,sites:scrubbed}}function githubDeploymentsEnabled(){return process.env.GITHUB_ACTIONS!=="true"&&process.env.TS_CLOUD_GITHUB_DEPLOYMENTS!=="0"}async function ghCliAvailable(){try{const{execSync}=await import("node:child_process");execSync("gh --version",{stdio:"ignore"});return!0}catch{return!1}}async function resolveSiteGithubSource(root){try{const{execSync}=await import("node:child_process"),run=(cmd)=>execSync(cmd,{cwd:root,stdio:["ignore","pipe","ignore"]}).toString().trim(),match=run("git config --get remote.origin.url").match(/github\.com[:/]([^/]+\/[^/]+?)(?:\.git)?$/);if(!match?.[1])return null;return{repo:match[1],ref:run("git rev-parse HEAD")}}catch{return null}}async function setGithubDeploymentStatus(record,state){try{const{execSync}=await import("node:child_process"),body=JSON.stringify({state,environment:record.environment,...record.environmentUrl?{environment_url:record.environmentUrl}:{},description:state==="success"?"Deployed":state==="failure"?"Deploy failed":"Deploying"});execSync(`gh api -X POST repos/${record.repo}/deployments/${record.id}/statuses --input -`,{input:body,stdio:["pipe","ignore","ignore"]})}catch(err){log.warn(`GitHub deployment status (${state}) skipped for ${record.repo}: ${getErrorMessage(err)}`)}}async function startGithubDeployment(source,environment,environmentUrl){try{const{execSync}=await import("node:child_process"),body=JSON.stringify({ref:source.ref,environment,description:`buddy deploy (${environment})`,auto_merge:!1,required_contexts:[],production_environment:environment==="production"}),out=execSync(`gh api -X POST repos/${source.repo}/deployments --input - --jq '.id'`,{input:body,stdio:["pipe","pipe","ignore"]}).toString().trim(),id=Number(out);if(!out||!Number.isInteger(id)||id<=0)return null;const record={repo:source.repo,id,environment,environmentUrl};await setGithubDeploymentStatus(record,"in_progress");return record}catch(err){log.warn(`GitHub deployment record skipped for ${source.repo}: ${getErrorMessage(err)}`);return null}}async function startGithubDeployments(args){const{sites,onlySite,environment,resolveSiteKind}=args,records=[];if(!githubDeploymentsEnabled()||!await ghCliAvailable())return records;const seen=new Set;for(const[siteName,site]of Object.entries(sites)){if(!site||onlySite&&siteName!==onlySite)continue;const kind=resolveSiteKind(site);if(kind==="bucket"||kind==="redirect")continue;const source=await resolveSiteGithubSource(site.root||".");if(!source)continue;const key=`${source.repo}@${source.ref}`;if(seen.has(key))continue;seen.add(key);const record=await startGithubDeployment(source,environment,site.domain?`https://${site.domain}`:void 0);if(record){records.push(record);log.info(`GitHub deployment ${record.repo}#${record.id} \u2192 ${environment}`)}}return records}async function runHetznerDeploy(args){const{tsCloudConfig,environment,verbose,docker,createCloudDriver,deployAllComputeSites,ensureManagementDashboard,resolveSiteKind,onlySite,persistedAttachBox}=args,startTime=performance.now();console.log("");console.log("\uD83D\uDE80 Deploy \u2192 Hetzner Cloud");console.log("");log.info(`Project: ${tsCloudConfig.project?.slug}`);log.info(`Environment: ${environment}`);log.info(`Location: ${tsCloudConfig.hetzner?.location||process.env.HCLOUD_LOCATION||"fsn1"}`);log.info(`Size: ${tsCloudConfig.infrastructure?.compute?.size||"small"}`);try{if(shouldInjectManagementDashboard(tsCloudConfig)&&typeof ensureManagementDashboard==="function")ensureManagementDashboard(tsCloudConfig,{cwd:process.cwd(),logger:{info:(m)=>log.info(m),warn:(m)=>log.warn(m)}});else if(tsCloudConfig.cloud?.attachTo)log.info(`Management dashboard: using the '${tsCloudConfig.cloud.attachTo}' server owner's dashboard`)}catch(err){log.warn(`Management dashboard injection skipped: ${getErrorMessage(err)}`)}const driver=createCloudDriver({config:tsCloudConfig,provider:"hetzner"});if(!driver.provisionComputeInfrastructure){log.error("Hetzner driver does not support compute provisioning (update @stacksjs/ts-cloud).");process.exit(ExitCode.FatalError)}const attachTo=tsCloudConfig.cloud?.attachTo;let ip,ipv6;if(attachTo){let lookup,box=persistedAttachBox;if(!box){lookup=await resolveAttachTargetBox(attachTo,environment,tsCloudConfig);box=lookup.box}if(box&&!box.publicIpv6){const resolved=await resolveAttachTargetBox(attachTo,environment,tsCloudConfig);if(resolved.box?.publicIpv6)box={...box,publicIpv6:resolved.box.publicIpv6}}if(!box?.publicIp){log.error(describeAttachLookupFailure(attachTo,environment,lookup?.failure));process.exit(ExitCode.FatalError)}ip=box.publicIp;ipv6=box.publicIpv6;if((tsCloudConfig.project?.slug||"app")===attachTo){log.error(`This project's slug is '${attachTo}', which is the slug of the box it attaches to.`);log.error(`A tenant's deploy owns /etc/rpx/sites.d/<slug>.json, so deploying would overwrite '${attachTo}'s own gateway fragment and take its sites down.`);log.info(`Set a distinct project.slug in config/cloud.ts (e.g. '${p.projectPath().split("/").pop()}') and deploy again.`);process.exit(ExitCode.FatalError)}log.info(`Attaching to '${attachTo}' box '${box.serverName}' (${ip}) \u2014 skipping provisioning`);await assertFragmentIsOurs(ip,tsCloudConfig,log);await assertPortsAreFree(ip,tsCloudConfig,log);const compute=(tsCloudConfig.infrastructure??={}).compute??={};compute.webServer="rpx";compute.proxy={onDemandTls:!0,...compute.proxy??{},engine:"rpx"};const stackName=tsCloudConfig.project?.stackName||`${tsCloudConfig.project?.slug||"app"}-${environment}`,stateDir=join(process.cwd(),"storage","cloud","state");mkdirSync(stateDir,{recursive:!0});writeFileSync(join(stateDir,`${stackName}.json`),`${JSON.stringify({stackName,serverId:box.serverId,serverName:box.serverName,publicIp:ip,...ipv6?{publicIpv6:ipv6}:{},sshUser:"root",deployStoragePath:"/var/ts-cloud/staging"},null,2)}
29
- `)}else{log.info("Provisioning Hetzner compute infrastructure...");const outputs=await driver.provisionComputeInfrastructure({config:scrubLoopbackSitePortsForFirewall(tsCloudConfig),environment});ip=outputs.appPublicIp;ipv6=outputs.appPublicIpv6;log.success("Hetzner compute infrastructure ready");if(outputs.appInstanceId)log.info(`Server ID: ${outputs.appInstanceId}`)}if(ip)log.info(`Server IP: ${ip}`);if(!ip){log.error("Provisioned server has no public IP \u2014 cannot deploy over SSH.");process.exit(ExitCode.FatalError)}await waitForRemoteReady(ip);if(onlySite&&!attachTo)await reconcilePartialDeployManagementDashboardsWithLiveBox(tsCloudConfig,ip);const{execSync}=await import("node:child_process"),{tmpdir}=await import("node:os"),sites=applyEnvironmentToSites(tsCloudConfig.sites||{},environment,tsCloudConfig),slug=tsCloudConfig.project?.slug||"app";let sha;try{sha=execSync("git rev-parse --short HEAD",{stdio:["ignore","pipe","ignore"]}).toString().trim()}catch{sha=Date.now().toString(36)}const tarExcludes=["node_modules","pantry",".git",".github",".cache","bin","dist",relative(p.projectPath(),p.stxPath()).replace(/\/$/,""),".stx",relative(p.projectPath(),p.cloudStatePath()).replace(/\/$/,""),".ts-cloud",relative(p.projectPath(),p.frameworkRuntimePath()).replace(/\/$/,""),"tmp","temp",".DS_Store","*.log",".env",".env.local",".env.keys",".env.production.bak",".env.production.plain",...encryptedEnvFileNames(p.projectPath()),"*.sqlite","*.sqlite-wal","*.sqlite-shm"];if(onlySite&&!sites[onlySite]){log.error(`--site '${onlySite}' is not a configured site. Available: ${Object.keys(sites).join(", ")}`);process.exit(ExitCode.FatalError)}const tarballs=new Map;for(const[siteName,site]of Object.entries(sites)){if(!site)continue;if(onlySite&&siteName!==onlySite)continue;const kind=resolveSiteKind(site);if(kind==="bucket")continue;if(kind==="redirect")continue;if(kind==="server-static"&&site.build){log.info(`Building static site '${siteName}': ${site.build}`);execSync(site.build,{stdio:verbose?"inherit":"pipe"})}const root=site.root||".",tarballPath=join(tmpdir(),`${slug}-${siteName}-${sha}.tar.gz`),siteExcludes=Array.isArray(site.exclude)?site.exclude.filter((entry)=>typeof entry==="string"&&entry.length>0):[],excludeArgs=[...tarExcludes,...siteExcludes].flatMap((pattern)=>[`--exclude='${pattern}'`,`--exclude='*/${pattern}'`]);if(siteExcludes.length>0)log.info(`Excluding server-owned paths: ${siteExcludes.join(", ")}`);log.info(`Packaging ${root} \u2192 ${tarballPath}...`);execSync(`tar czf "${tarballPath}" ${excludeArgs.join(" ")} -C "${root}" .`,{stdio:verbose?"inherit":"pipe",env:{...process.env,COPYFILE_DISABLE:"1"}});const sizeMb=Math.max(1,Math.round(statSync(tarballPath).size/1048576));log.info(`Release tarball: ~${sizeMb} MB`);tarballs.set(siteName,tarballPath)}if(docker)await buildContainerImageWithPantry({slug,sites,verbose});const resolvedDeployEnv=await resolveDeployEnvValues(environment,tsCloudConfig),sitesWithResolvedEnv=applyPreMigrationBackup(applyScheduledWork(applyPersistentStatePaths(mergeSiteDeployEnv(sites,resolvedDeployEnv),slug),p.projectPath("app/Scheduler.ts")),projectDatabaseTarget(slug,"backups"));for(const[envKey,envValue]of Object.entries(resolvedDeployEnv))if(process.env[envKey]===void 0)process.env[envKey]=envValue;const githubDeployments=await startGithubDeployments({sites,onlySite,environment,resolveSiteKind});log.info(onlySite?`Shipping site '${onlySite}' to the server...`:"Shipping release to the server...");const deployConfig=onlySite?{...tsCloudConfig,sites:{[onlySite]:sitesWithResolvedEnv[onlySite]}}:{...tsCloudConfig,sites:sitesWithResolvedEnv},ok=await deployAllComputeSites({config:deployConfig,managementDashboard:!onlySite,rpxConfig:{...tsCloudConfig,sites:sitesWithResolvedEnv},environment,driver,sha,runtime:tsCloudConfig.infrastructure?.compute?.runtime||"bun",tarballForSite:(siteName)=>{const path=tarballs.get(siteName);if(!path)throw Error(`Missing tarball for site '${siteName}'`);return path},logger:{info:(m)=>log.info(m),warn:(m)=>log.warn(m),error:(m)=>log.error(m),step:(m)=>log.info(m),success:(m)=>log.success(m)}});let publishedDns=[];if(ok){const autoWww=tsCloudConfig.infrastructure?.compute?.proxy?.autoWww;publishedDns=await reconcileHetznerDns(onlySite?{[onlySite]:sites[onlySite]}:sites,ip,log,ipv6,autoWww)}if(ok&&publishedDns.length>0){log.info(`Issuing TLS for ${publishedDns.length} newly published record(s)...`);try{const{execSync}=await import("node:child_process"),unit=`rpx-cert-renew-${tsCloudConfig.project?.slug||"app"}.service`,certSshArgs=["-o","StrictHostKeyChecking=accept-new","-o","BatchMode=yes","-o","ConnectTimeout=20",`root@${ip}`],out=execSync(`ssh ${certSshArgs.map((a)=>`'${a}'`).join(" ")} bash -s`,{input:`systemctl start ${unit} 2>&1 || true
30
+ `)}else{log.info("Provisioning Hetzner compute infrastructure...");const outputs=await driver.provisionComputeInfrastructure({config:scrubLoopbackSitePortsForFirewall(tsCloudConfig),environment});ip=outputs.appPublicIp;ipv6=outputs.appPublicIpv6;log.success("Hetzner compute infrastructure ready");if(outputs.appInstanceId)log.info(`Server ID: ${outputs.appInstanceId}`)}if(ip)log.info(`Server IP: ${ip}`);if(!ip){log.error("Provisioned server has no public IP \u2014 cannot deploy over SSH.");process.exit(ExitCode.FatalError)}await waitForRemoteReady(ip);if(onlySite&&!attachTo)await reconcilePartialDeployManagementDashboardsWithLiveBox(tsCloudConfig,ip);const{execSync}=await import("node:child_process"),{tmpdir}=await import("node:os"),sites=applyEnvironmentToSites(tsCloudConfig.sites||{},environment,tsCloudConfig),slug=tsCloudConfig.project?.slug||"app";let sha;try{sha=execSync("git rev-parse --short HEAD",{stdio:["ignore","pipe","ignore"]}).toString().trim()}catch{sha=Date.now().toString(36)}const tarExcludes=["node_modules","pantry",".git",".github",".cache","bin","dist",relative(p.projectPath(),p.stxPath()).replace(/\/$/,""),".stx",relative(p.projectPath(),p.cloudStatePath()).replace(/\/$/,""),".ts-cloud",relative(p.projectPath(),p.frameworkRuntimePath()).replace(/\/$/,""),"tmp","temp",".DS_Store","*.log",".env",".env.local",".env.keys",".env.production.bak",".env.production.plain",...encryptedEnvFileNames(p.projectPath()),"*.sqlite","*.sqlite-wal","*.sqlite-shm"];if(onlySite&&!sites[onlySite]){log.error(`--site '${onlySite}' is not a configured site. Available: ${Object.keys(sites).join(", ")}`);process.exit(ExitCode.FatalError)}const tarballs=new Map;for(const[siteName,site]of Object.entries(sites)){if(!site)continue;if(onlySite&&siteName!==onlySite)continue;const kind=resolveSiteKind(site);if(kind==="bucket")continue;if(kind==="redirect")continue;if(kind==="server-static"&&site.build){log.info(`Building static site '${siteName}': ${site.build}`);execSync(site.build,{stdio:verbose?"inherit":"pipe"})}const root=site.root||".",tarballPath=join(tmpdir(),`${slug}-${siteName}-${sha}.tar.gz`),siteExcludes=Array.isArray(site.exclude)?site.exclude.filter((entry)=>typeof entry==="string"&&entry.length>0):[],excludeArgs=[...tarExcludes,...siteExcludes].flatMap((pattern)=>[`--exclude='${pattern}'`,`--exclude='*/${pattern}'`]);if(siteExcludes.length>0)log.info(`Excluding server-owned paths: ${siteExcludes.join(", ")}`);log.info(`Packaging ${root} \u2192 ${tarballPath}...`);execSync(`tar czf "${tarballPath}" ${excludeArgs.join(" ")} -C "${root}" .`,{stdio:verbose?"inherit":"pipe",env:{...process.env,COPYFILE_DISABLE:"1"}});const sizeMb=Math.max(1,Math.round(statSync(tarballPath).size/1048576));log.info(`Release tarball: ~${sizeMb} MB`);tarballs.set(siteName,tarballPath)}if(docker)await buildContainerImageWithPantry({slug,sites,verbose});const resolvedDeployEnv=await resolveDeployEnvValues(environment,tsCloudConfig),sitesWithResolvedEnv=applyPreMigrationBackup(applyScheduledWork(applyPersistentStatePaths(mergeSiteDeployEnv(sites,resolvedDeployEnv),slug),p.projectPath("app/Scheduler.ts")),projectDatabaseTarget(slug,"backups")),apiProblem=apiDeploymentProblem(sitesWithResolvedEnv,existsSync(p.projectPath("routes/api.ts")));if(apiProblem){log.error(apiProblem);throw Error("Refusing to deploy: the API would not be reachable.")}for(const[envKey,envValue]of Object.entries(resolvedDeployEnv))if(process.env[envKey]===void 0)process.env[envKey]=envValue;const githubDeployments=await startGithubDeployments({sites,onlySite,environment,resolveSiteKind});log.info(onlySite?`Shipping site '${onlySite}' to the server...`:"Shipping release to the server...");const deployConfig=onlySite?{...tsCloudConfig,sites:{[onlySite]:sitesWithResolvedEnv[onlySite]}}:{...tsCloudConfig,sites:sitesWithResolvedEnv},ok=await deployAllComputeSites({config:deployConfig,managementDashboard:!onlySite,rpxConfig:{...tsCloudConfig,sites:sitesWithResolvedEnv},environment,driver,sha,runtime:tsCloudConfig.infrastructure?.compute?.runtime||"bun",tarballForSite:(siteName)=>{const path=tarballs.get(siteName);if(!path)throw Error(`Missing tarball for site '${siteName}'`);return path},logger:{info:(m)=>log.info(m),warn:(m)=>log.warn(m),error:(m)=>log.error(m),step:(m)=>log.info(m),success:(m)=>log.success(m)}});let publishedDns=[];if(ok){const autoWww=tsCloudConfig.infrastructure?.compute?.proxy?.autoWww;publishedDns=await reconcileHetznerDns(onlySite?{[onlySite]:sites[onlySite]}:sites,ip,log,ipv6,autoWww)}if(ok&&publishedDns.length>0){log.info(`Issuing TLS for ${publishedDns.length} newly published record(s)...`);try{const{execSync}=await import("node:child_process"),unit=`rpx-cert-renew-${tsCloudConfig.project?.slug||"app"}.service`,certSshArgs=["-o","StrictHostKeyChecking=accept-new","-o","BatchMode=yes","-o","ConnectTimeout=20",`root@${ip}`],out=execSync(`ssh ${certSshArgs.map((a)=>`'${a}'`).join(" ")} bash -s`,{input:`systemctl start ${unit} 2>&1 || true
30
31
  systemctl is-active ${unit} >/dev/null 2>&1 && echo TLSUNIT:running || echo TLSUNIT:done
31
32
  journalctl -u ${unit} -n 20 --no-pager 2>/dev/null | grep -E 'Certificate written|Skipping|error|Error' | tail -5 || true`,encoding:"utf8",stdio:["pipe","pipe","pipe"]});for(const line of out.split(`
32
33
  `))if(/Certificate written|Skipping/.test(line))log.info(` ${line.replace(/^.*?\]:\s*/,"").trim()}`);log.success("TLS issued and gateway reloaded for the new record(s)")}catch(err){log.warn(`TLS issuance after DNS failed: ${err?.message||err}`);log.warn(` Until it succeeds, ${publishedDns.join(", ")} serves a fallback certificate.`)}}if(ok)await reconcileConfigDns(onlySite?{[onlySite]:sites[onlySite]}:sites,log);if(ok){const mailOwner=mailServerOwnerFromConfig(emailConfig);let mailIp=ip;if(mailOwner){const mailLookup=await resolveAttachTargetBox(mailOwner,environment,tsCloudConfig);if(mailLookup.box?.publicIp){mailIp=mailLookup.box.publicIp;log.info(`Mail: reconciling on '${mailOwner}' box '${mailLookup.box.serverName}' (${mailIp})`)}else{mailIp=void 0;log.warn(`Mail: ${describeAttachLookupFailure(mailOwner,environment,mailLookup.failure)}`);log.warn("Mail: skipping mail reconciliation; the application deploy remains live.")}}const mailRes=mailIp?await provisionMailTenant(mailIp,log):null;if(mailOwner&&mailIp&&mailIp!==ip)await cleanupDetachedMailHealth(ip,log);if(mailRes)await reconcileMailDns(mailRes,mailIp,log)}for(const record of githubDeployments)await setGithubDeploymentStatus(record,ok?"success":"failure");console.log("");if(ok){await outro(`Deployed to Hetzner. Your site is live at http://${ip}:3000`,{startTime,useSeconds:!0});log.info(`Coming-soon page: http://${ip}:3000 (bypass with ?secret=\u2026)`)}else{await outro("Hetzner deploy reported a failure \u2014 see the per-instance output above.",{startTime,useSeconds:!0});process.exit(ExitCode.FatalError)}}function resolveMailboxes(mailboxes,domain){return resolveMailboxesWithSkipped(mailboxes,domain).boxes}function resolveMailboxesWithSkipped(mailboxes,domain){if(!Array.isArray(mailboxes))return{boxes:[],skipped:[]};const out=[],skipped=[];for(const entry of mailboxes){let raw,explicitPw;if(typeof entry==="string")raw=entry;else if(entry&&typeof entry==="object"){raw=entry.email??entry.username;explicitPw=entry.password}if(!raw||typeof raw!=="string")continue;const localPart=(raw.includes("@")?raw.split("@")[0]??"":raw).trim();if(!localPart)continue;const address=`${localPart}@${domain}`,envPw=explicitPw||process.env[`MAIL_PASSWORD_${localPart.toUpperCase().replace(/[^A-Z0-9]/g,"_")}`];if(!envPw){skipped.push(address);continue}out.push({address,localPart:localPart.toUpperCase(),password:envPw,generated:!1})}return{boxes:out,skipped}}export function hasExplicitEmailConfig(projectRoot=p.projectPath()){return existsSync(join(projectRoot,"config","email.ts"))}export function mailServerOwnerFromConfig(config){const owner=config?.server?.attachTo;return typeof owner==="string"&&owner.trim()?owner.trim():void 0}async function cleanupDetachedMailHealth(ip,logger){const{execSync}=await import("node:child_process"),sshArgs=["-o","StrictHostKeyChecking=accept-new","-o","BatchMode=yes","-o","ConnectTimeout=20",`root@${ip}`],script=`set -e
@@ -1,4 +1,4 @@
1
- import{cpSync,existsSync,readFileSync,writeFileSync}from"node:fs";import{homedir}from"node:os";import{join}from"node:path";import process from"node:process";import{runAction}from"@stacksjs/actions";import{log,onUnknownSubcommand,runCommand}from"@stacksjs/cli";import{Action}from"@stacksjs/enums";import{handleError}from"@stacksjs/error-handling";import{path as p}from"@stacksjs/path";import{copyFile,storage}from"@stacksjs/storage";import{ExitCode}from"@stacksjs/types";import{setupPrettyDevEnvironment}from"./dev";import{resultFailed}from"../result";function getTimeoutMs(envVar,fallbackMs){const value=Number(process.env[envVar]);if(Number.isFinite(value)&&value>0)return value;return fallbackMs}const PANTRY_CHECK_TIMEOUT_MS=getTimeoutMs("PANTRY_CHECK_TIMEOUT_MS",15000),PANTRY_INSTALL_TIMEOUT_MS=getTimeoutMs("PANTRY_INSTALL_TIMEOUT_MS",600000),PANTRY_DEPENDENCIES_TIMEOUT_MS=getTimeoutMs("PANTRY_DEPENDENCIES_TIMEOUT_MS",1200000),KEYGEN_TIMEOUT_MS=getTimeoutMs("KEYGEN_TIMEOUT_MS",120000),AWS_CONFIG_TIMEOUT_MS=getTimeoutMs("AWS_CONFIG_TIMEOUT_MS",900000);export function setup(buddy){const descriptions={setup:"This command ensures your project is setup correctly",ssl:"Setup SSL certificates and hosts file for HTTPS development",ai:"Set the project up for an AI coding agent (Claude Code, Codex, Cursor, Copilot, Gemini)",copy:"Copy the agent files instead of symlinking them, so they can be edited per project",force:"Overwrite files that already exist",ohMyZsh:"Enable Oh My Zsh",aws:"Ensures AWS is connected to the project",project:"Target a specific project",verbose:"Enable verbose output",domain:"Custom domain to setup (defaults to APP_URL)",skipHosts:"Skip adding domain to hosts file",skipTrust:"Skip trusting the certificate",skipAws:"Skip AWS configuration during setup",skipKeygen:"Skip generating an application key during setup"};buddy.command("setup",descriptions.setup).alias("ensure").option("-p, --project [project]",descriptions.project,{default:!1}).option("--skip-aws",descriptions.skipAws,{default:!1}).option("--skip-keygen",descriptions.skipKeygen,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy setup` ...",options);await ensurePantryInstalled();await optimizePantryDeps();await initializeProject(options)});buddy.command("setup:ssl",descriptions.ssl).alias("ssl:setup").option("-d, --domain [domain]",descriptions.domain).option("--skip-hosts",descriptions.skipHosts,{default:!1}).option("--skip-trust",descriptions.skipTrust,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy setup:ssl` ...",options);if(!await setupPrettyDevEnvironment({domain:options.domain,skipHosts:options.skipHosts,skipTrust:options.skipTrust,verbose:options.verbose})){log.warn("SSL setup completed with warnings");log.info("You may need to manually trust certificates or update hosts file")}});buddy.command("setup:ai [provider]",descriptions.ai).alias("ai:setup").option("--copy",descriptions.copy,{default:!1}).option("--force",descriptions.force,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(provider,options)=>{log.debug("Running `buddy setup:ai` ...",options);const{AI_PROVIDERS,isAiProvider,reportAiSetup,setupAiProvider}=await import("./setup-ai");let id=provider;if(!id){const{select}=await import("@stacksjs/cli");id=await select({message:"Which AI coding agent do you use?",choices:AI_PROVIDERS.map((entry)=>({value:entry.id,label:entry.label})),initial:0})}if(!id||!isAiProvider(id)){log.error(`Unknown AI provider: ${id}. Expected one of: ${AI_PROVIDERS.map((entry)=>entry.id).join(", ")}`);process.exit(ExitCode.InvalidArgument)}const definition=AI_PROVIDERS.find((entry)=>entry.id===id);reportAiSetup(definition,setupAiProvider(id,{copy:options.copy,force:options.force}))});buddy.command("setup:oh-my-zsh",descriptions.ohMyZsh).alias("upgrade:oh-my-zsh").option("--verbose",descriptions.verbose,{default:!1}).action(async(_options)=>{log.debug("Running `buddy setup:oh-my-zsh` ...",_options);const result=await runAction(Action.UpgradeShell);if(resultFailed(result)){log.error(result.error);process.exit(ExitCode.FatalError)}});onUnknownSubcommand(buddy,"setup")}async function isPantryInstalled(){try{return(await runCommand("pantry --version",{silent:!0,timeoutMs:PANTRY_CHECK_TIMEOUT_MS})).isOk}catch{return!1}}async function installPantry(){const bundledInstaller=p.frameworkPath("scripts/pantry-install"),command=existsSync(bundledInstaller)?[bundledInstaller]:["sh","-c","curl -fsSL https://pantry.dev | bash"],result=await runCommand(command,{timeoutMs:PANTRY_INSTALL_TIMEOUT_MS}),localBin=join(homedir(),".local","bin");if(!process.env.PATH?.split(":").includes(localBin))process.env.PATH=`${localBin}:${process.env.PATH||""}`;if(result.isOk&&await isPantryInstalled())return;if(resultFailed(result))handleError(result.error);else log.error("Pantry installed but is not available on PATH. Open a new shell and run `buddy setup` again.");process.exit(ExitCode.FatalError)}export async function ensurePantryInstalled(){if(await isPantryInstalled())return;log.info("Pantry is required. Installing it from https://pantry.dev...");await installPantry()}export async function ensurePantryDependencies(cwd){await ensurePantryInstalled();log.info("Installing project dependencies with Pantry...");const result=await runCommand("pantry install",{cwd,timeoutMs:PANTRY_DEPENDENCIES_TIMEOUT_MS});if(resultFailed(result)){handleError(result.error);process.exit(ExitCode.FatalError)}if(existsSync(join(cwd,"package.json"))&&!existsSync(join(cwd,"node_modules"))){log.error("Pantry completed without installing the project JavaScript dependencies.");process.exit(ExitCode.FatalError)}log.success("Installed project dependencies with Pantry")}function hasAppKey(cwd){const envPath=join(cwd,".env");if(!existsSync(envPath))return!1;return/^APP_KEY=.+$/m.test(readFileSync(envPath,"utf-8"))}export async function ensureAppKey(cwd){if(hasAppKey(cwd)||process.env.APP_KEY&&process.env.APP_KEY.length>0){log.success("APP_KEY existed");return}const keyResult=await runCommand("./buddy key:generate",{cwd,timeoutMs:KEYGEN_TIMEOUT_MS});if(resultFailed(keyResult)){handleError(keyResult.error);process.exit(ExitCode.FatalError)}log.success("Generated application key")}async function runInitialMigration(cwd){const appEnv=(process.env.APP_ENV||process.env.NODE_ENV||"local").toLowerCase();if(!["local","development","dev","test"].includes(appEnv)){log.info(`Skipping initial migration in the ${appEnv} environment`);return}log.info("Running initial database migration...");try{const result=await runAction(Action.Migrate,{cwd});if(resultFailed(result)){log.warn("Initial migration did not complete - you can run it later via ./buddy migrate");log.debug(result.error);return}log.success("Database is migrated")}catch(error){log.warn("Initial migration did not complete - you can run it later via ./buddy migrate");log.debug(error)}}async function initializeProject(options){const cwd=options.cwd||p.projectPath();await ensurePantryDependencies(cwd);await ensureEnvIsSet(options);if(!options.skipKeygen)await ensureAppKey(cwd);await runInitialMigration(cwd);ensureIdeSettings(cwd);if(!options.skipAws){log.info("Ensuring AWS is connected...");try{const awsResult=await runCommand("./buddy configure:aws",{cwd,timeoutMs:AWS_CONFIG_TIMEOUT_MS});if(resultFailed(awsResult)){log.warn("AWS not configured - you can do this later via ./buddy configure:aws");log.debug(awsResult.error)}else log.success("Configured AWS")}catch(error){log.warn("AWS not configured - you can do this later via ./buddy configure:aws");log.debug(error)}}log.success("Project is setup");log.info("Run `./buddy doctor` anytime to check your setup. Happy coding! \uD83D\uDC99")}export function ensureIdeSettings(cwd){const source=p.frameworkPath("defaults/ide/vscode/.vscode"),destination=join(cwd,".vscode");if(existsSync(destination)){log.debug(".vscode already exists; keeping the project settings");return}if(!existsSync(source)){log.debug("No bundled VS Code settings found; skipping IDE setup");return}cpSync(source,destination,{recursive:!0});log.success("Installed project VS Code settings")}const DB_CONNECTION_PACKAGES={postgres:{name:"postgresql.org",version:"^17.10",service:"postgres"},mysql:{name:"mysql.com",version:"*",service:"mysql"},sqlite:{name:"sqlite.org",version:"^3.47.2"}};function databaseAliases(connection,pkg){return[connection,pkg.name]}export function pantryDatabasePackage(connection){return DB_CONNECTION_PACKAGES[connection]}function detectDbPackage(cwd){const envPath=join(cwd,".env"),envExamplePath=join(cwd,".env.example"),filePath=existsSync(envPath)?envPath:existsSync(envExamplePath)?envExamplePath:void 0;if(!filePath)return;const match=readFileSync(filePath,"utf-8").match(/^DB_CONNECTION=(.+)$/m);if(!match)return;const value=match[1].trim().replace(/['"]/g,"");return pantryDatabasePackage(value)}export async function optimizePantryDeps(){const cwd=p.projectPath(),depsConfigPath=join(cwd,"config","deps.ts");if(!existsSync(depsConfigPath)){log.debug("No config/deps.ts found, skipping dependency optimization");return}let configDeps={},configServices=[],configDefined={};try{const mod=await import(depsConfigPath),config=mod.config||mod.default;if(config?.dependencies)configDeps={...config.dependencies};if(Array.isArray(config?.services?.autoStart))configServices=config.services.autoStart.filter((name)=>typeof name==="string");if(config?.services?.define&&typeof config.services.define==="object")configDefined=config.services.define}catch(err){log.debug("Could not load config/deps.ts, skipping dependency optimization");return}const dbPackage=detectDbPackage(cwd),autoStart=[...configServices];if(dbPackage){const selected=new Set(Object.entries(DB_CONNECTION_PACKAGES).filter(([,pkg])=>pkg.name===dbPackage.name).flatMap(([connection,pkg])=>databaseAliases(connection,pkg))),unused=new Set(Object.entries(DB_CONNECTION_PACKAGES).flatMap(([connection,pkg])=>databaseAliases(connection,pkg)).filter((alias)=>!selected.has(alias)));for(const pkg of Object.keys(configDeps)){const domain=pkg.split("/")[0];if(unused.has(domain)){log.info(`DB_CONNECTION selects ${dbPackage.name}, dropping unused ${pkg}`);delete configDeps[pkg]}}if(!Object.keys(configDeps).some((key)=>{const domain=key.split("/")[0];return selected.has(domain)})){log.info(`Detected DB_CONNECTION requires ${dbPackage.name}, adding to dependencies`);configDeps[dbPackage.name]=dbPackage.version}if(dbPackage.service&&!autoStart.includes(dbPackage.service))autoStart.push(dbPackage.service)}const lines=["# Auto-generated from config/deps.ts and .env sniffing.","# This file is regenerated on each `buddy setup` run.","#","# To learn more, please visit:","# https://stacksjs.com/docs/dependency-management","","dependencies:"];for(const[pkg,version]of Object.entries(configDeps))lines.push(` ${pkg}: ${version}`);const defined=Object.entries(configDefined);if(autoStart.length>0||defined.length>0){lines.push("","services:"," enabled: true");if(autoStart.length>0){lines.push(" autoStart:");for(const service of autoStart)lines.push(` - ${service}`)}if(defined.length>0){lines.push(" define:");for(const[name,definition]of defined){if(!definition||typeof definition!=="object")continue;lines.push(` ${name}:`);for(const[key,value]of Object.entries(definition)){if(value===void 0||value===null)continue;lines.push(` ${key}: ${String(value)}`)}}}}const depsYamlPath=join(cwd,"deps.yaml");writeFileSync(depsYamlPath,`${lines.join(`
1
+ import{cpSync,existsSync,readFileSync,writeFileSync}from"node:fs";import{homedir}from"node:os";import{join}from"node:path";import process from"node:process";import{runAction}from"@stacksjs/actions";import{log,onUnknownSubcommand,runCommand}from"@stacksjs/cli";import{Action}from"@stacksjs/enums";import{handleError}from"@stacksjs/error-handling";import{path as p}from"@stacksjs/path";import{copyFile,storage}from"@stacksjs/storage";import{ExitCode}from"@stacksjs/types";import{setupPrettyDevEnvironment}from"./dev";import{resultFailed}from"../result";function getTimeoutMs(envVar,fallbackMs){const value=Number(process.env[envVar]);if(Number.isFinite(value)&&value>0)return value;return fallbackMs}const PANTRY_CHECK_TIMEOUT_MS=getTimeoutMs("PANTRY_CHECK_TIMEOUT_MS",15000),PANTRY_INSTALL_TIMEOUT_MS=getTimeoutMs("PANTRY_INSTALL_TIMEOUT_MS",600000),PANTRY_DEPENDENCIES_TIMEOUT_MS=getTimeoutMs("PANTRY_DEPENDENCIES_TIMEOUT_MS",1200000),KEYGEN_TIMEOUT_MS=getTimeoutMs("KEYGEN_TIMEOUT_MS",120000),AWS_CONFIG_TIMEOUT_MS=getTimeoutMs("AWS_CONFIG_TIMEOUT_MS",900000);export function setup(buddy){const descriptions={setup:"This command ensures your project is setup correctly",ssl:"Setup SSL certificates and hosts file for HTTPS development",ai:"Set the project up for an AI coding agent (Claude Code, Codex, Cursor, Copilot, Gemini)",copy:"Copy the agent files instead of symlinking them, so they can be edited per project",force:"Overwrite files that already exist",ohMyZsh:"Enable Oh My Zsh",aws:"Ensures AWS is connected to the project",project:"Target a specific project",verbose:"Enable verbose output",domain:"Custom domain to setup (defaults to APP_URL)",skipHosts:"Skip adding domain to hosts file",skipTrust:"Skip trusting the certificate",skipAws:"Skip AWS configuration during setup",skipKeygen:"Skip generating an application key during setup"};buddy.command("setup",descriptions.setup).alias("ensure").option("-p, --project [project]",descriptions.project,{default:!1}).option("--skip-aws",descriptions.skipAws,{default:!1}).option("--skip-keygen",descriptions.skipKeygen,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy setup` ...",options);await ensurePantryInstalled();await optimizePantryDeps();await initializeProject(options)});buddy.command("setup:ssl",descriptions.ssl).alias("ssl:setup").option("-d, --domain [domain]",descriptions.domain).option("--skip-hosts",descriptions.skipHosts,{default:!1}).option("--skip-trust",descriptions.skipTrust,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy setup:ssl` ...",options);if(!await setupPrettyDevEnvironment({domain:options.domain,skipHosts:options.skipHosts,skipTrust:options.skipTrust,verbose:options.verbose})){log.warn("SSL setup completed with warnings");log.info("You may need to manually trust certificates or update hosts file")}});buddy.command("setup:ai [provider]",descriptions.ai).alias("ai:setup").option("--copy",descriptions.copy,{default:!1}).option("--force",descriptions.force,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(provider,options)=>{log.debug("Running `buddy setup:ai` ...",options);const{AI_PROVIDERS,isAiProvider,reportAiSetup,setupAiProvider}=await import("./setup-ai");let id=provider;if(!id){const{select}=await import("@stacksjs/cli");id=await select({message:"Which AI coding agent do you use?",choices:AI_PROVIDERS.map((entry)=>({value:entry.id,label:entry.label})),initial:0})}if(!id||!isAiProvider(id)){log.error(`Unknown AI provider: ${id}. Expected one of: ${AI_PROVIDERS.map((entry)=>entry.id).join(", ")}`);process.exit(ExitCode.InvalidArgument)}const definition=AI_PROVIDERS.find((entry)=>entry.id===id);reportAiSetup(definition,setupAiProvider(id,{copy:options.copy,force:options.force}))});buddy.command("setup:oh-my-zsh",descriptions.ohMyZsh).alias("upgrade:oh-my-zsh").option("--verbose",descriptions.verbose,{default:!1}).action(async(_options)=>{log.debug("Running `buddy setup:oh-my-zsh` ...",_options);const result=await runAction(Action.UpgradeShell);if(resultFailed(result)){log.error(result.error);process.exit(ExitCode.FatalError)}});onUnknownSubcommand(buddy,"setup")}async function isPantryInstalled(){try{return(await runCommand("pantry --version",{silent:!0,timeoutMs:PANTRY_CHECK_TIMEOUT_MS})).isOk}catch{return!1}}async function installPantry(){const bundledInstaller=p.frameworkPath("scripts/pantry-install"),command=existsSync(bundledInstaller)?[bundledInstaller]:["sh","-c","curl -fsSL https://pantry.dev | bash"],result=await runCommand(command,{timeoutMs:PANTRY_INSTALL_TIMEOUT_MS}),localBin=join(homedir(),".local","bin");if(!process.env.PATH?.split(":").includes(localBin))process.env.PATH=`${localBin}:${process.env.PATH||""}`;if(result.isOk&&await isPantryInstalled())return;if(resultFailed(result))handleError(result.error);else log.error("Pantry installed but is not available on PATH. Open a new shell and run `buddy setup` again.");process.exit(ExitCode.FatalError)}export async function ensurePantryInstalled(){if(await isPantryInstalled())return;log.info("Pantry is required. Installing it from https://pantry.dev...");await installPantry()}export async function ensurePantryDependencies(cwd){await ensurePantryInstalled();log.info("Installing project dependencies with Pantry...");const result=await runCommand("pantry install",{cwd,timeoutMs:PANTRY_DEPENDENCIES_TIMEOUT_MS});if(resultFailed(result)){handleError(result.error);process.exit(ExitCode.FatalError)}if(existsSync(join(cwd,"package.json"))&&!existsSync(join(cwd,"node_modules"))){log.error("Pantry completed without installing the project JavaScript dependencies.");process.exit(ExitCode.FatalError)}log.success("Installed project dependencies with Pantry")}function hasAppKey(cwd){const envPath=join(cwd,".env");if(!existsSync(envPath))return!1;return/^APP_KEY=.+$/m.test(readFileSync(envPath,"utf-8"))}export async function ensureAppKey(cwd){if(hasAppKey(cwd)||process.env.APP_KEY&&process.env.APP_KEY.length>0){log.success("APP_KEY existed");return}const keyResult=await runCommand("./buddy key:generate",{cwd,timeoutMs:KEYGEN_TIMEOUT_MS});if(resultFailed(keyResult)){handleError(keyResult.error);process.exit(ExitCode.FatalError)}log.success("Generated application key")}async function runInitialMigration(cwd){const appEnv=(process.env.APP_ENV||process.env.NODE_ENV||"local").toLowerCase();if(!["local","development","dev","test"].includes(appEnv)){log.info(`Skipping initial migration in the ${appEnv} environment`);return}log.info("Running initial database migration...");try{const result=await runAction(Action.Migrate,{cwd});if(resultFailed(result)){log.warn("Initial migration did not complete - you can run it later via ./buddy migrate");log.debug(result.error);return}log.success("Database is migrated")}catch(error){log.warn("Initial migration did not complete - you can run it later via ./buddy migrate");log.debug(error)}}async function initializeProject(options){const cwd=options.cwd||p.projectPath();await ensurePantryDependencies(cwd);await ensureEnvIsSet(options);if(!options.skipKeygen)await ensureAppKey(cwd);await runInitialMigration(cwd);ensureIdeSettings(cwd);if(!options.skipAws){log.info("Ensuring AWS is connected...");try{const awsResult=await runCommand("./buddy configure:aws",{cwd,timeoutMs:AWS_CONFIG_TIMEOUT_MS});if(resultFailed(awsResult)){log.warn("AWS not configured - you can do this later via ./buddy configure:aws");log.debug(awsResult.error)}else log.success("Configured AWS")}catch(error){log.warn("AWS not configured - you can do this later via ./buddy configure:aws");log.debug(error)}}log.success("Project is setup");log.info("Run `./buddy doctor` anytime to check your setup. Happy coding! \uD83D\uDC99")}export function ensureIdeSettings(cwd){const source=p.frameworkPath("defaults/ide/vscode/.vscode"),destination=join(cwd,".vscode");if(existsSync(destination)){log.debug(".vscode already exists; keeping the project settings");return}if(!existsSync(source)){log.debug("No bundled VS Code settings found; skipping IDE setup");return}cpSync(source,destination,{recursive:!0});log.success("Installed project VS Code settings")}const DB_CONNECTION_PACKAGES={postgres:{name:"postgresql.org",version:"^17.10",service:"postgres"},mysql:{name:"mysql.com",version:"^9.2",service:"mysql"},sqlite:{name:"sqlite.org",version:"^3.47.2"}};function databaseAliases(connection,pkg){return[connection,pkg.name]}export function pantryDatabasePackage(connection){return DB_CONNECTION_PACKAGES[connection]}function detectDbPackage(cwd){const envPath=join(cwd,".env"),envExamplePath=join(cwd,".env.example"),filePath=existsSync(envPath)?envPath:existsSync(envExamplePath)?envExamplePath:void 0;if(!filePath)return;const match=readFileSync(filePath,"utf-8").match(/^DB_CONNECTION=(.+)$/m);if(!match)return;const value=match[1].trim().replace(/['"]/g,"");return pantryDatabasePackage(value)}export async function optimizePantryDeps(){const cwd=p.projectPath(),depsConfigPath=join(cwd,"config","deps.ts");if(!existsSync(depsConfigPath)){log.debug("No config/deps.ts found, skipping dependency optimization");return}let configDeps={},configServices=[],configDefined={};try{const mod=await import(depsConfigPath),config=mod.config||mod.default;if(config?.dependencies)configDeps={...config.dependencies};if(Array.isArray(config?.services?.autoStart))configServices=config.services.autoStart.filter((name)=>typeof name==="string");if(config?.services?.define&&typeof config.services.define==="object")configDefined=config.services.define}catch(err){log.debug("Could not load config/deps.ts, skipping dependency optimization");return}const dbPackage=detectDbPackage(cwd),autoStart=[...configServices];if(dbPackage){const selected=new Set(Object.entries(DB_CONNECTION_PACKAGES).filter(([,pkg])=>pkg.name===dbPackage.name).flatMap(([connection,pkg])=>databaseAliases(connection,pkg))),unused=new Set(Object.entries(DB_CONNECTION_PACKAGES).flatMap(([connection,pkg])=>databaseAliases(connection,pkg)).filter((alias)=>!selected.has(alias)));for(const pkg of Object.keys(configDeps)){const domain=pkg.split("/")[0];if(unused.has(domain)){log.info(`DB_CONNECTION selects ${dbPackage.name}, dropping unused ${pkg}`);delete configDeps[pkg]}}if(!Object.keys(configDeps).some((key)=>{const domain=key.split("/")[0];return selected.has(domain)})){log.info(`Detected DB_CONNECTION requires ${dbPackage.name}, adding to dependencies`);configDeps[dbPackage.name]=dbPackage.version}if(dbPackage.service&&!autoStart.includes(dbPackage.service))autoStart.push(dbPackage.service)}const lines=["# Auto-generated from config/deps.ts and .env sniffing.","# This file is regenerated on each `buddy setup` run.","#","# To learn more, please visit:","# https://stacksjs.com/docs/dependency-management","","dependencies:"];for(const[pkg,version]of Object.entries(configDeps))lines.push(` ${pkg}: ${version}`);const defined=Object.entries(configDefined);if(autoStart.length>0||defined.length>0){lines.push("","services:"," enabled: true");if(autoStart.length>0){lines.push(" autoStart:");for(const service of autoStart)lines.push(` - ${service}`)}if(defined.length>0){lines.push(" define:");for(const[name,definition]of defined){if(!definition||typeof definition!=="object")continue;lines.push(` ${name}:`);for(const[key,value]of Object.entries(definition)){if(value===void 0||value===null)continue;lines.push(` ${key}: ${String(value)}`)}}}}const depsYamlPath=join(cwd,"deps.yaml");writeFileSync(depsYamlPath,`${lines.join(`
2
2
  `)}
3
3
  `);log.success("Generated deps.yaml from config/deps.ts")}export async function ensureEnvIsSet(options){log.info("Ensuring .env exists...");const cwd=options.cwd||p.projectPath(),envPath=`${cwd}/.env`,envExamplePath=`${cwd}/.env.example`;if(storage.doesNotExist(envPath)){try{copyFile(envExamplePath,envPath)}catch(error){handleError(error);process.exit(ExitCode.FatalError)}log.success(".env created")}else log.success(".env existed")}function envValues(contents){return contents.split(`
4
4
  `).map((line)=>line.trim()).filter((line)=>line.length>0&&!line.startsWith("#")&&!line.startsWith("DOTENV_PUBLIC_KEY")).map((line)=>line.slice(line.indexOf("=")+1).trim()).map((value)=>value.replace(/^['"]/,""))}function isCiphertext(value){return value.startsWith("encrypted:")||value.startsWith("enc:")}export function isEnvFileEncrypted(contents){return envValues(contents).every(isCiphertext)}function deployEnvTemplate(environment){return[`# Secrets for the ${environment} environment.`,"#","# Values here are encrypted with the public key below and decrypted at","# deploy time with the matching private key in .env.keys, which is NOT","# committed. This file is \u2014 that is the point: the ciphertext is reviewable","# and diffable, and losing a laptop does not leak production.","#","# Add one with:",`# buddy env:set STRIPE_SECRET_KEY sk_live_\u2026 --env ${environment}`,"#","# Left empty on purpose. Nothing was copied out of your .env: that file","# describes a laptop, and a server is not one.",""].join(`
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@stacksjs/buddy",
3
3
  "type": "module",
4
4
  "sideEffects": false,
5
- "version": "0.72.31",
5
+ "version": "0.72.33",
6
6
  "description": "Meet Buddy. The Stacks runtime.",
7
7
  "author": "Chris Breuer",
8
8
  "contributors": [
@@ -95,63 +95,63 @@
95
95
  "prepublishOnly": "bun run build"
96
96
  },
97
97
  "dependencies": {
98
- "@stacksjs/actions": "^0.72.30",
99
- "@stacksjs/ai": "^0.72.30",
100
- "@stacksjs/alias": "^0.72.30",
101
- "@stacksjs/arrays": "^0.72.30",
102
- "@stacksjs/auth": "^0.72.30",
103
- "@stacksjs/build": "^0.72.30",
104
- "@stacksjs/cache": "^0.72.30",
105
- "@stacksjs/chat": "^0.72.30",
98
+ "@stacksjs/actions": "^0.72.33",
99
+ "@stacksjs/ai": "^0.72.33",
100
+ "@stacksjs/alias": "^0.72.33",
101
+ "@stacksjs/arrays": "^0.72.33",
102
+ "@stacksjs/auth": "^0.72.33",
103
+ "@stacksjs/build": "^0.72.33",
104
+ "@stacksjs/cache": "^0.72.33",
105
+ "@stacksjs/chat": "^0.72.33",
106
106
  "@stacksjs/clapp": "^0.2.12",
107
- "@stacksjs/cli": "^0.72.30",
108
- "@stacksjs/cloud": "^0.72.30",
109
- "@stacksjs/cms": "^0.72.30",
110
- "@stacksjs/collections": "^0.72.30",
111
- "@stacksjs/config": "^0.72.30",
112
- "@stacksjs/database": "^0.72.30",
113
- "@stacksjs/desktop-build": "^0.72.30",
114
- "@stacksjs/dns": "^0.72.30",
107
+ "@stacksjs/cli": "^0.72.33",
108
+ "@stacksjs/cloud": "^0.72.33",
109
+ "@stacksjs/cms": "^0.72.33",
110
+ "@stacksjs/collections": "^0.72.33",
111
+ "@stacksjs/config": "^0.72.33",
112
+ "@stacksjs/database": "^0.72.33",
113
+ "@stacksjs/desktop-build": "^0.72.33",
114
+ "@stacksjs/dns": "^0.72.33",
115
115
  "@stacksjs/dnsx": "^0.2.3",
116
- "@stacksjs/email": "^0.72.30",
117
- "@stacksjs/enums": "^0.72.30",
118
- "@stacksjs/error-handling": "^0.72.30",
119
- "@stacksjs/events": "^0.72.30",
120
- "@stacksjs/git": "^0.72.30",
116
+ "@stacksjs/email": "^0.72.33",
117
+ "@stacksjs/enums": "^0.72.33",
118
+ "@stacksjs/error-handling": "^0.72.33",
119
+ "@stacksjs/events": "^0.72.33",
120
+ "@stacksjs/git": "^0.72.33",
121
121
  "@stacksjs/gitit": "^0.2.5",
122
- "@stacksjs/health": "^0.72.30",
122
+ "@stacksjs/health": "^0.72.33",
123
123
  "@stacksjs/httx": "^0.1.10",
124
- "@stacksjs/image": "^0.72.30",
125
- "@stacksjs/lint": "^0.72.30",
126
- "@stacksjs/logging": "^0.72.30",
127
- "@stacksjs/notifications": "^0.72.30",
128
- "@stacksjs/objects": "^0.72.30",
129
- "@stacksjs/orm": "^0.72.30",
130
- "@stacksjs/path": "^0.72.30",
131
- "@stacksjs/payments": "^0.72.30",
132
- "@stacksjs/realtime": "^0.72.30",
133
- "@stacksjs/router": "^0.72.30",
124
+ "@stacksjs/image": "^0.72.33",
125
+ "@stacksjs/lint": "^0.72.33",
126
+ "@stacksjs/logging": "^0.72.33",
127
+ "@stacksjs/notifications": "^0.72.33",
128
+ "@stacksjs/objects": "^0.72.33",
129
+ "@stacksjs/orm": "^0.72.33",
130
+ "@stacksjs/path": "^0.72.33",
131
+ "@stacksjs/payments": "^0.72.33",
132
+ "@stacksjs/realtime": "^0.72.33",
133
+ "@stacksjs/router": "^0.72.33",
134
134
  "@stacksjs/rpx": "^0.11.42",
135
- "@stacksjs/search-engine": "^0.72.30",
136
- "@stacksjs/security": "^0.72.30",
137
- "@stacksjs/server": "^0.72.30",
138
- "@stacksjs/sites": "^0.72.30",
139
- "@stacksjs/skills": "^0.72.30",
140
- "@stacksjs/storage": "^0.72.30",
141
- "@stacksjs/strings": "^0.72.30",
142
- "@stacksjs/testing": "^0.72.30",
143
- "@stacksjs/ts-cloud": "^0.8.3",
144
- "@stacksjs/tunnel": "^0.72.30",
145
- "@stacksjs/types": "^0.72.30",
146
- "@stacksjs/ui": "^0.72.30",
147
- "@stacksjs/utils": "^0.72.30",
148
- "@stacksjs/validation": "^0.72.30",
135
+ "@stacksjs/search-engine": "^0.72.33",
136
+ "@stacksjs/security": "^0.72.33",
137
+ "@stacksjs/server": "^0.72.33",
138
+ "@stacksjs/sites": "^0.72.33",
139
+ "@stacksjs/skills": "^0.72.33",
140
+ "@stacksjs/storage": "^0.72.33",
141
+ "@stacksjs/strings": "^0.72.33",
142
+ "@stacksjs/testing": "^0.72.33",
143
+ "@stacksjs/ts-cloud": "^0.9.4",
144
+ "@stacksjs/tunnel": "^0.72.33",
145
+ "@stacksjs/types": "^0.72.33",
146
+ "@stacksjs/ui": "^0.72.33",
147
+ "@stacksjs/utils": "^0.72.33",
148
+ "@stacksjs/validation": "^0.72.33",
149
149
  "ajv": "^8.20.0",
150
150
  "ajv-formats": "^3.0.1",
151
- "ts-pantry": "^0.11.29"
151
+ "ts-pantry": "^0.11.35"
152
152
  },
153
153
  "devDependencies": {
154
- "better-dx": "^0.2.23"
154
+ "better-dx": "^0.2.24"
155
155
  },
156
156
  "web-types": "./web-types.json"
157
157
  }