@stacksjs/buddy 0.75.371 → 0.75.373
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.d.ts +5 -0
- package/dist/commands/deploy.js +6 -6
- package/package.json +56 -56
|
@@ -333,6 +333,8 @@ export declare function apiDeploymentProblem(sites: Record<string, any>, hasApiR
|
|
|
333
333
|
* opens would be a refusal nobody can act on.
|
|
334
334
|
*/
|
|
335
335
|
export declare function siteDatabaseDrivers(sites: Record<string, any>): string[];
|
|
336
|
+
/** A multi-source deployment audits each shipped application's own migration corpus. */
|
|
337
|
+
export declare function siteDatabaseCorpora(sites: Record<string, any>, projectRoot: string): Array<{ root: string, driver: string }>;
|
|
336
338
|
/**
|
|
337
339
|
* The way THIS site invokes buddy, taken from its own migrate step: everything
|
|
338
340
|
* ahead of the migrate subcommand, when what it lands on is a buddy entrypoint.
|
|
@@ -477,6 +479,7 @@ export declare function mailServerOwnerFromConfig(config: { server?: { attachTo?
|
|
|
477
479
|
* (stacksjs/stacks#2344). One resolver, so the two cannot drift again.
|
|
478
480
|
*/
|
|
479
481
|
export declare function resolveHetznerApiToken(tsCloudConfig?: TsCloudConfig): string | undefined;
|
|
482
|
+
export declare function assertAttachResidency(policy: { countries: string[], locations?: string[] }, box: Pick<AttachTargetBox, 'country' | 'location'>): void;
|
|
480
483
|
/**
|
|
481
484
|
* What to tell the operator when no box came back.
|
|
482
485
|
*
|
|
@@ -842,6 +845,8 @@ export declare interface MailTenantResult {
|
|
|
842
845
|
* mean either lying about the pin or re-checking it at every use.
|
|
843
846
|
*/
|
|
844
847
|
export declare interface AttachTargetBox {
|
|
848
|
+
country?: string
|
|
849
|
+
location?: string
|
|
845
850
|
serverId?: number
|
|
846
851
|
serverName: string
|
|
847
852
|
publicIp?: string
|
package/dist/commands/deploy.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import{existsSync,mkdirSync,mkdtempSync,readFileSync,readdirSync,rmSync,statSync,writeFileSync}from"node:fs";import{homedir}from"node:os";import{dirname,isAbsolute,join,relative,resolve}from"node:path";import process from"node:process";import{pathToFileURL}from"node:url";import{runAction}from"@stacksjs/actions/run-action";import{assertPublicationAllowed}from"@stacksjs/actions/publication-preflight";import{runBeforeDeployHook}from"@stacksjs/actions/deploy/hooks";import{italic,onUnknownSubcommand,outro,prompts}from"@stacksjs/cli";import{app,dns as dnsConfig,email as emailConfig}from"@stacksjs/config";import{addDomain,hasUserDomainBeenAddedToCloud,syncDnsConfig}from"@stacksjs/dns";import{loadProjectDnsConfig}from"../config";import{env}from"@stacksjs/env";import{Action}from"@stacksjs/enums";import{path as p}from"@stacksjs/path";import{ExitCode}from"@stacksjs/types";import{getErrorCode,getErrorMessage}from"@stacksjs/utils";import{withDeployNotification}from"../deploy-notify";import{assertCurrentDeploymentRevision,assertNotBehindLiveRelease,automatedDeploymentRevision,liveReleasesCommand}from"../deployment-revision";import{dnsCredentialFix,envFileForEnvironment,resolveZoneNameservers}from"../dns-credentials";import{resultFailed}from"../result";import{findUnbackedManagedServices,hasOffsiteBackupDestination,unbackedDataMessage}from"../unbacked-data";import{applyDeploymentDomainOverride,createDeploymentPreview,deploymentPreviewJsonPrefix,formatDeploymentPreview,resolveDeploymentEnvironment}from"./deploy-preview";import{buildPreviewTeardownScript,derivePreviewConfig,previewDomain,previewPort,previewTarget}from"./preview-deploy";import{deployTargetLabel,dnsPublishingAllowed,hetznerTarget,isSshPipelineProvider,lanUrls,mergeSshStatePin,remoteExecOptions,resolveSshTarget,sshCliArgs,sshStatePin,toSshTarget}from"./deploy-ssh-target";const log={info:(...args)=>console.log("\u2139",...args),success:(...args)=>console.log("\u2713",...args),warn:(...args)=>console.log("\u26A0",...args),error:(...args)=>console.error("\u2717",...args),debug:(...args)=>{if(process.argv.includes("--verbose")||process.argv.includes("-v"))console.log("\uD83D\uDD0D",...args)}},MAIL_PACKAGE_DOMAIN="github.com/mail-os/mail",MAIL_PACKAGE_SPEC=`${MAIL_PACKAGE_DOMAIN}@0.1.0`,MAIL_TARGET_PLATFORM="linux-x86_64",MAIL_BINARY_NAMES=["mail","mail-x86_64-linux","mail-x86_64-linux-gnu"];export const ROLLBACK_FLAGS_SINCE="0.16.34";export function installedTsCloudVersion(cliPath=resolveTsCloudCliPath()){try{const pkg=JSON.parse(readFileSync(resolve(dirname(cliPath),"..","..","package.json"),"utf8"));return typeof pkg.version==="string"?pkg.version:null}catch{return null}}export function versionAtLeast(a,b){const pa=a.split(".").map((n)=>Number.parseInt(n,10)||0),pb=b.split(".").map((n)=>Number.parseInt(n,10)||0);for(let i=0;i<3;i++)if((pa[i]??0)!==(pb[i]??0))return(pa[i]??0)>(pb[i]??0);return!0}export function resolveTsCloudCliPath(tsCloudEntry=import.meta.resolve("@stacksjs/ts-cloud")){return resolve(dirname(new URL(tsCloudEntry).pathname),"bin/cli.js")}export async function runDeployRollback(site,options,execute=async(command)=>{return await Bun.spawn(command,{cwd:p.projectPath(),env:process.env,stdin:"inherit",stdout:"inherit",stderr:"inherit"}).exited},tsCloudVersion=installedTsCloudVersion()){const environment=resolveDeploymentEnvironment({option:options.env}),command=[process.execPath,resolveTsCloudCliPath(),"deploy:rollback"],newFlags=[options.dryRun&&"--dry-run",options.all&&"--all",options.onlyIfLive&&"--only-if-live"].filter(Boolean);if(newFlags.length>0&&!(tsCloudVersion&&versionAtLeast(tsCloudVersion,ROLLBACK_FLAGS_SINCE))){log.error(`${newFlags.join(", ")} needs @stacksjs/ts-cloud ${ROLLBACK_FLAGS_SINCE} or newer (installed: ${tsCloudVersion??"unknown"}). An older one ignores it and rolls back for real.`);log.info(` \u27A1\uFE0F bun add @stacksjs/ts-cloud@^${ROLLBACK_FLAGS_SINCE}`);return ExitCode.InvalidArgument}if(site)command.push(site);command.push("--env",environment);if(options.to)command.push("--to",options.to);if(options.dryRun)command.push("--dry-run");if(options.all)command.push("--all");if(options.onlyIfLive)command.push("--only-if-live",options.onlyIfLive);if(options.verbose)command.push("--verbose");return await execute(command)}function collectMatchingFiles(root,names,maxDepth=8){const nameSet=new Set(names),matches=[];if(!existsSync(root))return matches;function walk(dir,depth){if(depth>maxDepth)return;for(const entry of readdirSync(dir)){if(entry===".git"||entry==="node_modules")continue;const fullPath=join(dir,entry),stat=statSync(fullPath);if(stat.isDirectory())walk(fullPath,depth+1);else if(stat.isFile()&&nameSet.has(entry))matches.push(fullPath)}}walk(root,0);return matches}function isElfBinary(filePath){const header=readFileSync(filePath).slice(0,4);return header[0]===127&&header[1]===69&&header[2]===76&&header[3]===70}function resolvePantryInstallCommand(){const localPantryCli=join(homedir(),"Code","Tools","pantry","packages","ts-pantry","bin","cli.ts");if(existsSync(localPantryCli))return{command:"bun",args:[localPantryCli]};const projectPantry=p.projectPath("pantry/.bin/pantry");if(existsSync(projectPantry))return{command:projectPantry,args:[]};const globalPantry=join(homedir(),".local","share","pantry","global","bin","pantry");if(existsSync(globalPantry))return{command:globalPantry,args:[]};return{command:"pantry",args:[]}}async function installMailBinaryWithPantry(){const{execFileSync}=await import("node:child_process"),pantry=resolvePantryInstallCommand();execFileSync(pantry.command,[...pantry.args,"install",MAIL_PACKAGE_SPEC,"--install-dir",p.projectPath("pantry"),"--platform",MAIL_TARGET_PLATFORM,"--quiet"],{cwd:p.projectPath(),stdio:process.argv.includes("--verbose")||process.argv.includes("-v")?"inherit":"pipe",env:process.env})}async function findPantryMailBinary(){const directCandidates=[...MAIL_BINARY_NAMES.map((name)=>p.projectPath(`pantry/.bin/${name}`)),...MAIL_BINARY_NAMES.map((name)=>join(homedir(),".local","share","pantry","global","bin",name))];for(const candidate of directCandidates)if(existsSync(candidate)&&isElfBinary(candidate))return candidate;for(const root of[p.projectPath("pantry"),join(homedir(),".local","share","pantry")])for(const candidate of collectMatchingFiles(root,MAIL_BINARY_NAMES))if(isElfBinary(candidate))return candidate;return null}async function ensureDeployPrerequisites(verbose=!1,environment="production"){const{ensureAppKey,ensureDeployEnvIsSet,ensureEnvIsSet}=await import("./setup"),cwd=p.projectPath();await ensureEnvIsSet({cwd,verbose});await ensureDeployEnvIsSet(cwd,environment);await ensureAppKey(cwd)}function loadAwsCredentialsFromFile(){const credentialsPath=join(homedir(),".aws","credentials"),configPath=join(homedir(),".aws","config");if(!existsSync(credentialsPath))return{};try{const lines=readFileSync(credentialsPath,"utf-8").split(`
|
|
1
|
+
import{existsSync,mkdirSync,mkdtempSync,readFileSync,readdirSync,rmSync,statSync,writeFileSync}from"node:fs";import{homedir}from"node:os";import{dirname,isAbsolute,join,relative,resolve}from"node:path";import process from"node:process";import{pathToFileURL}from"node:url";import{runAction}from"@stacksjs/actions/run-action";import{assertPublicationAllowed}from"@stacksjs/actions/publication-preflight";import{runBeforeDeployHook}from"@stacksjs/actions/deploy/hooks";import{italic,onUnknownSubcommand,outro,prompts}from"@stacksjs/cli";import{app,dns as dnsConfig,email as emailConfig}from"@stacksjs/config";import{addDomain,hasUserDomainBeenAddedToCloud,syncDnsConfig}from"@stacksjs/dns";import{loadProjectDnsConfig}from"../config";import{env}from"@stacksjs/env";import{Action}from"@stacksjs/enums";import{path as p}from"@stacksjs/path";import{ExitCode}from"@stacksjs/types";import{getErrorCode,getErrorMessage}from"@stacksjs/utils";import{withDeployNotification}from"../deploy-notify";import{assertCurrentDeploymentRevision,assertNotBehindLiveRelease,automatedDeploymentRevision,liveReleasesCommand}from"../deployment-revision";import{dnsCredentialFix,envFileForEnvironment,resolveZoneNameservers}from"../dns-credentials";import{assertComputeResidency}from"@stacksjs/ts-cloud";import{resultFailed}from"../result";import{findUnbackedManagedServices,hasOffsiteBackupDestination,unbackedDataMessage}from"../unbacked-data";import{applyDeploymentDomainOverride,createDeploymentPreview,deploymentPreviewJsonPrefix,formatDeploymentPreview,resolveDeploymentEnvironment}from"./deploy-preview";import{buildPreviewTeardownScript,derivePreviewConfig,previewDomain,previewPort,previewTarget}from"./preview-deploy";import{deployTargetLabel,dnsPublishingAllowed,hetznerTarget,isSshPipelineProvider,lanUrls,mergeSshStatePin,remoteExecOptions,resolveSshTarget,sshCliArgs,sshStatePin,toSshTarget}from"./deploy-ssh-target";const log={info:(...args)=>console.log("\u2139",...args),success:(...args)=>console.log("\u2713",...args),warn:(...args)=>console.log("\u26A0",...args),error:(...args)=>console.error("\u2717",...args),debug:(...args)=>{if(process.argv.includes("--verbose")||process.argv.includes("-v"))console.log("\uD83D\uDD0D",...args)}},MAIL_PACKAGE_DOMAIN="github.com/mail-os/mail",MAIL_PACKAGE_SPEC=`${MAIL_PACKAGE_DOMAIN}@0.1.0`,MAIL_TARGET_PLATFORM="linux-x86_64",MAIL_BINARY_NAMES=["mail","mail-x86_64-linux","mail-x86_64-linux-gnu"];export const ROLLBACK_FLAGS_SINCE="0.16.34";export function installedTsCloudVersion(cliPath=resolveTsCloudCliPath()){try{const pkg=JSON.parse(readFileSync(resolve(dirname(cliPath),"..","..","package.json"),"utf8"));return typeof pkg.version==="string"?pkg.version:null}catch{return null}}export function versionAtLeast(a,b){const pa=a.split(".").map((n)=>Number.parseInt(n,10)||0),pb=b.split(".").map((n)=>Number.parseInt(n,10)||0);for(let i=0;i<3;i++)if((pa[i]??0)!==(pb[i]??0))return(pa[i]??0)>(pb[i]??0);return!0}export function resolveTsCloudCliPath(tsCloudEntry=import.meta.resolve("@stacksjs/ts-cloud")){return resolve(dirname(new URL(tsCloudEntry).pathname),"bin/cli.js")}export async function runDeployRollback(site,options,execute=async(command)=>{return await Bun.spawn(command,{cwd:p.projectPath(),env:process.env,stdin:"inherit",stdout:"inherit",stderr:"inherit"}).exited},tsCloudVersion=installedTsCloudVersion()){const environment=resolveDeploymentEnvironment({option:options.env}),command=[process.execPath,resolveTsCloudCliPath(),"deploy:rollback"],newFlags=[options.dryRun&&"--dry-run",options.all&&"--all",options.onlyIfLive&&"--only-if-live"].filter(Boolean);if(newFlags.length>0&&!(tsCloudVersion&&versionAtLeast(tsCloudVersion,ROLLBACK_FLAGS_SINCE))){log.error(`${newFlags.join(", ")} needs @stacksjs/ts-cloud ${ROLLBACK_FLAGS_SINCE} or newer (installed: ${tsCloudVersion??"unknown"}). An older one ignores it and rolls back for real.`);log.info(` \u27A1\uFE0F bun add @stacksjs/ts-cloud@^${ROLLBACK_FLAGS_SINCE}`);return ExitCode.InvalidArgument}if(site)command.push(site);command.push("--env",environment);if(options.to)command.push("--to",options.to);if(options.dryRun)command.push("--dry-run");if(options.all)command.push("--all");if(options.onlyIfLive)command.push("--only-if-live",options.onlyIfLive);if(options.verbose)command.push("--verbose");return await execute(command)}function collectMatchingFiles(root,names,maxDepth=8){const nameSet=new Set(names),matches=[];if(!existsSync(root))return matches;function walk(dir,depth){if(depth>maxDepth)return;for(const entry of readdirSync(dir)){if(entry===".git"||entry==="node_modules")continue;const fullPath=join(dir,entry),stat=statSync(fullPath);if(stat.isDirectory())walk(fullPath,depth+1);else if(stat.isFile()&&nameSet.has(entry))matches.push(fullPath)}}walk(root,0);return matches}function isElfBinary(filePath){const header=readFileSync(filePath).slice(0,4);return header[0]===127&&header[1]===69&&header[2]===76&&header[3]===70}function resolvePantryInstallCommand(){const localPantryCli=join(homedir(),"Code","Tools","pantry","packages","ts-pantry","bin","cli.ts");if(existsSync(localPantryCli))return{command:"bun",args:[localPantryCli]};const projectPantry=p.projectPath("pantry/.bin/pantry");if(existsSync(projectPantry))return{command:projectPantry,args:[]};const globalPantry=join(homedir(),".local","share","pantry","global","bin","pantry");if(existsSync(globalPantry))return{command:globalPantry,args:[]};return{command:"pantry",args:[]}}async function installMailBinaryWithPantry(){const{execFileSync}=await import("node:child_process"),pantry=resolvePantryInstallCommand();execFileSync(pantry.command,[...pantry.args,"install",MAIL_PACKAGE_SPEC,"--install-dir",p.projectPath("pantry"),"--platform",MAIL_TARGET_PLATFORM,"--quiet"],{cwd:p.projectPath(),stdio:process.argv.includes("--verbose")||process.argv.includes("-v")?"inherit":"pipe",env:process.env})}async function findPantryMailBinary(){const directCandidates=[...MAIL_BINARY_NAMES.map((name)=>p.projectPath(`pantry/.bin/${name}`)),...MAIL_BINARY_NAMES.map((name)=>join(homedir(),".local","share","pantry","global","bin",name))];for(const candidate of directCandidates)if(existsSync(candidate)&&isElfBinary(candidate))return candidate;for(const root of[p.projectPath("pantry"),join(homedir(),".local","share","pantry")])for(const candidate of collectMatchingFiles(root,MAIL_BINARY_NAMES))if(isElfBinary(candidate))return candidate;return null}async function ensureDeployPrerequisites(verbose=!1,environment="production"){const{ensureAppKey,ensureDeployEnvIsSet,ensureEnvIsSet}=await import("./setup"),cwd=p.projectPath();await ensureEnvIsSet({cwd,verbose});await ensureDeployEnvIsSet(cwd,environment);await ensureAppKey(cwd)}function loadAwsCredentialsFromFile(){const credentialsPath=join(homedir(),".aws","credentials"),configPath=join(homedir(),".aws","config");if(!existsSync(credentialsPath))return{};try{const lines=readFileSync(credentialsPath,"utf-8").split(`
|
|
2
2
|
`),profiles=["default","stacks"];let currentProfile="",credentials={};const profileCredentials={};for(const line of lines){const trimmed=line.trim(),profileMatch=trimmed.match(/^\[(.+)\]$/);if(profileMatch?.[1]){currentProfile=profileMatch[1];profileCredentials[currentProfile]={};continue}const keyValue=trimmed.match(/^(\w+)\s*=\s*(.+)$/);if(keyValue&¤tProfile){const[,key,value]=keyValue,target=profileCredentials[currentProfile];if(!target||value===void 0)continue;if(key==="aws_access_key_id")target.accessKeyId=value;else if(key==="aws_secret_access_key")target.secretAccessKey=value}}for(const profile of profiles)if(profileCredentials[profile]?.accessKeyId&&profileCredentials[profile]?.secretAccessKey){credentials=profileCredentials[profile];log.debug(`Using AWS credentials from ~/.aws/credentials [${profile}] profile`);break}if(!credentials.accessKeyId){for(const[profile,creds]of Object.entries(profileCredentials))if(creds.accessKeyId&&creds.secretAccessKey){credentials=creds;log.debug(`Using AWS credentials from ~/.aws/credentials [${profile}] profile`);break}}let region;if(existsSync(configPath)){const regionMatch=readFileSync(configPath,"utf-8").match(/region\s*=\s*(.+)/);if(regionMatch?.[1])region=regionMatch[1].trim()}return{...credentials,region}}catch(error){log.debug("Failed to read AWS credentials file:",error);return{}}}async function setupEmailDnsRecords(emailDomain,region,logger,options){logger.info("Setting up email DNS records...");try{const{SESClient}=await import("@stacksjs/ts-cloud"),{Route53Client}=await import("@stacksjs/ts-cloud"),ses=new SESClient(region),route53=new Route53Client(region);logger.info(`Getting DKIM tokens for ${emailDomain}...`);const tokens=(await ses.getEmailIdentity(emailDomain)).DkimAttributes?.Tokens||[];if(tokens.length===0){logger.warn("No DKIM tokens found - domain may not be set up in SES yet");return}logger.info(`Found ${tokens.length} DKIM tokens`);const zone=(await route53.listHostedZones()).HostedZones?.find((z)=>z.Name===`${emailDomain}.`);if(!zone){logger.warn(`Hosted zone not found for ${emailDomain} - DNS records must be added manually`);logger.info("DKIM records needed:");for(const token of tokens)logger.info(` CNAME: ${token}._domainkey.${emailDomain} -> ${token}.dkim.amazonses.com`);logger.info(` MX: ${emailDomain} -> 10 inbound-smtp.${region}.amazonaws.com`);return}const hostedZoneId=zone.Id?.replace("/hostedzone/","");logger.info(`Found hosted zone: ${hostedZoneId}`);for(const token of tokens){const recordName=`${token}._domainkey.${emailDomain}`,recordValue=`${token}.dkim.amazonses.com`;try{await route53.changeResourceRecordSets({HostedZoneId:hostedZoneId,ChangeBatch:{Changes:[{Action:"UPSERT",ResourceRecordSet:{Name:recordName,Type:"CNAME",TTL:300,ResourceRecords:[{Value:recordValue}]}}]}});logger.success(`Added DKIM record: ${token}._domainkey`)}catch(e){logger.warn(`Failed to add DKIM record: ${getErrorMessage(e)}`)}}const mailSubdomain=options?.mailSubdomain||"mail",mxTarget=options?.mode==="server"?`10 ${mailSubdomain}.${emailDomain}`:`10 inbound-smtp.${region}.amazonaws.com`;try{await route53.changeResourceRecordSets({HostedZoneId:hostedZoneId,ChangeBatch:{Changes:[{Action:"UPSERT",ResourceRecordSet:{Name:emailDomain,Type:"MX",TTL:300,ResourceRecords:[{Value:mxTarget}]}}]}});logger.success(`Added MX record: ${mxTarget}`)}catch(e){logger.warn(`Failed to add MX record: ${getErrorMessage(e)}`)}try{await route53.changeResourceRecordSets({HostedZoneId:hostedZoneId,ChangeBatch:{Changes:[{Action:"UPSERT",ResourceRecordSet:{Name:emailDomain,Type:"TXT",TTL:300,ResourceRecords:[{Value:'"v=spf1 include:amazonses.com ~all"'}]}}]}});logger.success("Added SPF record")}catch(e){logger.warn(`Failed to add SPF record: ${getErrorMessage(e)}`)}try{await route53.changeResourceRecordSets({HostedZoneId:hostedZoneId,ChangeBatch:{Changes:[{Action:"UPSERT",ResourceRecordSet:{Name:`_dmarc.${emailDomain}`,Type:"TXT",TTL:300,ResourceRecords:[{Value:`"v=DMARC1;p=quarantine;pct=25;rua=mailto:dmarcreports@${emailDomain}"`}]}}]}});logger.success("Added DMARC record")}catch(e){logger.warn(`Failed to add DMARC record: ${getErrorMessage(e)}`)}try{const ruleSetName=`${process.env.APP_NAME?.toLowerCase().replace(/[^a-z0-9]/g,"-")||"stacks"}-email-rules`;await ses.setActiveReceiptRuleSet(ruleSetName);logger.success(`Activated email receipt rule set: ${ruleSetName}`)}catch(e){logger.warn(`Failed to activate receipt rule set: ${getErrorMessage(e)}`)}logger.success("Email DNS records configured!");logger.info("Note: DKIM verification may take 5-15 minutes to complete")}catch(error){logger.warn(`Failed to set up email DNS records: ${getErrorMessage(error)}`);logger.info("You can manually set up DNS records using: buddy email:verify")}}async function createDefaultMailUser(appName,emailDomain,region,logger){try{const{DynamoDBClient}=await import("@stacksjs/ts-cloud"),crypto=await import("crypto"),dynamodb=new DynamoDBClient(region),tableName=`${appName}-mail-users`,mailboxes=emailConfig?.mailboxes||[];if(mailboxes.length===0){const defaultEmail=`admin@${emailDomain}`,defaultPassword=crypto.randomBytes(16).toString("hex"),passwordHash=crypto.createHash("sha256").update(defaultPassword).digest("hex");try{await dynamodb.putItem({TableName:tableName,Item:{email:{S:defaultEmail},passwordHash:{S:passwordHash},createdAt:{S:new Date().toISOString()},displayName:{S:"Admin"}}});logger.success(`Created default mail user: ${defaultEmail}`);logger.info(`Password: ${defaultPassword}`);logger.info("Save this password - it will not be shown again!")}catch(e){const msg=getErrorMessage(e);if(msg.includes("ConditionalCheckFailedException")||msg.includes("already exists"))logger.debug("Default mail user already exists");else throw e}}else for(const mailbox of mailboxes){const mb=typeof mailbox==="object"&&mailbox!==null?mailbox:null,configured=mb?.email,email=mb?configured?.includes("@")?configured:`${configured??""}@${emailDomain}`:`${mailbox}@${emailDomain}`,password=mb?.password?mb.password:crypto.randomBytes(16).toString("hex"),passwordHash=crypto.createHash("sha256").update(password).digest("hex");try{await dynamodb.putItem({TableName:tableName,Item:{email:{S:email},passwordHash:{S:passwordHash},createdAt:{S:new Date().toISOString()},displayName:{S:mb?mb.displayName||email:String(mailbox)}}});logger.success(`Created mail user: ${email}`);if(!mb?.password)logger.info(` Password: ${password}`)}catch(e){const msg=getErrorMessage(e);if(msg.includes("ConditionalCheckFailedException"))logger.debug(`Mail user ${email} already exists`);else logger.warn(`Failed to create mail user ${email}: ${msg}`)}}}catch(error){logger.warn(`Failed to create mail users: ${getErrorMessage(error)}`)}}async function uploadMailServerToS3(bucketName,region,mode){try{const{S3Client:S3}=await import("@stacksjs/ts-cloud"),s3Client=new S3(region);if(mode==="serverless"){const serverTsPath=p.frameworkPath("core/mail-server/server.ts");if(existsSync(serverTsPath)){const serverCode=readFileSync(serverTsPath,"utf-8");await s3Client.putObject({bucket:bucketName,key:"mail-server/server.ts",body:serverCode,contentType:"text/typescript"});log.success("Uploaded serverless mail server code to S3")}const pkgPath=p.frameworkPath("core/mail-server/package.json");if(existsSync(pkgPath)){const pkgJson=readFileSync(pkgPath,"utf-8");await s3Client.putObject({bucket:bucketName,key:"mail-server/package.json",body:pkgJson,contentType:"application/json"})}return}let binaryUploaded=!1;try{await installMailBinaryWithPantry();const linuxBinaryPath=await findPantryMailBinary();if(linuxBinaryPath&&existsSync(linuxBinaryPath)&&isElfBinary(linuxBinaryPath)){log.info(`Uploading Pantry mail binary: ${linuxBinaryPath}`);const binaryContent=readFileSync(linuxBinaryPath);await s3Client.putObject({bucket:bucketName,key:"mail-server/smtp-server",body:binaryContent,contentType:"application/octet-stream"});log.success("Uploaded Linux x86_64 mail server binary to S3");binaryUploaded=!0}}catch(error){log.debug(`Pantry mail install failed: ${getErrorMessage(error)}`)}if(!binaryUploaded)log.warn(`No Pantry-provided ${MAIL_TARGET_PLATFORM} mail binary found. Release ${MAIL_PACKAGE_DOMAIN}, then run the Pantry binary sync for that package.`)}catch(uploadErr){log.debug(`Could not upload mail server to S3 (bucket may not exist yet): ${uploadErr.message}`)}}export async function loadTsCloudConfig(envName){try{const base=p.projectPath("config/cloud.ts");return(await import(envName?`${base}?env=${envName}`:base)).tsCloud}catch(err){log.debug("Could not load config/cloud.ts tsCloud export:",err);return}}export function resolveProvider(tsCloudConfig){return tsCloudConfig?.cloud?.provider||process.env.CLOUD_PROVIDER||"aws"}function readWaitSecs(name,defaultSecs){const secs=process.env[name]?Number.parseInt(process.env[name],10):Number.NaN;return Number.isFinite(secs)&&secs>0?secs:defaultSecs}function fmtDuration(secs){const m=Math.floor(secs/60),s=secs%60;return m?s?`${m}m${s}s`:`${m}m`:`${s}s`}export function pollFailureDetail(error){const collapsed=(error instanceof Error?error.message:typeof error==="string"?error:"").replace(/\s+/g," ").trim();if(!collapsed)return;return collapsed.length>300?`${collapsed.slice(0,297)}...`:collapsed}export function sshUnreachableMessage(opts){const detail=pollFailureDetail(opts.lastError);return`SSH did not become reachable on ${opts.ip} within ${fmtDuration(opts.waitSecs)} (waited ${opts.elapsedSecs}s).`+(detail?`
|
|
3
3
|
Last attempt: ${detail}`:"")+`
|
|
4
4
|
A connection timeout means the box is probably still booting, so raise TS_CLOUD_SSH_WAIT_SECS and retry. "Permission denied" means the key is not authorized, and a refused or reset connection (especially after earlier attempts got further) usually means fail2ban banned this IP. Waiting longer fixes neither.`}export function bunRuntimeMissingMessage(opts){const detail=pollFailureDetail(opts.lastError);return`bun runtime did not appear at /usr/local/bin/bun within ${fmtDuration(opts.waitSecs)} (waited ${opts.elapsedSecs}s).`+(detail?`
|
|
@@ -9,13 +9,13 @@ cloud-init may have failed: SSH in and check /var/log/cloud-init-output.log. Rai
|
|
|
9
9
|
unit=$(systemctl status "$pid" 2>/dev/null | head -1 | grep -oE '[a-zA-Z0-9_.@-]+\\.service' | head -1)
|
|
10
10
|
echo "$p \${unit:-unknown}"
|
|
11
11
|
done`,encoding:"utf8",stdio:["pipe","pipe","pipe"]})}catch{return}const clashes=[];for(const line of listing.split(`
|
|
12
|
-
`)){const[portText,unit="unknown"]=line.trim().split(/\s+/),port=Number(portText);if(!Number.isFinite(port)||!wanted.has(port))continue;if(unit.startsWith(`${slug}-`))continue;clashes.push(` ${port} (site '${wanted.get(port)}') is held by ${unit}`)}if(clashes.length===0)return;log.error("Another service on the box is already listening on a port this project wants:");for(const clash of clashes)log.error(clash);log.error("Two services on one port do not error: the kernel load-balances, and each domain serves the other's site about half the time.");log.info("Pick free ports in config/cloud.ts. `ss -lntp` on the box lists what is taken.");process.exit(ExitCode.FatalError)}export async function resolveDeployEnvValues(environment,tsCloudConfig,fileOverride){const fileName=fileOverride??(environment==="production"?".env.production":environment==="staging"?".env.staging":existsSync(p.projectPath(".env.development"))?".env.development":".env"),filePath=p.projectPath(fileName);if(!existsSync(filePath))return{};try{const{getEnv}=await import("@stacksjs/env"),result=getEnv(void 0,{file:fileName,format:"json"});if(!result.success||!result.output)throw Error("The environment could not be read completely");const parsed=JSON.parse(result.output),values={},undecrypted=[];for(const[key,value]of Object.entries(parsed)){if(/^DOTENV_(PUBLIC|PRIVATE)_KEY/.test(key))continue;values[key]=String(value);if(/^(?:encrypted|enc):/.test(values[key]))undecrypted.push(key)}if(undecrypted.length>0)throw Error("The environment still contains ciphertext");const{foreignTenantKeys,partitionTenantEnv}=await import("@stacksjs/env"),partition=partitionTenantEnv(values,{self:tsCloudConfig?.project?.slug,tenants:await resolveDeclaredTenants()});for(const{tenant,keys}of foreignTenantKeys(partition))log.warn(`[deploy] Skipping ${keys.length} '${tenant}' key(s) in ${fileName} - they belong to that tenant's own repository, and shipping them writes its secrets into this project's site .env files. Remove them with: buddy env:check --file ${fileName}. Keys: ${keys.join(", ")}`);return partition.own}catch{throw Error(`Cannot load deployment environment from ${fileName}; restore its decryption key and readable file before deploying.`)}}export function mergeSiteDeployEnv(sites,resolvedDeployEnv){return Object.fromEntries(Object.entries(sites).map(([siteName,site])=>{if(!site)return[siteName,site];const base={...resolvedDeployEnv};if(site.port!==void 0)delete base.PORT;return[siteName,{...site,env:{...base,...site.env||{}}}]}))}function looksLikeSqliteFile(value){return typeof value==="string"&&/\.(?:sqlite3?|db)$/i.test(value.trim())}export function siteSqlitePath(siteEnv={}){if(String(siteEnv.DB_CONNECTION??"sqlite").trim().toLowerCase()!=="sqlite")return null;const configured=typeof siteEnv.DB_DATABASE_PATH==="string"&&siteEnv.DB_DATABASE_PATH.trim()?siteEnv.DB_DATABASE_PATH:looksLikeSqliteFile(siteEnv.DB_DATABASE)?siteEnv.DB_DATABASE:"database/stacks.sqlite",path=String(configured).trim().replace(/^\.\//,"");if(!path||path.startsWith("/")||path.startsWith("~")||path.split("/").includes(".."))return null;return path}export function tsCloudPersistentStateSupport(buildSiteDeployScript){const missing=[];if(typeof buildSiteDeployScript!=="function")return{ok:!1,missing:["buildSiteDeployScript"]};const target="/var/www/probe-shared/database/probe.sqlite";let script="";try{script=buildSiteDeployScript({siteName:"probe",slug:"probe",appDir:"/var/www/probe-probe",artifactFetch:[],releaseId:"probe",execStart:"/bin/true",envEntries:{},port:3000,sharedPaths:[{path:"database/probe.sqlite",target,seed:!0}]}).join(`
|
|
12
|
+
`)){const[portText,unit="unknown"]=line.trim().split(/\s+/),port=Number(portText);if(!Number.isFinite(port)||!wanted.has(port))continue;if(unit.startsWith(`${slug}-`))continue;clashes.push(` ${port} (site '${wanted.get(port)}') is held by ${unit}`)}if(clashes.length===0)return;log.error("Another service on the box is already listening on a port this project wants:");for(const clash of clashes)log.error(clash);log.error("Two services on one port do not error: the kernel load-balances, and each domain serves the other's site about half the time.");log.info("Pick free ports in config/cloud.ts. `ss -lntp` on the box lists what is taken.");process.exit(ExitCode.FatalError)}export async function resolveDeployEnvValues(environment,tsCloudConfig,fileOverride){const fileName=fileOverride??(environment==="production"?".env.production":environment==="staging"?".env.staging":existsSync(p.projectPath(".env.development"))?".env.development":".env"),filePath=p.projectPath(fileName);if(!existsSync(filePath))return{};try{const{getEnv}=await import("@stacksjs/env"),result=getEnv(void 0,{file:fileName,format:"json"});if(!result.success||!result.output)throw Error("The environment could not be read completely");const parsed=JSON.parse(result.output),values={},undecrypted=[];for(const[key,value]of Object.entries(parsed)){if(/^DOTENV_(PUBLIC|PRIVATE)_KEY/.test(key))continue;values[key]=String(value);if(/^(?:encrypted|enc):/.test(values[key]))undecrypted.push(key)}if(undecrypted.length>0)throw Error("The environment still contains ciphertext");const{foreignTenantKeys,partitionTenantEnv}=await import("@stacksjs/env"),partition=partitionTenantEnv(values,{self:tsCloudConfig?.project?.slug,tenants:await resolveDeclaredTenants()});for(const{tenant,keys}of foreignTenantKeys(partition))log.warn(`[deploy] Skipping ${keys.length} '${tenant}' key(s) in ${fileName} - they belong to that tenant's own repository, and shipping them writes its secrets into this project's site .env files. Remove them with: buddy env:check --file ${fileName}. Keys: ${keys.join(", ")}`);return partition.own}catch{throw Error(`Cannot load deployment environment from ${fileName}; restore its decryption key and readable file before deploying.`)}}export function mergeSiteDeployEnv(sites,resolvedDeployEnv){return Object.fromEntries(Object.entries(sites).map(([siteName,site])=>{if(!site)return[siteName,site];const base=site.inheritEnv===!1?{}:{...resolvedDeployEnv};if(site.port!==void 0)delete base.PORT;return[siteName,{...site,env:{...base,...site.env||{}}}]}))}function looksLikeSqliteFile(value){return typeof value==="string"&&/\.(?:sqlite3?|db)$/i.test(value.trim())}export function siteSqlitePath(siteEnv={}){if(String(siteEnv.DB_CONNECTION??"sqlite").trim().toLowerCase()!=="sqlite")return null;const configured=typeof siteEnv.DB_DATABASE_PATH==="string"&&siteEnv.DB_DATABASE_PATH.trim()?siteEnv.DB_DATABASE_PATH:looksLikeSqliteFile(siteEnv.DB_DATABASE)?siteEnv.DB_DATABASE:"database/stacks.sqlite",path=String(configured).trim().replace(/^\.\//,"");if(!path||path.startsWith("/")||path.startsWith("~")||path.split("/").includes(".."))return null;return path}export function tsCloudPersistentStateSupport(buildSiteDeployScript){const missing=[];if(typeof buildSiteDeployScript!=="function")return{ok:!1,missing:["buildSiteDeployScript"]};const target="/var/www/probe-shared/database/probe.sqlite";let script="";try{script=buildSiteDeployScript({siteName:"probe",slug:"probe",appDir:"/var/www/probe-probe",artifactFetch:[],releaseId:"probe",execStart:"/bin/true",envEntries:{},port:3000,sharedPaths:[{path:"database/probe.sqlite",target,seed:!0}]}).join(`
|
|
13
13
|
`)}catch{return{ok:!1,missing:["shared paths with an explicit target"]}}if(!(script.includes("cp -a")&&script.includes("/current/")))missing.push("adoption of existing state into shared/");if(!script.includes(`ln -sfn ${target} `))missing.push("shared paths with an explicit target");return{ok:missing.length===0,missing}}export function projectDatabaseTarget(slug,relativePath){return`/var/www/${slug}-shared/${relativePath}`}function migrateIndex(site){if(!Array.isArray(site?.preStart))return-1;return site.preStart.findIndex((cmd)=>typeof cmd==="string"&&migratesDatabase(cmd))}function migratesDatabase(command){const withoutMessages=command.replace(/'[^']*'/g,"").replace(/"[^"]*"/g,"");if(/\bmigrate\b/.test(withoutMessages))return!0;return!/^\s*(?:echo|printf)\b/.test(command)&&/\bmigrate\b/.test(command)}export function applyAutomaticMigrations(sites){if(Object.values(sites).some((site)=>runsMigrations(site)))return sites;const isServerApp=(site)=>!!site&&typeof site.start==="string",owner=Object.entries(sites).filter(([,site])=>isServerApp(site)).find(([,site])=>site?.migrateOnDeploy!==!1)?.[0];if(!owner)return sites;const site=sites[owner],preStart=Array.isArray(site.preStart)?[...site.preStart]:[];preStart.push("./buddy migrate --no-generate");return{...sites,[owner]:{...site,preStart}}}function runsMigrations(site){return migrateIndex(site)!==-1}export function applyPersistentStatePaths(sites,slug){const isServerApp=(site)=>!!site&&typeof site.start==="string",appSites=Object.entries(sites).filter(([,site])=>isServerApp(site)),owner=(appSites.find(([,site])=>runsMigrations(site))??appSites[0])?.[0],out={};for(const[name,site]of Object.entries(sites)){if(!isServerApp(site)){out[name]=site;continue}const sqlite=siteSqlitePath(site.env||{}),framework=["storage/logs"];if(sqlite)framework.unshift({path:sqlite,target:projectDatabaseTarget(slug,sqlite),seed:name===owner});const declared=Array.isArray(site.sharedPaths)?site.sharedPaths.filter(Boolean):[],byPath=new Map;for(const entry of[...declared,...framework])byPath.set(typeof entry==="string"?entry:entry.path,entry);out[name]={...site,sharedPaths:[...byPath.values()]}}return out}export function encryptedEnvFileNames(projectRoot){try{return readdirSync(projectRoot).filter((name)=>/^\.env\.[\w-]+$/.test(name)&&name!==".env.example")}catch{return[]}}export function declaresScheduledWork(schedulerFile){if(!existsSync(schedulerFile))return!1;const source=readFileSync(schedulerFile,"utf8").replace(/\/\*[\s\S]*?\*\//g,"").replace(/(^|[^:])\/\/.*$/gm,"$1");return/\bschedule\s*\.\s*(?:job|action|command|call|exec)\b/.test(source)}export function applyScheduledWork(sites,schedulerFile){if(Object.values(sites).some((site)=>site?.scheduler!==void 0))return sites;if(!declaresScheduledWork(schedulerFile))return sites;const appSites=Object.entries(sites).filter(([,site])=>typeof site?.start==="string"),owner=(appSites.find(([,site])=>runsMigrations(site))??appSites[0])?.[0];if(!owner)return sites;return{...sites,[owner]:{...sites[owner],scheduler:!0}}}const apiStartPattern=/\bserve:api\b|(?:^|[\s/"'])(?:serve|storage\/framework\/runtime\/production)\/api\.[cm]?[jt]s(?=$|[\s"';&|<>()])/;function servesApi(name,site){return name==="api"||typeof site?.start==="string"&&apiStartPattern.test(site.start)}function servesHttp(site){return Boolean(site?.port||site?.domain)}function describeSiteClassification(sites){const described=Object.entries(sites).filter(([,site])=>typeof site?.start==="string").map(([name,site])=>{if(servesApi(name,site))return`\`${name}\` (api)`;if(!servesHttp(site))return`\`${name}\` (headless, no HTTP surface)`;const env=site?.env??{},wiring=env.API_URL?"API_URL set":env.PORT_API?"PORT_API set":"no API_URL or PORT_API";return`\`${name}\` (page, ${wiring})`});return described.length>0?`Sites examined: ${described.join(", ")}.`:"No server-app sites were examined."}function isDashboardSite(name){return name==="dashboard"||name.startsWith("dashboard-")}export function apiDeploymentProblem(sites,hasApiRoutes){if(!hasApiRoutes)return;const entries=Object.entries(sites),appSites=entries.filter(([,site])=>typeof site?.start==="string");if(appSites.length===0)return;const api=entries.find(([name,site])=>servesApi(name,site)),pages=appSites.filter(([name,site])=>!servesApi(name,site)&&!isDashboardSite(name)&&servesHttp(site)),configured=(site)=>{const env=site?.env??{};return Boolean(env.API_URL||env.PORT_API)};if(!api){if(pages.every(([,site])=>configured(site)))return;return`This project declares API routes and no site serves them. \`/api/**\` will answer 502 on every request.
|
|
14
14
|
${describeSiteClassification(sites)}
|
|
15
15
|
Add an \`api\` site to config/cloud.ts running \`buddy serve:api\` on its own port, and set \`PORT_API\` on the site that serves the pages so its proxy can find it.
|
|
16
16
|
Set \`API_URL\` on the page site instead when the API lives on another host.`}const unwired=pages.filter(([,site])=>!configured(site));if(unwired.length===0)return;const port=api[1]?.port;return`The \`${api[0]}\` site serves the API, but ${unwired.map(([name])=>`\`${name}\``).join(", ")} will not proxy to it: neither \`PORT_API\` nor \`API_URL\` is set in its environment, so \`/api/**\` answers 502.
|
|
17
17
|
${describeSiteClassification(sites)}
|
|
18
|
-
Set \`PORT_API: '${port??"<the api site's port>"}'\` in that site's \`env\` in config/cloud.ts.`}export function siteDatabaseDrivers(sites){const drivers=new Set;for(const site of Object.values(sites??{})){if(typeof site?.start!=="string")continue;if(!(Array.isArray(site?.preStart)?site.preStart:[]).some((step)=>typeof step==="string"&&step.trim().split(/\s+/).some((token)=>migrateToken.test(token))))continue;const env=site?.env??{};drivers.add(String(env.DB_CONNECTION||"sqlite").toLowerCase())}return[...drivers]}const buddyEntrypoint=/(?:^|\/)(?:cli\.[cm]?[jt]s|buddy|bud|stacks)$/,migrateToken=/(?:^|:)migrate(?::|$)/;export function buddyInvocationFrom(migrateCommand){if(typeof migrateCommand!=="string")return;const tokens=migrateCommand.trim().split(/\s+/),at=tokens.findIndex((token)=>migrateToken.test(token.replace(/[;&|]+$/,"")));if(at<1)return;let from=at;while(from>0&&!isShellToken(tokens[from-1]))from--;const invocation=tokens.slice(from,at),entrypoint=invocation[invocation.length-1];if(!entrypoint||!buddyEntrypoint.test(entrypoint))return;return invocation.join(" ")}function isShellToken(token){if(/[;&|<>()`]/.test(token))return!0;if(/^[A-Za-z_][\w]*=/.test(token))return!0;return SHELL_KEYWORDS.has(token)}const SHELL_KEYWORDS=new Set(["do","done","then","else","elif","fi","if","for","while","until","case","esac","in","function","{","}","!"]);export function preMigrationBackupCommand(backupsDir,migrateCommand){const invocation=buddyInvocationFrom(migrateCommand);if(!invocation)return;return`${invocation} db:backup --before-migrations --out ${backupsDir}`}export function applyPreMigrationBackup(sites,backupsDir){const out={};for(const[name,site]of Object.entries(sites)){const at=migrateIndex(site);if(at===-1){out[name]=site;continue}const preStart=[...site.preStart];if(preStart.some((cmd)=>typeof cmd==="string"&&/\bdb:backup\b/.test(cmd))){out[name]=site;continue}const backup=preMigrationBackupCommand(backupsDir,preStart[at]);if(!backup){log.warn(`No pre-migration backup for "${name}": its migrate step (${String(preStart[at])}) is not a buddy invocation this can reuse. Add a \`db:backup\` command to its preStart to take one.`);out[name]=site;continue}preStart.splice(at,0,backup);out[name]={...site,preStart}}return out}function prefixHostForEnv(host,prefix){if(host.startsWith(`${prefix}.`)||host.startsWith(`www.${prefix}.`))return host;if(host.startsWith("www."))return`www.${prefix}.${host.slice(4)}`;return`${prefix}.${host}`}export function applyEnvironmentToSites(sites,environment,config){const prefix=config?.environments?.[environment]?.domainPrefix;if(!prefix||environment==="production")return sites;const allHosts=[];for(const site of Object.values(sites)){const d=site?.domain;if(typeof d==="string")allHosts.push(d);else if(Array.isArray(d)){for(const x of d)if(typeof x==="string")allHosts.push(x)}}allHosts.sort((a,b)=>b.length-a.length);const rewrite=(val)=>{let r=val;for(const h of allHosts){const esc=h.replace(/[.]/g,"\\.");r=r.replace(new RegExp(`//${esc}(?=[/:?#]|$)`,"g"),`//${prefixHostForEnv(h,prefix)}`)}return r},out={};for(const[name,site]of Object.entries(sites)){if(!site){out[name]=site;continue}const s={...site};if(typeof s.domain==="string")s.domain=prefixHostForEnv(s.domain,prefix);else if(Array.isArray(s.domain))s.domain=s.domain.map((d)=>typeof d==="string"?prefixHostForEnv(d,prefix):d);if(typeof s.redirect==="string")s.redirect=rewrite(s.redirect);if(s.env&&typeof s.env==="object"){const e={...s.env};for(const k of Object.keys(e))if(typeof e[k]==="string")e[k]=rewrite(e[k]);s.env=e}out[name]=s}return out}async function deployOverSsh(tsCloudConfig,deployEnv,options,envFile){const verbose=options.verbose===!0,environment=deployEnv==="prod"?"production":deployEnv,provider=resolveProvider(tsCloudConfig);await runBeforeDeployHook(p.projectPath(),{environment,region:tsCloudConfig.project?.region||"us-east-1"});const persistedAttachBox=resolvePersistedAttachTargetBox(tsCloudConfig,environment);let sshTarget;if(provider==="ssh"){const resolved=resolveSshTarget(tsCloudConfig);if(!resolved){log.error("No SSH host configured for this deploy.");log.info("Add one to config/cloud.ts: ssh: { hosts: [{ host: 'pi-stacks.local', user: 'pi' }] }");log.info("Or set TS_CLOUD_SSH_HOST (with TS_CLOUD_SSH_USER / TS_CLOUD_SSH_PORT / TS_CLOUD_SSH_KEY).");process.exit(ExitCode.FatalError)}sshTarget=resolved;if(resolved.identityFile&&!existsSync(resolved.identityFile)){log.error(`SSH private key not found at ${resolved.identityFile}.`);log.info("Fix ssh.hosts[].privateKeyPath in config/cloud.ts, or set TS_CLOUD_SSH_KEY.");process.exit(ExitCode.FatalError)}if(!resolved.identityFile)log.info(`Using ssh's own key selection for ${resolved.user}@${resolved.host} (agent or ~/.ssh/config).`)}else{if(!resolveHetznerApiToken(tsCloudConfig)&&!persistedAttachBox){log.error("No Hetzner API token found. Set HCLOUD_TOKEN in your .env (or hetzner.apiToken in config/cloud.ts).");process.exit(ExitCode.FatalError)}const sshPubKey=join(homedir(),".ssh","id_ed25519.pub");if(!existsSync(sshPubKey)){log.error(`SSH public key not found at ${sshPubKey}.`);log.info("ts-cloud deploys to Hetzner over SSH and registers this key on the server.");log.info("Generate one with: ssh-keygen -t ed25519");process.exit(ExitCode.FatalError)}}const{createCloudDriver,deployAllComputeSites,ensureManagementDashboard,resolveSiteKind,shipsARelease,buildSiteDeployScript}=await loadTsCloudDeployApi(),support=tsCloudPersistentStateSupport(buildSiteDeployScript);if(!support.ok){log.error("This ts-cloud cannot keep your database across deploys, and deploying anyway would destroy it.");log.error(`Missing: ${support.missing.join(", ")}.`);log.error("Upgrade @stacksjs/ts-cloud (bun update @stacksjs/ts-cloud), or point TS_CLOUD_MODULE at a build that has it.");process.exit(ExitCode.FatalError)}const unbacked=findUnbackedManagedServices(tsCloudConfig,await hasOffsiteBackupDestination());if(unbacked.length>0)log.warn(`[deploy] ${unbackedDataMessage(unbacked)}`);try{await runSshDeploy({tsCloudConfig,environment,verbose,docker:options.docker===!0,createCloudDriver,deployAllComputeSites,ensureManagementDashboard,resolveSiteKind,shipsARelease,onlySite:options.site||void 0,persistedAttachBox,provider,sshTarget,envFile})}catch(err){log.error(`${deployTargetLabel(provider,sshTarget?.profile)} deploy failed:`);console.error(err instanceof Error?err.stack||err.message:err);throw err}}export function resolveProjectTsCloudModule(projectRoot=process.cwd()){const manifestPath=join(projectRoot,"node_modules","@stacksjs","ts-cloud","package.json");if(!existsSync(manifestPath))return;const manifest=JSON.parse(readFileSync(manifestPath,"utf8")),rootExport=manifest.exports?.["."],entry=manifest.module??(typeof rootExport==="string"?rootExport:rootExport?.import);if(!entry)return;const modulePath=resolve(dirname(manifestPath),entry);return existsSync(modulePath)?modulePath:void 0}export async function loadTsCloudDeployApi(){const requested=process.env.TS_CLOUD_MODULE?.trim();if(!requested){const projectModule=resolveProjectTsCloudModule();return projectModule?import(pathToFileURL(projectModule).href):import("@stacksjs/ts-cloud")}const modulePath=isAbsolute(requested)?requested:resolve(process.cwd(),requested);if(!existsSync(modulePath))throw Error(`TS_CLOUD_MODULE points to a missing module: ${modulePath}`);return import(pathToFileURL(modulePath).href)}export function shouldInjectManagementDashboard(tsCloudConfig){return!tsCloudConfig.cloud?.attachTo}export function reconcilePartialDeployManagementDashboards(tsCloudConfig,livePorts){const sites=tsCloudConfig.sites,preserved=[],removed=[];if(!sites)return{preserved,removed};for(const[siteName,site]of Object.entries(sites)){if(siteName!=="dashboard"&&!siteName.startsWith("dashboard-"))continue;const livePort=livePorts[siteName];if(typeof livePort!=="number"||!Number.isInteger(livePort)||livePort<1||livePort>65535){delete sites[siteName];removed.push(siteName);continue}const next={...site,port:livePort};if(typeof next.start==="string")next.start=next.start.replace(/(--port(?:=|\s+))\d+/,`$1${livePort}`);sites[siteName]=next;preserved.push(siteName)}return{preserved,removed}}async function reconcilePartialDeployManagementDashboardsWithLiveBox(tsCloudConfig,where){const slug=String(tsCloudConfig.project?.slug||"app").replace(/[^a-z0-9._-]+/gi,"-"),siteNames=Object.keys(tsCloudConfig.sites??{}).filter((name)=>name==="dashboard"||name.startsWith("dashboard-"));if(siteNames.length===0)return;const units=siteNames.map((siteName)=>({siteName,unit:`${slug}-${siteName}.service`})),probe=`
|
|
18
|
+
Set \`PORT_API: '${port??"<the api site's port>"}'\` in that site's \`env\` in config/cloud.ts.`}export function siteDatabaseDrivers(sites){const drivers=new Set;for(const site of Object.values(sites??{})){if(typeof site?.start!=="string")continue;if(!(Array.isArray(site?.preStart)?site.preStart:[]).some((step)=>typeof step==="string"&&step.trim().split(/\s+/).some((token)=>migrateToken.test(token))))continue;const env=site?.env??{};drivers.add(String(env.DB_CONNECTION||"sqlite").toLowerCase())}return[...drivers]}export function siteDatabaseCorpora(sites,projectRoot){const corpora=new Map;for(const site of Object.values(sites??{}))for(const driver of siteDatabaseDrivers({site})){const root=resolve(projectRoot,site.root||".");corpora.set(`${root}\x00${driver}`,{root,driver})}return[...corpora.values()]}const buddyEntrypoint=/(?:^|\/)(?:cli\.[cm]?[jt]s|buddy|bud|stacks)$/,migrateToken=/(?:^|:)migrate(?::|$)/;export function buddyInvocationFrom(migrateCommand){if(typeof migrateCommand!=="string")return;const tokens=migrateCommand.trim().split(/\s+/),at=tokens.findIndex((token)=>migrateToken.test(token.replace(/[;&|]+$/,"")));if(at<1)return;let from=at;while(from>0&&!isShellToken(tokens[from-1]))from--;const invocation=tokens.slice(from,at),entrypoint=invocation[invocation.length-1];if(!entrypoint||!buddyEntrypoint.test(entrypoint))return;return invocation.join(" ")}function isShellToken(token){if(/[;&|<>()`]/.test(token))return!0;if(/^[A-Za-z_][\w]*=/.test(token))return!0;return SHELL_KEYWORDS.has(token)}const SHELL_KEYWORDS=new Set(["do","done","then","else","elif","fi","if","for","while","until","case","esac","in","function","{","}","!"]);export function preMigrationBackupCommand(backupsDir,migrateCommand){const invocation=buddyInvocationFrom(migrateCommand);if(!invocation)return;return`${invocation} db:backup --before-migrations --out ${backupsDir}`}export function applyPreMigrationBackup(sites,backupsDir){const out={};for(const[name,site]of Object.entries(sites)){const at=migrateIndex(site);if(at===-1){out[name]=site;continue}const preStart=[...site.preStart];if(preStart.some((cmd)=>typeof cmd==="string"&&/\bdb:backup\b/.test(cmd))){out[name]=site;continue}const backup=preMigrationBackupCommand(backupsDir,preStart[at]);if(!backup){log.warn(`No pre-migration backup for "${name}": its migrate step (${String(preStart[at])}) is not a buddy invocation this can reuse. Add a \`db:backup\` command to its preStart to take one.`);out[name]=site;continue}preStart.splice(at,0,backup);out[name]={...site,preStart}}return out}function prefixHostForEnv(host,prefix){if(host.startsWith(`${prefix}.`)||host.startsWith(`www.${prefix}.`))return host;if(host.startsWith("www."))return`www.${prefix}.${host.slice(4)}`;return`${prefix}.${host}`}export function applyEnvironmentToSites(sites,environment,config){const prefix=config?.environments?.[environment]?.domainPrefix;if(!prefix||environment==="production")return sites;const allHosts=[];for(const site of Object.values(sites)){const d=site?.domain;if(typeof d==="string")allHosts.push(d);else if(Array.isArray(d)){for(const x of d)if(typeof x==="string")allHosts.push(x)}}allHosts.sort((a,b)=>b.length-a.length);const rewrite=(val)=>{let r=val;for(const h of allHosts){const esc=h.replace(/[.]/g,"\\.");r=r.replace(new RegExp(`//${esc}(?=[/:?#]|$)`,"g"),`//${prefixHostForEnv(h,prefix)}`)}return r},out={};for(const[name,site]of Object.entries(sites)){if(!site){out[name]=site;continue}const s={...site};if(typeof s.domain==="string")s.domain=prefixHostForEnv(s.domain,prefix);else if(Array.isArray(s.domain))s.domain=s.domain.map((d)=>typeof d==="string"?prefixHostForEnv(d,prefix):d);if(typeof s.redirect==="string")s.redirect=rewrite(s.redirect);if(s.env&&typeof s.env==="object"){const e={...s.env};for(const k of Object.keys(e))if(typeof e[k]==="string")e[k]=rewrite(e[k]);s.env=e}out[name]=s}return out}async function deployOverSsh(tsCloudConfig,deployEnv,options,envFile){const verbose=options.verbose===!0,environment=deployEnv==="prod"?"production":deployEnv,provider=resolveProvider(tsCloudConfig);await runBeforeDeployHook(p.projectPath(),{environment,region:tsCloudConfig.project?.region||"us-east-1"});const persistedAttachBox=resolvePersistedAttachTargetBox(tsCloudConfig,environment);let sshTarget;if(provider==="ssh"){const resolved=resolveSshTarget(tsCloudConfig);if(!resolved){log.error("No SSH host configured for this deploy.");log.info("Add one to config/cloud.ts: ssh: { hosts: [{ host: 'pi-stacks.local', user: 'pi' }] }");log.info("Or set TS_CLOUD_SSH_HOST (with TS_CLOUD_SSH_USER / TS_CLOUD_SSH_PORT / TS_CLOUD_SSH_KEY).");process.exit(ExitCode.FatalError)}sshTarget=resolved;if(resolved.identityFile&&!existsSync(resolved.identityFile)){log.error(`SSH private key not found at ${resolved.identityFile}.`);log.info("Fix ssh.hosts[].privateKeyPath in config/cloud.ts, or set TS_CLOUD_SSH_KEY.");process.exit(ExitCode.FatalError)}if(!resolved.identityFile)log.info(`Using ssh's own key selection for ${resolved.user}@${resolved.host} (agent or ~/.ssh/config).`)}else{if(!resolveHetznerApiToken(tsCloudConfig)&&!persistedAttachBox){log.error("No Hetzner API token found. Set HCLOUD_TOKEN in your .env (or hetzner.apiToken in config/cloud.ts).");process.exit(ExitCode.FatalError)}const sshPubKey=join(homedir(),".ssh","id_ed25519.pub");if(!existsSync(sshPubKey)){log.error(`SSH public key not found at ${sshPubKey}.`);log.info("ts-cloud deploys to Hetzner over SSH and registers this key on the server.");log.info("Generate one with: ssh-keygen -t ed25519");process.exit(ExitCode.FatalError)}}const{createCloudDriver,deployAllComputeSites,ensureManagementDashboard,resolveSiteKind,shipsARelease,buildSiteDeployScript}=await loadTsCloudDeployApi(),support=tsCloudPersistentStateSupport(buildSiteDeployScript);if(!support.ok){log.error("This ts-cloud cannot keep your database across deploys, and deploying anyway would destroy it.");log.error(`Missing: ${support.missing.join(", ")}.`);log.error("Upgrade @stacksjs/ts-cloud (bun update @stacksjs/ts-cloud), or point TS_CLOUD_MODULE at a build that has it.");process.exit(ExitCode.FatalError)}const unbacked=findUnbackedManagedServices(tsCloudConfig,await hasOffsiteBackupDestination());if(unbacked.length>0)log.warn(`[deploy] ${unbackedDataMessage(unbacked)}`);try{await runSshDeploy({tsCloudConfig,environment,verbose,docker:options.docker===!0,createCloudDriver,deployAllComputeSites,ensureManagementDashboard,resolveSiteKind,shipsARelease,onlySite:options.site||void 0,persistedAttachBox,provider,sshTarget,envFile})}catch(err){log.error(`${deployTargetLabel(provider,sshTarget?.profile)} deploy failed:`);console.error(err instanceof Error?err.stack||err.message:err);throw err}}export function resolveProjectTsCloudModule(projectRoot=process.cwd()){const manifestPath=join(projectRoot,"node_modules","@stacksjs","ts-cloud","package.json");if(!existsSync(manifestPath))return;const manifest=JSON.parse(readFileSync(manifestPath,"utf8")),rootExport=manifest.exports?.["."],entry=manifest.module??(typeof rootExport==="string"?rootExport:rootExport?.import);if(!entry)return;const modulePath=resolve(dirname(manifestPath),entry);return existsSync(modulePath)?modulePath:void 0}export async function loadTsCloudDeployApi(){const requested=process.env.TS_CLOUD_MODULE?.trim();if(!requested){const projectModule=resolveProjectTsCloudModule();return projectModule?import(pathToFileURL(projectModule).href):import("@stacksjs/ts-cloud")}const modulePath=isAbsolute(requested)?requested:resolve(process.cwd(),requested);if(!existsSync(modulePath))throw Error(`TS_CLOUD_MODULE points to a missing module: ${modulePath}`);return import(pathToFileURL(modulePath).href)}export function shouldInjectManagementDashboard(tsCloudConfig){return!tsCloudConfig.cloud?.attachTo}export function reconcilePartialDeployManagementDashboards(tsCloudConfig,livePorts){const sites=tsCloudConfig.sites,preserved=[],removed=[];if(!sites)return{preserved,removed};for(const[siteName,site]of Object.entries(sites)){if(siteName!=="dashboard"&&!siteName.startsWith("dashboard-"))continue;const livePort=livePorts[siteName];if(typeof livePort!=="number"||!Number.isInteger(livePort)||livePort<1||livePort>65535){delete sites[siteName];removed.push(siteName);continue}const next={...site,port:livePort};if(typeof next.start==="string")next.start=next.start.replace(/(--port(?:=|\s+))\d+/,`$1${livePort}`);sites[siteName]=next;preserved.push(siteName)}return{preserved,removed}}async function reconcilePartialDeployManagementDashboardsWithLiveBox(tsCloudConfig,where){const slug=String(tsCloudConfig.project?.slug||"app").replace(/[^a-z0-9._-]+/gi,"-"),siteNames=Object.keys(tsCloudConfig.sites??{}).filter((name)=>name==="dashboard"||name.startsWith("dashboard-"));if(siteNames.length===0)return;const units=siteNames.map((siteName)=>({siteName,unit:`${slug}-${siteName}.service`})),probe=`
|
|
19
19
|
const units = ${JSON.stringify(units)}
|
|
20
20
|
const text = bytes => new TextDecoder().decode(bytes).trim()
|
|
21
21
|
const run = args => text(Bun.spawnSync(args).stdout)
|
|
@@ -31,9 +31,9 @@ for (const entry of units) {
|
|
|
31
31
|
console.log(JSON.stringify(ports))
|
|
32
32
|
`.trim(),encoded=Buffer.from(probe).toString("base64"),{sshExecOrThrow}=await import("@stacksjs/ts-cloud"),target=toSshTarget(where),line=(await sshExecOrThrow(target.host,`/usr/local/bin/bun -e "eval(Buffer.from('${encoded}','base64').toString())"`,remoteExecOptions(target,10))).trim().split(`
|
|
33
33
|
`).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")),isSshPin=state.provider==="ssh";if(state.stackName!==stackName||!isSshPin&&typeof state.serverId!=="number"||typeof state.publicIp!=="string"||!state.publicIp.trim())return null;return{...typeof state.serverId==="number"?{serverId:state.serverId}:{},serverName:typeof state.serverName==="string"&&state.serverName?state.serverName:isSshPin?String(state.publicIp):`${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}}export function deploymentReleaseSites(sites,onlySite,policy){return Object.entries(sites).filter((entry)=>{const[name,site]=entry;if(!site||onlySite&&name!==onlySite)return!1;return policy.shipsARelease(site)})}async function startGithubDeployments(args){const{sites,onlySite,environment,shipsARelease}=args,records=[];if(!githubDeploymentsEnabled()||!await ghCliAvailable())return records;const seen=new Set;for(const[siteName,site]of deploymentReleaseSites(sites,onlySite,{shipsARelease})){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}let automatedRevisionCheck;async function assertNotBehindLiveBox(slug,where){if(!automatedRevisionCheck||automatedRevisionCheck.newer.length===0)return;if(!slug)throw Error("Cannot tell which releases production serves without a project slug; production deployment stopped.");const{sshExecOrThrow}=await import("@stacksjs/ts-cloud"),target=toSshTarget(where);let output;try{output=await sshExecOrThrow(target.host,liveReleasesCommand(slug),remoteExecOptions(target,15))}catch{throw Error("Cannot read the release production serves; production deployment stopped.")}const live=output.split(`
|
|
34
|
-
`).map((line)=>line.trim()).filter(Boolean);assertNotBehindLiveRelease(automatedRevisionCheck,live);log.info(`Production serves ${live.join(", ")||"no release yet"}; ${automatedRevisionCheck.revision.sha.slice(0,7)} is newer, deploying it (${automatedRevisionCheck.newer.length} newer commit${automatedRevisionCheck.newer.length===1?"":"s"} still in CI).`)}async function runSshDeploy(args){const{tsCloudConfig,environment,verbose,docker,createCloudDriver,deployAllComputeSites,ensureManagementDashboard,resolveSiteKind,shipsARelease,onlySite,persistedAttachBox,provider,sshTarget}=args,startTime=performance.now(),targetLabel=deployTargetLabel(provider,sshTarget?.profile),host=sshTarget?`${sshTarget.user}@${sshTarget.host}${sshTarget.port===22?"":`:${sshTarget.port}`}`:`${tsCloudConfig.hetzner?.location||process.env.HCLOUD_LOCATION||"fsn1"} \xB7 ${tsCloudConfig.infrastructure?.compute?.size||"small"}`;console.log("");console.log(`\uD83D\uDE80 Deploy \u2192 ${targetLabel}`);if(verbose){console.log("");log.info(`Project: ${tsCloudConfig.project?.slug}`);log.info(`Environment: ${environment}`);if(sshTarget)log.info(`Host: ${host}`);else{log.info(`Location: ${tsCloudConfig.hetzner?.location||process.env.HCLOUD_LOCATION||"fsn1"}`);log.info(`Size: ${tsCloudConfig.infrastructure?.compute?.size||"small"}`)}}else console.log(` ${tsCloudConfig.project?.slug} \u2192 ${environment} \xB7 ${host}`);console.log("");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});if(!driver.provisionComputeInfrastructure){log.error(`The ${provider} driver does not support compute provisioning (update @stacksjs/ts-cloud).`);process.exit(ExitCode.FatalError)}const attachTo=tsCloudConfig.cloud?.attachTo;let ip,ipv6;if(attachTo){let lookup,box=persistedAttachBox;if(!box&&provider==="ssh"&&sshTarget)box={publicIp:sshTarget.host,serverName:sshTarget.host};if(!box){lookup=await resolveAttachTargetBox(attachTo,environment,tsCloudConfig);box=lookup.box}if(box&&!box.publicIpv6&&provider!=="ssh"){const resolved=await resolveAttachTargetBox(attachTo,environment,tsCloudConfig);if(resolved.box?.publicIpv6)box={...box,publicIpv6:resolved.box.publicIpv6}}if(!box?.publicIp){log.error(describeAttachLookupFailure(attachTo,environment,lookup?.failure));process.exit(ExitCode.FatalError)}ip=box.publicIp;ipv6=box.publicIpv6;if((tsCloudConfig.project?.slug||"app")===attachTo){log.error(`This project's slug is '${attachTo}', which is the slug of the box it attaches to.`);log.error(`A tenant's deploy owns /etc/rpx/sites.d/<slug>.json, so deploying would overwrite '${attachTo}'s own gateway fragment and take its sites down.`);log.info(`Set a distinct project.slug in config/cloud.ts (e.g. '${p.projectPath().split("/").pop()}') and deploy again.`);process.exit(ExitCode.FatalError)}log.info(`Attaching to '${attachTo}' box '${box.serverName}' (${ip}) - skipping provisioning`);const guardTarget=sshTarget?{...sshTarget,host:ip}:ip;await assertFragmentIsOurs(guardTarget,tsCloudConfig,log);await assertPortsAreFree(guardTarget,tsCloudConfig,log);const compute=(tsCloudConfig.infrastructure??={}).compute??={};compute.webServer="rpx";const wantsPublicTls=dnsPublishingAllowed({provider,publicIp:ip,sites:tsCloudConfig.sites});compute.proxy={...wantsPublicTls?{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});const attachPin=provider==="ssh"&&sshTarget?sshStatePin({stackName,target:{...sshTarget,host:ip},lanIp:ip}):{stackName,serverId:box.serverId,serverName:box.serverName,publicIp:ip,...ipv6?{publicIpv6:ipv6}:{},sshUser:"root",deployStoragePath:"/var/ts-cloud/staging"};writeFileSync(join(stateDir,`${stackName}.json`),`${JSON.stringify(attachPin,null,2)}
|
|
34
|
+
`).map((line)=>line.trim()).filter(Boolean);assertNotBehindLiveRelease(automatedRevisionCheck,live);log.info(`Production serves ${live.join(", ")||"no release yet"}; ${automatedRevisionCheck.revision.sha.slice(0,7)} is newer, deploying it (${automatedRevisionCheck.newer.length} newer commit${automatedRevisionCheck.newer.length===1?"":"s"} still in CI).`)}async function runSshDeploy(args){const{tsCloudConfig,environment,verbose,docker,createCloudDriver,deployAllComputeSites,ensureManagementDashboard,resolveSiteKind,shipsARelease,onlySite,persistedAttachBox,provider,sshTarget}=args,startTime=performance.now(),targetLabel=deployTargetLabel(provider,sshTarget?.profile),host=sshTarget?`${sshTarget.user}@${sshTarget.host}${sshTarget.port===22?"":`:${sshTarget.port}`}`:`${tsCloudConfig.hetzner?.location||process.env.HCLOUD_LOCATION||"fsn1"} \xB7 ${tsCloudConfig.infrastructure?.compute?.size||"small"}`;console.log("");console.log(`\uD83D\uDE80 Deploy \u2192 ${targetLabel}`);if(verbose){console.log("");log.info(`Project: ${tsCloudConfig.project?.slug}`);log.info(`Environment: ${environment}`);if(sshTarget)log.info(`Host: ${host}`);else{log.info(`Location: ${tsCloudConfig.hetzner?.location||process.env.HCLOUD_LOCATION||"fsn1"}`);log.info(`Size: ${tsCloudConfig.infrastructure?.compute?.size||"small"}`)}}else console.log(` ${tsCloudConfig.project?.slug} \u2192 ${environment} \xB7 ${host}`);console.log("");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});if(!driver.provisionComputeInfrastructure){log.error(`The ${provider} driver does not support compute provisioning (update @stacksjs/ts-cloud).`);process.exit(ExitCode.FatalError)}const attachTo=tsCloudConfig.cloud?.attachTo;let ip,ipv6;if(attachTo){let lookup,box=persistedAttachBox;if(!box&&provider==="ssh"&&sshTarget)box={publicIp:sshTarget.host,serverName:sshTarget.host};if(!box){lookup=await resolveAttachTargetBox(attachTo,environment,tsCloudConfig);box=lookup.box}if(box&&!box.publicIpv6&&provider!=="ssh"){const resolved=await resolveAttachTargetBox(attachTo,environment,tsCloudConfig);if(resolved.box?.publicIpv6)box={...box,publicIpv6:resolved.box.publicIpv6}}if(!box?.publicIp){log.error(describeAttachLookupFailure(attachTo,environment,lookup?.failure));process.exit(ExitCode.FatalError)}ip=box.publicIp;ipv6=box.publicIpv6;if(tsCloudConfig.cloud?.residency){if(provider!=="hetzner")throw Error("Residency requires fresh Hetzner provider evidence for the attached host");const fresh=await resolveAttachTargetBox(attachTo,environment,tsCloudConfig);if(!fresh.box||fresh.box.publicIp!==box.publicIp||box.serverId&&fresh.box.serverId!==box.serverId)throw Error("Cannot verify residency of the pinned attachment target");assertAttachResidency(tsCloudConfig.cloud.residency,fresh.box);log.info(`Verified attached host residency: ${fresh.box.country}/${fresh.box.location}`)}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}) - skipping provisioning`);const guardTarget=sshTarget?{...sshTarget,host:ip}:ip;await assertFragmentIsOurs(guardTarget,tsCloudConfig,log);await assertPortsAreFree(guardTarget,tsCloudConfig,log);const compute=(tsCloudConfig.infrastructure??={}).compute??={};compute.webServer="rpx";const wantsPublicTls=dnsPublishingAllowed({provider,publicIp:ip,sites:tsCloudConfig.sites});compute.proxy={...wantsPublicTls?{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});const attachPin=provider==="ssh"&&sshTarget?sshStatePin({stackName,target:{...sshTarget,host:ip},lanIp:ip}):{stackName,serverId:box.serverId,serverName:box.serverName,publicIp:ip,...ipv6?{publicIpv6:ipv6}:{},sshUser:"root",deployStoragePath:"/var/ts-cloud/staging"};writeFileSync(join(stateDir,`${stackName}.json`),`${JSON.stringify(attachPin,null,2)}
|
|
35
35
|
`)}else{log.info(provider==="ssh"?`Adopting ${sshTarget?.host??"host"} (preflight, then bootstrap if needed)...`:"Provisioning Hetzner compute infrastructure...");const outputs=await driver.provisionComputeInfrastructure({config:scrubLoopbackSitePortsForFirewall(tsCloudConfig),environment});ip=outputs.appPublicIp;ipv6=outputs.appPublicIpv6;log.success(provider==="ssh"?"Host ready":"Hetzner compute infrastructure ready");if(outputs.appInstanceId&&provider!=="ssh")log.info(`Server ID: ${outputs.appInstanceId}`);if(provider==="ssh"&&sshTarget&&ip)try{const stackName=tsCloudConfig.project?.stackName||`${tsCloudConfig.project?.slug||"app"}-${environment}`,dir=join(process.cwd(),"storage","cloud","state"),statePath=join(dir,`${stackName}.json`);let existing=null;try{existing=existsSync(statePath)?JSON.parse(readFileSync(statePath,"utf8")):null}catch{existing=null}const pin=mergeSshStatePin(existing,sshStatePin({stackName,target:sshTarget,deployStoragePath:outputs.deployStoragePath}));mkdirSync(dir,{recursive:!0});writeFileSync(statePath,`${JSON.stringify(pin,null,2)}
|
|
36
|
-
`)}catch(err){log.warn(`Could not record the ssh host pin: ${getErrorMessage(err)}`)}}if(ip)log.info(provider==="ssh"?`Host: ${ip}`:`Server IP: ${ip}`);if(!ip){log.error("The deploy target has no reachable address - cannot deploy over SSH.");process.exit(ExitCode.FatalError)}await waitForRemoteReady(sshTarget?{...sshTarget,host:ip}:ip);if(onlySite&&!attachTo)await reconcilePartialDeployManagementDashboardsWithLiveBox(tsCloudConfig,sshTarget?{...sshTarget,host:ip}: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,releaseDir=mkdtempSync(join(tmpdir(),`${slug}-release-`)),removeReleaseDir=()=>rmSync(releaseDir,{recursive:!0,force:!0});process.once("exit",removeReleaseDir);for(const[siteName,site]of deploymentReleaseSites(sites,onlySite,{shipsARelease})){if(resolveSiteKind(site)==="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(releaseDir,`${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,args.envFile),sitesWithResolvedEnv=applyPreMigrationBackup(applyScheduledWork(applyPersistentStatePaths(applyAutomaticMigrations(mergeSiteDeployEnv(sites,resolvedDeployEnv)),slug),p.projectPath("app/Scheduler.ts")),projectDatabaseTarget(slug,"backups")),apiProblem=apiDeploymentProblem(sitesWithResolvedEnv,existsSync(p.projectPath("routes/api.ts")));if(apiProblem){log.error(apiProblem);throw Error("Refusing to deploy: the API would not be reachable.")}const{validateMigrationDialect}=await import("./migrate");for(const
|
|
36
|
+
`)}catch(err){log.warn(`Could not record the ssh host pin: ${getErrorMessage(err)}`)}}if(ip)log.info(provider==="ssh"?`Host: ${ip}`:`Server IP: ${ip}`);if(!ip){log.error("The deploy target has no reachable address - cannot deploy over SSH.");process.exit(ExitCode.FatalError)}await waitForRemoteReady(sshTarget?{...sshTarget,host:ip}:ip);if(onlySite&&!attachTo)await reconcilePartialDeployManagementDashboardsWithLiveBox(tsCloudConfig,sshTarget?{...sshTarget,host:ip}: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,releaseDir=mkdtempSync(join(tmpdir(),`${slug}-release-`)),removeReleaseDir=()=>rmSync(releaseDir,{recursive:!0,force:!0});process.once("exit",removeReleaseDir);for(const[siteName,site]of deploymentReleaseSites(sites,onlySite,{shipsARelease})){if(resolveSiteKind(site)==="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(releaseDir,`${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,args.envFile),sitesWithResolvedEnv=applyPreMigrationBackup(applyScheduledWork(applyPersistentStatePaths(applyAutomaticMigrations(mergeSiteDeployEnv(sites,resolvedDeployEnv)),slug),p.projectPath("app/Scheduler.ts")),projectDatabaseTarget(slug,"backups")),apiProblem=apiDeploymentProblem(sitesWithResolvedEnv,existsSync(p.projectPath("routes/api.ts")));if(apiProblem){log.error(apiProblem);throw Error("Refusing to deploy: the API would not be reachable.")}const{validateMigrationDialect}=await import("./migrate");for(const{root,driver}of siteDatabaseCorpora(sitesWithResolvedEnv,p.projectPath())){const dialect=validateMigrationDialect(root,{driver});if(!dialect.valid){log.error(dialect.error??`The committed migrations cannot run on ${driver}.`);throw Error(`Refusing to deploy: the migrations cannot run on ${driver}.`)}}for(const[envKey,envValue]of Object.entries(resolvedDeployEnv))if(process.env[envKey]===void 0)process.env[envKey]=envValue;await assertNotBehindLiveBox(tsCloudConfig.project?.slug,sshTarget?{...sshTarget,host:ip}:ip);const githubDeployments=await startGithubDeployments({sites,onlySite,environment,shipsARelease});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)}});removeReleaseDir();const dnsAllowed=dnsPublishingAllowed({provider,publicIp:ip,sites});if(ok&&!dnsAllowed&&provider==="ssh"){const reachable=lanUrls(onlySite?{[onlySite]:sites[onlySite]}:sites,sshTarget??hetznerTarget(ip),tsCloudConfig.ssh?.lan?.hostname);log.info("Private host: skipping DNS, TLS issuance, CDN and mail reconciliation.");log.info(`Reachable on the local network at ${reachable.join(", ")}`);log.info("To publish a domain, give the host a routable address and set ssh.publicIp, or set TS_CLOUD_SSH_PUBLISH_DNS=1.")}let publishedDns=[];if(ok&&dnsAllowed){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=sshCliArgs(sshTarget?{...sshTarget,host:ip}:hetznerTarget(ip),{connectTimeoutSec:20}),out=execSync(`ssh ${certSshArgs.map((a)=>`'${a}'`).join(" ")} bash -s`,{input:`systemctl start ${unit} 2>&1 || true
|
|
37
37
|
systemctl is-active ${unit} >/dev/null 2>&1 && echo TLSUNIT:running || echo TLSUNIT:done
|
|
38
38
|
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(`
|
|
39
39
|
`))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&&dnsAllowed)await reconcileConfigDns(onlySite?{[onlySite]:sites[onlySite]}:sites,log);if(ok&&dnsAllowed)await reconcileCloudflareCdnForDeploy(tsCloudConfig,ip,ipv6,log);if(ok&&dnsAllowed)await reconcileR2ForDeploy(tsCloudConfig,log);if(ok&&dnsAllowed)await reconcileWorkersForDeploy(tsCloudConfig,log);if(ok&&dnsAllowed){const mailOwner=mailServerOwnerFromConfig(emailConfig);let mailIp=ip;if(mailOwner){const mailLookup=await resolveAttachTargetBox(mailOwner,environment,tsCloudConfig);if(mailLookup.box?.publicIp){mailIp=mailLookup.box.publicIp;log.info(`Mail: reconciling on '${mailOwner}' box '${mailLookup.box.serverName}' (${mailIp})`)}else{mailIp=void 0;log.warn(`Mail: ${describeAttachLookupFailure(mailOwner,environment,mailLookup.failure)}`);log.warn("Mail: skipping mail reconciliation; the application deploy remains live.")}}const mailRes=mailIp?await provisionMailTenant(mailIp,log):null;if(mailOwner&&mailIp&&mailIp!==ip)await cleanupDetachedMailHealth(ip,log);if(mailRes)await reconcileMailDns(mailRes,mailIp,log)}for(const record of githubDeployments)await setGithubDeploymentStatus(record,ok?"success":"failure");console.log("");if(ok){const liveAt=dnsAllowed?publishedDns[0]?`https://${publishedDns[0]}`:`http://${ip}:3000`:lanUrls(onlySite?{[onlySite]:sites[onlySite]}:sites,sshTarget??hetznerTarget(ip),tsCloudConfig.ssh?.lan?.hostname).join(", ");await outro(`Deployed to ${targetLabel}. Your site is live at ${liveAt}`,{startTime,useSeconds:!0});log.info(`Coming-soon page: http://${ip}:3000 (bypass with ?secret=\u2026)`)}else{await outro(`${targetLabel} deploy reported a failure - see the per-instance output above.`,{startTime,useSeconds:!0,type:"error"});process.exit(ExitCode.FatalError)}}function resolveMailboxes(mailboxes,domain,generatePassword=!1){return resolveMailboxesWithSkipped(mailboxes,domain,generatePassword).boxes}function generateMailboxPassword(){return Buffer.from(crypto.getRandomValues(new Uint8Array(24))).toString("base64url")}export function resolveMailboxesWithSkipped(mailboxes,domain,generatePassword=!1){if(!Array.isArray(mailboxes))return{boxes:[],skipped:[]};const out=[],skipped=[];for(const entry of mailboxes){let raw,explicitPw,passwordPolicy="initial";if(typeof entry==="string")raw=entry;else if(entry&&typeof entry==="object"){raw=entry.email??entry.username;explicitPw=entry.password;if(entry.passwordPolicy==="managed")passwordPolicy="managed";if(entry.generate===!0)generatePassword=!0}if(!raw||typeof raw!=="string")continue;const localPart=(raw.includes("@")?raw.split("@")[0]??"":raw).trim();if(!localPart)continue;const address=`${localPart}@${domain}`,envKey=`MAIL_PASSWORD_${localPart.toUpperCase().replace(/[^A-Z0-9]/g,"_")}`,declaredPw=explicitPw||process.env[envKey],envPw=declaredPw&&/^enc(?:rypted)?:/.test(declaredPw)?void 0:declaredPw;if(declaredPw&&!envPw)log.warn(`Mail: ${envKey} is still encrypted (no key for it in this environment), so ${address} keeps its current password`);if(!envPw){if(generatePassword){out.push({address,localPart:localPart.toUpperCase(),password:generateMailboxPassword(),generated:!0,passwordPolicy});continue}skipped.push(address);continue}out.push({address,localPart:localPart.toUpperCase(),password:envPw,generated:!1,passwordPolicy})}return{boxes:out,skipped}}export function resolveMailboxRecoveries(mailboxes,domain){const recoveries=[],invalid=[];if(!Array.isArray(mailboxes))return{recoveries,invalid};for(const entry of mailboxes){if(!entry||typeof entry!=="object")continue;const raw=entry.email??entry.username,declared=entry.recovery;if(typeof raw!=="string"||declared===void 0||declared===null||declared==="")continue;const localPart=(raw.includes("@")?raw.split("@")[0]??"":raw).trim();if(!localPart)continue;const address=`${localPart}@${domain}`,recovery=typeof declared==="string"?declared.trim():"";if(!(/^[^\s@,;:"<>\\/'`$]+@[^\s@,;:"<>\\/'`$]+\.[^\s@,;:"<>\\/'`$]+$/.test(recovery)&&recovery.length<=254)||recovery.toLowerCase()===address.toLowerCase()){invalid.push(address);continue}const policy=entry.recoveryPolicy==="managed"?"managed":"initial";recoveries.push({address,recovery,policy})}return{recoveries,invalid}}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
|
|
@@ -47,7 +47,7 @@ rm -f /etc/systemd/system/mail-health.service /etc/systemd/system/mail-health.ti
|
|
|
47
47
|
rm -f /usr/local/sbin/mail-health-check /etc/systemd/system/mail.service.d/reliability.conf
|
|
48
48
|
rmdir /etc/systemd/system/mail.service.d 2>/dev/null || true
|
|
49
49
|
systemctl daemon-reload
|
|
50
|
-
systemctl reset-failed`;try{execSync(`ssh ${sshArgs.map((a)=>`'${a}'`).join(" ")} bash -s`,{input:script,encoding:"utf8",stdio:["pipe","pipe","pipe"]});logger.success("Mail: removed the detached health timer from the application box")}catch(err){logger.warn(`Mail: could not remove the detached health timer: ${getErrorMessage(err)}`)}}export function resolveHetznerApiToken(tsCloudConfig){return tsCloudConfig?.hetzner?.apiToken||process.env.HCLOUD_TOKEN||process.env.HETZNER_API_TOKEN}export function describeAttachLookupFailure(owner,environment,failure){if(failure?.kind==="no-token")return`Attach target '${owner}': no Hetzner API token to look it up with, so no lookup was attempted. Set HCLOUD_TOKEN (or HETZNER_API_TOKEN, or hetzner.apiToken in config/cloud.ts).`;if(failure?.kind==="request-failed"){const where=failure.status>0?`returned HTTP ${failure.status}`:"could not be reached";return`Attach target '${owner}': the Hetzner API ${where}, so whether the box exists is unknown.`+(failure.detail?` ${failure.detail}`:"")+(failure.status===401||failure.status===403?" That is an auth failure, not a missing server: check the token is valid for this Hetzner project.":"")}return`Attach target '${owner}' has no reachable box for '${environment}'. Nothing matched ts-cloud/project=${owner},ts-cloud/environment=${environment},ts-cloud/role=app, and no server is named '${owner}-${environment}-app'. Is it provisioned (by its owner)?`}export async function resolveAttachTargetBox(owner,environment,tsCloudConfig){const token=resolveHetznerApiToken(tsCloudConfig);if(!token)return{box:null,failure:{kind:"no-token"}};const pick=(servers)=>servers.find((s)=>s?.status!=="off"&&s?.public_net?.ipv4?.ip)||servers[0];let requestFailure;const req=async(qs)=>{try{const res=await fetch(`https://api.hetzner.cloud/v1/servers?${qs}`,{headers:{Authorization:`Bearer ${token}`}});if(!res.ok){const body=await res.text().catch(()=>"");requestFailure??={kind:"request-failed",status:res.status,detail:pollFailureDetail(body)};return[]}return(await res.json()).servers||[]}catch(err){requestFailure??={kind:"request-failed",status:0,detail:pollFailureDetail(getErrorMessage(err))};return[]}},byLabel=await req(`label_selector=${encodeURIComponent(`ts-cloud/project=${owner},ts-cloud/environment=${environment},ts-cloud/role=app`)}`),chosen=pick(byLabel)||pick(await req(`name=${encodeURIComponent(`${owner}-${environment}-app`)}`));if(!chosen)return{box:null,failure:requestFailure??{kind:"no-match"}};const{normalizePublicIpv6}=await import("@stacksjs/ts-cloud");if(typeof normalizePublicIpv6!=="function")log.warn("DNS: @stacksjs/ts-cloud does not export normalizePublicIpv6 - AAAA records will be skipped. Upgrade ts-cloud.");const reportedIpv6=chosen.public_net?.ipv6?.ip;return{box:{serverId:chosen.id,serverName:chosen.name,publicIp:chosen.public_net?.ipv4?.ip,publicIpv6:typeof normalizePublicIpv6==="function"?normalizePublicIpv6(reportedIpv6):void 0}}}export function canRevealSecrets(env=process.env,stdout=process.stdout){if(env.CI||env.CONTINUOUS_INTEGRATION||env.GITHUB_ACTIONS||env.BUILD_NUMBER||env.RUN_ID)return!1;return Boolean(stdout.isTTY)}export function reportRecoveryReconcile(logger,out,declared){const pick=(key)=>[...out.matchAll(new RegExp(`^${key}:([^\\n]+)`,"gm"))].flatMap((m)=>m[1]?[m[1].trim()]:[]),set=pick("RECOVERY_SET"),same=pick("RECOVERY_SAME"),kept=pick("RECOVERY_KEPT"),nobox=pick("RECOVERY_NOBOX"),old=pick("RECOVERY_OLDSERVER"),failed=pick("RECOVERY_FAIL");if(set.length)logger.success(`Mail: set the recovery address of ${set.join(", ")}`);if(kept.length)logger.info(`Mail: kept the recovery address the owner chose in webmail for ${kept.join(", ")} (recoveryPolicy: 'managed' overrides it)`);if(nobox.length)logger.warn(`Mail: no recovery address set for ${nobox.join(", ")}: the mailbox does not exist on the mail server yet`);if(old.length)logger.warn(`Mail: the mail server is too old for recovery addresses, so none was set for ${old.join(", ")}. Upgrade it (mail upgrade) and deploy again.`);if(failed.length)logger.warn(`Mail: the mail server refused the recovery address of ${failed.join(", ")}`);const seen=new Set([...set,...same,...kept,...nobox,...old,...failed]),missing=declared.map((r)=>r.address).filter((address)=>!seen.has(address));if(missing.length)logger.warn(`Mail: the recovery address of ${missing.join(", ")} was not reconciled`)}function mailboxPasswordKey(box){return`MAIL_PASSWORD_${box.localPart.replace(/[^A-Z0-9]/g,"_")}`}export function reportMailboxCredentials(logger,created,options){const declared=created.filter((box)=>!box.generated);if(declared.length)logger.info(`Mail: created ${declared.length} mailbox(es) with their declared password (config/email.ts or MAIL_PASSWORD_<LOCALPART>): ${declared.map((box)=>box.address).join(", ")}`);const generated=created.filter((box)=>box.generated);if(!generated.length)return;if(options.interactive){logger.info(options.persisted?`Mail: created ${generated.length} mailbox(es) with generated passwords, saved encrypted to .env.production and shown once here:`:`Mail: created ${generated.length} mailbox(es) with generated passwords that were NOT saved - store them now, they are shown once:`);for(const box of generated)logger.info(` ${box.address} ${box.password}`);return}if(!options.persisted){logger.warn(`Mail: ${generated.length} mailbox(es) have generated passwords that were neither saved nor shown: ${generated.map((box)=>box.address).join(", ")}. Set ${generated.map(mailboxPasswordKey).join(", ")} and deploy again.`);return}logger.info(`Mail: created ${generated.length} mailbox(es) with generated passwords, saved encrypted to .env.production (not printed: this is not an interactive terminal):`);for(const box of generated)logger.info(` ${box.address} -> buddy env:get ${mailboxPasswordKey(box)} --file .env.production`)}export async function provisionMailTenant(ip,logger){if(!hasExplicitEmailConfig())return null;const cfg=emailConfig||{};if(cfg.server?.enabled===!1)return null;const domain=cfg.domain||(typeof cfg.from?.address==="string"&&cfg.from.address.includes("@")?cfg.from.address.split("@")[1]:void 0),declaredForwards=cfg.forwards&&typeof cfg.forwards==="object"?cfg.forwards:{},forwards={...declaredForwards},declaredBoxes=new Set(domain?resolveMailboxes(cfg.mailboxes,domain).map((b)=>b.address):[]);for(const[key,targets]of Object.entries(declaredForwards)){const at=key.indexOf("@");if(at===-1||declaredBoxes.has(key))continue;const localPart=key.slice(0,at);if(key.slice(at+1)!==domain||forwards[localPart])continue;forwards[localPart]=targets}const hasForwards=Object.keys(forwards).length>0,generatePasswords=cfg.server?.generatePasswords===!0,resolved=domain?resolveMailboxesWithSkipped(cfg.mailboxes,domain,generatePasswords):{boxes:[],skipped:[]};let boxes=resolved.boxes;const interactive=canRevealSecrets(),generated=boxes.filter((box)=>box.generated);let persisted=!0;if(generated.length>0)try{const{setEnv}=await import("@stacksjs/env");for(const box of generated)await setEnv(mailboxPasswordKey(box),box.password,{file:".env.production"});logger.success(`Mail: generated and saved ${generated.length} mailbox password(s) to .env.production (encrypted)`)}catch(err){persisted=!1;if(interactive)logger.warn(`Mail: could not save the generated password(s) to .env.production - they are printed below and will otherwise be regenerated next deploy: ${getErrorMessage(err)}`);else{const unsaved=new Set(generated.map((box)=>box.address));boxes=boxes.filter((box)=>!unsaved.has(box.address));logger.warn(`Mail: could not save the generated password(s) to .env.production, and this is not an interactive terminal, so ${[...unsaved].join(", ")} were not created: ${getErrorMessage(err)}`)}}if(resolved.skipped.length>0){logger.warn(`Mail: ${resolved.skipped.length} declared mailbox(es) were not created because no password was supplied: ${resolved.skipped.join(", ")}`);logger.info(`Set MAIL_PASSWORD_<LOCALPART> in the target environment (e.g. ${resolved.skipped[0]?.split("@")[0]?.toUpperCase().replace(/[^A-Z0-9]/g,"_")}) and run this again.`)}const declaredRecoveries=domain?resolveMailboxRecoveries(cfg.mailboxes,domain):{recoveries:[],invalid:[]};if(declaredRecoveries.invalid.length>0)logger.warn(`Mail: ignored the recovery address of ${declaredRecoveries.invalid.join(", ")} in config/email.ts: it must be a valid address outside the mailbox itself`);if(!domain&&!hasForwards)return null;const{execSync}=await import("node:child_process"),sshArgs=["-o","StrictHostKeyChecking=accept-new","-o","BatchMode=yes","-o","ConnectTimeout=20",`root@${ip}`],forwardsB64=hasForwards?Buffer.from(JSON.stringify(forwards)).toString("base64"):"",readme="Auto-forwarding rules, re-read on every message (edits take effect immediately, no restart). KEY = the delivered mailbox: the FULL address for per-domain isolated mailboxes (e.g. no-reply@app.com), or a bare local-part for legacy role mailboxes. VALUE = list of destination addresses; targets on a local domain are written straight to that mailbox Maildir, external targets are relayed. Managed by buddy deploy from config/email.ts (merge-based - hand edits to other keys are preserved).",readmeB64=Buffer.from(readme).toString("base64"),boxesB64=boxes.length?Buffer.from(`${boxes.map((b)=>`${b.address} ${b.password} ${b.passwordPolicy}`).join(`
|
|
50
|
+
systemctl reset-failed`;try{execSync(`ssh ${sshArgs.map((a)=>`'${a}'`).join(" ")} bash -s`,{input:script,encoding:"utf8",stdio:["pipe","pipe","pipe"]});logger.success("Mail: removed the detached health timer from the application box")}catch(err){logger.warn(`Mail: could not remove the detached health timer: ${getErrorMessage(err)}`)}}export function resolveHetznerApiToken(tsCloudConfig){return tsCloudConfig?.hetzner?.apiToken||process.env.HCLOUD_TOKEN||process.env.HETZNER_API_TOKEN}export function assertAttachResidency(policy,box){assertComputeResidency(policy,box)}export function describeAttachLookupFailure(owner,environment,failure){if(failure?.kind==="no-token")return`Attach target '${owner}': no Hetzner API token to look it up with, so no lookup was attempted. Set HCLOUD_TOKEN (or HETZNER_API_TOKEN, or hetzner.apiToken in config/cloud.ts).`;if(failure?.kind==="request-failed"){const where=failure.status>0?`returned HTTP ${failure.status}`:"could not be reached";return`Attach target '${owner}': the Hetzner API ${where}, so whether the box exists is unknown.`+(failure.detail?` ${failure.detail}`:"")+(failure.status===401||failure.status===403?" That is an auth failure, not a missing server: check the token is valid for this Hetzner project.":"")}return`Attach target '${owner}' has no reachable box for '${environment}'. Nothing matched ts-cloud/project=${owner},ts-cloud/environment=${environment},ts-cloud/role=app, and no server is named '${owner}-${environment}-app'. Is it provisioned (by its owner)?`}export async function resolveAttachTargetBox(owner,environment,tsCloudConfig){const token=resolveHetznerApiToken(tsCloudConfig);if(!token)return{box:null,failure:{kind:"no-token"}};const pick=(servers)=>servers.find((s)=>s?.status!=="off"&&s?.public_net?.ipv4?.ip)||servers[0];let requestFailure;const req=async(qs)=>{try{const res=await fetch(`https://api.hetzner.cloud/v1/servers?${qs}`,{headers:{Authorization:`Bearer ${token}`}});if(!res.ok){const body=await res.text().catch(()=>"");requestFailure??={kind:"request-failed",status:res.status,detail:pollFailureDetail(body)};return[]}return(await res.json()).servers||[]}catch(err){requestFailure??={kind:"request-failed",status:0,detail:pollFailureDetail(getErrorMessage(err))};return[]}},byLabel=await req(`label_selector=${encodeURIComponent(`ts-cloud/project=${owner},ts-cloud/environment=${environment},ts-cloud/role=app`)}`),chosen=pick(byLabel)||pick(await req(`name=${encodeURIComponent(`${owner}-${environment}-app`)}`));if(!chosen)return{box:null,failure:requestFailure??{kind:"no-match"}};const{normalizePublicIpv6}=await import("@stacksjs/ts-cloud");if(typeof normalizePublicIpv6!=="function")log.warn("DNS: @stacksjs/ts-cloud does not export normalizePublicIpv6 - AAAA records will be skipped. Upgrade ts-cloud.");const reportedIpv6=chosen.public_net?.ipv6?.ip;return{box:{serverId:chosen.id,country:chosen.location?.country||chosen.datacenter?.location?.country,location:chosen.location?.name||chosen.datacenter?.location?.name,serverName:chosen.name,publicIp:chosen.public_net?.ipv4?.ip,publicIpv6:typeof normalizePublicIpv6==="function"?normalizePublicIpv6(reportedIpv6):void 0}}}export function canRevealSecrets(env=process.env,stdout=process.stdout){if(env.CI||env.CONTINUOUS_INTEGRATION||env.GITHUB_ACTIONS||env.BUILD_NUMBER||env.RUN_ID)return!1;return Boolean(stdout.isTTY)}export function reportRecoveryReconcile(logger,out,declared){const pick=(key)=>[...out.matchAll(new RegExp(`^${key}:([^\\n]+)`,"gm"))].flatMap((m)=>m[1]?[m[1].trim()]:[]),set=pick("RECOVERY_SET"),same=pick("RECOVERY_SAME"),kept=pick("RECOVERY_KEPT"),nobox=pick("RECOVERY_NOBOX"),old=pick("RECOVERY_OLDSERVER"),failed=pick("RECOVERY_FAIL");if(set.length)logger.success(`Mail: set the recovery address of ${set.join(", ")}`);if(kept.length)logger.info(`Mail: kept the recovery address the owner chose in webmail for ${kept.join(", ")} (recoveryPolicy: 'managed' overrides it)`);if(nobox.length)logger.warn(`Mail: no recovery address set for ${nobox.join(", ")}: the mailbox does not exist on the mail server yet`);if(old.length)logger.warn(`Mail: the mail server is too old for recovery addresses, so none was set for ${old.join(", ")}. Upgrade it (mail upgrade) and deploy again.`);if(failed.length)logger.warn(`Mail: the mail server refused the recovery address of ${failed.join(", ")}`);const seen=new Set([...set,...same,...kept,...nobox,...old,...failed]),missing=declared.map((r)=>r.address).filter((address)=>!seen.has(address));if(missing.length)logger.warn(`Mail: the recovery address of ${missing.join(", ")} was not reconciled`)}function mailboxPasswordKey(box){return`MAIL_PASSWORD_${box.localPart.replace(/[^A-Z0-9]/g,"_")}`}export function reportMailboxCredentials(logger,created,options){const declared=created.filter((box)=>!box.generated);if(declared.length)logger.info(`Mail: created ${declared.length} mailbox(es) with their declared password (config/email.ts or MAIL_PASSWORD_<LOCALPART>): ${declared.map((box)=>box.address).join(", ")}`);const generated=created.filter((box)=>box.generated);if(!generated.length)return;if(options.interactive){logger.info(options.persisted?`Mail: created ${generated.length} mailbox(es) with generated passwords, saved encrypted to .env.production and shown once here:`:`Mail: created ${generated.length} mailbox(es) with generated passwords that were NOT saved - store them now, they are shown once:`);for(const box of generated)logger.info(` ${box.address} ${box.password}`);return}if(!options.persisted){logger.warn(`Mail: ${generated.length} mailbox(es) have generated passwords that were neither saved nor shown: ${generated.map((box)=>box.address).join(", ")}. Set ${generated.map(mailboxPasswordKey).join(", ")} and deploy again.`);return}logger.info(`Mail: created ${generated.length} mailbox(es) with generated passwords, saved encrypted to .env.production (not printed: this is not an interactive terminal):`);for(const box of generated)logger.info(` ${box.address} -> buddy env:get ${mailboxPasswordKey(box)} --file .env.production`)}export async function provisionMailTenant(ip,logger){if(!hasExplicitEmailConfig())return null;const cfg=emailConfig||{};if(cfg.server?.enabled===!1)return null;const domain=cfg.domain||(typeof cfg.from?.address==="string"&&cfg.from.address.includes("@")?cfg.from.address.split("@")[1]:void 0),declaredForwards=cfg.forwards&&typeof cfg.forwards==="object"?cfg.forwards:{},forwards={...declaredForwards},declaredBoxes=new Set(domain?resolveMailboxes(cfg.mailboxes,domain).map((b)=>b.address):[]);for(const[key,targets]of Object.entries(declaredForwards)){const at=key.indexOf("@");if(at===-1||declaredBoxes.has(key))continue;const localPart=key.slice(0,at);if(key.slice(at+1)!==domain||forwards[localPart])continue;forwards[localPart]=targets}const hasForwards=Object.keys(forwards).length>0,generatePasswords=cfg.server?.generatePasswords===!0,resolved=domain?resolveMailboxesWithSkipped(cfg.mailboxes,domain,generatePasswords):{boxes:[],skipped:[]};let boxes=resolved.boxes;const interactive=canRevealSecrets(),generated=boxes.filter((box)=>box.generated);let persisted=!0;if(generated.length>0)try{const{setEnv}=await import("@stacksjs/env");for(const box of generated)await setEnv(mailboxPasswordKey(box),box.password,{file:".env.production"});logger.success(`Mail: generated and saved ${generated.length} mailbox password(s) to .env.production (encrypted)`)}catch(err){persisted=!1;if(interactive)logger.warn(`Mail: could not save the generated password(s) to .env.production - they are printed below and will otherwise be regenerated next deploy: ${getErrorMessage(err)}`);else{const unsaved=new Set(generated.map((box)=>box.address));boxes=boxes.filter((box)=>!unsaved.has(box.address));logger.warn(`Mail: could not save the generated password(s) to .env.production, and this is not an interactive terminal, so ${[...unsaved].join(", ")} were not created: ${getErrorMessage(err)}`)}}if(resolved.skipped.length>0){logger.warn(`Mail: ${resolved.skipped.length} declared mailbox(es) were not created because no password was supplied: ${resolved.skipped.join(", ")}`);logger.info(`Set MAIL_PASSWORD_<LOCALPART> in the target environment (e.g. ${resolved.skipped[0]?.split("@")[0]?.toUpperCase().replace(/[^A-Z0-9]/g,"_")}) and run this again.`)}const declaredRecoveries=domain?resolveMailboxRecoveries(cfg.mailboxes,domain):{recoveries:[],invalid:[]};if(declaredRecoveries.invalid.length>0)logger.warn(`Mail: ignored the recovery address of ${declaredRecoveries.invalid.join(", ")} in config/email.ts: it must be a valid address outside the mailbox itself`);if(!domain&&!hasForwards)return null;const{execSync}=await import("node:child_process"),sshArgs=["-o","StrictHostKeyChecking=accept-new","-o","BatchMode=yes","-o","ConnectTimeout=20",`root@${ip}`],forwardsB64=hasForwards?Buffer.from(JSON.stringify(forwards)).toString("base64"):"",readme="Auto-forwarding rules, re-read on every message (edits take effect immediately, no restart). KEY = the delivered mailbox: the FULL address for per-domain isolated mailboxes (e.g. no-reply@app.com), or a bare local-part for legacy role mailboxes. VALUE = list of destination addresses; targets on a local domain are written straight to that mailbox Maildir, external targets are relayed. Managed by buddy deploy from config/email.ts (merge-based - hand edits to other keys are preserved).",readmeB64=Buffer.from(readme).toString("base64"),boxesB64=boxes.length?Buffer.from(`${boxes.map((b)=>`${b.address} ${b.password} ${b.passwordPolicy}`).join(`
|
|
51
51
|
`)}
|
|
52
52
|
`).toString("base64"):"",recoveries=declaredRecoveries.recoveries,recoveriesB64=recoveries.length?Buffer.from(`${recoveries.map((r)=>`${r.address} ${r.recovery} ${r.policy}`).join(`
|
|
53
53
|
`)}
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@stacksjs/buddy",
|
|
3
3
|
"type": "module",
|
|
4
4
|
"sideEffects": false,
|
|
5
|
-
"version": "0.75.
|
|
5
|
+
"version": "0.75.373",
|
|
6
6
|
"description": "Meet Buddy. The Stacks runtime.",
|
|
7
7
|
"author": "Chris Breuer",
|
|
8
8
|
"contributors": [
|
|
@@ -95,69 +95,69 @@
|
|
|
95
95
|
"prepublishOnly": "bun run build"
|
|
96
96
|
},
|
|
97
97
|
"dependencies": {
|
|
98
|
-
"@stacksjs/actions": "0.75.
|
|
99
|
-
"@stacksjs/ai": "0.75.
|
|
100
|
-
"@stacksjs/alias": "0.75.
|
|
101
|
-
"@stacksjs/analytics": "0.75.
|
|
102
|
-
"@stacksjs/api": "0.75.
|
|
103
|
-
"@stacksjs/arrays": "0.75.
|
|
104
|
-
"@stacksjs/auth": "0.75.
|
|
105
|
-
"@stacksjs/browser-extension": "0.75.
|
|
106
|
-
"@stacksjs/build": "0.75.
|
|
98
|
+
"@stacksjs/actions": "0.75.373",
|
|
99
|
+
"@stacksjs/ai": "0.75.373",
|
|
100
|
+
"@stacksjs/alias": "0.75.373",
|
|
101
|
+
"@stacksjs/analytics": "0.75.373",
|
|
102
|
+
"@stacksjs/api": "0.75.373",
|
|
103
|
+
"@stacksjs/arrays": "0.75.373",
|
|
104
|
+
"@stacksjs/auth": "0.75.373",
|
|
105
|
+
"@stacksjs/browser-extension": "0.75.373",
|
|
106
|
+
"@stacksjs/build": "0.75.373",
|
|
107
107
|
"@stacksjs/bumpx": "^0.2.26",
|
|
108
|
-
"@stacksjs/cache": "0.75.
|
|
109
|
-
"@stacksjs/chat": "0.75.
|
|
108
|
+
"@stacksjs/cache": "0.75.373",
|
|
109
|
+
"@stacksjs/chat": "0.75.373",
|
|
110
110
|
"@stacksjs/clapp": "^0.2.16",
|
|
111
|
-
"@stacksjs/cli": "0.75.
|
|
112
|
-
"@stacksjs/cloud": "0.75.
|
|
113
|
-
"@stacksjs/cms": "0.75.
|
|
114
|
-
"@stacksjs/collections": "0.75.
|
|
115
|
-
"@stacksjs/commerce": "0.75.
|
|
116
|
-
"@stacksjs/config": "0.75.
|
|
117
|
-
"@stacksjs/database": "0.75.
|
|
118
|
-
"@stacksjs/desktop-build": "0.75.
|
|
119
|
-
"@stacksjs/dns": "0.75.
|
|
111
|
+
"@stacksjs/cli": "0.75.373",
|
|
112
|
+
"@stacksjs/cloud": "0.75.373",
|
|
113
|
+
"@stacksjs/cms": "0.75.373",
|
|
114
|
+
"@stacksjs/collections": "0.75.373",
|
|
115
|
+
"@stacksjs/commerce": "0.75.373",
|
|
116
|
+
"@stacksjs/config": "0.75.373",
|
|
117
|
+
"@stacksjs/database": "0.75.373",
|
|
118
|
+
"@stacksjs/desktop-build": "0.75.373",
|
|
119
|
+
"@stacksjs/dns": "0.75.373",
|
|
120
120
|
"@stacksjs/dnsx": "^0.2.3",
|
|
121
|
-
"@stacksjs/email": "0.75.
|
|
122
|
-
"@stacksjs/enums": "0.75.
|
|
123
|
-
"@stacksjs/env": "0.75.
|
|
124
|
-
"@stacksjs/error-handling": "0.75.
|
|
125
|
-
"@stacksjs/events": "0.75.
|
|
126
|
-
"@stacksjs/features": "0.75.
|
|
127
|
-
"@stacksjs/git": "0.75.
|
|
121
|
+
"@stacksjs/email": "0.75.373",
|
|
122
|
+
"@stacksjs/enums": "0.75.373",
|
|
123
|
+
"@stacksjs/env": "0.75.373",
|
|
124
|
+
"@stacksjs/error-handling": "0.75.373",
|
|
125
|
+
"@stacksjs/events": "0.75.373",
|
|
126
|
+
"@stacksjs/features": "0.75.373",
|
|
127
|
+
"@stacksjs/git": "0.75.373",
|
|
128
128
|
"@stacksjs/gitit": "^0.2.5",
|
|
129
|
-
"@stacksjs/health": "0.75.
|
|
129
|
+
"@stacksjs/health": "0.75.373",
|
|
130
130
|
"@stacksjs/httx": "^0.1.10",
|
|
131
|
-
"@stacksjs/image": "0.75.
|
|
132
|
-
"@stacksjs/lint": "0.75.
|
|
133
|
-
"@stacksjs/logging": "0.75.
|
|
134
|
-
"@stacksjs/notifications": "0.75.
|
|
135
|
-
"@stacksjs/objects": "0.75.
|
|
136
|
-
"@stacksjs/orm": "0.75.
|
|
137
|
-
"@stacksjs/path": "0.75.
|
|
138
|
-
"@stacksjs/payments": "0.75.
|
|
139
|
-
"@stacksjs/realtime": "0.75.
|
|
140
|
-
"@stacksjs/router": "0.75.
|
|
141
|
-
"@stacksjs/security": "0.75.371",
|
|
131
|
+
"@stacksjs/image": "0.75.373",
|
|
132
|
+
"@stacksjs/lint": "0.75.373",
|
|
133
|
+
"@stacksjs/logging": "0.75.373",
|
|
134
|
+
"@stacksjs/notifications": "0.75.373",
|
|
135
|
+
"@stacksjs/objects": "0.75.373",
|
|
136
|
+
"@stacksjs/orm": "0.75.373",
|
|
137
|
+
"@stacksjs/path": "0.75.373",
|
|
138
|
+
"@stacksjs/payments": "0.75.373",
|
|
139
|
+
"@stacksjs/realtime": "0.75.373",
|
|
140
|
+
"@stacksjs/router": "0.75.373",
|
|
142
141
|
"@stacksjs/rpx": "^0.11.54",
|
|
143
|
-
"@stacksjs/scheduler": "0.75.
|
|
144
|
-
"@stacksjs/search-engine": "0.75.
|
|
145
|
-
"@stacksjs/
|
|
146
|
-
"@stacksjs/
|
|
147
|
-
"@stacksjs/
|
|
148
|
-
"@stacksjs/
|
|
149
|
-
"@stacksjs/
|
|
150
|
-
"@stacksjs/
|
|
142
|
+
"@stacksjs/scheduler": "0.75.373",
|
|
143
|
+
"@stacksjs/search-engine": "0.75.373",
|
|
144
|
+
"@stacksjs/security": "0.75.373",
|
|
145
|
+
"@stacksjs/server": "0.75.373",
|
|
146
|
+
"@stacksjs/sites": "0.75.373",
|
|
147
|
+
"@stacksjs/skills": "0.75.373",
|
|
148
|
+
"@stacksjs/socials": "0.75.373",
|
|
149
|
+
"@stacksjs/storage": "0.75.373",
|
|
150
|
+
"@stacksjs/strings": "0.75.373",
|
|
151
151
|
"@stacksjs/stx": "0.2.744",
|
|
152
|
-
"@stacksjs/testing": "0.75.
|
|
153
|
-
"@stacksjs/tinker": "0.75.
|
|
152
|
+
"@stacksjs/testing": "0.75.373",
|
|
153
|
+
"@stacksjs/tinker": "0.75.373",
|
|
154
154
|
"@stacksjs/tlsx": "^0.13.19",
|
|
155
|
-
"@stacksjs/ts-cloud": "^0.16.
|
|
156
|
-
"@stacksjs/tunnel": "0.75.
|
|
157
|
-
"@stacksjs/types": "0.75.
|
|
158
|
-
"@stacksjs/ui": "0.75.
|
|
159
|
-
"@stacksjs/utils": "0.75.
|
|
160
|
-
"@stacksjs/validation": "0.75.
|
|
155
|
+
"@stacksjs/ts-cloud": "^0.16.72",
|
|
156
|
+
"@stacksjs/tunnel": "0.75.373",
|
|
157
|
+
"@stacksjs/types": "0.75.373",
|
|
158
|
+
"@stacksjs/ui": "0.75.373",
|
|
159
|
+
"@stacksjs/utils": "0.75.373",
|
|
160
|
+
"@stacksjs/validation": "0.75.373",
|
|
161
161
|
"ajv": "^8.20.0",
|
|
162
162
|
"ajv-formats": "^3.0.1",
|
|
163
163
|
"bun-plugin-stx": "0.2.744",
|