@stacksjs/buddy 0.70.353 → 0.70.355

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +1 @@
1
- import process from"node:process";import{runAction}from"@stacksjs/actions";import{intro,log,onUnknownSubcommand,outro}from"@stacksjs/cli";import{hasTTY,isCI}from"@stacksjs/env";import{Action}from"@stacksjs/enums";import{ExitCode}from"@stacksjs/types";import{resultFailed}from"../result";export function clean(buddy){const descriptions={clean:"Removes all node_modules & lock files",project:"Target a specific project",force:"Skip the confirmation prompt (required in CI/non-interactive shells)",verbose:"Enable verbose output"};buddy.command("clean",descriptions.clean).option("-p, --project [project]",descriptions.project,{default:!1}).option("-f, --force",descriptions.force,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy clean` ...",options);const skipConfirm=options.force===!0||Boolean(buddy.isForce)||Boolean(buddy.isNoInteraction);if(!skipConfirm&&(isCI||!hasTTY||!process.stdin.isTTY)){log.syncError("Refusing to run `buddy clean` from a non-interactive shell without confirmation.");log.fatal(" \u27A1\uFE0F Re-run with `--force` to proceed: `buddy clean --force`")}if(!skipConfirm){const{confirm}=await import("@stacksjs/cli");if(!await confirm({message:"This will remove all node_modules and lock files. Continue?",initial:!1})){log.info("Clean cancelled");process.exit(ExitCode.Success)}}const perf=await intro("buddy clean"),result=await runAction(Action.Clean,options);if(resultFailed(result)){await outro("While running the clean command, there was an issue",{startTime:perf,useSeconds:!0},result.error);process.exit(ExitCode.FatalError)}await outro("Cleaned up",{startTime:perf,useSeconds:!0,message:"Cleaned up"});process.exit(ExitCode.Success)});onUnknownSubcommand(buddy,"clean")}
1
+ import process from"node:process";import{runAction}from"@stacksjs/actions";import{intro,log,onUnknownSubcommand,outro}from"@stacksjs/cli";import{hasTTY,isCI}from"@stacksjs/env";import{Action}from"@stacksjs/enums";import{ExitCode}from"@stacksjs/types";import{resultFailed}from"../result";export function clean(buddy){const descriptions={clean:"Removes all node_modules & lock files",project:"Target a specific project",force:"Skip the confirmation prompt (required in CI/non-interactive shells)",verbose:"Enable verbose output"};buddy.command("clean",descriptions.clean).option("-p, --project [project]",descriptions.project,{default:!1}).option("-f, --force",descriptions.force,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy clean` ...",options);if(options.dryRun){const perf=await intro("buddy clean");log.info("Dry run: would remove dependency directories, generated framework builds, and lockfiles.");await outro("Clean preview complete",{startTime:perf,useSeconds:!0,message:"No files were removed"});return}const skipConfirm=options.force===!0||Boolean(buddy.isForce)||Boolean(buddy.isNoInteraction);if(!skipConfirm&&(isCI||!hasTTY||!process.stdin.isTTY)){log.syncError("Refusing to run `buddy clean` from a non-interactive shell without confirmation.");log.fatal(" \u27A1\uFE0F Re-run with `--force` to proceed: `buddy clean --force`")}if(!skipConfirm){const{confirm}=await import("@stacksjs/cli");if(!await confirm({message:"This will remove all node_modules and lock files. Continue?",initial:!1})){log.info("Clean cancelled");process.exit(ExitCode.Success)}}const perf=await intro("buddy clean"),result=await runAction(Action.Clean,options);if(resultFailed(result)){await outro("While running the clean command, there was an issue",{startTime:perf,useSeconds:!0},result.error);process.exit(ExitCode.FatalError)}await outro("Cleaned up",{startTime:perf,useSeconds:!0,message:"Cleaned up"});process.exit(ExitCode.Success)});onUnknownSubcommand(buddy,"clean")}
@@ -139,6 +139,42 @@ export declare function projectDatabaseTarget(slug: string, relativePath: string
139
139
  * app can persist its own state (upload dirs, generated assets) the same way.
140
140
  */
141
141
  export declare function applyPersistentStatePaths(sites: Record<string, any>, slug: string): Record<string, any>;
142
+ /**
143
+ * Does this file declare any scheduled work?
144
+ *
145
+ * The scaffold ships an `app/Scheduler.ts` whose body is entirely commented
146
+ * out except for the example job, so "the file exists" is not the question —
147
+ * "does it schedule anything" is. A `schedule.` call outside a comment is the
148
+ * marker: it is what the scheduler action iterates over, and an app with none
149
+ * has nothing for a daemon to do.
150
+ */
151
+ export declare function declaresScheduledWork(schedulerFile: string): boolean;
152
+ /**
153
+ * Turn on the scheduler for the one site that should run it.
154
+ *
155
+ * `app/Scheduler.ts` is the Laravel-shaped place to declare recurring work, and
156
+ * a deploy shipped it to a box where nothing ran it. ts-cloud has known how to
157
+ * run it all along — `scheduler: true` on a site installs
158
+ * `<slug>-<site>-scheduler.service`, and for Stacks it runs as a daemon rather
159
+ * than Laravel's every-minute cron, because `buddy schedule:run` registers
160
+ * timers on the event loop and stays up where `artisan schedule:run` exits. The
161
+ * missing piece was that nothing ever set the flag, so every schedule any app
162
+ * declared was inert in production — silently, because a task that never fires
163
+ * looks exactly like a task with nothing to do. One dispensary's nightly menu
164
+ * import sat unrun while its storefront served the previous week's catalogue,
165
+ * and the only reason anyone noticed was a customer looking at the site.
166
+ *
167
+ * On for exactly ONE site, the same owner {@link applyPersistentStatePaths}
168
+ * picks: the site that runs migrations, else the first server app. An app that
169
+ * deploys `main` and `api` from one codebase has one `app/Scheduler.ts` between
170
+ * them, and turning it on per-site would fire every job twice — two of every
171
+ * email, two menu imports racing each other over one SQLite file.
172
+ *
173
+ * A site that says `scheduler` itself is left alone, in both directions: an app
174
+ * that wants the scheduler somewhere else has said so, and one that has turned
175
+ * it off has said that too.
176
+ */
177
+ export declare function applyScheduledWork(sites: Record<string, any>, schedulerFile: string): Record<string, any>;
142
178
  /**
143
179
  * Make the site model environment-aware. For a non-production environment that
144
180
  * declares a `domainPrefix` (staging → `staging`, development → `dev`), every
@@ -6,7 +6,7 @@ import{existsSync,mkdirSync,readFileSync,readdirSync,statSync,writeFileSync}from
6
6
  echo "$p \${unit:-unknown}"
7
7
  done`,encoding:"utf8",stdio:["pipe","pipe","pipe"]})}catch{return}const clashes=[];for(const line of listing.split(`
8
8
  `)){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={};for(const[key,value]of Object.entries(parsed)){if(/^DOTENV_(PUBLIC|PRIVATE)_KEY/.test(key))continue;values[key]=String(value)}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(`
9
- `)}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 runsMigrations(site){return Array.isArray(site?.preStart)&&site.preStart.some((cmd)=>typeof cmd==="string"&&/\bmigrate\b/.test(cmd))}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}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=tsCloudConfig.hetzner?.apiToken||process.env.HCLOUD_TOKEN||process.env.HETZNER_API_TOKEN,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)}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);process.exit(ExitCode.FatalError)}}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=`
9
+ `)}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 runsMigrations(site){return Array.isArray(site?.preStart)&&site.preStart.some((cmd)=>typeof cmd==="string"&&/\bmigrate\b/.test(cmd))}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 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 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=tsCloudConfig.hetzner?.apiToken||process.env.HCLOUD_TOKEN||process.env.HETZNER_API_TOKEN,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)}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);process.exit(ExitCode.FatalError)}}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=`
10
10
  const units = ${JSON.stringify(units)}
11
11
  const text = bytes => new TextDecoder().decode(bytes).trim()
12
12
  const run = args => text(Bun.spawnSync(args).stdout)
@@ -22,7 +22,7 @@ for (const entry of units) {
22
22
  console.log(JSON.stringify(ports))
23
23
  `.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(`
24
24
  `).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){const box=persistedAttachBox??await resolveAttachTargetBox(attachTo,environment);if(!box?.publicIp){log.error(`Attach target '${attachTo}' has no reachable box for '${environment}'. Is '${attachTo}-${environment}-app' provisioned (by its owner)?`);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,sshUser:"root",deployStoragePath:"/var/ts-cloud/staging"},null,2)}
25
- `)}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","*.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=applyPersistentStatePaths(mergeSiteDeployEnv(sites,resolvedDeployEnv),slug);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)publishedDns=await reconcileHetznerDns(onlySite?{[onlySite]:sites[onlySite]}:sites,ip,log,ipv6);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
25
+ `)}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","*.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=applyScheduledWork(applyPersistentStatePaths(mergeSiteDeployEnv(sites,resolvedDeployEnv),slug),p.projectPath("app/Scheduler.ts"));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)publishedDns=await reconcileHetznerDns(onlySite?{[onlySite]:sites[onlySite]}:sites,ip,log,ipv6);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
26
26
  systemctl is-active ${unit} >/dev/null 2>&1 && echo TLSUNIT:running || echo TLSUNIT:done
27
27
  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(`
28
28
  `))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 mailBox=await resolveAttachTargetBox(mailOwner,environment);if(mailBox?.publicIp){mailIp=mailBox.publicIp;log.info(`Mail: reconciling on '${mailOwner}' box '${mailBox.serverName}' (${mailIp})`)}else{mailIp=void 0;log.warn(`Mail: attach target '${mailOwner}' has no reachable '${environment}' box; 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){if(!Array.isArray(mailboxes))return[];const out=[];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)continue;out.push({address,localPart:localPart.toUpperCase(),password:envPw,generated:!1})}return out}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{readFileSync,existsSync}from"node:fs";import process from"node:process";import{email as emailConfig}from"@stacksjs/config";import{extractEmailPreview}from"@stacksjs/email";import{ExitCode}from"@stacksjs/types";import{onUnknownSubcommand}from"@stacksjs/cli";import{getErrorMessage}from"@stacksjs/utils";const TIMEOUT_MS=30000;async function withTimeout(promise,ms=TIMEOUT_MS){let timeoutId;const timeoutPromise=new Promise((_,reject)=>{timeoutId=setTimeout(()=>reject(Error(`Operation timed out after ${ms}ms`)),ms)});try{return await Promise.race([promise,timeoutPromise])}finally{clearTimeout(timeoutId)}}let _awsCredsLoaded=!1;async function validateS3Bucket(bucket,region){try{const{S3Client}=await import("@stacksjs/ts-cloud"),s3=new S3Client(region),list=s3.listObjects;if(typeof list!=="function")return{ok:!0};await withTimeout(list.call(s3,{bucket,maxKeys:1}),5000);return{ok:!0}}catch(err){const message=err instanceof Error?err.message:String(err);if(/NoSuchBucket/i.test(message))return{ok:!1,reason:`Bucket '${bucket}' does not exist in region '${region}'.`,hint:"Run `buddy deploy` to create email infrastructure, or pass --bucket <name> if you know the correct bucket."};if(/AccessDenied|Forbidden/i.test(message))return{ok:!1,reason:`Access denied to bucket '${bucket}'.`,hint:"Check that AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY (or your IAM role) have s3:ListBucket permission for this bucket."};if(/region/i.test(message)||/PermanentRedirect/i.test(message))return{ok:!1,reason:`Bucket '${bucket}' is not in region '${region}'.`,hint:"Set AWS_REGION to the bucket's actual region, or pass the right bucket for this region."};return{ok:!1,reason:message,hint:"Check AWS connectivity and credentials."}}}async function loadAwsCredentials(){if(_awsCredsLoaded)return;_awsCredsLoaded=!0;const envPath=".env.production";if(!existsSync(envPath))return;const{parse}=await import("@stacksjs/env"),content=readFileSync(envPath,"utf-8"),{parsed}=parse(content);for(const[key,value]of Object.entries(parsed))if(process.env[key]===void 0)process.env[key]=value}const descriptions={email:"Email server management commands",verify:"Check domain verification status",test:"Send a test email",list:"List configured mailboxes",logs:"View email processing logs",status:"Show email server status",inbox:"View inbox emails from S3",reprocess:"Reprocess raw emails from S3 into mailbox structure"};export function email(buddy){buddy.command("email",descriptions.email).alias("mail").action(async()=>{console.log(`
1
+ import{readFileSync,existsSync}from"node:fs";import process from"node:process";import{email as emailConfig}from"@stacksjs/config";import{extractEmailPreview}from"@stacksjs/email";import{ExitCode}from"@stacksjs/types";import{onUnknownSubcommand}from"@stacksjs/cli";import{getErrorMessage}from"@stacksjs/utils";import{reprocessInboundEmails}from"../email-reprocess";const TIMEOUT_MS=30000;async function withTimeout(promise,ms=TIMEOUT_MS){let timeoutId;const timeoutPromise=new Promise((_,reject)=>{timeoutId=setTimeout(()=>reject(Error(`Operation timed out after ${ms}ms`)),ms)});try{return await Promise.race([promise,timeoutPromise])}finally{clearTimeout(timeoutId)}}let _awsCredsLoaded=!1;async function validateS3Bucket(bucket,region){try{const{S3Client}=await import("@stacksjs/ts-cloud"),s3=new S3Client(region),list=s3.listObjects;if(typeof list!=="function")return{ok:!0};await withTimeout(list.call(s3,{bucket,maxKeys:1}),5000);return{ok:!0}}catch(err){const message=err instanceof Error?err.message:String(err);if(/NoSuchBucket/i.test(message))return{ok:!1,reason:`Bucket '${bucket}' does not exist in region '${region}'.`,hint:"Run `buddy deploy` to create email infrastructure, or pass --bucket <name> if you know the correct bucket."};if(/AccessDenied|Forbidden/i.test(message))return{ok:!1,reason:`Access denied to bucket '${bucket}'.`,hint:"Check that AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY (or your IAM role) have s3:ListBucket permission for this bucket."};if(/region/i.test(message)||/PermanentRedirect/i.test(message))return{ok:!1,reason:`Bucket '${bucket}' is not in region '${region}'.`,hint:"Set AWS_REGION to the bucket's actual region, or pass the right bucket for this region."};return{ok:!1,reason:message,hint:"Check AWS connectivity and credentials."}}}async function loadAwsCredentials(){if(_awsCredsLoaded)return;_awsCredsLoaded=!0;const envPath=".env.production";if(!existsSync(envPath))return;const{parse}=await import("@stacksjs/env"),content=readFileSync(envPath,"utf-8"),{parsed}=parse(content);for(const[key,value]of Object.entries(parsed))if(process.env[key]===void 0)process.env[key]=value}const descriptions={email:"Email server management commands",verify:"Check domain verification status",test:"Send a test email",list:"List configured mailboxes",logs:"View email processing logs",status:"Show email server status",inbox:"View inbox emails from S3",reprocess:"Reprocess raw emails from S3 into mailbox structure"};export function email(buddy){buddy.command("email",descriptions.email).alias("mail").action(async()=>{console.log(`
2
2
  \uD83D\uDCE7 Email Server Commands
3
3
  `);console.log(" buddy email:verify - Check domain verification status");console.log(" buddy email:test - Send a test email");console.log(" buddy email:list - List configured mailboxes");console.log(" buddy email:inbox - View inbox emails from S3");console.log(" buddy email:reprocess - Reprocess raw emails into mailbox structure");console.log(" buddy email:logs - View email processing logs");console.log(" buddy email:status - Show email server status");console.log("")});buddy.command("email:verify",descriptions.verify).action(async()=>{console.log(`
4
4
  \uD83D\uDCE7 Checking Email Domain Verification...
@@ -41,6 +41,6 @@ Configuration:`);for(const output of emailOutputs)console.log(` ${output.Output
41
41
  `);console.log(" %-4s %-20s %-30s %s","#","Date","From","Subject");console.log(" "+"-".repeat(90));for(let i=0;i<Math.min(inboxEmails.length,limit);i++){const e=inboxEmails[i],date=e.date?new Date(e.date).toLocaleDateString("en-US",{month:"short",day:"numeric",hour:"2-digit",minute:"2-digit"}):"?",from=(e.from||"").substring(0,20).padEnd(20),subject=(e.subject||"").substring(0,40),readMark=e.read===!1?"*":" ";console.log(` ${readMark}${String(i+1).padStart(3)} ${date.padEnd(20)} ${from} ${subject}`)}console.log("");console.log(" * = unread");console.log(`
42
42
  View raw email: buddy email:inbox ${resolvedMailbox} --raw <messageId>`)}catch(error){console.error("Error reading inbox:",getErrorMessage(error))}process.exit(0)});buddy.command("email:reprocess",descriptions.reprocess).option("--bucket <name>","S3 bucket name override").option("--prefix <prefix>","S3 prefix to scan",{default:"inbox/"}).option("--domain <domain>","Email domain",{default:"stacksjs.com"}).action(async(options)=>{await loadAwsCredentials();const region=process.env.AWS_REGION||"us-east-1",appName=(process.env.APP_NAME||"stacks").toLowerCase().replace(/[^a-z0-9-]/g,"-"),bucketName=options?.bucket||`${appName}-production-email`,prefix=options?.prefix||"inbox/",domain=options?.domain||"stacksjs.com";console.log(`
43
43
  \uD83D\uDCE7 Reprocessing emails from s3://${bucketName}/${prefix}
44
- `);try{const{S3Client}=await import("@stacksjs/ts-cloud"),s3=new S3Client(region),objects=(await withTimeout(s3.listObjects({bucket:bucketName,prefix,maxKeys:1000}),60000)).Contents||[];console.log(` Found ${objects.length} raw emails to process
45
- `);const inboxes={};let processed=0;for(const obj of objects){if(!obj.Key||obj.Key.endsWith("/"))continue;try{const rawEmail=await withTimeout(s3.getObject(bucketName,obj.Key),15000);if(!rawEmail)continue;const headers=parseRawEmailHeaders(rawEmail),messageId=obj.Key.split("/").pop()||obj.Key,from=extractEmailAddress(headers.from||""),fromName=extractEmailName(headers.from||""),toList=(headers.to||"").split(",").map((s)=>s.trim()).filter(Boolean),subject=headers.subject||"No Subject",date=headers.date||(obj.LastModified?new Date(obj.LastModified).toISOString():new Date().toISOString()),preview=extractEmailPreview(rawEmail),recipients=toList.length>0?toList:[`unknown@${domain}`];for(const recipient of recipients){const recipientEmail=extractEmailAddress(recipient);if(!recipientEmail.endsWith(`@${domain}`))continue;const[localPart]=recipientEmail.split("@"),d=new Date(date),year=d.getFullYear(),month=String(d.getMonth()+1).padStart(2,"0"),day=String(d.getDate()).padStart(2,"0"),emailPath=`mailboxes/${domain}/${localPart}/${year}/${month}/${day}/${messageId}`;await s3.putObject({bucket:bucketName,key:`${emailPath}/raw.eml`,body:rawEmail,contentType:"message/rfc822"});const metadata={messageId,from,fromName,to:recipientEmail,subject,date,preview,hasAttachments:!1};await s3.putObject({bucket:bucketName,key:`${emailPath}/metadata.json`,body:JSON.stringify(metadata,null,2),contentType:"application/json"});const inboxKey=`${domain}/${localPart}`;if(!inboxes[inboxKey])inboxes[inboxKey]=[];inboxes[inboxKey].push({messageId,from,fromName,to:recipientEmail,subject,date,read:!1,preview,hasAttachments:!1,path:emailPath})}processed++;if(processed%5===0)console.log(` Processed ${processed}/${objects.length} emails...`)}catch(emailErr){console.log(` Skipping ${obj.Key}: ${emailErr.message}`)}}for(const[key,emails]of Object.entries(inboxes)){const[d,localPart]=key.split("/"),inboxJsonKey=`mailboxes/${d}/${localPart}/inbox.json`;emails.sort((a,b)=>new Date(b.date).getTime()-new Date(a.date).getTime());let existing=[];try{const existingData=await s3.getObject(bucketName,inboxJsonKey);if(existingData)try{existing=JSON.parse(existingData)}catch{existing=[]}}catch{}const existingIds=new Set(existing.map((e)=>e.messageId)),newEmails=emails.filter((e)=>!existingIds.has(e.messageId)),merged=[...newEmails,...existing].slice(0,1000);await s3.putObject({bucket:bucketName,key:inboxJsonKey,body:JSON.stringify(merged,null,2),contentType:"application/json"});console.log(` Updated inbox for ${localPart}@${d}: ${newEmails.length} new emails (${merged.length} total)`)}console.log(`
46
- Done! Processed ${processed} emails.`)}catch(error){console.error("Error reprocessing:",getErrorMessage(error))}process.exit(0)});onUnknownSubcommand(buddy,"email")}function parseRawEmailHeaders(rawEmail){const headers={},lines=rawEmail.split(/\r?\n/);let currentKey="",currentValue="";for(const line of lines){if(line==="")break;if(/^\s/.test(line)&&currentKey){currentValue+=" "+line.trim();headers[currentKey]=currentValue}else{const match=line.match(/^([^:]+):\s*(.*)$/);if(match){currentKey=match[1].toLowerCase();currentValue=match[2]??"";headers[currentKey]=currentValue}}}return headers}function extractEmailAddress(str){if(!str)return"";return(str.match(/<([^>]+)>/)?.[1]??str).toLowerCase().trim()}function extractEmailName(str){if(!str)return"";return str.match(/^"?([^"<]+)"?\s*</)?.[1]?.trim()??""}
44
+ `);try{const{S3Client}=await import("@stacksjs/ts-cloud"),s3=new S3Client(region),report=await reprocessInboundEmails({storage:s3,bucket:bucketName,prefix,domain,onProgress(processed,discovered){if(processed%5===0)console.log(` Processed ${processed}/${discovered} emails...`)}});console.log(` Found ${report.discovered} raw emails to process
45
+ `);for(const skipped of report.skipped)console.log(` Skipping ${skipped.key}: ${skipped.error}`);for(const mailbox of report.mailboxes)console.log(` Updated inbox for ${mailbox.mailbox}: ${mailbox.newCount} new, ${mailbox.refreshedCount} refreshed (${mailbox.total} total)`);console.log(`
46
+ Done! Processed ${report.processed} emails.`);process.exit(ExitCode.Success)}catch(error){console.error("Error reprocessing:",getErrorMessage(error));process.exit(ExitCode.FatalError)}});onUnknownSubcommand(buddy,"email")}function parseRawEmailHeaders(rawEmail){const headers={},lines=rawEmail.split(/\r?\n/);let currentKey="",currentValue="";for(const line of lines){if(line==="")break;if(/^\s/.test(line)&&currentKey){currentValue+=" "+line.trim();headers[currentKey]=currentValue}else{const match=line.match(/^([^:]+):\s*(.*)$/);if(match){currentKey=match[1].toLowerCase();currentValue=match[2]??"";headers[currentKey]=currentValue}}}return headers}function extractEmailAddress(str){if(!str)return"";return(str.match(/<([^>]+)>/)?.[1]??str).toLowerCase().trim()}function extractEmailName(str){if(!str)return"";return str.match(/^"?([^"<]+)"?\s*</)?.[1]?.trim()??""}
@@ -0,0 +1,35 @@
1
+ export declare function reprocessInboundEmails(options: EmailReprocessOptions): Promise<EmailReprocessReport>;
2
+ export declare interface EmailReprocessObject {
3
+ Key: string
4
+ LastModified?: string
5
+ }
6
+ export declare interface EmailReprocessStorage {
7
+ listAllObjects: (options: { bucket: string, prefix?: string }) => Promise<EmailReprocessObject[]>
8
+ getObject: (bucket: string, key: string) => Promise<string>
9
+ getObjectBytes: (bucket: string, key: string) => Promise<{ body: Uint8Array }>
10
+ putObject: (options: {
11
+ bucket: string
12
+ key: string
13
+ body: string | Buffer | Uint8Array
14
+ contentType?: string
15
+ }) => Promise<void>
16
+ }
17
+ export declare interface EmailReprocessMailboxReport {
18
+ mailbox: string
19
+ newCount: number
20
+ refreshedCount: number
21
+ total: number
22
+ }
23
+ export declare interface EmailReprocessReport {
24
+ discovered: number
25
+ processed: number
26
+ skipped: Array<{ key: string, error: string }>
27
+ mailboxes: EmailReprocessMailboxReport[]
28
+ }
29
+ export declare interface EmailReprocessOptions {
30
+ storage: EmailReprocessStorage
31
+ bucket: string
32
+ prefix: string
33
+ domain: string
34
+ onProgress?: (processed: number, discovered: number) => void
35
+ }
@@ -0,0 +1 @@
1
+ import{extractEmailPreview,inboundMailboxRecipient,inboundMessageStorageId,normalizeEmailPreview,parseInboundEmail}from"@stacksjs/email";function errorMessage(error){return error instanceof Error?error.message:String(error)}async function withOperationTimeout(promise,milliseconds){let timeout;const expired=new Promise((_resolve,reject)=>{timeout=setTimeout(()=>reject(Error(`Email reprocess operation timed out after ${milliseconds}ms.`)),milliseconds)});try{return await Promise.race([promise,expired])}finally{if(timeout)clearTimeout(timeout)}}export async function reprocessInboundEmails(options){const domain=options.domain.trim().toLowerCase();if(!inboundMailboxRecipient(`validation@${domain}`,domain))throw TypeError(`Invalid email domain: ${options.domain}`);const objects=await withOperationTimeout(options.storage.listAllObjects({bucket:options.bucket,prefix:options.prefix}),60000),inboxes=new Map,skipped=[];let processed=0;for(const object of objects){if(!object.Key||object.Key.endsWith("/"))continue;try{const rawEmail=(await withOperationTimeout(options.storage.getObjectBytes(options.bucket,object.Key),15000)).body;if(!rawEmail.byteLength)continue;const messageId=object.Key.split("/").pop()||object.Key,storageMessageId=inboundMessageStorageId(messageId),parsedEmail=await parseInboundEmail(rawEmail),parsedDate=parsedEmail.date?new Date(parsedEmail.date):null,date=parsedDate&&!Number.isNaN(parsedDate.getTime())?parsedDate.toISOString():object.LastModified||new Date().toISOString(),fallbackRaw=new TextDecoder().decode(rawEmail),preview=normalizeEmailPreview(parsedEmail.text||parsedEmail.html||extractEmailPreview(fallbackRaw)),hasAttachments=parsedEmail.attachments.length>0,recipients=parsedEmail.recipients.length>0?parsedEmail.recipients:[`unknown@${domain}`];for(const recipient of recipients){const mailbox=inboundMailboxRecipient(recipient,domain);if(!mailbox)continue;const instant=new Date(date),year=instant.getFullYear(),month=String(instant.getMonth()+1).padStart(2,"0"),day=String(instant.getDate()).padStart(2,"0"),emailPath=`mailboxes/${mailbox.domain}/${mailbox.localPart}/${year}/${month}/${day}/${storageMessageId}`,attachmentMetadata=parsedEmail.attachments.map((attachment)=>({name:attachment.name,contentType:attachment.contentType,size:attachment.content.byteLength,...attachment.contentId?{contentId:attachment.contentId}:{},...attachment.disposition?{disposition:attachment.disposition}:{}})),metadata={messageId,from:parsedEmail.from,fromName:parsedEmail.fromName,to:mailbox.address,subject:parsedEmail.subject,date,preview,hasAttachments,attachments:attachmentMetadata},writes=[options.storage.putObject({bucket:options.bucket,key:`${emailPath}/raw.eml`,body:rawEmail,contentType:"message/rfc822"}),options.storage.putObject({bucket:options.bucket,key:`${emailPath}/metadata.json`,body:JSON.stringify(metadata,null,2),contentType:"application/json"}),...parsedEmail.attachments.map((attachment)=>options.storage.putObject({bucket:options.bucket,key:`${emailPath}/attachments/${attachment.storageName}`,body:attachment.content,contentType:attachment.contentType}))];if(parsedEmail.text)writes.push(options.storage.putObject({bucket:options.bucket,key:`${emailPath}/body.txt`,body:parsedEmail.text,contentType:"text/plain; charset=utf-8"}));if(parsedEmail.html)writes.push(options.storage.putObject({bucket:options.bucket,key:`${emailPath}/body.html`,body:parsedEmail.html,contentType:"text/html; charset=utf-8"}));await withOperationTimeout(Promise.all(writes),30000);const inboxKey=`${mailbox.domain}/${mailbox.localPart}`,inbox=inboxes.get(inboxKey)||[];inbox.push({messageId,from:parsedEmail.from,fromName:parsedEmail.fromName,to:mailbox.address,subject:parsedEmail.subject,date,read:!1,preview,hasAttachments,path:emailPath});inboxes.set(inboxKey,inbox)}processed++;options.onProgress?.(processed,objects.length)}catch(error){skipped.push({key:object.Key,error:errorMessage(error)})}}const mailboxes=[];for(const[key,emails]of inboxes){const[mailboxDomain,localPart]=key.split("/"),inboxJsonKey=`mailboxes/${mailboxDomain}/${localPart}/inbox.json`;let existing=[];try{const existingData=await options.storage.getObject(options.bucket,inboxJsonKey);existing=existingData?JSON.parse(existingData):[]}catch{existing=[]}const existingIds=new Set(existing.map((email)=>email.messageId)),refreshedIds=new Set(emails.map((email)=>email.messageId)),existingById=new Map(existing.map((email)=>[email.messageId,email])),merged=[...emails.map((email)=>{const current=existingById.get(email.messageId);return current?{...email,read:current.read===!0}:email}),...existing.filter((email)=>!refreshedIds.has(email.messageId))].sort((left,right)=>new Date(right.date).getTime()-new Date(left.date).getTime()).slice(0,1000),newCount=emails.filter((email)=>!existingIds.has(email.messageId)).length,refreshedCount=emails.length-newCount;await withOperationTimeout(options.storage.putObject({bucket:options.bucket,key:inboxJsonKey,body:JSON.stringify(merged,null,2),contentType:"application/json"}),30000);mailboxes.push({mailbox:`${localPart}@${mailboxDomain}`,newCount,refreshedCount,total:merged.length})}return{discovered:objects.length,processed,skipped,mailboxes}}
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@stacksjs/buddy",
3
3
  "type": "module",
4
4
  "sideEffects": false,
5
- "version": "0.70.353",
5
+ "version": "0.70.355",
6
6
  "description": "Meet Buddy. The Stacks runtime.",
7
7
  "author": "Chris Breuer",
8
8
  "contributors": [
@@ -95,53 +95,53 @@
95
95
  "prepublishOnly": "bun run build"
96
96
  },
97
97
  "dependencies": {
98
- "@stacksjs/actions": "^0.70.353",
99
- "@stacksjs/ai": "^0.70.353",
100
- "@stacksjs/alias": "^0.70.353",
101
- "@stacksjs/arrays": "^0.70.353",
102
- "@stacksjs/auth": "^0.70.353",
103
- "@stacksjs/build": "^0.70.353",
104
- "@stacksjs/cache": "^0.70.353",
105
- "@stacksjs/cli": "^0.70.353",
98
+ "@stacksjs/actions": "^0.70.355",
99
+ "@stacksjs/ai": "^0.70.355",
100
+ "@stacksjs/alias": "^0.70.355",
101
+ "@stacksjs/arrays": "^0.70.355",
102
+ "@stacksjs/auth": "^0.70.355",
103
+ "@stacksjs/build": "^0.70.355",
104
+ "@stacksjs/cache": "^0.70.355",
105
+ "@stacksjs/cli": "^0.70.355",
106
106
  "@stacksjs/clapp": "^0.2.12",
107
- "@stacksjs/cloud": "^0.70.353",
108
- "@stacksjs/collections": "^0.70.353",
109
- "@stacksjs/config": "^0.70.353",
110
- "@stacksjs/database": "^0.70.353",
111
- "@stacksjs/desktop-build": "^0.70.353",
112
- "@stacksjs/dns": "^0.70.353",
113
- "@stacksjs/email": "^0.70.353",
114
- "@stacksjs/enums": "^0.70.353",
115
- "@stacksjs/error-handling": "^0.70.353",
116
- "@stacksjs/events": "^0.70.353",
117
- "@stacksjs/git": "^0.70.353",
107
+ "@stacksjs/cloud": "^0.70.355",
108
+ "@stacksjs/collections": "^0.70.355",
109
+ "@stacksjs/config": "^0.70.355",
110
+ "@stacksjs/database": "^0.70.355",
111
+ "@stacksjs/desktop-build": "^0.70.355",
112
+ "@stacksjs/dns": "^0.70.355",
113
+ "@stacksjs/email": "^0.70.355",
114
+ "@stacksjs/enums": "^0.70.355",
115
+ "@stacksjs/error-handling": "^0.70.355",
116
+ "@stacksjs/events": "^0.70.355",
117
+ "@stacksjs/git": "^0.70.355",
118
118
  "@stacksjs/gitit": "^0.2.5",
119
- "@stacksjs/health": "^0.70.353",
119
+ "@stacksjs/health": "^0.70.355",
120
120
  "@stacksjs/dnsx": "^0.2.3",
121
121
  "@stacksjs/httx": "^0.1.10",
122
- "@stacksjs/image": "^0.70.353",
123
- "@stacksjs/lint": "^0.70.353",
124
- "@stacksjs/logging": "^0.70.353",
125
- "@stacksjs/notifications": "^0.70.353",
126
- "@stacksjs/objects": "^0.70.353",
127
- "@stacksjs/orm": "^0.70.353",
128
- "@stacksjs/path": "^0.70.353",
129
- "@stacksjs/skills": "^0.70.353",
130
- "@stacksjs/payments": "^0.70.353",
131
- "@stacksjs/realtime": "^0.70.353",
132
- "@stacksjs/router": "^0.70.353",
122
+ "@stacksjs/image": "^0.70.355",
123
+ "@stacksjs/lint": "^0.70.355",
124
+ "@stacksjs/logging": "^0.70.355",
125
+ "@stacksjs/notifications": "^0.70.355",
126
+ "@stacksjs/objects": "^0.70.355",
127
+ "@stacksjs/orm": "^0.70.355",
128
+ "@stacksjs/path": "^0.70.355",
129
+ "@stacksjs/skills": "^0.70.355",
130
+ "@stacksjs/payments": "^0.70.355",
131
+ "@stacksjs/realtime": "^0.70.355",
132
+ "@stacksjs/router": "^0.70.355",
133
133
  "@stacksjs/rpx": "^0.11.42",
134
- "@stacksjs/search-engine": "^0.70.353",
135
- "@stacksjs/security": "^0.70.353",
136
- "@stacksjs/server": "^0.70.353",
137
- "@stacksjs/storage": "^0.70.353",
138
- "@stacksjs/strings": "^0.70.353",
139
- "@stacksjs/testing": "^0.70.353",
140
- "@stacksjs/tunnel": "^0.70.353",
141
- "@stacksjs/types": "^0.70.353",
142
- "@stacksjs/ui": "^0.70.353",
143
- "@stacksjs/utils": "^0.70.353",
144
- "@stacksjs/validation": "^0.70.353",
134
+ "@stacksjs/search-engine": "^0.70.355",
135
+ "@stacksjs/security": "^0.70.355",
136
+ "@stacksjs/server": "^0.70.355",
137
+ "@stacksjs/storage": "^0.70.355",
138
+ "@stacksjs/strings": "^0.70.355",
139
+ "@stacksjs/testing": "^0.70.355",
140
+ "@stacksjs/tunnel": "^0.70.355",
141
+ "@stacksjs/types": "^0.70.355",
142
+ "@stacksjs/ui": "^0.70.355",
143
+ "@stacksjs/utils": "^0.70.355",
144
+ "@stacksjs/validation": "^0.70.355",
145
145
  "@stacksjs/ts-cloud": "^0.7.103",
146
146
  "ajv": "^8.20.0",
147
147
  "ajv-formats": "^3.0.1",