@stacksjs/buddy 0.74.33 → 0.74.35
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 +21 -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/desktop-matrix.d.ts +2 -0
- package/dist/commands/docs/desktop-matrix.js +2 -0
- package/dist/commands/docs/snippets.d.ts +37 -0
- package/dist/commands/docs/snippets.js +10 -0
- package/dist/commands/docs.js +1 -1
- package/dist/commands/env.js +1 -1
- package/dist/commands/share.js +1 -1
- package/dist/deploy-notify.js +1 -1
- package/package.json +55 -55
|
@@ -633,6 +633,16 @@ export declare function dnsProviderNameFromNameservers(nameservers: string[]): '
|
|
|
633
633
|
* implementation of "which names does this site publish, and at which
|
|
634
634
|
* registrar" is the drift this function exists to prevent.
|
|
635
635
|
*/
|
|
636
|
+
/**
|
|
637
|
+
* Point the registrar at `dns.provider`, when the project says to.
|
|
638
|
+
*
|
|
639
|
+
* The whole operation lives in ts-cloud (`delegateZoneFromConfig`); this is
|
|
640
|
+
* only the deploy's call site and its logging. Every inapplicable case — no
|
|
641
|
+
* registrar declared, a runner without the credentials, an explicit opt-out —
|
|
642
|
+
* comes back as `skipped` rather than throwing, because none of them is a
|
|
643
|
+
* reason to fail a deployment that is otherwise fine.
|
|
644
|
+
*/
|
|
645
|
+
export declare function delegateDeclaredZone(config: TsCloudConfig | null | undefined, logger: typeof log): Promise<void>;
|
|
636
646
|
export declare function reconcileHetznerDns(sites: Record<string, any>, ip: string, logger: typeof log, ipv6?: string, autoWww?: boolean): Promise<string[]>;
|
|
637
647
|
export declare function deploy(buddy: CLI): void;
|
|
638
648
|
/**
|
|
@@ -688,7 +698,17 @@ export declare interface TsCloudInfrastructure {
|
|
|
688
698
|
cdn?: { provider?: string }
|
|
689
699
|
}
|
|
690
700
|
}
|
|
691
|
-
dns?: {
|
|
701
|
+
dns?: {
|
|
702
|
+
domain?: string
|
|
703
|
+
provider?: string
|
|
704
|
+
hostedZoneId?: string
|
|
705
|
+
registrar?: {
|
|
706
|
+
provider?: 'porkbun' | 'godaddy'
|
|
707
|
+
proxied?: string[]
|
|
708
|
+
dryRun?: boolean
|
|
709
|
+
delegate?: boolean
|
|
710
|
+
}
|
|
711
|
+
}
|
|
692
712
|
}
|
|
693
713
|
export declare interface TsCloudConfig {
|
|
694
714
|
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{delegateZoneFromConfig,describeDelegation}=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.")}catch(error){logger.warn(`DNS: delegation 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,2 @@
|
|
|
1
|
+
import{readFileSync,writeFileSync}from"node:fs";import{join}from"node:path";import process from"node:process";import{desktopSupportMatrix}from"../../../../desktop/src/support";import{assertFrameworkRepo}from"./framework-repo";const root=new URL("../../../../../../../",import.meta.url).pathname,page=join(root,"docs/packages/desktop.md"),BEGIN="<!-- desktop-support-matrix:begin -->",END="<!-- desktop-support-matrix:end -->",PLATFORM_NAMES={darwin:"macOS",linux:"Linux",win32:"Windows"};function link(url){const issue=url.match(/github\.com\/([^/]+\/[^/]+)\/issues\/(\d+)/);if(issue)return`[${issue[1]}#${issue[2]}](${url})`;const run=url.match(/actions\/runs\/(\d+)/);if(run)return`[run ${run[1]}](${url})`;return`[evidence](${url})`}function row(entry){return`| ${[`${PLATFORM_NAMES[entry.platform]} ${entry.architecture}`,`**${entry.status}**`,entry.osVersions.join(", "),entry.packageFormat,entry.signing,entry.notarization,entry.installLaunchEvidence?link(entry.installLaunchEvidence):"none",entry.blockingIssues.map(link).join(", ")||"none"].join(" | ")} |`}export function render(){const header=["| Target | Status | Tested on | Package | Signing | Notarization | Install + launch | Blocked by |","|---|---|---|---|---|---|---|---|"],limitations=desktopSupportMatrix.flatMap((entry)=>entry.limitations.map((limitation)=>`- **${PLATFORM_NAMES[entry.platform]} ${entry.architecture}** - ${limitation}`)),unique=[...new Set(limitations)];return[BEGIN,"","<!-- Generated by `buddy docs:desktop-matrix` from `@stacksjs/desktop`'s"," `desktopSupportMatrix`. Edit that, not this. -->","",...header,...desktopSupportMatrix.map(row),"","### Known limitations","",...unique,"",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:desktop-matrix] ${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:desktop-matrix");if(process.argv.includes("--write")){console.log(write()?"\u2713 rewrote the desktop support matrix in docs/packages/desktop.md":"\u2713 the desktop support matrix was already current");return}const published=current();if(published===render()){console.log(`\u2713 the desktop support matrix is current (${desktopSupportMatrix.length} targets)`);return}console.error(published===null?"\u2717 docs/packages/desktop.md is missing its generated support-matrix section":"\u2717 docs/packages/desktop.md no longer matches desktopSupportMatrix");console.error("\nRun `buddy docs:desktop-matrix` to rewrite it from the source.");if(process.argv.includes("--check"))process.exit(1)}if(import.meta.main)await run();
|
|
@@ -0,0 +1,37 @@
|
|
|
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
|
+
export declare function run(): Promise<void>;
|
|
23
|
+
/** One import statement, and where a reader would find it. */
|
|
24
|
+
export declare interface DocsImport {
|
|
25
|
+
file: string
|
|
26
|
+
line: number
|
|
27
|
+
text: string
|
|
28
|
+
specifier: string
|
|
29
|
+
}
|
|
30
|
+
/** A compile failure, traced back to the documentation that caused it. */
|
|
31
|
+
export declare interface SnippetFinding {
|
|
32
|
+
file: string
|
|
33
|
+
line: number
|
|
34
|
+
specifier: string
|
|
35
|
+
code: string
|
|
36
|
+
message: string
|
|
37
|
+
}
|
|
@@ -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}).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/")}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)}`;if(byName.has(name))continue;byName.set(name,entry);writeFileSync(join(workDir,`${name}.ts`),`${entry.text}
|
|
5
|
+
export {}
|
|
6
|
+
`)}writeFileSync(join(workDir,"tsconfig.json"),`${JSON.stringify({extends:"../../tsconfig.base.json",compilerOptions:{noEmit:!0,skipLibCheck:!0,noUnusedLocals:!1,isolatedDeclarations:!1,verbatimModuleSyntax:!1},include:["./*.js"]},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 entry=byName.get(match[1]);if(!entry)continue;if(!inScope(entry.specifier,match[2]))continue;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 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: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/dist/commands/env.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import process from"node:process";import{log,onUnknownSubcommand}from"@stacksjs/cli";import{decryptEnv,encryptEnv,getEnv,getKeypair,resolveEnvFile,rotateKeypair,setEnv}from"@stacksjs/env";import{ExitCode}from"@stacksjs/types";export function env(buddy){const descriptions={env:"Interact with your environment variables",get:"Get an environment variable",set:"Set an environment variable",encrypt:"Encrypt a value",decrypt:"Decrypt a value",check:"Check environment configuration and validate setup",pretty:"Pretty print the result",stdout:"Output the result to stdout",keypair:"Generate a keypair",fileKeys:"The path to the file containing the keys",excludeKey:"The key to exclude from encryption",rotate:"Rotate a keypair",rotateStdout:"Print the rotated file and its new keypair without writing either",rotateDryRun:"Report what would change without writing anything",format:"The format to output the result (json, shell, eval)",all:"Get all environment variables",file:"The environment file to use",project:"Target a specific project",verbose:"Enable verbose output"};buddy.command("env:get [key]",descriptions.get).option("-f, --file [file]",descriptions.file,{default:""}).option("--format [format]",descriptions.format,{default:"json"}).option("-a, --all",descriptions.all,{default:!1}).option("-p, --pretty",descriptions.pretty,{default:!1}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).example("buddy env:get SECRET").example("buddy env:get SECRET --file .env.production").example("buddy env:get --all --pretty").example("buddy env:get --format shell").example("buddy env:get --format eval").example("buddy env:get --format json").action(async(key,options)=>{log.debug("Running `buddy env:get` ...",options);const result=getEnv(key,{file:resolveEnvFile(options.file,options.env),keysFile:options.fileKeys,all:options.all,format:options.format,prettyPrint:options.pretty});if(result.success){console.log(result.output);process.exit(ExitCode.Success)}else{console.error(result.error);process.exit(ExitCode.FatalError)}});buddy.command("env:set [key] [value]",descriptions.set).option("-f, --file [file]",descriptions.file,{default:""}).option("-fk, --file-keys [fileKeys]",descriptions.fileKeys,{default:""}).option("--plain","Don't encrypt the value",{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).example("buddy env:set SECRET=value").example("buddy env:set SECRET value").example("buddy env:set SECRET value --file .env.production").example("buddy env:set SECRET value --plain").action(async(key,value,options)=>{log.debug("Running `buddy env:set` ...",options);if(!key){console.error("A key is required. Pass an empty value to clear it: `buddy env:set KEY ''`");process.exit(ExitCode.FatalError)}if(value===void 0){console.error(`No value given for ${key}. Pass one, or an empty string to clear it: \`buddy env:set ${key} ''\``);process.exit(ExitCode.FatalError)}const result=setEnv(key,value,{file:resolveEnvFile(options.file,options.env),keysFile:options.fileKeys,plain:options.plain});if(result.success){console.log(result.output);process.exit(ExitCode.Success)}else{console.error(result.error);process.exit(ExitCode.FatalError)}});buddy.command("env:encrypt [key]",descriptions.encrypt).option("-f, --file [file]",descriptions.file,{default:""}).option("-fk, --file-keys [fileKeys]",descriptions.fileKeys,{default:""}).option("-o, --stdout",descriptions.stdout,{default:!1}).option("-ek, --exclude-key [excludeKey]",descriptions.excludeKey,{default:""}).example("buddy env:encrypt").example("buddy env:encrypt --file .env.production").example('buddy env:encrypt -k "SECRET_*"').action(async(key,options)=>{log.debug("Running `buddy env:encrypt` ...",options);const result=encryptEnv({file:resolveEnvFile(options.file,options.env),keysFile:options.fileKeys,key,excludeKey:options.excludeKey,stdout:options.stdout});if(result.success){console.log(result.output);process.exit(ExitCode.Success)}else{console.error(result.error);process.exit(ExitCode.FatalError)}});buddy.command("env:decrypt [key]",descriptions.decrypt).option("-f, --file [file]",descriptions.file,{default:""}).option("-fk, --file-keys [fileKeys]",descriptions.fileKeys,{default:""}).option("-o, --stdout",descriptions.stdout,{default:!1}).example("buddy env:decrypt").example("buddy env:decrypt --file .env.production").example('buddy env:decrypt -k "SECRET_*"').action(async(key,options)=>{log.debug("Running `buddy env:decrypt` ...",options);const result=decryptEnv({file:resolveEnvFile(options.file,options.env),keysFile:options.fileKeys,key,stdout:options.stdout});if(result.success){console.log(result.output);process.exit(ExitCode.Success)}else{console.error(result.error);process.exit(ExitCode.FatalError)}});buddy.command("env:keypair [key]",descriptions.keypair).option("-f, --file [file]",descriptions.file,{default:""}).option("-fk, --file-keys [fileKeys]",descriptions.fileKeys,{default:""}).option("--format [format]",descriptions.format,{default:"json"}).example("buddy env:keypair").example("buddy env:keypair --file .env.production").example("buddy env:keypair DOTENV_PRIVATE_KEY").action(async(key,options)=>{log.debug("Running `buddy env:keypair` ...",options);const result=getKeypair(key,{file:resolveEnvFile(options.file,options.env),keysFile:options.fileKeys,format:options.format});if(result.success){console.log(result.output);process.exit(ExitCode.Success)}else{console.error(result.error);process.exit(ExitCode.FatalError)}});buddy.command("env:rotate [key]",descriptions.rotate).option("-f, --file [file]",descriptions.file,{default:""}).option("-fk, --file-keys [fileKeys]",descriptions.fileKeys,{default:""}).option("-o, --stdout",descriptions.rotateStdout,{default:!1}).option("--dry-run",descriptions.rotateDryRun,{default:!1}).option("-ek, --exclude-key [excludeKey]",descriptions.excludeKey,{default:""}).example("buddy env:rotate").example("buddy env:rotate --file .env.production").example("buddy env:rotate --file .env.production --dry-run").action(async(key,options)=>{log.debug("Running `buddy env:rotate` ...",options);const result=rotateKeypair({file:resolveEnvFile(options.file,options.env),keysFile:options.fileKeys,key,excludeKey:options.excludeKey,stdout:options.stdout,dryRun:options.dryRun});if(result.success){console.log(result.output);if(result.notice)console.error(result.notice);process.exit(ExitCode.Success)}else{console.error(result.error);process.exit(ExitCode.FatalError)}});buddy.command("env:check",descriptions.check).option("-f, --file [file]",descriptions.file,{default:""}).option("--strict","Require every value in a committed env file to be encrypted, not just secret-shaped ones",{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).example("buddy env:check").example("buddy env:check --file .env.production").example("buddy env:check --file .env.production --strict").action(async(options)=>{log.debug("Running `buddy env:check` ...",options);const{bold,dim,green,red,yellow,intro}=await import("@stacksjs/cli"),{storage}=await import("@stacksjs/storage"),{existsSync}=await import("node:fs"),{resolve}=await import("node:path");await intro("buddy env:check");const checks=[],envFile=options.file||".env",envPath=resolve(process.cwd(),envFile);if(existsSync(envPath)){checks.push({name:`${envFile} file`,status:"pass",message:"Found"});try{const envContent=await storage.readTextFile(envPath),contentStr=typeof envContent==="string"?envContent:envContent.data,values=parseEnvAssignments(contentStr),keys=Object.keys(values),varCount=keys.length;checks.push({name:"Environment variables",status:"pass",message:`${varCount} variables defined`});if("APP_KEY"in values)if((values.APP_KEY??"").length>0)checks.push({name:"APP_KEY",status:"pass",message:"Set"});else checks.push({name:"APP_KEY",status:"warn",message:"Empty (run: buddy key:generate)"});else checks.push({name:"APP_KEY",status:"warn",message:"Not found (run: buddy key:generate)"});const hasPublicKey=keys.some((key)=>key.startsWith("DOTENV_PUBLIC_KEY")),hasPrivateKey=keys.some((key)=>key.startsWith("DOTENV_PRIVATE_KEY"))||existsSync(resolve(process.cwd(),".env.keys"));if(hasPublicKey&&hasPrivateKey)checks.push({name:"Encryption keys",status:"pass",message:"Public and private keys configured"});else if(hasPublicKey||hasPrivateKey)checks.push({name:"Encryption keys",status:"warn",message:"Incomplete keypair (run: buddy env:keypair)"});else checks.push({name:"Encryption keys",status:"warn",message:"Not configured (optional)"});const declaredTenants=await resolveDeclaredTenants();if(declaredTenants.tenants.length===0)checks.push({name:"Tenant isolation",status:"pass",message:"No tenants declared (cloud.tenants)"});else{const{foreignTenantKeys,partitionTenantEnv}=await import("@stacksjs/env"),foreign=foreignTenantKeys(partitionTenantEnv(values,declaredTenants)),isShipped=/^\.env\.[a-z]+$/.test(envFile),total=foreign.reduce((sum,entry)=>sum+entry.keys.length,0);if(foreign.length===0)checks.push({name:"Tenant isolation",status:"pass",message:`No foreign keys (checked ${declaredTenants.tenants.join(", ")})`});else if(!isShipped)checks.push({name:"Tenant isolation",status:"pass",message:`${total} archived key(s) - ${envFile} is local-only and never deployed`});else{checks.push({name:"Tenant isolation",status:"warn",message:`${total} key(s) belong to another tenant - move them to .env`});for(const{tenant,keys}of foreign)checks.push({name:` ${tenant}`,status:"warn",message:keys.join(", ")})}}const{plaintextSecrets,trackedEnvFiles}=await import("@stacksjs/env");if(!trackedEnvFiles(gitLsFiles()).includes(envFile))checks.push({name:"Committed secrets",status:"pass",message:`${envFile} is not committed; plaintext here stays local`});else{let placeholders={};try{const examplePath=resolve(process.cwd(),".env.example");if(existsSync(examplePath))placeholders=parseEnvAssignments(await storage.readTextFile(examplePath).then((f)=>f.data))}catch{}const leaked=plaintextSecrets(values,{placeholders,strict:options.strict});if(leaked.length===0)checks.push({name:"Committed secrets",status:"pass",message:`No unencrypted secrets in ${envFile}`});else checks.push({name:"Committed secrets",status:"fail",message:`${leaked.length} unencrypted secret${leaked.length===1?"":"s"} in committed ${envFile}: ${leaked.map((f)=>f.key).join(", ")}. Run \`buddy env:encrypt\`, then rotate them - they are in git history.`})}}catch(error){checks.push({name:`${envFile} content`,status:"fail",message:`Cannot read file: ${error}`})}}else checks.push({name:`${envFile} file`,status:"fail",message:"Not found"});const keysPath=resolve(process.cwd(),".env.keys");if(existsSync(keysPath))checks.push({name:".env.keys file",status:"pass",message:"Found"});else checks.push({name:".env.keys file",status:"warn",message:"Not found (optional for encryption)"});console.log("");console.log(bold("Environment Configuration Check:"));console.log(dim("\u2500".repeat(60)));console.log("");let hasFailures=!1,hasWarnings=!1;for(const check of checks){let statusIcon="",statusColor=(text)=>text;if(check.status==="pass"){statusIcon="\u2713";statusColor=green}else if(check.status==="warn"){statusIcon="\u26A0";statusColor=yellow;hasWarnings=!0}else{statusIcon="\u2717";statusColor=red;hasFailures=!0}console.log(`${statusColor(statusIcon)} ${bold(check.name.padEnd(25))} ${dim(check.message)}`)}console.log("");console.log(dim("\u2500".repeat(60)));console.log("");if(hasFailures){console.log(red("\u2717 Some critical checks failed. Please address the issues above."));process.exit(ExitCode.FatalError)}else if(hasWarnings){console.log(yellow("\u26A0 Some checks have warnings. Your environment should work but may have issues."));process.exit(ExitCode.Success)}else{console.log(green("\u2713 All checks passed! Your environment configuration looks healthy."));process.exit(ExitCode.Success)}});onUnknownSubcommand(buddy,"env")}async function resolveDeclaredTenants(){try{const{config}=await import("@stacksjs/config"),{cloud,app}=config,tenants=Array.isArray(cloud?.tenants)?cloud.tenants.filter((slug)=>typeof slug==="string"):[];return{self:app?.name,tenants}}catch{return{tenants:[]}}}function gitLsFiles(){try{const result=Bun.spawnSync(["git","ls-files"],{cwd:process.cwd(),stdout:"pipe",stderr:"ignore"});return result.success?result.stdout.toString():""}catch{return""}}export function parseEnvAssignments(content){const values={},assignment=/^\s*(?:export\s+)?([A-Z_][A-Z0-9_]*)\s*=\s*(.*)$/i,lines=content.split(/\r?\n/);for(let i=0;i<lines.length;i++){const line=lines[i];if(!line.trim()||line.trimStart().startsWith("#"))continue;const match=line.match(assignment);if(!match)continue;const[,key,first]=match;let raw=first.trim();const quote=raw[0]==='"'||raw[0]==="'"?raw[0]:void 0;if(quote){raw=raw.slice(1);while(!raw.endsWith(quote)&&i+1<lines.length){i++;raw+=lines[i]}raw=raw.endsWith(quote)?raw.slice(0,-1):raw}else raw=raw.replace(/\s+#.*$/,"").trim();values[key]=raw}return values}
|
|
1
|
+
import process from"node:process";import{log,onUnknownSubcommand}from"@stacksjs/cli";import{decryptEnv,encryptEnv,getEnv,getKeypair,resolveEnvFile,rotateKeypair,setEnv}from"@stacksjs/env";import{ExitCode}from"@stacksjs/types";export function env(buddy){const descriptions={env:"Interact with your environment variables",get:"Get an environment variable",set:"Set an environment variable",encrypt:"Encrypt a value",decrypt:"Decrypt a value",check:"Check environment configuration and validate setup",pretty:"Pretty print the result",stdout:"Output the result to stdout",keypair:"Generate a keypair",fileKeys:"The path to the file containing the keys",excludeKey:"The key to exclude from encryption",rotate:"Rotate a keypair",rotateStdout:"Print the rotated file and its new keypair without writing either",rotateDryRun:"Report what would change without writing anything",format:"The format to output the result (json, shell, eval)",all:"Get all environment variables",file:"The environment file to use",project:"Target a specific project",verbose:"Enable verbose output"};buddy.command("env:get [key]",descriptions.get).option("-f, --file [file]",descriptions.file,{default:""}).option("--format [format]",descriptions.format,{default:"json"}).option("-a, --all",descriptions.all,{default:!1}).option("-p, --pretty",descriptions.pretty,{default:!1}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).example("buddy env:get SECRET").example("buddy env:get SECRET --file .env.production").example("buddy env:get --all --pretty").example("buddy env:get --format shell").example("buddy env:get --format eval").example("buddy env:get --format json").action(async(key,options)=>{log.debug("Running `buddy env:get` ...",options);const result=getEnv(key,{file:resolveEnvFile(options.file,options.env),keysFile:options.fileKeys,all:options.all,format:options.format,prettyPrint:options.pretty});if(result.success){console.log(result.output);process.exit(ExitCode.Success)}else{console.error(result.error);process.exit(ExitCode.FatalError)}});buddy.command("env:set [key] [value]",descriptions.set).option("-f, --file [file]",descriptions.file,{default:""}).option("-fk, --file-keys [fileKeys]",descriptions.fileKeys,{default:""}).option("--plain","Don't encrypt the value",{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).example("buddy env:set SECRET=value").example("buddy env:set SECRET value").example("buddy env:set SECRET value --file .env.production").example("buddy env:set SECRET value --plain").action(async(key,value,options)=>{log.debug("Running `buddy env:set` ...",options);if(!key){console.error("A key is required. Pass an empty value to clear it: `buddy env:set KEY ''`");process.exit(ExitCode.FatalError)}if(value===void 0){console.error(`No value given for ${key}. Pass one, or an empty string to clear it: \`buddy env:set ${key} ''\``);process.exit(ExitCode.FatalError)}const result=setEnv(key,value,{file:resolveEnvFile(options.file,options.env),keysFile:options.fileKeys,plain:options.plain});if(result.success){console.log(result.output);process.exit(ExitCode.Success)}else{console.error(result.error);process.exit(ExitCode.FatalError)}});buddy.command("env:encrypt [key]",descriptions.encrypt).option("-f, --file [file]",descriptions.file,{default:""}).option("-fk, --file-keys [fileKeys]",descriptions.fileKeys,{default:""}).option("-o, --stdout",descriptions.stdout,{default:!1}).option("-ek, --exclude-key [excludeKey]",descriptions.excludeKey,{default:""}).example("buddy env:encrypt").example("buddy env:encrypt --file .env.production").example('buddy env:encrypt -k "SECRET_*"').action(async(key,options)=>{log.debug("Running `buddy env:encrypt` ...",options);const result=encryptEnv({file:resolveEnvFile(options.file,options.env),keysFile:options.fileKeys,key,excludeKey:options.excludeKey,stdout:options.stdout});if(result.success){console.log(result.output);process.exit(ExitCode.Success)}else{console.error(result.error);process.exit(ExitCode.FatalError)}});buddy.command("env:decrypt [key]",descriptions.decrypt).option("-f, --file [file]",descriptions.file,{default:""}).option("-fk, --file-keys [fileKeys]",descriptions.fileKeys,{default:""}).option("-o, --stdout",descriptions.stdout,{default:!1}).example("buddy env:decrypt").example("buddy env:decrypt --file .env.production").example('buddy env:decrypt -k "SECRET_*"').action(async(key,options)=>{log.debug("Running `buddy env:decrypt` ...",options);const result=decryptEnv({file:resolveEnvFile(options.file,options.env),keysFile:options.fileKeys,key,stdout:options.stdout});if(result.success){console.log(result.output);process.exit(ExitCode.Success)}else{console.error(result.error);process.exit(ExitCode.FatalError)}});buddy.command("env:keypair [key]",descriptions.keypair).option("-f, --file [file]",descriptions.file,{default:""}).option("-fk, --file-keys [fileKeys]",descriptions.fileKeys,{default:""}).option("--format [format]",descriptions.format,{default:"json"}).example("buddy env:keypair").example("buddy env:keypair --file .env.production").example("buddy env:keypair DOTENV_PRIVATE_KEY").action(async(key,options)=>{log.debug("Running `buddy env:keypair` ...",options);const result=getKeypair(key,{file:resolveEnvFile(options.file,options.env),keysFile:options.fileKeys,format:options.format});if(result.success){console.log(result.output);process.exit(ExitCode.Success)}else{console.error(result.error);process.exit(ExitCode.FatalError)}});buddy.command("env:rotate [key]",descriptions.rotate).option("-f, --file [file]",descriptions.file,{default:""}).option("-fk, --file-keys [fileKeys]",descriptions.fileKeys,{default:""}).option("-o, --stdout",descriptions.rotateStdout,{default:!1}).option("--dry-run",descriptions.rotateDryRun,{default:!1}).option("-ek, --exclude-key [excludeKey]",descriptions.excludeKey,{default:""}).example("buddy env:rotate").example("buddy env:rotate --file .env.production").example("buddy env:rotate --file .env.production --dry-run").action(async(key,options)=>{log.debug("Running `buddy env:rotate` ...",options);const result=rotateKeypair({file:resolveEnvFile(options.file,options.env),keysFile:options.fileKeys,key,excludeKey:options.excludeKey,stdout:options.stdout,dryRun:options.dryRun});if(result.success){console.log(result.output);if(result.notice)console.error(result.notice);process.exit(ExitCode.Success)}else{console.error(result.error);process.exit(ExitCode.FatalError)}});buddy.command("env:check",descriptions.check).option("-f, --file [file]",descriptions.file,{default:""}).option("--strict","Require every value in a committed env file to be encrypted, not just secret-shaped ones",{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).example("buddy env:check").example("buddy env:check --file .env.production").example("buddy env:check --file .env.production --strict").action(async(options)=>{log.debug("Running `buddy env:check` ...",options);const{bold,dim,green,red,yellow,intro}=await import("@stacksjs/cli"),{storage}=await import("@stacksjs/storage"),{existsSync}=await import("node:fs"),{resolve}=await import("node:path");await intro("buddy env:check");const checks=[],envFile=options.file||".env",envPath=resolve(process.cwd(),envFile);if(existsSync(envPath)){checks.push({name:`${envFile} file`,status:"pass",message:"Found"});try{const envContent=await storage.readTextFile(envPath),contentStr=typeof envContent==="string"?envContent:envContent.data,values=parseEnvAssignments(contentStr),keys=Object.keys(values),varCount=keys.length;checks.push({name:"Environment variables",status:"pass",message:`${varCount} variables defined`});if("APP_KEY"in values)if((values.APP_KEY??"").length>0)checks.push({name:"APP_KEY",status:"pass",message:"Set"});else checks.push({name:"APP_KEY",status:"warn",message:"Empty (run: buddy key:generate)"});else checks.push({name:"APP_KEY",status:"warn",message:"Not found (run: buddy key:generate)"});const hasPublicKey=keys.some((key)=>key.startsWith("DOTENV_PUBLIC_KEY")),hasPrivateKey=keys.some((key)=>key.startsWith("DOTENV_PRIVATE_KEY"))||existsSync(resolve(process.cwd(),".env.keys"));if(hasPublicKey&&hasPrivateKey)checks.push({name:"Encryption keys",status:"pass",message:"Public and private keys configured"});else if(hasPublicKey||hasPrivateKey)checks.push({name:"Encryption keys",status:"warn",message:"Incomplete keypair (run: buddy env:keypair)"});else checks.push({name:"Encryption keys",status:"warn",message:"Not configured (optional)"});const declaredTenants=await resolveDeclaredTenants();if(declaredTenants.tenants.length===0)checks.push({name:"Tenant isolation",status:"pass",message:"No tenants declared (cloud.tenants)"});else{const{foreignTenantKeys,partitionTenantEnv}=await import("@stacksjs/env"),foreign=foreignTenantKeys(partitionTenantEnv(values,declaredTenants)),isShipped=/^\.env\.[a-z]+$/.test(envFile),total=foreign.reduce((sum,entry)=>sum+entry.keys.length,0);if(foreign.length===0)checks.push({name:"Tenant isolation",status:"pass",message:`No foreign keys (checked ${declaredTenants.tenants.join(", ")})`});else if(!isShipped)checks.push({name:"Tenant isolation",status:"pass",message:`${total} archived key(s) - ${envFile} is local-only and never deployed`});else{checks.push({name:"Tenant isolation",status:"warn",message:`${total} key(s) belong to another tenant - move them to .env`});for(const{tenant,keys}of foreign)checks.push({name:` ${tenant}`,status:"warn",message:keys.join(", ")})}}const{plaintextSecrets,trackedEnvFiles}=await import("@stacksjs/env");if(!trackedEnvFiles(gitLsFiles()).includes(envFile))checks.push({name:"Committed secrets",status:"pass",message:`${envFile} is not committed; plaintext here stays local`});else{let placeholders={};try{const examplePath=resolve(process.cwd(),".env.example");if(existsSync(examplePath))placeholders=parseEnvAssignments(await storage.readTextFile(examplePath).then((f)=>f.data))}catch{}const leaked=plaintextSecrets(values,{placeholders,strict:options.strict});if(leaked.length===0)checks.push({name:"Committed secrets",status:"pass",message:`No unencrypted secrets in ${envFile}`});else checks.push({name:"Committed secrets",status:"fail",message:`${leaked.length} unencrypted secret${leaked.length===1?"":"s"} in committed ${envFile}: ${leaked.map((f)=>f.key).join(", ")}. Run \`buddy env:encrypt\`, then rotate them - they are in git history.`})}}catch(error){checks.push({name:`${envFile} content`,status:"fail",message:`Cannot read file: ${error}`})}}else checks.push({name:`${envFile} file`,status:"fail",message:"Not found"});const keysPath=resolve(process.cwd(),".env.keys");if(existsSync(keysPath))checks.push({name:".env.keys file",status:"pass",message:"Found"});else checks.push({name:".env.keys file",status:"warn",message:"Not found (optional for encryption)"});console.log("");console.log(bold("Environment Configuration Check:"));console.log(dim("\u2500".repeat(60)));console.log("");let hasFailures=!1,hasWarnings=!1;for(const check of checks){let statusIcon="",statusColor=(text)=>text;if(check.status==="pass"){statusIcon="\u2713";statusColor=green}else if(check.status==="warn"){statusIcon="\u26A0";statusColor=yellow;hasWarnings=!0}else{statusIcon="\u2717";statusColor=red;hasFailures=!0}console.log(`${statusColor(statusIcon)} ${bold(check.name.padEnd(25))} ${dim(check.message)}`)}console.log("");console.log(dim("\u2500".repeat(60)));console.log("");if(hasFailures){console.log(red("\u2717 Some critical checks failed. Please address the issues above."));process.exit(ExitCode.FatalError)}else if(hasWarnings){console.log(yellow("\u26A0 Some checks have warnings. Your environment should work but may have issues."));process.exit(ExitCode.Success)}else{console.log(green("\u2713 All checks passed! Your environment configuration looks healthy."));process.exit(ExitCode.Success)}});onUnknownSubcommand(buddy,"env")}async function resolveDeclaredTenants(){try{const{config}=await import("@stacksjs/config"),cloud=config.cloud,app=config.app,tenants=Array.isArray(cloud?.tenants)?cloud.tenants.filter((slug)=>typeof slug==="string"):[];return{self:app?.name,tenants}}catch{return{tenants:[]}}}function gitLsFiles(){try{const result=Bun.spawnSync(["git","ls-files"],{cwd:process.cwd(),stdout:"pipe",stderr:"ignore"});return result.success?result.stdout.toString():""}catch{return""}}export function parseEnvAssignments(content){const values={},assignment=/^\s*(?:export\s+)?([A-Z_][A-Z0-9_]*)\s*=\s*(.*)$/i,lines=content.split(/\r?\n/);for(let i=0;i<lines.length;i++){const line=lines[i];if(!line.trim()||line.trimStart().startsWith("#"))continue;const match=line.match(assignment);if(!match)continue;const[,key,first]=match;let raw=first.trim();const quote=raw[0]==='"'||raw[0]==="'"?raw[0]:void 0;if(quote){raw=raw.slice(1);while(!raw.endsWith(quote)&&i+1<lines.length){i++;raw+=lines[i]}raw=raw.endsWith(quote)?raw.slice(0,-1):raw}else raw=raw.replace(/\s+#.*$/,"").trim();values[key]=raw}return values}
|
package/dist/commands/share.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{getErrorMessage}from"@stacksjs/utils";import process from"node:process";import{runApiDevServer,runDashboardDevServer,runDesktopDevServer,runDocsDevServer,runFrontendDevServer}from"@stacksjs/actions";import{bold,cyan,dim,green,intro,log,outro,spinner}from"@stacksjs/cli";import{ports as configPorts}from"@stacksjs/config";import{ExitCode}from"@stacksjs/types";const originalStdoutWrite=process.stdout.write.bind(process.stdout),originalStderrWrite=process.stderr.write.bind(process.stderr),
|
|
1
|
+
import{getErrorMessage}from"@stacksjs/utils";import process from"node:process";import{runApiDevServer,runDashboardDevServer,runDesktopDevServer,runDocsDevServer,runFrontendDevServer}from"@stacksjs/actions";import{bold,cyan,dim,green,intro,log,outro,spinner}from"@stacksjs/cli";import{ports as configPorts}from"@stacksjs/config";import{ExitCode}from"@stacksjs/types";const originalStdoutWrite=process.stdout.write.bind(process.stdout),originalStderrWrite=process.stderr.write.bind(process.stderr),originalConsoleLog=console.log,originalConsoleError=console.error,originalConsoleWarn=console.warn,originalConsoleInfo=console.info;let _muted=!1,_verboseBuffer=[];function muteOutput(){_muted=!0;_verboseBuffer=[];const filter=(fn)=>{return function(chunk,...args){if(_muted){_verboseBuffer.push(String(chunk));return!0}return fn(chunk,...args)}};process.stdout.write=filter(originalStdoutWrite);process.stderr.write=filter(originalStderrWrite);console.log=(...args)=>{if(!_muted)originalConsoleLog(...args)};console.error=(...args)=>{if(!_muted)originalConsoleError(...args)};console.warn=(...args)=>{if(!_muted)originalConsoleWarn(...args)};console.info=(...args)=>{if(!_muted)originalConsoleInfo(...args)}}function unmuteOutput(){_muted=!1;process.stdout.write=originalStdoutWrite;process.stderr.write=originalStderrWrite;console.log=originalConsoleLog;console.error=originalConsoleError;console.warn=originalConsoleWarn;console.info=originalConsoleInfo}async function waitForPort(port,host="localhost",timeoutMs=30000){const start=Date.now();while(Date.now()-start<timeoutMs)try{await(await fetch(`http://${host}:${port}`,{signal:AbortSignal.timeout(1000)})).arrayBuffer();return}catch{await Bun.sleep(300)}throw Error(`Timed out waiting for server on port ${port}`)}const devServerRunners={frontend:runFrontendDevServer,api:runApiDevServer,backend:runApiDevServer,admin:runDashboardDevServer,dashboard:runDashboardDevServer,desktop:runDesktopDevServer,docs:runDocsDevServer},companionServices={frontend:[{port:configPorts?.api||3008,suffix:"api",label:"API",runner:runApiDevServer},{port:configPorts?.docs||3006,suffix:"docs",label:"Docs",runner:runDocsDevServer}]};function capitalize(s){return s.charAt(0).toUpperCase()+s.slice(1)}export function share(buddy){buddy.command("share [type]","Share your local development server via a public tunnel").option("-p, --port <port>","Local port to share").option("--server <url>","Tunnel server URL",{default:"api.localtunnel.dev"}).option("--subdomain <name>","Request a specific subdomain").option("--verbose","Enable verbose output",{default:!1}).action(async(type,options)=>{const perf=await intro("buddy share"),serviceType=type||"frontend",defaultPorts={frontend:configPorts?.frontend||3000,api:configPorts?.api||3008,backend:configPorts?.backend||3001,admin:configPorts?.admin||3002,dashboard:configPorts?.admin||3002,library:configPorts?.library||3003,desktop:configPorts?.desktop||3004,email:configPorts?.email||3005,docs:configPorts?.docs||3006,inspect:configPorts?.inspect||3007},port=options.port?Number.parseInt(options.port,10):defaultPorts[serviceType]||3000;if(Number.isNaN(port)||port<1||port>65535){await log.error(`Invalid port: ${options.port}`);process.exit(ExitCode.InvalidArgument)}const server=options.server||"api.localtunnel.dev",tunnels=[],companions=companionServices[serviceType]||[],s=spinner();try{const{localTunnel}=await import("@stacksjs/tunnel");console.log();const runner=devServerRunners[serviceType];if(runner){s.start(`Starting ${serviceType} dev server...`);muteOutput();runner({verbose:options.verbose??!1}).catch(()=>{});await waitForPort(port);unmuteOutput();s.succeed(`${bold(capitalize(serviceType))} ready ${dim(`on :${port}`)}`)}const startedCompanions=[];if(companions.length>0){s.start("Starting companion services...");muteOutput();for(const companion of companions)companion.runner({verbose:options.verbose??!1}).catch(()=>{});const results=await Promise.allSettled(companions.map((c)=>waitForPort(c.port,"localhost",60000)));unmuteOutput();for(let i=0;i<companions.length;i++){const result=results[i],companion=companions[i];if(!result||!companion)continue;if(result.status==="fulfilled"){startedCompanions.push(companion);s.succeed(`${bold(companion.label)} ready ${dim(`on :${companion.port}`)}`)}else s.fail(`${companion.label} failed to start ${dim(`on :${companion.port}`)}`)}}console.log();s.start("Creating tunnel...");const primaryTunnel=await localTunnel({port,server,subdomain:options.subdomain,verbose:options.verbose,onRequest:(req)=>{if(options.verbose)console.log(` ${dim(`${req.method} ${req.url}`)}`)},onReconnecting:(info)=>{s.start(`Reconnecting... (attempt ${info.attempt})`)}});tunnels.push(primaryTunnel);const baseSubdomain=primaryTunnel.subdomain;for(const companion of startedCompanions)try{const companionTunnel=await localTunnel({port:companion.port,server,subdomain:`${baseSubdomain}-${companion.suffix}`,verbose:options.verbose,onReconnecting:(info)=>{s.start(`${companion.label}: reconnecting... (attempt ${info.attempt})`)}});tunnels.push(companionTunnel)}catch{}s.succeed(`Connected ${dim(`to ${server}`)}`);const entries=[{label:capitalize(serviceType),url:primaryTunnel.url,local:`localhost:${port}`}];for(const companion of startedCompanions){const tunnel=tunnels.find((t)=>t.subdomain===`${baseSubdomain}-${companion.suffix}`);if(tunnel)entries.push({label:companion.label,url:tunnel.url,local:`localhost:${companion.port}`})}const maxLabel=Math.max(...entries.map((e)=>e.label.length));console.log();for(const entry of entries){const padding=" ".repeat(maxLabel-entry.label.length+1);console.log(` ${green("\u279C")} ${bold(entry.label)}${padding} ${cyan(entry.url)}`)}console.log();for(const entry of entries){const padding=" ".repeat(maxLabel-entry.label.length+1);console.log(` ${dim(entry.label)}${dim(padding)}${dim(entry.url)} ${dim("\u2192")} ${dim(entry.local)}`)}console.log();console.log(` ${dim("press Ctrl+C to stop")}`);console.log();const cleanup=async()=>{console.log();s.start("Closing tunnels...");await Promise.all(tunnels.map((t)=>t.close()));s.succeed("Tunnels closed");outro("Stopped sharing",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)};process.on("SIGINT",()=>cleanup());process.on("SIGTERM",()=>cleanup());await new Promise(()=>{})}catch(error){unmuteOutput();s.fail(getErrorMessage(error));const caught=error instanceof Error?error:Error(String(error));if(caught.message.includes("timeout")||caught.message.includes("ECONNREFUSED")){log.error(`Could not reach tunnel server at ${server}`);log.info(`Verify with: curl -sk https://${server}/status`)}else log.error(`Failed to create tunnel: ${caught.message}`);if(options.verbose)log.error(caught.stack);for(const t of tunnels)t.close();await outro("Share failed",{startTime:perf,useSeconds:!0,type:"error"});process.exit(ExitCode.FatalError)}})}
|
package/dist/deploy-notify.js
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
import process from"node:process";import{sendToDiscord,sendToSlack,sendToTeams}from"@stacksjs/chat";import{log}from"@stacksjs/logging";import{ExitCode}from"@stacksjs/types";export function driverForWebhookUrl(url){let host="";try{host=new URL(url).hostname.toLowerCase()}catch{return"slack"}if(host.endsWith("discord.com")||host.endsWith("discordapp.com"))return"discord";if(host.endsWith("office.com")||host.endsWith("office365.com")||host.endsWith("outlook.com"))return"teams";return"slack"}export function resolveDeployNotifyConfig(env=process.env){const url=env.DEPLOY_WEBHOOK_URL?.trim();if(!url)return{};const rawDriver=env.DEPLOY_WEBHOOK_DRIVER?.trim().toLowerCase();return{url,driver:(rawDriver==="slack"||rawDriver==="discord"||rawDriver==="teams"?rawDriver:void 0)??driverForWebhookUrl(url),notifyOn:env.DEPLOY_WEBHOOK_NOTIFY?.trim().toLowerCase()==="all"?"all":"failure"}}function summarizeError(error,limit=1200){if(error==null)return"No error detail was captured.";const trimmed=(error instanceof Error?error.stack||error.message:String(error)).trim();return trimmed.length>limit?`${trimmed.slice(0,limit)}
|
|
2
|
-
\u2026 (truncated)`:trimmed}function formatDuration(ms){if(typeof ms!=="number"||!Number.isFinite(ms)||ms<0)return null;const total=Math.round(ms/1000);return total<60?`${total}s`:`${Math.floor(total/60)}m ${total%60}s`}function ciContext(env=process.env){const lines=[],
|
|
2
|
+
\u2026 (truncated)`:trimmed}function formatDuration(ms){if(typeof ms!=="number"||!Number.isFinite(ms)||ms<0)return null;const total=Math.round(ms/1000);return total<60?`${total}s`:`${Math.floor(total/60)}m ${total%60}s`}function ciContext(env=process.env){const lines=[],repo=env.GITHUB_REPOSITORY,sha=env.GITHUB_SHA;if(repo&&sha)lines.push(`Commit: ${repo}@${sha.slice(0,7)}`);else if(sha)lines.push(`Commit: ${sha.slice(0,7)}`);if(repo&&env.GITHUB_RUN_ID)lines.push(`Run: ${env.GITHUB_SERVER_URL||"https://github.com"}/${repo}/actions/runs/${env.GITHUB_RUN_ID}`);return lines}export function buildDeployMessage(outcome,env=process.env){const failed=outcome.status==="failed",target=[outcome.project,outcome.environment].filter(Boolean).join(" \xB7 ")||"project",lines=[failed?`\uD83D\uDD34 Deploy FAILED - ${target}`:`\u2705 Deploy succeeded - ${target}`];if(outcome.provider)lines.push(`Provider: ${outcome.provider}`);const duration=formatDuration(outcome.durationMs);if(duration)lines.push(`Duration: ${duration}`);lines.push(...ciContext(env));if(failed){lines.push("");lines.push("```");lines.push(summarizeError(outcome.error));lines.push("```");const envLabel=outcome.environment||"The target environment";lines.push(`${envLabel==="production"?"Production":envLabel} is still serving the previous release.`)}return lines.join(`
|
|
3
3
|
`)}export async function notifyDeployOutcome(outcome,config=resolveDeployNotifyConfig()){try{if(!config.url){log.debug("[deploy] no DEPLOY_WEBHOOK_URL set - skipping deploy notification");return!1}const notifyOn=config.notifyOn??"failure";if(outcome.status==="succeeded"&¬ifyOn!=="all"){log.debug('[deploy] deploy succeeded and DEPLOY_WEBHOOK_NOTIFY is not "all" - not notifying');return!1}const driver=config.driver??driverForWebhookUrl(config.url),message=buildDeployMessage(outcome),result=driver==="discord"?await sendToDiscord(config.url,message):driver==="teams"?await sendToTeams(config.url,message):await sendToSlack(config.url,message);if(!result?.success){log.warn(`[deploy] deploy notification to ${driver} failed: ${result?.message??"unknown error"}`);return!1}log.debug(`[deploy] deploy ${outcome.status} notification sent via ${driver}`);return!0}catch(error){log.warn(`[deploy] deploy notification could not be sent: ${error instanceof Error?error.message:String(error)}`);return!1}}export function getDeployErrorMessage(error){if(error instanceof Error&&error.message)return error.message;if(typeof error==="string"&&error)return error;try{const rendered=JSON.stringify(error);if(rendered&&rendered!=="{}"&&rendered!=="null")return rendered}catch{}return`${String(error)} (no message; re-run with --verbose for the stack)`}export function withDeployNotification(action){return async(...args)=>{const startedAt=Date.now(),environment=typeof args[0]==="string"&&args[0]?args[0]:"production",context={environment,provider:process.env.CLOUD_PROVIDER||void 0,project:process.env.APP_NAME||void 0};try{await action(...args)}catch(error){log.error(`Deploy to ${environment} failed: ${getDeployErrorMessage(error)}`);const stack=error instanceof Error?error.stack:void 0;if(stack)log.debug(stack);await notifyDeployOutcome({...context,status:"failed",durationMs:Date.now()-startedAt,error});process.exit(ExitCode.FatalError)}await notifyDeployOutcome({...context,status:"succeeded",durationMs:Date.now()-startedAt})}}
|
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.35",
|
|
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.35",
|
|
99
|
+
"@stacksjs/ai": "^0.74.35",
|
|
100
|
+
"@stacksjs/alias": "^0.74.35",
|
|
101
|
+
"@stacksjs/analytics": "^0.74.35",
|
|
102
|
+
"@stacksjs/api": "^0.74.35",
|
|
103
|
+
"@stacksjs/arrays": "^0.74.35",
|
|
104
|
+
"@stacksjs/auth": "^0.74.35",
|
|
105
|
+
"@stacksjs/browser-extension": "^0.74.35",
|
|
106
|
+
"@stacksjs/build": "^0.74.35",
|
|
107
|
+
"@stacksjs/cache": "^0.74.35",
|
|
108
|
+
"@stacksjs/chat": "^0.74.35",
|
|
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.35",
|
|
111
|
+
"@stacksjs/cloud": "^0.74.35",
|
|
112
|
+
"@stacksjs/cms": "^0.74.35",
|
|
113
|
+
"@stacksjs/collections": "^0.74.35",
|
|
114
|
+
"@stacksjs/config": "^0.74.35",
|
|
115
|
+
"@stacksjs/database": "^0.74.35",
|
|
116
|
+
"@stacksjs/desktop-build": "^0.74.35",
|
|
117
|
+
"@stacksjs/dns": "^0.74.35",
|
|
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.35",
|
|
120
|
+
"@stacksjs/enums": "^0.74.35",
|
|
121
|
+
"@stacksjs/env": "^0.74.35",
|
|
122
|
+
"@stacksjs/error-handling": "^0.74.35",
|
|
123
|
+
"@stacksjs/events": "^0.74.35",
|
|
124
|
+
"@stacksjs/features": "^0.74.35",
|
|
125
|
+
"@stacksjs/git": "^0.74.35",
|
|
126
126
|
"@stacksjs/gitit": "^0.2.5",
|
|
127
|
-
"@stacksjs/health": "^0.74.
|
|
127
|
+
"@stacksjs/health": "^0.74.35",
|
|
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.
|
|
139
|
-
"@stacksjs/rpx": "^0.11.
|
|
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.
|
|
129
|
+
"@stacksjs/image": "^0.74.35",
|
|
130
|
+
"@stacksjs/lint": "^0.74.35",
|
|
131
|
+
"@stacksjs/logging": "^0.74.35",
|
|
132
|
+
"@stacksjs/notifications": "^0.74.35",
|
|
133
|
+
"@stacksjs/objects": "^0.74.35",
|
|
134
|
+
"@stacksjs/orm": "^0.74.35",
|
|
135
|
+
"@stacksjs/path": "^0.74.35",
|
|
136
|
+
"@stacksjs/payments": "^0.74.35",
|
|
137
|
+
"@stacksjs/realtime": "^0.74.35",
|
|
138
|
+
"@stacksjs/router": "^0.74.35",
|
|
139
|
+
"@stacksjs/rpx": "^0.11.53",
|
|
140
|
+
"@stacksjs/scheduler": "^0.74.35",
|
|
141
|
+
"@stacksjs/search-engine": "^0.74.35",
|
|
142
|
+
"@stacksjs/security": "^0.74.35",
|
|
143
|
+
"@stacksjs/server": "^0.74.35",
|
|
144
|
+
"@stacksjs/sites": "^0.74.35",
|
|
145
|
+
"@stacksjs/skills": "^0.74.35",
|
|
146
|
+
"@stacksjs/storage": "^0.74.35",
|
|
147
|
+
"@stacksjs/strings": "^0.74.35",
|
|
148
148
|
"@stacksjs/stx": "^0.2.274",
|
|
149
|
-
"@stacksjs/testing": "^0.74.
|
|
150
|
-
"@stacksjs/tinker": "^0.74.
|
|
149
|
+
"@stacksjs/testing": "^0.74.35",
|
|
150
|
+
"@stacksjs/tinker": "^0.74.35",
|
|
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.13.1",
|
|
153
|
+
"@stacksjs/tunnel": "^0.74.35",
|
|
154
|
+
"@stacksjs/types": "^0.74.35",
|
|
155
|
+
"@stacksjs/ui": "^0.74.35",
|
|
156
|
+
"@stacksjs/utils": "^0.74.35",
|
|
157
|
+
"@stacksjs/validation": "^0.74.35",
|
|
158
158
|
"ajv": "^8.20.0",
|
|
159
159
|
"ajv-formats": "^3.0.1",
|
|
160
160
|
"bun-plugin-stx": "^0.2.279",
|