@stacksjs/buddy 0.70.366 → 0.70.367
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-preview.d.ts +52 -0
- package/dist/commands/deploy-preview.js +4 -0
- package/dist/commands/deploy.d.ts +9 -0
- package/dist/commands/deploy.js +3 -3
- package/dist/commands/doctor.js +1 -1
- package/dist/commands/index.d.ts +1 -0
- package/dist/commands/index.js +1 -1
- package/dist/commands/schedule.js +1 -1
- package/dist/lazy-commands.js +1 -1
- package/dist/unbacked-data.d.ts +54 -0
- package/dist/unbacked-data.js +1 -0
- package/package.json +43 -43
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { DeploymentPreview, DeploymentSiteKind } from '@stacksjs/types';
|
|
2
|
+
export declare function resolveDeploymentEnvironment(options: ResolveDeploymentEnvironmentOptions): string;
|
|
3
|
+
export declare function applyDeploymentDomainOverride<T extends DeploymentPreviewConfig>(config: T, domain?: unknown): T;
|
|
4
|
+
export declare function createDeploymentPreview(options: CreateDeploymentPreviewOptions): DeploymentPreview;
|
|
5
|
+
export declare function formatDeploymentPreview(plan: DeploymentPreview): string;
|
|
6
|
+
export declare const deploymentPreviewJsonPrefix: 'STACKS_DEPLOY_PREVIEW_JSON=';
|
|
7
|
+
declare interface DeploymentPreviewConfig {
|
|
8
|
+
project?: {
|
|
9
|
+
name?: string
|
|
10
|
+
slug?: string
|
|
11
|
+
region?: string
|
|
12
|
+
}
|
|
13
|
+
cloud?: {
|
|
14
|
+
provider?: string
|
|
15
|
+
attachTo?: string
|
|
16
|
+
}
|
|
17
|
+
infrastructure?: {
|
|
18
|
+
compute?: {
|
|
19
|
+
size?: string
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
environments?: Record<string, {
|
|
23
|
+
region?: string
|
|
24
|
+
}>
|
|
25
|
+
mode?: string
|
|
26
|
+
sites?: Record<string, Record<string, unknown> | null | undefined>
|
|
27
|
+
}
|
|
28
|
+
export declare interface CreateDeploymentPreviewOptions {
|
|
29
|
+
config?: DeploymentPreviewConfig
|
|
30
|
+
environment: string
|
|
31
|
+
site?: string
|
|
32
|
+
domain?: string
|
|
33
|
+
docker?: boolean
|
|
34
|
+
fallbackProjectName?: string
|
|
35
|
+
fallbackProjectSlug?: string
|
|
36
|
+
fallbackProvider?: string
|
|
37
|
+
fallbackMode?: string
|
|
38
|
+
fallbackRegion?: string
|
|
39
|
+
resolveSiteKind: (site: Record<string, unknown>) => DeploymentSiteKind
|
|
40
|
+
applyEnvironmentToSites: (
|
|
41
|
+
sites: Record<string, Record<string, unknown> | null | undefined>,
|
|
42
|
+
environment: string,
|
|
43
|
+
config: DeploymentPreviewConfig,
|
|
44
|
+
) => Record<string, Record<string, unknown> | null | undefined>
|
|
45
|
+
warnings?: string[]
|
|
46
|
+
}
|
|
47
|
+
export declare interface ResolveDeploymentEnvironmentOptions {
|
|
48
|
+
positional?: string
|
|
49
|
+
option?: string
|
|
50
|
+
staging?: boolean
|
|
51
|
+
development?: boolean
|
|
52
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export function resolveDeploymentEnvironment(options){const requested=options.positional||options.option||(options.staging?"staging":options.development?"development":"production");return requested==="prod"?"production":requested==="dev"?"development":requested}export function applyDeploymentDomainOverride(config,domain){if(domain!==void 0&&typeof domain!=="string")throw Error("Domain must be a valid DNS name.");const override=domain?.trim().toLowerCase();if(!override)return config;if(!/^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/.test(override))throw Error("Domain must be a valid DNS name.");const entries=Object.entries(config.sites||{}),primary=entries.map(([,site])=>site).find((site)=>site?.path==="/"&&typeof site.start==="string"&&strings(site.domain).length>0)||entries.map(([,site])=>site).find((site)=>strings(site?.domain).length>0),current=strings(primary?.domain)[0]?.toLowerCase();if(!current||current===override)return config;const replaceHost=(value)=>{const normalized=value.toLowerCase();if(normalized===current)return override;if(normalized.endsWith(`.${current}`))return`${value.slice(0,-current.length)}${override}`;return value.replace(new RegExp(`(https?://)((?:[a-z0-9-]+\\.)*)${current.replace(/[.]/g,"\\.")}(?=[:/?#]|$)`,"gi"),(_match,scheme,prefix)=>`${scheme}${prefix}${override}`)};return{...config,sites:Object.fromEntries(entries.map(([name,site])=>{if(!site)return[name,site];const next={...site};if(typeof next.domain==="string")next.domain=replaceHost(next.domain);else if(Array.isArray(next.domain))next.domain=next.domain.map((value)=>typeof value==="string"?replaceHost(value):value);if(typeof next.redirect==="string")next.redirect=replaceHost(next.redirect);else if(next.redirect&&typeof next.redirect==="object"&&!Array.isArray(next.redirect)){const redirect={...next.redirect};if(typeof redirect.to==="string")redirect.to=replaceHost(redirect.to);next.redirect=redirect}return[name,next]}))}}function strings(value){if(typeof value==="string"&&value.trim())return[value.trim()];if(!Array.isArray(value))return[];return value.filter((entry)=>typeof entry==="string"&&entry.trim().length>0)}function previewSite(name,site,resolveSiteKind){const port=typeof site.port==="number"&&Number.isInteger(site.port)?site.port:null;return{name,kind:resolveSiteKind(site),domains:strings(site.domain),path:typeof site.path==="string"&&site.path?site.path:"/",root:typeof site.root==="string"&&site.root?site.root:null,port,build:typeof site.build==="string"&&site.build?site.build:null,preStart:strings(site.preStart)}}function operation(phase,label,detail,sites=[]){return{phase,label,detail,sites}}export function createDeploymentPreview(options){const config=applyDeploymentDomainOverride(options.config||{},options.domain),configuredSites=options.applyEnvironmentToSites(config.sites||{},options.environment,config),availableSites=Object.entries(configuredSites).filter((entry)=>Boolean(entry[1]));if(options.site&&!availableSites.some(([name])=>name===options.site)){const available=availableSites.map(([name])=>name).join(", ")||"none";throw Error(`Site '${options.site}' is not configured. Available sites: ${available}.`)}const selectedSites=availableSites.filter(([name])=>!options.site||name===options.site).map(([name,site])=>previewSite(name,site,options.resolveSiteKind)),provider=config.cloud?.provider||options.fallbackProvider||"aws",mode=config.mode||options.fallbackMode||"server",attachTo=config.cloud?.attachTo||null,projectName=config.project?.name||options.fallbackProjectName||"Stacks application",projectSlug=config.project?.slug||options.fallbackProjectSlug||"app",region=config.environments?.[options.environment]?.region||config.project?.region||options.fallbackRegion||"us-east-1",siteNames=selectedSites.map((site)=>site.name),shippable=selectedSites.filter((site)=>site.kind!=="bucket"&&site.kind!=="redirect"),staticSites=shippable.filter((site)=>site.kind==="server-static"&&site.build),runtimeSites=shippable.filter((site)=>site.kind==="server-app"||site.kind==="server-php"),publicSites=selectedSites.filter((site)=>site.domains.length>0),operations=[operation("validate","Validate deployment inputs",`Resolve the ${options.environment} configuration and list the prerequisites checked before a real deployment.`,siteNames)];if(provider==="hetzner")operations.push(attachTo?operation("infrastructure","Use attached server",`Resolve the existing '${attachTo}' server and verify that this project owns its gateway fragment and ports.`,siteNames):operation("infrastructure","Reconcile compute infrastructure",`Create or reuse the ${config.infrastructure?.compute?.size||"configured"} Hetzner server, firewall, SSH key, and managed services.`,siteNames));else operations.push(operation("infrastructure","Reconcile cloud infrastructure",`Generate and apply the ${provider} infrastructure for ${region}.`,siteNames));if(staticSites.length>0)operations.push(operation("build","Build static sites",`Run each configured static build: ${staticSites.map((site)=>`${site.name}: ${site.build}`).join("; ")}.`,staticSites.map((site)=>site.name)));if(shippable.length>0){operations.push(operation("package","Package releases","Create source or static release archives while excluding local dependencies, secrets, databases, caches, logs, and server-owned paths.",shippable.map((site)=>site.name)));operations.push(operation("release",options.site?`Ship site '${options.site}'`:"Ship release",options.site?"Upload and activate only the selected site while preserving every other configured route and service.":"Upload and atomically activate the selected releases on the target infrastructure.",shippable.map((site)=>site.name)))}if(runtimeSites.length>0){const hookCount=runtimeSites.reduce((total,site)=>total+site.preStart.length,0);operations.push(operation("runtime","Prepare and restart services",`Run ${hookCount} configured pre-start command${hookCount===1?"":"s"}, update service definitions, and restart application runtimes.`,runtimeSites.map((site)=>site.name)))}if(publicSites.length>0){operations.push(operation("gateway","Reconcile public routes","Regenerate the reverse-proxy routes from the complete environment-aware site model.",publicSites.map((site)=>site.name)));operations.push(operation("dns","Reconcile DNS records","Publish the configured public domains through their resolved DNS providers.",publicSites.map((site)=>site.name)));operations.push(operation("tls","Reconcile TLS certificates","Issue or renew certificates for public domains and reload the gateway when records change.",publicSites.map((site)=>site.name)))}if(options.docker)operations.push(operation("container","Build OCI images","Build the requested OCI images with Pantry and push them when registry credentials are configured.",shippable.map((site)=>site.name)));return{version:1,dryRun:!0,project:{name:projectName,slug:projectSlug},provider,mode,environment:options.environment,region,target:{site:options.site||null,domain:options.domain||null,attachTo},sites:selectedSites,operations,warnings:[...options.warnings||[]]}}export function formatDeploymentPreview(plan){const lines=["","Deployment preview","No changes will be made.","",`Project: ${plan.project.name} (${plan.project.slug})`,`Environment: ${plan.environment}`,`Provider: ${plan.provider}`,`Mode: ${plan.mode}`,`Region: ${plan.region}`,`Target: ${plan.target.site||"all configured sites"}`,"","Planned operations:",...plan.operations.map((item,index)=>`${index+1}. ${item.label}
|
|
2
|
+
${item.detail}`)];if(plan.warnings.length>0)lines.push("","Warnings:",...plan.warnings.map((warning)=>`- ${warning}`));return`${lines.join(`
|
|
3
|
+
`)}
|
|
4
|
+
`}export const deploymentPreviewJsonPrefix="STACKS_DEPLOY_PREVIEW_JSON=";
|
|
@@ -1,4 +1,7 @@
|
|
|
1
|
+
import { env } from '@stacksjs/env';
|
|
1
2
|
import type { CLI } from '@stacksjs/types';
|
|
3
|
+
export declare function resolveTsCloudCliPath(tsCloudEntry?: unknown): string;
|
|
4
|
+
export declare function runDeployRollback(site: string | undefined, options: DeployRollbackOptions, execute?: (command: string[]) => Promise<number>): Promise<number>;
|
|
2
5
|
/**
|
|
3
6
|
* Resolve (and decrypt) the deploy-target's environment file into a flat
|
|
4
7
|
* key/value map, so its values can be shipped to the server as each site's
|
|
@@ -383,6 +386,12 @@ declare const log: {
|
|
|
383
386
|
error: (...args: any[]) => unknown;
|
|
384
387
|
debug: (...args: any[]) => void
|
|
385
388
|
};
|
|
389
|
+
export declare interface DeployRollbackOptions {
|
|
390
|
+
env?: string
|
|
391
|
+
to?: string
|
|
392
|
+
dryRun?: boolean
|
|
393
|
+
verbose?: boolean
|
|
394
|
+
}
|
|
386
395
|
declare interface AttachedComputeBox {
|
|
387
396
|
serverId: number
|
|
388
397
|
serverName: string
|
package/dist/commands/deploy.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import{existsSync,mkdirSync,readFileSync,readdirSync,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";import{italic,onUnknownSubcommand,outro,prompts,runCommand}from"@stacksjs/cli";import{app,dns as dnsConfig,email as emailConfig,cloud as cloudConfig}from"@stacksjs/config";import{addDomain,hasUserDomainBeenAddedToCloud,syncDnsConfig}from"@stacksjs/dns";import{loadProjectDnsConfig}from"../config";import{encryptEnv,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{ensureAppKey,ensureDeployEnvIsSet,ensureEnvIsSet}from"./setup";import{resultFailed}from"../result";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"];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 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,readFileSync,readdirSync,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";import{italic,onUnknownSubcommand,outro,prompts,runCommand}from"@stacksjs/cli";import{app,dns as dnsConfig,email as emailConfig,cloud as cloudConfig}from"@stacksjs/config";import{addDomain,hasUserDomainBeenAddedToCloud,syncDnsConfig}from"@stacksjs/dns";import{loadProjectDnsConfig}from"../config";import{encryptEnv,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{ensureAppKey,ensureDeployEnvIsSet,ensureEnvIsSet}from"./setup";import{resultFailed}from"../result";import{findUnbackedManagedServices,unbackedDataMessage}from"../unbacked-data";import{applyDeploymentDomainOverride,createDeploymentPreview,deploymentPreviewJsonPrefix,formatDeploymentPreview,resolveDeploymentEnvironment}from"./deploy-preview";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 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}){const environment=resolveDeploymentEnvironment({option:options.env}),command=[process.execPath,resolveTsCloudCliPath(),"deploy:rollback"];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.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 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=mailbox,email=typeof mailbox==="string"?`${mailbox}@${emailDomain}`:`${mb.name||mb.address?.split("@")[0]}@${emailDomain}`,password=typeof mailbox==="object"&&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:typeof mailbox==="object"?mb.displayName||mb.name||email:mailbox}}});logger.success(`Created mail user: ${email}`);if(typeof mailbox!=="object"||!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}`)}}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}}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`}async function pollUntil(opts){log.info(`${opts.label} (up to ${fmtDuration(opts.timeoutSecs)})...`);const started=Date.now(),deadline=started+opts.timeoutSecs*1000;let lastHeartbeat=0;for(;;)try{await opts.check();return}catch{const elapsedSecs=Math.floor((Date.now()-started)/1000);if(Date.now()>deadline)throw Error(opts.timeoutMessage(elapsedSecs));if(elapsedSecs-lastHeartbeat>=30){log.info(` \u2026 still waiting (${elapsedSecs}s elapsed)`);lastHeartbeat=elapsedSecs}await new Promise((r)=>setTimeout(r,opts.intervalMs??5000))}}async function waitForRemoteReady(ip){const{sshExecOrThrow}=await import("@stacksjs/ts-cloud"),run=(remote)=>sshExecOrThrow(ip,remote,{user:"root",connectTimeoutSec:10}),sshWaitSecs=readWaitSecs("TS_CLOUD_SSH_WAIT_SECS",480);await pollUntil({label:"Waiting for SSH to come up",timeoutSecs:sshWaitSecs,check:()=>run("true"),timeoutMessage:(elapsed)=>`SSH did not become reachable on ${ip} within ${fmtDuration(sshWaitSecs)} (waited ${elapsed}s). `+"The box may still be booting \u2014 raise TS_CLOUD_SSH_WAIT_SECS and retry."});log.success("SSH is up");log.info("Waiting for cloud-init (installing bun + caddy)...");try{await run("cloud-init status --wait || true")}catch(err){log.debug("cloud-init status --wait returned non-zero (continuing):",err)}const bootWaitSecs=readWaitSecs("TS_CLOUD_BOOT_WAIT_SECS",720);await pollUntil({label:"Waiting for the bun runtime",timeoutSecs:bootWaitSecs,check:()=>run("test -x /usr/local/bin/bun"),timeoutMessage:(elapsed)=>`bun runtime did not appear at /usr/local/bin/bun within ${fmtDuration(bootWaitSecs)} (waited ${elapsed}s). `+"cloud-init may have failed \u2014 SSH in and check /var/log/cloud-init-output.log; "+"raise TS_CLOUD_BOOT_WAIT_SECS for slow regions."});log.success("Server is ready (bun installed)")}async function resolveDeclaredTenants(){try{const{config}=await import("@stacksjs/config"),tenants=config.cloud?.tenants;return Array.isArray(tenants)?tenants.filter((slug)=>typeof slug==="string"):[]}catch{return[]}}export async function assertFragmentIsOurs(ip,tsCloudConfig,log){const slug=tsCloudConfig.project?.slug||"app",ours=new Set(Object.values(tsCloudConfig.sites??{}).map((site)=>String(site?.domain??"").toLowerCase()).filter(Boolean));let remote="";try{const{execSync}=await import("node:child_process"),args=["-o","StrictHostKeyChecking=accept-new","-o","BatchMode=yes","-o","ConnectTimeout=15",`root@${ip}`];remote=execSync(`ssh ${args.map((a)=>`'${a}'`).join(" ")} bash -s`,{input:`cat /etc/rpx/sites.d/${slug}.json 2>/dev/null || true`,encoding:"utf8",stdio:["pipe","pipe","pipe"]})}catch{return}if(!remote.trim())return;const orphaned=[...new Set([...remote.matchAll(/"(?:domain|to|from)"\s*:\s*"([a-z0-9.*-]+\.[a-z]{2,})"/gi)].map((match)=>String(match[1]).toLowerCase()).filter(Boolean))].filter((domain)=>!ours.has(domain)&&!ours.has(domain.replace(/^www\./,"")));if(orphaned.length===0)return;log.error(`/etc/rpx/sites.d/${slug}.json on the box already serves ${orphaned.length} domain(s) this project does not declare:`);for(const domain of orphaned.slice(0,8))log.error(` ${domain}`);log.error("Deploying would replace that fragment and take those domains down.");log.info(`Either the slug '${slug}' belongs to another project (pick a different project.slug), or those domains belong here and should be in config/cloud.ts sites.`);process.exit(ExitCode.FatalError)}export async function assertPortsAreFree(ip,tsCloudConfig,log){const slug=tsCloudConfig.project?.slug||"app",wanted=new Map;for(const[name,site]of Object.entries(tsCloudConfig.sites??{})){const port=Number(site?.port);if(Number.isFinite(port)&&port>0)wanted.set(port,name)}if(wanted.size===0)return;let listing="";try{const{execSync}=await import("node:child_process"),args=["-o","StrictHostKeyChecking=accept-new","-o","BatchMode=yes","-o","ConnectTimeout=15",`root@${ip}`];listing=execSync(`ssh ${args.map((a)=>`'${a}'`).join(" ")} bash -s`,{input:`for p in ${[...wanted.keys()].join(" ")}; do
|
|
3
3
|
pid=$(ss -lntpH "sport = :$p" 2>/dev/null | grep -oE 'pid=[0-9]+' | head -1 | cut -d= -f2)
|
|
4
4
|
[ -n "$pid" ] || continue
|
|
@@ -6,7 +6,7 @@ import{existsSync,mkdirSync,readFileSync,readdirSync,statSync,writeFileSync}from
|
|
|
6
6
|
echo "$p \${unit:-unknown}"
|
|
7
7
|
done`,encoding:"utf8",stdio:["pipe","pipe","pipe"]})}catch{return}const clashes=[];for(const line of listing.split(`
|
|
8
8
|
`)){const[portText,unit="unknown"]=line.trim().split(/\s+/),port=Number(portText);if(!Number.isFinite(port)||!wanted.has(port))continue;if(unit.startsWith(`${slug}-`))continue;clashes.push(` ${port} (site '${wanted.get(port)}') is held by ${unit}`)}if(clashes.length===0)return;log.error("Another service on the box is already listening on a port this project wants:");for(const clash of clashes)log.error(clash);log.error("Two services on one port do not error: the kernel load-balances, and each domain serves the other's site about half the time.");log.info("Pick free ports in config/cloud.ts. `ss -lntp` on the box lists what is taken.");process.exit(ExitCode.FatalError)}export async function resolveDeployEnvValues(environment,tsCloudConfig){const fileName=environment==="production"?".env.production":environment==="staging"?".env.staging":existsSync(p.projectPath(".env.development"))?".env.development":".env",filePath=p.projectPath(fileName);if(!existsSync(filePath))return{};try{const{getEnv}=await import("@stacksjs/env"),result=getEnv(void 0,{file:fileName,format:"json"});if(!result.success||!result.output){log.debug(`[deploy] Could not read ${fileName} for site env merging: ${result.error??"unknown error"}`);return{}}const parsed=JSON.parse(result.output),values={},undecrypted=[];for(const[key,value]of Object.entries(parsed)){if(/^DOTENV_(PUBLIC|PRIVATE)_KEY/.test(key))continue;values[key]=String(value);if(/^(?:encrypted|enc):/.test(values[key]))undecrypted.push(key)}if(undecrypted.length>0){log.error(`${undecrypted.length} value(s) in ${fileName} could not be decrypted: ${undecrypted.join(", ")}`);log.info(`The private key for this environment lives in .env.keys, which is not committed. Restore it, or set DOTENV_PRIVATE_KEY_${environment.toUpperCase()} in the environment running the deploy.`);process.exit(ExitCode.FatalError)}const{foreignTenantKeys,partitionTenantEnv}=await import("@stacksjs/env"),partition=partitionTenantEnv(values,{self:tsCloudConfig?.project?.slug,tenants:await resolveDeclaredTenants()});for(const{tenant,keys}of foreignTenantKeys(partition))log.warn(`[deploy] Skipping ${keys.length} '${tenant}' key(s) in ${fileName} \u2014 they belong to that tenant's own `+`repository, and shipping them writes its secrets into this project's site .env files. Remove them with: buddy env:check --file ${fileName}. Keys: ${keys.join(", ")}`);return partition.own}catch(error){log.debug(`[deploy] Failed to resolve ${fileName} for site env merging:`,error);return{}}}export function mergeSiteDeployEnv(sites,resolvedDeployEnv){return Object.fromEntries(Object.entries(sites).map(([siteName,site])=>{if(!site)return[siteName,site];const base={...resolvedDeployEnv};if(site.port!==void 0)delete base.PORT;return[siteName,{...site,env:{...base,...site.env||{}}}]}))}function looksLikeSqliteFile(value){return typeof value==="string"&&/\.(?:sqlite3?|db)$/i.test(value.trim())}export function siteSqlitePath(siteEnv={}){if(String(siteEnv.DB_CONNECTION??"sqlite").trim().toLowerCase()!=="sqlite")return null;const configured=typeof siteEnv.DB_DATABASE_PATH==="string"&&siteEnv.DB_DATABASE_PATH.trim()?siteEnv.DB_DATABASE_PATH:looksLikeSqliteFile(siteEnv.DB_DATABASE)?siteEnv.DB_DATABASE:"database/stacks.sqlite",path=String(configured).trim().replace(/^\.\//,"");if(!path||path.startsWith("/")||path.startsWith("~")||path.split("/").includes(".."))return null;return path}export function tsCloudPersistentStateSupport(buildSiteDeployScript){const missing=[];if(typeof buildSiteDeployScript!=="function")return{ok:!1,missing:["buildSiteDeployScript"]};const target="/var/www/probe-shared/database/probe.sqlite";let script="";try{script=buildSiteDeployScript({siteName:"probe",slug:"probe",appDir:"/var/www/probe-probe",artifactFetch:[],releaseId:"probe",execStart:"/bin/true",envEntries:{},port:3000,sharedPaths:[{path:"database/probe.sqlite",target,seed:!0}]}).join(`
|
|
9
|
-
`)}catch{return{ok:!1,missing:["shared paths with an explicit target"]}}if(!(script.includes("cp -a")&&script.includes("/current/")))missing.push("adoption of existing state into shared/");if(!script.includes(`ln -sfn ${target} `))missing.push("shared paths with an explicit target");return{ok:missing.length===0,missing}}export function projectDatabaseTarget(slug,relativePath){return`/var/www/${slug}-shared/${relativePath}`}function runsMigrations(site){return Array.isArray(site?.preStart)&&site.preStart.some((cmd)=>typeof cmd==="string"&&/\bmigrate\b/.test(cmd))}export function applyPersistentStatePaths(sites,slug){const isServerApp=(site)=>!!site&&typeof site.start==="string",appSites=Object.entries(sites).filter(([,site])=>isServerApp(site)),owner=(appSites.find(([,site])=>runsMigrations(site))??appSites[0])?.[0],out={};for(const[name,site]of Object.entries(sites)){if(!isServerApp(site)){out[name]=site;continue}const sqlite=siteSqlitePath(site.env||{}),framework=["storage/logs"];if(sqlite)framework.unshift({path:sqlite,target:projectDatabaseTarget(slug,sqlite),seed:name===owner});const declared=Array.isArray(site.sharedPaths)?site.sharedPaths.filter(Boolean):[],byPath=new Map;for(const entry of[...declared,...framework])byPath.set(typeof entry==="string"?entry:entry.path,entry);out[name]={...site,sharedPaths:[...byPath.values()]}}return out}export function encryptedEnvFileNames(projectRoot){try{return readdirSync(projectRoot).filter((name)=>/^\.env\.[\w-]+$/.test(name)&&name!==".env.example")}catch{return[]}}export function declaresScheduledWork(schedulerFile){if(!existsSync(schedulerFile))return!1;const source=readFileSync(schedulerFile,"utf8").replace(/\/\*[\s\S]*?\*\//g,"").replace(/(^|[^:])\/\/.*$/gm,"$1");return/\bschedule\s*\.\s*(?:job|action|command|call|exec)\b/.test(source)}export function applyScheduledWork(sites,schedulerFile){if(Object.values(sites).some((site)=>site?.scheduler!==void 0))return sites;if(!declaresScheduledWork(schedulerFile))return sites;const appSites=Object.entries(sites).filter(([,site])=>typeof site?.start==="string"),owner=(appSites.find(([,site])=>runsMigrations(site))??appSites[0])?.[0];if(!owner)return sites;return{...sites,[owner]:{...sites[owner],scheduler:!0}}}function prefixHostForEnv(host,prefix){if(host.startsWith(`${prefix}.`)||host.startsWith(`www.${prefix}.`))return host;if(host.startsWith("www."))return`www.${prefix}.${host.slice(4)}`;return`${prefix}.${host}`}export function applyEnvironmentToSites(sites,environment,config){const prefix=config?.environments?.[environment]?.domainPrefix;if(!prefix||environment==="production")return sites;const allHosts=[];for(const site of Object.values(sites)){const d=site?.domain;if(typeof d==="string")allHosts.push(d);else if(Array.isArray(d)){for(const x of d)if(typeof x==="string")allHosts.push(x)}}allHosts.sort((a,b)=>b.length-a.length);const rewrite=(val)=>{let r=val;for(const h of allHosts){const esc=h.replace(/[.]/g,"\\.");r=r.replace(new RegExp(`//${esc}(?=[/:?#]|$)`,"g"),`//${prefixHostForEnv(h,prefix)}`)}return r},out={};for(const[name,site]of Object.entries(sites)){if(!site){out[name]=site;continue}const s={...site};if(typeof s.domain==="string")s.domain=prefixHostForEnv(s.domain,prefix);else if(Array.isArray(s.domain))s.domain=s.domain.map((d)=>typeof d==="string"?prefixHostForEnv(d,prefix):d);if(typeof s.redirect==="string")s.redirect=rewrite(s.redirect);if(s.env&&typeof s.env==="object"){const e={...s.env};for(const k of Object.keys(e))if(typeof e[k]==="string")e[k]=rewrite(e[k]);s.env=e}out[name]=s}return out}async function deployToHetzner(tsCloudConfig,deployEnv,options){const verbose=options.verbose===!0,environment=deployEnv==="prod"?"production":deployEnv,apiToken=tsCloudConfig.hetzner?.apiToken||process.env.HCLOUD_TOKEN||process.env.HETZNER_API_TOKEN,persistedAttachBox=resolvePersistedAttachTargetBox(tsCloudConfig,environment);if(!apiToken&&!persistedAttachBox){log.error("No Hetzner API token found. Set HCLOUD_TOKEN in your .env (or hetzner.apiToken in config/cloud.ts).");process.exit(ExitCode.FatalError)}const sshPubKey=join(homedir(),".ssh","id_ed25519.pub");if(!existsSync(sshPubKey)){log.error(`SSH public key not found at ${sshPubKey}.`);log.info("ts-cloud deploys to Hetzner over SSH and registers this key on the server.");log.info("Generate one with: ssh-keygen -t ed25519");process.exit(ExitCode.FatalError)}const{createCloudDriver,deployAllComputeSites,ensureManagementDashboard,resolveSiteKind,buildSiteDeployScript}=await loadTsCloudDeployApi(),support=tsCloudPersistentStateSupport(buildSiteDeployScript);if(!support.ok){log.error("This ts-cloud cannot keep your database across deploys, and deploying anyway would destroy it.");log.error(`Missing: ${support.missing.join(", ")}.`);log.error("Upgrade @stacksjs/ts-cloud (bun update @stacksjs/ts-cloud), or point TS_CLOUD_MODULE at a build that has it.");process.exit(ExitCode.FatalError)}try{await runHetznerDeploy({tsCloudConfig,environment,verbose,docker:options.docker===!0,createCloudDriver,deployAllComputeSites,ensureManagementDashboard,resolveSiteKind,onlySite:options.site||void 0,persistedAttachBox})}catch(err){log.error("Hetzner deploy failed:");console.error(err instanceof Error?err.stack||err.message:err);process.exit(ExitCode.FatalError)}}export function resolveProjectTsCloudModule(projectRoot=process.cwd()){const manifestPath=join(projectRoot,"node_modules","@stacksjs","ts-cloud","package.json");if(!existsSync(manifestPath))return;const manifest=JSON.parse(readFileSync(manifestPath,"utf8")),rootExport=manifest.exports?.["."],entry=manifest.module??(typeof rootExport==="string"?rootExport:rootExport?.import);if(!entry)return;const modulePath=resolve(dirname(manifestPath),entry);return existsSync(modulePath)?modulePath:void 0}export async function loadTsCloudDeployApi(){const requested=process.env.TS_CLOUD_MODULE?.trim();if(!requested){const projectModule=resolveProjectTsCloudModule();return projectModule?import(pathToFileURL(projectModule).href):import("@stacksjs/ts-cloud")}const modulePath=isAbsolute(requested)?requested:resolve(process.cwd(),requested);if(!existsSync(modulePath))throw Error(`TS_CLOUD_MODULE points to a missing module: ${modulePath}`);return import(pathToFileURL(modulePath).href)}export function shouldInjectManagementDashboard(tsCloudConfig){return!tsCloudConfig.cloud?.attachTo}export function reconcilePartialDeployManagementDashboards(tsCloudConfig,livePorts){const sites=tsCloudConfig.sites,preserved=[],removed=[];if(!sites)return{preserved,removed};for(const[siteName,site]of Object.entries(sites)){if(siteName!=="dashboard"&&!siteName.startsWith("dashboard-"))continue;const livePort=livePorts[siteName];if(typeof livePort!=="number"||!Number.isInteger(livePort)||livePort<1||livePort>65535){delete sites[siteName];removed.push(siteName);continue}const next={...site,port:livePort};if(typeof next.start==="string")next.start=next.start.replace(/(--port(?:=|\s+))\d+/,`$1${livePort}`);sites[siteName]=next;preserved.push(siteName)}return{preserved,removed}}async function reconcilePartialDeployManagementDashboardsWithLiveBox(tsCloudConfig,ip){const slug=String(tsCloudConfig.project?.slug||"app").replace(/[^a-z0-9._-]+/gi,"-"),siteNames=Object.keys(tsCloudConfig.sites??{}).filter((name)=>name==="dashboard"||name.startsWith("dashboard-"));if(siteNames.length===0)return;const units=siteNames.map((siteName)=>({siteName,unit:`${slug}-${siteName}.service`})),probe=`
|
|
9
|
+
`)}catch{return{ok:!1,missing:["shared paths with an explicit target"]}}if(!(script.includes("cp -a")&&script.includes("/current/")))missing.push("adoption of existing state into shared/");if(!script.includes(`ln -sfn ${target} `))missing.push("shared paths with an explicit target");return{ok:missing.length===0,missing}}export function projectDatabaseTarget(slug,relativePath){return`/var/www/${slug}-shared/${relativePath}`}function runsMigrations(site){return Array.isArray(site?.preStart)&&site.preStart.some((cmd)=>typeof cmd==="string"&&/\bmigrate\b/.test(cmd))}export function applyPersistentStatePaths(sites,slug){const isServerApp=(site)=>!!site&&typeof site.start==="string",appSites=Object.entries(sites).filter(([,site])=>isServerApp(site)),owner=(appSites.find(([,site])=>runsMigrations(site))??appSites[0])?.[0],out={};for(const[name,site]of Object.entries(sites)){if(!isServerApp(site)){out[name]=site;continue}const sqlite=siteSqlitePath(site.env||{}),framework=["storage/logs"];if(sqlite)framework.unshift({path:sqlite,target:projectDatabaseTarget(slug,sqlite),seed:name===owner});const declared=Array.isArray(site.sharedPaths)?site.sharedPaths.filter(Boolean):[],byPath=new Map;for(const entry of[...declared,...framework])byPath.set(typeof entry==="string"?entry:entry.path,entry);out[name]={...site,sharedPaths:[...byPath.values()]}}return out}export function encryptedEnvFileNames(projectRoot){try{return readdirSync(projectRoot).filter((name)=>/^\.env\.[\w-]+$/.test(name)&&name!==".env.example")}catch{return[]}}export function declaresScheduledWork(schedulerFile){if(!existsSync(schedulerFile))return!1;const source=readFileSync(schedulerFile,"utf8").replace(/\/\*[\s\S]*?\*\//g,"").replace(/(^|[^:])\/\/.*$/gm,"$1");return/\bschedule\s*\.\s*(?:job|action|command|call|exec)\b/.test(source)}export function applyScheduledWork(sites,schedulerFile){if(Object.values(sites).some((site)=>site?.scheduler!==void 0))return sites;if(!declaresScheduledWork(schedulerFile))return sites;const appSites=Object.entries(sites).filter(([,site])=>typeof site?.start==="string"),owner=(appSites.find(([,site])=>runsMigrations(site))??appSites[0])?.[0];if(!owner)return sites;return{...sites,[owner]:{...sites[owner],scheduler:!0}}}function prefixHostForEnv(host,prefix){if(host.startsWith(`${prefix}.`)||host.startsWith(`www.${prefix}.`))return host;if(host.startsWith("www."))return`www.${prefix}.${host.slice(4)}`;return`${prefix}.${host}`}export function applyEnvironmentToSites(sites,environment,config){const prefix=config?.environments?.[environment]?.domainPrefix;if(!prefix||environment==="production")return sites;const allHosts=[];for(const site of Object.values(sites)){const d=site?.domain;if(typeof d==="string")allHosts.push(d);else if(Array.isArray(d)){for(const x of d)if(typeof x==="string")allHosts.push(x)}}allHosts.sort((a,b)=>b.length-a.length);const rewrite=(val)=>{let r=val;for(const h of allHosts){const esc=h.replace(/[.]/g,"\\.");r=r.replace(new RegExp(`//${esc}(?=[/:?#]|$)`,"g"),`//${prefixHostForEnv(h,prefix)}`)}return r},out={};for(const[name,site]of Object.entries(sites)){if(!site){out[name]=site;continue}const s={...site};if(typeof s.domain==="string")s.domain=prefixHostForEnv(s.domain,prefix);else if(Array.isArray(s.domain))s.domain=s.domain.map((d)=>typeof d==="string"?prefixHostForEnv(d,prefix):d);if(typeof s.redirect==="string")s.redirect=rewrite(s.redirect);if(s.env&&typeof s.env==="object"){const e={...s.env};for(const k of Object.keys(e))if(typeof e[k]==="string")e[k]=rewrite(e[k]);s.env=e}out[name]=s}return out}async function deployToHetzner(tsCloudConfig,deployEnv,options){const verbose=options.verbose===!0,environment=deployEnv==="prod"?"production":deployEnv,apiToken=tsCloudConfig.hetzner?.apiToken||process.env.HCLOUD_TOKEN||process.env.HETZNER_API_TOKEN,persistedAttachBox=resolvePersistedAttachTargetBox(tsCloudConfig,environment);if(!apiToken&&!persistedAttachBox){log.error("No Hetzner API token found. Set HCLOUD_TOKEN in your .env (or hetzner.apiToken in config/cloud.ts).");process.exit(ExitCode.FatalError)}const sshPubKey=join(homedir(),".ssh","id_ed25519.pub");if(!existsSync(sshPubKey)){log.error(`SSH public key not found at ${sshPubKey}.`);log.info("ts-cloud deploys to Hetzner over SSH and registers this key on the server.");log.info("Generate one with: ssh-keygen -t ed25519");process.exit(ExitCode.FatalError)}const{createCloudDriver,deployAllComputeSites,ensureManagementDashboard,resolveSiteKind,buildSiteDeployScript}=await loadTsCloudDeployApi(),support=tsCloudPersistentStateSupport(buildSiteDeployScript);if(!support.ok){log.error("This ts-cloud cannot keep your database across deploys, and deploying anyway would destroy it.");log.error(`Missing: ${support.missing.join(", ")}.`);log.error("Upgrade @stacksjs/ts-cloud (bun update @stacksjs/ts-cloud), or point TS_CLOUD_MODULE at a build that has it.");process.exit(ExitCode.FatalError)}const unbacked=findUnbackedManagedServices(tsCloudConfig);if(unbacked.length>0)log.warn(`[deploy] ${unbackedDataMessage(unbacked)}`);try{await runHetznerDeploy({tsCloudConfig,environment,verbose,docker:options.docker===!0,createCloudDriver,deployAllComputeSites,ensureManagementDashboard,resolveSiteKind,onlySite:options.site||void 0,persistedAttachBox})}catch(err){log.error("Hetzner deploy failed:");console.error(err instanceof Error?err.stack||err.message:err);process.exit(ExitCode.FatalError)}}export function resolveProjectTsCloudModule(projectRoot=process.cwd()){const manifestPath=join(projectRoot,"node_modules","@stacksjs","ts-cloud","package.json");if(!existsSync(manifestPath))return;const manifest=JSON.parse(readFileSync(manifestPath,"utf8")),rootExport=manifest.exports?.["."],entry=manifest.module??(typeof rootExport==="string"?rootExport:rootExport?.import);if(!entry)return;const modulePath=resolve(dirname(manifestPath),entry);return existsSync(modulePath)?modulePath:void 0}export async function loadTsCloudDeployApi(){const requested=process.env.TS_CLOUD_MODULE?.trim();if(!requested){const projectModule=resolveProjectTsCloudModule();return projectModule?import(pathToFileURL(projectModule).href):import("@stacksjs/ts-cloud")}const modulePath=isAbsolute(requested)?requested:resolve(process.cwd(),requested);if(!existsSync(modulePath))throw Error(`TS_CLOUD_MODULE points to a missing module: ${modulePath}`);return import(pathToFileURL(modulePath).href)}export function shouldInjectManagementDashboard(tsCloudConfig){return!tsCloudConfig.cloud?.attachTo}export function reconcilePartialDeployManagementDashboards(tsCloudConfig,livePorts){const sites=tsCloudConfig.sites,preserved=[],removed=[];if(!sites)return{preserved,removed};for(const[siteName,site]of Object.entries(sites)){if(siteName!=="dashboard"&&!siteName.startsWith("dashboard-"))continue;const livePort=livePorts[siteName];if(typeof livePort!=="number"||!Number.isInteger(livePort)||livePort<1||livePort>65535){delete sites[siteName];removed.push(siteName);continue}const next={...site,port:livePort};if(typeof next.start==="string")next.start=next.start.replace(/(--port(?:=|\s+))\d+/,`$1${livePort}`);sites[siteName]=next;preserved.push(siteName)}return{preserved,removed}}async function reconcilePartialDeployManagementDashboardsWithLiveBox(tsCloudConfig,ip){const slug=String(tsCloudConfig.project?.slug||"app").replace(/[^a-z0-9._-]+/gi,"-"),siteNames=Object.keys(tsCloudConfig.sites??{}).filter((name)=>name==="dashboard"||name.startsWith("dashboard-"));if(siteNames.length===0)return;const units=siteNames.map((siteName)=>({siteName,unit:`${slug}-${siteName}.service`})),probe=`
|
|
10
10
|
const units = ${JSON.stringify(units)}
|
|
11
11
|
const text = bytes => new TextDecoder().decode(bytes).trim()
|
|
12
12
|
const run = args => text(Bun.spawnSync(args).stdout)
|
|
@@ -346,5 +346,5 @@ EOF
|
|
|
346
346
|
systemctl daemon-reload
|
|
347
347
|
systemctl enable --now mail-health.timer >/dev/null 2>&1
|
|
348
348
|
# 6) Restart only when the startup-read env actually changed (domain or DKIM key).
|
|
349
|
-
if [ "$ENV_CHANGED" = 1 ]; then systemctl restart mail 2>/dev/null || true; echo "MAILTENANT:env-changed+restarted,forwards=$FWD_STATE"; else echo "MAILTENANT:current,forwards=$FWD_STATE"; fi`;try{const out=execSync(`ssh ${sshArgs.map((a)=>`'${a}'`).join(" ")} bash -s`,{input:script,encoding:"utf8",stdio:["pipe","pipe","pipe"]}),line=(out.match(/MAILTENANT:[^\n]*/)||[])[0]||"MAILTENANT:done",mailHostFromOut=(out.match(/MAILHOST:([^\n]*)/)||[])[1]?.trim(),mailHost=mailHostFromOut||`mail.${domain}`,dkimPubB64=(out.match(/DKIMPUB:([^\n]*)/)||[])[1]?.trim()||void 0,dkimSelector=(out.match(/DKIMSEL:([^\n]*)/)||[])[1]?.trim()||void 0,dkimGlobalKey=(out.match(/DKIMGLOBAL:([^\n]*)/)||[])[1]?.trim();if(dkimGlobalKey)logger.warn(`Mail: ${domain} is the mail server's global DKIM_DOMAIN, so it signs with ${dkimGlobalKey} and the per-domain key at /opt/mail/dkim/${domain}.private is unused. Rotate the global key, not that one.`);const madeAddrs=new Set([...out.matchAll(/MADE:([^\n]+)/g)].flatMap((m)=>m[1]?[m[1].trim()]:[])),migratedAddrs=new Set([...out.matchAll(/MIGRATED:([^\n]+)/g)].flatMap((m)=>m[1]?[m[1].trim()]:[])),created=boxes.filter((b)=>madeAddrs.has(b.address)).map((b)=>({address:b.address,password:b.password}));logger.success(`Mail routing reconciled (${line.replace("MAILTENANT:","")})`);const failed=[...out.matchAll(/FAIL:([^\n]+)/g)].flatMap((m)=>m[1]?[m[1].trim()]:[]);if(failed.length)logger.warn(`Mail: the server refused ${failed.length} mailbox(es): ${failed.join(", ")}`);const forwardError=(out.match(/FWDERR:([^\n]*)/)||[])[1]?.trim();if(forwardError)logger.warn(`Mail: the forward rules were not merged: ${forwardError}`);const certHost=(out.match(/CERTHOST:([^\n]*)/)||[])[1]?.trim();if(certHost)logger.success(`Mail: ${certHost} added to the mail certificate \u2014 clients can use it as the server name`);const certError=(out.match(/CERTFAIL:([^\n]*)/)||[])[1]?.trim();if(certError)logger.warn(`Mail: could not issue a certificate for mail.${domain} (clients should use ${mailHostFromOut||"the shared mail host"}): ${certError}`);const seen=new Set([...madeAddrs,...migratedAddrs,...[...out.matchAll(/EXISTS:([^\n]+)/g)].flatMap((m)=>m[1]?[m[1].trim()]:[])]),unaccounted=boxes.filter((b)=>!seen.has(b.address)).map((b)=>b.address);if(unaccounted.length)logger.warn(`Mail: ${unaccounted.length} declared mailbox(es) were not reconciled: ${unaccounted.join(", ")}`);if(created.length){logger.info(`Mail: created ${created.length} mailbox(es) \u2014 credentials below (save them; shown once):`);for(const b of created)logger.info(` ${b.address} ${b.password}`)}if(migratedAddrs.size)logger.success(`Mail: migrated ${migratedAddrs.size} legacy mailbox username(s) to isolated full addresses`);return domain?{domain,mailHost,dkimPubB64,dkimSelector,created}:null}catch(err){logger.warn(`Mail routing reconcile skipped: ${getErrorMessage(err)}`);return null}}export function dnsProviderConfigsFromEnv(){const configs=[];if(process.env.PORKBUN_API_KEY&&process.env.PORKBUN_SECRET_KEY)configs.push({provider:"porkbun",apiKey:process.env.PORKBUN_API_KEY,secretKey:process.env.PORKBUN_SECRET_KEY});if(process.env.CLOUDFLARE_API_TOKEN)configs.push({provider:"cloudflare",apiToken:process.env.CLOUDFLARE_API_TOKEN});if(process.env.GODADDY_API_KEY&&process.env.GODADDY_API_SECRET)configs.push({provider:"godaddy",apiKey:process.env.GODADDY_API_KEY,apiSecret:process.env.GODADDY_API_SECRET,environment:process.env.GODADDY_ENVIRONMENT});if(process.env.AWS_ACCESS_KEY_ID||process.env.AWS_PROFILE)configs.push({provider:"route53"});return configs}async function resolveZoneDnsProvider(domain,providerConfigs,logger){if(providerConfigs.length===0)return;const{createDnsProvider,detectDnsProvider}=await import("@stacksjs/ts-cloud"),provider=await detectDnsProvider(domain,providerConfigs).catch((err)=>{logger.warn(` DNS: ignoring a configured provider for ${domain} \u2014 its credentials were rejected (${err?.message||err})`);return});if(provider)return provider;let nameservers=[];try{const{resolveNs}=await import("node:dns/promises");nameservers=await resolveNs(domain)}catch{}const providerName=dnsProviderNameFromNameservers(nameservers),providerConfig=providerConfigs.find((config)=>config.provider===providerName);return providerConfig?createDnsProvider(providerConfig):void 0}export function resolveDmarcPolicy(policy){return policy==="none"||policy==="quarantine"||policy==="reject"?policy:"quarantine"}export function zoneFqdn(name,zone){const apex=zone.replace(/\.$/,"").toLowerCase(),n=String(name??"").replace(/\.$/,"").toLowerCase();if(!n||n==="@")return apex;return n===apex||n.endsWith(`.${apex}`)?n:`${n}.${apex}`}export function selectRecordsAt(records,fqdn,type,zone){const target=zoneFqdn(fqdn,zone);return records.filter((r)=>String(r.type).toUpperCase()===type.toUpperCase()&&zoneFqdn(r.name,zone)===target)}export function findMailDnsAnomalies(records,expectations,zone){const problems=[];for(const expectation of expectations){const at=selectRecordsAt(records,expectation.fqdn,expectation.type,zone),ours=expectation.owns?at.filter((record)=>expectation.owns(txtContent(record))):at;if(ours.length===0)problems.push(`${expectation.label}: nothing published at ${expectation.fqdn}`);else if(ours.length>1)problems.push(`${expectation.label}: ${ours.length} records at ${expectation.fqdn}, expected 1 \u2014 receivers treat a duplicated ${expectation.type} here as unconfigured, so remove the stale one`)}return problems}export function txtContent(record){return String(record.content??record.value??"").replace(/^"|"$/g,"")}export function planTxtReplacement(existing,content,owns){const ours=existing.filter((record)=>owns(txtContent(record)));if(ours.length===1&&txtContent(ours[0])===content)return{remove:[],create:!1};return{remove:ours,create:!0}}export async function reconcileMailDns(res,ip,logger){const{domain,dkimPubB64}=res;let{mailHost}=res;const dkimName=`${res.dkimSelector||"mail"}._domainkey`;try{const dns=await import("node:dns"),own=`mail.${domain}`;if(own!==mailHost&&(await dns.promises.resolve4(own)).includes(ip))mailHost=own}catch{}const spf=`v=spf1 ip4:${ip} ~all`,cfg=emailConfig||{},firstBox=resolveMailboxes(cfg.mailboxes,domain)[0]?.address,fromAddress=typeof cfg.from?.address==="string"&&cfg.from.address.includes("@")?cfg.from.address:void 0,dmarcCfg=cfg.server?.dmarc||{},rua=dmarcCfg.reportTo||fromAddress||firstBox||`chris@${domain}`,dmarc=`v=DMARC1; p=${resolveDmarcPolicy(dmarcCfg.policy)}; rua=mailto:${rua}`,dkim=dkimPubB64?`v=DKIM1; k=rsa; p=${dkimPubB64}`:void 0,byHand=(reason)=>{logger.warn(`Mail DNS not published for ${domain}: ${reason}`);logger.info("Add these records by hand, or point a configured provider at the zone:");logger.info(` MX @ 10 ${mailHost}`);logger.info(` TXT @ ${spf}`);if(dkim)logger.info(` TXT ${dkimName.padEnd(17)}${dkim}`);logger.info(` TXT _dmarc ${dmarc}`);logger.info(` A mail ${ip}`)},providerConfigs=dnsProviderConfigsFromEnv();if(providerConfigs.length===0)return byHand("no DNS provider credentials are configured");const provider=await resolveZoneDnsProvider(domain,providerConfigs,logger);if(!provider)return byHand("no configured DNS provider administers this zone");const apex=domain.toLowerCase(),dkimFqdn=`${dkimName}.${domain}`,dmarcFqdn=`_dmarc.${domain}`,mailFqdn=`mail.${domain}`,recordsAt=async(fqdn,type)=>{const res=await provider.listRecords(domain);return selectRecordsAt(res?.success?res.records||[]:[],fqdn,type,domain)},replaceTxt=async(fqdn,content,owns)=>{const existing=await recordsAt(fqdn,"TXT"),{remove,create}=planTxtReplacement(existing,content,owns);if(!create)return;for(const record of remove){const removed=await provider.deleteRecord(domain,{...record,name:fqdn,type:"TXT"});if(removed&&removed.success===!1)throw Error(`TXT ${fqdn}: could not remove the record being replaced (${removed.message||"provider refused the delete"})`)}const created=await provider.createRecord(domain,{name:fqdn,type:"TXT",content,ttl:600});if(!created?.success)throw Error(`TXT ${fqdn}: ${created?.message||"provider rejected the record"}`)};try{const mxRecords=await recordsAt(apex,"MX"),staleMx=mxRecords.filter((r)=>String(r.content??r.value??"").replace(/\.$/,"").toLowerCase()!==mailHost.toLowerCase());for(const record of staleMx){logger.warn(` Mail DNS: replacing an existing MX for ${domain} \u2192 ${record.content??record.value}`);await provider.deleteRecord(domain,{...record,name:apex,type:"MX"})}if(mxRecords.length===staleMx.length){const created=await provider.createRecord(domain,{name:apex,type:"MX",content:mailHost,ttl:600,priority:10});if(!created?.success)throw Error(`MX ${domain}: ${created?.message||"provider rejected the record"}`)}await replaceTxt(apex,spf,(existing)=>existing.toLowerCase().startsWith("v=spf1"));if(dkim)await replaceTxt(dkimFqdn,dkim,()=>!0);await replaceTxt(dmarcFqdn,dmarc,(existing)=>existing.toLowerCase().startsWith("v=dmarc1"));const mailA=await provider.upsertRecord(domain,{name:mailFqdn,type:"A",content:ip,ttl:600});if(!mailA?.success)throw Error(`A ${mailFqdn}: ${mailA?.message||"provider rejected the record"}`);const verifyRes=await provider.listRecords(domain),anomalies=verifyRes?.success?findMailDnsAnomalies(verifyRes.records||[],[{label:"MX",fqdn:apex,type:"MX"},{label:"SPF",fqdn:apex,type:"TXT",owns:(content)=>content.toLowerCase().startsWith("v=spf1")},...dkim?[{label:"DKIM",fqdn:dkimFqdn,type:"TXT"}]:[],{label:"DMARC",fqdn:dmarcFqdn,type:"TXT",owns:(content)=>content.toLowerCase().startsWith("v=dmarc1")}],domain):[];for(const anomaly of anomalies)logger.warn(` Mail DNS: ${anomaly}`);logger.success(`Mail DNS published for ${domain} via ${provider.name} (MX\u2192${mailHost}, SPF, DKIM at ${dkimName}, DMARC, ${mailFqdn}\u2192${ip})`)}catch(err){logger.warn(`Mail DNS reconcile skipped for ${domain}: ${getErrorMessage(err)}`)}}export function configDnsDomains(sites){const domains=new Set;for(const site of Object.values(sites))if(!site?.redirect&&site?.domain&&typeof site.domain==="string")domains.add(site.domain.replace(/^www\./,""));return[...domains]}export function dnsProviderNameFromNameservers(nameservers){const normalized=nameservers.map((name)=>name.toLowerCase().replace(/\.$/,""));if(normalized.some((name)=>name.endsWith(".porkbun.com")))return"porkbun";if(normalized.some((name)=>name.endsWith(".ns.cloudflare.com")))return"cloudflare";if(normalized.some((name)=>/(^|\.)awsdns-\d+\.(?:com|net|org|co\.uk)$/.test(name)))return"route53";if(normalized.some((name)=>name.endsWith(".domaincontrol.com")))return"godaddy";return null}async function reconcileConfigDns(sites,logger){const projectDnsConfig=await loadProjectDnsConfig(dnsConfig);if(["a","aaaa","cname","mx","txt"].reduce((total,key)=>total+(Array.isArray(projectDnsConfig?.[key])?projectDnsConfig[key].length:0),0)===0)return;for(const domain of configDnsDomains(sites))try{const result=await syncDnsConfig(domain,projectDnsConfig);if(!result.provider)continue;if(result.created||result.failed)logger.info(`DNS (config/dns.ts) ${domain}: ${result.created} created, ${result.kept} kept${result.failed?`, ${result.failed} failed`:""}`)}catch(err){logger.warn(`DNS (config/dns.ts) reconcile for ${domain} failed: ${err instanceof Error?err.message:String(err)}`)}}async function reconcileHetznerDns(sites,ip,logger,ipv6){const published=[],domains=new Set;for(const site of Object.values(sites))if(site?.domain&&typeof site.domain==="string")domains.add(site.domain.replace(/^www\./,""));if(domains.size===0)return published;const providerConfigs=dnsProviderConfigsFromEnv();if(providerConfigs.length===0){logger.warn("DNS: no DNS provider credentials found (PORKBUN_API_KEY/\u2026); skipping DNS reconciliation.");for(const d of domains)logger.info(` Point manually: A ${d} \u2192 ${ip} and A www.${d} \u2192 ${ip}`);return published}const{reconcileAddressRecords}=await import("@stacksjs/ts-cloud");logger.info("Reconciling DNS records...");const resolveA=async(fqdn)=>{try{const{resolve4}=await import("node:dns/promises");return await resolve4(fqdn)}catch{return[]}};for(const domain of domains)try{const provider=await resolveZoneDnsProvider(domain,providerConfigs,logger);if(!provider){for(const sub of["","www"]){const fqdn=sub?`${sub}.${domain}`:domain,current=await resolveA(fqdn);if(current.includes(ip))logger.success(` DNS: ${fqdn} \u2192 ${ip} (externally managed, already correct)`);else if(current.length===0)logger.warn(` DNS: ${fqdn} does not resolve and no configured provider manages ${domain} \u2014 create it manually: A ${fqdn} \u2192 ${ip}`);else logger.warn(` DNS: ${fqdn} resolves to ${current.join(", ")} but this deploy targets ${ip}, and no configured provider manages ${domain} \u2014 update it manually: A ${fqdn} \u2192 ${ip}`)}continue}for(const sub of["","www"]){const fqdn=sub?`${sub}.${domain}`:domain,report=await reconcileAddressRecords({provider,zone:domain,fqdn,ipv4:ip,ipv6});for(const record of report.published){logger.success(` DNS: ${record.fqdn} \u2192 ${record.content} (${provider.name})`);published.push(record.fqdn)}for(const warning of report.warnings)logger.warn(` DNS: ${warning}`)}}catch(err){logger.warn(` DNS: ${domain} reconciliation failed: ${err?.message||err}`)}return published}async function buildContainerImageWithPantry(args){const{slug,sites,verbose}=args,{execSync}=await import("node:child_process");let cli;for(const candidate of["pantry","ts-pantry"])try{execSync(`command -v ${candidate}`,{stdio:"pipe"});cli=candidate;break}catch{}if(!cli){log.warn("pantry CLI not found on PATH \u2014 skipping container image build. Install pantry to enable `--docker`.");return}const dockerfile="storage/framework/Dockerfile",canPush=Boolean(process.env.PANTRY_REGISTRY_TOKEN||process.env.PANTRY_TOKEN);for(const[siteName,site]of Object.entries(sites)){if(!site?.start)continue;const tag=`${slug}-${siteName}:latest`;log.info(`[${siteName}] building image ${tag} with pantry (native, no Docker daemon)...`);const flags=["build",".","-t",tag,"-f",dockerfile,"--run-mode","skip"];if(canPush)flags.push("--push");execSync(`${cli} ${flags.map((f)=>f.includes(" ")?`'${f}'`:f).join(" ")}`,{stdio:verbose?"inherit":"pipe",maxBuffer:536870912});log.success(`[${siteName}] image built${canPush?" + pushed to the pantry registry":""}`)}}export function deploy(buddy){const descriptions={deploy:"Deploy your project",project:"Target a specific project",production:"Deploy to production",development:"Deploy to development",staging:"Deploy to staging",yes:"Confirm all prompts by default",domain:"Specify a domain to deploy to",verbose:"Enable verbose output"};buddy.command("deploy [env]",descriptions.deploy).option("--domain",descriptions.domain,{default:void 0}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--prod",descriptions.production,{default:!0}).option("--dev",descriptions.development,{default:!1}).option("--yes",descriptions.yes,{default:!1}).option("--site <name>","Deploy only this one site to the existing server (multi-tenant surgical add)",{default:void 0}).option("--staging",descriptions.staging,{default:!1}).option("--docker","Also build an OCI image with pantry (native, no Docker daemon) and push it to the pantry registry",{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(envArg,options)=>{log.debug("Running `buddy deploy` ...",options);if(process.argv.includes("--dry-run")||options.dryRun===!0){log.error("`buddy deploy --dry-run` is not supported.");log.info("Deploy has no preview mode: provisioning, release shipping, and DNS all mutate real infrastructure.");log.info("To see what would ship without touching the server, inspect `config/cloud.ts` sites, or run `buddy deploy --site <name>` to narrow a real deploy to one site.");process.exit(ExitCode.FatalError)}const deployEnv=envArg||(options.staging?"staging":options.dev?"development":"production"),deployEnvName=deployEnv==="prod"?"production":deployEnv==="dev"?"development":deployEnv;await ensureDeployPrerequisites(options.verbose===!0,deployEnvName);process.env.APP_ENV=deployEnvName;{const envFile=deployEnvName==="production"?".env.production":`.env.${deployEnvName}`;if(existsSync(p.projectPath(envFile)))try{const{loadEnv}=await import("@stacksjs/env");loadEnv({path:envFile,env:deployEnvName,keysFile:".env.keys",overload:!0,quiet:!0})}catch(err){log.warn(`Could not load ${envFile}: ${getErrorMessage(err)}`)}}const tsCloudConfig=await loadTsCloudConfig(deployEnvName);if(tsCloudConfig&&resolveProvider(tsCloudConfig)==="hetzner"){await deployToHetzner(tsCloudConfig,deployEnv,options);return}if(process.env.AWS_ACCESS_KEY_ID&&process.env.AWS_SECRET_ACCESS_KEY)delete process.env.AWS_PROFILE;const startTime=performance.now();console.log("");console.log("\uD83D\uDE80 Deploy");console.log("");let productionUrl;if(deployEnv==="production"||deployEnv==="prod"){const prodEnvPath=p.projectPath(".env.production");if(existsSync(prodEnvPath)){const urlMatch=readFileSync(prodEnvPath,"utf-8").match(/^APP_URL=(.+)$/m);if(urlMatch?.[1]){productionUrl=urlMatch[1].trim();log.debug("Using APP_URL from .env.production:",productionUrl)}}}const envUrl=env.APP_URL,domain=options.domain||productionUrl||envUrl||app.url;if((options.prod||deployEnv==="production"||deployEnv==="prod")&&!options.yes)await confirmProductionDeployment();if(!domain){log.info("No domain found in your .env.production or ./config/app.ts");log.info("Please ensure your domain is properly configured.");log.info("For more info, check out the docs or join our Discord.");process.exit(ExitCode.FatalError)}log.info(`Deploying to ${italic(domain)} (${deployEnv})`);await checkIfAwsIsBootstrapped(options);options.domain=await configureDomain(domain,options,startTime);const result=await runAction(Action.Deploy,options);if(resultFailed(result)){await outro("While running the `buddy deploy`, there was an issue",{startTime,useSeconds:!0},result.error);process.exit(ExitCode.FatalError)}await outro("Project deployed.",{startTime,useSeconds:!0})});onUnknownSubcommand(buddy,"deploy")}async function confirmProductionDeployment(){if(!process.stdin.isTTY){log.error("Refusing to deploy to production from a non-interactive shell without confirmation.");log.info(" \u27A1\uFE0F Re-run with `--yes` to confirm (e.g. in CI): `buddy deploy --prod --yes`");process.exit(ExitCode.InvalidArgument)}if(!await prompts.confirm({message:"Are you sure you want to deploy to production?",initial:!0})){log.info("Aborting deployment...");process.exit(ExitCode.InvalidArgument)}}async function configureDomain(domain,options,startTime){log.debug("Configuring domain...",domain);if(!domain){log.info("We could not identify a domain to deploy to.");log.warn("Please set your .env or ./config/app.ts properly.");log.info("Alternatively, specify a domain to deploy via the `--domain` flag.");console.log("");log.info(" \u27A1\uFE0F Example: `buddy deploy --domain example.com`");console.log("");process.exit(ExitCode.FatalError)}if(domain.includes("localhost")){log.info("You are deploying to a local environment.");log.warn("Please set your .env or ./config/app.ts properly. The domain we are deploying cannot be a `localhost` domain.");log.info("Alternatively, specify a domain to deploy via the `--domain` flag.");console.log("");log.info(" \u27A1\uFE0F Example: `buddy deploy --domain example.com`");console.log("");process.exit(ExitCode.FatalError)}if(await hasUserDomainBeenAddedToCloud(domain)){log.info("Domain is properly configured");log.info("Your cloud is deploying...");log.info(`${italic("This may take a while...")}`);return domain}console.log("");log.info(` \uD83D\uDC4B It appears to be your first ${italic(domain)} deployment.`);console.log("");log.info(italic("Let\u2019s ensure it is all connected properly."));log.info(italic("One moment..."));console.log("");const result=await addDomain({...options,deploy:!0,startTime});if(resultFailed(result)){await outro("While running the `buddy deploy`, there was an issue",{startTime,useSeconds:!0},result.error);process.exit(ExitCode.FatalError)}await outro("Added your domain.",{startTime,useSeconds:!0});process.exit(ExitCode.Success)}async function promptAndSaveCredentials(){const accessKeyId=await prompts.text({message:"AWS Access Key ID:",validate:(value)=>value.length>0?!0:"Access Key ID is required"});if(!accessKeyId){log.info("Deployment cancelled");process.exit(ExitCode.Success)}const secretAccessKey=await prompts.password({message:"AWS Secret Access Key:",validate:(value)=>value.length>0?!0:"Secret Access Key is required"});if(!secretAccessKey){log.info("Deployment cancelled");process.exit(ExitCode.Success)}const region=await prompts.text({message:"AWS Region:",initial:"us-east-1"});if(!region){log.info("Deployment cancelled");process.exit(ExitCode.Success)}const{setEnv}=await import("@stacksjs/env");await setEnv("AWS_ACCESS_KEY_ID",accessKeyId,{file:".env.production",encrypt:!0});await setEnv("AWS_SECRET_ACCESS_KEY",secretAccessKey,{file:".env.production",encrypt:!0});await setEnv("AWS_REGION",region||"us-east-1",{file:".env.production"});process.env.AWS_ACCESS_KEY_ID=accessKeyId;process.env.AWS_SECRET_ACCESS_KEY=secretAccessKey;process.env.AWS_REGION=region||"us-east-1";log.success("AWS credentials saved securely to .env.production");console.log("")}function loadAwsCredentialsFromEnv(){const environment=process.env.APP_ENV||process.env.NODE_ENV||"production",envFiles=[p.projectPath(`.env.${environment}`),p.projectPath(".env")];for(const envPath of envFiles){if(!existsSync(envPath))continue;try{const lines=readFileSync(envPath,"utf-8").split(`
|
|
349
|
+
if [ "$ENV_CHANGED" = 1 ]; then systemctl restart mail 2>/dev/null || true; echo "MAILTENANT:env-changed+restarted,forwards=$FWD_STATE"; else echo "MAILTENANT:current,forwards=$FWD_STATE"; fi`;try{const out=execSync(`ssh ${sshArgs.map((a)=>`'${a}'`).join(" ")} bash -s`,{input:script,encoding:"utf8",stdio:["pipe","pipe","pipe"]}),line=(out.match(/MAILTENANT:[^\n]*/)||[])[0]||"MAILTENANT:done",mailHostFromOut=(out.match(/MAILHOST:([^\n]*)/)||[])[1]?.trim(),mailHost=mailHostFromOut||`mail.${domain}`,dkimPubB64=(out.match(/DKIMPUB:([^\n]*)/)||[])[1]?.trim()||void 0,dkimSelector=(out.match(/DKIMSEL:([^\n]*)/)||[])[1]?.trim()||void 0,dkimGlobalKey=(out.match(/DKIMGLOBAL:([^\n]*)/)||[])[1]?.trim();if(dkimGlobalKey)logger.warn(`Mail: ${domain} is the mail server's global DKIM_DOMAIN, so it signs with ${dkimGlobalKey} and the per-domain key at /opt/mail/dkim/${domain}.private is unused. Rotate the global key, not that one.`);const madeAddrs=new Set([...out.matchAll(/MADE:([^\n]+)/g)].flatMap((m)=>m[1]?[m[1].trim()]:[])),migratedAddrs=new Set([...out.matchAll(/MIGRATED:([^\n]+)/g)].flatMap((m)=>m[1]?[m[1].trim()]:[])),created=boxes.filter((b)=>madeAddrs.has(b.address)).map((b)=>({address:b.address,password:b.password}));logger.success(`Mail routing reconciled (${line.replace("MAILTENANT:","")})`);const failed=[...out.matchAll(/FAIL:([^\n]+)/g)].flatMap((m)=>m[1]?[m[1].trim()]:[]);if(failed.length)logger.warn(`Mail: the server refused ${failed.length} mailbox(es): ${failed.join(", ")}`);const forwardError=(out.match(/FWDERR:([^\n]*)/)||[])[1]?.trim();if(forwardError)logger.warn(`Mail: the forward rules were not merged: ${forwardError}`);const certHost=(out.match(/CERTHOST:([^\n]*)/)||[])[1]?.trim();if(certHost)logger.success(`Mail: ${certHost} added to the mail certificate \u2014 clients can use it as the server name`);const certError=(out.match(/CERTFAIL:([^\n]*)/)||[])[1]?.trim();if(certError)logger.warn(`Mail: could not issue a certificate for mail.${domain} (clients should use ${mailHostFromOut||"the shared mail host"}): ${certError}`);const seen=new Set([...madeAddrs,...migratedAddrs,...[...out.matchAll(/EXISTS:([^\n]+)/g)].flatMap((m)=>m[1]?[m[1].trim()]:[])]),unaccounted=boxes.filter((b)=>!seen.has(b.address)).map((b)=>b.address);if(unaccounted.length)logger.warn(`Mail: ${unaccounted.length} declared mailbox(es) were not reconciled: ${unaccounted.join(", ")}`);if(created.length){logger.info(`Mail: created ${created.length} mailbox(es) \u2014 credentials below (save them; shown once):`);for(const b of created)logger.info(` ${b.address} ${b.password}`)}if(migratedAddrs.size)logger.success(`Mail: migrated ${migratedAddrs.size} legacy mailbox username(s) to isolated full addresses`);return domain?{domain,mailHost,dkimPubB64,dkimSelector,created}:null}catch(err){logger.warn(`Mail routing reconcile skipped: ${getErrorMessage(err)}`);return null}}export function dnsProviderConfigsFromEnv(){const configs=[];if(process.env.PORKBUN_API_KEY&&process.env.PORKBUN_SECRET_KEY)configs.push({provider:"porkbun",apiKey:process.env.PORKBUN_API_KEY,secretKey:process.env.PORKBUN_SECRET_KEY});if(process.env.CLOUDFLARE_API_TOKEN)configs.push({provider:"cloudflare",apiToken:process.env.CLOUDFLARE_API_TOKEN});if(process.env.GODADDY_API_KEY&&process.env.GODADDY_API_SECRET)configs.push({provider:"godaddy",apiKey:process.env.GODADDY_API_KEY,apiSecret:process.env.GODADDY_API_SECRET,environment:process.env.GODADDY_ENVIRONMENT});if(process.env.AWS_ACCESS_KEY_ID||process.env.AWS_PROFILE)configs.push({provider:"route53"});return configs}async function resolveZoneDnsProvider(domain,providerConfigs,logger){if(providerConfigs.length===0)return;const{createDnsProvider,detectDnsProvider}=await import("@stacksjs/ts-cloud"),provider=await detectDnsProvider(domain,providerConfigs).catch((err)=>{logger.warn(` DNS: ignoring a configured provider for ${domain} \u2014 its credentials were rejected (${err?.message||err})`);return});if(provider)return provider;let nameservers=[];try{const{resolveNs}=await import("node:dns/promises");nameservers=await resolveNs(domain)}catch{}const providerName=dnsProviderNameFromNameservers(nameservers),providerConfig=providerConfigs.find((config)=>config.provider===providerName);return providerConfig?createDnsProvider(providerConfig):void 0}export function resolveDmarcPolicy(policy){return policy==="none"||policy==="quarantine"||policy==="reject"?policy:"quarantine"}export function zoneFqdn(name,zone){const apex=zone.replace(/\.$/,"").toLowerCase(),n=String(name??"").replace(/\.$/,"").toLowerCase();if(!n||n==="@")return apex;return n===apex||n.endsWith(`.${apex}`)?n:`${n}.${apex}`}export function selectRecordsAt(records,fqdn,type,zone){const target=zoneFqdn(fqdn,zone);return records.filter((r)=>String(r.type).toUpperCase()===type.toUpperCase()&&zoneFqdn(r.name,zone)===target)}export function findMailDnsAnomalies(records,expectations,zone){const problems=[];for(const expectation of expectations){const at=selectRecordsAt(records,expectation.fqdn,expectation.type,zone),ours=expectation.owns?at.filter((record)=>expectation.owns(txtContent(record))):at;if(ours.length===0)problems.push(`${expectation.label}: nothing published at ${expectation.fqdn}`);else if(ours.length>1)problems.push(`${expectation.label}: ${ours.length} records at ${expectation.fqdn}, expected 1 \u2014 receivers treat a duplicated ${expectation.type} here as unconfigured, so remove the stale one`)}return problems}export function txtContent(record){return String(record.content??record.value??"").replace(/^"|"$/g,"")}export function planTxtReplacement(existing,content,owns){const ours=existing.filter((record)=>owns(txtContent(record)));if(ours.length===1&&txtContent(ours[0])===content)return{remove:[],create:!1};return{remove:ours,create:!0}}export async function reconcileMailDns(res,ip,logger){const{domain,dkimPubB64}=res;let{mailHost}=res;const dkimName=`${res.dkimSelector||"mail"}._domainkey`;try{const dns=await import("node:dns"),own=`mail.${domain}`;if(own!==mailHost&&(await dns.promises.resolve4(own)).includes(ip))mailHost=own}catch{}const spf=`v=spf1 ip4:${ip} ~all`,cfg=emailConfig||{},firstBox=resolveMailboxes(cfg.mailboxes,domain)[0]?.address,fromAddress=typeof cfg.from?.address==="string"&&cfg.from.address.includes("@")?cfg.from.address:void 0,dmarcCfg=cfg.server?.dmarc||{},rua=dmarcCfg.reportTo||fromAddress||firstBox||`chris@${domain}`,dmarc=`v=DMARC1; p=${resolveDmarcPolicy(dmarcCfg.policy)}; rua=mailto:${rua}`,dkim=dkimPubB64?`v=DKIM1; k=rsa; p=${dkimPubB64}`:void 0,byHand=(reason)=>{logger.warn(`Mail DNS not published for ${domain}: ${reason}`);logger.info("Add these records by hand, or point a configured provider at the zone:");logger.info(` MX @ 10 ${mailHost}`);logger.info(` TXT @ ${spf}`);if(dkim)logger.info(` TXT ${dkimName.padEnd(17)}${dkim}`);logger.info(` TXT _dmarc ${dmarc}`);logger.info(` A mail ${ip}`)},providerConfigs=dnsProviderConfigsFromEnv();if(providerConfigs.length===0)return byHand("no DNS provider credentials are configured");const provider=await resolveZoneDnsProvider(domain,providerConfigs,logger);if(!provider)return byHand("no configured DNS provider administers this zone");const apex=domain.toLowerCase(),dkimFqdn=`${dkimName}.${domain}`,dmarcFqdn=`_dmarc.${domain}`,mailFqdn=`mail.${domain}`,recordsAt=async(fqdn,type)=>{const res=await provider.listRecords(domain);return selectRecordsAt(res?.success?res.records||[]:[],fqdn,type,domain)},replaceTxt=async(fqdn,content,owns)=>{const existing=await recordsAt(fqdn,"TXT"),{remove,create}=planTxtReplacement(existing,content,owns);if(!create)return;for(const record of remove){const removed=await provider.deleteRecord(domain,{...record,name:fqdn,type:"TXT"});if(removed&&removed.success===!1)throw Error(`TXT ${fqdn}: could not remove the record being replaced (${removed.message||"provider refused the delete"})`)}const created=await provider.createRecord(domain,{name:fqdn,type:"TXT",content,ttl:600});if(!created?.success)throw Error(`TXT ${fqdn}: ${created?.message||"provider rejected the record"}`)};try{const mxRecords=await recordsAt(apex,"MX"),staleMx=mxRecords.filter((r)=>String(r.content??r.value??"").replace(/\.$/,"").toLowerCase()!==mailHost.toLowerCase());for(const record of staleMx){logger.warn(` Mail DNS: replacing an existing MX for ${domain} \u2192 ${record.content??record.value}`);await provider.deleteRecord(domain,{...record,name:apex,type:"MX"})}if(mxRecords.length===staleMx.length){const created=await provider.createRecord(domain,{name:apex,type:"MX",content:mailHost,ttl:600,priority:10});if(!created?.success)throw Error(`MX ${domain}: ${created?.message||"provider rejected the record"}`)}await replaceTxt(apex,spf,(existing)=>existing.toLowerCase().startsWith("v=spf1"));if(dkim)await replaceTxt(dkimFqdn,dkim,()=>!0);await replaceTxt(dmarcFqdn,dmarc,(existing)=>existing.toLowerCase().startsWith("v=dmarc1"));const mailA=await provider.upsertRecord(domain,{name:mailFqdn,type:"A",content:ip,ttl:600});if(!mailA?.success)throw Error(`A ${mailFqdn}: ${mailA?.message||"provider rejected the record"}`);const verifyRes=await provider.listRecords(domain),anomalies=verifyRes?.success?findMailDnsAnomalies(verifyRes.records||[],[{label:"MX",fqdn:apex,type:"MX"},{label:"SPF",fqdn:apex,type:"TXT",owns:(content)=>content.toLowerCase().startsWith("v=spf1")},...dkim?[{label:"DKIM",fqdn:dkimFqdn,type:"TXT"}]:[],{label:"DMARC",fqdn:dmarcFqdn,type:"TXT",owns:(content)=>content.toLowerCase().startsWith("v=dmarc1")}],domain):[];for(const anomaly of anomalies)logger.warn(` Mail DNS: ${anomaly}`);logger.success(`Mail DNS published for ${domain} via ${provider.name} (MX\u2192${mailHost}, SPF, DKIM at ${dkimName}, DMARC, ${mailFqdn}\u2192${ip})`)}catch(err){logger.warn(`Mail DNS reconcile skipped for ${domain}: ${getErrorMessage(err)}`)}}export function configDnsDomains(sites){const domains=new Set;for(const site of Object.values(sites))if(!site?.redirect&&site?.domain&&typeof site.domain==="string")domains.add(site.domain.replace(/^www\./,""));return[...domains]}export function dnsProviderNameFromNameservers(nameservers){const normalized=nameservers.map((name)=>name.toLowerCase().replace(/\.$/,""));if(normalized.some((name)=>name.endsWith(".porkbun.com")))return"porkbun";if(normalized.some((name)=>name.endsWith(".ns.cloudflare.com")))return"cloudflare";if(normalized.some((name)=>/(^|\.)awsdns-\d+\.(?:com|net|org|co\.uk)$/.test(name)))return"route53";if(normalized.some((name)=>name.endsWith(".domaincontrol.com")))return"godaddy";return null}async function reconcileConfigDns(sites,logger){const projectDnsConfig=await loadProjectDnsConfig(dnsConfig);if(["a","aaaa","cname","mx","txt"].reduce((total,key)=>total+(Array.isArray(projectDnsConfig?.[key])?projectDnsConfig[key].length:0),0)===0)return;for(const domain of configDnsDomains(sites))try{const result=await syncDnsConfig(domain,projectDnsConfig);if(!result.provider)continue;if(result.created||result.failed)logger.info(`DNS (config/dns.ts) ${domain}: ${result.created} created, ${result.kept} kept${result.failed?`, ${result.failed} failed`:""}`)}catch(err){logger.warn(`DNS (config/dns.ts) reconcile for ${domain} failed: ${err instanceof Error?err.message:String(err)}`)}}async function reconcileHetznerDns(sites,ip,logger,ipv6){const published=[],domains=new Set;for(const site of Object.values(sites))if(site?.domain&&typeof site.domain==="string")domains.add(site.domain.replace(/^www\./,""));if(domains.size===0)return published;const providerConfigs=dnsProviderConfigsFromEnv();if(providerConfigs.length===0){logger.warn("DNS: no DNS provider credentials found (PORKBUN_API_KEY/\u2026); skipping DNS reconciliation.");for(const d of domains)logger.info(d.split(".").length===2?` Point manually: A ${d} \u2192 ${ip} and A www.${d} \u2192 ${ip}`:` Point manually: A ${d} \u2192 ${ip}`);return published}const{reconcileAddressRecords}=await import("@stacksjs/ts-cloud");logger.info("Reconciling DNS records...");const resolveA=async(fqdn)=>{try{const{resolve4}=await import("node:dns/promises");return await resolve4(fqdn)}catch{return[]}};for(const domain of domains){const subs=domain.split(".").length===2?["","www"]:[""];try{const provider=await resolveZoneDnsProvider(domain,providerConfigs,logger);if(!provider){for(const sub of subs){const fqdn=sub?`${sub}.${domain}`:domain,current=await resolveA(fqdn);if(current.includes(ip))logger.success(` DNS: ${fqdn} \u2192 ${ip} (externally managed, already correct)`);else if(current.length===0)logger.warn(` DNS: ${fqdn} does not resolve and no configured provider manages ${domain} \u2014 create it manually: A ${fqdn} \u2192 ${ip}`);else logger.warn(` DNS: ${fqdn} resolves to ${current.join(", ")} but this deploy targets ${ip}, and no configured provider manages ${domain} \u2014 update it manually: A ${fqdn} \u2192 ${ip}`)}continue}for(const sub of subs){const fqdn=sub?`${sub}.${domain}`:domain,report=await reconcileAddressRecords({provider,zone:domain,fqdn,ipv4:ip,ipv6});for(const record of report.published){logger.success(` DNS: ${record.fqdn} \u2192 ${record.content} (${provider.name})`);published.push(record.fqdn)}for(const warning of report.warnings)logger.warn(` DNS: ${warning}`)}}catch(err){logger.warn(` DNS: ${domain} reconciliation failed: ${err?.message||err}`)}}return published}async function buildContainerImageWithPantry(args){const{slug,sites,verbose}=args,{execSync}=await import("node:child_process");let cli;for(const candidate of["pantry","ts-pantry"])try{execSync(`command -v ${candidate}`,{stdio:"pipe"});cli=candidate;break}catch{}if(!cli){log.warn("pantry CLI not found on PATH \u2014 skipping container image build. Install pantry to enable `--docker`.");return}const dockerfile="storage/framework/Dockerfile",canPush=Boolean(process.env.PANTRY_REGISTRY_TOKEN||process.env.PANTRY_TOKEN);for(const[siteName,site]of Object.entries(sites)){if(!site?.start)continue;const tag=`${slug}-${siteName}:latest`;log.info(`[${siteName}] building image ${tag} with pantry (native, no Docker daemon)...`);const flags=["build",".","-t",tag,"-f",dockerfile,"--run-mode","skip"];if(canPush)flags.push("--push");execSync(`${cli} ${flags.map((f)=>f.includes(" ")?`'${f}'`:f).join(" ")}`,{stdio:verbose?"inherit":"pipe",maxBuffer:536870912});log.success(`[${siteName}] image built${canPush?" + pushed to the pantry registry":""}`)}}export function deploy(buddy){const descriptions={deploy:"Deploy your project",project:"Target a specific project",production:"Deploy to production",development:"Deploy to development",staging:"Deploy to staging",yes:"Confirm all prompts by default",domain:"Specify a domain to deploy to",verbose:"Enable verbose output"};buddy.command("deploy [env]",descriptions.deploy).option("--domain <domain>",descriptions.domain,{default:void 0}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--prod",descriptions.production,{default:!1}).option("--dev",descriptions.development,{default:!1}).option("--yes",descriptions.yes,{default:!1}).option("--site <name>","Deploy only this one site to the existing server (multi-tenant surgical add)",{default:void 0}).option("--staging",descriptions.staging,{default:!1}).option("--docker","Also build an OCI image with pantry (native, no Docker daemon) and push it to the pantry registry",{default:!1}).option("-J, --json","Emit a machine-readable deployment preview",{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(envArg,options)=>{log.debug("Running `buddy deploy` ...",options);const askedForDryRun=process.argv.includes("--dry-run")||options.dryRun===!0,optionEnvironment=options.env,deployEnvName=resolveDeploymentEnvironment({positional:envArg,option:optionEnvironment,staging:options.staging,development:options.dev}),deployEnv=deployEnvName;try{applyDeploymentDomainOverride({},options.domain)}catch(error){log.error(getErrorMessage(error));process.exit(ExitCode.InvalidArgument)}if(askedForDryRun){process.env.APP_ENV=deployEnvName;const envFile=deployEnvName==="production"?".env.production":`.env.${deployEnvName}`;if(existsSync(p.projectPath(envFile)))try{const{loadEnv}=await import("@stacksjs/env");loadEnv({path:envFile,env:deployEnvName,keysFile:".env.keys",overload:!0,quiet:!0})}catch(error){log.debug(`Could not load ${envFile} for preview: ${getErrorMessage(error)}`)}const tsCloudConfig=await loadTsCloudConfig(deployEnvName),{resolveSiteKind}=await loadTsCloudDeployApi(),unbacked=tsCloudConfig?findUnbackedManagedServices(tsCloudConfig):[];let plan;try{plan=createDeploymentPreview({config:tsCloudConfig,environment:deployEnvName,site:options.site,domain:options.domain,docker:options.docker===!0,fallbackProjectName:app.name,fallbackProjectSlug:app.name?.toLowerCase().replace(/[^a-z0-9]+/g,"-")||"app",fallbackProvider:"aws",fallbackMode:cloudConfig.mode||"server",fallbackRegion:process.env.AWS_REGION||"us-east-1",resolveSiteKind,applyEnvironmentToSites,warnings:unbacked.length>0?[unbackedDataMessage(unbacked)]:[]})}catch(error){log.error(getErrorMessage(error));process.exit(ExitCode.InvalidArgument)}if(options.json||process.argv.includes("--json"))console.log(`${deploymentPreviewJsonPrefix}${JSON.stringify(plan)}`);else process.stdout.write(formatDeploymentPreview(plan));return}await ensureDeployPrerequisites(options.verbose===!0,deployEnvName);process.env.APP_ENV=deployEnvName;{const envFile=deployEnvName==="production"?".env.production":`.env.${deployEnvName}`;if(existsSync(p.projectPath(envFile)))try{const{loadEnv}=await import("@stacksjs/env");loadEnv({path:envFile,env:deployEnvName,keysFile:".env.keys",overload:!0,quiet:!0})}catch(err){log.warn(`Could not load ${envFile}: ${getErrorMessage(err)}`)}}const tsCloudConfig=await loadTsCloudConfig(deployEnvName);if(tsCloudConfig&&resolveProvider(tsCloudConfig)==="hetzner"){await deployToHetzner(applyDeploymentDomainOverride(tsCloudConfig,options.domain),deployEnv,options);return}if(process.env.AWS_ACCESS_KEY_ID&&process.env.AWS_SECRET_ACCESS_KEY)delete process.env.AWS_PROFILE;const startTime=performance.now();console.log("");console.log("\uD83D\uDE80 Deploy");console.log("");let productionUrl;if(deployEnv==="production"||deployEnv==="prod"){const prodEnvPath=p.projectPath(".env.production");if(existsSync(prodEnvPath)){const urlMatch=readFileSync(prodEnvPath,"utf-8").match(/^APP_URL=(.+)$/m);if(urlMatch?.[1]){productionUrl=urlMatch[1].trim();log.debug("Using APP_URL from .env.production:",productionUrl)}}}const envUrl=env.APP_URL,domain=options.domain||productionUrl||envUrl||app.url;if(deployEnvName==="production"&&!options.yes)await confirmProductionDeployment();if(!domain){log.info("No domain found in your .env.production or ./config/app.ts");log.info("Please ensure your domain is properly configured.");log.info("For more info, check out the docs or join our Discord.");process.exit(ExitCode.FatalError)}log.info(`Deploying to ${italic(domain)} (${deployEnv})`);await checkIfAwsIsBootstrapped(options);options.domain=await configureDomain(domain,options,startTime);const result=await runAction(Action.Deploy,options);if(resultFailed(result)){await outro("While running the `buddy deploy`, there was an issue",{startTime,useSeconds:!0},result.error);process.exit(ExitCode.FatalError)}await outro("Project deployed.",{startTime,useSeconds:!0})});buddy.command("deploy:rollback [site]","Roll back a deployment to a preserved release").option("--env <environment>","Environment to roll back",{default:"production"}).option("--to <release>","Preserved release id to activate",{default:void 0}).option("--dry-run","Preview the rollback without changing the active release",{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(site,options)=>{const exitCode=await runDeployRollback(site,options);if(exitCode!==ExitCode.Success)process.exit(exitCode)});onUnknownSubcommand(buddy,"deploy")}async function confirmProductionDeployment(){if(!process.stdin.isTTY){log.error("Refusing to deploy to production from a non-interactive shell without confirmation.");log.info(" \u27A1\uFE0F Re-run with `--yes` to confirm (e.g. in CI): `buddy deploy --prod --yes`");process.exit(ExitCode.InvalidArgument)}if(!await prompts.confirm({message:"Are you sure you want to deploy to production?",initial:!0})){log.info("Aborting deployment...");process.exit(ExitCode.InvalidArgument)}}async function configureDomain(domain,options,startTime){log.debug("Configuring domain...",domain);if(!domain){log.info("We could not identify a domain to deploy to.");log.warn("Please set your .env or ./config/app.ts properly.");log.info("Alternatively, specify a domain to deploy via the `--domain` flag.");console.log("");log.info(" \u27A1\uFE0F Example: `buddy deploy --domain example.com`");console.log("");process.exit(ExitCode.FatalError)}if(domain.includes("localhost")){log.info("You are deploying to a local environment.");log.warn("Please set your .env or ./config/app.ts properly. The domain we are deploying cannot be a `localhost` domain.");log.info("Alternatively, specify a domain to deploy via the `--domain` flag.");console.log("");log.info(" \u27A1\uFE0F Example: `buddy deploy --domain example.com`");console.log("");process.exit(ExitCode.FatalError)}if(await hasUserDomainBeenAddedToCloud(domain)){log.info("Domain is properly configured");log.info("Your cloud is deploying...");log.info(`${italic("This may take a while...")}`);return domain}console.log("");log.info(` \uD83D\uDC4B It appears to be your first ${italic(domain)} deployment.`);console.log("");log.info(italic("Let\u2019s ensure it is all connected properly."));log.info(italic("One moment..."));console.log("");const result=await addDomain({...options,deploy:!0,startTime});if(resultFailed(result)){await outro("While running the `buddy deploy`, there was an issue",{startTime,useSeconds:!0},result.error);process.exit(ExitCode.FatalError)}await outro("Added your domain.",{startTime,useSeconds:!0});process.exit(ExitCode.Success)}async function promptAndSaveCredentials(){const accessKeyId=await prompts.text({message:"AWS Access Key ID:",validate:(value)=>value.length>0?!0:"Access Key ID is required"});if(!accessKeyId){log.info("Deployment cancelled");process.exit(ExitCode.Success)}const secretAccessKey=await prompts.password({message:"AWS Secret Access Key:",validate:(value)=>value.length>0?!0:"Secret Access Key is required"});if(!secretAccessKey){log.info("Deployment cancelled");process.exit(ExitCode.Success)}const region=await prompts.text({message:"AWS Region:",initial:"us-east-1"});if(!region){log.info("Deployment cancelled");process.exit(ExitCode.Success)}const{setEnv}=await import("@stacksjs/env");await setEnv("AWS_ACCESS_KEY_ID",accessKeyId,{file:".env.production",encrypt:!0});await setEnv("AWS_SECRET_ACCESS_KEY",secretAccessKey,{file:".env.production",encrypt:!0});await setEnv("AWS_REGION",region||"us-east-1",{file:".env.production"});process.env.AWS_ACCESS_KEY_ID=accessKeyId;process.env.AWS_SECRET_ACCESS_KEY=secretAccessKey;process.env.AWS_REGION=region||"us-east-1";log.success("AWS credentials saved securely to .env.production");console.log("")}function loadAwsCredentialsFromEnv(){const environment=process.env.APP_ENV||process.env.NODE_ENV||"production",envFiles=[p.projectPath(`.env.${environment}`),p.projectPath(".env")];for(const envPath of envFiles){if(!existsSync(envPath))continue;try{const lines=readFileSync(envPath,"utf-8").split(`
|
|
350
350
|
`);let accessKeyId,secretAccessKey,region,accountId;for(const line of lines){const trimmed=line.trim();if(trimmed.startsWith("#")||!trimmed.includes("="))continue;const[key,...valueParts]=trimmed.split("="),value=valueParts.join("=").trim();if(key==="AWS_ACCESS_KEY_ID"&&value)accessKeyId=value;else if(key==="AWS_SECRET_ACCESS_KEY"&&value)secretAccessKey=value;else if(key==="AWS_REGION"&&value)region=value;else if(key==="AWS_ACCOUNT_ID"&&value)accountId=value}if(accessKeyId&&secretAccessKey){log.debug(`Found AWS credentials in ${envPath}`);return{accessKeyId,secretAccessKey,region,accountId}}}catch(error){log.debug(`Failed to read ${envPath} file:`,error)}}return{}}async function checkIfAwsIsBootstrapped(options){let handlingAlreadyExists=!1;try{log.info("Ensuring AWS cloud stack exists...");let hasCredentials=process.env.AWS_ACCESS_KEY_ID&&process.env.AWS_SECRET_ACCESS_KEY;if(!hasCredentials){const envCredentials=loadAwsCredentialsFromEnv();if(envCredentials.accessKeyId&&envCredentials.secretAccessKey){process.env.AWS_ACCESS_KEY_ID=envCredentials.accessKeyId;process.env.AWS_SECRET_ACCESS_KEY=envCredentials.secretAccessKey;if(envCredentials.region&&!process.env.AWS_REGION)process.env.AWS_REGION=envCredentials.region;if(envCredentials.accountId&&!process.env.AWS_ACCOUNT_ID)process.env.AWS_ACCOUNT_ID=envCredentials.accountId;hasCredentials=!0;const environment=process.env.APP_ENV||process.env.NODE_ENV||"production";log.success(`Using AWS credentials from .env.${environment}`)}}if(!hasCredentials){const fileCredentials=loadAwsCredentialsFromFile();if(fileCredentials.accessKeyId&&fileCredentials.secretAccessKey){process.env.AWS_ACCESS_KEY_ID=fileCredentials.accessKeyId;process.env.AWS_SECRET_ACCESS_KEY=fileCredentials.secretAccessKey;if(fileCredentials.region&&!process.env.AWS_REGION)process.env.AWS_REGION=fileCredentials.region;hasCredentials=!0;log.success("Using AWS credentials from ~/.aws/credentials")}}if(!hasCredentials){log.info("AWS credentials not found in .env or ~/.aws/credentials.");log.info("You can either:");log.info(" 1. Set AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY in .env.production");log.info(" 2. Add credentials to ~/.aws/credentials");log.info(" 3. Configure them interactively below");console.log("");if(options?.yes){log.info("Skipping credential setup (--yes flag provided)");process.exit(ExitCode.FatalError)}const setupCredentials=await prompts.confirm({message:"Would you like to configure AWS credentials now?",initial:!0});log.debug("setupCredentials response:",setupCredentials,typeof setupCredentials);if(setupCredentials===void 0||setupCredentials===!1){if(setupCredentials===void 0){console.log("");log.info("Deployment cancelled");process.exit(ExitCode.Success)}console.log("");log.info("Skipping cloud infrastructure check");log.info("You can configure AWS credentials later by running: buddy configure:aws");return!0}await promptAndSaveCredentials()}else log.success("AWS credentials found");const appName=(process.env.APP_NAME||app.name||"stacks").toLowerCase().replace(/[^a-z0-9-]/g,"-"),stackName=`${appName}-cloud`,{AWSCloudFormationClient}=await import("@stacksjs/ts-cloud"),cfnClient=new AWSCloudFormationClient(process.env.AWS_REGION||"us-east-1");let stackExists=!1,needsEmailUpdate=!1;try{const stack=(await cfnClient.describeStacks({stackName})).Stacks?.[0];if(stack){stackExists=!0;log.success("Cloud stack exists");const{AWSCloudFormationClient}=await import("@stacksjs/ts-cloud"),resources=await new AWSCloudFormationClient(process.env.AWS_REGION||"us-east-1").listStackResources(stackName),hasEmailBucket=resources.StackResourceSummaries?.some((r)=>r.LogicalResourceId==="EmailBucket"),hasOutboundLambda=resources.StackResourceSummaries?.some((r)=>r.LogicalResourceId==="OutboundEmailLambda"),hasConversionLambda=resources.StackResourceSummaries?.some((r)=>r.LogicalResourceId==="EmailConversionLambda"),hasNotificationTopic=resources.StackResourceSummaries?.some((r)=>r.LogicalResourceId==="EmailNotificationTopic"),hasMailApiLambda=resources.StackResourceSummaries?.some((r)=>r.LogicalResourceId==="MailApiLambda"),hasMailUsersTable=resources.StackResourceSummaries?.some((r)=>r.LogicalResourceId==="MailUsersTable"),hasMailServerInstance=resources.StackResourceSummaries?.some((r)=>r.LogicalResourceId==="MailServerInstance"),currentEmailDomain=stack.Outputs?.find((o)=>o.OutputKey==="EmailDomain")?.OutputValue,configuredDomain=(emailConfig?.from?.address?.includes("@")?emailConfig.from.address.split("@")[1]:void 0)||"stacksjs.com";if(!hasEmailBucket&&emailConfig?.server?.scan!==void 0){log.info("Email infrastructure not found in stack, will update...");needsEmailUpdate=!0}else if(currentEmailDomain&¤tEmailDomain!==configuredDomain){log.info(`Email domain changed: ${currentEmailDomain} -> ${configuredDomain}, will update...`);needsEmailUpdate=!0}else if(hasEmailBucket&&(!hasOutboundLambda||!hasConversionLambda||!hasNotificationTopic)){log.info("Email infrastructure incomplete, will update...");needsEmailUpdate=!0}else if(hasEmailBucket&&(!hasMailApiLambda||!hasMailUsersTable)){log.info("Mail API infrastructure missing, will update...");needsEmailUpdate=!0}else if(hasEmailBucket&&!hasMailServerInstance&&emailConfig?.server?.enabled){log.info("Mail server EC2 instance missing, will update...");needsEmailUpdate=!0}const currentMode=(stack.Outputs||[]).find((o)=>o.OutputKey==="MailServerMode")?.OutputValue,configuredMode=emailConfig?.server?.mode||"serverless";if(currentMode&¤tMode!==configuredMode){log.info(`Mail server mode changed: ${currentMode} -> ${configuredMode}, will update...`);needsEmailUpdate=!0}if(hasMailServerInstance&&emailConfig?.server?.enabled){if(process.env.FORCE_MAIL_UPDATE==="true"){log.info("Forcing mail server update...");needsEmailUpdate=!0}}if(!needsEmailUpdate)return!0}}catch(error){const caught=error&&typeof error==="object"?error:{message:String(error)};log.debug(`Stack not found: ${getErrorMessage(error)}`)}if(!stackExists)log.info("Cloud stack not found, will be created by deploy action");return!0}catch(err){if(!handlingAlreadyExists){log.error("Error checking cloud infrastructure");log.error(`Error: ${getErrorMessage(err)}`);if(options?.verbose)console.error(err)}process.exit(ExitCode.FatalError)}}
|
package/dist/commands/doctor.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import{lstatSync,readlinkSync}from"node:fs";import{resolve}from"node:path";import process from"node:process";import{bold,dim,green,intro,log,onUnknownSubcommand,red,yellow}from"@stacksjs/cli";import{feature}from"@stacksjs/config";import{inspectDefaultsProvenance}from"@stacksjs/path";import{storage}from"@stacksjs/storage";import{isSupportedBunVersion,isVersionGreaterThanOrEqual,minimumBunVersion}from"@stacksjs/utils";import{FEATURE_NAMES,featurePathsPresent}from"./features";const minimumSqliteVersion="3.47.2";class ProbeWarning extends Error{}async function probe(checks,name,fn,timeoutMs=2000){const start=Date.now(),ac=new AbortController,timer=setTimeout(()=>ac.abort(),timeoutMs);try{const message=await Promise.race([fn(),new Promise((_,rej)=>ac.signal.addEventListener("abort",()=>rej(Error(`timed out (>${Math.round(timeoutMs/1000)}s)`))))]);checks.push({name,status:"pass",message:`${message} (${Date.now()-start}ms)`})}catch(err){checks.push({name,status:err instanceof ProbeWarning?"warn":"fail",message:err instanceof Error?err.message:String(err)})}finally{clearTimeout(timer)}}export function doctor(buddy){buddy.command("doctor","Run health checks on your Stacks installation").option("--no-fail","Print results but always exit 0 (CI ramp-up)").action(async(options)=>{log.debug("Running `buddy doctor` ...");await intro("buddy doctor");const checks=[],modulesPath=resolve(process.cwd(),"node_modules");try{if(lstatSync(modulesPath).isSymbolicLink()){const target=resolve(process.cwd(),readlinkSync(modulesPath)),insideProject=target.startsWith(`${resolve(process.cwd())}/`);checks.push({name:"Dependency tree",status:insideProject?"warn":"fail",message:insideProject?`node_modules is a symlink to ${target}`:`node_modules is a symlink to ${target}, which belongs to another project \u2014 installs here mutate it, and what runs locally is not what the lockfile resolves. Remove the link and run \`bun install\`.`})}else checks.push({name:"Dependency tree",status:"pass",message:"node_modules is this project's own"})}catch{checks.push({name:"Dependency tree",status:"warn",message:"node_modules is missing \u2014 run `bun install`"})}await probe(checks,"Framework defaults",async()=>{const{measureDefaultsDrift,summarizeStructureChanges}=await import("@stacksjs/actions"),root=resolve(process.cwd()),skew=inspectDefaultsProvenance(root);if(skew.status==="not-applicable")return"Not a package-managed project (nothing to compare)";const drift=measureDefaultsDrift(root)??[],synced=skew.syncedAt?`, synced ${skew.syncedAt.slice(0,10)}`:"";if(drift.length===0)return`Vendored tree matches @stacksjs/defaults ${skew.installed}${synced}`;const counts=summarizeStructureChanges(drift);if(skew.status==="stale")throw new ProbeWarning(`storage/framework/defaults is from ${skew.vendored} while @stacksjs/defaults ${skew.installed} is installed (${counts}). Boot resolves the package, so the tree on disk is not what runs. Run \`buddy upgrade\` to sync it.`);throw new ProbeWarning(`storage/framework/defaults differs from the installed @stacksjs/defaults ${skew.installed} (${counts}), and carries no record of what it was synced from. The app runs the vendored copy. Run \`buddy upgrade\` to sync it.`)},8000);const bunVersion=process.versions.bun;if(bunVersion&&isSupportedBunVersion(bunVersion))checks.push({name:"Bun Runtime",status:"pass",message:`v${bunVersion}`});else if(bunVersion)checks.push({name:"Bun Runtime",status:"fail",message:`v${bunVersion} (requires v${minimumBunVersion} or later, run: bun upgrade)`});else checks.push({name:"Bun Runtime",status:"fail",message:"Not found"});const nodeVersion=process.versions.node;if(nodeVersion)if(Number.parseInt(nodeVersion.split(".")[0]||"0",10)>=18)checks.push({name:"Node.js",status:"pass",message:`v${nodeVersion}`});else checks.push({name:"Node.js",status:"warn",message:`v${nodeVersion} (v18+ recommended)`});try{const pkg=await storage.readPackageJson("./package.json");if(pkg.name)checks.push({name:"package.json",status:"pass",message:`Found: ${pkg.name}`})}catch{checks.push({name:"package.json",status:"fail",message:"Not found in current directory"})}try{await storage.readTextFile(".env");checks.push({name:".env file",status:"pass",message:"Found"})}catch{checks.push({name:".env file",status:"warn",message:"Not found (optional)"})}const appKey=process.env.APP_KEY;if(appKey&&appKey.length>0)checks.push({name:"APP_KEY",status:appKey.length>=32?"pass":"warn",message:appKey.length>=32?"Set (\u226532 chars)":`Set but short (${appKey.length} chars; \u226532 recommended)`});else checks.push({name:"APP_KEY",status:"fail",message:"Not set \u2014 features that depend on it (encrypted columns, signed URLs, env decryption) will refuse to run. Run `buddy key:generate`."});await probe(checks,"SQLite Engine",async()=>{const{Database}=await import("bun:sqlite"),memory=new Database(":memory:");let version;try{const row=memory.query("SELECT sqlite_version() AS version").get();if(typeof row?.version==="string")version=row.version}finally{memory.close()}if(!version)throw new ProbeWarning("could not read sqlite_version() (skipped)");if(!isVersionGreaterThanOrEqual(version,minimumSqliteVersion))throw Error(`v${version} is below the required v${minimumSqliteVersion} (system.sqlite in package.json); upgrade Bun for a newer bundled SQLite`);return`v${version}`});await probe(checks,"Database",async()=>{const{db}=await import("@stacksjs/database"),unsafe=db.unsafe;if(typeof unsafe!=="function")throw new ProbeWarning("driver exposes no raw query method (skipped)");const statement=unsafe("SELECT 1");if(!statement||typeof statement.then!=="function"&&typeof statement.execute!=="function")throw new ProbeWarning("driver returned a non-executable statement (skipped)");const result=typeof statement.execute==="function"?await statement.execute():await statement;if(result===void 0||result===null)throw Error("probe query returned no result");if(Array.isArray(result)&&result.length===0)throw Error("probe query returned no rows");return"Reachable"});await probe(checks,"Database FKs",async()=>{const{auditForeignKeys}=await import("@stacksjs/database"),result=await auditForeignKeys();if(result.declared.length===0)return"No belongsTo declarations";if(result.missing.length===0)return`${result.declared.length} declared FKs all present`;const sample=result.missing.slice(0,5).map((fk)=>`${fk.fromTable}.${fk.fromColumn} \u2192 ${fk.toTable}.${fk.toColumn}`).join(", "),more=result.missing.length>5?` (+${result.missing.length-5} more)`:"";throw Error(`${result.missing.length}/${result.declared.length} declared FKs missing from live schema: ${sample}${more}. Run \`buddy migrate:fresh\` (will reset data) or \`buddy migrate\` against a clean DB.`)});await probe(checks,"Unique indexes",async()=>{const{auditUniqueIndexes}=await import("@stacksjs/database"),result=await auditUniqueIndexes();if(!result.supported)return"Dialect not audited (skipped)";const skipped=result.skippedTables.length>0?`, ${result.skippedTables.length} tables skipped (not migrated)`:"";if(result.missing.length===0)return`${result.declared.length} declared unique constraints all indexed${skipped}`;const sample=result.missing.slice(0,5).map((u)=>`${u.table}.${u.columns.join("+")}`).join(", "),more=result.missing.length>5?` (+${result.missing.length-5} more)`:"",first=result.missing[0];if(!first)throw Error("Unique-index audit reported missing entries without details");const example=`CREATE UNIQUE INDEX IF NOT EXISTS "${first.table}_${first.columns.join("_")}_unique" ON "${first.table}" ("${first.columns.join('", "')}")`;throw Error(`${result.missing.length}/${result.declared.length} declared unique constraints have no UNIQUE index: ${sample}${more}. Run \`buddy migrate\` (re-queues missing unique-index migrations, #1952) \u2014 dedupe duplicate rows first or migrate hard-fails; if no migration file exists run \`buddy generate:migrations\`, or create manually: ${example}`)},1e4);await probe(checks,"Migration ledger",async()=>{const{auditMigrationLedger}=await import("@stacksjs/database"),result=await auditMigrationLedger();if(!result.supported)return"Dialect not audited (skipped)";if(result.entries.length===0)return"No migration files";const{counts,orphans}=result;if(!result.drift)return`${result.entries.length} migrations, ledger consistent with the schema`;const parts=[];if(counts.stranded>0)parts.push(`${counts.stranded} applied but unrecorded (would re-run)`);if(counts.partial>0)parts.push(`${counts.partial} half-applied`);if(counts.reverted>0)parts.push(`${counts.reverted} recorded but missing from the schema`);if(orphans.length>0)parts.push(`${orphans.length} ledger row(s) with no file on disk`);throw Error(`Migration ledger has drifted: ${parts.join(", ")}. Inspect with \`buddy migrate:status\`, repair with \`buddy migrate:status --reconcile\`.`)},1e4);await probe(checks,"FK orphans",async()=>{const{findFkOrphans}=await import("@stacksjs/database"),result=await findFkOrphans();if(!result.supported)return"Dialect not audited (skipped)";if(result.total===0)return"No orphan rows (PRAGMA foreign_key_check clean)";const sample=result.orphans.slice(0,5).map((o)=>`${o.table}.${o.column} \u2192 ${o.parent} (${o.count})`).join(", "),more=result.orphans.length>5?` (+${result.orphans.length-5} more)`:"",first=result.orphans[0];if(!first)throw Error("Foreign-key audit reported orphan rows without details");throw Error(`${result.total} orphan rows violate FKs: ${sample}${more}. Legacy rows written under foreign_keys=OFF (#1951). Review and clean manually, e.g. DELETE FROM ${first.table} WHERE ${first.column} IS NOT NULL AND ${first.column} NOT IN (SELECT id FROM ${first.parent}) \u2014 doctor never deletes data.`)},1e4);await probe(checks,"Cache",async()=>{const{cache}=await import("@stacksjs/cache"),k=`__doctor_${Date.now()}`;await cache.set(k,1,5);const v=await cache.get(k);await cache.del(k);if(v!==1)throw Error("round-trip failed");return"Round-trip ok"});await probe(checks,"Queue driver",async()=>{const{config}=await import("@stacksjs/config");return`Driver: ${config.queue?.default??"sync"}`});await probe(checks,"Mail driver",async()=>{const{config}=await import("@stacksjs/config");return`Driver: ${config.email?.default??"log"}`});{const{ensureRuntimeDirectories,projectPath}=await import("@stacksjs/path"),leftover=ensureRuntimeDirectories().filter((entry)=>!entry.cleared);checks.push(leftover.length===0?{name:"Runtime directories",status:"pass",message:"All under storage/"}:{name:"Runtime directories",status:"warn",message:`Still in the project root: ${leftover.map((entry)=>entry.legacy.slice(projectPath().length+1)).join(", ")} \u2014 remove them so state stays under storage/`})}const hasAwsCreds=Boolean(process.env.AWS_ACCESS_KEY_ID&&process.env.AWS_SECRET_ACCESS_KEY),hasAwsRole=Boolean(process.env.AWS_PROFILE)||Boolean(process.env.AWS_ROLE_ARN);if(hasAwsCreds||hasAwsRole)checks.push({name:"AWS credentials",status:"pass",message:hasAwsCreds?"Static keys in env":"IAM role configured"});else checks.push({name:"AWS credentials",status:"warn",message:"Not configured (cloud / SES / S3 commands will fail)"});await probe(checks,".env decryption",async()=>{const fs=await import("node:fs");if(!fs.existsSync(".env"))return"No .env (skipped)";const content=fs.readFileSync(".env","utf8");if(!/(?:^|=)(?:enc|encrypted):/m.test(content))return"No encrypted values";const privateKey=process.env.DOTENV_PRIVATE_KEY;if(!privateKey)throw Error("Encrypted values present but DOTENV_PRIVATE_KEY is unset");const{parse}=await import("@stacksjs/env"),{errors}=parse(content,{privateKey});if(errors.length>0)throw Error(errors.join("; "));return"All encrypted values decrypt cleanly"});try{const{filesystems}=await import("@stacksjs/config"),driver=filesystems.driver??"local";if(driver==="s3"){const bucket=process.env.S3_BUCKET??filesystems.s3?.bucket,accessKeyId=process.env.AWS_ACCESS_KEY_ID,secretAccessKey=process.env.AWS_SECRET_ACCESS_KEY,missing=[];if(!bucket)missing.push("S3_BUCKET");if(!accessKeyId)missing.push("AWS_ACCESS_KEY_ID");if(!secretAccessKey)missing.push("AWS_SECRET_ACCESS_KEY");if(missing.length>0)checks.push({name:"Storage credentials",status:"warn",message:`Default disk is 's3' but missing: ${missing.join(", ")}. Uploads will throw on first use.`});else checks.push({name:"Storage credentials",status:"pass",message:"s3 default disk has bucket + AWS credentials"})}else checks.push({name:"Storage credentials",status:"pass",message:`Default disk is '${driver}' (no remote credentials required)`})}catch(err){checks.push({name:"Storage credentials",status:"warn",message:`Could not audit storage config: ${err instanceof Error?err.message:String(err)}`})}try{if(process.platform!=="darwin")checks.push({name:"Dev domains (rpx)",status:"pass",message:"Not macOS (skipped)"});else{const fs=await import("node:fs"),os=await import("node:os"),path=await import("node:path"),isAlive=(pid)=>{try{process.kill(pid,0);return!0}catch(err){return err.code==="EPERM"}},registryDir=path.join(os.homedir(),".stacks","rpx","registry.d"),registered=new Set,deadRegistryFiles=[];if(fs.existsSync(registryDir))for(const file of fs.readdirSync(registryDir)){if(!file.endsWith(".json"))continue;try{const entry=JSON.parse(fs.readFileSync(path.join(registryDir,file),"utf8"));if(entry.to&&(entry.pid===void 0||isAlive(entry.pid)))registered.add(entry.to.toLowerCase());if(typeof entry.pid==="number"&&!isAlive(entry.pid))deadRegistryFiles.push(file)}catch{}}const staleHosts=new Set,staleResolvers=[],hostsPath="/etc/hosts",hostsLines=fs.existsSync(hostsPath)?fs.readFileSync(hostsPath,"utf8").split(`
|
|
1
|
+
import{lstatSync,readlinkSync}from"node:fs";import{resolve}from"node:path";import process from"node:process";import{bold,dim,green,intro,log,onUnknownSubcommand,red,yellow}from"@stacksjs/cli";import{feature}from"@stacksjs/config";import{inspectDefaultsProvenance}from"@stacksjs/path";import{storage}from"@stacksjs/storage";import{isSupportedBunVersion,isVersionGreaterThanOrEqual,minimumBunVersion}from"@stacksjs/utils";import{FEATURE_NAMES,featurePathsPresent}from"./features";const minimumSqliteVersion="3.47.2";class ProbeWarning extends Error{}async function probe(checks,name,fn,timeoutMs=2000){const start=Date.now(),ac=new AbortController,timer=setTimeout(()=>ac.abort(),timeoutMs);try{const message=await Promise.race([fn(),new Promise((_,rej)=>ac.signal.addEventListener("abort",()=>rej(Error(`timed out (>${Math.round(timeoutMs/1000)}s)`))))]);checks.push({name,status:"pass",message:`${message} (${Date.now()-start}ms)`})}catch(err){checks.push({name,status:err instanceof ProbeWarning?"warn":"fail",message:err instanceof Error?err.message:String(err)})}finally{clearTimeout(timer)}}export function doctor(buddy){buddy.command("doctor","Run health checks on your Stacks installation").option("--no-fail","Print results but always exit 0 (CI ramp-up)").action(async(options)=>{log.debug("Running `buddy doctor` ...");await intro("buddy doctor");const checks=[],modulesPath=resolve(process.cwd(),"node_modules");try{if(lstatSync(modulesPath).isSymbolicLink()){const target=resolve(process.cwd(),readlinkSync(modulesPath)),insideProject=target.startsWith(`${resolve(process.cwd())}/`);checks.push({name:"Dependency tree",status:insideProject?"warn":"fail",message:insideProject?`node_modules is a symlink to ${target}`:`node_modules is a symlink to ${target}, which belongs to another project \u2014 installs here mutate it, and what runs locally is not what the lockfile resolves. Remove the link and run \`bun install\`.`})}else checks.push({name:"Dependency tree",status:"pass",message:"node_modules is this project's own"})}catch{checks.push({name:"Dependency tree",status:"warn",message:"node_modules is missing \u2014 run `bun install`"})}await probe(checks,"Framework defaults",async()=>{const{measureDefaultsDrift,summarizeStructureChanges}=await import("@stacksjs/actions"),root=resolve(process.cwd()),skew=inspectDefaultsProvenance(root);if(skew.status==="not-applicable")return"Not a package-managed project (nothing to compare)";const drift=measureDefaultsDrift(root)??[],synced=skew.syncedAt?`, synced ${skew.syncedAt.slice(0,10)}`:"";if(drift.length===0)return`Vendored tree matches @stacksjs/defaults ${skew.installed}${synced}`;const counts=summarizeStructureChanges(drift);if(skew.status==="stale")throw new ProbeWarning(`storage/framework/defaults is from ${skew.vendored} while @stacksjs/defaults ${skew.installed} is installed (${counts}). Boot resolves the package, so the tree on disk is not what runs. Run \`buddy upgrade\` to sync it.`);throw new ProbeWarning(`storage/framework/defaults differs from the installed @stacksjs/defaults ${skew.installed} (${counts}), and carries no record of what it was synced from. The app runs the vendored copy. Run \`buddy upgrade\` to sync it.`)},8000);const bunVersion=process.versions.bun;if(bunVersion&&isSupportedBunVersion(bunVersion))checks.push({name:"Bun Runtime",status:"pass",message:`v${bunVersion}`});else if(bunVersion)checks.push({name:"Bun Runtime",status:"fail",message:`v${bunVersion} (requires v${minimumBunVersion} or later, run: bun upgrade)`});else checks.push({name:"Bun Runtime",status:"fail",message:"Not found"});const nodeVersion=process.versions.node;if(nodeVersion)if(Number.parseInt(nodeVersion.split(".")[0]||"0",10)>=18)checks.push({name:"Node.js",status:"pass",message:`v${nodeVersion}`});else checks.push({name:"Node.js",status:"warn",message:`v${nodeVersion} (v18+ recommended)`});try{const pkg=await storage.readPackageJson("./package.json");if(pkg.name)checks.push({name:"package.json",status:"pass",message:`Found: ${pkg.name}`})}catch{checks.push({name:"package.json",status:"fail",message:"Not found in current directory"})}try{await storage.readTextFile(".env");checks.push({name:".env file",status:"pass",message:"Found"})}catch{checks.push({name:".env file",status:"warn",message:"Not found (optional)"})}const appKey=process.env.APP_KEY;if(appKey&&appKey.length>0)checks.push({name:"APP_KEY",status:appKey.length>=32?"pass":"warn",message:appKey.length>=32?"Set (\u226532 chars)":`Set but short (${appKey.length} chars; \u226532 recommended)`});else checks.push({name:"APP_KEY",status:"fail",message:"Not set \u2014 features that depend on it (encrypted columns, signed URLs, env decryption) will refuse to run. Run `buddy key:generate`."});await probe(checks,"SQLite Engine",async()=>{const{Database}=await import("bun:sqlite"),memory=new Database(":memory:");let version;try{const row=memory.query("SELECT sqlite_version() AS version").get();if(typeof row?.version==="string")version=row.version}finally{memory.close()}if(!version)throw new ProbeWarning("could not read sqlite_version() (skipped)");if(!isVersionGreaterThanOrEqual(version,minimumSqliteVersion))throw Error(`v${version} is below the required v${minimumSqliteVersion} (system.sqlite in package.json); upgrade Bun for a newer bundled SQLite`);return`v${version}`});await probe(checks,"Database",async()=>{const{db}=await import("@stacksjs/database"),unsafe=db.unsafe;if(typeof unsafe!=="function")throw new ProbeWarning("driver exposes no raw query method (skipped)");const statement=unsafe("SELECT 1");if(!statement||typeof statement.then!=="function"&&typeof statement.execute!=="function")throw new ProbeWarning("driver returned a non-executable statement (skipped)");const result=typeof statement.execute==="function"?await statement.execute():await statement;if(result===void 0||result===null)throw Error("probe query returned no result");if(Array.isArray(result)&&result.length===0)throw Error("probe query returned no rows");return"Reachable"});await probe(checks,"Database FKs",async()=>{const{auditForeignKeys}=await import("@stacksjs/database"),result=await auditForeignKeys();if(result.declared.length===0)return"No belongsTo declarations";if(result.missing.length===0)return`${result.declared.length} declared FKs all present`;const sample=result.missing.slice(0,5).map((fk)=>`${fk.fromTable}.${fk.fromColumn} \u2192 ${fk.toTable}.${fk.toColumn}`).join(", "),more=result.missing.length>5?` (+${result.missing.length-5} more)`:"";throw Error(`${result.missing.length}/${result.declared.length} declared FKs missing from live schema: ${sample}${more}. Run \`buddy migrate:fresh\` (will reset data) or \`buddy migrate\` against a clean DB.`)});await probe(checks,"Unique indexes",async()=>{const{auditUniqueIndexes}=await import("@stacksjs/database"),result=await auditUniqueIndexes();if(!result.supported)return"Dialect not audited (skipped)";const skipped=result.skippedTables.length>0?`, ${result.skippedTables.length} tables skipped (not migrated)`:"";if(result.missing.length===0)return`${result.declared.length} declared unique constraints all indexed${skipped}`;const sample=result.missing.slice(0,5).map((u)=>`${u.table}.${u.columns.join("+")}`).join(", "),more=result.missing.length>5?` (+${result.missing.length-5} more)`:"",first=result.missing[0];if(!first)throw Error("Unique-index audit reported missing entries without details");const example=`CREATE UNIQUE INDEX IF NOT EXISTS "${first.table}_${first.columns.join("_")}_unique" ON "${first.table}" ("${first.columns.join('", "')}")`;throw Error(`${result.missing.length}/${result.declared.length} declared unique constraints have no UNIQUE index: ${sample}${more}. Run \`buddy migrate\` (re-queues missing unique-index migrations, #1952) \u2014 dedupe duplicate rows first or migrate hard-fails; if no migration file exists run \`buddy generate:migrations\`, or create manually: ${example}`)},1e4);await probe(checks,"Migration ledger",async()=>{const{auditMigrationLedger}=await import("@stacksjs/database"),result=await auditMigrationLedger();if(!result.supported)return"Dialect not audited (skipped)";if(result.entries.length===0)return"No migration files";const{counts,orphans}=result;if(!result.drift)return`${result.entries.length} migrations, ledger consistent with the schema`;const parts=[];if(counts.stranded>0)parts.push(`${counts.stranded} applied but unrecorded (would re-run)`);if(counts.partial>0)parts.push(`${counts.partial} half-applied`);if(counts.reverted>0)parts.push(`${counts.reverted} recorded but missing from the schema`);if(orphans.length>0)parts.push(`${orphans.length} ledger row(s) with no file on disk`);throw Error(`Migration ledger has drifted: ${parts.join(", ")}. Inspect with \`buddy migrate:status\`, repair with \`buddy migrate:status --reconcile\`.`)},1e4);await probe(checks,"FK orphans",async()=>{const{findFkOrphans}=await import("@stacksjs/database"),result=await findFkOrphans();if(!result.supported)return"Dialect not audited (skipped)";if(result.total===0)return"No orphan rows (PRAGMA foreign_key_check clean)";const sample=result.orphans.slice(0,5).map((o)=>`${o.table}.${o.column} \u2192 ${o.parent} (${o.count})`).join(", "),more=result.orphans.length>5?` (+${result.orphans.length-5} more)`:"",first=result.orphans[0];if(!first)throw Error("Foreign-key audit reported orphan rows without details");throw Error(`${result.total} orphan rows violate FKs: ${sample}${more}. Legacy rows written under foreign_keys=OFF (#1951). Review and clean manually, e.g. DELETE FROM ${first.table} WHERE ${first.column} IS NOT NULL AND ${first.column} NOT IN (SELECT id FROM ${first.parent}) \u2014 doctor never deletes data.`)},1e4);await probe(checks,"Cache",async()=>{const{cache}=await import("@stacksjs/cache"),k=`__doctor_${Date.now()}`;await cache.set(k,1,5);const v=await cache.get(k);await cache.del(k);if(v!==1)throw Error("round-trip failed");return"Round-trip ok"});await probe(checks,"Queue driver",async()=>{const{config}=await import("@stacksjs/config");return`Driver: ${config.queue?.default??"sync"}`});await probe(checks,"Mail driver",async()=>{const{config}=await import("@stacksjs/config");return`Driver: ${config.email?.default??"log"}`});{const{ensureRuntimeDirectories,projectPath}=await import("@stacksjs/path"),leftover=ensureRuntimeDirectories().filter((entry)=>!entry.cleared);checks.push(leftover.length===0?{name:"Runtime directories",status:"pass",message:"All under storage/"}:{name:"Runtime directories",status:"warn",message:`Still in the project root: ${leftover.map((entry)=>entry.legacy.slice(projectPath().length+1)).join(", ")} \u2014 remove them so state stays under storage/`})}const hasAwsCreds=Boolean(process.env.AWS_ACCESS_KEY_ID&&process.env.AWS_SECRET_ACCESS_KEY),hasAwsRole=Boolean(process.env.AWS_PROFILE)||Boolean(process.env.AWS_ROLE_ARN);if(hasAwsCreds||hasAwsRole)checks.push({name:"AWS credentials",status:"pass",message:hasAwsCreds?"Static keys in env":"IAM role configured"});else checks.push({name:"AWS credentials",status:"warn",message:"Not configured (cloud / SES / S3 commands will fail)"});await probe(checks,"Database backups",async()=>{const fs=await import("node:fs"),cloudConfig=resolve(process.cwd(),"config/cloud.ts");if(!fs.existsSync(cloudConfig))return"No config/cloud.ts (skipped)";const{findUnbackedManagedServices,unbackedDataMessage}=await import("../unbacked-data");let tsCloud;try{tsCloud=(await import(cloudConfig)).tsCloud}catch{throw new ProbeWarning("could not read config/cloud.ts (skipped)")}const unbacked=findUnbackedManagedServices(tsCloud);if(unbacked.length===0)return"No unbacked managed data services";throw new ProbeWarning(unbackedDataMessage(unbacked))});await probe(checks,".env decryption",async()=>{const fs=await import("node:fs");if(!fs.existsSync(".env"))return"No .env (skipped)";const content=fs.readFileSync(".env","utf8");if(!/(?:^|=)(?:enc|encrypted):/m.test(content))return"No encrypted values";const privateKey=process.env.DOTENV_PRIVATE_KEY;if(!privateKey)throw Error("Encrypted values present but DOTENV_PRIVATE_KEY is unset");const{parse}=await import("@stacksjs/env"),{errors}=parse(content,{privateKey});if(errors.length>0)throw Error(errors.join("; "));return"All encrypted values decrypt cleanly"});try{const{filesystems}=await import("@stacksjs/config"),driver=filesystems.driver??"local";if(driver==="s3"){const bucket=process.env.S3_BUCKET??filesystems.s3?.bucket,accessKeyId=process.env.AWS_ACCESS_KEY_ID,secretAccessKey=process.env.AWS_SECRET_ACCESS_KEY,missing=[];if(!bucket)missing.push("S3_BUCKET");if(!accessKeyId)missing.push("AWS_ACCESS_KEY_ID");if(!secretAccessKey)missing.push("AWS_SECRET_ACCESS_KEY");if(missing.length>0)checks.push({name:"Storage credentials",status:"warn",message:`Default disk is 's3' but missing: ${missing.join(", ")}. Uploads will throw on first use.`});else checks.push({name:"Storage credentials",status:"pass",message:"s3 default disk has bucket + AWS credentials"})}else checks.push({name:"Storage credentials",status:"pass",message:`Default disk is '${driver}' (no remote credentials required)`})}catch(err){checks.push({name:"Storage credentials",status:"warn",message:`Could not audit storage config: ${err instanceof Error?err.message:String(err)}`})}try{if(process.platform!=="darwin")checks.push({name:"Dev domains (rpx)",status:"pass",message:"Not macOS (skipped)"});else{const fs=await import("node:fs"),os=await import("node:os"),path=await import("node:path"),isAlive=(pid)=>{try{process.kill(pid,0);return!0}catch(err){return err.code==="EPERM"}},registryDir=path.join(os.homedir(),".stacks","rpx","registry.d"),registered=new Set,deadRegistryFiles=[];if(fs.existsSync(registryDir))for(const file of fs.readdirSync(registryDir)){if(!file.endsWith(".json"))continue;try{const entry=JSON.parse(fs.readFileSync(path.join(registryDir,file),"utf8"));if(entry.to&&(entry.pid===void 0||isAlive(entry.pid)))registered.add(entry.to.toLowerCase());if(typeof entry.pid==="number"&&!isAlive(entry.pid))deadRegistryFiles.push(file)}catch{}}const staleHosts=new Set,staleResolvers=[],hostsPath="/etc/hosts",hostsLines=fs.existsSync(hostsPath)?fs.readFileSync(hostsPath,"utf8").split(`
|
|
2
2
|
`):[];for(let i=0;i<hostsLines.length;i++){const line=hostsLines[i];if(line.trim()==="# Added by rpx"){for(let j=i+1;j<hostsLines.length;j++){const blockLine=hostsLines[j].trim();if(blockLine===""||blockLine.startsWith("#"))break;const names=blockLine.split("#")[0]?.trim().split(/\s+/).slice(1)??[];for(const name of names)if(!registered.has(name.toLowerCase()))staleHosts.add(name)}continue}const hash=line.indexOf("#");if(hash===-1)continue;const marker=/^rpx(?::pid=(\d+))?$/.exec(line.slice(hash+1).trim());if(!marker)continue;const names=line.slice(0,hash).trim().split(/\s+/).slice(1),pid=marker[1]?Number.parseInt(marker[1],10):null;if(pid!==null?!isAlive(pid):names.every((n)=>!registered.has(n.toLowerCase())))for(const name of names)staleHosts.add(name)}const resolverDir="/etc/resolver";if(fs.existsSync(resolverDir))for(const file of fs.readdirSync(resolverDir))try{const content=fs.readFileSync(path.join(resolverDir,file),"utf8");if(!content.includes("127.0.0.1")||!content.includes("15353"))continue;const domain=file.toLowerCase();if(![...registered].some((host)=>host===domain||host.endsWith(`.${domain}`)))staleResolvers.push(file)}catch{}if(staleHosts.size>0||staleResolvers.length>0||deadRegistryFiles.length>0){const parts=[];if(staleHosts.size>0)parts.push(`hosts(${[...staleHosts].join(", ")})`);if(staleResolvers.length>0)parts.push(`resolver(${staleResolvers.join(", ")})`);if(deadRegistryFiles.length>0)parts.push(`registry(${deadRegistryFiles.join(", ")})`);checks.push({name:"Dev domains (rpx)",status:"warn",message:`Stale loopback overrides from dead dev sessions: ${parts.join(" ")}. These keep pointing the domain at 127.0.0.1. Remove with: sudo nano /etc/hosts; sudo rm /etc/resolver/<name>; rm ~/.stacks/rpx/registry.d/<file>. Updating @stacksjs/rpx lets the daemon sweep pid-stamped entries automatically.`})}else checks.push({name:"Dev domains (rpx)",status:"pass",message:"No stale dev-domain overrides"})}}catch(err){checks.push({name:"Dev domains (rpx)",status:"warn",message:`Could not audit dev-domain overrides: ${err instanceof Error?err.message:String(err)}`})}await probe(checks,"Dev ports",async()=>{const net=await import("node:net"),{config}=await import("@stacksjs/config"),configured=config.ports??{},targets=[{name:"frontend",key:"frontend",envVar:"PORT",fallback:3000},{name:"api",key:"api",envVar:"PORT_API",fallback:3008},{name:"docs",key:"docs",envVar:"PORT_DOCS",fallback:3006},{name:"dashboard",key:"admin",envVar:"PORT_ADMIN",fallback:3002}].map((t)=>({...t,port:Number(configured[t.key])||t.fallback})),canConnect=(port,host)=>new Promise((resolve)=>{const socket=net.createConnection({port,host});socket.setTimeout(400);const done=(occupied)=>{socket.destroy();resolve(occupied)};socket.once("connect",()=>done(!0));socket.once("timeout",()=>done(!1));socket.once("error",()=>done(!1))}),occupied=new Set;await Promise.all([...new Set(targets.map((t)=>t.port))].map(async(port)=>{if(await canConnect(port,"127.0.0.1")||await canConnect(port,"::1"))occupied.add(port)}));const busy=targets.filter((t)=>occupied.has(t.port));if(busy.length>0){const list=busy.map((t)=>`${t.name} :${t.port} (${t.envVar})`).join(", ");throw new ProbeWarning(`in use: ${list}. buddy dev will fail to bind; stop the process holding the port or set the override env var`)}return`All free: ${targets.map((t)=>`${t.name} :${t.port}`).join(", ")}`});try{const orphans=[];for(const name of FEATURE_NAMES){if(feature(name))continue;const present=featurePathsPresent(name);if(present.length>0)orphans.push({feature:name,count:present.length})}if(orphans.length>0){const summary=orphans.map((o)=>`${o.feature} (${o.count} path${o.count===1?"":"s"})`).join(", ");checks.push({name:"Feature scaffolding",status:"warn",message:`Stamped files remain for disabled features: ${summary}. Run \`./buddy <feature>:uninstall\` to remove or \`<feature>:install\` to re-enable.`})}else checks.push({name:"Feature scaffolding",status:"pass",message:"No orphan files for disabled features"})}catch(err){checks.push({name:"Feature scaffolding",status:"warn",message:`Could not audit feature scaffolding: ${err instanceof Error?err.message:String(err)}`})}log.info("");log.info(bold("Health Check Results:"));log.info(dim("\u2500".repeat(60)));log.info("");let hasFailures=!1,hasWarnings=!1;for(const check of checks){let statusIcon="",statusColor=(text)=>text;if(check.status==="pass"){statusIcon="\u2713";statusColor=green}else if(check.status==="warn"){statusIcon="\u26A0";statusColor=yellow;hasWarnings=!0}else{statusIcon="\u2717";statusColor=red;hasFailures=!0}log.info(`${statusColor(statusIcon)} ${bold(check.name.padEnd(20))} ${dim(check.message)}`)}log.info("");log.info(dim("\u2500".repeat(60)));log.info("");if(hasFailures){log.error("Some critical checks failed. Please address the issues above.");if(options?.fail!==!1){await log.flush();process.exit(1)}}else if(hasWarnings)log.info(yellow("Some checks have warnings. Your system should work but may have issues."));else log.success(green("All checks passed! Your Stacks installation looks healthy."));log.info("")});onUnknownSubcommand(buddy,"doctor")}
|
package/dist/commands/index.d.ts
CHANGED
package/dist/commands/index.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export*from"./about";export*from"./auth";export*from"./build";export*from"./cd";export*from"./changelog";export*from"./clean";export*from"./cloud";export*from"./commit";export*from"./completion";export*from"./config-migrate";export*from"./configure";export*from"./create";export*from"./deploy";export*from"./dev";export*from"./dns";export*from"./docs";export*from"./doctor";export*from"./domains";export*from"./email";export*from"./env";export*from"./features";export*from"./fresh";export*from"./generate";export*from"./http";export*from"./install";export*from"./key";export*from"./lint";export*from"./list";export*from"./mail";export*from"./make";export*from"./migrate";export*from"./migrate-project";export*from"./outdated";export*from"./phone";export*from"./ports";export*from"./link";export*from"./user";export*from"./prepublish";export*from"./projects";export*from"./publish";export*from"./queue";export*from"./release";export*from"./route";export*from"./saas";export*from"./schedule";export*from"./search";export*from"./seed";export*from"./serve";export*from"./setup";export*from"./sms";export*from"./telemetry";export*from"./test";export*from"./tinker";export*from"./types";export*from"./upgrade";export*from"./version";
|
|
1
|
+
export*from"./about";export*from"./auth";export*from"./build";export*from"./cd";export*from"./changelog";export*from"./clean";export*from"./cloud";export*from"./commit";export*from"./completion";export*from"./config-migrate";export*from"./configure";export*from"./create";export*from"./deploy";export*from"./deploy-preview";export*from"./dev";export*from"./dns";export*from"./docs";export*from"./doctor";export*from"./domains";export*from"./email";export*from"./env";export*from"./features";export*from"./fresh";export*from"./generate";export*from"./http";export*from"./install";export*from"./key";export*from"./lint";export*from"./list";export*from"./mail";export*from"./make";export*from"./migrate";export*from"./migrate-project";export*from"./outdated";export*from"./phone";export*from"./ports";export*from"./link";export*from"./user";export*from"./prepublish";export*from"./projects";export*from"./publish";export*from"./queue";export*from"./release";export*from"./route";export*from"./saas";export*from"./schedule";export*from"./search";export*from"./seed";export*from"./serve";export*from"./setup";export*from"./sms";export*from"./telemetry";export*from"./test";export*from"./tinker";export*from"./types";export*from"./upgrade";export*from"./version";
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import process from"node:process";import{runAction}from"@stacksjs/actions";import{intro,log,outro}from"@stacksjs/cli";import{Action}from"@stacksjs/enums";import{ExitCode}from"@stacksjs/types";import{resultFailed}from"../result";export function schedule(buddy){const descriptions={project:"Target a specific project",schedule:"Run the scheduler",verbose:"Enable verbose output",list:"List all registered scheduled tasks with their next run time",status:"Show currently-held overlap locks (this-process only)"};buddy.command("schedule:run",descriptions.schedule).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy schedule:run` ...",options);const perf=await intro("buddy schedule:run"),result=await runAction(Action.ScheduleRun,options);if(resultFailed(result)){await outro("While running the schedule:run command, there was an issue",{startTime:perf,useSeconds:!0},result.error);process.exit(ExitCode.FatalError)}process.exit(ExitCode.Success)});buddy.command("schedule:list",descriptions.list).action(async()=>{try{await import(
|
|
1
|
+
import process from"node:process";import{runAction}from"@stacksjs/actions";import{intro,log,outro}from"@stacksjs/cli";import{Action}from"@stacksjs/enums";import{ExitCode}from"@stacksjs/types";import{resultFailed}from"../result";export function schedule(buddy){const descriptions={project:"Target a specific project",schedule:"Run the scheduler",verbose:"Enable verbose output",list:"List all registered scheduled tasks with their next run time",status:"Show currently-held overlap locks (this-process only)",runOne:"Run one registered scheduled task immediately",enable:"Resume a paused scheduled task",disable:"Pause a scheduled task without editing source"};buddy.command("schedule:run",descriptions.schedule).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy schedule:run` ...",options);const perf=await intro("buddy schedule:run"),result=await runAction(Action.ScheduleRun,options);if(resultFailed(result)){await outro("While running the schedule:run command, there was an issue",{startTime:perf,useSeconds:!0},result.error);process.exit(ExitCode.FatalError)}process.exit(ExitCode.Success)});buddy.command("schedule:list",descriptions.list).option("--json","Print a machine-readable schedule registry",{default:!1}).action(async(options)=>{try{const{runScheduler,Schedule}=await import("@stacksjs/scheduler");await runScheduler();await Promise.resolve();const jobs=Schedule.listJobs();if(options.json){console.log(`STACKS_SCHEDULE_JSON=${JSON.stringify({jobs,locks:Schedule.listLocks()})}`);await Schedule.gracefulShutdown();process.exit(ExitCode.Success)}if(jobs.length===0){log.info("No scheduled tasks registered.");await Schedule.gracefulShutdown();process.exit(ExitCode.Success)}const nameWidth=Math.max(4,...jobs.map((j)=>j.name.length)),patternWidth=Math.max(7,...jobs.map((j)=>(j.pattern??"").length));log.info(`${"NAME".padEnd(nameWidth)} ${"PATTERN".padEnd(patternWidth)} STATUS TIMEZONE NEXT RUN (UTC)`);for(const j of jobs){const next=j.nextRun?j.nextRun.toISOString():"-";log.info(`${j.name.padEnd(nameWidth)} ${(j.pattern??"").padEnd(patternWidth)} ${(j.enabled?"active":"paused").padEnd(6)} ${(j.timezone??"UTC").padEnd(25)} ${next}`)}await Schedule.gracefulShutdown();process.exit(ExitCode.Success)}catch(err){log.error(`[schedule:list] failed: ${err instanceof Error?err.message:String(err)}`);process.exit(ExitCode.FatalError)}});buddy.command("schedule:status",descriptions.status).option("--json","Print machine-readable scheduler status",{default:!1}).action(async(options)=>{try{const{Schedule}=await import("@stacksjs/scheduler"),held=Schedule.listLocks();if(options.json){console.log(`STACKS_SCHEDULE_STATUS_JSON=${JSON.stringify({locks:held})}`);process.exit(ExitCode.Success)}if(held.length===0)log.info("No in-process scheduler locks held.");else{log.info(`Held locks (${held.length}):`);for(const name of held)log.info(` \u2022 ${name}`)}process.exit(ExitCode.Success)}catch(err){log.error(`[schedule:status] failed: ${err instanceof Error?err.message:String(err)}`);process.exit(ExitCode.FatalError)}});buddy.command("schedule:run-one <name>",descriptions.runOne).action(async(name)=>{try{const{runScheduler,Schedule}=await import("@stacksjs/scheduler");await runScheduler();await Promise.resolve();await Schedule.runNow(name);await Schedule.gracefulShutdown();log.success(`Scheduled task ${name} completed.`);await log.flush();process.exit(ExitCode.Success)}catch(err){log.error(`[schedule:run-one] failed: ${err instanceof Error?err.message:String(err)}`);process.exit(ExitCode.FatalError)}});for(const[command,enabled,description]of[["schedule:enable",!0,descriptions.enable],["schedule:disable",!1,descriptions.disable]])buddy.command(`${command} <name>`,description).action(async(name)=>{try{const{runScheduler,Schedule}=await import("@stacksjs/scheduler");await runScheduler();await Promise.resolve();const exists=Schedule.listJobs().some((job)=>job.name===name);await Schedule.gracefulShutdown();if(!exists)throw Error(`Scheduled task "${name}" was not found.`);Schedule.setEnabled(name,enabled);log.success(`Scheduled task ${name} ${enabled?"resumed":"paused"}.`);await log.flush();process.exit(ExitCode.Success)}catch(err){log.error(`[${command}] failed: ${err instanceof Error?err.message:String(err)}`);process.exit(ExitCode.FatalError)}})}
|
package/dist/lazy-commands.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
const commandRegistry={about:{path:"./commands/about.js",exportName:"about"},add:{path:"./commands/add.js",exportName:"add"},"ai:context":{path:"./commands/ai-context.js",exportName:"aiContext"},auth:{path:"./commands/auth.js",exportName:"auth"},build:{path:"./commands/build.js",exportName:"build"},cd:{path:"./commands/cd.js",exportName:"cd"},changelog:{path:"./commands/changelog.js",exportName:"changelog"},clean:{path:"./commands/clean.js",exportName:"clean"},cloud:{path:"./commands/cloud.js",exportName:"cloud"},commit:{path:"./commands/commit.js",exportName:"commit"},completion:{path:"./commands/completion.js",exportName:"completion"},"config:migrate":{path:"./commands/config-migrate.js",exportName:"configMigrate"},configure:{path:"./commands/configure.js",exportName:"configure"},create:{path:"./commands/create.js",exportName:"create"},new:{path:"./commands/create.js",exportName:"create"},deploy:{path:"./commands/deploy.js",exportName:"deploy"},dev:{path:"./commands/dev.js",exportName:"dev"},"desktop:apple:doctor":{path:"./commands/desktop-apple.js",exportName:"desktopApple"},"desktop:apple:csr":{path:"./commands/desktop-apple.js",exportName:"desktopApple"},"desktop:apple:init":{path:"./commands/desktop-apple.js",exportName:"desktopApple"},"desktop:apple:package":{path:"./commands/desktop-apple.js",exportName:"desktopApple"},"desktop:apple:provision":{path:"./commands/desktop-apple.js",exportName:"desktopApple"},"desktop:apple:publish":{path:"./commands/desktop-apple.js",exportName:"desktopApple"},dns:{path:"./commands/dns.js",exportName:"dns"},"dns:pull":{path:"./commands/dns.js",exportName:"dns"},"dns:diff":{path:"./commands/dns.js",exportName:"dns"},"dns:sync":{path:"./commands/dns.js",exportName:"dns"},doctor:{path:"./commands/doctor.js",exportName:"doctor"},domains:{path:"./commands/domains.js",exportName:"domains"},email:{path:"./commands/email.js",exportName:"email"},env:{path:"./commands/env.js",exportName:"env"},"extension:init":{path:"./commands/extension.js",exportName:"extension"},"extension:build":{path:"./commands/extension.js",exportName:"extension"},"extension:package":{path:"./commands/extension.js",exportName:"extension"},"extension:chrome:publish":{path:"./commands/extension.js",exportName:"extension"},"extension:chrome:status":{path:"./commands/extension.js",exportName:"extension"},"extension:firefox:publish":{path:"./commands/extension.js",exportName:"extension"},"extension:safari:init":{path:"./commands/extension.js",exportName:"extension"},"extension:safari:provision":{path:"./commands/extension.js",exportName:"extension"},"extension:safari:app":{path:"./commands/extension.js",exportName:"extension"},"extension:safari:publish":{path:"./commands/extension.js",exportName:"extension"},"dashboard:install":{path:"./commands/features.js",exportName:"features"},"dashboard:uninstall":{path:"./commands/features.js",exportName:"features"},"commerce:install":{path:"./commands/features.js",exportName:"features"},"commerce:uninstall":{path:"./commands/features.js",exportName:"features"},"cms:install":{path:"./commands/features.js",exportName:"features"},"cms:uninstall":{path:"./commands/features.js",exportName:"features"},"marketing:install":{path:"./commands/features.js",exportName:"features"},"marketing:uninstall":{path:"./commands/features.js",exportName:"features"},"monitoring:install":{path:"./commands/features.js",exportName:"features"},"monitoring:uninstall":{path:"./commands/features.js",exportName:"features"},"realtime:install":{path:"./commands/features.js",exportName:"features"},"realtime:uninstall":{path:"./commands/features.js",exportName:"features"},"queue:install":{path:"./commands/features.js",exportName:"features"},"queue:uninstall":{path:"./commands/features.js",exportName:"features"},fresh:{path:"./commands/fresh.js",exportName:"fresh"},generate:{path:"./commands/generate.js",exportName:"generate"},http:{path:"./commands/http.js",exportName:"http"},install:{path:"./commands/install.js",exportName:"install"},key:{path:"./commands/key.js",exportName:"key"},lint:{path:"./commands/lint.js",exportName:"lint"},format:{path:"./commands/lint.js",exportName:"lint"},"format:check":{path:"./commands/lint.js",exportName:"lint"},list:{path:"./commands/list.js",exportName:"list"},mail:{path:"./commands/mail.js",exportName:"mailCommands"},"mail:preview":{path:"./commands/mail.js",exportName:"mailCommands"},maintenance:{path:"./commands/maintenance.js",exportName:"maintenance"},down:{path:"./commands/maintenance.js",exportName:"maintenance"},up:{path:"./commands/maintenance.js",exportName:"maintenance"},status:{path:"./commands/maintenance.js",exportName:"maintenance"},"coming-soon":{path:"./commands/maintenance.js",exportName:"maintenance"},"coming-soon:status":{path:"./commands/maintenance.js",exportName:"maintenance"},launch:{path:"./commands/maintenance.js",exportName:"maintenance"},make:{path:"./commands/make.js",exportName:"make"},"scaffold:crud":{path:"./commands/make.js",exportName:"make"},migrate:{path:"./commands/migrate.js",exportName:"migrate"},"migrate:fresh":{path:"./commands/migrate.js",exportName:"migrate"},"migrate:switch":{path:"./commands/migrate.js",exportName:"migrate"},"migrate:dns":{path:"./commands/migrate.js",exportName:"migrate"},"migrate:project":{path:"./commands/migrate-project.js",exportName:"migrateProject"},outdated:{path:"./commands/outdated.js",exportName:"outdated"},package:{path:"./commands/package.js",exportName:"packageCommands"},phone:{path:"./commands/phone.js",exportName:"phone"},ports:{path:"./commands/ports.js",exportName:"ports"},"link:core":{path:"./commands/link.js",exportName:"link"},"user:add":{path:"./commands/user.js",exportName:"user"},"user:list":{path:"./commands/user.js",exportName:"user"},"unlink:core":{path:"./commands/link.js",exportName:"link"},prepublish:{path:"./commands/prepublish.js",exportName:"prepublish"},projects:{path:"./commands/projects.js",exportName:"projects"},publish:{path:"./commands/publish.js",exportName:"publish"},"publish:model":{path:"./commands/publish.js",exportName:"publish"},"publish:controller":{path:"./commands/publish.js",exportName:"publish"},"publish:middleware":{path:"./commands/publish.js",exportName:"publish"},"publish:action":{path:"./commands/publish.js",exportName:"publish"},"publish:core":{path:"./commands/publish.js",exportName:"publish"},"core:status":{path:"./commands/publish.js",exportName:"publish"},queue:{path:"./commands/queue.js",exportName:"queue"},release:{path:"./commands/release.js",exportName:"release"},route:{path:"./commands/route.js",exportName:"route"},saas:{path:"./commands/saas.js",exportName:"saas"},"stripe:setup":{path:"./commands/saas.js",exportName:"saas"},schedule:{path:"./commands/schedule.js",exportName:"schedule"},search:{path:"./commands/search.js",exportName:"search"},"search-engine:update":{path:"./commands/search.js",exportName:"search"},"search-engine:settings":{path:"./commands/search.js",exportName:"search"},seed:{path:"./commands/seed.js",exportName:"seed"},"seed:roles":{path:"./commands/seed.js",exportName:"seed"},"roles:seed":{path:"./commands/seed.js",exportName:"seed"},preview:{path:"./commands/serve.js",exportName:"preview"},serve:{path:"./commands/serve.js",exportName:"serve"},"serve:api":{path:"./commands/serve.js",exportName:"serveApi"},setup:{path:"./commands/setup.js",exportName:"setup"},"setup:ai":{path:"./commands/setup.js",exportName:"setup"},"setup:ssl":{path:"./commands/setup.js",exportName:"setup"},"setup:oh-my-zsh":{path:"./commands/setup.js",exportName:"setup"},"ai:setup":{path:"./commands/setup.js",exportName:"setup"},share:{path:"./commands/share.js",exportName:"share"},stack:{path:"./commands/stacks.js",exportName:"stacks"},sms:{path:"./commands/sms.js",exportName:"sms"},telemetry:{path:"./commands/telemetry.js",exportName:"telemetryCommand"},test:{path:"./commands/test.js",exportName:"test"},tinker:{path:"./commands/tinker.js",exportName:"tinker"},types:{path:"./commands/types.js",exportName:"types"},undeploy:{path:"./commands/cloud.js",exportName:"cloud"},upgrade:{path:"./commands/upgrade.js",exportName:"upgrade"},version:{path:"./commands/version.js",exportName:"version"},"docs:buddy":{path:"./commands/docs.js",exportName:"docs"},"docs:buddy:check":{path:"./commands/docs.js",exportName:"docs"},"docs:artifacts":{path:"./commands/docs.js",exportName:"docs"},"docs:artifacts:check":{path:"./commands/docs.js",exportName:"docs"},"docs:links":{path:"./commands/docs.js",exportName:"docs"},"docs:links:check":{path:"./commands/docs.js",exportName:"docs"}},commandGroups={minimal:["version","help"],development:["dev","build","test","lint"],database:["migrate","seed","fresh"],scaffolding:["make","generate"],deployment:["deploy","release","cloud"],info:["about","doctor","list"]},loadedRegistrars=new WeakMap;function loaderKey(loader){return`${loader.path}#${loader.exportName}`}export function markLoaded(buddy,commandName){const loader=commandRegistry[commandName];if(!loader)return;let set=loadedRegistrars.get(buddy);if(!set){set=new Set;loadedRegistrars.set(buddy,set)}set.add(loaderKey(loader))}export async function loadCommand(commandName,buddy){const loader=commandRegistry[commandName];if(!loader)return!1;let set=loadedRegistrars.get(buddy);if(!set){set=new Set;loadedRegistrars.set(buddy,set)}const key=loaderKey(loader);if(set.has(key))return!0;set.add(key);try{const commandFunction=(await import(loader.path))[loader.exportName];if(typeof commandFunction==="function"){commandFunction(buddy);return!0}else return!1}catch{return!1}}export async function loadCommands(commandNames,buddy){const timeout=(ms)=>new Promise((_,reject)=>setTimeout(()=>reject(Error("timeout")),ms)),seen=new Set,unique=[];for(const name of commandNames){const loader=commandRegistry[name],key=loader?`${loader.path}#${loader.exportName}`:`name:${name}`;if(seen.has(key))continue;seen.add(key);unique.push(name)}await Promise.all(unique.map(async(name)=>{try{await Promise.race([loadCommand(name,buddy),timeout(5000)])}catch{}}))}export async function loadCommandGroup(groupName,buddy){const commands=commandGroups[groupName];if(commands)await loadCommands(commands,buddy)}export async function loadAllCommands(buddy){const allCommands=Object.keys(commandRegistry);await loadCommands(allCommands,buddy)}export function getCommandNames(){return Object.keys(commandRegistry)}export function getCommandsToLoad(args){const requestedCommand=args[0],isVersionFlag=requestedCommand==="--version"||requestedCommand==="-v",isHelpFlag=requestedCommand==="--help"||requestedCommand==="-h",isHelpWord=requestedCommand==="help";if(isVersionFlag)return["version"];if(!requestedCommand||isHelpFlag||isHelpWord)return Object.keys(commandRegistry);const baseCommand=requestedCommand.split(":")[0];if(baseCommand==="list")return["list",...Object.keys(commandRegistry).filter((k)=>k!=="list")];if(commandRegistry[requestedCommand])return[requestedCommand];if(commandRegistry[baseCommand])return[baseCommand];return Object.keys(commandRegistry)}
|
|
1
|
+
const commandRegistry={about:{path:"./commands/about.js",exportName:"about"},add:{path:"./commands/add.js",exportName:"add"},"ai:context":{path:"./commands/ai-context.js",exportName:"aiContext"},auth:{path:"./commands/auth.js",exportName:"auth"},build:{path:"./commands/build.js",exportName:"build"},cd:{path:"./commands/cd.js",exportName:"cd"},changelog:{path:"./commands/changelog.js",exportName:"changelog"},clean:{path:"./commands/clean.js",exportName:"clean"},cloud:{path:"./commands/cloud.js",exportName:"cloud"},commit:{path:"./commands/commit.js",exportName:"commit"},completion:{path:"./commands/completion.js",exportName:"completion"},"config:migrate":{path:"./commands/config-migrate.js",exportName:"configMigrate"},configure:{path:"./commands/configure.js",exportName:"configure"},create:{path:"./commands/create.js",exportName:"create"},new:{path:"./commands/create.js",exportName:"create"},deploy:{path:"./commands/deploy.js",exportName:"deploy"},"deploy:rollback":{path:"./commands/deploy.js",exportName:"deploy"},dev:{path:"./commands/dev.js",exportName:"dev"},"desktop:apple:doctor":{path:"./commands/desktop-apple.js",exportName:"desktopApple"},"desktop:apple:csr":{path:"./commands/desktop-apple.js",exportName:"desktopApple"},"desktop:apple:init":{path:"./commands/desktop-apple.js",exportName:"desktopApple"},"desktop:apple:package":{path:"./commands/desktop-apple.js",exportName:"desktopApple"},"desktop:apple:provision":{path:"./commands/desktop-apple.js",exportName:"desktopApple"},"desktop:apple:publish":{path:"./commands/desktop-apple.js",exportName:"desktopApple"},dns:{path:"./commands/dns.js",exportName:"dns"},"dns:pull":{path:"./commands/dns.js",exportName:"dns"},"dns:diff":{path:"./commands/dns.js",exportName:"dns"},"dns:sync":{path:"./commands/dns.js",exportName:"dns"},doctor:{path:"./commands/doctor.js",exportName:"doctor"},domains:{path:"./commands/domains.js",exportName:"domains"},email:{path:"./commands/email.js",exportName:"email"},env:{path:"./commands/env.js",exportName:"env"},"extension:init":{path:"./commands/extension.js",exportName:"extension"},"extension:build":{path:"./commands/extension.js",exportName:"extension"},"extension:package":{path:"./commands/extension.js",exportName:"extension"},"extension:chrome:publish":{path:"./commands/extension.js",exportName:"extension"},"extension:chrome:status":{path:"./commands/extension.js",exportName:"extension"},"extension:firefox:publish":{path:"./commands/extension.js",exportName:"extension"},"extension:safari:init":{path:"./commands/extension.js",exportName:"extension"},"extension:safari:provision":{path:"./commands/extension.js",exportName:"extension"},"extension:safari:app":{path:"./commands/extension.js",exportName:"extension"},"extension:safari:publish":{path:"./commands/extension.js",exportName:"extension"},"dashboard:install":{path:"./commands/features.js",exportName:"features"},"dashboard:uninstall":{path:"./commands/features.js",exportName:"features"},"commerce:install":{path:"./commands/features.js",exportName:"features"},"commerce:uninstall":{path:"./commands/features.js",exportName:"features"},"cms:install":{path:"./commands/features.js",exportName:"features"},"cms:uninstall":{path:"./commands/features.js",exportName:"features"},"marketing:install":{path:"./commands/features.js",exportName:"features"},"marketing:uninstall":{path:"./commands/features.js",exportName:"features"},"monitoring:install":{path:"./commands/features.js",exportName:"features"},"monitoring:uninstall":{path:"./commands/features.js",exportName:"features"},"realtime:install":{path:"./commands/features.js",exportName:"features"},"realtime:uninstall":{path:"./commands/features.js",exportName:"features"},"queue:install":{path:"./commands/features.js",exportName:"features"},"queue:uninstall":{path:"./commands/features.js",exportName:"features"},fresh:{path:"./commands/fresh.js",exportName:"fresh"},generate:{path:"./commands/generate.js",exportName:"generate"},http:{path:"./commands/http.js",exportName:"http"},install:{path:"./commands/install.js",exportName:"install"},key:{path:"./commands/key.js",exportName:"key"},lint:{path:"./commands/lint.js",exportName:"lint"},format:{path:"./commands/lint.js",exportName:"lint"},"format:check":{path:"./commands/lint.js",exportName:"lint"},list:{path:"./commands/list.js",exportName:"list"},mail:{path:"./commands/mail.js",exportName:"mailCommands"},"mail:preview":{path:"./commands/mail.js",exportName:"mailCommands"},maintenance:{path:"./commands/maintenance.js",exportName:"maintenance"},down:{path:"./commands/maintenance.js",exportName:"maintenance"},up:{path:"./commands/maintenance.js",exportName:"maintenance"},status:{path:"./commands/maintenance.js",exportName:"maintenance"},"coming-soon":{path:"./commands/maintenance.js",exportName:"maintenance"},"coming-soon:status":{path:"./commands/maintenance.js",exportName:"maintenance"},launch:{path:"./commands/maintenance.js",exportName:"maintenance"},make:{path:"./commands/make.js",exportName:"make"},"scaffold:crud":{path:"./commands/make.js",exportName:"make"},migrate:{path:"./commands/migrate.js",exportName:"migrate"},"migrate:fresh":{path:"./commands/migrate.js",exportName:"migrate"},"migrate:switch":{path:"./commands/migrate.js",exportName:"migrate"},"migrate:dns":{path:"./commands/migrate.js",exportName:"migrate"},"migrate:project":{path:"./commands/migrate-project.js",exportName:"migrateProject"},outdated:{path:"./commands/outdated.js",exportName:"outdated"},package:{path:"./commands/package.js",exportName:"packageCommands"},phone:{path:"./commands/phone.js",exportName:"phone"},ports:{path:"./commands/ports.js",exportName:"ports"},"link:core":{path:"./commands/link.js",exportName:"link"},"user:add":{path:"./commands/user.js",exportName:"user"},"user:list":{path:"./commands/user.js",exportName:"user"},"unlink:core":{path:"./commands/link.js",exportName:"link"},prepublish:{path:"./commands/prepublish.js",exportName:"prepublish"},projects:{path:"./commands/projects.js",exportName:"projects"},publish:{path:"./commands/publish.js",exportName:"publish"},"publish:model":{path:"./commands/publish.js",exportName:"publish"},"publish:controller":{path:"./commands/publish.js",exportName:"publish"},"publish:middleware":{path:"./commands/publish.js",exportName:"publish"},"publish:action":{path:"./commands/publish.js",exportName:"publish"},"publish:core":{path:"./commands/publish.js",exportName:"publish"},"core:status":{path:"./commands/publish.js",exportName:"publish"},queue:{path:"./commands/queue.js",exportName:"queue"},release:{path:"./commands/release.js",exportName:"release"},route:{path:"./commands/route.js",exportName:"route"},saas:{path:"./commands/saas.js",exportName:"saas"},"stripe:setup":{path:"./commands/saas.js",exportName:"saas"},schedule:{path:"./commands/schedule.js",exportName:"schedule"},search:{path:"./commands/search.js",exportName:"search"},"search-engine:update":{path:"./commands/search.js",exportName:"search"},"search-engine:settings":{path:"./commands/search.js",exportName:"search"},seed:{path:"./commands/seed.js",exportName:"seed"},"seed:roles":{path:"./commands/seed.js",exportName:"seed"},"roles:seed":{path:"./commands/seed.js",exportName:"seed"},preview:{path:"./commands/serve.js",exportName:"preview"},serve:{path:"./commands/serve.js",exportName:"serve"},"serve:api":{path:"./commands/serve.js",exportName:"serveApi"},setup:{path:"./commands/setup.js",exportName:"setup"},"setup:ai":{path:"./commands/setup.js",exportName:"setup"},"setup:ssl":{path:"./commands/setup.js",exportName:"setup"},"setup:oh-my-zsh":{path:"./commands/setup.js",exportName:"setup"},"ai:setup":{path:"./commands/setup.js",exportName:"setup"},share:{path:"./commands/share.js",exportName:"share"},stack:{path:"./commands/stacks.js",exportName:"stacks"},sms:{path:"./commands/sms.js",exportName:"sms"},telemetry:{path:"./commands/telemetry.js",exportName:"telemetryCommand"},test:{path:"./commands/test.js",exportName:"test"},tinker:{path:"./commands/tinker.js",exportName:"tinker"},types:{path:"./commands/types.js",exportName:"types"},undeploy:{path:"./commands/cloud.js",exportName:"cloud"},upgrade:{path:"./commands/upgrade.js",exportName:"upgrade"},version:{path:"./commands/version.js",exportName:"version"},"docs:buddy":{path:"./commands/docs.js",exportName:"docs"},"docs:buddy:check":{path:"./commands/docs.js",exportName:"docs"},"docs:artifacts":{path:"./commands/docs.js",exportName:"docs"},"docs:artifacts:check":{path:"./commands/docs.js",exportName:"docs"},"docs:links":{path:"./commands/docs.js",exportName:"docs"},"docs:links:check":{path:"./commands/docs.js",exportName:"docs"}},commandGroups={minimal:["version","help"],development:["dev","build","test","lint"],database:["migrate","seed","fresh"],scaffolding:["make","generate"],deployment:["deploy","release","cloud"],info:["about","doctor","list"]},loadedRegistrars=new WeakMap;function loaderKey(loader){return`${loader.path}#${loader.exportName}`}export function markLoaded(buddy,commandName){const loader=commandRegistry[commandName];if(!loader)return;let set=loadedRegistrars.get(buddy);if(!set){set=new Set;loadedRegistrars.set(buddy,set)}set.add(loaderKey(loader))}export async function loadCommand(commandName,buddy){const loader=commandRegistry[commandName];if(!loader)return!1;let set=loadedRegistrars.get(buddy);if(!set){set=new Set;loadedRegistrars.set(buddy,set)}const key=loaderKey(loader);if(set.has(key))return!0;set.add(key);try{const commandFunction=(await import(loader.path))[loader.exportName];if(typeof commandFunction==="function"){commandFunction(buddy);return!0}else return!1}catch{return!1}}export async function loadCommands(commandNames,buddy){const timeout=(ms)=>new Promise((_,reject)=>setTimeout(()=>reject(Error("timeout")),ms)),seen=new Set,unique=[];for(const name of commandNames){const loader=commandRegistry[name],key=loader?`${loader.path}#${loader.exportName}`:`name:${name}`;if(seen.has(key))continue;seen.add(key);unique.push(name)}await Promise.all(unique.map(async(name)=>{try{await Promise.race([loadCommand(name,buddy),timeout(5000)])}catch{}}))}export async function loadCommandGroup(groupName,buddy){const commands=commandGroups[groupName];if(commands)await loadCommands(commands,buddy)}export async function loadAllCommands(buddy){const allCommands=Object.keys(commandRegistry);await loadCommands(allCommands,buddy)}export function getCommandNames(){return Object.keys(commandRegistry)}export function getCommandsToLoad(args){const requestedCommand=args[0],isVersionFlag=requestedCommand==="--version"||requestedCommand==="-v",isHelpFlag=requestedCommand==="--help"||requestedCommand==="-h",isHelpWord=requestedCommand==="help";if(isVersionFlag)return["version"];if(!requestedCommand||isHelpFlag||isHelpWord)return Object.keys(commandRegistry);const baseCommand=requestedCommand.split(":")[0];if(baseCommand==="list")return["list",...Object.keys(commandRegistry).filter((k)=>k!=="list")];if(commandRegistry[requestedCommand])return[requestedCommand];if(commandRegistry[baseCommand])return[baseCommand];return Object.keys(commandRegistry)}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read the `managedServices` block of a ts-cloud config and return the stateful
|
|
3
|
+
* services it provisions on the instance.
|
|
4
|
+
*
|
|
5
|
+
* Every one of them is unbacked today, so presence in this list is the whole
|
|
6
|
+
* finding. When a `backups` surface exists, this is where it gets consulted.
|
|
7
|
+
*/
|
|
8
|
+
export declare function findUnbackedManagedServices(tsCloudConfig: unknown): UnbackedService[];
|
|
9
|
+
/**
|
|
10
|
+
* The sentence `doctor` and `deploy` both say. One wording, so the two cannot
|
|
11
|
+
* drift into describing the situation differently.
|
|
12
|
+
*/
|
|
13
|
+
export declare function unbackedDataMessage(services: UnbackedService[]): string;
|
|
14
|
+
/**
|
|
15
|
+
* Which stateful services this project runs on its own compute instance with
|
|
16
|
+
* nothing backing them up.
|
|
17
|
+
*
|
|
18
|
+
* `managedServices: { postgres: true }` is one boolean, and it decides that the
|
|
19
|
+
* only copy of the application's data lives on the same disk as the web
|
|
20
|
+
* process. Nothing in the framework then takes a dump, a snapshot, or anything
|
|
21
|
+
* offsite, and nothing says so - which is how it goes unnoticed
|
|
22
|
+
* (stacksjs/stacks#2313).
|
|
23
|
+
*
|
|
24
|
+
* The asymmetry that makes it worth saying out loud: `buddy deploy` runs
|
|
25
|
+
* `migrate` on every deploy. The framework is willing to run a schema change
|
|
26
|
+
* against production data it has no way to restore.
|
|
27
|
+
*
|
|
28
|
+
* ## Why this reports rather than fixes
|
|
29
|
+
*
|
|
30
|
+
* ts-cloud already carries a full backup subsystem - destinations, policies,
|
|
31
|
+
* retention, recovery points, verification, restore planning - but its logical
|
|
32
|
+
* database source runs `pg_dumpall` through `runtime.exec()` against a **data
|
|
33
|
+
* container**, and throws `Data container <name> was not found` for anything
|
|
34
|
+
* else. `managedServices` installs the engine from pantry as a boot-time
|
|
35
|
+
* systemd service, so there is no container and none of that machinery can
|
|
36
|
+
* reach it.
|
|
37
|
+
*
|
|
38
|
+
* Writing the dump here instead would mean hand-rolling the admin connection,
|
|
39
|
+
* and that is the part which is not obvious: pantry's postgres grants `trust`
|
|
40
|
+
* on the local unix socket but requires md5 over TCP loopback, where the
|
|
41
|
+
* `postgres` superuser has no password - so an on-box admin command must omit
|
|
42
|
+
* `-h` entirely and let the client find the socket. ts-cloud knows this and
|
|
43
|
+
* encodes it in `pgAdminCommand()`, which is declared in its types but exported
|
|
44
|
+
* from none of its entry points. A second copy of that rule in this repo would
|
|
45
|
+
* be wrong the first time upstream changed it.
|
|
46
|
+
*
|
|
47
|
+
* So the dump belongs upstream, next to the code that installed the engine, and
|
|
48
|
+
* this file's job is to make sure nobody finds out the way bughq did.
|
|
49
|
+
*/
|
|
50
|
+
/** A stateful service running on the instance with no backup path. */
|
|
51
|
+
export declare interface UnbackedService {
|
|
52
|
+
name: string
|
|
53
|
+
holds: string
|
|
54
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
const STATEFUL_SERVICES={postgres:"the application database",mysql:"the application database",mariadb:"the application database",vitess:"the application database"};function isEnabled(value){if(value===!0)return!0;if(!value||typeof value!=="object")return!1;return value.enabled!==!1}export function findUnbackedManagedServices(tsCloudConfig){const managed=tsCloudConfig?.infrastructure?.compute?.managedServices;if(!managed||typeof managed!=="object")return[];const out=[];for(const[name,holds]of Object.entries(STATEFUL_SERVICES))if(isEnabled(managed[name]))out.push({name,holds});return out}export function unbackedDataMessage(services){const first=services[0];if(!first)return"No unbacked managed data services.";const names=services.map((s)=>s.name).join(", "),one=services.length===1,subject=one?`${names} is`:`${names} are`,holds=first.holds.charAt(0).toUpperCase()+first.holds.slice(1);return`${subject} provisioned on the compute instance and nothing backs ${one?"it":"them"} up: no dump, no snapshot, nothing offsite, and no restore path. ${holds} shares a disk with the web process, and \`buddy deploy\` runs \`migrate\` against it on every deploy. Until a backup surface lands (stacksjs/stacks#2313), take your own dump on a schedule and copy it off the box.`}
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@stacksjs/buddy",
|
|
3
3
|
"type": "module",
|
|
4
4
|
"sideEffects": false,
|
|
5
|
-
"version": "0.70.
|
|
5
|
+
"version": "0.70.367",
|
|
6
6
|
"description": "Meet Buddy. The Stacks runtime.",
|
|
7
7
|
"author": "Chris Breuer",
|
|
8
8
|
"contributors": [
|
|
@@ -95,53 +95,53 @@
|
|
|
95
95
|
"prepublishOnly": "bun run build"
|
|
96
96
|
},
|
|
97
97
|
"dependencies": {
|
|
98
|
-
"@stacksjs/actions": "^0.70.
|
|
99
|
-
"@stacksjs/ai": "^0.70.
|
|
100
|
-
"@stacksjs/alias": "^0.70.
|
|
101
|
-
"@stacksjs/arrays": "^0.70.
|
|
102
|
-
"@stacksjs/auth": "^0.70.
|
|
103
|
-
"@stacksjs/build": "^0.70.
|
|
104
|
-
"@stacksjs/cache": "^0.70.
|
|
105
|
-
"@stacksjs/cli": "^0.70.
|
|
98
|
+
"@stacksjs/actions": "^0.70.367",
|
|
99
|
+
"@stacksjs/ai": "^0.70.367",
|
|
100
|
+
"@stacksjs/alias": "^0.70.367",
|
|
101
|
+
"@stacksjs/arrays": "^0.70.367",
|
|
102
|
+
"@stacksjs/auth": "^0.70.367",
|
|
103
|
+
"@stacksjs/build": "^0.70.367",
|
|
104
|
+
"@stacksjs/cache": "^0.70.367",
|
|
105
|
+
"@stacksjs/cli": "^0.70.367",
|
|
106
106
|
"@stacksjs/clapp": "^0.2.12",
|
|
107
|
-
"@stacksjs/cloud": "^0.70.
|
|
108
|
-
"@stacksjs/collections": "^0.70.
|
|
109
|
-
"@stacksjs/config": "^0.70.
|
|
110
|
-
"@stacksjs/database": "^0.70.
|
|
111
|
-
"@stacksjs/desktop-build": "^0.70.
|
|
112
|
-
"@stacksjs/dns": "^0.70.
|
|
113
|
-
"@stacksjs/email": "^0.70.
|
|
114
|
-
"@stacksjs/enums": "^0.70.
|
|
115
|
-
"@stacksjs/error-handling": "^0.70.
|
|
116
|
-
"@stacksjs/events": "^0.70.
|
|
117
|
-
"@stacksjs/git": "^0.70.
|
|
107
|
+
"@stacksjs/cloud": "^0.70.367",
|
|
108
|
+
"@stacksjs/collections": "^0.70.367",
|
|
109
|
+
"@stacksjs/config": "^0.70.367",
|
|
110
|
+
"@stacksjs/database": "^0.70.367",
|
|
111
|
+
"@stacksjs/desktop-build": "^0.70.367",
|
|
112
|
+
"@stacksjs/dns": "^0.70.367",
|
|
113
|
+
"@stacksjs/email": "^0.70.367",
|
|
114
|
+
"@stacksjs/enums": "^0.70.367",
|
|
115
|
+
"@stacksjs/error-handling": "^0.70.367",
|
|
116
|
+
"@stacksjs/events": "^0.70.367",
|
|
117
|
+
"@stacksjs/git": "^0.70.367",
|
|
118
118
|
"@stacksjs/gitit": "^0.2.5",
|
|
119
|
-
"@stacksjs/health": "^0.70.
|
|
119
|
+
"@stacksjs/health": "^0.70.367",
|
|
120
120
|
"@stacksjs/dnsx": "^0.2.3",
|
|
121
121
|
"@stacksjs/httx": "^0.1.10",
|
|
122
|
-
"@stacksjs/image": "^0.70.
|
|
123
|
-
"@stacksjs/lint": "^0.70.
|
|
124
|
-
"@stacksjs/logging": "^0.70.
|
|
125
|
-
"@stacksjs/notifications": "^0.70.
|
|
126
|
-
"@stacksjs/objects": "^0.70.
|
|
127
|
-
"@stacksjs/orm": "^0.70.
|
|
128
|
-
"@stacksjs/path": "^0.70.
|
|
129
|
-
"@stacksjs/skills": "^0.70.
|
|
130
|
-
"@stacksjs/payments": "^0.70.
|
|
131
|
-
"@stacksjs/realtime": "^0.70.
|
|
132
|
-
"@stacksjs/router": "^0.70.
|
|
122
|
+
"@stacksjs/image": "^0.70.367",
|
|
123
|
+
"@stacksjs/lint": "^0.70.367",
|
|
124
|
+
"@stacksjs/logging": "^0.70.367",
|
|
125
|
+
"@stacksjs/notifications": "^0.70.367",
|
|
126
|
+
"@stacksjs/objects": "^0.70.367",
|
|
127
|
+
"@stacksjs/orm": "^0.70.367",
|
|
128
|
+
"@stacksjs/path": "^0.70.367",
|
|
129
|
+
"@stacksjs/skills": "^0.70.367",
|
|
130
|
+
"@stacksjs/payments": "^0.70.367",
|
|
131
|
+
"@stacksjs/realtime": "^0.70.367",
|
|
132
|
+
"@stacksjs/router": "^0.70.367",
|
|
133
133
|
"@stacksjs/rpx": "^0.11.42",
|
|
134
|
-
"@stacksjs/search-engine": "^0.70.
|
|
135
|
-
"@stacksjs/security": "^0.70.
|
|
136
|
-
"@stacksjs/server": "^0.70.
|
|
137
|
-
"@stacksjs/storage": "^0.70.
|
|
138
|
-
"@stacksjs/strings": "^0.70.
|
|
139
|
-
"@stacksjs/testing": "^0.70.
|
|
140
|
-
"@stacksjs/tunnel": "^0.70.
|
|
141
|
-
"@stacksjs/types": "^0.70.
|
|
142
|
-
"@stacksjs/ui": "^0.70.
|
|
143
|
-
"@stacksjs/utils": "^0.70.
|
|
144
|
-
"@stacksjs/validation": "^0.70.
|
|
134
|
+
"@stacksjs/search-engine": "^0.70.367",
|
|
135
|
+
"@stacksjs/security": "^0.70.367",
|
|
136
|
+
"@stacksjs/server": "^0.70.367",
|
|
137
|
+
"@stacksjs/storage": "^0.70.367",
|
|
138
|
+
"@stacksjs/strings": "^0.70.367",
|
|
139
|
+
"@stacksjs/testing": "^0.70.367",
|
|
140
|
+
"@stacksjs/tunnel": "^0.70.367",
|
|
141
|
+
"@stacksjs/types": "^0.70.367",
|
|
142
|
+
"@stacksjs/ui": "^0.70.367",
|
|
143
|
+
"@stacksjs/utils": "^0.70.367",
|
|
144
|
+
"@stacksjs/validation": "^0.70.367",
|
|
145
145
|
"@stacksjs/ts-cloud": "^0.7.103",
|
|
146
146
|
"ajv": "^8.20.0",
|
|
147
147
|
"ajv-formats": "^3.0.1",
|