@stacksjs/buddy 0.70.380 → 0.71.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/deploy.js +3 -3
- package/dist/commands/extension.js +4 -1
- package/dist/commands/features.d.ts +5 -3
- package/dist/commands/features.js +12 -1
- package/dist/commands/migrate.js +7 -7
- package/dist/production-server.js +1 -1
- package/dist/workflow-prune.js +1 -1
- package/package.json +48 -46
package/dist/commands/deploy.js
CHANGED
|
@@ -21,8 +21,8 @@ for (const entry of units) {
|
|
|
21
21
|
}
|
|
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
|
-
`).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",...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)publishedDns=await reconcileHetznerDns(onlySite?{[onlySite]:sites[onlySite]}:sites,ip,log,ipv6)
|
|
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,...ipv6?{publicIpv6:ipv6}:{},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",...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
|
|
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){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
|
|
@@ -346,5 +346,5 @@ EOF
|
|
|
346
346
|
systemctl daemon-reload
|
|
347
347
|
systemctl enable --now mail-health.timer >/dev/null 2>&1
|
|
348
348
|
# 6) Restart only when the startup-read env actually changed (domain or DKIM key).
|
|
349
|
-
if [ "$ENV_CHANGED" = 1 ]; then systemctl restart mail 2>/dev/null || true; echo "MAILTENANT:env-changed+restarted,forwards=$FWD_STATE"; else echo "MAILTENANT:current,forwards=$FWD_STATE"; fi`;try{const out=execSync(`ssh ${sshArgs.map((a)=>`'${a}'`).join(" ")} bash -s`,{input:script,encoding:"utf8",stdio:["pipe","pipe","pipe"]}),line=(out.match(/MAILTENANT:[^\n]*/)||[])[0]||"MAILTENANT:done",mailHostFromOut=(out.match(/MAILHOST:([^\n]*)/)||[])[1]?.trim(),mailHost=mailHostFromOut||`mail.${domain}`,dkimPubB64=(out.match(/DKIMPUB:([^\n]*)/)||[])[1]?.trim()||void 0,dkimSelector=(out.match(/DKIMSEL:([^\n]*)/)||[])[1]?.trim()||void 0,dkimGlobalKey=(out.match(/DKIMGLOBAL:([^\n]*)/)||[])[1]?.trim();if(dkimGlobalKey)logger.warn(`Mail: ${domain} is the mail server's global DKIM_DOMAIN, so it signs with ${dkimGlobalKey} and the per-domain key at /opt/mail/dkim/${domain}.private is unused. Rotate the global key, not that one.`);const madeAddrs=new Set([...out.matchAll(/MADE:([^\n]+)/g)].flatMap((m)=>m[1]?[m[1].trim()]:[])),migratedAddrs=new Set([...out.matchAll(/MIGRATED:([^\n]+)/g)].flatMap((m)=>m[1]?[m[1].trim()]:[])),created=boxes.filter((b)=>madeAddrs.has(b.address)).map((b)=>({address:b.address,password:b.password}));logger.success(`Mail routing reconciled (${line.replace("MAILTENANT:","")})`);const failed=[...out.matchAll(/FAIL:([^\n]+)/g)].flatMap((m)=>m[1]?[m[1].trim()]:[]);if(failed.length)logger.warn(`Mail: the server refused ${failed.length} mailbox(es): ${failed.join(", ")}`);const forwardError=(out.match(/FWDERR:([^\n]*)/)||[])[1]?.trim();if(forwardError)logger.warn(`Mail: the forward rules were not merged: ${forwardError}`);const certHost=(out.match(/CERTHOST:([^\n]*)/)||[])[1]?.trim();if(certHost)logger.success(`Mail: ${certHost} added to the mail certificate \u2014 clients can use it as the server name`);const certError=(out.match(/CERTFAIL:([^\n]*)/)||[])[1]?.trim();if(certError)logger.warn(`Mail: could not issue a certificate for mail.${domain} (clients should use ${mailHostFromOut||"the shared mail host"}): ${certError}`);const seen=new Set([...madeAddrs,...migratedAddrs,...[...out.matchAll(/EXISTS:([^\n]+)/g)].flatMap((m)=>m[1]?[m[1].trim()]:[])]),unaccounted=boxes.filter((b)=>!seen.has(b.address)).map((b)=>b.address);if(unaccounted.length)logger.warn(`Mail: ${unaccounted.length} declared mailbox(es) were not reconciled: ${unaccounted.join(", ")}`);if(created.length){logger.info(`Mail: created ${created.length} mailbox(es) \u2014 credentials below (save them; shown once):`);for(const b of created)logger.info(` ${b.address} ${b.password}`)}if(migratedAddrs.size)logger.success(`Mail: migrated ${migratedAddrs.size} legacy mailbox username(s) to isolated full addresses`);return domain?{domain,mailHost,dkimPubB64,dkimSelector,created}:null}catch(err){logger.warn(`Mail routing reconcile skipped: ${getErrorMessage(err)}`);return null}}export function dnsProviderConfigsFromEnv(){const configs=[];if(process.env.PORKBUN_API_KEY&&process.env.PORKBUN_SECRET_KEY)configs.push({provider:"porkbun",apiKey:process.env.PORKBUN_API_KEY,secretKey:process.env.PORKBUN_SECRET_KEY});if(process.env.CLOUDFLARE_API_TOKEN)configs.push({provider:"cloudflare",apiToken:process.env.CLOUDFLARE_API_TOKEN});if(process.env.GODADDY_API_KEY&&process.env.GODADDY_API_SECRET)configs.push({provider:"godaddy",apiKey:process.env.GODADDY_API_KEY,apiSecret:process.env.GODADDY_API_SECRET,environment:process.env.GODADDY_ENVIRONMENT});if(process.env.AWS_ACCESS_KEY_ID||process.env.AWS_PROFILE)configs.push({provider:"route53"});return configs}async function resolveZoneDnsProvider(domain,providerConfigs,logger){if(providerConfigs.length===0)return;const{createDnsProvider,detectDnsProvider}=await import("@stacksjs/ts-cloud"),provider=await detectDnsProvider(domain,providerConfigs).catch((err)=>{logger.warn(` DNS: ignoring a configured provider for ${domain} \u2014 its credentials were rejected (${err?.message||err})`);return});if(provider)return provider;let nameservers=[];try{const{resolveNs}=await import("node:dns/promises");nameservers=await resolveNs(domain)}catch{}const providerName=dnsProviderNameFromNameservers(nameservers),providerConfig=providerConfigs.find((config)=>config.provider===providerName);return providerConfig?createDnsProvider(providerConfig):void 0}export function resolveDmarcPolicy(policy){return policy==="none"||policy==="quarantine"||policy==="reject"?policy:"quarantine"}export function zoneFqdn(name,zone){const apex=zone.replace(/\.$/,"").toLowerCase(),n=String(name??"").replace(/\.$/,"").toLowerCase();if(!n||n==="@")return apex;return n===apex||n.endsWith(`.${apex}`)?n:`${n}.${apex}`}export function selectRecordsAt(records,fqdn,type,zone){const target=zoneFqdn(fqdn,zone);return records.filter((r)=>String(r.type).toUpperCase()===type.toUpperCase()&&zoneFqdn(r.name,zone)===target)}export function findMailDnsAnomalies(records,expectations,zone){const problems=[];for(const expectation of expectations){const at=selectRecordsAt(records,expectation.fqdn,expectation.type,zone),ours=expectation.owns?at.filter((record)=>expectation.owns(txtContent(record))):at;if(ours.length===0)problems.push(`${expectation.label}: nothing published at ${expectation.fqdn}`);else if(ours.length>1)problems.push(`${expectation.label}: ${ours.length} records at ${expectation.fqdn}, expected 1 \u2014 receivers treat a duplicated ${expectation.type} here as unconfigured, so remove the stale one`)}return problems}export function txtContent(record){return String(record.content??record.value??"").replace(/^"|"$/g,"")}export function planTxtReplacement(existing,content,owns){const ours=existing.filter((record)=>owns(txtContent(record)));if(ours.length===1&&txtContent(ours[0])===content)return{remove:[],create:!1};return{remove:ours,create:!0}}export async function reconcileMailDns(res,ip,logger){const{domain,dkimPubB64}=res;let{mailHost}=res;const dkimName=`${res.dkimSelector||"mail"}._domainkey`;try{const dns=await import("node:dns"),own=`mail.${domain}`;if(own!==mailHost&&(await dns.promises.resolve4(own)).includes(ip))mailHost=own}catch{}const spf=`v=spf1 ip4:${ip} ~all`,cfg=emailConfig||{},firstBox=resolveMailboxes(cfg.mailboxes,domain)[0]?.address,fromAddress=typeof cfg.from?.address==="string"&&cfg.from.address.includes("@")?cfg.from.address:void 0,dmarcCfg=cfg.server?.dmarc||{},rua=dmarcCfg.reportTo||fromAddress||firstBox||`chris@${domain}`,dmarc=`v=DMARC1; p=${resolveDmarcPolicy(dmarcCfg.policy)}; rua=mailto:${rua}`,dkim=dkimPubB64?`v=DKIM1; k=rsa; p=${dkimPubB64}`:void 0,byHand=(reason)=>{logger.warn(`Mail DNS not published for ${domain}: ${reason}`);logger.info("Add these records by hand, or point a configured provider at the zone:");logger.info(` MX @ 10 ${mailHost}`);logger.info(` TXT @ ${spf}`);if(dkim)logger.info(` TXT ${dkimName.padEnd(17)}${dkim}`);logger.info(` TXT _dmarc ${dmarc}`);logger.info(` A mail ${ip}`)},providerConfigs=dnsProviderConfigsFromEnv();if(providerConfigs.length===0)return byHand("no DNS provider credentials are configured");const provider=await resolveZoneDnsProvider(domain,providerConfigs,logger);if(!provider)return byHand("no configured DNS provider administers this zone");const apex=domain.toLowerCase(),dkimFqdn=`${dkimName}.${domain}`,dmarcFqdn=`_dmarc.${domain}`,mailFqdn=`mail.${domain}`,recordsAt=async(fqdn,type)=>{const res=await provider.listRecords(domain);return selectRecordsAt(res?.success?res.records||[]:[],fqdn,type,domain)},replaceTxt=async(fqdn,content,owns)=>{const existing=await recordsAt(fqdn,"TXT"),{remove,create}=planTxtReplacement(existing,content,owns);if(!create)return;for(const record of remove){const removed=await provider.deleteRecord(domain,{...record,name:fqdn,type:"TXT"});if(removed&&removed.success===!1)throw Error(`TXT ${fqdn}: could not remove the record being replaced (${removed.message||"provider refused the delete"})`)}const created=await provider.createRecord(domain,{name:fqdn,type:"TXT",content,ttl:600});if(!created?.success)throw Error(`TXT ${fqdn}: ${created?.message||"provider rejected the record"}`)};try{const mxRecords=await recordsAt(apex,"MX"),staleMx=mxRecords.filter((r)=>String(r.content??r.value??"").replace(/\.$/,"").toLowerCase()!==mailHost.toLowerCase());for(const record of staleMx){logger.warn(` Mail DNS: replacing an existing MX for ${domain} \u2192 ${record.content??record.value}`);await provider.deleteRecord(domain,{...record,name:apex,type:"MX"})}if(mxRecords.length===staleMx.length){const created=await provider.createRecord(domain,{name:apex,type:"MX",content:mailHost,ttl:600,priority:10});if(!created?.success)throw Error(`MX ${domain}: ${created?.message||"provider rejected the record"}`)}await replaceTxt(apex,spf,(existing)=>existing.toLowerCase().startsWith("v=spf1"));if(dkim)await replaceTxt(dkimFqdn,dkim,()=>!0);await replaceTxt(dmarcFqdn,dmarc,(existing)=>existing.toLowerCase().startsWith("v=dmarc1"));const mailA=await provider.upsertRecord(domain,{name:mailFqdn,type:"A",content:ip,ttl:600});if(!mailA?.success)throw Error(`A ${mailFqdn}: ${mailA?.message||"provider rejected the record"}`);const verifyRes=await provider.listRecords(domain),anomalies=verifyRes?.success?findMailDnsAnomalies(verifyRes.records||[],[{label:"MX",fqdn:apex,type:"MX"},{label:"SPF",fqdn:apex,type:"TXT",owns:(content)=>content.toLowerCase().startsWith("v=spf1")},...dkim?[{label:"DKIM",fqdn:dkimFqdn,type:"TXT"}]:[],{label:"DMARC",fqdn:dmarcFqdn,type:"TXT",owns:(content)=>content.toLowerCase().startsWith("v=dmarc1")}],domain):[];for(const anomaly of anomalies)logger.warn(` Mail DNS: ${anomaly}`);logger.success(`Mail DNS published for ${domain} via ${provider.name} (MX\u2192${mailHost}, SPF, DKIM at ${dkimName}, DMARC, ${mailFqdn}\u2192${ip})`)}catch(err){logger.warn(`Mail DNS reconcile skipped for ${domain}: ${getErrorMessage(err)}`)}}export function configDnsDomains(sites){const domains=new Set;for(const site of Object.values(sites))if(!site?.redirect&&site?.domain&&typeof site.domain==="string")domains.add(site.domain.replace(/^www\./,""));return[...domains]}export function dnsProviderNameFromNameservers(nameservers){const normalized=nameservers.map((name)=>name.toLowerCase().replace(/\.$/,""));if(normalized.some((name)=>name.endsWith(".porkbun.com")))return"porkbun";if(normalized.some((name)=>name.endsWith(".ns.cloudflare.com")))return"cloudflare";if(normalized.some((name)=>/(^|\.)awsdns-\d+\.(?:com|net|org|co\.uk)$/.test(name)))return"route53";if(normalized.some((name)=>name.endsWith(".domaincontrol.com")))return"godaddy";return null}async function reconcileConfigDns(sites,logger){const projectDnsConfig=await loadProjectDnsConfig(dnsConfig);if(["a","aaaa","cname","mx","txt"].reduce((total,key)=>total+(Array.isArray(projectDnsConfig?.[key])?projectDnsConfig[key].length:0),0)===0)return;for(const domain of configDnsDomains(sites))try{const result=await syncDnsConfig(domain,projectDnsConfig);if(!result.provider)continue;if(result.created||result.failed)logger.info(`DNS (config/dns.ts) ${domain}: ${result.created} created, ${result.kept} kept${result.failed?`, ${result.failed} failed`:""}`)}catch(err){logger.warn(`DNS (config/dns.ts) reconcile for ${domain} failed: ${err instanceof Error?err.message:String(err)}`)}}async function reconcileHetznerDns(sites,ip,logger,ipv6){const published=[],domains=new Set;for(const site of Object.values(sites))if(site?.domain&&typeof site.domain==="string")domains.add(site.domain.replace(/^www\./,""));if(domains.size===0)return published;const providerConfigs=dnsProviderConfigsFromEnv();if(providerConfigs.length===0){logger.warn("DNS: no DNS provider credentials found (PORKBUN_API_KEY/\u2026); skipping DNS reconciliation.");for(const d of domains)logger.info(d.split(".").length===2?` Point manually: A ${d} \u2192 ${ip} and A www.${d} \u2192 ${ip}`:` Point manually: A ${d} \u2192 ${ip}`);return published}const{reconcileAddressRecords}=await import("@stacksjs/ts-cloud");logger.info("Reconciling DNS records...");const resolveA=async(fqdn)=>{try{const{resolve4}=await import("node:dns/promises");return await resolve4(fqdn)}catch{return[]}};for(const domain of domains){const subs=domain.split(".").length===2?["","www"]:[""];try{const provider=await resolveZoneDnsProvider(domain,providerConfigs,logger);if(!provider){for(const sub of subs){const fqdn=sub?`${sub}.${domain}`:domain,current=await resolveA(fqdn);if(current.includes(ip))logger.success(` DNS: ${fqdn} \u2192 ${ip} (externally managed, already correct)`);else if(current.length===0)logger.warn(` DNS: ${fqdn} does not resolve and no configured provider manages ${domain} \u2014 create it manually: A ${fqdn} \u2192 ${ip}`);else logger.warn(` DNS: ${fqdn} resolves to ${current.join(", ")} but this deploy targets ${ip}, and no configured provider manages ${domain} \u2014 update it manually: A ${fqdn} \u2192 ${ip}`)}continue}for(const sub of subs){const fqdn=sub?`${sub}.${domain}`:domain,report=await reconcileAddressRecords({provider,zone:domain,fqdn,ipv4:ip,ipv6});for(const record of report.published){logger.success(` DNS: ${record.fqdn} \u2192 ${record.content} (${provider.name})`);published.push(record.fqdn)}for(const warning of report.warnings)logger.warn(` DNS: ${warning}`)}}catch(err){logger.warn(` DNS: ${domain} reconciliation failed: ${err?.message||err}`)}}return published}async function buildContainerImageWithPantry(args){const{slug,sites,verbose}=args,{execSync}=await import("node:child_process");let cli;for(const candidate of["pantry","ts-pantry"])try{execSync(`command -v ${candidate}`,{stdio:"pipe"});cli=candidate;break}catch{}if(!cli){log.warn("pantry CLI not found on PATH \u2014 skipping container image build. Install pantry to enable `--docker`.");return}const dockerfile="storage/framework/Dockerfile",canPush=Boolean(process.env.PANTRY_REGISTRY_TOKEN||process.env.PANTRY_TOKEN);for(const[siteName,site]of Object.entries(sites)){if(!site?.start)continue;const tag=`${slug}-${siteName}:latest`;log.info(`[${siteName}] building image ${tag} with pantry (native, no Docker daemon)...`);const flags=["build",".","-t",tag,"-f",dockerfile,"--run-mode","skip"];if(canPush)flags.push("--push");execSync(`${cli} ${flags.map((f)=>f.includes(" ")?`'${f}'`:f).join(" ")}`,{stdio:verbose?"inherit":"pipe",maxBuffer:536870912});log.success(`[${siteName}] image built${canPush?" + pushed to the pantry registry":""}`)}}export function deploy(buddy){const descriptions={deploy:"Deploy your project",project:"Target a specific project",production:"Deploy to production",development:"Deploy to development",staging:"Deploy to staging",yes:"Confirm all prompts by default",domain:"Specify a domain to deploy to",verbose:"Enable verbose output"};buddy.command("deploy [env]",descriptions.deploy).option("--domain <domain>",descriptions.domain,{default:void 0}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--prod",descriptions.production,{default:!1}).option("--dev",descriptions.development,{default:!1}).option("--yes",descriptions.yes,{default:!1}).option("--site <name>","Deploy only this one site to the existing server (multi-tenant surgical add)",{default:void 0}).option("--staging",descriptions.staging,{default:!1}).option("--docker","Also build an OCI image with pantry (native, no Docker daemon) and push it to the pantry registry",{default:!1}).option("-J, --json","Emit a machine-readable deployment preview",{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(envArg,options)=>{log.debug("Running `buddy deploy` ...",options);const askedForDryRun=process.argv.includes("--dry-run")||options.dryRun===!0,optionEnvironment=options.env,deployEnvName=resolveDeploymentEnvironment({positional:envArg,option:optionEnvironment,staging:options.staging,development:options.dev}),deployEnv=deployEnvName;try{applyDeploymentDomainOverride({},options.domain)}catch(error){log.error(getErrorMessage(error));process.exit(ExitCode.InvalidArgument)}if(askedForDryRun){process.env.APP_ENV=deployEnvName;const envFile=deployEnvName==="production"?".env.production":`.env.${deployEnvName}`;if(existsSync(p.projectPath(envFile)))try{const{loadEnv}=await import("@stacksjs/env");loadEnv({path:envFile,env:deployEnvName,keysFile:".env.keys",overload:!0,quiet:!0})}catch(error){log.debug(`Could not load ${envFile} for preview: ${getErrorMessage(error)}`)}const tsCloudConfig=await loadTsCloudConfig(deployEnvName),{resolveSiteKind}=await loadTsCloudDeployApi(),unbacked=tsCloudConfig?findUnbackedManagedServices(tsCloudConfig):[];let plan;try{plan=createDeploymentPreview({config:tsCloudConfig,environment:deployEnvName,site:options.site,domain:options.domain,docker:options.docker===!0,fallbackProjectName:app.name,fallbackProjectSlug:app.name?.toLowerCase().replace(/[^a-z0-9]+/g,"-")||"app",fallbackProvider:"aws",fallbackMode:cloudConfig.mode||"server",fallbackRegion:process.env.AWS_REGION||"us-east-1",resolveSiteKind,applyEnvironmentToSites,warnings:unbacked.length>0?[unbackedDataMessage(unbacked)]:[]})}catch(error){log.error(getErrorMessage(error));process.exit(ExitCode.InvalidArgument)}if(options.json||process.argv.includes("--json"))console.log(`${deploymentPreviewJsonPrefix}${JSON.stringify(plan)}`);else process.stdout.write(formatDeploymentPreview(plan));return}await ensureDeployPrerequisites(options.verbose===!0,deployEnvName);process.env.APP_ENV=deployEnvName;{const envFile=deployEnvName==="production"?".env.production":`.env.${deployEnvName}`;if(existsSync(p.projectPath(envFile)))try{const{loadEnv}=await import("@stacksjs/env");loadEnv({path:envFile,env:deployEnvName,keysFile:".env.keys",overload:!0,quiet:!0})}catch(err){log.warn(`Could not load ${envFile}: ${getErrorMessage(err)}`)}}const tsCloudConfig=await loadTsCloudConfig(deployEnvName);if(tsCloudConfig&&resolveProvider(tsCloudConfig)==="hetzner"){await deployToHetzner(applyDeploymentDomainOverride(tsCloudConfig,options.domain),deployEnv,options);return}if(process.env.AWS_ACCESS_KEY_ID&&process.env.AWS_SECRET_ACCESS_KEY)delete process.env.AWS_PROFILE;const startTime=performance.now();console.log("");console.log("\uD83D\uDE80 Deploy");console.log("");let productionUrl;if(deployEnv==="production"||deployEnv==="prod"){const prodEnvPath=p.projectPath(".env.production");if(existsSync(prodEnvPath)){const urlMatch=readFileSync(prodEnvPath,"utf-8").match(/^APP_URL=(.+)$/m);if(urlMatch?.[1]){productionUrl=urlMatch[1].trim();log.debug("Using APP_URL from .env.production:",productionUrl)}}}const envUrl=env.APP_URL,domain=options.domain||productionUrl||envUrl||app.url;if(deployEnvName==="production"&&!options.yes)await confirmProductionDeployment();if(!domain){log.info("No domain found in your .env.production or ./config/app.ts");log.info("Please ensure your domain is properly configured.");log.info("For more info, check out the docs or join our Discord.");process.exit(ExitCode.FatalError)}log.info(`Deploying to ${italic(domain)} (${deployEnv})`);await checkIfAwsIsBootstrapped(options);options.domain=await configureDomain(domain,options,startTime);const result=await runAction(Action.Deploy,options);if(resultFailed(result)){await outro("While running the `buddy deploy`, there was an issue",{startTime,useSeconds:!0},result.error);process.exit(ExitCode.FatalError)}await outro("Project deployed.",{startTime,useSeconds:!0})});buddy.command("deploy:rollback [site]","Roll back a deployment to a preserved release").option("--env <environment>","Environment to roll back",{default:"production"}).option("--to <release>","Preserved release id to activate",{default:void 0}).option("--dry-run","Preview the rollback without changing the active release",{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(site,options)=>{const exitCode=await runDeployRollback(site,options);if(exitCode!==ExitCode.Success)process.exit(exitCode)});onUnknownSubcommand(buddy,"deploy")}async function confirmProductionDeployment(){if(!process.stdin.isTTY){log.error("Refusing to deploy to production from a non-interactive shell without confirmation.");log.info(" \u27A1\uFE0F Re-run with `--yes` to confirm (e.g. in CI): `buddy deploy --prod --yes`");process.exit(ExitCode.InvalidArgument)}if(!await prompts.confirm({message:"Are you sure you want to deploy to production?",initial:!0})){log.info("Aborting deployment...");process.exit(ExitCode.InvalidArgument)}}async function configureDomain(domain,options,startTime){log.debug("Configuring domain...",domain);if(!domain){log.info("We could not identify a domain to deploy to.");log.warn("Please set your .env or ./config/app.ts properly.");log.info("Alternatively, specify a domain to deploy via the `--domain` flag.");console.log("");log.info(" \u27A1\uFE0F Example: `buddy deploy --domain example.com`");console.log("");process.exit(ExitCode.FatalError)}if(domain.includes("localhost")){log.info("You are deploying to a local environment.");log.warn("Please set your .env or ./config/app.ts properly. The domain we are deploying cannot be a `localhost` domain.");log.info("Alternatively, specify a domain to deploy via the `--domain` flag.");console.log("");log.info(" \u27A1\uFE0F Example: `buddy deploy --domain example.com`");console.log("");process.exit(ExitCode.FatalError)}if(await hasUserDomainBeenAddedToCloud(domain)){log.info("Domain is properly configured");log.info("Your cloud is deploying...");log.info(`${italic("This may take a while...")}`);return domain}console.log("");log.info(` \uD83D\uDC4B It appears to be your first ${italic(domain)} deployment.`);console.log("");log.info(italic("Let\u2019s ensure it is all connected properly."));log.info(italic("One moment..."));console.log("");const result=await addDomain({...options,deploy:!0,startTime});if(resultFailed(result)){await outro("While running the `buddy deploy`, there was an issue",{startTime,useSeconds:!0},result.error);process.exit(ExitCode.FatalError)}await outro("Added your domain.",{startTime,useSeconds:!0});process.exit(ExitCode.Success)}async function promptAndSaveCredentials(){const accessKeyId=await prompts.text({message:"AWS Access Key ID:",validate:(value)=>value.length>0?!0:"Access Key ID is required"});if(!accessKeyId){log.info("Deployment cancelled");process.exit(ExitCode.Success)}const secretAccessKey=await prompts.password({message:"AWS Secret Access Key:",validate:(value)=>value.length>0?!0:"Secret Access Key is required"});if(!secretAccessKey){log.info("Deployment cancelled");process.exit(ExitCode.Success)}const region=await prompts.text({message:"AWS Region:",initial:"us-east-1"});if(!region){log.info("Deployment cancelled");process.exit(ExitCode.Success)}const{setEnv}=await import("@stacksjs/env");await setEnv("AWS_ACCESS_KEY_ID",accessKeyId,{file:".env.production",encrypt:!0});await setEnv("AWS_SECRET_ACCESS_KEY",secretAccessKey,{file:".env.production",encrypt:!0});await setEnv("AWS_REGION",region||"us-east-1",{file:".env.production"});process.env.AWS_ACCESS_KEY_ID=accessKeyId;process.env.AWS_SECRET_ACCESS_KEY=secretAccessKey;process.env.AWS_REGION=region||"us-east-1";log.success("AWS credentials saved securely to .env.production");console.log("")}function loadAwsCredentialsFromEnv(){const environment=process.env.APP_ENV||process.env.NODE_ENV||"production",envFiles=[p.projectPath(`.env.${environment}`),p.projectPath(".env")];for(const envPath of envFiles){if(!existsSync(envPath))continue;try{const lines=readFileSync(envPath,"utf-8").split(`
|
|
349
|
+
if [ "$ENV_CHANGED" = 1 ]; then systemctl restart mail 2>/dev/null || true; echo "MAILTENANT:env-changed+restarted,forwards=$FWD_STATE"; else echo "MAILTENANT:current,forwards=$FWD_STATE"; fi`;try{const out=execSync(`ssh ${sshArgs.map((a)=>`'${a}'`).join(" ")} bash -s`,{input:script,encoding:"utf8",stdio:["pipe","pipe","pipe"]}),line=(out.match(/MAILTENANT:[^\n]*/)||[])[0]||"MAILTENANT:done",mailHostFromOut=(out.match(/MAILHOST:([^\n]*)/)||[])[1]?.trim(),mailHost=mailHostFromOut||`mail.${domain}`,dkimPubB64=(out.match(/DKIMPUB:([^\n]*)/)||[])[1]?.trim()||void 0,dkimSelector=(out.match(/DKIMSEL:([^\n]*)/)||[])[1]?.trim()||void 0,dkimGlobalKey=(out.match(/DKIMGLOBAL:([^\n]*)/)||[])[1]?.trim();if(dkimGlobalKey)logger.warn(`Mail: ${domain} is the mail server's global DKIM_DOMAIN, so it signs with ${dkimGlobalKey} and the per-domain key at /opt/mail/dkim/${domain}.private is unused. Rotate the global key, not that one.`);const madeAddrs=new Set([...out.matchAll(/MADE:([^\n]+)/g)].flatMap((m)=>m[1]?[m[1].trim()]:[])),migratedAddrs=new Set([...out.matchAll(/MIGRATED:([^\n]+)/g)].flatMap((m)=>m[1]?[m[1].trim()]:[])),created=boxes.filter((b)=>madeAddrs.has(b.address)).map((b)=>({address:b.address,password:b.password}));logger.success(`Mail routing reconciled (${line.replace("MAILTENANT:","")})`);const failed=[...out.matchAll(/FAIL:([^\n]+)/g)].flatMap((m)=>m[1]?[m[1].trim()]:[]);if(failed.length)logger.warn(`Mail: the server refused ${failed.length} mailbox(es): ${failed.join(", ")}`);const forwardError=(out.match(/FWDERR:([^\n]*)/)||[])[1]?.trim();if(forwardError)logger.warn(`Mail: the forward rules were not merged: ${forwardError}`);const certHost=(out.match(/CERTHOST:([^\n]*)/)||[])[1]?.trim();if(certHost)logger.success(`Mail: ${certHost} added to the mail certificate \u2014 clients can use it as the server name`);const certError=(out.match(/CERTFAIL:([^\n]*)/)||[])[1]?.trim();if(certError)logger.warn(`Mail: could not issue a certificate for mail.${domain} (clients should use ${mailHostFromOut||"the shared mail host"}): ${certError}`);const seen=new Set([...madeAddrs,...migratedAddrs,...[...out.matchAll(/EXISTS:([^\n]+)/g)].flatMap((m)=>m[1]?[m[1].trim()]:[])]),unaccounted=boxes.filter((b)=>!seen.has(b.address)).map((b)=>b.address);if(unaccounted.length)logger.warn(`Mail: ${unaccounted.length} declared mailbox(es) were not reconciled: ${unaccounted.join(", ")}`);if(created.length){logger.info(`Mail: created ${created.length} mailbox(es) \u2014 credentials below (save them; shown once):`);for(const b of created)logger.info(` ${b.address} ${b.password}`)}if(migratedAddrs.size)logger.success(`Mail: migrated ${migratedAddrs.size} legacy mailbox username(s) to isolated full addresses`);return domain?{domain,mailHost,dkimPubB64,dkimSelector,created}:null}catch(err){logger.warn(`Mail routing reconcile skipped: ${getErrorMessage(err)}`);return null}}export function dnsProviderConfigsFromEnv(){const configs=[];if(process.env.PORKBUN_API_KEY&&process.env.PORKBUN_SECRET_KEY)configs.push({provider:"porkbun",apiKey:process.env.PORKBUN_API_KEY,secretKey:process.env.PORKBUN_SECRET_KEY});if(process.env.CLOUDFLARE_API_TOKEN)configs.push({provider:"cloudflare",apiToken:process.env.CLOUDFLARE_API_TOKEN});if(process.env.GODADDY_API_KEY&&process.env.GODADDY_API_SECRET)configs.push({provider:"godaddy",apiKey:process.env.GODADDY_API_KEY,apiSecret:process.env.GODADDY_API_SECRET,environment:process.env.GODADDY_ENVIRONMENT});if(process.env.AWS_ACCESS_KEY_ID||process.env.AWS_PROFILE)configs.push({provider:"route53"});return configs}async function resolveZoneDnsProvider(domain,providerConfigs,logger){if(providerConfigs.length===0)return;const{createDnsProvider,detectDnsProvider}=await import("@stacksjs/ts-cloud"),provider=await detectDnsProvider(domain,providerConfigs).catch((err)=>{logger.warn(` DNS: ignoring a configured provider for ${domain} \u2014 its credentials were rejected (${err?.message||err})`);return});if(provider)return provider;let nameservers=[];try{const{resolveNs}=await import("node:dns/promises");nameservers=await resolveNs(domain)}catch{}const providerName=dnsProviderNameFromNameservers(nameservers),providerConfig=providerConfigs.find((config)=>config.provider===providerName);return providerConfig?createDnsProvider(providerConfig):void 0}export function resolveDmarcPolicy(policy){return policy==="none"||policy==="quarantine"||policy==="reject"?policy:"quarantine"}export function zoneFqdn(name,zone){const apex=zone.replace(/\.$/,"").toLowerCase(),n=String(name??"").replace(/\.$/,"").toLowerCase();if(!n||n==="@")return apex;return n===apex||n.endsWith(`.${apex}`)?n:`${n}.${apex}`}export function selectRecordsAt(records,fqdn,type,zone){const target=zoneFqdn(fqdn,zone);return records.filter((r)=>String(r.type).toUpperCase()===type.toUpperCase()&&zoneFqdn(r.name,zone)===target)}export function findMailDnsAnomalies(records,expectations,zone){const problems=[];for(const expectation of expectations){const at=selectRecordsAt(records,expectation.fqdn,expectation.type,zone),ours=expectation.owns?at.filter((record)=>expectation.owns(txtContent(record))):at;if(ours.length===0)problems.push(`${expectation.label}: nothing published at ${expectation.fqdn}`);else if(ours.length>1)problems.push(`${expectation.label}: ${ours.length} records at ${expectation.fqdn}, expected 1 \u2014 receivers treat a duplicated ${expectation.type} here as unconfigured, so remove the stale one`)}return problems}export function txtContent(record){return String(record.content??record.value??"").replace(/^"|"$/g,"")}export function planTxtReplacement(existing,content,owns){const ours=existing.filter((record)=>owns(txtContent(record)));if(ours.length===1&&txtContent(ours[0])===content)return{remove:[],create:!1};return{remove:ours,create:!0}}export async function reconcileMailDns(res,ip,logger){const{domain,dkimPubB64}=res;let{mailHost}=res;const dkimName=`${res.dkimSelector||"mail"}._domainkey`;try{const dns=await import("node:dns"),own=`mail.${domain}`;if(own!==mailHost&&(await dns.promises.resolve4(own)).includes(ip))mailHost=own}catch{}const spf=`v=spf1 ip4:${ip} ~all`,cfg=emailConfig||{},firstBox=resolveMailboxes(cfg.mailboxes,domain)[0]?.address,fromAddress=typeof cfg.from?.address==="string"&&cfg.from.address.includes("@")?cfg.from.address:void 0,dmarcCfg=cfg.server?.dmarc||{},rua=dmarcCfg.reportTo||fromAddress||firstBox||`chris@${domain}`,dmarc=`v=DMARC1; p=${resolveDmarcPolicy(dmarcCfg.policy)}; rua=mailto:${rua}`,dkim=dkimPubB64?`v=DKIM1; k=rsa; p=${dkimPubB64}`:void 0,byHand=(reason)=>{logger.warn(`Mail DNS not published for ${domain}: ${reason}`);logger.info("Add these records by hand, or point a configured provider at the zone:");logger.info(` MX @ 10 ${mailHost}`);logger.info(` TXT @ ${spf}`);if(dkim)logger.info(` TXT ${dkimName.padEnd(17)}${dkim}`);logger.info(` TXT _dmarc ${dmarc}`);logger.info(` A mail ${ip}`)},providerConfigs=dnsProviderConfigsFromEnv();if(providerConfigs.length===0)return byHand("no DNS provider credentials are configured");const provider=await resolveZoneDnsProvider(domain,providerConfigs,logger);if(!provider)return byHand("no configured DNS provider administers this zone");const apex=domain.toLowerCase(),dkimFqdn=`${dkimName}.${domain}`,dmarcFqdn=`_dmarc.${domain}`,mailFqdn=`mail.${domain}`,recordsAt=async(fqdn,type)=>{const res=await provider.listRecords(domain);return selectRecordsAt(res?.success?res.records||[]:[],fqdn,type,domain)},replaceTxt=async(fqdn,content,owns)=>{const existing=await recordsAt(fqdn,"TXT"),{remove,create}=planTxtReplacement(existing,content,owns);if(!create)return;for(const record of remove){const removed=await provider.deleteRecord(domain,{...record,name:fqdn,type:"TXT"});if(removed&&removed.success===!1)throw Error(`TXT ${fqdn}: could not remove the record being replaced (${removed.message||"provider refused the delete"})`)}const created=await provider.createRecord(domain,{name:fqdn,type:"TXT",content,ttl:600});if(!created?.success)throw Error(`TXT ${fqdn}: ${created?.message||"provider rejected the record"}`)};try{const mxRecords=await recordsAt(apex,"MX"),staleMx=mxRecords.filter((r)=>String(r.content??r.value??"").replace(/\.$/,"").toLowerCase()!==mailHost.toLowerCase());for(const record of staleMx){logger.warn(` Mail DNS: replacing an existing MX for ${domain} \u2192 ${record.content??record.value}`);await provider.deleteRecord(domain,{...record,name:apex,type:"MX"})}if(mxRecords.length===staleMx.length){const created=await provider.createRecord(domain,{name:apex,type:"MX",content:mailHost,ttl:600,priority:10});if(!created?.success)throw Error(`MX ${domain}: ${created?.message||"provider rejected the record"}`)}await replaceTxt(apex,spf,(existing)=>existing.toLowerCase().startsWith("v=spf1"));if(dkim)await replaceTxt(dkimFqdn,dkim,()=>!0);await replaceTxt(dmarcFqdn,dmarc,(existing)=>existing.toLowerCase().startsWith("v=dmarc1"));const mailA=await provider.upsertRecord(domain,{name:mailFqdn,type:"A",content:ip,ttl:600});if(!mailA?.success)throw Error(`A ${mailFqdn}: ${mailA?.message||"provider rejected the record"}`);const verifyRes=await provider.listRecords(domain),anomalies=verifyRes?.success?findMailDnsAnomalies(verifyRes.records||[],[{label:"MX",fqdn:apex,type:"MX"},{label:"SPF",fqdn:apex,type:"TXT",owns:(content)=>content.toLowerCase().startsWith("v=spf1")},...dkim?[{label:"DKIM",fqdn:dkimFqdn,type:"TXT"}]:[],{label:"DMARC",fqdn:dmarcFqdn,type:"TXT",owns:(content)=>content.toLowerCase().startsWith("v=dmarc1")}],domain):[];for(const anomaly of anomalies)logger.warn(` Mail DNS: ${anomaly}`);logger.success(`Mail DNS published for ${domain} via ${provider.name} (MX\u2192${mailHost}, SPF, DKIM at ${dkimName}, DMARC, ${mailFqdn}\u2192${ip})`)}catch(err){logger.warn(`Mail DNS reconcile skipped for ${domain}: ${getErrorMessage(err)}`)}}export function configDnsDomains(sites){const domains=new Set;for(const site of Object.values(sites))if(!site?.redirect&&site?.domain&&typeof site.domain==="string")domains.add(site.domain.replace(/^www\./,""));return[...domains]}export function dnsProviderNameFromNameservers(nameservers){const normalized=nameservers.map((name)=>name.toLowerCase().replace(/\.$/,""));if(normalized.some((name)=>name.endsWith(".porkbun.com")))return"porkbun";if(normalized.some((name)=>name.endsWith(".ns.cloudflare.com")))return"cloudflare";if(normalized.some((name)=>/(^|\.)awsdns-\d+\.(?:com|net|org|co\.uk)$/.test(name)))return"route53";if(normalized.some((name)=>name.endsWith(".domaincontrol.com")))return"godaddy";return null}async function reconcileConfigDns(sites,logger){const projectDnsConfig=await loadProjectDnsConfig(dnsConfig);if(["a","aaaa","cname","mx","txt"].reduce((total,key)=>total+(Array.isArray(projectDnsConfig?.[key])?projectDnsConfig[key].length:0),0)===0)return;for(const domain of configDnsDomains(sites))try{const result=await syncDnsConfig(domain,projectDnsConfig);if(!result.provider)continue;if(result.created||result.failed)logger.info(`DNS (config/dns.ts) ${domain}: ${result.created} created, ${result.kept} kept${result.failed?`, ${result.failed} failed`:""}`)}catch(err){logger.warn(`DNS (config/dns.ts) reconcile for ${domain} failed: ${err instanceof Error?err.message:String(err)}`)}}async function reconcileHetznerDns(sites,ip,logger,ipv6,autoWww){const published=[],{gatewayHostnames}=await import("@stacksjs/ts-cloud"),hostnames=gatewayHostnames(sites,{autoWww}),byBase=new Map;for(const fqdn of hostnames){const base=fqdn.replace(/^www\./,""),group=byBase.get(base)??new Set;group.add(fqdn);byBase.set(base,group)}if(byBase.size===0)return published;const providerConfigs=dnsProviderConfigsFromEnv();if(providerConfigs.length===0){logger.warn("DNS: no DNS provider credentials found (PORKBUN_API_KEY/\u2026); skipping DNS reconciliation.");for(const fqdn of hostnames)logger.info(` Point manually: A ${fqdn} \u2192 ${ip}`);return published}const{reconcileAddressRecords}=await import("@stacksjs/ts-cloud");logger.info("Reconciling DNS records...");const resolveA=async(fqdn)=>{try{const{resolve4}=await import("node:dns/promises");return await resolve4(fqdn)}catch{return[]}};for(const[domain,group]of byBase){const fqdns=[...group].sort();try{const provider=await resolveZoneDnsProvider(domain,providerConfigs,logger);if(!provider){for(const fqdn of fqdns){const current=await resolveA(fqdn);if(current.includes(ip))logger.success(` DNS: ${fqdn} \u2192 ${ip} (externally managed, already correct)`);else if(current.length===0)logger.warn(` DNS: ${fqdn} does not resolve and no configured provider manages ${domain} \u2014 create it manually: A ${fqdn} \u2192 ${ip}`);else logger.warn(` DNS: ${fqdn} resolves to ${current.join(", ")} but this deploy targets ${ip}, and no configured provider manages ${domain} \u2014 update it manually: A ${fqdn} \u2192 ${ip}`)}continue}for(const fqdn of fqdns){const report=await reconcileAddressRecords({provider,zone:domain,fqdn,ipv4:ip,ipv6});for(const record of report.published){logger.success(` DNS: ${record.fqdn} \u2192 ${record.content} (${provider.name})`);published.push(record.fqdn)}for(const warning of report.warnings)logger.warn(` DNS: ${warning}`)}}catch(err){logger.warn(` DNS: ${domain} reconciliation failed: ${err?.message||err}`)}}return published}async function buildContainerImageWithPantry(args){const{slug,sites,verbose}=args,{execSync}=await import("node:child_process");let cli;for(const candidate of["pantry","ts-pantry"])try{execSync(`command -v ${candidate}`,{stdio:"pipe"});cli=candidate;break}catch{}if(!cli){log.warn("pantry CLI not found on PATH \u2014 skipping container image build. Install pantry to enable `--docker`.");return}const dockerfile="storage/framework/Dockerfile",canPush=Boolean(process.env.PANTRY_REGISTRY_TOKEN||process.env.PANTRY_TOKEN);for(const[siteName,site]of Object.entries(sites)){if(!site?.start)continue;const tag=`${slug}-${siteName}:latest`;log.info(`[${siteName}] building image ${tag} with pantry (native, no Docker daemon)...`);const flags=["build",".","-t",tag,"-f",dockerfile,"--run-mode","skip"];if(canPush)flags.push("--push");execSync(`${cli} ${flags.map((f)=>f.includes(" ")?`'${f}'`:f).join(" ")}`,{stdio:verbose?"inherit":"pipe",maxBuffer:536870912});log.success(`[${siteName}] image built${canPush?" + pushed to the pantry registry":""}`)}}export function deploy(buddy){const descriptions={deploy:"Deploy your project",project:"Target a specific project",production:"Deploy to production",development:"Deploy to development",staging:"Deploy to staging",yes:"Confirm all prompts by default",domain:"Specify a domain to deploy to",verbose:"Enable verbose output"};buddy.command("deploy [env]",descriptions.deploy).option("--domain <domain>",descriptions.domain,{default:void 0}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--prod",descriptions.production,{default:!1}).option("--dev",descriptions.development,{default:!1}).option("--yes",descriptions.yes,{default:!1}).option("--site <name>","Deploy only this one site to the existing server (multi-tenant surgical add)",{default:void 0}).option("--staging",descriptions.staging,{default:!1}).option("--docker","Also build an OCI image with pantry (native, no Docker daemon) and push it to the pantry registry",{default:!1}).option("-J, --json","Emit a machine-readable deployment preview",{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(envArg,options)=>{log.debug("Running `buddy deploy` ...",options);const askedForDryRun=process.argv.includes("--dry-run")||options.dryRun===!0,optionEnvironment=options.env,deployEnvName=resolveDeploymentEnvironment({positional:envArg,option:optionEnvironment,staging:options.staging,development:options.dev}),deployEnv=deployEnvName;try{applyDeploymentDomainOverride({},options.domain)}catch(error){log.error(getErrorMessage(error));process.exit(ExitCode.InvalidArgument)}if(askedForDryRun){process.env.APP_ENV=deployEnvName;const envFile=deployEnvName==="production"?".env.production":`.env.${deployEnvName}`;if(existsSync(p.projectPath(envFile)))try{const{loadEnv}=await import("@stacksjs/env");loadEnv({path:envFile,env:deployEnvName,keysFile:".env.keys",overload:!0,quiet:!0})}catch(error){log.debug(`Could not load ${envFile} for preview: ${getErrorMessage(error)}`)}const tsCloudConfig=await loadTsCloudConfig(deployEnvName),{resolveSiteKind}=await loadTsCloudDeployApi(),unbacked=tsCloudConfig?findUnbackedManagedServices(tsCloudConfig):[];let plan;try{plan=createDeploymentPreview({config:tsCloudConfig,environment:deployEnvName,site:options.site,domain:options.domain,docker:options.docker===!0,fallbackProjectName:app.name,fallbackProjectSlug:app.name?.toLowerCase().replace(/[^a-z0-9]+/g,"-")||"app",fallbackProvider:"aws",fallbackMode:cloudConfig.mode||"server",fallbackRegion:process.env.AWS_REGION||"us-east-1",resolveSiteKind,applyEnvironmentToSites,warnings:unbacked.length>0?[unbackedDataMessage(unbacked)]:[]})}catch(error){log.error(getErrorMessage(error));process.exit(ExitCode.InvalidArgument)}if(options.json||process.argv.includes("--json"))console.log(`${deploymentPreviewJsonPrefix}${JSON.stringify(plan)}`);else process.stdout.write(formatDeploymentPreview(plan));return}await ensureDeployPrerequisites(options.verbose===!0,deployEnvName);process.env.APP_ENV=deployEnvName;{const envFile=deployEnvName==="production"?".env.production":`.env.${deployEnvName}`;if(existsSync(p.projectPath(envFile)))try{const{loadEnv}=await import("@stacksjs/env");loadEnv({path:envFile,env:deployEnvName,keysFile:".env.keys",overload:!0,quiet:!0})}catch(err){log.warn(`Could not load ${envFile}: ${getErrorMessage(err)}`)}}const tsCloudConfig=await loadTsCloudConfig(deployEnvName);if(tsCloudConfig&&resolveProvider(tsCloudConfig)==="hetzner"){await deployToHetzner(applyDeploymentDomainOverride(tsCloudConfig,options.domain),deployEnv,options);return}if(process.env.AWS_ACCESS_KEY_ID&&process.env.AWS_SECRET_ACCESS_KEY)delete process.env.AWS_PROFILE;const startTime=performance.now();console.log("");console.log("\uD83D\uDE80 Deploy");console.log("");let productionUrl;if(deployEnv==="production"||deployEnv==="prod"){const prodEnvPath=p.projectPath(".env.production");if(existsSync(prodEnvPath)){const urlMatch=readFileSync(prodEnvPath,"utf-8").match(/^APP_URL=(.+)$/m);if(urlMatch?.[1]){productionUrl=urlMatch[1].trim();log.debug("Using APP_URL from .env.production:",productionUrl)}}}const envUrl=env.APP_URL,domain=options.domain||productionUrl||envUrl||app.url;if(deployEnvName==="production"&&!options.yes)await confirmProductionDeployment();if(!domain){log.info("No domain found in your .env.production or ./config/app.ts");log.info("Please ensure your domain is properly configured.");log.info("For more info, check out the docs or join our Discord.");process.exit(ExitCode.FatalError)}log.info(`Deploying to ${italic(domain)} (${deployEnv})`);await checkIfAwsIsBootstrapped(options);options.domain=await configureDomain(domain,options,startTime);const result=await runAction(Action.Deploy,options);if(resultFailed(result)){await outro("While running the `buddy deploy`, there was an issue",{startTime,useSeconds:!0},result.error);process.exit(ExitCode.FatalError)}await outro("Project deployed.",{startTime,useSeconds:!0})});buddy.command("deploy:rollback [site]","Roll back a deployment to a preserved release").option("--env <environment>","Environment to roll back",{default:"production"}).option("--to <release>","Preserved release id to activate",{default:void 0}).option("--dry-run","Preview the rollback without changing the active release",{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(site,options)=>{const exitCode=await runDeployRollback(site,options);if(exitCode!==ExitCode.Success)process.exit(exitCode)});onUnknownSubcommand(buddy,"deploy")}async function confirmProductionDeployment(){if(!process.stdin.isTTY){log.error("Refusing to deploy to production from a non-interactive shell without confirmation.");log.info(" \u27A1\uFE0F Re-run with `--yes` to confirm (e.g. in CI): `buddy deploy --prod --yes`");process.exit(ExitCode.InvalidArgument)}if(!await prompts.confirm({message:"Are you sure you want to deploy to production?",initial:!0})){log.info("Aborting deployment...");process.exit(ExitCode.InvalidArgument)}}async function configureDomain(domain,options,startTime){log.debug("Configuring domain...",domain);if(!domain){log.info("We could not identify a domain to deploy to.");log.warn("Please set your .env or ./config/app.ts properly.");log.info("Alternatively, specify a domain to deploy via the `--domain` flag.");console.log("");log.info(" \u27A1\uFE0F Example: `buddy deploy --domain example.com`");console.log("");process.exit(ExitCode.FatalError)}if(domain.includes("localhost")){log.info("You are deploying to a local environment.");log.warn("Please set your .env or ./config/app.ts properly. The domain we are deploying cannot be a `localhost` domain.");log.info("Alternatively, specify a domain to deploy via the `--domain` flag.");console.log("");log.info(" \u27A1\uFE0F Example: `buddy deploy --domain example.com`");console.log("");process.exit(ExitCode.FatalError)}if(await hasUserDomainBeenAddedToCloud(domain)){log.info("Domain is properly configured");log.info("Your cloud is deploying...");log.info(`${italic("This may take a while...")}`);return domain}console.log("");log.info(` \uD83D\uDC4B It appears to be your first ${italic(domain)} deployment.`);console.log("");log.info(italic("Let\u2019s ensure it is all connected properly."));log.info(italic("One moment..."));console.log("");const result=await addDomain({...options,deploy:!0,startTime});if(resultFailed(result)){await outro("While running the `buddy deploy`, there was an issue",{startTime,useSeconds:!0},result.error);process.exit(ExitCode.FatalError)}await outro("Added your domain.",{startTime,useSeconds:!0});process.exit(ExitCode.Success)}async function promptAndSaveCredentials(){const accessKeyId=await prompts.text({message:"AWS Access Key ID:",validate:(value)=>value.length>0?!0:"Access Key ID is required"});if(!accessKeyId){log.info("Deployment cancelled");process.exit(ExitCode.Success)}const secretAccessKey=await prompts.password({message:"AWS Secret Access Key:",validate:(value)=>value.length>0?!0:"Secret Access Key is required"});if(!secretAccessKey){log.info("Deployment cancelled");process.exit(ExitCode.Success)}const region=await prompts.text({message:"AWS Region:",initial:"us-east-1"});if(!region){log.info("Deployment cancelled");process.exit(ExitCode.Success)}const{setEnv}=await import("@stacksjs/env");await setEnv("AWS_ACCESS_KEY_ID",accessKeyId,{file:".env.production",encrypt:!0});await setEnv("AWS_SECRET_ACCESS_KEY",secretAccessKey,{file:".env.production",encrypt:!0});await setEnv("AWS_REGION",region||"us-east-1",{file:".env.production"});process.env.AWS_ACCESS_KEY_ID=accessKeyId;process.env.AWS_SECRET_ACCESS_KEY=secretAccessKey;process.env.AWS_REGION=region||"us-east-1";log.success("AWS credentials saved securely to .env.production");console.log("")}function loadAwsCredentialsFromEnv(){const environment=process.env.APP_ENV||process.env.NODE_ENV||"production",envFiles=[p.projectPath(`.env.${environment}`),p.projectPath(".env")];for(const envPath of envFiles){if(!existsSync(envPath))continue;try{const lines=readFileSync(envPath,"utf-8").split(`
|
|
350
350
|
`);let accessKeyId,secretAccessKey,region,accountId;for(const line of lines){const trimmed=line.trim();if(trimmed.startsWith("#")||!trimmed.includes("="))continue;const[key,...valueParts]=trimmed.split("="),value=valueParts.join("=").trim();if(key==="AWS_ACCESS_KEY_ID"&&value)accessKeyId=value;else if(key==="AWS_SECRET_ACCESS_KEY"&&value)secretAccessKey=value;else if(key==="AWS_REGION"&&value)region=value;else if(key==="AWS_ACCOUNT_ID"&&value)accountId=value}if(accessKeyId&&secretAccessKey){log.debug(`Found AWS credentials in ${envPath}`);return{accessKeyId,secretAccessKey,region,accountId}}}catch(error){log.debug(`Failed to read ${envPath} file:`,error)}}return{}}async function checkIfAwsIsBootstrapped(options){let handlingAlreadyExists=!1;try{log.info("Ensuring AWS cloud stack exists...");let hasCredentials=process.env.AWS_ACCESS_KEY_ID&&process.env.AWS_SECRET_ACCESS_KEY;if(!hasCredentials){const envCredentials=loadAwsCredentialsFromEnv();if(envCredentials.accessKeyId&&envCredentials.secretAccessKey){process.env.AWS_ACCESS_KEY_ID=envCredentials.accessKeyId;process.env.AWS_SECRET_ACCESS_KEY=envCredentials.secretAccessKey;if(envCredentials.region&&!process.env.AWS_REGION)process.env.AWS_REGION=envCredentials.region;if(envCredentials.accountId&&!process.env.AWS_ACCOUNT_ID)process.env.AWS_ACCOUNT_ID=envCredentials.accountId;hasCredentials=!0;const environment=process.env.APP_ENV||process.env.NODE_ENV||"production";log.success(`Using AWS credentials from .env.${environment}`)}}if(!hasCredentials){const fileCredentials=loadAwsCredentialsFromFile();if(fileCredentials.accessKeyId&&fileCredentials.secretAccessKey){process.env.AWS_ACCESS_KEY_ID=fileCredentials.accessKeyId;process.env.AWS_SECRET_ACCESS_KEY=fileCredentials.secretAccessKey;if(fileCredentials.region&&!process.env.AWS_REGION)process.env.AWS_REGION=fileCredentials.region;hasCredentials=!0;log.success("Using AWS credentials from ~/.aws/credentials")}}if(!hasCredentials){log.info("AWS credentials not found in .env or ~/.aws/credentials.");log.info("You can either:");log.info(" 1. Set AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY in .env.production");log.info(" 2. Add credentials to ~/.aws/credentials");log.info(" 3. Configure them interactively below");console.log("");if(options?.yes){log.info("Skipping credential setup (--yes flag provided)");process.exit(ExitCode.FatalError)}const setupCredentials=await prompts.confirm({message:"Would you like to configure AWS credentials now?",initial:!0});log.debug("setupCredentials response:",setupCredentials,typeof setupCredentials);if(setupCredentials===void 0||setupCredentials===!1){if(setupCredentials===void 0){console.log("");log.info("Deployment cancelled");process.exit(ExitCode.Success)}console.log("");log.info("Skipping cloud infrastructure check");log.info("You can configure AWS credentials later by running: buddy configure:aws");return!0}await promptAndSaveCredentials()}else log.success("AWS credentials found");const appName=(process.env.APP_NAME||app.name||"stacks").toLowerCase().replace(/[^a-z0-9-]/g,"-"),stackName=`${appName}-cloud`,{AWSCloudFormationClient}=await import("@stacksjs/ts-cloud"),cfnClient=new AWSCloudFormationClient(process.env.AWS_REGION||"us-east-1");let stackExists=!1,needsEmailUpdate=!1;try{const stack=(await cfnClient.describeStacks({stackName})).Stacks?.[0];if(stack){stackExists=!0;log.success("Cloud stack exists");const{AWSCloudFormationClient}=await import("@stacksjs/ts-cloud"),resources=await new AWSCloudFormationClient(process.env.AWS_REGION||"us-east-1").listStackResources(stackName),hasEmailBucket=resources.StackResourceSummaries?.some((r)=>r.LogicalResourceId==="EmailBucket"),hasOutboundLambda=resources.StackResourceSummaries?.some((r)=>r.LogicalResourceId==="OutboundEmailLambda"),hasConversionLambda=resources.StackResourceSummaries?.some((r)=>r.LogicalResourceId==="EmailConversionLambda"),hasNotificationTopic=resources.StackResourceSummaries?.some((r)=>r.LogicalResourceId==="EmailNotificationTopic"),hasMailApiLambda=resources.StackResourceSummaries?.some((r)=>r.LogicalResourceId==="MailApiLambda"),hasMailUsersTable=resources.StackResourceSummaries?.some((r)=>r.LogicalResourceId==="MailUsersTable"),hasMailServerInstance=resources.StackResourceSummaries?.some((r)=>r.LogicalResourceId==="MailServerInstance"),currentEmailDomain=stack.Outputs?.find((o)=>o.OutputKey==="EmailDomain")?.OutputValue,configuredDomain=(emailConfig?.from?.address?.includes("@")?emailConfig.from.address.split("@")[1]:void 0)||"stacksjs.com";if(!hasEmailBucket&&emailConfig?.server?.scan!==void 0){log.info("Email infrastructure not found in stack, will update...");needsEmailUpdate=!0}else if(currentEmailDomain&¤tEmailDomain!==configuredDomain){log.info(`Email domain changed: ${currentEmailDomain} -> ${configuredDomain}, will update...`);needsEmailUpdate=!0}else if(hasEmailBucket&&(!hasOutboundLambda||!hasConversionLambda||!hasNotificationTopic)){log.info("Email infrastructure incomplete, will update...");needsEmailUpdate=!0}else if(hasEmailBucket&&(!hasMailApiLambda||!hasMailUsersTable)){log.info("Mail API infrastructure missing, will update...");needsEmailUpdate=!0}else if(hasEmailBucket&&!hasMailServerInstance&&emailConfig?.server?.enabled){log.info("Mail server EC2 instance missing, will update...");needsEmailUpdate=!0}const currentMode=(stack.Outputs||[]).find((o)=>o.OutputKey==="MailServerMode")?.OutputValue,configuredMode=emailConfig?.server?.mode||"serverless";if(currentMode&¤tMode!==configuredMode){log.info(`Mail server mode changed: ${currentMode} -> ${configuredMode}, will update...`);needsEmailUpdate=!0}if(hasMailServerInstance&&emailConfig?.server?.enabled){if(process.env.FORCE_MAIL_UPDATE==="true"){log.info("Forcing mail server update...");needsEmailUpdate=!0}}if(!needsEmailUpdate)return!0}}catch(error){const caught=error&&typeof error==="object"?error:{message:String(error)};log.debug(`Stack not found: ${getErrorMessage(error)}`)}if(!stackExists)log.info("Cloud stack not found, will be created by deploy action");return!0}catch(err){if(!handlingAlreadyExists){log.error("Error checking cloud infrastructure");log.error(`Error: ${getErrorMessage(err)}`);if(options?.verbose)console.error(err)}process.exit(ExitCode.FatalError)}}
|
|
@@ -1 +1,4 @@
|
|
|
1
|
-
import process from"node:process";import{log}from"@stacksjs/cli";function parseSafariPlatforms(platform){if(!platform)return;if(platform==="all")return["macos","ios"];if(platform==="macos"||platform==="ios")return[platform];throw Error(`Invalid Safari platform ${platform}; use macos, ios, or all`)}export function extension(buddy){const load=async()=>{const{loadExtensionConfig}=await import("@stacksjs/browser-extension"),config=await loadExtensionConfig(process.cwd());if(!config){log.error("No extension config found. Create `config/extension.ts` exporting `defineExtension({ \u2026 })`.");process.exit(1)}const pkg=await Bun.file(`${process.cwd()}/package.json`).json().catch(()=>({}));return{config,version:pkg.version??"0.0.0"}};buddy.command("extension:build","Build the browser extension (Chrome + Firefox + Safari) from config/extension.ts").option("--target <target>","Build a single target (chrome | firefox | safari); omit to build all").option("--version <version>","Override the extension version (defaults to package.json)").action(async(options)=>{const{buildExtension,buildAllTargets}=await import("@stacksjs/browser-extension"),{config,version}=await load(),v=options.version??version;if(options.target){const{outdir}=await buildExtension(config,{target:options.target,version:v});log.success(`Built ${config.name} ${v} (${options.target}) \u2192 ${outdir}`)}else{await buildAllTargets(config,{version:v});log.success(`Built ${config.name} ${v} for ${(config.targets??["chrome","firefox"]).join(", ")}`)}});buddy.command("extension:init","Scaffold a Chrome, Firefox, or Safari extension, including the Safari Xcode app").option("--name <name>","Extension display name").option("--target <target>","Scaffold chrome, firefox, safari, or all (default all)").option("--bundle-id <id>","Safari container bundle identifier").option("--team-id <id>","Apple Developer team used for Safari signing").option("--platform <platform>","Safari platform: macos, ios, or all (default all)").option("--force","Overwrite existing starter and Safari scaffold files").action(async(options)=>{const target=options.target??"all";if(!["chrome","firefox","safari","all"].includes(target))throw Error(`Invalid extension target ${target}; use chrome, firefox, safari, or all`);const{scaffoldExtensionProject}=await import("@stacksjs/browser-extension"),result=await scaffoldExtensionProject({name:options.name,target,bundleId:options.bundleId,teamId:options.teamId,platforms:parseSafariPlatforms(options.platform)??["macos","ios"],force:Boolean(options.force)});for(const file of result.written)log.success(`created ${file}`);for(const file of result.skipped)log.info(`skip (exists): ${file}`);if(result.safari)log.success(`Scaffolded the Safari container app \u2192 ${result.safari.dir}`);log.info("Next: add icons under public/icons, then run `buddy extension:build`.")});buddy.command("extension:package","Build + zip the browser extension into store-ready archives").option("--target <target>","Package a single target (chrome | firefox | safari); omit to package all").option("--version <version>","Override the extension version (defaults to package.json)").action(async(options)=>{const{packageExtension}=await import("@stacksjs/browser-extension"),{config,version}=await load(),v=options.version??version,targets=options.target?[options.target]:config.targets??["chrome","firefox"];for(const target of targets){const out=await packageExtension(config,{target,version:v});log.success(`Packaged ${config.name} (${target}) \u2192 ${out}`)}});buddy.command("extension:chrome:status","Fetch the Chrome Web Store item status").option("--service-account-path <path>","Google service-account JSON key path").option("--access-token <token>","Short-lived Chrome Web Store OAuth access token").action(async(options)=>{const{ChromeWebStoreClient}=await import("@stacksjs/browser-extension"),{config}=await load();if(!config.chromeWebStore)throw Error("Chrome status needs chromeWebStore.publisherId and chromeWebStore.itemId in config/extension.ts");const status=await new ChromeWebStoreClient(options).fetchStatus(config.chromeWebStore);log.info(`Chrome Web Store item ${status.itemId}`);log.info(`published: ${status.publishedItemRevisionStatus?.state??"none"}`);log.info(`submitted: ${status.submittedItemRevisionStatus?.state??"none"}`);if(status.warned)log.warn("Chrome has warned this item for a policy violation.");if(status.takenDown)log.error("Chrome has taken this item down for a policy violation.")});buddy.command("extension:chrome:publish","Build, upload, and submit the Chrome extension through Web Store API v2").option("--version <version>","Override the extension version (defaults to package.json)").option("--service-account-path <path>","Google service-account JSON key path").option("--access-token <token>","Short-lived Chrome Web Store OAuth access token").option("--upload-only","Upload without submitting the item for review").option("--allow-warnings","Submit even when Chrome reports validation warnings").action(async(options)=>{const{publishChromeExtension}=await import("@stacksjs/browser-extension"),{config,version}=await load(),result=await publishChromeExtension(config,{version:options.version??version,serviceAccountPath:options.serviceAccountPath,accessToken:options.accessToken,uploadOnly:Boolean(options.uploadOnly),blockOnWarnings:!options.allowWarnings});if(result.deferred){log.warn(`${result.deferred.reason}. Version ${options.version??version} remains queued for the next automated retry.`);return}if(result.alreadyPublished){log.success(result.alreadyPublished.reason);return}log.success(`Uploaded Chrome package ${result.packagePath} (${result.upload?.crxVersion??"processing complete"})`);if(result.publish)log.success(`Submitted Chrome Web Store item ${result.publish.itemId}: ${result.publish.state}`)});buddy.command("extension:firefox:previews","Sync the Firefox listing screenshots declared in config/extension.ts").option("--api-key <issuer>","AMO JWT issuer").option("--api-secret <secret>","AMO JWT secret").option("--dry-run","Report what would change without touching the listing").action(async(options)=>{const{syncFirefoxPreviews}=await import("@stacksjs/browser-extension"),{config}=await load(),result=await syncFirefoxPreviews(config,{issuer:options.apiKey,secret:options.apiSecret,dryRun:options.dryRun});if(result.unchanged)log.info("Firefox listing screenshots already match config/extension.ts");else if(options.dryRun)log.info(`Would replace ${result.removed.length} Firefox listing screenshot(s)`);else log.success(`Synced ${result.uploaded.length} Firefox listing screenshot(s), removed ${result.removed.length}`)});buddy.command("extension:firefox:publish","Build and submit the Firefox extension through Mozilla Add-ons").option("--version <version>","Override the extension version (defaults to package.json)").option("--api-key <issuer>","AMO JWT issuer").option("--api-secret <secret>","AMO JWT secret").option("--source-code <path>","Human-readable source archive for AMO review").option("--approval-timeout <milliseconds>","How long to wait for human approval (default 0)").action(async(options)=>{const{publishFirefoxExtension}=await import("@stacksjs/browser-extension"),{config,version}=await load(),result=await publishFirefoxExtension(config,{version:options.version??version,issuer:options.apiKey,secret:options.apiSecret,sourceCodePath:options.sourceCode,approvalTimeout:options.approvalTimeout===void 0?void 0:Number(options.approvalTimeout)});log.success(`Submitted Firefox extension (${result.channel}) \u2192 ${result.artifactsDir}`);if(config.firefoxAddons?.screenshots?.length)try{const{syncFirefoxPreviews}=await import("@stacksjs/browser-extension"),previews=await syncFirefoxPreviews(config,{issuer:options.apiKey,secret:options.apiSecret});if(previews.unchanged)log.info("Firefox listing screenshots already match");else log.success(`Synced ${previews.uploaded.length} Firefox listing screenshot(s), removed ${previews.removed.length}`)}catch(error){log.warn(`[extension:firefox:publish] listing screenshots left as they were: ${error.message}`)}if(result.artifacts.length)log.info(`new artifacts: ${result.artifacts.join(", ")}`)});buddy.command("extension:safari:provision","Register Safari Bundle IDs and check the App Store Connect app record").option("--api-key-id <id>","App Store Connect API key ID").option("--api-issuer-id <id>","App Store Connect API issuer ID").option("--api-key-path <path>","Path to the App Store Connect AuthKey_*.p8 file").option("--check","Report missing resources without creating Bundle IDs").option("--version <version>","Create or align App Store versions (defaults to package.json)").option("--platform <platform>","Provision macos, ios, or all (defaults to config safariPlatforms)").action(async(options)=>{const{provisionSafariApp}=await import("@stacksjs/browser-extension"),{config,version}=await load(),result=await provisionSafariApp(config,{keyId:options.apiKeyId,issuerId:options.apiIssuerId,keyPath:options.apiKeyPath,checkOnly:Boolean(options.check),version:options.version??version,platforms:parseSafariPlatforms(options.platform)});for(const resource of[result.container,result.extension])if(resource.created)log.success(`Registered Bundle ID ${resource.identifier}`);else if(resource.exists)log.success(`Bundle ID exists: ${resource.identifier}`);else log.warn(`Bundle ID is missing: ${resource.identifier}`);if(result.appRecord.exists)log.success(`App Store Connect app record exists (${result.appRecord.id})`);else log.warn("App Store Connect app record is missing. Apple requires creating it in the App Store Connect website.");for(const appStoreVersion of result.appStoreVersions){const action=appStoreVersion.created?"Created":appStoreVersion.updated?"Updated":"Ready";log.success(`${action} Safari ${appStoreVersion.platform} App Store version ${appStoreVersion.version}`)}});buddy.command("extension:safari:init","Scaffold the Safari container app (Xcode project) from the template").option("--bundle-id <id>","Base bundle identifier (defaults to config safariBundleId)").option("--dir <dir>","Output directory for the Xcode project (default safari)").option("--force","Overwrite existing scaffold files").option("--team-id <id>","Apple Developer team used for signing").action(async(options)=>{const{scaffoldSafariApp}=await import("@stacksjs/browser-extension"),{config}=await load(),{dir,written,skipped}=await scaffoldSafariApp(config,{bundleId:options.bundleId,dir:options.dir,force:Boolean(options.force),teamId:options.teamId});log.success(`Scaffolded the Safari container app \u2192 ${dir} (${written.length} files)`);if(skipped.length)log.info(`kept ${skipped.length} existing files (use --force to overwrite)`)});buddy.command("extension:safari:app","Build the extension and its macOS, iPhone, and iPad Safari container apps").option("--release","Build the Release configuration (default Debug)").option("--signed","Sign locally against the Apple ID in Xcode (local builds only \u2014 see below)").option("--skip-xcodebuild","Only build + sync the extension payload").option("--version <version>","Override the extension version (defaults to package.json)").option("--platform <platform>","Build macos, ios, or all (defaults to config safariPlatforms)").action(async(options)=>{const{buildSafariApp,buildSafariUniversalApp}=await import("@stacksjs/browser-extension"),{config,version}=await load(),platforms=parseSafariPlatforms(options.platform)??config.safariPlatforms??["macos"];if(platforms.includes("ios")){const result=await buildSafariUniversalApp(config,{version:options.version??version,release:Boolean(options.release),signed:Boolean(options.signed),skipXcodebuild:Boolean(options.skipXcodebuild),platforms});for(const platform of platforms){const appPath=result.appPaths[platform];if(appPath)log.success(`Built Safari ${platform} app ${appPath}`)}if(options.skipXcodebuild)log.success(`Generated universal Safari project \u2192 ${result.project}`);if(result.appPaths.macos)log.info("Open the macOS app once, then enable the extension in Safari > Settings > Extensions.");if(result.appPaths.ios)log.info("Install the iOS app on an iPhone, iPad, or Simulator, then enable it in Settings > Apps > Safari > Extensions.");return}const{appPath,resources}=await buildSafariApp(config,{version:options.version??version,release:Boolean(options.release),signed:Boolean(options.signed),skipXcodebuild:Boolean(options.skipXcodebuild)});if(appPath){log.success(`Built ${appPath}`);log.info("Open the app once, then enable the extension in Safari > Settings > Extensions.")}else log.success(`Extension payload synced \u2192 ${resources}`)});buddy.command("extension:safari:publish","Archive and validate or upload the Safari app to App Store Connect").option("--version <version>","Override the marketing version (defaults to package.json)").option("--build-number <number>","CFBundleVersion (defaults to GITHUB_RUN_NUMBER or Unix time)").option("--team-id <id>","Apple Developer team (defaults to config safariTeamId)").option("--api-key-id <id>","App Store Connect API key ID").option("--api-issuer-id <id>","App Store Connect API issuer ID").option("--api-key-path <path>","Path to the App Store Connect AuthKey_*.p8 file").option("--validate-only","Create and validate the archive without uploading it").option("--platform <platform>","Publish macos, ios, or all (defaults to config safariPlatforms)").option("--skip-screenshots","Do not regenerate the App Store screenshot set before publishing").action(async(options)=>{const{publishSafariApp}=await import("@stacksjs/browser-extension"),{config,version}=await load();if(!options.skipScreenshots)try{const{generateProjectImages}=await import("@stacksjs/actions");await generateProjectImages({only:["app-store"]})}catch(error){log.warn(`[extension:safari:publish] using the committed screenshots \u2014 could not regenerate: ${error.message}`)}const result=await publishSafariApp(config,{version:options.version??version,buildNumber:options.buildNumber,teamId:options.teamId,keyId:options.apiKeyId,issuerId:options.apiIssuerId,keyPath:options.apiKeyPath,validateOnly:Boolean(options.validateOnly),platforms:parseSafariPlatforms(options.platform)});for(const deferred of result.deferred)log.warn(`${deferred.reason}. Version ${deferred.version} remains queued for the next automated retry.`);for(const published of result.alreadyPublished)log.success(`Safari ${published.platform} version ${published.version} is already published${published.state?` (${published.state})`:""}`);if(result.artifacts.length)log.success(options.validateOnly?`Validated Safari ${result.artifacts.map((artifact)=>artifact.platform).join(" + ")} archives (build ${result.buildNumber})`:`Uploaded and selected Safari ${result.attachments.map((attachment)=>attachment.platform).join(" + ")} build ${result.buildNumber} in App Store Connect`);if(result.appStoreSubmission?.reviewSubmissionIds.length)log.success(`Submitted ${result.appStoreSubmission.reviewSubmissionIds.length} Safari version(s) to App Review`)});buddy.command("extension:safari:submit","Synchronize metadata and submit an existing Safari version to App Review").option("--version <version>","Marketing version to submit (defaults to package.json)").option("--api-key-id <id>","App Store Connect API key ID").option("--api-issuer-id <id>","App Store Connect API issuer ID").option("--api-key-path <path>","Path to the App Store Connect AuthKey_*.p8 file").option("--platform <platform>","Submit macos, ios, or all (defaults to config safariPlatforms)").option("--prepare-only","Synchronize the listing without submitting it for review").action(async(options)=>{const{submitSafariAppStore}=await import("@stacksjs/browser-extension"),{config,version}=await load(),result=await submitSafariAppStore(config,{version:options.version??version,keyId:options.apiKeyId,issuerId:options.apiIssuerId,keyPath:options.apiKeyPath,platforms:parseSafariPlatforms(options.platform),submit:!options.prepareOnly});log.success(`Synchronized ${result.versions.map((item)=>item.platform).join(" + ")} App Store listings`);if(result.reviewSubmissionIds.length)log.success(`Submitted ${result.reviewSubmissionIds.length} Safari version(s) to App Review`)})}
|
|
1
|
+
import process from"node:process";import{log}from"@stacksjs/cli";function parseSafariPlatforms(platform){if(!platform)return;if(platform==="all")return["macos","ios"];if(platform==="macos"||platform==="ios")return[platform];throw Error(`Invalid Safari platform ${platform}; use macos, ios, or all`)}async function refreshAppStoreScreenshots(){try{const{generateProjectImages}=await import("@stacksjs/actions");await generateProjectImages({only:["app-store"]})}catch(error){log.warn(`[extension:publish] using the committed screenshots \u2014 could not regenerate: ${error.message}`)}}async function syncFirefoxListing(config,credentials){if(!config.firefoxAddons?.screenshots?.length)return;try{const{syncFirefoxPreviews}=await import("@stacksjs/browser-extension"),previews=await syncFirefoxPreviews(config,credentials);if(previews.unchanged)log.info("Firefox listing screenshots already match");else log.success(`Synced ${previews.uploaded.length} Firefox listing screenshot(s), removed ${previews.removed.length}`)}catch(error){log.warn(`[extension:publish] Firefox listing screenshots not synced: ${error.message}`)}}export function extension(buddy){const load=async()=>{const{loadExtensionConfig}=await import("@stacksjs/browser-extension"),config=await loadExtensionConfig(process.cwd());if(!config){log.error("No extension config found. Create `config/extension.ts` exporting `defineExtension({ \u2026 })`.");process.exit(1)}const pkg=await Bun.file(`${process.cwd()}/package.json`).json().catch(()=>({}));return{config,version:pkg.version??"0.0.0"}};buddy.command("extension:build","Build the browser extension (Chrome + Firefox + Safari) from config/extension.ts").option("--target <target>","Build a single target (chrome | firefox | safari); omit to build all").option("--version <version>","Override the extension version (defaults to package.json)").action(async(options)=>{const{buildExtension,buildAllTargets}=await import("@stacksjs/browser-extension"),{config,version}=await load(),v=options.version??version;if(options.target){const{outdir}=await buildExtension(config,{target:options.target,version:v});log.success(`Built ${config.name} ${v} (${options.target}) \u2192 ${outdir}`)}else{await buildAllTargets(config,{version:v});log.success(`Built ${config.name} ${v} for ${(config.targets??["chrome","firefox"]).join(", ")}`)}});buddy.command("extension:init","Scaffold a Chrome, Firefox, or Safari extension, including the Safari Xcode app").option("--name <name>","Extension display name").option("--target <target>","Scaffold chrome, firefox, safari, or all (default all)").option("--bundle-id <id>","Safari container bundle identifier").option("--team-id <id>","Apple Developer team used for Safari signing").option("--platform <platform>","Safari platform: macos, ios, or all (default all)").option("--force","Overwrite existing starter and Safari scaffold files").action(async(options)=>{const target=options.target??"all";if(!["chrome","firefox","safari","all"].includes(target))throw Error(`Invalid extension target ${target}; use chrome, firefox, safari, or all`);const{scaffoldExtensionProject}=await import("@stacksjs/browser-extension"),result=await scaffoldExtensionProject({name:options.name,target,bundleId:options.bundleId,teamId:options.teamId,platforms:parseSafariPlatforms(options.platform)??["macos","ios"],force:Boolean(options.force)});for(const file of result.written)log.success(`created ${file}`);for(const file of result.skipped)log.info(`skip (exists): ${file}`);if(result.safari)log.success(`Scaffolded the Safari container app \u2192 ${result.safari.dir}`);log.info("Next: add icons under public/icons, then run `buddy extension:build`.")});buddy.command("extension:package","Build + zip the browser extension into store-ready archives").option("--target <target>","Package a single target (chrome | firefox | safari); omit to package all").option("--version <version>","Override the extension version (defaults to package.json)").action(async(options)=>{const{packageExtension}=await import("@stacksjs/browser-extension"),{config,version}=await load(),v=options.version??version,targets=options.target?[options.target]:config.targets??["chrome","firefox"];for(const target of targets){const out=await packageExtension(config,{target,version:v});log.success(`Packaged ${config.name} (${target}) \u2192 ${out}`)}});buddy.command("extension:publish","Publish to every store this project is set up for \u2014 the release-tag entry point").option("--version <version>","Override the extension version (defaults to package.json)").option("--targets <targets>","Comma-separated subset of chrome,firefox,safari").option("--dry-run","Report the publish plan without uploading anything").action(async(options)=>{const{formatPublishPlan,planExtensionPublish,publishChromeExtension,publishFirefoxExtension,publishSafariApp}=await import("@stacksjs/browser-extension"),{config,version}=await load(),requested=options.targets?.split(",").map((value)=>value.trim()).filter(Boolean),plan=planExtensionPublish(config,process.env,requested?.length?requested:void 0);log.info(`Extension publish plan for v${options.version??version}:
|
|
2
|
+
${formatPublishPlan(plan)}`);const publishing=plan.filter((decision)=>decision.publish);if(options.dryRun||!publishing.length){if(!publishing.length)log.warn("No store is both configured and credentialed, so nothing was published.");return}const failures=[];for(const{target}of publishing)try{if(target==="chrome"){const result=await publishChromeExtension(config,{version:options.version??version,blockOnWarnings:!0});if(result.deferred)log.warn(`${result.deferred.reason}. Version ${options.version??version} remains queued for the next automated retry.`);else if(result.alreadyPublished)log.success(result.alreadyPublished.reason);else log.success(`Submitted Chrome Web Store item ${result.publish?.itemId??""}: ${result.publish?.state??"uploaded"}`)}else if(target==="firefox"){const result=await publishFirefoxExtension(config,{version:options.version??version});log.success(`Submitted Firefox extension (${result.channel}) \u2192 ${result.artifactsDir}`);await syncFirefoxListing(config,{})}else{await refreshAppStoreScreenshots();await publishSafariApp(config,{version:options.version??version});log.success("Uploaded Safari app to App Store Connect")}}catch(error){failures.push(`${target}: ${error.message}`);log.error(`[extension:publish] ${target} failed: ${error.message}`)}if(failures.length){log.error(`Failed to publish ${failures.length} of ${publishing.length} store(s):
|
|
3
|
+
${failures.join(`
|
|
4
|
+
`)}`);process.exit(1)}});buddy.command("extension:chrome:status","Fetch the Chrome Web Store item status").option("--service-account-path <path>","Google service-account JSON key path").option("--access-token <token>","Short-lived Chrome Web Store OAuth access token").action(async(options)=>{const{ChromeWebStoreClient}=await import("@stacksjs/browser-extension"),{config}=await load();if(!config.chromeWebStore)throw Error("Chrome status needs chromeWebStore.publisherId and chromeWebStore.itemId in config/extension.ts");const status=await new ChromeWebStoreClient(options).fetchStatus(config.chromeWebStore);log.info(`Chrome Web Store item ${status.itemId}`);log.info(`published: ${status.publishedItemRevisionStatus?.state??"none"}`);log.info(`submitted: ${status.submittedItemRevisionStatus?.state??"none"}`);if(status.warned)log.warn("Chrome has warned this item for a policy violation.");if(status.takenDown)log.error("Chrome has taken this item down for a policy violation.")});buddy.command("extension:chrome:publish","Build, upload, and submit the Chrome extension through Web Store API v2").option("--version <version>","Override the extension version (defaults to package.json)").option("--service-account-path <path>","Google service-account JSON key path").option("--access-token <token>","Short-lived Chrome Web Store OAuth access token").option("--upload-only","Upload without submitting the item for review").option("--allow-warnings","Submit even when Chrome reports validation warnings").action(async(options)=>{const{publishChromeExtension}=await import("@stacksjs/browser-extension"),{config,version}=await load(),result=await publishChromeExtension(config,{version:options.version??version,serviceAccountPath:options.serviceAccountPath,accessToken:options.accessToken,uploadOnly:Boolean(options.uploadOnly),blockOnWarnings:!options.allowWarnings});if(result.deferred){log.warn(`${result.deferred.reason}. Version ${options.version??version} remains queued for the next automated retry.`);return}if(result.alreadyPublished){log.success(result.alreadyPublished.reason);return}log.success(`Uploaded Chrome package ${result.packagePath} (${result.upload?.crxVersion??"processing complete"})`);if(result.publish)log.success(`Submitted Chrome Web Store item ${result.publish.itemId}: ${result.publish.state}`)});buddy.command("extension:firefox:previews","Sync the Firefox listing screenshots declared in config/extension.ts").option("--api-key <issuer>","AMO JWT issuer").option("--api-secret <secret>","AMO JWT secret").option("--dry-run","Report what would change without touching the listing").action(async(options)=>{const{syncFirefoxPreviews}=await import("@stacksjs/browser-extension"),{config}=await load(),result=await syncFirefoxPreviews(config,{issuer:options.apiKey,secret:options.apiSecret,dryRun:options.dryRun});if(result.unchanged)log.info("Firefox listing screenshots already match config/extension.ts");else if(options.dryRun)log.info(`Would replace ${result.removed.length} Firefox listing screenshot(s)`);else log.success(`Synced ${result.uploaded.length} Firefox listing screenshot(s), removed ${result.removed.length}`)});buddy.command("extension:firefox:publish","Build and submit the Firefox extension through Mozilla Add-ons").option("--version <version>","Override the extension version (defaults to package.json)").option("--api-key <issuer>","AMO JWT issuer").option("--api-secret <secret>","AMO JWT secret").option("--source-code <path>","Human-readable source archive for AMO review").option("--approval-timeout <milliseconds>","How long to wait for human approval (default 0)").action(async(options)=>{const{publishFirefoxExtension}=await import("@stacksjs/browser-extension"),{config,version}=await load(),result=await publishFirefoxExtension(config,{version:options.version??version,issuer:options.apiKey,secret:options.apiSecret,sourceCodePath:options.sourceCode,approvalTimeout:options.approvalTimeout===void 0?void 0:Number(options.approvalTimeout)});log.success(`Submitted Firefox extension (${result.channel}) \u2192 ${result.artifactsDir}`);if(config.firefoxAddons?.screenshots?.length)try{const{syncFirefoxPreviews}=await import("@stacksjs/browser-extension"),previews=await syncFirefoxPreviews(config,{issuer:options.apiKey,secret:options.apiSecret});if(previews.unchanged)log.info("Firefox listing screenshots already match");else log.success(`Synced ${previews.uploaded.length} Firefox listing screenshot(s), removed ${previews.removed.length}`)}catch(error){log.warn(`[extension:firefox:publish] listing screenshots left as they were: ${error.message}`)}if(result.artifacts.length)log.info(`new artifacts: ${result.artifacts.join(", ")}`)});buddy.command("extension:safari:provision","Register Safari Bundle IDs and check the App Store Connect app record").option("--api-key-id <id>","App Store Connect API key ID").option("--api-issuer-id <id>","App Store Connect API issuer ID").option("--api-key-path <path>","Path to the App Store Connect AuthKey_*.p8 file").option("--check","Report missing resources without creating Bundle IDs").option("--version <version>","Create or align App Store versions (defaults to package.json)").option("--platform <platform>","Provision macos, ios, or all (defaults to config safariPlatforms)").action(async(options)=>{const{provisionSafariApp}=await import("@stacksjs/browser-extension"),{config,version}=await load(),result=await provisionSafariApp(config,{keyId:options.apiKeyId,issuerId:options.apiIssuerId,keyPath:options.apiKeyPath,checkOnly:Boolean(options.check),version:options.version??version,platforms:parseSafariPlatforms(options.platform)});for(const resource of[result.container,result.extension])if(resource.created)log.success(`Registered Bundle ID ${resource.identifier}`);else if(resource.exists)log.success(`Bundle ID exists: ${resource.identifier}`);else log.warn(`Bundle ID is missing: ${resource.identifier}`);if(result.appRecord.exists)log.success(`App Store Connect app record exists (${result.appRecord.id})`);else log.warn("App Store Connect app record is missing. Apple requires creating it in the App Store Connect website.");for(const appStoreVersion of result.appStoreVersions){const action=appStoreVersion.created?"Created":appStoreVersion.updated?"Updated":"Ready";log.success(`${action} Safari ${appStoreVersion.platform} App Store version ${appStoreVersion.version}`)}});buddy.command("extension:safari:init","Scaffold the Safari container app (Xcode project) from the template").option("--bundle-id <id>","Base bundle identifier (defaults to config safariBundleId)").option("--dir <dir>","Output directory for the Xcode project (default safari)").option("--force","Overwrite existing scaffold files").option("--team-id <id>","Apple Developer team used for signing").action(async(options)=>{const{scaffoldSafariApp}=await import("@stacksjs/browser-extension"),{config}=await load(),{dir,written,skipped}=await scaffoldSafariApp(config,{bundleId:options.bundleId,dir:options.dir,force:Boolean(options.force),teamId:options.teamId});log.success(`Scaffolded the Safari container app \u2192 ${dir} (${written.length} files)`);if(skipped.length)log.info(`kept ${skipped.length} existing files (use --force to overwrite)`)});buddy.command("extension:safari:app","Build the extension and its macOS, iPhone, and iPad Safari container apps").option("--release","Build the Release configuration (default Debug)").option("--signed","Sign locally against the Apple ID in Xcode (local builds only \u2014 see below)").option("--skip-xcodebuild","Only build + sync the extension payload").option("--version <version>","Override the extension version (defaults to package.json)").option("--platform <platform>","Build macos, ios, or all (defaults to config safariPlatforms)").action(async(options)=>{const{buildSafariApp,buildSafariUniversalApp}=await import("@stacksjs/browser-extension"),{config,version}=await load(),platforms=parseSafariPlatforms(options.platform)??config.safariPlatforms??["macos"];if(platforms.includes("ios")){const result=await buildSafariUniversalApp(config,{version:options.version??version,release:Boolean(options.release),signed:Boolean(options.signed),skipXcodebuild:Boolean(options.skipXcodebuild),platforms});for(const platform of platforms){const appPath=result.appPaths[platform];if(appPath)log.success(`Built Safari ${platform} app ${appPath}`)}if(options.skipXcodebuild)log.success(`Generated universal Safari project \u2192 ${result.project}`);if(result.appPaths.macos)log.info("Open the macOS app once, then enable the extension in Safari > Settings > Extensions.");if(result.appPaths.ios)log.info("Install the iOS app on an iPhone, iPad, or Simulator, then enable it in Settings > Apps > Safari > Extensions.");return}const{appPath,resources}=await buildSafariApp(config,{version:options.version??version,release:Boolean(options.release),signed:Boolean(options.signed),skipXcodebuild:Boolean(options.skipXcodebuild)});if(appPath){log.success(`Built ${appPath}`);log.info("Open the app once, then enable the extension in Safari > Settings > Extensions.")}else log.success(`Extension payload synced \u2192 ${resources}`)});buddy.command("extension:safari:publish","Archive and validate or upload the Safari app to App Store Connect").option("--version <version>","Override the marketing version (defaults to package.json)").option("--build-number <number>","CFBundleVersion (defaults to GITHUB_RUN_NUMBER or Unix time)").option("--team-id <id>","Apple Developer team (defaults to config safariTeamId)").option("--api-key-id <id>","App Store Connect API key ID").option("--api-issuer-id <id>","App Store Connect API issuer ID").option("--api-key-path <path>","Path to the App Store Connect AuthKey_*.p8 file").option("--validate-only","Create and validate the archive without uploading it").option("--platform <platform>","Publish macos, ios, or all (defaults to config safariPlatforms)").option("--skip-screenshots","Do not regenerate the App Store screenshot set before publishing").action(async(options)=>{const{publishSafariApp}=await import("@stacksjs/browser-extension"),{config,version}=await load();if(!options.skipScreenshots)try{const{generateProjectImages}=await import("@stacksjs/actions");await generateProjectImages({only:["app-store"]})}catch(error){log.warn(`[extension:safari:publish] using the committed screenshots \u2014 could not regenerate: ${error.message}`)}const result=await publishSafariApp(config,{version:options.version??version,buildNumber:options.buildNumber,teamId:options.teamId,keyId:options.apiKeyId,issuerId:options.apiIssuerId,keyPath:options.apiKeyPath,validateOnly:Boolean(options.validateOnly),platforms:parseSafariPlatforms(options.platform)});for(const deferred of result.deferred)log.warn(`${deferred.reason}. Version ${deferred.version} remains queued for the next automated retry.`);for(const published of result.alreadyPublished)log.success(`Safari ${published.platform} version ${published.version} is already published${published.state?` (${published.state})`:""}`);if(result.artifacts.length)log.success(options.validateOnly?`Validated Safari ${result.artifacts.map((artifact)=>artifact.platform).join(" + ")} archives (build ${result.buildNumber})`:`Uploaded and selected Safari ${result.attachments.map((attachment)=>attachment.platform).join(" + ")} build ${result.buildNumber} in App Store Connect`);if(result.appStoreSubmission?.reviewSubmissionIds.length)log.success(`Submitted ${result.appStoreSubmission.reviewSubmissionIds.length} Safari version(s) to App Review`)});buddy.command("extension:safari:submit","Synchronize metadata and submit an existing Safari version to App Review").option("--version <version>","Marketing version to submit (defaults to package.json)").option("--api-key-id <id>","App Store Connect API key ID").option("--api-issuer-id <id>","App Store Connect API issuer ID").option("--api-key-path <path>","Path to the App Store Connect AuthKey_*.p8 file").option("--platform <platform>","Submit macos, ios, or all (defaults to config safariPlatforms)").option("--prepare-only","Synchronize the listing without submitting it for review").action(async(options)=>{const{submitSafariAppStore}=await import("@stacksjs/browser-extension"),{config,version}=await load(),result=await submitSafariAppStore(config,{version:options.version??version,keyId:options.apiKeyId,issuerId:options.apiIssuerId,keyPath:options.apiKeyPath,platforms:parseSafariPlatforms(options.platform),submit:!options.prepareOnly});log.success(`Synchronized ${result.versions.map((item)=>item.platform).join(" + ")} App Store listings`);if(result.reviewSubmissionIds.length)log.success(`Submitted ${result.reviewSubmissionIds.length} Safari version(s) to App Review`)})}
|
|
@@ -110,7 +110,7 @@ export declare function features(buddy: CLI): void;
|
|
|
110
110
|
* Mirrors Laravel's `php artisan passport:install` / `horizon:install`
|
|
111
111
|
* pattern: features are inert dead code on disk until installed.
|
|
112
112
|
*/
|
|
113
|
-
export declare const FEATURE_NAMES: readonly ['dashboard', 'commerce', 'cms', 'marketing', 'monitoring', 'realtime', 'queue'];
|
|
113
|
+
export declare const FEATURE_NAMES: readonly ['dashboard', 'commerce', 'cms', 'forms', 'marketing', 'monitoring', 'realtime', 'queue'];
|
|
114
114
|
/**
|
|
115
115
|
* Per-feature stamped file/directory manifest. Paths are relative to the
|
|
116
116
|
* project root and mirror the layout that `./buddy new` lays down. Entries
|
|
@@ -130,6 +130,7 @@ export declare const FEATURE_NAMES: readonly ['dashboard', 'commerce', 'cms', 'm
|
|
|
130
130
|
* @defaultValue
|
|
131
131
|
* ```ts
|
|
132
132
|
* {
|
|
133
|
+
* forms: [ 'app/Models/Forms/', ],
|
|
133
134
|
* cms: [ 'app/Actions/Cms/', 'app/Actions/Dashboard/Content/', 'app/Models/Content/', 'app/Models/Tag.ts', 'app/Models/Comment.ts', 'resources/views/dashboard/content/', ],
|
|
134
135
|
* commerce: [ 'app/Actions/Commerce/', 'app/Actions/Dashboard/Commerce/', 'app/Models/commerce/', 'resources/components/Dashboard/Commerce/', 'resources/views/dashboard/commerce/', ],
|
|
135
136
|
* dashboard: [ 'app/Actions/Dashboard/', 'resources/components/Dashboard/', 'resources/views/dashboard/', 'routes/dashboard.ts', 'routes/dashboard-api.ts', ],
|
|
@@ -163,8 +164,9 @@ export declare const FEATURE_FILES: Record<FeatureName, readonly string[]>;
|
|
|
163
164
|
* @defaultValue
|
|
164
165
|
* ```ts
|
|
165
166
|
* {
|
|
166
|
-
*
|
|
167
|
-
*
|
|
167
|
+
* forms: ['forms', 'form_fields', 'form_submissions'],
|
|
168
|
+
* cms: [ 'posts', 'pages', 'comments', 'tags', 'authors', 'categories', 'taggable_models', 'categorizable_models', 'commentables', 'page_revisions', 'redirects', 'menus', 'menu_items', ],
|
|
169
|
+
* commerce: [ 'products', 'product_variants', 'product_units', 'manufacturers', 'orders', 'order_items', 'order_idempotency', 'carts', 'cart_items', 'payments', 'payment_methods', 'payment_products', 'payment_transactions', 'customers', 'subscribers', 'subscriber_emails', 'subscriptions', 'gift_cards', 'coupons', 'transactions', 'reviews', 'drivers', 'driver_pings', 'delivery_routes', 'delivery_stops', 'digital_deliveries', 'shipping_methods', 'shipping_rates', 'shipping_zones', 'license_keys', 'loyalty_points', 'loyalty_rewards', 'print_devices', 'receipts', 'tax_rates', 'waitlist_products', 'waitlist_restaurants', 'auctions', 'auction_items', 'bids', 'pledges', ],
|
|
168
170
|
* dashboard: [ 'boards', 'board_columns', 'cards', 'card_labels', 'card_assignees', 'card_comments', 'labels', 'ci_run_states', 'ci_runner_samples', 'ci_runner_alert_states', 'requests', 'logs', ],
|
|
169
171
|
* marketing: [ 'campaigns', 'campaign_sends', 'email_lists', 'email_list_subscribers', 'social_posts', 'mail_preferences', ],
|
|
170
172
|
* monitoring: ['errors'],
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import{existsSync,readdirSync,readFileSync}from"node:fs";import{cp,rm}from"node:fs/promises";import{join}from"node:path";import process from"node:process";import{frameworkPath,projectPath}from"@stacksjs/path";import{ExitCode}from"@stacksjs/types";export const FEATURE_NAMES=["dashboard","commerce","cms","marketing","monitoring","realtime","queue"],FEATURE_FILES={cms:["app/Actions/Cms/","app/Actions/Dashboard/Content/","app/Models/Content/","app/Models/Tag.ts","app/Models/Comment.ts","resources/views/dashboard/content/"],commerce:["app/Actions/Commerce/","app/Actions/Dashboard/Commerce/","app/Models/commerce/","resources/components/Dashboard/Commerce/","resources/views/dashboard/commerce/"],dashboard:["app/Actions/Dashboard/","resources/components/Dashboard/","resources/views/dashboard/","routes/dashboard.ts","routes/dashboard-api.ts"],marketing:["app/Actions/Dashboard/Marketing/","app/Models/Campaign.ts","app/Models/CampaignSend.ts","app/Models/EmailList.ts","app/Models/EmailListSubscriber.ts","app/Models/SocialPost.ts","resources/components/Marketing/","resources/views/dashboard/marketing/"],monitoring:["app/Actions/Monitoring/","app/Actions/TestErrorAction.ts","app/Models/Error.ts","functions/monitoring/","resources/views/dashboard/monitoring/","resources/views/dashboard/errors/"],realtime:["app/Actions/Realtime/","app/Actions/Dashboard/Realtime/","app/Models/realtime/","app/Broadcasts/","functions/realtime/","resources/views/dashboard/realtime/"],queue:["app/Actions/Queue/","app/Actions/Dashboard/Jobs/","app/Jobs/","app/Models/Job.ts","app/Models/FailedJob.ts","functions/jobs.ts","resources/views/dashboard/queue/","resources/views/dashboard/jobs/"]},FEATURE_TABLES={cms:["posts","pages","comments","tags","authors","categories","taggable_models","categorizable_models","commentables"],commerce:["products","product_variants","product_units","manufacturers","orders","order_items","order_idempotency","carts","cart_items","payments","payment_methods","payment_products","payment_transactions","customers","subscribers","subscriber_emails","subscriptions","gift_cards","coupons","transactions","reviews","drivers","driver_pings","delivery_routes","delivery_stops","digital_deliveries","shipping_methods","shipping_rates","shipping_zones","license_keys","loyalty_points","loyalty_rewards","print_devices","receipts","tax_rates","waitlist_products","waitlist_restaurants"],dashboard:["boards","board_columns","cards","card_labels","card_assignees","card_comments","labels","ci_run_states","ci_runner_samples","ci_runner_alert_states","requests","logs"],marketing:["campaigns","campaign_sends","email_lists","email_list_subscribers","social_posts","mail_preferences"],monitoring:["errors"],realtime:["websockets"],queue:["jobs","failed_jobs"]};export function migrationTable(filename){const inMatch=filename.match(/-in-([a-z0-9_]+)\.sql$/i);if(inMatch)return inMatch[1]??null;const createMatch=filename.match(/-create-([a-z0-9_]+)-table\.sql$/i);if(createMatch)return createMatch[1]??null;const alterMatch=filename.match(/-alter-([a-z0-9_]+)-/i);if(alterMatch)return alterMatch[1]??null;return null}export function migrationFeature(filename){const table=migrationTable(filename);if(!table)return null;for(const f of FEATURE_NAMES)if(FEATURE_TABLES[f].includes(table))return f;return null}export function appModelClaimsTable(table,root=projectPath()){const modelsDir=join(root,"app/Models");if(!existsSync(modelsDir))return!1;const featureModelFiles=new Set(FEATURE_NAMES.flatMap((feature)=>FEATURE_FILES[feature]).filter((path)=>path.startsWith("app/Models/")&&!path.endsWith("/"))),escaped=table.replace(/[.*+?^${}()|[\]\\]/g,"\\$&"),declaration=new RegExp(`\\btable\\s*:\\s*['"]${escaped}['"]`);for(const entry of readdirSync(modelsDir,{withFileTypes:!0})){if(!entry.isFile()||!/\.[cm]?[jt]s$/.test(entry.name))continue;if(featureModelFiles.has(`app/Models/${entry.name}`))continue;if(declaration.test(readFileSync(join(modelsDir,entry.name),"utf8")))return!0}return!1}export function featurePathsPresent(feature,root=projectPath()){return FEATURE_FILES[feature].filter((rel)=>existsSync(`${root}/${rel}`))}export async function deleteFeatureFiles(feature,root=projectPath()){const removed=[];for(const rel of FEATURE_FILES[feature]){const full=`${root}/${rel}`;if(!existsSync(full))continue;await rm(full,{recursive:!0,force:!0});removed.push(rel)}return removed}export async function copyFeatureFiles(feature,options={}){const source=options.source??frameworkPath("defaults"),target=options.target??projectPath(),force=options.force===!0,copied=[],skipped=[];for(const rel of FEATURE_FILES[feature]){const sourceFull=join(source,rel);if(!existsSync(sourceFull)){skipped.push(rel);continue}const targetFull=join(target,rel);if(existsSync(targetFull)&&!force){skipped.push(rel);continue}await cp(sourceFull,targetFull,{recursive:!0,force});copied.push(rel)}return{copied,skipped}}const FEATURE_DESCRIPTIONS={dashboard:"Admin SPA shell + Activity/Log/Request/Deployment/Notification dashboards.",commerce:"Order/Cart/Product/Customer/Coupon/GiftCard/Shipping + storefront API.",cms:"Post/Page/Author/Comment/Tag models + content edit dashboards.",marketing:"/api/email/subscribe, /api/contact, Campaign/EmailList/SocialPost.",monitoring:"Error model + error-tracking views and actions.",realtime:"WebSocket broadcaster + Websocket model + realtime-stats actions.",queue:"Job + FailedJob models + queue dashboard pages."},STARTER_TEMPLATES={dashboard:`import type { DashboardConfig } from '@stacksjs/types'
|
|
1
|
+
import{existsSync,readdirSync,readFileSync}from"node:fs";import{cp,rm}from"node:fs/promises";import{join}from"node:path";import process from"node:process";import{frameworkPath,projectPath}from"@stacksjs/path";import{ExitCode}from"@stacksjs/types";export const FEATURE_NAMES=["dashboard","commerce","cms","forms","marketing","monitoring","realtime","queue"],FEATURE_FILES={forms:["app/Models/Forms/"],cms:["app/Actions/Cms/","app/Actions/Dashboard/Content/","app/Models/Content/","app/Models/Tag.ts","app/Models/Comment.ts","resources/views/dashboard/content/"],commerce:["app/Actions/Commerce/","app/Actions/Dashboard/Commerce/","app/Models/commerce/","resources/components/Dashboard/Commerce/","resources/views/dashboard/commerce/"],dashboard:["app/Actions/Dashboard/","resources/components/Dashboard/","resources/views/dashboard/","routes/dashboard.ts","routes/dashboard-api.ts"],marketing:["app/Actions/Dashboard/Marketing/","app/Models/Campaign.ts","app/Models/CampaignSend.ts","app/Models/EmailList.ts","app/Models/EmailListSubscriber.ts","app/Models/SocialPost.ts","resources/components/Marketing/","resources/views/dashboard/marketing/"],monitoring:["app/Actions/Monitoring/","app/Actions/TestErrorAction.ts","app/Models/Error.ts","functions/monitoring/","resources/views/dashboard/monitoring/","resources/views/dashboard/errors/"],realtime:["app/Actions/Realtime/","app/Actions/Dashboard/Realtime/","app/Models/realtime/","app/Broadcasts/","functions/realtime/","resources/views/dashboard/realtime/"],queue:["app/Actions/Queue/","app/Actions/Dashboard/Jobs/","app/Jobs/","app/Models/Job.ts","app/Models/FailedJob.ts","functions/jobs.ts","resources/views/dashboard/queue/","resources/views/dashboard/jobs/"]},FEATURE_TABLES={forms:["forms","form_fields","form_submissions"],cms:["posts","pages","comments","tags","authors","categories","taggable_models","categorizable_models","commentables","page_revisions","redirects","menus","menu_items"],commerce:["products","product_variants","product_units","manufacturers","orders","order_items","order_idempotency","carts","cart_items","payments","payment_methods","payment_products","payment_transactions","customers","subscribers","subscriber_emails","subscriptions","gift_cards","coupons","transactions","reviews","drivers","driver_pings","delivery_routes","delivery_stops","digital_deliveries","shipping_methods","shipping_rates","shipping_zones","license_keys","loyalty_points","loyalty_rewards","print_devices","receipts","tax_rates","waitlist_products","waitlist_restaurants","auctions","auction_items","bids","pledges"],dashboard:["boards","board_columns","cards","card_labels","card_assignees","card_comments","labels","ci_run_states","ci_runner_samples","ci_runner_alert_states","requests","logs"],marketing:["campaigns","campaign_sends","email_lists","email_list_subscribers","social_posts","mail_preferences"],monitoring:["errors"],realtime:["websockets"],queue:["jobs","failed_jobs"]};export function migrationTable(filename){const inMatch=filename.match(/-in-([a-z0-9_]+)\.sql$/i);if(inMatch)return inMatch[1]??null;const createMatch=filename.match(/-create-([a-z0-9_]+)-table\.sql$/i);if(createMatch)return createMatch[1]??null;const alterMatch=filename.match(/-alter-([a-z0-9_]+)-/i);if(alterMatch)return alterMatch[1]??null;return null}export function migrationFeature(filename){const table=migrationTable(filename);if(!table)return null;for(const f of FEATURE_NAMES)if(FEATURE_TABLES[f].includes(table))return f;return null}export function appModelClaimsTable(table,root=projectPath()){const modelsDir=join(root,"app/Models");if(!existsSync(modelsDir))return!1;const featureModelFiles=new Set(FEATURE_NAMES.flatMap((feature)=>FEATURE_FILES[feature]).filter((path)=>path.startsWith("app/Models/")&&!path.endsWith("/"))),escaped=table.replace(/[.*+?^${}()|[\]\\]/g,"\\$&"),declaration=new RegExp(`\\btable\\s*:\\s*['"]${escaped}['"]`);for(const entry of readdirSync(modelsDir,{withFileTypes:!0})){if(!entry.isFile()||!/\.[cm]?[jt]s$/.test(entry.name))continue;if(featureModelFiles.has(`app/Models/${entry.name}`))continue;if(declaration.test(readFileSync(join(modelsDir,entry.name),"utf8")))return!0}return!1}export function featurePathsPresent(feature,root=projectPath()){return FEATURE_FILES[feature].filter((rel)=>existsSync(`${root}/${rel}`))}export async function deleteFeatureFiles(feature,root=projectPath()){const removed=[];for(const rel of FEATURE_FILES[feature]){const full=`${root}/${rel}`;if(!existsSync(full))continue;await rm(full,{recursive:!0,force:!0});removed.push(rel)}return removed}export async function copyFeatureFiles(feature,options={}){const source=options.source??frameworkPath("defaults"),target=options.target??projectPath(),force=options.force===!0,copied=[],skipped=[];for(const rel of FEATURE_FILES[feature]){const sourceFull=join(source,rel);if(!existsSync(sourceFull)){skipped.push(rel);continue}const targetFull=join(target,rel);if(existsSync(targetFull)&&!force){skipped.push(rel);continue}await cp(sourceFull,targetFull,{recursive:!0,force});copied.push(rel)}return{copied,skipped}}const FEATURE_DESCRIPTIONS={dashboard:"Admin SPA shell + Activity/Log/Request/Deployment/Notification dashboards.",commerce:"Order/Cart/Product/Customer/Coupon/GiftCard/Shipping + storefront API.",cms:"Post/Page/Author/Comment/Tag models + content edit dashboards.",forms:"User-defined forms: builder models, conditional fields, public submit + CSV export.",marketing:"/api/email/subscribe, /api/contact, Campaign/EmailList/SocialPost.",monitoring:"Error model + error-tracking views and actions.",realtime:"WebSocket broadcaster + Websocket model + realtime-stats actions.",queue:"Job + FailedJob models + queue dashboard pages."},STARTER_TEMPLATES={dashboard:`import type { DashboardConfig } from '@stacksjs/types'
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* **Dashboard Configuration**
|
|
@@ -56,6 +56,17 @@ export default {
|
|
|
56
56
|
export default {
|
|
57
57
|
enabled: true,
|
|
58
58
|
} satisfies CmsConfig
|
|
59
|
+
`,forms:`import type { FormsConfig } from '@stacksjs/types'
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* **Forms Configuration**
|
|
63
|
+
*
|
|
64
|
+
* Controls the form-builder bundle (Form / FormField / FormSubmission
|
|
65
|
+
* models, public submit endpoints, CSV export).
|
|
66
|
+
*/
|
|
67
|
+
export default {
|
|
68
|
+
enabled: true,
|
|
69
|
+
} satisfies FormsConfig
|
|
59
70
|
`,marketing:`import type { MarketingConfig } from '@stacksjs/types'
|
|
60
71
|
|
|
61
72
|
/**
|
package/dist/commands/migrate.js
CHANGED
|
@@ -41,13 +41,13 @@ ${sample}${more}
|
|
|
41
41
|
2. (Optional) Export data from the current ${current} database.
|
|
42
42
|
3. ${switchBlocked?`Regenerate the migration files for ${target} FIRST \u2014 \`./buddy migrate\` will refuse until then.`:"Run `./buddy migrate` (or `migrate:fresh` to start clean)."}
|
|
43
43
|
4. The post-migrate FK audit will report any constraints that didn't replay.
|
|
44
|
-
`);await outro("Plan rendered. Re-run after updating .env to actually switch.",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("migrate:regenerate [dialect]","Rebuild database/migrations from your models for a given dialect").option("--dry-run","Show what would change without writing anything",{default:!1}).option("-f, --force","Regenerate even though the database already has migrations recorded",{default:!1}).option("--replace-unmarked","Also delete migrations carrying no @generated marker (pre-marker corpora only)",{default:!1}).action(async(dialect,options)=>{const perf=await intro("buddy migrate:regenerate"),target=(dialect||process.env.DB_CONNECTION||"sqlite").toLowerCase();if(!new Set(["sqlite","mysql","vitess","postgres","singlestore"]).has(target)){log.syncError(`Unknown dialect "${target}". Allowed: sqlite, mysql, vitess, postgres, singlestore.`);process.exit(ExitCode.FatalError)}const{countAppliedMigrations,regenerateMigrationCorpus}=await import("@stacksjs/database");let applied=0;try{applied=await countAppliedMigrations()}catch{applied=0}if(applied>0&&!options.force&&!options.dryRun){log.syncError(`This database already has ${applied} migration(s) recorded.`);log.syncError("Regenerating renumbers every file, and the migrations table keys on the filename,");log.syncError("so already-applied migrations would look pending and run a second time.");log.syncError(" Point at an empty database, or re-run with --force if you know it is safe.");process.exit(ExitCode.FatalError)}const plan=await regenerateMigrationCorpus({dialect:target,dryRun:!0,replaceUnmarked:options.replaceUnmarked});if(resultFailed(plan)){log.syncError(plan.error.message);process.exit(ExitCode.FatalError)}const{files,removed,preserved,preservedOutOfScope,models,modelRoots}=plan.value,outOfScope=new Set(preservedOutOfScope),
|
|
45
|
-
\u2022 ${
|
|
46
|
-
${
|
|
44
|
+
`);await outro("Plan rendered. Re-run after updating .env to actually switch.",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("migrate:regenerate [dialect]","Rebuild database/migrations from your models for a given dialect").option("--dry-run","Show what would change without writing anything",{default:!1}).option("-f, --force","Regenerate even though the database already has migrations recorded",{default:!1}).option("--replace-unmarked","Also delete migrations carrying no @generated marker (pre-marker corpora only)",{default:!1}).action(async(dialect,options)=>{const perf=await intro("buddy migrate:regenerate"),target=(dialect||process.env.DB_CONNECTION||"sqlite").toLowerCase();if(!new Set(["sqlite","mysql","vitess","postgres","singlestore"]).has(target)){log.syncError(`Unknown dialect "${target}". Allowed: sqlite, mysql, vitess, postgres, singlestore.`);process.exit(ExitCode.FatalError)}const{countAppliedMigrations,regenerateMigrationCorpus}=await import("@stacksjs/database");let applied=0;try{applied=await countAppliedMigrations()}catch{applied=0}if(applied>0&&!options.force&&!options.dryRun){log.syncError(`This database already has ${applied} migration(s) recorded.`);log.syncError("Regenerating renumbers every file, and the migrations table keys on the filename,");log.syncError("so already-applied migrations would look pending and run a second time.");log.syncError(" Point at an empty database, or re-run with --force if you know it is safe.");process.exit(ExitCode.FatalError)}const plan=await regenerateMigrationCorpus({dialect:target,dryRun:!0,replaceUnmarked:options.replaceUnmarked});if(resultFailed(plan)){log.syncError(plan.error.message);process.exit(ExitCode.FatalError)}const{files,removed,preserved,preservedOutOfScope,models,modelRoots}=plan.value,outOfScope=new Set(preservedOutOfScope),protectedHistory=preserved.filter((f)=>!outOfScope.has(f)),protectedHistoryBlock=protectedHistory.length===0?"":`
|
|
45
|
+
\u2022 ${protectedHistory.length} protected history file(s) will be KEPT:
|
|
46
|
+
${protectedHistory.map((f)=>` ${f}`).join(`
|
|
47
47
|
`)}
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
48
|
+
These files either cannot be regenerated safely or follow a preserved
|
|
49
|
+
CREATE migration whose chronology must remain stable. --replace-unmarked
|
|
50
|
+
only replaces files that genuinely lack the @generated marker.`,OUT_OF_SCOPE_SHOWN=20,outOfScopeMore=preservedOutOfScope.length-OUT_OF_SCOPE_SHOWN,outOfScopeBlock=preservedOutOfScope.length===0?"":`
|
|
51
51
|
\u2022 ${preservedOutOfScope.length} file(s) describe tables this corpus does not rebuild, and will be KEPT:
|
|
52
52
|
${preservedOutOfScope.slice(0,OUT_OF_SCOPE_SHOWN).map((f)=>` ${f}`).join(`
|
|
53
53
|
`)}${outOfScopeMore>0?`
|
|
@@ -61,7 +61,7 @@ ${preservedOutOfScope.slice(0,OUT_OF_SCOPE_SHOWN).map((f)=>` ${f}`).join(`
|
|
|
61
61
|
\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
62
62
|
\u2022 ${models} model(s) read from ${rootList}
|
|
63
63
|
\u2022 ${files.length} migration file(s) will be written
|
|
64
|
-
\u2022 ${removed.length} existing file(s) will be removed${
|
|
64
|
+
\u2022 ${removed.length} existing file(s) will be removed${protectedHistoryBlock}${outOfScopeBlock}
|
|
65
65
|
\u2022 These files are tracked in git, so review with \`git diff\` afterwards
|
|
66
66
|
\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
67
67
|
`);if(options.dryRun){await outro("Dry run. Nothing was written.",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)}if(!isCI&&hasTTY&&process.stdin.isTTY){await log.flush();if(!await confirm({message:`Replace ${removed.length} migration file(s) with ${files.length} generated for ${target}?`,initial:!1})){await outro("Cancelled. Nothing was written.",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)}}const result=await regenerateMigrationCorpus({dialect:target,replaceUnmarked:options.replaceUnmarked});if(resultFailed(result)){log.syncError(result.error.message);process.exit(ExitCode.FatalError)}log.success(`Wrote ${result.value.files.length} ${target} migration file(s) to database/migrations.`);if(applied>0){const{reconcileMigrationLedger}=await import("@stacksjs/database"),fixed=await reconcileMigrationLedger();if(fixed.remapped.length>0)log.info(`Repointed ${fixed.remapped.length} ledger row(s) at their renumbered file.`);if(fixed.recorded.length>0)log.info(`Recorded ${fixed.recorded.length} migration(s) already present in the schema.`);if(fixed.skipped.length>0)log.warn(`${fixed.skipped.length} ledger entr(ies) need a look \u2014 run \`./buddy migrate:status\`.`)}log.info("Review the change with `git diff`, then run `./buddy migrate`.");await outro("Regenerated.",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("migrate:status","Compare database/migrations, the migrations ledger, and the live schema").option("--reconcile","Repair the ledger where the schema proves what happened",{default:!1}).option("--include-partial","With --reconcile, also record half-applied migrations",{default:!1}).option("--json","Emit the audit as JSON",{default:!1}).action(async(options)=>{const perf=options.json?void 0:await intro("buddy migrate:status"),{auditMigrationLedger,reconcileMigrationLedger}=await import("@stacksjs/database"),audit=await auditMigrationLedger();if(options.json){console.log(JSON.stringify(audit,(_k,v)=>v instanceof Set?[...v]:v,2));process.exit(audit.drift?ExitCode.FatalError:ExitCode.Success)}if(!audit.supported){log.info(`Dialect "${audit.dialect}" is not audited. Nothing to compare.`);await outro("Skipped.",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)}const{counts,entries,orphans}=audit,list=(status)=>entries.filter((e)=>e.status===status).map((e)=>e.file),report=[],section=(heading,files)=>{if(files.length===0)return;if(heading)report.push(` ${heading}`);for(const file of files.slice(0,8))report.push(` ${file}`);if(files.length>8)report.push(` \u2026 +${files.length-8} more`);report.push("")};report.push("");report.push(` Migration status: ${audit.dialect}`);report.push(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");report.push(` ${entries.length} file(s) on disk \xB7 ${audit.recordedCount} recorded in the ledger`);report.push("");if(counts.applied>0)report.push(` ${counts.applied} applied - recorded, and present in the schema.`,"");if(counts.unverifiable>0)report.push(` ${counts.unverifiable} unverifiable - data migrations with no schema trace to check.`,"");section(`${counts.pending} pending - not applied yet, will run on the next \`buddy migrate\`:`,list("pending"));if(counts.stranded>0){report.push(` ${counts.stranded} STRANDED - already applied to the schema, but missing from the ledger.`);report.push(" These re-run on the next `buddy migrate`, which is unsafe for anything not idempotent.");section("",list("stranded"))}section(`${counts.partial} PARTIAL - some effects present, some missing. Needs a human:`,list("partial"));section(`${counts.reverted} REVERTED - recorded as applied, but the effects are gone from the schema:`,list("reverted"));if(orphans.length>0)section(`${orphans.length} orphaned ledger row(s) - recorded, but no such file on disk:`,orphans.map((o)=>`${o.migration}${o.renamedTo?` -> renumbered to ${o.renamedTo}`:" (no counterpart; migration deleted?)"}`));console.log(report.join(`
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{existsSync}from"node:fs";import{homedir}from"node:os";import{dirname,join}from"node:path";import process from"node:process";import{installRequestContext,parseCookieHeader}from"@stacksjs/config";import{log}from"@stacksjs/logging";function productionRequestSnapshot(){const{__stxServeContext:snapshot,__stxServeCookies:legacyCookies,__stxServeSearch:legacySearch}=globalThis;if(!snapshot&&!legacyCookies&&legacySearch===void 0)return;return{...snapshot,cookies:snapshot?.cookies??legacyCookies??{},url:snapshot?.url||snapshot?.search||legacySearch||"",search:snapshot?.search??legacySearch??""}}installRequestContext(productionRequestSnapshot);function parseCookies(req){return parseCookieHeader(req.headers.get("cookie"))}export function resolveUserPartialsPath(cwd=process.cwd(),configuredDir){if(configuredDir){const configured=[join(cwd,"resources",configuredDir),join(cwd,configuredDir)].find((candidate)=>existsSync(candidate));if(configured)return configured}const existing=["resources/partials","resources/views/partials","partials","resources/components"].filter((candidate)=>existsSync(join(cwd,candidate)));if(existing.length===0)return;const populated=existing.find((candidate)=>containsTemplates(join(cwd,candidate)));return join(cwd,populated??existing[0])}function containsTemplates(dir){try{return[...new Bun.Glob("**/*.stx").scanSync({cwd:dir,onlyFiles:!0})].length>0}catch{return!1}}export async function loadStxPartialsDir(cwd=process.cwd()){const configPath=join(cwd,"config/stx.ts");if(!existsSync(configPath))return;try{const dir=(await import(configPath)).default?.partialsDir;return typeof dir==="string"&&dir.length>0?dir:void 0}catch{return}}function resolveCsrfMiddlewarePath(){const rel="app/Middleware/Csrf.ts",vendored=join(process.cwd(),"storage/framework/defaults",rel);if(existsSync(vendored))return vendored;try{const pkgJson=Bun.resolveSync("@stacksjs/defaults/package.json",process.cwd());return`${pkgJson.slice(0,pkgJson.lastIndexOf("/"))}/${rel}`}catch{return vendored}}export function resolveApiBase(configuredPort,env=process.env){if(env.API_URL)return env.API_URL;const explicitPort=Number(env.PORT_API);if(explicitPort)return`http://127.0.0.1:${explicitPort}`;if(["production","staging","development"].includes((env.APP_ENV||"").toLowerCase()))return null;return`http://127.0.0.1:${configuredPort||3008}`}export async function startProductionServer(options){if(options?.port)process.env.PORT=String(options.port);process.env.APP_ENV=process.env.APP_ENV||"production";const port=Number(process.env.PORT)||3000,{config,overridesReady,resolveViewPatterns}=await import("@stacksjs/config");await overridesReady;const{describeApiProxyRules,describeRedirectRules,injectGlobalAutoImports,resolveApiProxyRules,resolveRedirectRules}=await import("@stacksjs/server"),{resolveDefaultsResources}=await import("@stacksjs/actions/dev/defaults-resources"),{stxPageAuthMiddleware}=await import("@stacksjs/auth");await injectGlobalAutoImports();let stxServe;const serveCandidates=[join(homedir(),"Code/Tools/stx/packages/bun-plugin/dist/serve.js"),join(process.cwd(),"pantry/bun-plugin-stx/dist/serve.js")];for(const entry of serveCandidates)try{if(existsSync(entry)){({serve:stxServe}=await import(entry));break}}catch{}if(!stxServe)({serve:stxServe}=await import("bun-plugin-stx/serve"));const stxModule=await resolveVendoredStxModule(),{site:siteConfig,i18n:i18nConfig}=await loadStxSiteConfig(),userViewsPath="resources/views",defaultsResources=resolveDefaultsResources(),defaultViewsPath=join(defaultsResources,"views"),userLayoutsPath=existsSync("resources/views/layouts")?"resources/views/layouts":"resources/layouts",userPartialsPath=resolveUserPartialsPath(process.cwd(),await loadStxPartialsDir()),apiBase=resolveApiBase(config.ports?.api),viewPatterns=resolveViewPatterns(userViewsPath,defaultViewsPath,config?.ui?.defaultViews);for(const name of viewPatterns.missing)log.warn(`ui.defaultViews lists "${name}", which does not exist under ${defaultViewsPath} \u2014 ignoring.`);const apiProxyRules=resolveApiProxyRules(config.server?.proxy);if(apiProxyRules.paths.length>0||apiProxyRules.prefixes.length>1)log.info(`API proxy: ${describeApiProxyRules(apiProxyRules)}`);const redirectRules=resolveRedirectRules(config.server?.redirects);if(redirectRules.size>0)log.info(`Redirects: ${describeRedirectRules(redirectRules)}`);log.info(`Starting production server on port ${port}...`);await stxServe({patterns:viewPatterns.patterns,port,autoIncrementPort:!1,reusePort:["production","staging","development"].includes((process.env.APP_ENV||"").toLowerCase()),componentsDir:join(defaultsResources,"components"),layoutsDir:userLayoutsPath,...userPartialsPath&&{partialsDir:userPartialsPath},fallbackLayoutsDir:join(defaultsResources,"layouts"),fallbackPartialsDir:defaultViewsPath,quiet:options?.verbose!==!0,...stxModule&&{stxModule},...i18nConfig&&{i18n:i18nConfig},...siteConfig?.url&&{site:siteConfig},middleware:stxPageAuthMiddleware(),onRequest:async(req)=>{const{maintenanceGate,isApiBoundRequest:isApiBound,proxyToBackend,resolveRedirect}=await import("@stacksjs/server"),gated=await maintenanceGate(req);if(gated)return gated;const url=new URL(req.url),redirected=resolveRedirect(url,redirectRules);if(redirected)return redirected;if(isApiBound(req,url.pathname,apiProxyRules)){if(!apiBase){log.error(`No API target configured for ${url.pathname}. This app shares its host with other `+"deployments, so there is no safe default port to guess \u2014 refusing to proxy. Set "+"PORT_API (or API_URL) for this site, and deploy an `api` site on its own port.");return new Response("Bad Gateway",{status:502})}try{return await proxyToBackend(req,apiBase)}catch(error){log.error(`API proxy to ${apiBase} failed: ${error.message}`);return new Response("Bad Gateway",{status:502})}}if(existsSync(join(process.cwd(),"resources/views/blog.stx"))){const{renderBlogFeed}=await import("@stacksjs/actions/blog"),feed=await renderBlogFeed(req);if(feed)return feed}globalThis.__stxServeSearch=url.search;globalThis.__stxServeCookies=parseCookies(req);return},onResponse:async(req,response)=>{const method=req.method.toUpperCase();if(method!=="GET"&&method!=="HEAD")return;try{const{seedCsrfCookieIfMissing}=await import(resolveCsrfMiddlewarePath());return seedCsrfCookieIfMissing(req,response)}catch(error){log.debug(`CSRF cookie seeding skipped: ${error.message}`)}}});log.success(`Production server listening on http://0.0.0.0:${port}`)}async function resolveVendoredStxModule(){const candidates=[join(homedir(),"Code/Tools/stx/packages/stx/dist/index.js"),join(process.cwd(),"pantry/@stacksjs/stx/dist/index.js")];for(const entry of candidates)try{if(existsSync(entry))return await import(entry)}catch{}try{return await import("@stacksjs/stx")}catch{}return}function fallbackI18nFromSite(site){const locales=site.i18n.locales,defaultLocale=site.i18n.defaultLocale??locales[0];return{locales,defaultLocale,labels:site.i18n.labels??Object.fromEntries(locales.map((c)=>[c,c.toUpperCase()])),translations:{},pickerSelector:site.i18n.pickerSelector??"#lang-picker"}}async function resolveSiteI18n(site){const resolverPaths=[join(homedir(),"Code/Tools/stx/packages/stx/src/site-builder/i18n.ts"),join(homedir(),"Code/Tools/stx/packages/stx/dist/index.js"),join(process.cwd(),"pantry/@stacksjs/stx/dist/index.js")];for(const resolverPath of resolverPaths)try{if(!existsSync(resolverPath))continue;const resolved=await import(resolverPath);if(typeof resolved.resolveI18n!=="function")continue;const i18n=resolved.resolveI18n(site,process.cwd());if(i18n)return i18n}catch{}try{const resolved=await import("@stacksjs/stx");if(typeof resolved.resolveI18n==="function"){const i18n=resolved.resolveI18n(site,process.cwd());if(i18n)return i18n}}catch{}return fallbackI18nFromSite(site)}async function loadStxSiteConfig(){const sitePath=join(process.cwd(),"site.config.ts");if(!existsSync(sitePath))return{};try{const site=(await import(sitePath)).default;if(!site)return{};if(!site.i18n)return{site};const i18n=await resolveSiteI18n(site);return{site,i18n}}catch{}return{}}
|
|
1
|
+
import{existsSync}from"node:fs";import{homedir}from"node:os";import{dirname,join}from"node:path";import process from"node:process";import{installRequestContext,parseCookieHeader}from"@stacksjs/config";import{log}from"@stacksjs/logging";function productionRequestSnapshot(){const{__stxServeContext:snapshot,__stxServeCookies:legacyCookies,__stxServeSearch:legacySearch,__stxServeSite:site}=globalThis;if(!snapshot&&!legacyCookies&&legacySearch===void 0)return;return{...snapshot,cookies:snapshot?.cookies??legacyCookies??{},url:snapshot?.url||snapshot?.search||legacySearch||"",search:snapshot?.search??legacySearch??"",site:snapshot?.site??site??null}}installRequestContext(productionRequestSnapshot);function parseCookies(req){return parseCookieHeader(req.headers.get("cookie"))}export function resolveUserPartialsPath(cwd=process.cwd(),configuredDir){if(configuredDir){const configured=[join(cwd,"resources",configuredDir),join(cwd,configuredDir)].find((candidate)=>existsSync(candidate));if(configured)return configured}const existing=["resources/partials","resources/views/partials","partials","resources/components"].filter((candidate)=>existsSync(join(cwd,candidate)));if(existing.length===0)return;const populated=existing.find((candidate)=>containsTemplates(join(cwd,candidate)));return join(cwd,populated??existing[0])}function containsTemplates(dir){try{return[...new Bun.Glob("**/*.stx").scanSync({cwd:dir,onlyFiles:!0})].length>0}catch{return!1}}export async function loadStxPartialsDir(cwd=process.cwd()){const configPath=join(cwd,"config/stx.ts");if(!existsSync(configPath))return;try{const dir=(await import(configPath)).default?.partialsDir;return typeof dir==="string"&&dir.length>0?dir:void 0}catch{return}}function resolveCsrfMiddlewarePath(){const rel="app/Middleware/Csrf.ts",vendored=join(process.cwd(),"storage/framework/defaults",rel);if(existsSync(vendored))return vendored;try{const pkgJson=Bun.resolveSync("@stacksjs/defaults/package.json",process.cwd());return`${pkgJson.slice(0,pkgJson.lastIndexOf("/"))}/${rel}`}catch{return vendored}}export function resolveApiBase(configuredPort,env=process.env){if(env.API_URL)return env.API_URL;const explicitPort=Number(env.PORT_API);if(explicitPort)return`http://127.0.0.1:${explicitPort}`;if(["production","staging","development"].includes((env.APP_ENV||"").toLowerCase()))return null;return`http://127.0.0.1:${configuredPort||3008}`}export async function startProductionServer(options){if(options?.port)process.env.PORT=String(options.port);process.env.APP_ENV=process.env.APP_ENV||"production";const port=Number(process.env.PORT)||3000,{config,overridesReady,resolveViewPatterns}=await import("@stacksjs/config");await overridesReady;const{describeApiProxyRules,describeRedirectRules,injectGlobalAutoImports,resolveApiProxyRules,resolveRedirectRules}=await import("@stacksjs/server"),{resolveDefaultsResources}=await import("@stacksjs/actions/dev/defaults-resources"),{stxPageAuthMiddleware}=await import("@stacksjs/auth");await injectGlobalAutoImports();let stxServe;const serveCandidates=[join(homedir(),"Code/Tools/stx/packages/bun-plugin/dist/serve.js"),join(process.cwd(),"pantry/bun-plugin-stx/dist/serve.js")];for(const entry of serveCandidates)try{if(existsSync(entry)){({serve:stxServe}=await import(entry));break}}catch{}if(!stxServe)({serve:stxServe}=await import("bun-plugin-stx/serve"));const stxModule=await resolveVendoredStxModule(),{site:siteConfig,i18n:i18nConfig}=await loadStxSiteConfig(),userViewsPath="resources/views",defaultsResources=resolveDefaultsResources(),defaultViewsPath=join(defaultsResources,"views"),userLayoutsPath=existsSync("resources/views/layouts")?"resources/views/layouts":"resources/layouts",userPartialsPath=resolveUserPartialsPath(process.cwd(),await loadStxPartialsDir()),apiBase=resolveApiBase(config.ports?.api),viewPatterns=resolveViewPatterns(userViewsPath,defaultViewsPath,config?.ui?.defaultViews);for(const name of viewPatterns.missing)log.warn(`ui.defaultViews lists "${name}", which does not exist under ${defaultViewsPath} \u2014 ignoring.`);const apiProxyRules=resolveApiProxyRules(config.server?.proxy);if(apiProxyRules.paths.length>0||apiProxyRules.prefixes.length>1)log.info(`API proxy: ${describeApiProxyRules(apiProxyRules)}`);const redirectRules=resolveRedirectRules(config.server?.redirects);if(redirectRules.size>0)log.info(`Redirects: ${describeRedirectRules(redirectRules)}`);log.info(`Starting production server on port ${port}...`);await stxServe({patterns:viewPatterns.patterns,port,autoIncrementPort:!1,reusePort:["production","staging","development"].includes((process.env.APP_ENV||"").toLowerCase()),componentsDir:join(defaultsResources,"components"),layoutsDir:userLayoutsPath,...userPartialsPath&&{partialsDir:userPartialsPath},fallbackLayoutsDir:join(defaultsResources,"layouts"),fallbackPartialsDir:defaultViewsPath,quiet:options?.verbose!==!0,...stxModule&&{stxModule},...i18nConfig&&{i18n:i18nConfig},...siteConfig?.url&&{site:siteConfig},middleware:stxPageAuthMiddleware(),onRequest:async(req)=>{const{maintenanceGate,isApiBoundRequest:isApiBound,proxyToBackend,resolveRedirect}=await import("@stacksjs/server"),gated=await maintenanceGate(req);if(gated)return gated;const url=new URL(req.url),redirected=resolveRedirect(url,redirectRules);if(redirected)return redirected;if(isApiBound(req,url.pathname,apiProxyRules)){if(!apiBase){log.error(`No API target configured for ${url.pathname}. This app shares its host with other `+"deployments, so there is no safe default port to guess \u2014 refusing to proxy. Set "+"PORT_API (or API_URL) for this site, and deploy an `api` site on its own port.");return new Response("Bad Gateway",{status:502})}try{return await proxyToBackend(req,apiBase)}catch(error){log.error(`API proxy to ${apiBase} failed: ${error.message}`);return new Response("Bad Gateway",{status:502})}}if(existsSync(join(process.cwd(),"resources/views/blog.stx"))){const{renderBlogFeed}=await import("@stacksjs/actions/blog"),feed=await renderBlogFeed(req);if(feed)return feed}globalThis.__stxServeSearch=url.search;globalThis.__stxServeCookies=parseCookies(req);if(config.analytics?.capturePageviews)import("@stacksjs/analytics").then(({recordPageview})=>recordPageview(req)).catch(()=>{});if(config.sites?.enabled){const sites=await import("@stacksjs/sites"),resolved=await sites.resolveSiteByHost(sites.requestHost(req.headers,sites.sitesOptions()));sites.setCurrentSite(resolved);globalThis.__stxServeSite=sites.toSiteSnapshot(resolved)}else globalThis.__stxServeSite=null;return},onResponse:async(req,response)=>{const method=req.method.toUpperCase();if(method!=="GET"&&method!=="HEAD")return;let current=response;if(response.status===404&&config.sites?.enabled)try{const{cmsNotFoundFallback}=await import("@stacksjs/cms"),cmsResponse=await cmsNotFoundFallback(req);if(cmsResponse)current=cmsResponse}catch(error){log.debug(`CMS fallback skipped: ${error.message}`)}try{const{seedCsrfCookieIfMissing}=await import(resolveCsrfMiddlewarePath());return await seedCsrfCookieIfMissing(req,current)??(current===response?void 0:current)}catch(error){log.debug(`CSRF cookie seeding skipped: ${error.message}`);return current===response?void 0:current}}});log.success(`Production server listening on http://0.0.0.0:${port}`)}async function resolveVendoredStxModule(){const candidates=[join(homedir(),"Code/Tools/stx/packages/stx/dist/index.js"),join(process.cwd(),"pantry/@stacksjs/stx/dist/index.js")];for(const entry of candidates)try{if(existsSync(entry))return await import(entry)}catch{}try{return await import("@stacksjs/stx")}catch{}return}function fallbackI18nFromSite(site){const locales=site.i18n.locales,defaultLocale=site.i18n.defaultLocale??locales[0];return{locales,defaultLocale,labels:site.i18n.labels??Object.fromEntries(locales.map((c)=>[c,c.toUpperCase()])),translations:{},pickerSelector:site.i18n.pickerSelector??"#lang-picker"}}async function resolveSiteI18n(site){const resolverPaths=[join(homedir(),"Code/Tools/stx/packages/stx/src/site-builder/i18n.ts"),join(homedir(),"Code/Tools/stx/packages/stx/dist/index.js"),join(process.cwd(),"pantry/@stacksjs/stx/dist/index.js")];for(const resolverPath of resolverPaths)try{if(!existsSync(resolverPath))continue;const resolved=await import(resolverPath);if(typeof resolved.resolveI18n!=="function")continue;const i18n=resolved.resolveI18n(site,process.cwd());if(i18n)return i18n}catch{}try{const resolved=await import("@stacksjs/stx");if(typeof resolved.resolveI18n==="function"){const i18n=resolved.resolveI18n(site,process.cwd());if(i18n)return i18n}}catch{}return fallbackI18nFromSite(site)}async function loadStxSiteConfig(){const sitePath=join(process.cwd(),"site.config.ts");if(!existsSync(sitePath))return{};try{const site=(await import(sitePath)).default;if(!site)return{};if(!site.i18n)return{site};const i18n=await resolveSiteI18n(site);return{site,i18n}}catch{}return{}}
|
package/dist/workflow-prune.js
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
const FRAMEWORK_ONLY_PATH=/storage\/framework\/(?:core|scripts\/publish-commit)/,FRAMEWORK_ONLY_SCRIPTS=new Set(["docs:buddy","docs:buddy:check","docs:artifacts","docs:artifacts:check","docs:links","docs:links:check"]);function invocations(line){return[...line.matchAll(/(?:bun\s+run|bun\s+buddy|\.\/buddy|bunx?\s+buddy)\s+["']?([\w:.-]+)/g)].map((match)=>match[1])}function referencesCore(line){if(line.trim().startsWith("#"))return!1;const code=line.replace(/\s#.*$/,"");return FRAMEWORK_ONLY_PATH.test(code)||invocations(code).some((name)=>FRAMEWORK_ONLY_SCRIPTS.has(name))}export const frameworkOnlyScripts=[...FRAMEWORK_ONLY_SCRIPTS];function jobStarts(lines){const out=[];let inJobs=!1;for(const[at,line]of lines.entries()){if(/^jobs:\s*$/.test(line)){inJobs=!0;continue}if(!inJobs)continue;if(/^\S/.test(line)&&!/^\s*#/.test(line))break;const match=line.match(/^ {2}([A-Za-z_][\w-]*):\s*$/);if(match)out.push({name:match[1],at})}return out}function blockEnd(lines,from,indent){const sibling=new RegExp(`^ {${indent}}(?:- |[A-Za-z_"'])`);for(let at=from+1;at<lines.length;at++){const line=lines[at];if(line.trim()==="")continue;const leading=line.length-line.trimStart().length;if(leading<indent)return at;if(leading===indent&&sibling.test(line))return at}return lines.length}const SETUP_STEP=[/uses:\s*actions\/checkout/,/uses:\s*actions\/cache/,/uses:\s*actions\/setup-/,/uses:\s*pantry-pm\/pantry/,/run:\s*(?:bun|pantry|npm|pnpm|yarn)\s+(?:install|ci)\b/];function isSetupStep(step){const code=step.filter((line)=>!line.trim().startsWith("#"));return SETUP_STEP.some((pattern)=>code.some((line)=>pattern.test(line)))}function withLeadingComments(lines,at){let start=at;while(start-1>=0){const previous=lines[start-1].trim();if(previous.startsWith("#")||previous==="")start--;else break}while(start<at&&lines[start].trim()==="")start++;return start}export function pruneVendoredCoreFromWorkflow(source){let lines=source.split(`
|
|
2
2
|
`);const removedJobs=[];let removedSteps=0;for(const job of jobStarts(lines).reverse()){const end=blockEnd(lines,job.at,2),body=lines.slice(job.at,end),steps=[];for(const[offset,line]of body.entries()){if(!/^ {6}- /.test(line))continue;const at=job.at+offset,stepEnd=blockEnd(lines,at,6),step=lines.slice(at,stepEnd);steps.push({at,end:stepEnd,core:step.some(referencesCore),setup:isSetupStep(step)})}if(steps.length===0||!steps.some((step)=>step.core))continue;if(steps.filter((step)=>!step.setup).every((step)=>step.core)){lines.splice(withLeadingComments(lines,job.at),end-withLeadingComments(lines,job.at));removedJobs.push(job.name);continue}for(const step of[...steps].reverse()){if(!step.core)continue;const from=withLeadingComments(lines,step.at);lines.splice(from,step.end-from);removedSteps++}}if(removedJobs.length>0)lines=lines.map((line)=>{const inline=line.match(/^(\s*needs:\s*)\[([^\]]*)\]\s*$/);if(inline){const kept=inline[2].split(",").map((name)=>name.trim()).filter((name)=>name&&!removedJobs.includes(name));return kept.length>0?`${inline[1]}[${kept.join(", ")}]`:""}const scalar=line.match(/^(\s*)needs:\s*([A-Za-z_][\w-]*)\s*$/);if(scalar&&removedJobs.includes(scalar[2]))return"";return line}).filter((line,at,all)=>!(line===""&&all[at-1]===""&&all[at+1]===""));return{yaml:lines.join(`
|
|
3
|
-
`),removedJobs,removedSteps}}export async function pruneVendoredCoreFromWorkflows(cwd){const{readdir,readFile,writeFile}=await import("node:fs/promises"),{join}=await import("node:path"),dir=join(cwd,".github","workflows"),pruned=[];let entries;try{entries=await readdir(dir)}catch{return pruned}for(const entry of entries.sort()){if(!/\.ya?ml$/.test(entry))continue;const file=join(dir,entry);try{const source=await readFile(file,"utf-8"),result=pruneVendoredCoreFromWorkflow(source);if(result.yaml===source)continue;await writeFile(file,result.yaml);pruned.push({file:`.github/workflows/${entry}`,removedJobs:result.removedJobs,removedSteps:result.removedSteps})}catch{continue}}return pruned}export function splitFrameworkTypecheckScript(scripts){if(!scripts.typecheck?.includes("tsconfig.framework.json")||!scripts["typecheck:app"])return null;return Object.fromEntries(Object.entries(scripts).flatMap(([name,script])=>name==="typecheck"?[["typecheck","bun run typecheck:app && bun run typecheck:framework"],["typecheck:framework",script]]:[[name,script]]))}
|
|
3
|
+
`),removedJobs,removedSteps}}export async function pruneVendoredCoreFromWorkflows(cwd){const{readdir,readFile,writeFile}=await import("node:fs/promises"),{join}=await import("node:path"),dir=join(cwd,".github","workflows"),pruned=[];let entries;try{entries=await readdir(dir)}catch{return pruned}for(const entry of entries.sort()){if(!/\.ya?ml$/.test(entry))continue;const file=join(dir,entry);try{const source=await readFile(file,"utf-8"),result=pruneVendoredCoreFromWorkflow(source);if(result.yaml===source)continue;await writeFile(file,result.yaml);pruned.push({file:`.github/workflows/${entry}`,removedJobs:result.removedJobs,removedSteps:result.removedSteps})}catch{continue}}return pruned}export function splitFrameworkTypecheckScript(scripts){if(!scripts.typecheck?.includes("tsconfig.framework.json")||!scripts["typecheck:app"])return null;return Object.fromEntries(Object.entries(scripts).flatMap(([name,script])=>name==="typecheck"?[["typecheck","bun run typecheck:app && bun run typecheck:framework"],["typecheck:framework",dropVendoredCoreSegments(script)]]:[[name,script]]))}function dropVendoredCoreSegments(script){const kept=splitTopLevelAnd(script).map((segment)=>segment.trim()).filter((segment)=>segment&&!segment.includes("storage/framework/core"));return kept.length>0?kept.join(" && "):script}function splitTopLevelAnd(script){const segments=[];let depth=0,current="";for(let i=0;i<script.length;i++){const char=script[i];if(char==="(")depth++;else if(char===")")depth=Math.max(0,depth-1);if(depth===0&&char==="&"&&script[i+1]==="&"){segments.push(current);current="";i++;continue}current+=char}segments.push(current);return segments}
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@stacksjs/buddy",
|
|
3
3
|
"type": "module",
|
|
4
4
|
"sideEffects": false,
|
|
5
|
-
"version": "0.
|
|
5
|
+
"version": "0.71.2",
|
|
6
6
|
"description": "Meet Buddy. The Stacks runtime.",
|
|
7
7
|
"author": "Chris Breuer",
|
|
8
8
|
"contributors": [
|
|
@@ -95,60 +95,62 @@
|
|
|
95
95
|
"prepublishOnly": "bun run build"
|
|
96
96
|
},
|
|
97
97
|
"dependencies": {
|
|
98
|
-
"@stacksjs/actions": "^0.
|
|
99
|
-
"@stacksjs/ai": "^0.
|
|
100
|
-
"@stacksjs/alias": "^0.
|
|
101
|
-
"@stacksjs/arrays": "^0.
|
|
102
|
-
"@stacksjs/auth": "^0.
|
|
103
|
-
"@stacksjs/build": "^0.
|
|
104
|
-
"@stacksjs/cache": "^0.
|
|
105
|
-
"@stacksjs/cli": "^0.
|
|
98
|
+
"@stacksjs/actions": "^0.71.2",
|
|
99
|
+
"@stacksjs/ai": "^0.71.2",
|
|
100
|
+
"@stacksjs/alias": "^0.71.2",
|
|
101
|
+
"@stacksjs/arrays": "^0.71.2",
|
|
102
|
+
"@stacksjs/auth": "^0.71.2",
|
|
103
|
+
"@stacksjs/build": "^0.71.2",
|
|
104
|
+
"@stacksjs/cache": "^0.71.2",
|
|
105
|
+
"@stacksjs/cli": "^0.71.2",
|
|
106
106
|
"@stacksjs/clapp": "^0.2.12",
|
|
107
|
-
"@stacksjs/cloud": "^0.
|
|
108
|
-
"@stacksjs/collections": "^0.
|
|
109
|
-
"@stacksjs/config": "^0.
|
|
110
|
-
"@stacksjs/database": "^0.
|
|
111
|
-
"@stacksjs/desktop-build": "^0.
|
|
112
|
-
"@stacksjs/dns": "^0.
|
|
113
|
-
"@stacksjs/email": "^0.
|
|
114
|
-
"@stacksjs/enums": "^0.
|
|
115
|
-
"@stacksjs/error-handling": "^0.
|
|
116
|
-
"@stacksjs/events": "^0.
|
|
117
|
-
"@stacksjs/git": "^0.
|
|
107
|
+
"@stacksjs/cloud": "^0.71.2",
|
|
108
|
+
"@stacksjs/collections": "^0.71.2",
|
|
109
|
+
"@stacksjs/config": "^0.71.2",
|
|
110
|
+
"@stacksjs/database": "^0.71.2",
|
|
111
|
+
"@stacksjs/desktop-build": "^0.71.2",
|
|
112
|
+
"@stacksjs/dns": "^0.71.2",
|
|
113
|
+
"@stacksjs/email": "^0.71.2",
|
|
114
|
+
"@stacksjs/enums": "^0.71.2",
|
|
115
|
+
"@stacksjs/error-handling": "^0.71.2",
|
|
116
|
+
"@stacksjs/events": "^0.71.2",
|
|
117
|
+
"@stacksjs/git": "^0.71.2",
|
|
118
118
|
"@stacksjs/gitit": "^0.2.5",
|
|
119
|
-
"@stacksjs/health": "^0.
|
|
119
|
+
"@stacksjs/health": "^0.71.2",
|
|
120
120
|
"@stacksjs/dnsx": "^0.2.3",
|
|
121
121
|
"@stacksjs/httx": "^0.1.10",
|
|
122
|
-
"@stacksjs/image": "^0.
|
|
123
|
-
"@stacksjs/lint": "^0.
|
|
124
|
-
"@stacksjs/logging": "^0.
|
|
125
|
-
"@stacksjs/notifications": "^0.
|
|
126
|
-
"@stacksjs/objects": "^0.
|
|
127
|
-
"@stacksjs/orm": "^0.
|
|
128
|
-
"@stacksjs/path": "^0.
|
|
129
|
-
"@stacksjs/skills": "^0.
|
|
130
|
-
"@stacksjs/payments": "^0.
|
|
131
|
-
"@stacksjs/realtime": "^0.
|
|
132
|
-
"@stacksjs/router": "^0.
|
|
122
|
+
"@stacksjs/image": "^0.71.2",
|
|
123
|
+
"@stacksjs/lint": "^0.71.2",
|
|
124
|
+
"@stacksjs/logging": "^0.71.2",
|
|
125
|
+
"@stacksjs/notifications": "^0.71.2",
|
|
126
|
+
"@stacksjs/objects": "^0.71.2",
|
|
127
|
+
"@stacksjs/orm": "^0.71.2",
|
|
128
|
+
"@stacksjs/path": "^0.71.2",
|
|
129
|
+
"@stacksjs/skills": "^0.71.2",
|
|
130
|
+
"@stacksjs/payments": "^0.71.2",
|
|
131
|
+
"@stacksjs/realtime": "^0.71.2",
|
|
132
|
+
"@stacksjs/router": "^0.71.2",
|
|
133
133
|
"@stacksjs/rpx": "^0.11.42",
|
|
134
|
-
"@stacksjs/search-engine": "^0.
|
|
135
|
-
"@stacksjs/security": "^0.
|
|
136
|
-
"@stacksjs/server": "^0.
|
|
137
|
-
"@stacksjs/
|
|
138
|
-
"@stacksjs/
|
|
139
|
-
"@stacksjs/
|
|
140
|
-
"@stacksjs/
|
|
141
|
-
"@stacksjs/
|
|
142
|
-
"@stacksjs/
|
|
143
|
-
"@stacksjs/
|
|
144
|
-
"@stacksjs/
|
|
145
|
-
"@stacksjs/
|
|
134
|
+
"@stacksjs/search-engine": "^0.71.2",
|
|
135
|
+
"@stacksjs/security": "^0.71.2",
|
|
136
|
+
"@stacksjs/server": "^0.71.2",
|
|
137
|
+
"@stacksjs/cms": "^0.71.2",
|
|
138
|
+
"@stacksjs/sites": "^0.71.2",
|
|
139
|
+
"@stacksjs/storage": "^0.71.2",
|
|
140
|
+
"@stacksjs/strings": "^0.71.2",
|
|
141
|
+
"@stacksjs/testing": "^0.71.2",
|
|
142
|
+
"@stacksjs/tunnel": "^0.71.2",
|
|
143
|
+
"@stacksjs/types": "^0.71.2",
|
|
144
|
+
"@stacksjs/ui": "^0.71.2",
|
|
145
|
+
"@stacksjs/utils": "^0.71.2",
|
|
146
|
+
"@stacksjs/validation": "^0.71.2",
|
|
147
|
+
"@stacksjs/ts-cloud": "^0.8.3",
|
|
146
148
|
"ajv": "^8.20.0",
|
|
147
149
|
"ajv-formats": "^3.0.1",
|
|
148
|
-
"ts-pantry": "^0.11.
|
|
150
|
+
"ts-pantry": "^0.11.27"
|
|
149
151
|
},
|
|
150
152
|
"devDependencies": {
|
|
151
|
-
"better-dx": "^0.2.
|
|
153
|
+
"better-dx": "^0.2.23"
|
|
152
154
|
},
|
|
153
155
|
"web-types": "./web-types.json"
|
|
154
156
|
}
|