@stacksjs/buddy 0.74.34 → 0.74.36
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/deploy.d.ts +12 -1
- package/dist/commands/deploy.js +1 -1
- package/dist/commands/desktop-apple.d.ts +50 -0
- package/dist/commands/desktop-apple.js +5 -3
- package/dist/commands/docs/capabilities.d.ts +2 -0
- package/dist/commands/docs/capabilities.js +2 -0
- package/dist/commands/docs/snippets.d.ts +41 -0
- package/dist/commands/docs/snippets.js +10 -0
- package/dist/commands/docs.js +1 -1
- package/package.json +54 -54
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { env } from '@stacksjs/env';
|
|
2
2
|
import type { CLI } from '@stacksjs/types';
|
|
3
|
+
import type { DnsConfig } from '@stacksjs/ts-cloud';
|
|
3
4
|
import type { SshTarget } from './deploy-ssh-target';
|
|
4
5
|
export declare function resolveTsCloudCliPath(tsCloudEntry?: unknown): string;
|
|
5
6
|
export declare function runDeployRollback(site: string | undefined, options: DeployRollbackOptions, execute?: (command: string[]) => Promise<number>): Promise<number>;
|
|
@@ -633,6 +634,16 @@ export declare function dnsProviderNameFromNameservers(nameservers: string[]): '
|
|
|
633
634
|
* implementation of "which names does this site publish, and at which
|
|
634
635
|
* registrar" is the drift this function exists to prevent.
|
|
635
636
|
*/
|
|
637
|
+
/**
|
|
638
|
+
* Point the registrar at `dns.provider`, when the project says to.
|
|
639
|
+
*
|
|
640
|
+
* The whole operation lives in ts-cloud (`delegateZoneFromConfig`); this is
|
|
641
|
+
* only the deploy's call site and its logging. Every inapplicable case — no
|
|
642
|
+
* registrar declared, a runner without the credentials, an explicit opt-out —
|
|
643
|
+
* comes back as `skipped` rather than throwing, because none of them is a
|
|
644
|
+
* reason to fail a deployment that is otherwise fine.
|
|
645
|
+
*/
|
|
646
|
+
export declare function delegateDeclaredZone(config: TsCloudConfig | null | undefined, logger: typeof log): Promise<void>;
|
|
636
647
|
export declare function reconcileHetznerDns(sites: Record<string, any>, ip: string, logger: typeof log, ipv6?: string, autoWww?: boolean): Promise<string[]>;
|
|
637
648
|
export declare function deploy(buddy: CLI): void;
|
|
638
649
|
/**
|
|
@@ -688,7 +699,7 @@ export declare interface TsCloudInfrastructure {
|
|
|
688
699
|
cdn?: { provider?: string }
|
|
689
700
|
}
|
|
690
701
|
}
|
|
691
|
-
dns?:
|
|
702
|
+
dns?: Partial<DnsConfig>
|
|
692
703
|
}
|
|
693
704
|
export declare interface TsCloudConfig {
|
|
694
705
|
project?: { name?: string, slug?: string, region?: string }
|
package/dist/commands/deploy.js
CHANGED
|
@@ -402,5 +402,5 @@ EOF
|
|
|
402
402
|
systemctl daemon-reload
|
|
403
403
|
systemctl enable --now mail-health.timer >/dev/null 2>&1
|
|
404
404
|
# 6) Restart only when the startup-read env actually changed (domain or DKIM key).
|
|
405
|
-
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.info(`Mail: ${domain} signs with the server's global DKIM key (${dkimGlobalKey}) \u2014 rotate that one.`);const dkimStaleKey=(out.match(/DKIMSTALE:([^\n]*)/)||[])[1]?.trim();if(dkimStaleKey)logger.warn(`Mail: ${dkimStaleKey} is an unused DKIM key left by an earlier deploy \u2014 nothing signs with it. Remove it with: rm ${dkimStaleKey}`);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 - 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) - 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}}const DNS_PROVIDER_CREDENTIALS={porkbun:["PORKBUN_API_KEY","PORKBUN_SECRET_KEY"],cloudflare:["CLOUDFLARE_API_TOKEN"],godaddy:["GODADDY_API_KEY","GODADDY_API_SECRET"],route53:["AWS_ACCESS_KEY_ID (or AWS_PROFILE)"]};export function declaredDnsProvider(config){const provider=config?.tsCloud?.infrastructure?.dns?.provider??config?.infrastructure?.dns?.provider;return typeof provider==="string"&&provider?provider.toLowerCase():void 0}export function dnsProviderConfigsFromEnv(declared){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"});if(!declared)return configs;return configs.filter((config)=>config.provider===declared)}export function declaredDnsProviderProblem(declared,configs){if(!declared||configs.length>0)return;const needed=DNS_PROVIDER_CREDENTIALS[declared];if(!needed)return`config/cloud.ts declares the DNS provider '${declared}', which is not one this deploy knows how to drive (${Object.keys(DNS_PROVIDER_CREDENTIALS).join(", ")}).`;return`config/cloud.ts declares '${declared}' as the DNS provider for this project, but ${needed.join(" and ")} ${needed.length>1?"are":"is"} not set in this environment. Set ${needed.length>1?"them":"it"} in .env.production (\`buddy env:set\`) so the records land at the registrar that actually administers the zone. Refusing to try another provider: writing DNS into the wrong zone is not better than writing none.`}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} - 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 - 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}`)},declared=declaredDnsProvider(await loadTsCloudConfig(process.env.APP_ENV||"production").catch(()=>{return})),providerConfigs=dnsProviderConfigsFromEnv(declared),declaredProblem=declaredDnsProviderProblem(declared,providerConfigs);if(declaredProblem){logger.warn(` DNS: ${declaredProblem}`);return byHand(`the declared provider '${declared}' has no credentials in this environment`)}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\./,""));const all=[...domains];return all.filter((domain)=>!all.some((other)=>other!==domain&&domain.endsWith(`.${other}`)))}async function reconcileCloudflareCdnForDeploy(tsCloudConfig,ip,ipv6,logger){let resolveCloudflareCdnPlan,reconcileCloudflareCdn,CloudflareProvider,normalizePublicIpv6;try{({resolveCloudflareCdnPlan,reconcileCloudflareCdn,CloudflareProvider,normalizePublicIpv6}=await import("@stacksjs/ts-cloud"))}catch{return}if(typeof resolveCloudflareCdnPlan!=="function"||typeof reconcileCloudflareCdn!=="function"){if(tsCloudConfig?.infrastructure?.compute?.proxy?.cdn?.provider==="cloudflare")logger.warn("Cloudflare CDN: installed @stacksjs/ts-cloud is too old to manage it \u2014 upgrade to ^0.9.4.");return}const{plan,errors}=resolveCloudflareCdnPlan(tsCloudConfig);for(const error of errors)logger.warn(`Cloudflare CDN: ${error}`);if(!plan)return;if(!ip){logger.warn("Cloudflare CDN: no box IP resolved \u2014 skipping.");return}logger.info(`Cloudflare CDN: reconciling ${plan.hosts.length} host(s) on ${plan.zone}...`);try{const provider=new CloudflareProvider(plan.apiToken,{zoneId:plan.zoneId,accountId:plan.accountId}),report=await reconcileCloudflareCdn({provider,zone:plan.zone,hosts:plan.hosts,ipv4:ip,ipv6:typeof normalizePublicIpv6==="function"?normalizePublicIpv6(ipv6):ipv6,proxied:plan.proxied,settings:plan.settings,cache:plan.cache,originGuard:plan.originGuard,purge:plan.purge,skipOriginProbe:plan.skipOriginProbe});for(const record of report.records)logger.success(` ${record.host} ${record.type} \u2192 ${record.content}${record.proxied?" (proxied)":" (DNS-only)"}`);for(const setting of report.settingsChanged)logger.info(` ${setting.id}: ${JSON.stringify(setting.from)} \u2192 ${JSON.stringify(setting.to)}`);if(report.cacheRules>0)logger.success(` ${report.cacheRules} cache rule(s) applied`);if(report.originGuard)logger.success(" origin guard header applied");if(report.purged)logger.success(" edge cache purged");for(const deferred of report.deferredProxy||[])logger.warn(` ${deferred.host} left DNS-only: ${deferred.reason} \u2014 re-run the deploy once TLS is issued.`);for(const warning of report.warnings)logger.warn(` ${warning}`)}catch(err){logger.warn(`Cloudflare CDN: ${err?.message||err}`)}}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`:""}`);for(const failure of result.failures)logger.warn(` ${failure.record.type} ${failure.record.name} \u2192 ${failure.record.content}: ${failure.reason}`);for(const skipped of result.skipped)logger.info(` skipped ${skipped.record.type} ${skipped.record.name}: ${skipped.reason}`)}catch(err){logger.warn(`DNS (config/dns.ts) reconcile for ${domain} failed: ${err instanceof Error?err.message:String(err)}`)}}export async function reconcileHetznerDns(sites,ip,logger,ipv6,autoWww){const published=[],{gatewayHostnames}=await import("@stacksjs/ts-cloud"),hostnames=gatewayHostnames(sites,{autoWww}),byBase=new Map;for(const fqdn of hostnames){const base=fqdn.replace(/^www\./,""),group=byBase.get(base)??new Set;group.add(fqdn);byBase.set(base,group)}if(byBase.size===0)return published;const declared=declaredDnsProvider(await loadTsCloudConfig(process.env.APP_ENV||"production").catch(()=>{return})),providerConfigs=dnsProviderConfigsFromEnv(declared),declaredProblem=declaredDnsProviderProblem(declared,providerConfigs);if(declaredProblem)logger.warn(`DNS: ${declaredProblem}`);if(providerConfigs.length===0){if(!declaredProblem)logger.warn("DNS: no DNS provider credentials found (PORKBUN_API_KEY/\u2026); skipping DNS reconciliation.");for(const fqdn of hostnames)logger.info(` Point manually: A ${fqdn} \u2192 ${ip}`);return published}const{reconcileAddressRecords}=await import("@stacksjs/ts-cloud");logger.info("Reconciling DNS records...");const resolveA=async(fqdn)=>{try{const{resolve4}=await import("node:dns/promises");return await resolve4(fqdn)}catch{return[]}};for(const[domain,group]of byBase){const fqdns=[...group].sort();try{const provider=await resolveZoneDnsProvider(domain,providerConfigs,logger);if(!provider){for(const fqdn of fqdns){const current=await resolveA(fqdn);if(current.includes(ip))logger.success(` DNS: ${fqdn} \u2192 ${ip} (externally managed, already correct)`);else if(current.length===0)logger.warn(` DNS: ${fqdn} does not resolve and no configured provider manages ${domain} - 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} - update it manually: A ${fqdn} \u2192 ${ip}`)}continue}for(const fqdn of fqdns){const report=await reconcileAddressRecords({provider,zone:domain,fqdn,ipv4:ip,ipv6});for(const record of report.published){logger.success(` DNS: ${record.fqdn} \u2192 ${record.content} (${provider.name})`);published.push(record.fqdn)}for(const warning of report.warnings)logger.warn(` DNS: ${warning}`)}}catch(err){logger.warn(` DNS: ${domain} reconciliation failed: ${err?.message||err}`)}}return published}async function buildContainerImageWithPantry(args){const{slug,sites,verbose}=args,{execSync}=await import("node:child_process");let cli;for(const candidate of["pantry","ts-pantry"])try{execSync(`command -v ${candidate}`,{stdio:"pipe"});cli=candidate;break}catch{}if(!cli){log.warn("pantry CLI not found on PATH - 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(withDeployNotification(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,await hasOffsiteBackupDestination()):[];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&&isSshPipelineProvider(resolveProvider(tsCloudConfig))){await deployOverSsh(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"){productionUrl=(await resolveDeployEnvValues("production",tsCloudConfig)).APP_URL?.trim()||void 0;if(productionUrl)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"});await setEnv("AWS_SECRET_ACCESS_KEY",secretAccessKey,{file:".env.production"});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(`
|
|
405
|
+
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.info(`Mail: ${domain} signs with the server's global DKIM key (${dkimGlobalKey}) \u2014 rotate that one.`);const dkimStaleKey=(out.match(/DKIMSTALE:([^\n]*)/)||[])[1]?.trim();if(dkimStaleKey)logger.warn(`Mail: ${dkimStaleKey} is an unused DKIM key left by an earlier deploy \u2014 nothing signs with it. Remove it with: rm ${dkimStaleKey}`);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 - 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) - 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}}const DNS_PROVIDER_CREDENTIALS={porkbun:["PORKBUN_API_KEY","PORKBUN_SECRET_KEY"],cloudflare:["CLOUDFLARE_API_TOKEN"],godaddy:["GODADDY_API_KEY","GODADDY_API_SECRET"],route53:["AWS_ACCESS_KEY_ID (or AWS_PROFILE)"]};export function declaredDnsProvider(config){const provider=config?.tsCloud?.infrastructure?.dns?.provider??config?.infrastructure?.dns?.provider;return typeof provider==="string"&&provider?provider.toLowerCase():void 0}export function dnsProviderConfigsFromEnv(declared){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"});if(!declared)return configs;return configs.filter((config)=>config.provider===declared)}export function declaredDnsProviderProblem(declared,configs){if(!declared||configs.length>0)return;const needed=DNS_PROVIDER_CREDENTIALS[declared];if(!needed)return`config/cloud.ts declares the DNS provider '${declared}', which is not one this deploy knows how to drive (${Object.keys(DNS_PROVIDER_CREDENTIALS).join(", ")}).`;return`config/cloud.ts declares '${declared}' as the DNS provider for this project, but ${needed.join(" and ")} ${needed.length>1?"are":"is"} not set in this environment. Set ${needed.length>1?"them":"it"} in .env.production (\`buddy env:set\`) so the records land at the registrar that actually administers the zone. Refusing to try another provider: writing DNS into the wrong zone is not better than writing none.`}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} - 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 - 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}`)},declared=declaredDnsProvider(await loadTsCloudConfig(process.env.APP_ENV||"production").catch(()=>{return})),providerConfigs=dnsProviderConfigsFromEnv(declared),declaredProblem=declaredDnsProviderProblem(declared,providerConfigs);if(declaredProblem){logger.warn(` DNS: ${declaredProblem}`);return byHand(`the declared provider '${declared}' has no credentials in this environment`)}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\./,""));const all=[...domains];return all.filter((domain)=>!all.some((other)=>other!==domain&&domain.endsWith(`.${other}`)))}async function reconcileCloudflareCdnForDeploy(tsCloudConfig,ip,ipv6,logger){let resolveCloudflareCdnPlan,reconcileCloudflareCdn,CloudflareProvider,normalizePublicIpv6;try{({resolveCloudflareCdnPlan,reconcileCloudflareCdn,CloudflareProvider,normalizePublicIpv6}=await import("@stacksjs/ts-cloud"))}catch{return}if(typeof resolveCloudflareCdnPlan!=="function"||typeof reconcileCloudflareCdn!=="function"){if(tsCloudConfig?.infrastructure?.compute?.proxy?.cdn?.provider==="cloudflare")logger.warn("Cloudflare CDN: installed @stacksjs/ts-cloud is too old to manage it \u2014 upgrade to ^0.9.4.");return}const{plan,errors}=resolveCloudflareCdnPlan(tsCloudConfig);for(const error of errors)logger.warn(`Cloudflare CDN: ${error}`);if(!plan)return;if(!ip){logger.warn("Cloudflare CDN: no box IP resolved \u2014 skipping.");return}logger.info(`Cloudflare CDN: reconciling ${plan.hosts.length} host(s) on ${plan.zone}...`);try{const provider=new CloudflareProvider(plan.apiToken,{zoneId:plan.zoneId,accountId:plan.accountId}),report=await reconcileCloudflareCdn({provider,zone:plan.zone,hosts:plan.hosts,ipv4:ip,ipv6:typeof normalizePublicIpv6==="function"?normalizePublicIpv6(ipv6):ipv6,proxied:plan.proxied,settings:plan.settings,cache:plan.cache,originGuard:plan.originGuard,purge:plan.purge,skipOriginProbe:plan.skipOriginProbe});for(const record of report.records)logger.success(` ${record.host} ${record.type} \u2192 ${record.content}${record.proxied?" (proxied)":" (DNS-only)"}`);for(const setting of report.settingsChanged)logger.info(` ${setting.id}: ${JSON.stringify(setting.from)} \u2192 ${JSON.stringify(setting.to)}`);if(report.cacheRules>0)logger.success(` ${report.cacheRules} cache rule(s) applied`);if(report.originGuard)logger.success(" origin guard header applied");if(report.purged)logger.success(" edge cache purged");for(const deferred of report.deferredProxy||[])logger.warn(` ${deferred.host} left DNS-only: ${deferred.reason} \u2014 re-run the deploy once TLS is issued.`);for(const warning of report.warnings)logger.warn(` ${warning}`)}catch(err){logger.warn(`Cloudflare CDN: ${err?.message||err}`)}}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`:""}`);for(const failure of result.failures)logger.warn(` ${failure.record.type} ${failure.record.name} \u2192 ${failure.record.content}: ${failure.reason}`);for(const skipped of result.skipped)logger.info(` skipped ${skipped.record.type} ${skipped.record.name}: ${skipped.reason}`)}catch(err){logger.warn(`DNS (config/dns.ts) reconcile for ${domain} failed: ${err instanceof Error?err.message:String(err)}`)}}export async function delegateDeclaredZone(config,logger){const dns=config?.tsCloud?.infrastructure?.dns??config?.infrastructure?.dns;if(!dns?.registrar)return;try{const{applyDeclaredZoneSettings,delegateZoneFromConfig,describeDelegation,describeZoneSettings}=await import("@stacksjs/ts-cloud"),result=await delegateZoneFromConfig(dns);for(const line of describeDelegation(result))logger.info(line);if(result.status==="blocked")logger.warn("DNS: delegation did not complete; the domain still resolves through its registrar.");const settings=await applyDeclaredZoneSettings(dns);for(const line of describeZoneSettings(settings))logger.info(line);if(settings.status!=="skipped"&&settings.failed.length>0)logger.warn(`DNS: ${settings.failed.length} zone setting(s) could not be applied; the zone is serving with its previous values.`)}catch(error){logger.warn(`DNS: zone reconciliation could not run: ${error instanceof Error?error.message:String(error)}`)}}export async function reconcileHetznerDns(sites,ip,logger,ipv6,autoWww){const published=[],{gatewayHostnames}=await import("@stacksjs/ts-cloud"),hostnames=gatewayHostnames(sites,{autoWww}),byBase=new Map;for(const fqdn of hostnames){const base=fqdn.replace(/^www\./,""),group=byBase.get(base)??new Set;group.add(fqdn);byBase.set(base,group)}if(byBase.size===0)return published;const tsCloudConfig=await loadTsCloudConfig(process.env.APP_ENV||"production").catch(()=>{return});await delegateDeclaredZone(tsCloudConfig,logger);const declared=declaredDnsProvider(tsCloudConfig),providerConfigs=dnsProviderConfigsFromEnv(declared),declaredProblem=declaredDnsProviderProblem(declared,providerConfigs);if(declaredProblem)logger.warn(`DNS: ${declaredProblem}`);if(providerConfigs.length===0){if(!declaredProblem)logger.warn("DNS: no DNS provider credentials found (PORKBUN_API_KEY/\u2026); skipping DNS reconciliation.");for(const fqdn of hostnames)logger.info(` Point manually: A ${fqdn} \u2192 ${ip}`);return published}const{reconcileAddressRecords}=await import("@stacksjs/ts-cloud");logger.info("Reconciling DNS records...");const resolveA=async(fqdn)=>{try{const{resolve4}=await import("node:dns/promises");return await resolve4(fqdn)}catch{return[]}};for(const[domain,group]of byBase){const fqdns=[...group].sort();try{const provider=await resolveZoneDnsProvider(domain,providerConfigs,logger);if(!provider){for(const fqdn of fqdns){const current=await resolveA(fqdn);if(current.includes(ip))logger.success(` DNS: ${fqdn} \u2192 ${ip} (externally managed, already correct)`);else if(current.length===0)logger.warn(` DNS: ${fqdn} does not resolve and no configured provider manages ${domain} - 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} - update it manually: A ${fqdn} \u2192 ${ip}`)}continue}for(const fqdn of fqdns){const report=await reconcileAddressRecords({provider,zone:domain,fqdn,ipv4:ip,ipv6});for(const record of report.published){logger.success(` DNS: ${record.fqdn} \u2192 ${record.content} (${provider.name})`);published.push(record.fqdn)}for(const warning of report.warnings)logger.warn(` DNS: ${warning}`)}}catch(err){logger.warn(` DNS: ${domain} reconciliation failed: ${err?.message||err}`)}}return published}async function buildContainerImageWithPantry(args){const{slug,sites,verbose}=args,{execSync}=await import("node:child_process");let cli;for(const candidate of["pantry","ts-pantry"])try{execSync(`command -v ${candidate}`,{stdio:"pipe"});cli=candidate;break}catch{}if(!cli){log.warn("pantry CLI not found on PATH - 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(withDeployNotification(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,await hasOffsiteBackupDestination()):[];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&&isSshPipelineProvider(resolveProvider(tsCloudConfig))){await deployOverSsh(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"){productionUrl=(await resolveDeployEnvValues("production",tsCloudConfig)).APP_URL?.trim()||void 0;if(productionUrl)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"});await setEnv("AWS_SECRET_ACCESS_KEY",secretAccessKey,{file:".env.production"});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(`
|
|
406
406
|
`);let accessKeyId,secretAccessKey,region,accountId;for(const line of lines){const trimmed=line.trim();if(trimmed.startsWith("#")||!trimmed.includes("="))continue;const[key,...valueParts]=trimmed.split("="),value=valueParts.join("=").trim();if(key==="AWS_ACCESS_KEY_ID"&&value)accessKeyId=value;else if(key==="AWS_SECRET_ACCESS_KEY"&&value)secretAccessKey=value;else if(key==="AWS_REGION"&&value)region=value;else if(key==="AWS_ACCOUNT_ID"&&value)accountId=value}if(accessKeyId&&secretAccessKey){log.debug(`Found AWS credentials in ${envPath}`);return{accessKeyId,secretAccessKey,region,accountId}}}catch(error){log.debug(`Failed to read ${envPath} file:`,error)}}return{}}async function checkIfAwsIsBootstrapped(options){let handlingAlreadyExists=!1;try{log.info("Ensuring AWS cloud stack exists...");let hasCredentials=process.env.AWS_ACCESS_KEY_ID&&process.env.AWS_SECRET_ACCESS_KEY;if(!hasCredentials){const envCredentials=loadAwsCredentialsFromEnv();if(envCredentials.accessKeyId&&envCredentials.secretAccessKey){process.env.AWS_ACCESS_KEY_ID=envCredentials.accessKeyId;process.env.AWS_SECRET_ACCESS_KEY=envCredentials.secretAccessKey;if(envCredentials.region&&!process.env.AWS_REGION)process.env.AWS_REGION=envCredentials.region;if(envCredentials.accountId&&!process.env.AWS_ACCOUNT_ID)process.env.AWS_ACCOUNT_ID=envCredentials.accountId;hasCredentials=!0;const environment=process.env.APP_ENV||process.env.NODE_ENV||"production";log.success(`Using AWS credentials from .env.${environment}`)}}if(!hasCredentials){const fileCredentials=loadAwsCredentialsFromFile();if(fileCredentials.accessKeyId&&fileCredentials.secretAccessKey){process.env.AWS_ACCESS_KEY_ID=fileCredentials.accessKeyId;process.env.AWS_SECRET_ACCESS_KEY=fileCredentials.secretAccessKey;if(fileCredentials.region&&!process.env.AWS_REGION)process.env.AWS_REGION=fileCredentials.region;hasCredentials=!0;log.success("Using AWS credentials from ~/.aws/credentials")}}if(!hasCredentials){log.info("AWS credentials not found in .env or ~/.aws/credentials.");log.info("You can either:");log.info(" 1. Set AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY in .env.production");log.info(" 2. Add credentials to ~/.aws/credentials");log.info(" 3. Configure them interactively below");console.log("");if(options?.yes){log.info("Skipping credential setup (--yes flag provided)");process.exit(ExitCode.FatalError)}const setupCredentials=await prompts.confirm({message:"Would you like to configure AWS credentials now?",initial:!0});log.debug("setupCredentials response:",setupCredentials,typeof setupCredentials);if(setupCredentials===void 0||setupCredentials===!1){if(setupCredentials===void 0){console.log("");log.info("Deployment cancelled");process.exit(ExitCode.Success)}console.log("");log.info("Skipping cloud infrastructure check");log.info("You can configure AWS credentials later by running: buddy configure:aws");return!0}await promptAndSaveCredentials()}else log.success("AWS credentials found");const appName=(process.env.APP_NAME||app.name||"stacks").toLowerCase().replace(/[^a-z0-9-]/g,"-"),stackName=`${appName}-cloud`,{AWSCloudFormationClient}=await import("@stacksjs/ts-cloud"),cfnClient=new AWSCloudFormationClient(process.env.AWS_REGION||"us-east-1");let stackExists=!1,needsEmailUpdate=!1;try{const stack=(await cfnClient.describeStacks({stackName})).Stacks?.[0];if(stack){stackExists=!0;log.success("Cloud stack exists");const{AWSCloudFormationClient}=await import("@stacksjs/ts-cloud"),resources=await new AWSCloudFormationClient(process.env.AWS_REGION||"us-east-1").listStackResources(stackName),hasEmailBucket=resources.StackResourceSummaries?.some((r)=>r.LogicalResourceId==="EmailBucket"),hasOutboundLambda=resources.StackResourceSummaries?.some((r)=>r.LogicalResourceId==="OutboundEmailLambda"),hasConversionLambda=resources.StackResourceSummaries?.some((r)=>r.LogicalResourceId==="EmailConversionLambda"),hasNotificationTopic=resources.StackResourceSummaries?.some((r)=>r.LogicalResourceId==="EmailNotificationTopic"),hasMailApiLambda=resources.StackResourceSummaries?.some((r)=>r.LogicalResourceId==="MailApiLambda"),hasMailUsersTable=resources.StackResourceSummaries?.some((r)=>r.LogicalResourceId==="MailUsersTable"),hasMailServerInstance=resources.StackResourceSummaries?.some((r)=>r.LogicalResourceId==="MailServerInstance"),currentEmailDomain=stack.Outputs?.find((o)=>o.OutputKey==="EmailDomain")?.OutputValue,configuredDomain=(emailConfig?.from?.address?.includes("@")?emailConfig.from.address.split("@")[1]:void 0)||"stacksjs.com";if(!hasEmailBucket&&emailConfig?.server?.scan!==void 0){log.info("Email infrastructure not found in stack, will update...");needsEmailUpdate=!0}else if(currentEmailDomain&¤tEmailDomain!==configuredDomain){log.info(`Email domain changed: ${currentEmailDomain} -> ${configuredDomain}, will update...`);needsEmailUpdate=!0}else if(hasEmailBucket&&(!hasOutboundLambda||!hasConversionLambda||!hasNotificationTopic)){log.info("Email infrastructure incomplete, will update...");needsEmailUpdate=!0}else if(hasEmailBucket&&(!hasMailApiLambda||!hasMailUsersTable)){log.info("Mail API infrastructure missing, will update...");needsEmailUpdate=!0}else if(hasEmailBucket&&!hasMailServerInstance&&emailConfig?.server?.enabled){log.info("Mail server EC2 instance missing, will update...");needsEmailUpdate=!0}const currentMode=(stack.Outputs||[]).find((o)=>o.OutputKey==="MailServerMode")?.OutputValue,configuredMode=emailConfig?.server?.mode||"serverless";if(currentMode&¤tMode!==configuredMode){log.info(`Mail server mode changed: ${currentMode} -> ${configuredMode}, will update...`);needsEmailUpdate=!0}if(hasMailServerInstance&&emailConfig?.server?.enabled){if(process.env.FORCE_MAIL_UPDATE==="true"){log.info("Forcing mail server update...");needsEmailUpdate=!0}}if(!needsEmailUpdate)return!0}}catch(error){const caught=error&&typeof error==="object"?error:{message:String(error)};log.debug(`Stack not found: ${getErrorMessage(error)}`)}if(!stackExists)log.info("Cloud stack not found, will be created by deploy action");return!0}catch(err){if(!handlingAlreadyExists){log.error("Error checking cloud infrastructure");log.error(`Error: ${getErrorMessage(err)}`);if(options?.verbose)console.error(err)}process.exit(ExitCode.FatalError)}}
|
|
@@ -1,10 +1,52 @@
|
|
|
1
1
|
import type { CLI } from '@stacksjs/types';
|
|
2
2
|
export declare function resolveAppleDesktopConfig(options?: AppleDesktopOptions): AppleDesktopConfig;
|
|
3
3
|
export declare function validateAppleDesktopConfig(config: AppleDesktopConfig, includeApi?: boolean): string[];
|
|
4
|
+
/**
|
|
5
|
+
* Whether a string is shaped like an `LSApplicationCategoryType`.
|
|
6
|
+
*
|
|
7
|
+
* The SHAPE, not membership of a list. Apple's set changes and games carry
|
|
8
|
+
* their own subcategories (`public.app-category.action-games`), so a hard-coded
|
|
9
|
+
* list here would reject a legitimate value the day Apple adds one - a worse
|
|
10
|
+
* failure than the one it prevents. What this catches is the actual mistake:
|
|
11
|
+
* free text like `Productivity`, which App Store Connect rejects on upload
|
|
12
|
+
* after everything has been built and signed.
|
|
13
|
+
*/
|
|
14
|
+
export declare function isAppleCategory(value: string): boolean;
|
|
15
|
+
/**
|
|
16
|
+
* Store-rejection preflight over a built `.app` bundle's file list
|
|
17
|
+
* (stacksjs/stacks#2199).
|
|
18
|
+
*
|
|
19
|
+
* Every one of these is a real rejection reason, and every one of them is
|
|
20
|
+
* cheaper to find here than after an upload: App Review answers hours or days
|
|
21
|
+
* later, and the message names a rule rather than a file.
|
|
22
|
+
*
|
|
23
|
+
* A pure function of the paths so it can be tested without building a bundle -
|
|
24
|
+
* and this repository cannot build a signed one at all until the platform
|
|
25
|
+
* identities exist (#2062).
|
|
26
|
+
*
|
|
27
|
+
* `paths` are relative to the `.app` directory, POSIX-separated.
|
|
28
|
+
*/
|
|
29
|
+
export declare function storeRejectionFindings(paths: readonly string[], executable?: string): string[];
|
|
4
30
|
export declare function renderInfoPlist(config: AppleDesktopConfig, executable?: string): string;
|
|
5
31
|
export declare function renderAppEntitlements(config: AppleDesktopConfig): string;
|
|
6
32
|
export declare function renderHelperEntitlements(): string;
|
|
7
33
|
export declare function renderAppleWorkflowCaller(): string;
|
|
34
|
+
/**
|
|
35
|
+
* The `codesign` invocations for a Mac App Store bundle, in the order they
|
|
36
|
+
* must run (stacksjs/stacks#2199).
|
|
37
|
+
*
|
|
38
|
+
* **Inner code first.** Signing the parent bundle seals a hash of everything
|
|
39
|
+
* inside it, so signing the helper afterwards invalidates the parent's
|
|
40
|
+
* signature - `codesign --verify` then fails with a modification error naming a
|
|
41
|
+
* file nobody touched. It is the ordering mistake that is easiest to make and
|
|
42
|
+
* hardest to see, because it only shows up on a runner that has real signing
|
|
43
|
+
* identities, and this repository has none yet (#2062). So the order is a
|
|
44
|
+
* value that can be asserted rather than a sequence of statements that cannot.
|
|
45
|
+
*
|
|
46
|
+
* The helper gets `com.apple.security.inherit` and the parent the real
|
|
47
|
+
* entitlements, which is why they cannot share one invocation.
|
|
48
|
+
*/
|
|
49
|
+
export declare function signingPlan(input: SigningPlanInput): string[][];
|
|
8
50
|
/**
|
|
9
51
|
* What a failure prints, as a value.
|
|
10
52
|
*
|
|
@@ -58,3 +100,11 @@ export declare interface AppleDesktopConfig {
|
|
|
58
100
|
apiKeyPath: string
|
|
59
101
|
icon?: string
|
|
60
102
|
}
|
|
103
|
+
/** What {@link signingPlan} needs to know; paths are absolute. */
|
|
104
|
+
export declare interface SigningPlanInput {
|
|
105
|
+
identity: string
|
|
106
|
+
appPath: string
|
|
107
|
+
helperPath: string
|
|
108
|
+
appEntitlements: string
|
|
109
|
+
helperEntitlements: string
|
|
110
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import{createHash}from"node:crypto";import{chmodSync,copyFileSync,existsSync,mkdirSync,readFileSync,rmSync,writeFileSync}from"node:fs";import{basename,join,resolve}from"node:path";import process from"node:process";import{log}from"@stacksjs/cli";import{projectPath,storagePath}from"@stacksjs/path";import{resultFailed}from"../result";const REQUIRED_TOOLS=["codesign","security","productbuild","pkgutil","xcrun"];function env(name){return process.env[name]?.trim()||""}function xml(value){return value.replaceAll("&","&").replaceAll("<","<").replaceAll(">",">").replaceAll('"',""").replaceAll("'","'")}function packageMetadata(){const packageJson=JSON.parse(readFileSync(projectPath("package.json"),"utf8"));return{name:packageJson.name||"Stacks",version:packageJson.version||"0.1.0"}}export function resolveAppleDesktopConfig(options={}){const metadata=packageMetadata(),provisioningProfile=options.provisioningProfile||env("APPLE_PROVISIONING_PROFILE"),apiKeyPath=options.apiKeyPath||env("APP_STORE_CONNECT_API_KEY_PATH");return{appName:options.appName||env("APPLE_APP_NAME")||env("APP_NAME")||metadata.name,bundleId:options.bundleId||env("APPLE_BUNDLE_ID"),teamId:options.teamId||env("APPLE_TEAM_ID"),version:options.appVersion||env("APPLE_APP_VERSION")||metadata.version,buildNumber:options.buildNumber||env("APPLE_BUILD_NUMBER")||env("GITHUB_RUN_NUMBER")||String(Math.floor(Date.now()/1000)),minimumMacos:options.minimumMacos||env("APPLE_MINIMUM_MACOS")||"13.0",category:options.category||env("APPLE_APP_CATEGORY")||"public.app-category.productivity",appSigningIdentity:options.appSigningIdentity||env("APPLE_APP_SIGNING_IDENTITY"),installerSigningIdentity:options.installerSigningIdentity||env("APPLE_INSTALLER_SIGNING_IDENTITY"),provisioningProfile:provisioningProfile?resolve(provisioningProfile):"",apiKeyId:options.apiKeyId||env("APP_STORE_CONNECT_API_KEY_ID"),apiIssuerId:options.apiIssuerId||env("APP_STORE_CONNECT_API_ISSUER_ID"),apiKeyPath:apiKeyPath?resolve(apiKeyPath):"",icon:options.icon||env("APPLE_APP_ICON")||void 0}}export function validateAppleDesktopConfig(config,includeApi=!0){const errors=[];if(process.platform!=="darwin")errors.push("Mac App Store packaging must run on macOS");if(!config.appName)errors.push("APPLE_APP_NAME or --app-name is required");if(!/^[A-Za-z0-9][A-Za-z0-9.-]+$/.test(config.bundleId))errors.push("APPLE_BUNDLE_ID must be a reverse-DNS bundle identifier");if(!/^[A-Z0-9]{10}$/.test(config.teamId))errors.push("APPLE_TEAM_ID must be the 10-character Apple Developer team ID");if(!/^\d+(?:\.\d+){0,2}$/.test(config.version))errors.push("The marketing version must contain one to three numeric components");if(!/^[A-Za-z0-9.-]+$/.test(config.buildNumber))errors.push("The build number must contain only letters, numbers, periods, and hyphens");if(!config.appSigningIdentity)errors.push("APPLE_APP_SIGNING_IDENTITY is required");if(!config.installerSigningIdentity)errors.push("APPLE_INSTALLER_SIGNING_IDENTITY is required");if(!config.provisioningProfile||!existsSync(config.provisioningProfile))errors.push("APPLE_PROVISIONING_PROFILE must point to an existing .provisionprofile file");if(config.icon&&!existsSync(resolve(config.icon)))errors.push(`Apple app icon does not exist: ${config.icon}`);if(includeApi){if(!config.apiKeyId)errors.push("APP_STORE_CONNECT_API_KEY_ID is required");if(!config.apiIssuerId)errors.push("APP_STORE_CONNECT_API_ISSUER_ID is required");if(!config.apiKeyPath||!existsSync(config.apiKeyPath))errors.push("APP_STORE_CONNECT_API_KEY_PATH must point to an existing .p8 file")}for(const tool of REQUIRED_TOOLS)if(!Bun.which(tool))errors.push(`Required Apple command is unavailable: ${tool}`);return errors}export function renderInfoPlist(config,executable="stacks-desktop"){const iconEntry=config.icon?`
|
|
1
|
+
import{createHash}from"node:crypto";import{chmodSync,copyFileSync,existsSync,mkdirSync,readdirSync,readFileSync,rmSync,writeFileSync}from"node:fs";import{basename,join,resolve}from"node:path";import process from"node:process";import{log}from"@stacksjs/cli";import{projectPath,storagePath}from"@stacksjs/path";import{resultFailed}from"../result";const REQUIRED_TOOLS=["codesign","security","productbuild","pkgutil","xcrun"];function env(name){return process.env[name]?.trim()||""}function xml(value){return value.replaceAll("&","&").replaceAll("<","<").replaceAll(">",">").replaceAll('"',""").replaceAll("'","'")}function packageMetadata(){const packageJson=JSON.parse(readFileSync(projectPath("package.json"),"utf8"));return{name:packageJson.name||"Stacks",version:packageJson.version||"0.1.0"}}export function resolveAppleDesktopConfig(options={}){const metadata=packageMetadata(),provisioningProfile=options.provisioningProfile||env("APPLE_PROVISIONING_PROFILE"),apiKeyPath=options.apiKeyPath||env("APP_STORE_CONNECT_API_KEY_PATH");return{appName:options.appName||env("APPLE_APP_NAME")||env("APP_NAME")||metadata.name,bundleId:options.bundleId||env("APPLE_BUNDLE_ID"),teamId:options.teamId||env("APPLE_TEAM_ID"),version:options.appVersion||env("APPLE_APP_VERSION")||metadata.version,buildNumber:options.buildNumber||env("APPLE_BUILD_NUMBER")||env("GITHUB_RUN_NUMBER")||String(Math.floor(Date.now()/1000)),minimumMacos:options.minimumMacos||env("APPLE_MINIMUM_MACOS")||"13.0",category:options.category||env("APPLE_APP_CATEGORY")||"public.app-category.productivity",appSigningIdentity:options.appSigningIdentity||env("APPLE_APP_SIGNING_IDENTITY"),installerSigningIdentity:options.installerSigningIdentity||env("APPLE_INSTALLER_SIGNING_IDENTITY"),provisioningProfile:provisioningProfile?resolve(provisioningProfile):"",apiKeyId:options.apiKeyId||env("APP_STORE_CONNECT_API_KEY_ID"),apiIssuerId:options.apiIssuerId||env("APP_STORE_CONNECT_API_ISSUER_ID"),apiKeyPath:apiKeyPath?resolve(apiKeyPath):"",icon:options.icon||env("APPLE_APP_ICON")||void 0}}export function validateAppleDesktopConfig(config,includeApi=!0){const errors=[];if(process.platform!=="darwin")errors.push("Mac App Store packaging must run on macOS");if(!config.appName)errors.push("APPLE_APP_NAME or --app-name is required");if(!/^[A-Za-z0-9][A-Za-z0-9.-]+$/.test(config.bundleId))errors.push("APPLE_BUNDLE_ID must be a reverse-DNS bundle identifier");if(!/^[A-Z0-9]{10}$/.test(config.teamId))errors.push("APPLE_TEAM_ID must be the 10-character Apple Developer team ID");if(!/^\d+(?:\.\d+){0,2}$/.test(config.version))errors.push("The marketing version must contain one to three numeric components");if(!/^[A-Za-z0-9.-]+$/.test(config.buildNumber))errors.push("The build number must contain only letters, numbers, periods, and hyphens");if(!config.appSigningIdentity)errors.push("APPLE_APP_SIGNING_IDENTITY is required");if(!config.installerSigningIdentity)errors.push("APPLE_INSTALLER_SIGNING_IDENTITY is required");if(!config.provisioningProfile||!existsSync(config.provisioningProfile))errors.push("APPLE_PROVISIONING_PROFILE must point to an existing .provisionprofile file");if(config.icon&&!existsSync(resolve(config.icon)))errors.push(`Apple app icon does not exist: ${config.icon}`);if(!/^\d+(?:\.\d+){0,2}$/.test(config.minimumMacos))errors.push("APPLE_MINIMUM_MACOS must be a macOS version such as 13.0");if(!isAppleCategory(config.category))errors.push("APPLE_APP_CATEGORY must be an Apple LSApplicationCategoryType such as public.app-category.productivity");if(includeApi){if(!config.apiKeyId)errors.push("APP_STORE_CONNECT_API_KEY_ID is required");if(!config.apiIssuerId)errors.push("APP_STORE_CONNECT_API_ISSUER_ID is required");if(!config.apiKeyPath||!existsSync(config.apiKeyPath))errors.push("APP_STORE_CONNECT_API_KEY_PATH must point to an existing .p8 file")}for(const tool of REQUIRED_TOOLS)if(!Bun.which(tool))errors.push(`Required Apple command is unavailable: ${tool}`);return errors}function bundleFiles(appPath){const walk=(dir,prefix)=>readdirSync(dir,{withFileTypes:!0}).flatMap((entry)=>{const relative=prefix?`${prefix}/${entry.name}`:entry.name;return entry.isDirectory()?walk(join(dir,entry.name),relative):[relative]});return walk(appPath,"")}export function isAppleCategory(value){return/^public\.app-category\.[a-z][a-z0-9-]*$/.test(value)}export function storeRejectionFindings(paths,executable="stacks-desktop"){const findings=[],has=(path)=>paths.includes(path);for(const required of["Contents/Info.plist",`Contents/MacOS/${executable}`,"Contents/embedded.provisionprofile"])if(!has(required))findings.push(`missing required bundle file: ${required}`);for(const path of paths){const base=path.split("/").pop()??"";if(base===".DS_Store"||path.startsWith("__MACOSX/")||base.startsWith("._"))findings.push(`forbidden file in the bundle: ${path}`);if(/\.(?:dylib|so|framework|app|xpc)$/.test(path)){if(!(path.startsWith("Contents/Frameworks/")||path.startsWith("Contents/MacOS/")||path.startsWith("Contents/XPCServices/")||path.startsWith("Contents/PlugIns/")))findings.push(`nested code outside a signed location: ${path}`)}if(path.startsWith("Contents/Resources/")&&/\.(?:sh|command|dylib|so)$/.test(path))findings.push(`executable content under Resources: ${path}`)}return findings}export function renderInfoPlist(config,executable="stacks-desktop"){const iconEntry=config.icon?`
|
|
2
2
|
<key>CFBundleIconFile</key>
|
|
3
3
|
<string>AppIcon</string>`:"";return`<?xml version="1.0" encoding="UTF-8"?>
|
|
4
4
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "https://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
@@ -93,8 +93,10 @@ jobs:
|
|
|
93
93
|
s3-prefix: \${{ vars.RELEASE_S3_PREFIX }}
|
|
94
94
|
s3-public-url: \${{ vars.RELEASE_S3_PUBLIC_URL }}
|
|
95
95
|
secrets: inherit
|
|
96
|
-
`}function command(args,cwd=projectPath()){const result=Bun.spawnSync(args,{cwd,stdin:"inherit",stdout:"inherit",stderr:"inherit"});if(result.exitCode!==0)throw Error(`${args[0]} ${args.slice(1).join(" ")} exited with code ${result.exitCode}`)}function signingIdentityExists(identity){if(!identity)return!1;const result=Bun.spawnSync(["security","find-identity","-v"],{stdout:"pipe",stderr:"pipe"});return result.exitCode===0&&result.stdout.toString().includes(identity)}function provisioningProfileMatches(config){if(!config.provisioningProfile||!existsSync(config.provisioningProfile))return!1;const result=Bun.spawnSync(["security","cms","-D","-i",config.provisioningProfile],{stdout:"pipe",stderr:"pipe"});if(result.exitCode!==0)return!1;const profile=result.stdout.toString();return profile.includes(config.bundleId)&&profile.includes(config.teamId)}async function buildDesktop(){const{runAction}=await import("@stacksjs/actions"),{Action}=await import("@stacksjs/enums"),result=await runAction(Action.BuildDesktop);if(resultFailed(result))throw result.error}function sha256(path){return createHash("sha256").update(readFileSync(path)).digest("hex")}async function packageAppleDesktop(config,skipBuild=!1){const errors=validateAppleDesktopConfig(config,!1);if(!signingIdentityExists(config.appSigningIdentity))errors.push(`App signing identity is not installed: ${config.appSigningIdentity}`);if(!signingIdentityExists(config.installerSigningIdentity))errors.push(`Installer signing identity is not installed: ${config.installerSigningIdentity}`);if(!provisioningProfileMatches(config))errors.push(`Provisioning profile does not match ${config.teamId}.${config.bundleId}`);if(errors.length)throw Error(errors.join(`
|
|
97
|
-
`));if(!skipBuild)await buildDesktop();const desktopDist=storagePath("framework/desktop-dist"),launcher=join(desktopDist,"stacks-desktop"),runtime=join(desktopDist,"craft-runtime"),manifest=join(desktopDist,"desktop.json");for(const path of[launcher,runtime,manifest])if(!existsSync(path))throw Error(`Desktop build artifact is missing: ${path}`);const appleDir=join(desktopDist,"apple"),appPath=join(appleDir,`${config.appName}.app`),contents=join(appPath,"Contents"),macosDir=join(contents,"MacOS"),resourcesDir=join(contents,"Resources");if(existsSync(appleDir))rmSync(appleDir,{recursive:!0});mkdirSync(macosDir,{recursive:!0});mkdirSync(resourcesDir,{recursive:!0});copyFileSync(launcher,join(macosDir,"stacks-desktop"));copyFileSync(runtime,join(macosDir,"craft-runtime"));copyFileSync(manifest,join(macosDir,"desktop.json"));copyFileSync(config.provisioningProfile,join(contents,"embedded.provisionprofile"));chmodSync(join(macosDir,"stacks-desktop"),493);chmodSync(join(macosDir,"craft-runtime"),493);if(config.icon)copyFileSync(resolve(config.icon),join(resourcesDir,"AppIcon.icns"));const infoPlist=join(contents,"Info.plist"),appEntitlements=join(appleDir,"app.entitlements"),helperEntitlements=join(appleDir,"helper.entitlements");writeFileSync(infoPlist,renderInfoPlist(config));writeFileSync(appEntitlements,renderAppEntitlements(config));writeFileSync(helperEntitlements,renderHelperEntitlements());command(["plutil","-lint",infoPlist,appEntitlements,helperEntitlements]);
|
|
96
|
+
`}function command(args,cwd=projectPath()){const result=Bun.spawnSync(args,{cwd,stdin:"inherit",stdout:"inherit",stderr:"inherit"});if(result.exitCode!==0)throw Error(`${args[0]} ${args.slice(1).join(" ")} exited with code ${result.exitCode}`)}export function signingPlan(input){const sign=(entitlements,target)=>["codesign","--force","--timestamp","--options","runtime","--entitlements",entitlements,"--sign",input.identity,target];return[sign(input.helperEntitlements,input.helperPath),sign(input.appEntitlements,input.appPath)]}function signingIdentityExists(identity){if(!identity)return!1;const result=Bun.spawnSync(["security","find-identity","-v"],{stdout:"pipe",stderr:"pipe"});return result.exitCode===0&&result.stdout.toString().includes(identity)}function provisioningProfileMatches(config){if(!config.provisioningProfile||!existsSync(config.provisioningProfile))return!1;const result=Bun.spawnSync(["security","cms","-D","-i",config.provisioningProfile],{stdout:"pipe",stderr:"pipe"});if(result.exitCode!==0)return!1;const profile=result.stdout.toString();return profile.includes(config.bundleId)&&profile.includes(config.teamId)}async function buildDesktop(){const{runAction}=await import("@stacksjs/actions"),{Action}=await import("@stacksjs/enums"),result=await runAction(Action.BuildDesktop);if(resultFailed(result))throw result.error}function sha256(path){return createHash("sha256").update(readFileSync(path)).digest("hex")}async function packageAppleDesktop(config,skipBuild=!1){const errors=validateAppleDesktopConfig(config,!1);if(!signingIdentityExists(config.appSigningIdentity))errors.push(`App signing identity is not installed: ${config.appSigningIdentity}`);if(!signingIdentityExists(config.installerSigningIdentity))errors.push(`Installer signing identity is not installed: ${config.installerSigningIdentity}`);if(!provisioningProfileMatches(config))errors.push(`Provisioning profile does not match ${config.teamId}.${config.bundleId}`);if(errors.length)throw Error(errors.join(`
|
|
97
|
+
`));if(!skipBuild)await buildDesktop();const desktopDist=storagePath("framework/desktop-dist"),launcher=join(desktopDist,"stacks-desktop"),runtime=join(desktopDist,"craft-runtime"),manifest=join(desktopDist,"desktop.json");for(const path of[launcher,runtime,manifest])if(!existsSync(path))throw Error(`Desktop build artifact is missing: ${path}`);const appleDir=join(desktopDist,"apple"),appPath=join(appleDir,`${config.appName}.app`),contents=join(appPath,"Contents"),macosDir=join(contents,"MacOS"),resourcesDir=join(contents,"Resources");if(existsSync(appleDir))rmSync(appleDir,{recursive:!0});mkdirSync(macosDir,{recursive:!0});mkdirSync(resourcesDir,{recursive:!0});copyFileSync(launcher,join(macosDir,"stacks-desktop"));copyFileSync(runtime,join(macosDir,"craft-runtime"));copyFileSync(manifest,join(macosDir,"desktop.json"));copyFileSync(config.provisioningProfile,join(contents,"embedded.provisionprofile"));chmodSync(join(macosDir,"stacks-desktop"),493);chmodSync(join(macosDir,"craft-runtime"),493);if(config.icon)copyFileSync(resolve(config.icon),join(resourcesDir,"AppIcon.icns"));const infoPlist=join(contents,"Info.plist"),appEntitlements=join(appleDir,"app.entitlements"),helperEntitlements=join(appleDir,"helper.entitlements");writeFileSync(infoPlist,renderInfoPlist(config));writeFileSync(appEntitlements,renderAppEntitlements(config));writeFileSync(helperEntitlements,renderHelperEntitlements());command(["plutil","-lint",infoPlist,appEntitlements,helperEntitlements]);for(const args of signingPlan({identity:config.appSigningIdentity,appPath,helperPath:join(macosDir,"craft-runtime"),appEntitlements,helperEntitlements}))command(args);command(["codesign","--verify","--deep","--strict","--verbose=2",appPath]);command(["codesign","-d","--entitlements",":-",appPath]);const findings=storeRejectionFindings(bundleFiles(appPath));if(findings.length>0)throw Error(`The app bundle would be rejected by App Review:
|
|
98
|
+
${findings.map((finding)=>` - ${finding}`).join(`
|
|
99
|
+
`)}`);const packagePath=join(appleDir,`${config.appName}-${config.version}-${config.buildNumber}.pkg`);command(["productbuild","--component",appPath,"/Applications","--sign",config.installerSigningIdentity,packagePath]);command(["pkgutil","--check-signature",packagePath]);const packageHash=sha256(packagePath);writeFileSync(join(appleDir,"checksums.sha256"),`${packageHash} ${basename(packagePath)}
|
|
98
100
|
`);writeFileSync(join(appleDir,"apple-provenance.json"),`${JSON.stringify({schemaVersion:"1.0.0",appName:config.appName,bundleId:config.bundleId,teamId:config.teamId,version:config.version,buildNumber:config.buildNumber,minimumMacos:config.minimumMacos,package:{name:basename(packagePath),sha256:packageHash},sourceRevision:Bun.spawnSync(["git","rev-parse","HEAD"],{cwd:projectPath()}).stdout.toString().trim(),craft:{sha256:sha256(join(macosDir,"craft-runtime"))}},null,2)}
|
|
99
101
|
`);return packagePath}function validateOrUpload(packagePath,config,validateOnly){const errors=validateAppleDesktopConfig(config,!0);if(errors.length)throw Error(errors.join(`
|
|
100
102
|
`));const common=["--type","macos","--file",packagePath,"--apiKey",config.apiKeyId,"--apiIssuer",config.apiIssuerId];process.env.API_PRIVATE_KEYS_DIR=resolve(config.apiKeyPath,"..");command(["xcrun","altool","--validate-app",...common]);if(!validateOnly)command(["xcrun","altool","--upload-app",...common])}export function renderFailure(error){return`${error instanceof Error?error.message:String(error)}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import{readFileSync,writeFileSync}from"node:fs";import{join}from"node:path";import process from"node:process";import{capabilityRegistry}from"../../../../config/src/capabilities";import{assertFrameworkRepo}from"./framework-repo";const root=new URL("../../../../../../../",import.meta.url).pathname,page=join(root,"docs/features/capabilities.md"),BEGIN="<!-- capability-matrix:begin -->",END="<!-- capability-matrix:end -->",CATEGORIES=[["database","Database"],["queue","Queue"],["cache","Cache"],["storage","Storage"],["mail","Mail"],["realtime","Realtime"],["deploy","Deploy"]],STATUS_NOTE={supported:"Contract evidence runs in CI.",partial:"Works, with the gaps named below.",experimental:"Usable, but without retained evidence.",unsupported:"Cannot be selected; configuring it fails loudly."};function row(driver){const evidence=driver.testEvidence.length===0?"none retained":driver.testEvidence.map((path)=>`\`${path.split("/").pop()}\``).join("<br>"),live=driver.liveServiceContract?`${driver.liveServiceContract.service} ${driver.liveServiceContract.version}`:"-";return`| \`${driver.name}\` | **${driver.status}** | ${driver.topology} | ${live} | ${evidence} |`}function section(category,heading){const drivers=capabilityRegistry.filter((driver)=>driver.category===category);if(drivers.length===0)return[];const limitations=drivers.flatMap((driver)=>driver.limitations.map((limitation)=>`- **\`${driver.name}\`** - ${limitation}`));return[`### ${heading}`,"","| Driver | Status | Topology | Live service | Evidence |","|---|---|---|---|---|",...drivers.map(row),"",...limitations.length>0?["**Limitations**","",...limitations,""]:[]]}export function render(){const statuses=Object.entries(STATUS_NOTE).map(([status,note])=>`- **${status}** - ${note}`);return[BEGIN,"","<!-- Generated by `buddy docs:capabilities` from `@stacksjs/config`'s"," `capabilityRegistry`. Edit that, not this. -->","",...statuses,"",...CATEGORIES.flatMap(([category,heading])=>section(category,heading)),END].join(`
|
|
2
|
+
`)}function current(){let contents;try{contents=readFileSync(page,"utf8")}catch{return null}const start=contents.indexOf(BEGIN),end=contents.indexOf(END);if(start===-1||end===-1)return null;return contents.slice(start,end+END.length)}function write(){const contents=readFileSync(page,"utf8"),start=contents.indexOf(BEGIN),end=contents.indexOf(END);if(start===-1||end===-1)throw Error(`[docs:capabilities] ${page} is missing the ${BEGIN} / ${END} markers`);const next=contents.slice(0,start)+render()+contents.slice(end+END.length);if(next===contents)return!1;writeFileSync(page,next);return!0}export async function run(){assertFrameworkRepo(root,"docs:capabilities");if(process.argv.includes("--write")){console.log(write()?"\u2713 rewrote the capability matrix in docs/features/capabilities.md":"\u2713 the capability matrix was already current");return}if(current()===render()){console.log(`\u2713 the capability matrix is current (${capabilityRegistry.length} drivers)`);return}console.error(current()===null?"\u2717 docs/features/capabilities.md is missing its generated matrix section":"\u2717 docs/features/capabilities.md no longer matches capabilityRegistry");console.error("\nRun `buddy docs:capabilities` to rewrite it from the registry.");if(process.argv.includes("--check"))process.exit(1)}if(import.meta.main)await run();
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every import statement inside a TypeScript block in `contents`.
|
|
3
|
+
*
|
|
4
|
+
* Multi-line imports are joined: `import {\n a,\n} from 'x'` is one statement,
|
|
5
|
+
* and reading it a line at a time would report the first line as a syntax
|
|
6
|
+
* error. The scan gives up after twelve lines rather than running to the end of
|
|
7
|
+
* a document that never closes the statement.
|
|
8
|
+
*/
|
|
9
|
+
export declare function extractImports(file: string, contents: string): DocsImport[];
|
|
10
|
+
export declare function collectImports(): DocsImport[];
|
|
11
|
+
/**
|
|
12
|
+
* A stable identity for one finding, so a baseline survives edits elsewhere in
|
|
13
|
+
* the same document.
|
|
14
|
+
*
|
|
15
|
+
* The line number is deliberately NOT part of it: adding a paragraph above a
|
|
16
|
+
* sample would otherwise retire every baseline entry below it and report the
|
|
17
|
+
* same rot as new.
|
|
18
|
+
*/
|
|
19
|
+
export declare function findingKey(finding: Pick<SnippetFinding, 'file' | 'specifier' | 'message'>): string;
|
|
20
|
+
/** Whether a failure to resolve this specifier is worth reporting. */
|
|
21
|
+
export declare function inScope(specifier: string, code: string): boolean;
|
|
22
|
+
/**`,
|
|
23
|
+
* which is exactly where these files are written.
|
|
24
|
+
*/
|
|
25
|
+
export declare function generatedTsconfig(): Record<string, unknown>;
|
|
26
|
+
export declare function run(): Promise<void>;
|
|
27
|
+
/** One import statement, and where a reader would find it. */
|
|
28
|
+
export declare interface DocsImport {
|
|
29
|
+
file: string
|
|
30
|
+
line: number
|
|
31
|
+
text: string
|
|
32
|
+
specifier: string
|
|
33
|
+
}
|
|
34
|
+
/** A compile failure, traced back to the documentation that caused it. */
|
|
35
|
+
export declare interface SnippetFinding {
|
|
36
|
+
file: string
|
|
37
|
+
line: number
|
|
38
|
+
specifier: string
|
|
39
|
+
code: string
|
|
40
|
+
message: string
|
|
41
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import{mkdirSync,readdirSync,readFileSync,rmSync,writeFileSync}from"node:fs";import{createHash}from"node:crypto";import{join}from"node:path";import process from"node:process";import{assertFrameworkRepo}from"./framework-repo";const root=new URL("../../../../../../../",import.meta.url).pathname,docsDir=join(root,"docs"),workDir=join(root,"storage/framework/runtime/docs-imports"),baselinePath=join(root,"docs/.snippet-imports-baseline.json"),FENCE=/^```(?:ts|typescript)\b/;export function extractImports(file,contents){const found=[],lines=contents.split(`
|
|
2
|
+
`);let inside=!1,pending=null;const specifierOf=(text)=>text.match(/from\s+['"]([^'"]+)['"]/)?.[1]??text.match(/^\s*import\s+['"]([^'"]+)['"]/)?.[1],finish=(entry)=>{const specifier=specifierOf(entry.text);if(specifier&&!specifier.startsWith("."))found.push({file,line:entry.line,text:entry.text.trim(),specifier})};for(const[index,raw]of lines.entries()){if(raw.startsWith("```")){inside=!inside&&FENCE.test(raw.trim());pending=null;continue}if(!inside)continue;if(pending){pending.text+=`
|
|
3
|
+
${raw.trimEnd()}`;if(specifierOf(pending.text)){finish(pending);pending=null}else if(pending.text.split(`
|
|
4
|
+
`).length>12)pending=null;continue}if(!/^\s*import\s/.test(raw))continue;const entry={line:index+1,text:raw.trimEnd()};if(specifierOf(entry.text))finish(entry);else pending=entry}return found}function markdownFiles(dir){return readdirSync(dir,{withFileTypes:!0}).sort((a,b)=>a.name.localeCompare(b.name)).flatMap((entry)=>{const full=join(dir,entry.name);if(entry.isDirectory())return markdownFiles(full);return entry.name.endsWith(".md")?[full]:[]})}export function collectImports(){return markdownFiles(docsDir).flatMap((file)=>extractImports(file.slice(root.length),readFileSync(file,"utf8")))}export function findingKey(finding){return`${finding.file} :: ${finding.specifier} :: ${finding.message}`}export function inScope(specifier,code){if(code!=="TS2307")return!0;return specifier.startsWith("@stacksjs/")}export function generatedTsconfig(){return{extends:"../../tsconfig.framework.json",compilerOptions:{noEmit:!0,skipLibCheck:!0,noUnusedLocals:!1,isolatedDeclarations:!1,verbatimModuleSyntax:!1},include:["./*.js"],exclude:[]}}function compile(imports){rmSync(workDir,{force:!0,recursive:!0});mkdirSync(workDir,{recursive:!0});const byName=new Map;for(const entry of imports){const name=`i${createHash("sha1").update(entry.text).digest("hex").slice(0,16)}`,existing=byName.get(name);if(existing){existing.push(entry);continue}byName.set(name,[entry]);writeFileSync(join(workDir,`${name}.ts`),`${entry.text}
|
|
5
|
+
export {}
|
|
6
|
+
`)}writeFileSync(join(workDir,"tsconfig.json"),`${JSON.stringify(generatedTsconfig(),null,2)}
|
|
7
|
+
`);const proc=Bun.spawnSync(["bunx","--bun","tsc","--noEmit","-p",workDir],{cwd:root,stdout:"pipe",stderr:"pipe"}),output=`${proc.stdout.toString()}${proc.stderr.toString()}`,findings=[];for(const line of output.split(`
|
|
8
|
+
`)){const match=line.match(/docs-imports\/(i[0-9a-f]+)\.ts.*?error (TS\d+): (.*)$/);if(!match)continue;const occurrences=byName.get(match[1]);if(!occurrences||occurrences.length===0)continue;if(!inScope(occurrences[0].specifier,match[2]))continue;for(const entry of occurrences)findings.push({file:entry.file,line:entry.line,specifier:entry.specifier,code:match[2],message:match[3].trim()})}rmSync(workDir,{force:!0,recursive:!0});return findings}function readBaseline(){try{return JSON.parse(readFileSync(baselinePath,"utf8")).known??[]}catch{return[]}}export async function run(){assertFrameworkRepo(root,"docs:snippets");const imports=collectImports(),findings=compile(imports),baseline=new Set(readBaseline()),keys=findings.map(findingKey);if(process.argv.includes("--write")){writeFileSync(baselinePath,`${JSON.stringify({comment:"Known docs-import failures. Run `buddy docs:snippets` after fixing some, and commit the shorter list. See stacksjs/stacks#2580.",known:[...new Set(keys)].sort()},null,2)}
|
|
9
|
+
`);console.log(`\u2713 recorded ${new Set(keys).size} known docs-import failure(s) across ${imports.length} import(s)`);return}const fresh=findings.filter((finding)=>!baseline.has(findingKey(finding))),fixed=[...baseline].filter((entry)=>!new Set(keys).has(entry));if(fresh.length===0){const trailer=fixed.length>0?` (${fixed.length} baselined failure(s) now fixed - run \`buddy docs:snippets\` to shorten the baseline)`:"";console.log(`\u2713 no new docs-import failures across ${imports.length} import(s)${trailer}`);return}console.error(`\u2717 ${fresh.length} documented import(s) do not compile:`);for(const finding of fresh)console.error(` ${finding.file}:${finding.line} ${finding.specifier}
|
|
10
|
+
${finding.message}`);console.error("\nFix the sample, or run `buddy docs:snippets` to record it if it is expected.");if(process.argv.includes("--check"))process.exit(1)}if(import.meta.main)await run();
|
package/dist/commands/docs.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{run as runAgentCounts}from"./docs/agent-counts";import{run as runArtifacts}from"./docs/generated-artifacts";import{run as runBuddyDocs}from"./docs/buddy-commands";import{run as runDesktopMatrix}from"./docs/desktop-matrix";import{run as runLinks}from"./docs/links";import{runTool}from"./run-tool";export function docs(buddy){buddy.command("docs:buddy","Regenerate the buddy command reference doc").action(async()=>{await runTool(runBuddyDocs,"--write")});buddy.command("docs:buddy:check","Verify the buddy command reference doc is current").action(async()=>{await runTool(runBuddyDocs,"--check")});buddy.command("docs:artifacts","Regenerate the generated API artifacts (OpenAPI + types)").action(async()=>{await runTool(runArtifacts,"--write")});buddy.command("docs:artifacts:check","Verify the generated API artifacts are current").action(async()=>{await runTool(runArtifacts,"--check")});buddy.command("docs:agent-counts","Rewrite the counts AGENTS.md and the skills state, from the tree").action(async()=>{await runTool(runAgentCounts,"--write")});buddy.command("docs:agent-counts:check","Verify the counts AGENTS.md and the skills state are current").action(async()=>{await runTool(runAgentCounts,"--check")});buddy.command("docs:desktop-matrix","Regenerate the desktop support matrix in docs/packages/desktop.md").action(async()=>{await runTool(runDesktopMatrix,"--write")});buddy.command("docs:desktop-matrix:check","Verify the published desktop support matrix matches its source").action(async()=>{await runTool(runDesktopMatrix,"--check")});buddy.command("docs:links","Report internal documentation links").action(async()=>{await runTool(runLinks)});buddy.command("docs:links:check","Verify internal documentation links resolve").action(async()=>{await runTool(runLinks,"--check")})}
|
|
1
|
+
import{run as runAgentCounts}from"./docs/agent-counts";import{run as runArtifacts}from"./docs/generated-artifacts";import{run as runBuddyDocs}from"./docs/buddy-commands";import{run as runCapabilities}from"./docs/capabilities";import{run as runSnippets}from"./docs/snippets";import{run as runDesktopMatrix}from"./docs/desktop-matrix";import{run as runLinks}from"./docs/links";import{runTool}from"./run-tool";export function docs(buddy){buddy.command("docs:buddy","Regenerate the buddy command reference doc").action(async()=>{await runTool(runBuddyDocs,"--write")});buddy.command("docs:buddy:check","Verify the buddy command reference doc is current").action(async()=>{await runTool(runBuddyDocs,"--check")});buddy.command("docs:artifacts","Regenerate the generated API artifacts (OpenAPI + types)").action(async()=>{await runTool(runArtifacts,"--write")});buddy.command("docs:artifacts:check","Verify the generated API artifacts are current").action(async()=>{await runTool(runArtifacts,"--check")});buddy.command("docs:agent-counts","Rewrite the counts AGENTS.md and the skills state, from the tree").action(async()=>{await runTool(runAgentCounts,"--write")});buddy.command("docs:agent-counts:check","Verify the counts AGENTS.md and the skills state are current").action(async()=>{await runTool(runAgentCounts,"--check")});buddy.command("docs:capabilities","Regenerate the driver capability matrix in docs/features/capabilities.md").action(async()=>{await runTool(runCapabilities,"--write")});buddy.command("docs:capabilities:check","Verify the published capability matrix matches its registry").action(async()=>{await runTool(runCapabilities,"--check")});buddy.command("docs:desktop-matrix","Regenerate the desktop support matrix in docs/packages/desktop.md").action(async()=>{await runTool(runDesktopMatrix,"--write")});buddy.command("docs:desktop-matrix:check","Verify the published desktop support matrix matches its source").action(async()=>{await runTool(runDesktopMatrix,"--check")});buddy.command("docs:snippets","Record the documented imports that do not compile, as the baseline").action(async()=>{await runTool(runSnippets,"--write")});buddy.command("docs:snippets:check","Verify no NEW documented import fails to compile").action(async()=>{await runTool(runSnippets,"--check")});buddy.command("docs:links","Report internal documentation links").action(async()=>{await runTool(runLinks)});buddy.command("docs:links:check","Verify internal documentation links resolve").action(async()=>{await runTool(runLinks,"--check")})}
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@stacksjs/buddy",
|
|
3
3
|
"type": "module",
|
|
4
4
|
"sideEffects": false,
|
|
5
|
-
"version": "0.74.
|
|
5
|
+
"version": "0.74.36",
|
|
6
6
|
"description": "Meet Buddy. The Stacks runtime.",
|
|
7
7
|
"author": "Chris Breuer",
|
|
8
8
|
"contributors": [
|
|
@@ -95,66 +95,66 @@
|
|
|
95
95
|
"prepublishOnly": "bun run build"
|
|
96
96
|
},
|
|
97
97
|
"dependencies": {
|
|
98
|
-
"@stacksjs/actions": "^0.74.
|
|
99
|
-
"@stacksjs/ai": "^0.74.
|
|
100
|
-
"@stacksjs/alias": "^0.74.
|
|
101
|
-
"@stacksjs/analytics": "^0.74.
|
|
102
|
-
"@stacksjs/api": "^0.74.
|
|
103
|
-
"@stacksjs/arrays": "^0.74.
|
|
104
|
-
"@stacksjs/auth": "^0.74.
|
|
105
|
-
"@stacksjs/browser-extension": "^0.74.
|
|
106
|
-
"@stacksjs/build": "^0.74.
|
|
107
|
-
"@stacksjs/cache": "^0.74.
|
|
108
|
-
"@stacksjs/chat": "^0.74.
|
|
98
|
+
"@stacksjs/actions": "^0.74.36",
|
|
99
|
+
"@stacksjs/ai": "^0.74.36",
|
|
100
|
+
"@stacksjs/alias": "^0.74.36",
|
|
101
|
+
"@stacksjs/analytics": "^0.74.36",
|
|
102
|
+
"@stacksjs/api": "^0.74.36",
|
|
103
|
+
"@stacksjs/arrays": "^0.74.36",
|
|
104
|
+
"@stacksjs/auth": "^0.74.36",
|
|
105
|
+
"@stacksjs/browser-extension": "^0.74.36",
|
|
106
|
+
"@stacksjs/build": "^0.74.36",
|
|
107
|
+
"@stacksjs/cache": "^0.74.36",
|
|
108
|
+
"@stacksjs/chat": "^0.74.36",
|
|
109
109
|
"@stacksjs/clapp": "^0.2.12",
|
|
110
|
-
"@stacksjs/cli": "^0.74.
|
|
111
|
-
"@stacksjs/cloud": "^0.74.
|
|
112
|
-
"@stacksjs/cms": "^0.74.
|
|
113
|
-
"@stacksjs/collections": "^0.74.
|
|
114
|
-
"@stacksjs/config": "^0.74.
|
|
115
|
-
"@stacksjs/database": "^0.74.
|
|
116
|
-
"@stacksjs/desktop-build": "^0.74.
|
|
117
|
-
"@stacksjs/dns": "^0.74.
|
|
110
|
+
"@stacksjs/cli": "^0.74.36",
|
|
111
|
+
"@stacksjs/cloud": "^0.74.36",
|
|
112
|
+
"@stacksjs/cms": "^0.74.36",
|
|
113
|
+
"@stacksjs/collections": "^0.74.36",
|
|
114
|
+
"@stacksjs/config": "^0.74.36",
|
|
115
|
+
"@stacksjs/database": "^0.74.36",
|
|
116
|
+
"@stacksjs/desktop-build": "^0.74.36",
|
|
117
|
+
"@stacksjs/dns": "^0.74.36",
|
|
118
118
|
"@stacksjs/dnsx": "^0.2.3",
|
|
119
|
-
"@stacksjs/email": "^0.74.
|
|
120
|
-
"@stacksjs/enums": "^0.74.
|
|
121
|
-
"@stacksjs/env": "^0.74.
|
|
122
|
-
"@stacksjs/error-handling": "^0.74.
|
|
123
|
-
"@stacksjs/events": "^0.74.
|
|
124
|
-
"@stacksjs/features": "^0.74.
|
|
125
|
-
"@stacksjs/git": "^0.74.
|
|
119
|
+
"@stacksjs/email": "^0.74.36",
|
|
120
|
+
"@stacksjs/enums": "^0.74.36",
|
|
121
|
+
"@stacksjs/env": "^0.74.36",
|
|
122
|
+
"@stacksjs/error-handling": "^0.74.36",
|
|
123
|
+
"@stacksjs/events": "^0.74.36",
|
|
124
|
+
"@stacksjs/features": "^0.74.36",
|
|
125
|
+
"@stacksjs/git": "^0.74.36",
|
|
126
126
|
"@stacksjs/gitit": "^0.2.5",
|
|
127
|
-
"@stacksjs/health": "^0.74.
|
|
127
|
+
"@stacksjs/health": "^0.74.36",
|
|
128
128
|
"@stacksjs/httx": "^0.1.10",
|
|
129
|
-
"@stacksjs/image": "^0.74.
|
|
130
|
-
"@stacksjs/lint": "^0.74.
|
|
131
|
-
"@stacksjs/logging": "^0.74.
|
|
132
|
-
"@stacksjs/notifications": "^0.74.
|
|
133
|
-
"@stacksjs/objects": "^0.74.
|
|
134
|
-
"@stacksjs/orm": "^0.74.
|
|
135
|
-
"@stacksjs/path": "^0.74.
|
|
136
|
-
"@stacksjs/payments": "^0.74.
|
|
137
|
-
"@stacksjs/realtime": "^0.74.
|
|
138
|
-
"@stacksjs/router": "^0.74.
|
|
129
|
+
"@stacksjs/image": "^0.74.36",
|
|
130
|
+
"@stacksjs/lint": "^0.74.36",
|
|
131
|
+
"@stacksjs/logging": "^0.74.36",
|
|
132
|
+
"@stacksjs/notifications": "^0.74.36",
|
|
133
|
+
"@stacksjs/objects": "^0.74.36",
|
|
134
|
+
"@stacksjs/orm": "^0.74.36",
|
|
135
|
+
"@stacksjs/path": "^0.74.36",
|
|
136
|
+
"@stacksjs/payments": "^0.74.36",
|
|
137
|
+
"@stacksjs/realtime": "^0.74.36",
|
|
138
|
+
"@stacksjs/router": "^0.74.36",
|
|
139
139
|
"@stacksjs/rpx": "^0.11.53",
|
|
140
|
-
"@stacksjs/scheduler": "^0.74.
|
|
141
|
-
"@stacksjs/search-engine": "^0.74.
|
|
142
|
-
"@stacksjs/security": "^0.74.
|
|
143
|
-
"@stacksjs/server": "^0.74.
|
|
144
|
-
"@stacksjs/sites": "^0.74.
|
|
145
|
-
"@stacksjs/skills": "^0.74.
|
|
146
|
-
"@stacksjs/storage": "^0.74.
|
|
147
|
-
"@stacksjs/strings": "^0.74.
|
|
140
|
+
"@stacksjs/scheduler": "^0.74.36",
|
|
141
|
+
"@stacksjs/search-engine": "^0.74.36",
|
|
142
|
+
"@stacksjs/security": "^0.74.36",
|
|
143
|
+
"@stacksjs/server": "^0.74.36",
|
|
144
|
+
"@stacksjs/sites": "^0.74.36",
|
|
145
|
+
"@stacksjs/skills": "^0.74.36",
|
|
146
|
+
"@stacksjs/storage": "^0.74.36",
|
|
147
|
+
"@stacksjs/strings": "^0.74.36",
|
|
148
148
|
"@stacksjs/stx": "^0.2.274",
|
|
149
|
-
"@stacksjs/testing": "^0.74.
|
|
150
|
-
"@stacksjs/tinker": "^0.74.
|
|
149
|
+
"@stacksjs/testing": "^0.74.36",
|
|
150
|
+
"@stacksjs/tinker": "^0.74.36",
|
|
151
151
|
"@stacksjs/tlsx": "^0.13.19",
|
|
152
|
-
"@stacksjs/ts-cloud": "^0.
|
|
153
|
-
"@stacksjs/tunnel": "^0.74.
|
|
154
|
-
"@stacksjs/types": "^0.74.
|
|
155
|
-
"@stacksjs/ui": "^0.74.
|
|
156
|
-
"@stacksjs/utils": "^0.74.
|
|
157
|
-
"@stacksjs/validation": "^0.74.
|
|
152
|
+
"@stacksjs/ts-cloud": "^0.14.0",
|
|
153
|
+
"@stacksjs/tunnel": "^0.74.36",
|
|
154
|
+
"@stacksjs/types": "^0.74.36",
|
|
155
|
+
"@stacksjs/ui": "^0.74.36",
|
|
156
|
+
"@stacksjs/utils": "^0.74.36",
|
|
157
|
+
"@stacksjs/validation": "^0.74.36",
|
|
158
158
|
"ajv": "^8.20.0",
|
|
159
159
|
"ajv-formats": "^3.0.1",
|
|
160
160
|
"bun-plugin-stx": "^0.2.279",
|