@stacksjs/buddy 0.72.37 → 0.72.38

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,27 +243,6 @@ 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
246
  export declare function apiDeploymentProblem(sites: Record<string, any>, hasApiRoutes: boolean): string | undefined;
268
247
  /**
269
248
  * The database each site will migrate against, once its env is resolved.
@@ -10,7 +10,7 @@ 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 apiStartPattern=/\bserve:api\b|(?:^|[\s/])serve\/api\.[cm]?[jt]s\b/;function servesApi(name,site){return name==="api"||typeof site?.start==="string"&&apiStartPattern.test(site.start)}function describeSiteClassification(sites){const described=Object.entries(sites).filter(([,site])=>typeof site?.start==="string").map(([name,site])=>{if(servesApi(name,site))return`\`${name}\` (api)`;const env=site?.env??{},wiring=env.API_URL?"API_URL set":env.PORT_API?"PORT_API set":"no API_URL or PORT_API";return`\`${name}\` (page, ${wiring})`});return described.length>0?`Sites examined: ${described.join(", ")}.`:"No server-app sites were examined."}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.
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 apiStartPattern=/\bserve:api\b|(?:^|[\s/])serve\/api\.[cm]?[jt]s\b/;function servesApi(name,site){return name==="api"||typeof site?.start==="string"&&apiStartPattern.test(site.start)}function describeSiteClassification(sites){const described=Object.entries(sites).filter(([,site])=>typeof site?.start==="string").map(([name,site])=>{if(servesApi(name,site))return`\`${name}\` (api)`;const env=site?.env??{},wiring=env.API_URL?"API_URL set":env.PORT_API?"PORT_API set":"no API_URL or PORT_API";return`\`${name}\` (page, ${wiring})`});return described.length>0?`Sites examined: ${described.join(", ")}.`:"No server-app sites were examined."}function isDashboardSite(name){return name==="dashboard"||name.startsWith("dashboard-")}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)&&!isDashboardSite(name)),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.
14
14
  ${describeSiteClassification(sites)}
15
15
  Add 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.
16
16
  Set \`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.
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.37",
5
+ "version": "0.72.38",
6
6
  "description": "Meet Buddy. The Stacks runtime.",
7
7
  "author": "Chris Breuer",
8
8
  "contributors": [
@@ -95,57 +95,57 @@
95
95
  "prepublishOnly": "bun run build"
96
96
  },
97
97
  "dependencies": {
98
- "@stacksjs/actions": "^0.72.37",
99
- "@stacksjs/ai": "^0.72.37",
100
- "@stacksjs/alias": "^0.72.37",
101
- "@stacksjs/arrays": "^0.72.37",
102
- "@stacksjs/auth": "^0.72.37",
103
- "@stacksjs/build": "^0.72.37",
104
- "@stacksjs/cache": "^0.72.37",
105
- "@stacksjs/chat": "^0.72.37",
98
+ "@stacksjs/actions": "^0.72.38",
99
+ "@stacksjs/ai": "^0.72.38",
100
+ "@stacksjs/alias": "^0.72.38",
101
+ "@stacksjs/arrays": "^0.72.38",
102
+ "@stacksjs/auth": "^0.72.38",
103
+ "@stacksjs/build": "^0.72.38",
104
+ "@stacksjs/cache": "^0.72.38",
105
+ "@stacksjs/chat": "^0.72.38",
106
106
  "@stacksjs/clapp": "^0.2.12",
107
- "@stacksjs/cli": "^0.72.37",
108
- "@stacksjs/cloud": "^0.72.37",
109
- "@stacksjs/cms": "^0.72.37",
110
- "@stacksjs/collections": "^0.72.37",
111
- "@stacksjs/config": "^0.72.37",
112
- "@stacksjs/database": "^0.72.37",
113
- "@stacksjs/desktop-build": "^0.72.37",
114
- "@stacksjs/dns": "^0.72.37",
107
+ "@stacksjs/cli": "^0.72.38",
108
+ "@stacksjs/cloud": "^0.72.38",
109
+ "@stacksjs/cms": "^0.72.38",
110
+ "@stacksjs/collections": "^0.72.38",
111
+ "@stacksjs/config": "^0.72.38",
112
+ "@stacksjs/database": "^0.72.38",
113
+ "@stacksjs/desktop-build": "^0.72.38",
114
+ "@stacksjs/dns": "^0.72.38",
115
115
  "@stacksjs/dnsx": "^0.2.3",
116
- "@stacksjs/email": "^0.72.37",
117
- "@stacksjs/enums": "^0.72.37",
118
- "@stacksjs/error-handling": "^0.72.37",
119
- "@stacksjs/events": "^0.72.37",
120
- "@stacksjs/git": "^0.72.37",
116
+ "@stacksjs/email": "^0.72.38",
117
+ "@stacksjs/enums": "^0.72.38",
118
+ "@stacksjs/error-handling": "^0.72.38",
119
+ "@stacksjs/events": "^0.72.38",
120
+ "@stacksjs/git": "^0.72.38",
121
121
  "@stacksjs/gitit": "^0.2.5",
122
- "@stacksjs/health": "^0.72.37",
122
+ "@stacksjs/health": "^0.72.38",
123
123
  "@stacksjs/httx": "^0.1.10",
124
- "@stacksjs/image": "^0.72.37",
125
- "@stacksjs/lint": "^0.72.37",
126
- "@stacksjs/logging": "^0.72.37",
127
- "@stacksjs/notifications": "^0.72.37",
128
- "@stacksjs/objects": "^0.72.37",
129
- "@stacksjs/orm": "^0.72.37",
130
- "@stacksjs/path": "^0.72.37",
131
- "@stacksjs/payments": "^0.72.37",
132
- "@stacksjs/realtime": "^0.72.37",
133
- "@stacksjs/router": "^0.72.37",
124
+ "@stacksjs/image": "^0.72.38",
125
+ "@stacksjs/lint": "^0.72.38",
126
+ "@stacksjs/logging": "^0.72.38",
127
+ "@stacksjs/notifications": "^0.72.38",
128
+ "@stacksjs/objects": "^0.72.38",
129
+ "@stacksjs/orm": "^0.72.38",
130
+ "@stacksjs/path": "^0.72.38",
131
+ "@stacksjs/payments": "^0.72.38",
132
+ "@stacksjs/realtime": "^0.72.38",
133
+ "@stacksjs/router": "^0.72.38",
134
134
  "@stacksjs/rpx": "^0.11.42",
135
- "@stacksjs/search-engine": "^0.72.37",
136
- "@stacksjs/security": "^0.72.37",
137
- "@stacksjs/server": "^0.72.37",
138
- "@stacksjs/sites": "^0.72.37",
139
- "@stacksjs/skills": "^0.72.37",
140
- "@stacksjs/storage": "^0.72.37",
141
- "@stacksjs/strings": "^0.72.37",
142
- "@stacksjs/testing": "^0.72.37",
135
+ "@stacksjs/search-engine": "^0.72.38",
136
+ "@stacksjs/security": "^0.72.38",
137
+ "@stacksjs/server": "^0.72.38",
138
+ "@stacksjs/sites": "^0.72.38",
139
+ "@stacksjs/skills": "^0.72.38",
140
+ "@stacksjs/storage": "^0.72.38",
141
+ "@stacksjs/strings": "^0.72.38",
142
+ "@stacksjs/testing": "^0.72.38",
143
143
  "@stacksjs/ts-cloud": "^0.9.4",
144
- "@stacksjs/tunnel": "^0.72.37",
145
- "@stacksjs/types": "^0.72.37",
146
- "@stacksjs/ui": "^0.72.37",
147
- "@stacksjs/utils": "^0.72.37",
148
- "@stacksjs/validation": "^0.72.37",
144
+ "@stacksjs/tunnel": "^0.72.38",
145
+ "@stacksjs/types": "^0.72.38",
146
+ "@stacksjs/ui": "^0.72.38",
147
+ "@stacksjs/utils": "^0.72.38",
148
+ "@stacksjs/validation": "^0.72.38",
149
149
  "ajv": "^8.20.0",
150
150
  "ajv-formats": "^3.0.1",
151
151
  "ts-pantry": "^0.11.35"