@stacksjs/buddy 0.75.432 → 0.75.443
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/cloud.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import{existsSync,readdirSync,readFileSync,writeFileSync}from"node:fs";import{join}from"node:path";import process from"node:process";import{intro,italic,log,onUnknownSubcommand,outro,prompts,runCommand,text,underline}from"@stacksjs/cli";import{addJumpBox,deleteCdkRemnants,deleteIamUsers,deleteJumpBox,deleteLogGroups,deleteParameterStore,deleteStacksBuckets,deleteStacksFunctions,deleteSubnets,deleteVpcs,cloudEnvironment,diffStackTemplate,getCloudFrontDistributionId,stacksCloudRegion}from"@stacksjs/cloud";import{hasTTY,isCI}from"@stacksjs/env";import{path as p}from"@stacksjs/path";import{ExitCode}from"@stacksjs/types";import{resultFailed}from"../result";import{isSshPipelineProvider,sshFleetFromConfigAndState}from"./deploy-ssh-target";async function createTemporaryCdkRole(roleName){const{AWSClient}=await import("@stacksjs/ts-cloud"),client=new AWSClient,trustPolicy={Version:"2012-10-17",Statement:[{Effect:"Allow",Principal:{Service:"cloudformation.amazonaws.com"},Action:"sts:AssumeRole"}]};try{try{const getRoleParams=new URLSearchParams({Action:"GetRole",RoleName:roleName,Version:"2010-05-08"});await client.request({service:"iam",region:"us-east-1",method:"POST",path:"/",body:getRoleParams.toString()});log.debug(`Role ${roleName} already exists`);return}catch(e){if(!e.message?.includes("NoSuchEntity")&&!e.message?.includes("cannot be found"))throw e}log.info("Creating temporary IAM role to enable stack deletion...");const createRoleParams=new URLSearchParams({Action:"CreateRole",RoleName:roleName,AssumeRolePolicyDocument:JSON.stringify(trustPolicy),Description:"Temporary role to allow CloudFormation to delete stuck stack",Version:"2010-05-08"});await client.request({service:"iam",region:"us-east-1",method:"POST",path:"/",body:createRoleParams.toString()});log.success("Created IAM role");log.info("Attaching permissions...");const attachPolicyParams=new URLSearchParams({Action:"AttachRolePolicy",RoleName:roleName,PolicyArn:"arn:aws:iam::aws:policy/AdministratorAccess",Version:"2010-05-08"});await client.request({service:"iam",region:"us-east-1",method:"POST",path:"/",body:attachPolicyParams.toString()});log.success("IAM role ready for stack deletion");log.info("Waiting for IAM role to propagate...");await new Promise((resolve)=>setTimeout(resolve,1e4))}catch(error){if(error.message?.includes("EntityAlreadyExists"))log.debug("Role already exists");else throw error}}async function deleteTemporaryCdkRole(roleName){const{AWSClient}=await import("@stacksjs/ts-cloud"),client=new AWSClient;try{const detachPolicyParams=new URLSearchParams({Action:"DetachRolePolicy",RoleName:roleName,PolicyArn:"arn:aws:iam::aws:policy/AdministratorAccess",Version:"2010-05-08"});await client.request({service:"iam",region:"us-east-1",method:"POST",path:"/",body:detachPolicyParams.toString()});const deleteRoleParams=new URLSearchParams({Action:"DeleteRole",RoleName:roleName,Version:"2010-05-08"});await client.request({service:"iam",region:"us-east-1",method:"POST",path:"/",body:deleteRoleParams.toString()});log.success("Cleaned up temporary IAM role")}catch(error){log.debug(`Could not clean up temporary role: ${error.message}`)}}function isResultError(result){return resultFailed(result)}function getResultError(result){if(!result||typeof result!=="object")return"Unknown error";return String(result.error||"Unknown error")}function getResultValue(result){if(!result||typeof result!=="object")return;return result.value}async function refuse(...messages){for(const message of messages.slice(0,-1))await log.error(message);return log.exit(messages[messages.length-1],ExitCode.FatalError)}async function driverStatePins(){const{driverStatePath}=await import("@stacksjs/ts-cloud"),dir=join(driverStatePath("probe"),"..");let files;try{files=readdirSync(dir).filter((name)=>name.endsWith(".json"))}catch{return[]}const pins=[];for(const name of files){const file=join(dir,name);try{const state=JSON.parse(readFileSync(file,"utf8"));if(typeof state?.serverName==="string")pins.push({file,state})}catch{}}return pins}function writePinName(file,next){const state=JSON.parse(readFileSync(file,"utf8"));writeFileSync(file,`${JSON.stringify({...state,serverName:next},null,2)}
|
|
2
2
|
`)}export async function renameEffects(tsCloudConfig,servers,current){const taken=servers.map((s)=>String(s?.name)).filter(Boolean),effects={takenNames:async()=>taken,inventoryName:()=>String(current?.name??""),renameInventory:(next)=>{current.name=next}},pinned=(await driverStatePins()).filter((pin)=>pin.state.serverName===current?.name);if(pinned.length>0){effects.stateName=async()=>{const stale=(await driverStatePins()).find((pin)=>pin.state.serverName===current?.name);return stale?String(stale.state.serverName):void 0};effects.writeStateName=async(next)=>{for(const pin of pinned)writePinName(pin.file,next)}}const host=current?.ipv4;if(host){const{sshExec,buildSetHostnameScript}=await import("@stacksjs/ts-cloud"),ssh={user:"root",connectTimeoutSec:10};effects.remoteHostname=async()=>{const result=await sshExec(host,"hostname",ssh);if(result.code!==0)throw Error(result.stderr.trim()||`Could not reach ${host} over SSH.`);return result.stdout.trim()};effects.setRemoteHostname=async(next)=>{const result=await sshExec(host,buildSetHostnameScript(next),ssh);if(result.code!==0)throw Error(result.stderr.trim()||`Setting the hostname on ${host} failed.`)}}if((tsCloudConfig?.cloud?.provider||process.env.CLOUD_PROVIDER||"aws")!=="hetzner")return effects;const{HetznerClient}=await import("@stacksjs/ts-cloud"),{resolveHetznerApiToken}=await import("./deploy"),apiToken=resolveHetznerApiToken(tsCloudConfig),serverId=Number(current?.id);if(!apiToken||!Number.isFinite(serverId))return effects;const client=new HetznerClient({apiToken});effects.providerName=async()=>(await client.getServer(serverId))?.name;effects.renameProvider=async(next)=>{await client.renameServer(serverId,next)};return effects}export function planServerDestroy(name,effects){const steps=[{id:"provider:delete",title:`Delete the server ${name}`,change:{from:name,to:"gone"},destructive:!0,satisfied:async()=>!await effects.providerExists(),apply:async()=>await effects.deleteProvider()}];if(effects.pinnedName&&effects.clearPin)steps.push({id:"state:clear",title:"Clear the state pin that names it",change:{from:name,to:"none"},satisfied:async()=>await effects.pinnedName()!==name,apply:async()=>await effects.clearPin()});return{operation:"server:destroy",target:name,steps}}export async function destroyEffects(tsCloudConfig,current){const pinned=(await driverStatePins()).filter((pin)=>pin.state.serverName===current?.name),effects={providerExists:async()=>!0,deleteProvider:async()=>{throw Error(`No provider API for this fleet, so ${current?.name} cannot be deleted from here.`)}};if(pinned.length>0){effects.pinnedName=async()=>{const stale=(await driverStatePins()).find((pin)=>pin.state.serverName===current?.name);return stale?String(stale.state.serverName):void 0};effects.clearPin=async()=>{for(const pin of pinned){const{serverId:_id,serverName:_name,...rest}=JSON.parse(readFileSync(pin.file,"utf8"));writeFileSync(pin.file,`${JSON.stringify(rest,null,2)}
|
|
3
3
|
`)}}}if((tsCloudConfig?.cloud?.provider||process.env.CLOUD_PROVIDER||"aws")!=="hetzner")return effects;const{HetznerClient}=await import("@stacksjs/ts-cloud"),{resolveHetznerApiToken}=await import("./deploy"),apiToken=resolveHetznerApiToken(tsCloudConfig),serverId=Number(current?.id);if(!apiToken||!Number.isFinite(serverId))return effects;const client=new HetznerClient({apiToken});effects.providerExists=async()=>{try{return Boolean(await client.getServer(serverId))}catch{return!1}};effects.deleteProvider=async()=>{await client.deleteServer(serverId)};return effects}export function sitesSharingHostnames(sites,siteName,hostnames,hostsOf){return Object.entries(sites??{}).filter(([name])=>name!==siteName).filter(([name,site])=>hostsOf(name,site).some((host)=>hostnames.includes(host))).map(([name,site])=>`${name} (${String(site?.path??"/")})`).sort()}async function moveEffects(context){const{slug,siteName,rawSite,source,target,tsCloudConfig}=context,{buildCertificatePackScript,buildCertificateStateScript,buildCertificateUnpackScript,buildRpxConfig,buildRpxFragmentRefreshScript,buildSshArgs,certificatesMatch,DEFAULT_RPX_CERTS_DIR,parseCertificateState,probeHostRoutes,sshExec,sshExecOrThrow}=await import("@stacksjs/ts-cloud"),{reconcileHetznerDns}=await import("./deploy"),ssh={user:"root",connectTimeoutSec:10},runOnSource=(script)=>sshExecOrThrow(source.ipv4,script,ssh),runOnTarget=(script)=>sshExecOrThrow(target.ipv4,script,ssh);async function carry(path){const args=buildSshArgs(ssh,"ssh"),reader=Bun.spawn(["ssh",...args,`root@${source.ipv4}`,`cat ${path}`],{stdout:"pipe",stderr:"pipe"}),writer=Bun.spawn(["ssh",...args,`root@${target.ipv4}`,`cat > ${path}`],{stdin:reader.stdout,stdout:"pipe",stderr:"pipe"}),[readCode,writeCode]=await Promise.all([reader.exited,writer.exited]);if(readCode!==0||writeCode!==0){const why=(await new Response(readCode===0?writer.stderr:reader.stderr).text()).trim();throw Error(why||`Carrying ${path} from ${source.name} to ${target.name} failed.`)}}const staged=async(path)=>(await sshExec(target.ipv4,`test -s ${path} && echo staged`,ssh)).stdout.includes("staged"),{siteMoveArchivePath,siteMoveCertArchivePath}=await import("@stacksjs/ts-cloud"),archive=siteMoveArchivePath(slug,siteName),effects={runOnSource,runOnTarget,archiveStaged:()=>staged(archive),transferArchive:()=>carry(archive),publishedAddress:async()=>{const host=context.hostnames[0];if(!host)return;try{const{resolve4}=await import("node:dns/promises");return(await resolve4(host))[0]}catch{return}},cutoverDns:async()=>{const warnings=[],collect=new Proxy(log,{get:(target_,key)=>key==="warn"?(message)=>{warnings.push(String(message))}:target_[key]});await reconcileHetznerDns({[siteName]:rawSite},target.ipv4,collect,target.ipv6,tsCloudConfig?.infrastructure?.compute?.proxy?.autoWww);return warnings},targetRoutesSite:async()=>{const probe=await probeHostRoutes(target,(host,command)=>sshExec(host,command,ssh));if(probe.unavailable)return!1;return probe.routes.some((route)=>context.hostnames.includes(String(route.host)))},refreshTargetGateway:async()=>{const rpx=buildRpxConfig(tsCloudConfig?.sites??{},{proxy:tsCloudConfig?.infrastructure?.compute?.proxy??{},slug});await runOnTarget(buildRpxFragmentRefreshScript({config:rpx,slug}).join(`
|
|
4
|
-
`))}},certArchive=siteMoveCertArchivePath(slug,siteName);effects.certificates={inPlace:async()=>{const read=async(run)=>parseCertificateState(await run(buildCertificateStateScript(DEFAULT_RPX_CERTS_DIR,context.hostnames)));return certificatesMatch(await read(runOnSource),await read(runOnTarget))},carry:async()=>{await runOnSource(buildCertificatePackScript(DEFAULT_RPX_CERTS_DIR,context.hostnames,certArchive));await carry(certArchive);await runOnTarget(buildCertificateUnpackScript(DEFAULT_RPX_CERTS_DIR,certArchive))}};return effects}export async function serverServing(servers,hosts,probe){for(const candidate of servers){if(!candidate?.ipv4)continue;const result=await probe(candidate);if(!result.unavailable&&(result.routes??[]).some((route)=>hosts.includes(String(route.host))))return candidate}return}export async function onBoxDatabase(tsCloudConfig){const{isLocalDatabase,resolveAppDatabase}=await import("@stacksjs/ts-cloud"),database=resolveAppDatabase(tsCloudConfig);if(!database||!isLocalDatabase(database))return;const engine=String(database.engine||database.driver||"");if(engine==="sqlite"||engine==="better-sqlite3")return;const name=String(database.name||"");return name?{name}:void 0}async function assertFleetProvider(tsCloudConfig,command){const provider=tsCloudConfig?.cloud?.provider||process.env.CLOUD_PROVIDER||"aws";if(isSshPipelineProvider(provider))return;await log.info("The on-box half (the rpx gateway registry) is provider-independent; the server listing is not yet.");await log.exit(`\`buddy ${command}\` can list Hetzner and ssh servers, and this project's provider is '${provider}'.`,ExitCode.FatalError)}function readSshStatePins(cwd=process.cwd()){const dir=join(cwd,"storage","cloud","state");if(!existsSync(dir))return[];const pins=[];for(const file of readdirSync(dir)){if(!file.endsWith(".json"))continue;try{pins.push(JSON.parse(readFileSync(join(dir,file),"utf8")))}catch{}}return pins}async function listFleet(tsCloudConfig){if((tsCloudConfig?.cloud?.provider||process.env.CLOUD_PROVIDER)==="ssh"){const servers=sshFleetFromConfigAndState(tsCloudConfig,readSshStatePins());return{servers,problem:servers.length===0?"No ssh hosts are configured. Add one under `ssh.hosts` in config/cloud.ts, or set TS_CLOUD_SSH_HOST.":void 0}}const{HetznerClient,toInventoryServer}=await import("@stacksjs/ts-cloud"),{resolveHetznerApiToken}=await import("./deploy"),apiToken=resolveHetznerApiToken(tsCloudConfig);if(!apiToken)return{servers:[],problem:"No Hetzner API token, so no servers were looked up. Set HCLOUD_TOKEN (or HETZNER_API_TOKEN, or hetzner.apiToken in config/cloud.ts)."};try{return{servers:(await new HetznerClient({apiToken}).listServers()).map((server)=>toInventoryServer(server)).sort((a,b)=>a.name.localeCompare(b.name))}}catch(error){const status=Number(error?.status),where=Number.isFinite(status)&&status>0?`returned HTTP ${status}`:"could not be reached";return{servers:[],problem:`The Hetzner API ${where}, so the server list is incomplete. ${error?.message??String(error)}`+(status===401||status===403?" That is an auth failure, not an empty project: check the token is valid for this Hetzner project.":"")}}}async function declaredSitesFor(tsCloudConfig,slug){const{resolveSiteKind,siteInstallBase}=await import("@stacksjs/ts-cloud");return Object.entries(tsCloudConfig?.sites??{}).map(([name,site])=>{const domain=typeof site?.domain==="string"&&site.domain.trim()?site.domain.trim():void 0,port=Number(site?.port);return{name,kind:resolveSiteKind(site),domain,path:typeof site?.path==="string"&&site.path.trim()?site.path.trim():"/",port:Number.isFinite(port)&&port>0?port:void 0,installBase:siteInstallBase(slug,name),loopbackOnly:!domain}})}function describeAttachEdits(slug,owner,edit,dryRun){const lines=[" Two edits make the attach real, in two different repositories:",""];if(edit.state==="refused"){lines.push(` 1. config/cloud.ts here: could not edit it (${edit.reason})`);lines.push(` Add \`attachTo: '${owner}'\` to the \`cloud\` block by hand.`)}else if(edit.state==="already-set")lines.push(` 1. config/cloud.ts here: already sets attachTo: '${owner}'. Nothing to do.`);else lines.push(` 1. config/cloud.ts here: ${dryRun?`would set attachTo: '${owner}' (--dry-run, not written)`:`set attachTo: '${owner}'`}`);lines.push("");lines.push(` 2. In the '${owner}' project's own repository, which this command cannot edit:`);lines.push(` add '${slug}' to the \`tenants\` array in its config/cloud.ts.`);lines.push(` Without it, that project's deploy ships ${slug.toUpperCase()}_* keys from its`);lines.push(" env files into this project's .env instead of dropping them.");lines.push("");lines.push(" Then `buddy deploy` from here puts these sites on that box.");return lines}async function runCloudDiff(){try{const{InfrastructureGenerator}=await import("@stacksjs/ts-cloud"),{tsCloud:cloudConfig}=await import("~/config/cloud"),environment=cloudEnvironment(),template=new InfrastructureGenerator({config:cloudConfig,environment}).generate().toJSON(),result=await diffStackTemplate(template,{config:cloudConfig});if(result.isErr)return result.error.message;const diff=result.value;if(!diff.deployed)log.info(`${diff.stack} is not deployed in ${diff.region}. The whole template (${diff.localBytes} bytes) is new.`);else if(!diff.changed)log.info(`No changes detected against ${diff.stack} (${diff.region}).`);else{log.info(`Changes detected between ${diff.stack} (${diff.region}) and the local template:`);log.info(`Deployed template: ${diff.deployedBytes} bytes`);log.info(`Local template: ${diff.localBytes} bytes`)}return}catch(error){return error instanceof Error?error.message:String(error)}}export function cloud(buddy){buddy.command("cloud:apply","Apply configured server size, persistent volumes and deployment storage").option("--env [env]","Environment to reconcile",{default:"production"}).option("--dry-run","Show the compute plan without changing anything",{default:!1}).option("--yes","Apply the displayed plan without prompting",{default:!1}).option("-J, --json","Print the plan as JSON",{default:!1}).action(async(options)=>{try{const{createCloudDriver}=await import("@stacksjs/ts-cloud"),{loadTsCloudConfig}=await import("./deploy"),config=await loadTsCloudConfig(options.env);if(!config?.project?.slug)throw Error("config/cloud.ts must declare tsCloud.project.slug");const environment=options.env||"production",nativeConfig=config,driver=createCloudDriver({config:nativeConfig});if(!driver.applyComputeChanges)throw Error("This cloud provider does not support configured compute changes");const plan=await driver.applyComputeChanges({config:nativeConfig,environment,dryRun:!0});if(options.json)console.log(JSON.stringify(plan,null,2));else{for(const change of plan.changes)await log.info(`${change.resource}: ${change.action} (${change.detail})`);if(plan.waitingForCapacity)await log.warn(plan.waitingForCapacity)}if(options.dryRun)return;if(!await confirmCloudChange(options,"buddy cloud:apply --yes","Apply these compute changes?","apply compute changes"))return;const applied=await driver.applyComputeChanges({config:nativeConfig,environment});if(applied.waitingForCapacity)await log.warn(applied.waitingForCapacity);await log.success("Configured compute changes applied")}catch(error){await log.error(error instanceof Error?error.message:String(error));process.exitCode=ExitCode.FatalError}});const descriptions={cloud:"Interact with the Stacks Cloud",ssh:"SSH into the Stacks Cloud",add:"Add a resource to the Stacks Cloud",remove:"Remove the Stacks Cloud. In case it fails, try again",optimizeCost:"Remove certain resources that may be re-applied at a later time",cleanUp:"Remove all resources that were retained during the cloud deletion",invalidateCache:"Invalidate the CloudFront cache",diff:"Show the diff of the current, undeployed cloud changes ",dashboard:"Run the local Stacks Cloud management cockpit (servers, sites, deploys)",sites:"List every server and what each one is hosting, across projects",attach:"Attach this project to a server another project owns, after checking it is safe",rename:"Rename a server in place, keeping the provider, state pin, hostname and inventory in step",renameTo:"The new server name",renameEnv:"Environment whose fleet and state pin to rename in",renameJson:"Emit the rename plan as JSON",destroy:"Destroy a drained server, once nothing on it is anyone's rollback",destroyEnv:"Environment whose fleet and state pin the server belongs to",destroyJson:"Emit the teardown plan as JSON",destroyConfirm:"The exact server name, which an irreversible step requires before it runs",destroyDiscardDrained:"Destroy it even though it still holds site trees that are somebody's rollback",move:"Move a deployed site to another server, cutting DNS over once the target serves it",moveTo:"Server to move the site to, by provider name or by owning project slug",moveFrom:"Server the site is on now, when more than one could be serving it",moveEnv:"Environment whose fleet and sites to move within",moveJson:"Emit the move plan as JSON",moveConfirm:"The exact site name, which the irreversible steps require before they run",attachServer:"Server to attach to, by provider name or by owning project slug",dryRun:"Print the plan and change nothing",sitesEnv:"Environment to take the inventory for",remote:"Skip the SSH read of each box's gateway registry (co-tenants are then not listed)",json:"Emit the inventory as JSON",host:"Host to bind the dashboard to",port:"Port to bind the dashboard to",env:"Environment to manage",paths:"The paths to invalidate",project:"Target a specific project",verbose:"Enable verbose output"};buddy.command("cloud",descriptions.cloud).option("--ssh",descriptions.ssh,{default:!1}).option("--connect",descriptions.ssh,{default:!1}).option("--invalidate-cache",descriptions.invalidateCache,{default:!1}).option("--paths [paths]",descriptions.paths).option("-y, --yes","Invalidate the CDN cache without asking first",{default:!1}).option("--diff",descriptions.diff,{default:!1}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy cloud` ...",options);const startTime=performance.now();if(options.ssh||options.connect){const jumpBox=await addJumpBox();if(isResultError(jumpBox)){await outro("Could not find the jump box",{startTime,useSeconds:!0},getResultError(jumpBox));process.exit(ExitCode.FatalError)}const{message,state}=getResultValue(jumpBox);if(!state.instanceId){log.info(message);await outro("There is no jump box to connect to",{startTime,useSeconds:!0,type:"warning"});process.exit(ExitCode.FatalError)}const result=await runCommand(["aws","ssm","start-session","--target",state.instanceId,"--region",state.region],{...options,cwd:p.projectPath(),stdin:"inherit",stdout:"inherit"});if(isResultError(result)){await outro("While running the cloud command, there was an issue",{startTime,useSeconds:!0},getResultError(result));process.exit(ExitCode.FatalError)}await outro("Exited",{startTime,useSeconds:!0});process.exit(ExitCode.Success)}if(options.invalidateCache){if(!await confirmInvalidation(options,"buddy cloud --invalidate-cache --yes","Would you like to invalidate the CDN (CloudFront) cache?")){await outro("Exited",{startTime,useSeconds:!0});process.exit(ExitCode.Success)}log.info("Invalidating the CloudFront cache...");const{AWSCloudFrontClient}=await import("@stacksjs/ts-cloud"),cloudfront=new AWSCloudFrontClient,distributionId=await getCloudFrontDistributionId();try{const invalidationId=await cloudfront.invalidateAll(distributionId);log.success(`Invalidation created: ${invalidationId}`);log.info("Status: pending")}catch(err){log.error(`Failed to invalidate CloudFront cache: ${err.message}`)}await outro("Exited",{startTime,useSeconds:!0});process.exit(ExitCode.Success)}if(options.diff){const failure=await runCloudDiff();if(failure){await outro("While running the cloud diff command, there was an issue",{startTime,useSeconds:!0},failure);process.exit(ExitCode.FatalError)}await outro("Cloud diff complete",{startTime,useSeconds:!0});process.exit(ExitCode.Success)}log.info("Not implemented yet. Read more about `buddy cloud` here: https://stacksjs.com/docs/cloud");await log.flush();process.exit(ExitCode.Success)});buddy.command("cloud:add",descriptions.add).option("--jump-box","Check for the jump box, and say how to declare one if the stack has none",{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy cloud:add` ...",options);const startTime=await intro("buddy cloud:add");if(options.jumpBox){const result=await addJumpBox();if(isResultError(result)){await outro("While running the cloud:add command, there was an issue",{startTime,useSeconds:!0},getResultError(result));process.exit(ExitCode.FatalError)}const change=getResultValue(result);log.info(change.message);if(!change.done){await outro("No jump box was added.",{startTime,useSeconds:!0,type:"warning"});process.exit(ExitCode.FatalError)}await outro("Your cloud has a jump box.",{startTime,useSeconds:!0});process.exit(ExitCode.Success)}log.info("This functionality is not yet implemented.");await log.flush();process.exit(ExitCode.Success)});buddy.command("cloud:remove",descriptions.remove).alias("cloud:rm").alias("undeploy").option("--jump-box","Check for the jump box, and say how to remove it from the stack",{default:!1}).option("-y, --yes","Skip the confirmation prompt",{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy cloud:remove` ...",options);const startTime=await intro("buddy cloud:remove"),environment=process.env.APP_ENV||process.env.NODE_ENV||"production";if(options.jumpBox){const result=await deleteJumpBox();if(isResultError(result)){await outro("While removing your jump-box, there was an issue",{startTime,useSeconds:!0},getResultError(result));process.exit(ExitCode.FatalError)}const change=getResultValue(result);log.info(change.message);if(!change.done){await outro("The jump box was not removed.",{startTime,useSeconds:!0,type:"warning"});process.exit(ExitCode.FatalError)}await outro("Your cloud has no jump box.",{startTime,useSeconds:!0});process.exit(ExitCode.Success)}if(!options.yes&&(isCI||!hasTTY||!process.stdin.isTTY)){log.syncError(`Refusing to remove the "${environment}" cloud infrastructure from a non-interactive shell without confirmation.`);log.syncError(" \u27A1\uFE0F Re-run with `--yes` to confirm (e.g. in CI): `buddy cloud:remove --yes`");await outro("cloud:remove cancelled.",{startTime,useSeconds:!0,type:"warning"});await log.flush();process.exit(ExitCode.FatalError)}if(!options.yes){log.warning(`This will permanently delete the "${environment}" cloud infrastructure (compute, storage, CDN, and DNS managed by Stacks). This cannot be undone.`);if((await text({message:`Type the environment name "${environment}" to confirm (blank to cancel):`})).trim()!==environment){await outro("cloud:remove cancelled - confirmation did not match.",{startTime,useSeconds:!0});process.exit(ExitCode.Success)}}console.log("");console.log("Removing cloud infrastructure...");console.log(` ${italic("This typically takes 2-5 minutes.")}`);console.log("");if(!process.env.AWS_ACCESS_KEY_ID||!process.env.AWS_SECRET_ACCESS_KEY){const{existsSync,readFileSync}=await import("node:fs"),{projectPath}=await import("@stacksjs/path"),envFiles=[projectPath(`.env.${environment}`),projectPath(".env")];for(const envPath of envFiles)if(existsSync(envPath)){const lines=readFileSync(envPath,"utf-8").split(`
|
|
5
|
-
`);for(const line of lines){const trimmed=line.trim();if(trimmed.startsWith("#")||!trimmed.includes("="))continue;const[key,...valueParts]=trimmed.split("="),value=valueParts.join("=").replace(/^["']|["']$/g,"");if(key==="AWS_ACCESS_KEY_ID"||key==="AWS_SECRET_ACCESS_KEY"||key==="AWS_REGION"||key==="AWS_ACCOUNT_ID")process.env[key]=value}break}}delete process.env.AWS_PROFILE;try{const{undeployStack}=await import("../../../actions/deploy"),region=await stacksCloudRegion(void 0,environment);await undeployStack({environment,region,verbose:options.verbose});await outro("Cloud infrastructure removed",{startTime,useSeconds:!0});process.exit(ExitCode.Success)}catch(error){console.log("");console.error("\u2717 Failed to remove cloud infrastructure");const errorStr=String(error.message||error);if(errorStr.includes("security token")||errorStr.includes("credentials")){console.log("");console.error(" AWS credentials are invalid or expired");console.log(" Check your AWS credentials in .env.production:");console.log(" - AWS_ACCESS_KEY_ID");console.log(" - AWS_SECRET_ACCESS_KEY")}else if(errorStr.includes("region")||errorStr.includes("AWS_REGION")){console.log("");console.error(" AWS Region not configured");console.log(" Add AWS_REGION to your .env.production file")}else if(errorStr.includes("AccessDenied")){console.log("");console.error(" Access denied");console.log(" Your AWS credentials may not have permission to delete stacks")}else console.error(` ${errorStr}`);console.log("");console.log("Troubleshooting:");console.log(" ./buddy cloud:cleanup - Clean up resources manually");console.log(" --verbose - Show detailed error information");console.log("");if(options.verbose)console.error("Error details:",error);await outro("Failed to remove infrastructure",{startTime,useSeconds:!0,type:"error"});process.exit(ExitCode.FatalError)}});buddy.command("cloud:optimize-cost",descriptions.optimizeCost).option("--jump-box","Check for the jump box, and say how to remove it from the stack",{default:!0}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy cloud:optimize-cost` ...",options);const startTime=await intro("buddy cloud:optimize-cost");if(options.jumpBox){const result=await deleteJumpBox();if(isResultError(result)){await outro("While checking your jump-box, there was an issue",{startTime,useSeconds:!0},getResultError(result));process.exit(ExitCode.FatalError)}const change=getResultValue(result);log.info(change.message);if(!change.done){await outro("No cost optimization was applied",{startTime,useSeconds:!0,type:"warning"});process.exit(ExitCode.FatalError)}}await outro("No cost optimization was applied",{startTime,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("cloud:cleanup",descriptions.cleanUp).alias("cloud:clean-up").option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy cloud:cleanup` ...",options);const startTime=await intro("buddy cloud:cleanup");delete process.env.AWS_PROFILE;log.info("Cleaning up your cloud resources will take a while to complete. Please be patient.");await new Promise((resolve)=>setTimeout(resolve,2000));const cleanupSteps=[{label:"retained S3 buckets",fn:deleteStacksBuckets},{label:"retained Lambda functions",fn:deleteStacksFunctions,ignoreErrors:["No stacks functions found"]},{label:"remaining Stacks logs",fn:deleteLogGroups},{label:"stored parameters",fn:deleteParameterStore},{label:"VPCs",fn:deleteVpcs},{label:"Subnets",fn:deleteSubnets},{label:"CDK remnants",fn:deleteCdkRemnants},{label:"IAM users",fn:deleteIamUsers}],errors=[];for(const step of cleanupSteps){log.info(`Removing any ${step.label}...`);try{const result=await step.fn();if(isResultError(result)){const errMsg=getResultError(result);if(!step.ignoreErrors?.includes(errMsg)){log.warn(`${step.label} cleanup issue: ${errMsg}`);errors.push({label:step.label,error:errMsg})}}else{const value=getResultValue(result);if(value)log.info(String(value))}}catch(e){const errMsg=e.message||"AWS SDK error";log.warn(`${step.label} cleanup skipped: ${errMsg}`);errors.push({label:step.label,error:errMsg})}}if(errors.length>0){log.warn(`Cleanup completed with ${errors.length} issue(s):`);for(const{label,error}of errors)log.warn(` - ${label}: ${error}`)}await outro("AWS resources have been removed",{startTime,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("cloud:invalidate-cache",descriptions.invalidateCache).option("--paths [paths]",descriptions.paths,{default:!1}).option("-y, --yes","Invalidate without asking first",{default:!1}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy cloud:invalidate-cache` ...",options);const startTime=await intro("buddy cloud:invalidate-cache");if(!await confirmInvalidation(options,"buddy cloud:invalidate-cache --yes","Would you like to invalidate the CloudFront cache?")){await outro("Exited",{startTime,useSeconds:!0});process.exit(ExitCode.Success)}log.info("Invalidating the CloudFront cache...");const distributionId=await getCloudFrontDistributionId();if(!distributionId){await outro("Could not resolve CloudFront distribution ID",{startTime,useSeconds:!0},"Ensure your cloud stack is deployed before invalidating cache.");process.exit(ExitCode.FatalError)}const paths=options.paths?String(options.paths):"/*",result=await runCommand(`aws cloudfront create-invalidation --distribution-id ${distributionId} --paths ${paths}`,{...options,cwd:p.projectPath(),stdin:"pipe",stdout:"pipe"});if(isResultError(result)){await outro("While running the cloud command, there was an issue",{startTime,useSeconds:!0},getResultError(result));process.exit(ExitCode.FatalError)}await outro("Exited",{startTime,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("cloud:diff",descriptions.diff).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy cloud:diff` ...",options);const startTime=await intro("buddy cloud:diff"),failure=await runCloudDiff();if(failure){await outro("While running the cloud diff command, there was an issue",{startTime,useSeconds:!0},failure);process.exit(ExitCode.FatalError)}await outro("Cloud diff complete",{startTime,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("cloud:dashboard",descriptions.dashboard).alias("cloud:cockpit").option("--host [host]",descriptions.host,{default:"127.0.0.1"}).option("--port [port]",descriptions.port,{default:"7676"}).option("--env [env]",descriptions.env).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy cloud:dashboard` ...",options);const startTime=await intro("buddy cloud:dashboard"),tsCloud=await import("@stacksjs/ts-cloud");if(typeof tsCloud.startLocalDashboardServer!=="function"){await outro("The installed @stacksjs/ts-cloud does not provide the local cockpit yet",{startTime,useSeconds:!0},"Update your dependencies (requires @stacksjs/ts-cloud >= 0.5.27).");process.exit(ExitCode.FatalError)}try{const server=await tsCloud.startLocalDashboardServer({host:options.host?String(options.host):void 0,port:options.port?Number(options.port):void 0,environment:options.env,verbose:!!options.verbose});log.success(`Stacks Cloud cockpit running at ${underline(server.url)}`);log.info(italic("Manage servers, sites, SSH keys and deploys. Press Ctrl+C to stop."));await new Promise(()=>{})}catch(error){await outro("While starting the cloud dashboard, there was an issue",{startTime,useSeconds:!0},error?.message??String(error));process.exit(ExitCode.FatalError)}});buddy.command("cloud:sites",descriptions.sites).option("--env [env]",descriptions.sitesEnv).option("--no-remote",descriptions.remote).option("-J, --json",descriptions.json,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy cloud:sites` ...",options);const{formatInventory,probeHostRoutes}=await import("@stacksjs/ts-cloud"),{loadTsCloudConfig}=await import("./deploy"),environment=String(options.env||process.env.APP_ENV||process.env.NODE_ENV||"production"),tsCloudConfig=await loadTsCloudConfig(options.env?environment:void 0),slug=tsCloudConfig?.project?.slug||"app";await assertFleetProvider(tsCloudConfig,"cloud:sites");const listing=await listFleet(tsCloudConfig);if(listing.problem)await log.error(listing.problem);const declared=await declaredSitesFor(tsCloudConfig,slug),probes=options.remote===!1?[]:await probeFleet(listing.servers),inventory={slug,servers:listing.servers,probes,declared};if(options.json)console.log(JSON.stringify({environment,...inventory},null,2));else for(const line of formatInventory(inventory))console.log(line);process.exit(listing.problem&&listing.servers.length===0?ExitCode.FatalError:ExitCode.Success);async function probeFleet(servers){const{sshExec}=await import("@stacksjs/ts-cloud"),probes=[];for(let index=0;index<servers.length;index+=6)probes.push(...await Promise.all(servers.slice(index,index+6).map((server)=>probeHostRoutes(server,(host,command)=>sshExec(host,command,{user:"root",connectTimeoutSec:10})))));return probes}});buddy.command("cloud:attach",descriptions.attach).option("--server <server>",descriptions.attachServer).option("--env [env]",descriptions.sitesEnv).option("--dry-run",descriptions.dryRun,{default:!1}).option("-J, --json",descriptions.json,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy cloud:attach` ...",options);const{attachConflicts,attachIsViable,attachPreconditions,formatAttachPlan,probeHostRoutes,resolveAttachTarget,setAttachToInCloudConfig,sshExec}=await import("@stacksjs/ts-cloud"),{loadTsCloudConfig}=await import("./deploy");if(!options.server)return await refuse("Which server? Pass --server <name|owner-slug>. `buddy cloud:sites` lists them.");const environment=String(options.env||process.env.APP_ENV||process.env.NODE_ENV||"production"),tsCloudConfig=await loadTsCloudConfig(options.env?environment:void 0),slug=tsCloudConfig?.project?.slug||"app";await assertFleetProvider(tsCloudConfig,"cloud:attach");const listing=await listFleet(tsCloudConfig);if(listing.problem)return await refuse(listing.problem);const target=resolveAttachTarget(listing.servers,String(options.server),environment);if("problem"in target)return await refuse(`${target.problem} \`buddy cloud:sites\` lists what is there.`);const server=target.server,preconditions=attachPreconditions(slug,server);if(preconditions.length>0)return await refuse(...preconditions);const declared=await declaredSitesFor(tsCloudConfig,slug),probe=await probeHostRoutes(server,(host,command)=>sshExec(host,command,{user:"root",connectTimeoutSec:10})),plan={slug,owner:server.project,server,declared,conflicts:probe.unavailable?[]:attachConflicts(slug,declared,probe.routes),registryRead:!probe.unavailable,registryProblem:probe.unavailable},viable=attachIsViable(plan),edit=viable?await applyAttachToConfig(plan.owner,Boolean(options.dryRun)):void 0;if(options.json)console.log(JSON.stringify({environment,...plan,edit},null,2));else{for(const line of formatAttachPlan(plan))console.log(line);if(edit)console.log(["",...describeAttachEdits(plan.slug,plan.owner,edit,Boolean(options.dryRun))].join(`
|
|
4
|
+
`))}},certArchive=siteMoveCertArchivePath(slug,siteName);effects.certificates={inPlace:async()=>{const read=async(run)=>parseCertificateState(await run(buildCertificateStateScript(DEFAULT_RPX_CERTS_DIR,context.hostnames)));return certificatesMatch(await read(runOnSource),await read(runOnTarget))},carry:async()=>{await runOnSource(buildCertificatePackScript(DEFAULT_RPX_CERTS_DIR,context.hostnames,certArchive));await carry(certArchive);await runOnTarget(buildCertificateUnpackScript(DEFAULT_RPX_CERTS_DIR,certArchive))}};return effects}export async function serverServing(servers,hosts,probe){for(const candidate of servers){if(!candidate?.ipv4)continue;const result=await probe(candidate);if(!result.unavailable&&(result.routes??[]).some((route)=>hosts.includes(String(route.host))))return candidate}return}export async function onBoxDatabase(tsCloudConfig){const{isLocalDatabase,resolveAppDatabase}=await import("@stacksjs/ts-cloud"),database=resolveAppDatabase(tsCloudConfig);if(!database||!isLocalDatabase(database))return;const engine=String(database.engine||database.driver||"");if(engine==="sqlite"||engine==="better-sqlite3")return;const name=String(database.name||"");return name?{name}:void 0}async function assertFleetProvider(tsCloudConfig,command){const provider=tsCloudConfig?.cloud?.provider||process.env.CLOUD_PROVIDER||"aws";if(isSshPipelineProvider(provider))return;await log.info("The on-box half (the rpx gateway registry) is provider-independent; the server listing is not yet.");await log.exit(`\`buddy ${command}\` can list Hetzner and ssh servers, and this project's provider is '${provider}'.`,ExitCode.FatalError)}function readSshStatePins(cwd=process.cwd()){const dir=join(cwd,"storage","cloud","state");if(!existsSync(dir))return[];const pins=[];for(const file of readdirSync(dir)){if(!file.endsWith(".json"))continue;try{pins.push(JSON.parse(readFileSync(join(dir,file),"utf8")))}catch{}}return pins}async function listFleet(tsCloudConfig){if((tsCloudConfig?.cloud?.provider||process.env.CLOUD_PROVIDER)==="ssh"){const servers=sshFleetFromConfigAndState(tsCloudConfig,readSshStatePins());return{servers,problem:servers.length===0?"No ssh hosts are configured. Add one under `ssh.hosts` in config/cloud.ts, or set TS_CLOUD_SSH_HOST.":void 0}}const{HetznerClient,toInventoryServer}=await import("@stacksjs/ts-cloud"),{resolveHetznerApiToken}=await import("./deploy"),apiToken=resolveHetznerApiToken(tsCloudConfig);if(!apiToken)return{servers:[],problem:"No Hetzner API token, so no servers were looked up. Set HCLOUD_TOKEN (or HETZNER_API_TOKEN, or hetzner.apiToken in config/cloud.ts)."};try{return{servers:(await new HetznerClient({apiToken}).listServers()).map((server)=>toInventoryServer(server)).sort((a,b)=>a.name.localeCompare(b.name))}}catch(error){const status=Number(error?.status),where=Number.isFinite(status)&&status>0?`returned HTTP ${status}`:"could not be reached";return{servers:[],problem:`The Hetzner API ${where}, so the server list is incomplete. ${error?.message??String(error)}`+(status===401||status===403?" That is an auth failure, not an empty project: check the token is valid for this Hetzner project.":"")}}}async function declaredSitesFor(tsCloudConfig,slug){const{resolveSiteKind,siteInstallBase}=await import("@stacksjs/ts-cloud");return Object.entries(tsCloudConfig?.sites??{}).map(([name,site])=>{const domain=typeof site?.domain==="string"&&site.domain.trim()?site.domain.trim():void 0,port=Number(site?.port);return{name,kind:resolveSiteKind(site),domain,path:typeof site?.path==="string"&&site.path.trim()?site.path.trim():"/",port:Number.isFinite(port)&&port>0?port:void 0,installBase:siteInstallBase(slug,name),loopbackOnly:!domain}})}function describeAttachEdits(slug,owner,edit,dryRun){const lines=[" Two edits make the attach real, in two different repositories:",""];if(edit.state==="refused"){lines.push(` 1. config/cloud.ts here: could not edit it (${edit.reason})`);lines.push(` Add \`attachTo: '${owner}'\` to the \`cloud\` block by hand.`)}else if(edit.state==="already-set")lines.push(` 1. config/cloud.ts here: already sets attachTo: '${owner}'. Nothing to do.`);else lines.push(` 1. config/cloud.ts here: ${dryRun?`would set attachTo: '${owner}' (--dry-run, not written)`:`set attachTo: '${owner}'`}`);lines.push("");lines.push(` 2. In the '${owner}' project's own repository, which this command cannot edit:`);lines.push(` add '${slug}' to the \`tenants\` array in its config/cloud.ts.`);lines.push(` Without it, that project's deploy ships ${slug.toUpperCase()}_* keys from its`);lines.push(" env files into this project's .env instead of dropping them.");lines.push("");lines.push(" Then `buddy deploy` from here puts these sites on that box.");return lines}async function runCloudDiff(){try{const{InfrastructureGenerator}=await import("@stacksjs/ts-cloud"),{tsCloud:cloudConfig}=await import(p.projectPath("config/cloud")),environment=cloudEnvironment(),template=new InfrastructureGenerator({config:cloudConfig,environment}).generate().toJSON(),result=await diffStackTemplate(template,{config:cloudConfig});if(result.isErr)return result.error.message;const diff=result.value;if(!diff.deployed)log.info(`${diff.stack} is not deployed in ${diff.region}. The whole template (${diff.localBytes} bytes) is new.`);else if(!diff.changed)log.info(`No changes detected against ${diff.stack} (${diff.region}).`);else{log.info(`Changes detected between ${diff.stack} (${diff.region}) and the local template:`);log.info(`Deployed template: ${diff.deployedBytes} bytes`);log.info(`Local template: ${diff.localBytes} bytes`)}return}catch(error){return error instanceof Error?error.message:String(error)}}export function cloud(buddy){buddy.command("cloud:apply","Apply configured server size, persistent volumes and deployment storage").option("--env [env]","Environment to reconcile",{default:"production"}).option("--dry-run","Show the compute plan without changing anything",{default:!1}).option("--yes","Apply the displayed plan without prompting",{default:!1}).option("-J, --json","Print the plan as JSON",{default:!1}).action(async(options)=>{try{const{createCloudDriver}=await import("@stacksjs/ts-cloud"),{loadTsCloudConfig}=await import("./deploy"),config=await loadTsCloudConfig(options.env);if(!config?.project?.slug)throw Error("config/cloud.ts must declare tsCloud.project.slug");const environment=options.env||"production",nativeConfig=config,driver=createCloudDriver({config:nativeConfig});if(!driver.applyComputeChanges)throw Error("This cloud provider does not support configured compute changes");const plan=await driver.applyComputeChanges({config:nativeConfig,environment,dryRun:!0});if(options.json)console.log(JSON.stringify(plan,null,2));else{for(const change of plan.changes)await log.info(`${change.resource}: ${change.action} (${change.detail})`);if(plan.waitingForCapacity)await log.warn(plan.waitingForCapacity)}if(options.dryRun)return;if(!await confirmCloudChange(options,"buddy cloud:apply --yes","Apply these compute changes?","apply compute changes"))return;const applied=await driver.applyComputeChanges({config:nativeConfig,environment});if(applied.waitingForCapacity)await log.warn(applied.waitingForCapacity);await log.success("Configured compute changes applied")}catch(error){await log.error(error instanceof Error?error.message:String(error));process.exitCode=ExitCode.FatalError}});const descriptions={cloud:"Interact with the Stacks Cloud",ssh:"SSH into the Stacks Cloud",add:"Add a resource to the Stacks Cloud",remove:"Remove the Stacks Cloud. In case it fails, try again",optimizeCost:"Remove certain resources that may be re-applied at a later time",cleanUp:"Remove all resources that were retained during the cloud deletion",invalidateCache:"Invalidate the CloudFront cache",diff:"Show the diff of the current, undeployed cloud changes ",dashboard:"Run the local Stacks Cloud management cockpit (servers, sites, deploys)",sites:"List every server and what each one is hosting, across projects",attach:"Attach this project to a server another project owns, after checking it is safe",rename:"Rename a server in place, keeping the provider, state pin, hostname and inventory in step",renameTo:"The new server name",renameEnv:"Environment whose fleet and state pin to rename in",renameJson:"Emit the rename plan as JSON",destroy:"Destroy a drained server, once nothing on it is anyone's rollback",destroyEnv:"Environment whose fleet and state pin the server belongs to",destroyJson:"Emit the teardown plan as JSON",destroyConfirm:"The exact server name, which an irreversible step requires before it runs",destroyDiscardDrained:"Destroy it even though it still holds site trees that are somebody's rollback",move:"Move a deployed site to another server, cutting DNS over once the target serves it",moveTo:"Server to move the site to, by provider name or by owning project slug",moveFrom:"Server the site is on now, when more than one could be serving it",moveEnv:"Environment whose fleet and sites to move within",moveJson:"Emit the move plan as JSON",moveConfirm:"The exact site name, which the irreversible steps require before they run",attachServer:"Server to attach to, by provider name or by owning project slug",dryRun:"Print the plan and change nothing",sitesEnv:"Environment to take the inventory for",remote:"Skip the SSH read of each box's gateway registry (co-tenants are then not listed)",json:"Emit the inventory as JSON",host:"Host to bind the dashboard to",port:"Port to bind the dashboard to",env:"Environment to manage",paths:"The paths to invalidate",project:"Target a specific project",verbose:"Enable verbose output"};buddy.command("cloud",descriptions.cloud).option("--ssh",descriptions.ssh,{default:!1}).option("--connect",descriptions.ssh,{default:!1}).option("--invalidate-cache",descriptions.invalidateCache,{default:!1}).option("--paths [paths]",descriptions.paths).option("-y, --yes","Invalidate the CDN cache without asking first",{default:!1}).option("--diff",descriptions.diff,{default:!1}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy cloud` ...",options);const startTime=performance.now();if(options.ssh||options.connect){const jumpBox=await addJumpBox();if(isResultError(jumpBox)){await outro("Could not find the jump box",{startTime,useSeconds:!0},getResultError(jumpBox));process.exit(ExitCode.FatalError)}const{message,state}=getResultValue(jumpBox);if(!state.instanceId){log.info(message);await outro("There is no jump box to connect to",{startTime,useSeconds:!0,type:"warning"});process.exit(ExitCode.FatalError)}const result=await runCommand(["aws","ssm","start-session","--target",state.instanceId,"--region",state.region],{...options,cwd:p.projectPath(),stdin:"inherit",stdout:"inherit"});if(isResultError(result)){await outro("While running the cloud command, there was an issue",{startTime,useSeconds:!0},getResultError(result));process.exit(ExitCode.FatalError)}await outro("Exited",{startTime,useSeconds:!0});process.exit(ExitCode.Success)}if(options.invalidateCache){if(!await confirmInvalidation(options,"buddy cloud --invalidate-cache --yes","Would you like to invalidate the CDN (CloudFront) cache?")){await outro("Exited",{startTime,useSeconds:!0});process.exit(ExitCode.Success)}log.info("Invalidating the CloudFront cache...");const{AWSCloudFrontClient}=await import("@stacksjs/ts-cloud"),cloudfront=new AWSCloudFrontClient,distributionId=await getCloudFrontDistributionId();try{const invalidationId=await cloudfront.invalidateAll(distributionId);log.success(`Invalidation created: ${invalidationId}`);log.info("Status: pending")}catch(err){log.error(`Failed to invalidate CloudFront cache: ${err.message}`)}await outro("Exited",{startTime,useSeconds:!0});process.exit(ExitCode.Success)}if(options.diff){const failure=await runCloudDiff();if(failure){await outro("While running the cloud diff command, there was an issue",{startTime,useSeconds:!0},failure);process.exit(ExitCode.FatalError)}await outro("Cloud diff complete",{startTime,useSeconds:!0});process.exit(ExitCode.Success)}log.info("Not implemented yet. Read more about `buddy cloud` here: https://stacksjs.com/docs/cloud");await log.flush();process.exit(ExitCode.Success)});buddy.command("cloud:add",descriptions.add).option("--jump-box","Check for the jump box, and say how to declare one if the stack has none",{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy cloud:add` ...",options);const startTime=await intro("buddy cloud:add");if(options.jumpBox){const result=await addJumpBox();if(isResultError(result)){await outro("While running the cloud:add command, there was an issue",{startTime,useSeconds:!0},getResultError(result));process.exit(ExitCode.FatalError)}const change=getResultValue(result);log.info(change.message);if(!change.done){await outro("No jump box was added.",{startTime,useSeconds:!0,type:"warning"});process.exit(ExitCode.FatalError)}await outro("Your cloud has a jump box.",{startTime,useSeconds:!0});process.exit(ExitCode.Success)}log.info("This functionality is not yet implemented.");await log.flush();process.exit(ExitCode.Success)});buddy.command("cloud:remove",descriptions.remove).alias("cloud:rm").alias("undeploy").option("--jump-box","Check for the jump box, and say how to remove it from the stack",{default:!1}).option("-y, --yes","Skip the confirmation prompt",{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy cloud:remove` ...",options);const startTime=await intro("buddy cloud:remove"),environment=process.env.APP_ENV||process.env.NODE_ENV||"production";if(options.jumpBox){const result=await deleteJumpBox();if(isResultError(result)){await outro("While removing your jump-box, there was an issue",{startTime,useSeconds:!0},getResultError(result));process.exit(ExitCode.FatalError)}const change=getResultValue(result);log.info(change.message);if(!change.done){await outro("The jump box was not removed.",{startTime,useSeconds:!0,type:"warning"});process.exit(ExitCode.FatalError)}await outro("Your cloud has no jump box.",{startTime,useSeconds:!0});process.exit(ExitCode.Success)}if(!options.yes&&(isCI||!hasTTY||!process.stdin.isTTY)){log.syncError(`Refusing to remove the "${environment}" cloud infrastructure from a non-interactive shell without confirmation.`);log.syncError(" \u27A1\uFE0F Re-run with `--yes` to confirm (e.g. in CI): `buddy cloud:remove --yes`");await outro("cloud:remove cancelled.",{startTime,useSeconds:!0,type:"warning"});await log.flush();process.exit(ExitCode.FatalError)}if(!options.yes){log.warning(`This will permanently delete the "${environment}" cloud infrastructure (compute, storage, CDN, and DNS managed by Stacks). This cannot be undone.`);if((await text({message:`Type the environment name "${environment}" to confirm (blank to cancel):`})).trim()!==environment){await outro("cloud:remove cancelled - confirmation did not match.",{startTime,useSeconds:!0});process.exit(ExitCode.Success)}}console.log("");console.log("Removing cloud infrastructure...");console.log(` ${italic("This typically takes 2-5 minutes.")}`);console.log("");if(!process.env.AWS_ACCESS_KEY_ID||!process.env.AWS_SECRET_ACCESS_KEY){const{existsSync,readFileSync}=await import("node:fs"),{projectPath}=await import("@stacksjs/path"),envFiles=[projectPath(`.env.${environment}`),projectPath(".env")];for(const envPath of envFiles)if(existsSync(envPath)){const lines=readFileSync(envPath,"utf-8").split(`
|
|
5
|
+
`);for(const line of lines){const trimmed=line.trim();if(trimmed.startsWith("#")||!trimmed.includes("="))continue;const[key,...valueParts]=trimmed.split("="),value=valueParts.join("=").replace(/^["']|["']$/g,"");if(key==="AWS_ACCESS_KEY_ID"||key==="AWS_SECRET_ACCESS_KEY"||key==="AWS_REGION"||key==="AWS_ACCOUNT_ID")process.env[key]=value}break}}delete process.env.AWS_PROFILE;try{const{undeployStack}=await import("@stacksjs/actions/deploy/stack"),region=await stacksCloudRegion(void 0,environment);await undeployStack({environment,region,verbose:options.verbose});await outro("Cloud infrastructure removed",{startTime,useSeconds:!0});process.exit(ExitCode.Success)}catch(error){console.log("");console.error("\u2717 Failed to remove cloud infrastructure");const errorStr=String(error.message||error);if(errorStr.includes("security token")||errorStr.includes("credentials")){console.log("");console.error(" AWS credentials are invalid or expired");console.log(" Check your AWS credentials in .env.production:");console.log(" - AWS_ACCESS_KEY_ID");console.log(" - AWS_SECRET_ACCESS_KEY")}else if(errorStr.includes("region")||errorStr.includes("AWS_REGION")){console.log("");console.error(" AWS Region not configured");console.log(" Add AWS_REGION to your .env.production file")}else if(errorStr.includes("AccessDenied")){console.log("");console.error(" Access denied");console.log(" Your AWS credentials may not have permission to delete stacks")}else console.error(` ${errorStr}`);console.log("");console.log("Troubleshooting:");console.log(" ./buddy cloud:cleanup - Clean up resources manually");console.log(" --verbose - Show detailed error information");console.log("");if(options.verbose)console.error("Error details:",error);await outro("Failed to remove infrastructure",{startTime,useSeconds:!0,type:"error"});process.exit(ExitCode.FatalError)}});buddy.command("cloud:optimize-cost",descriptions.optimizeCost).option("--jump-box","Check for the jump box, and say how to remove it from the stack",{default:!0}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy cloud:optimize-cost` ...",options);const startTime=await intro("buddy cloud:optimize-cost");if(options.jumpBox){const result=await deleteJumpBox();if(isResultError(result)){await outro("While checking your jump-box, there was an issue",{startTime,useSeconds:!0},getResultError(result));process.exit(ExitCode.FatalError)}const change=getResultValue(result);log.info(change.message);if(!change.done){await outro("No cost optimization was applied",{startTime,useSeconds:!0,type:"warning"});process.exit(ExitCode.FatalError)}}await outro("No cost optimization was applied",{startTime,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("cloud:cleanup",descriptions.cleanUp).alias("cloud:clean-up").option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy cloud:cleanup` ...",options);const startTime=await intro("buddy cloud:cleanup");delete process.env.AWS_PROFILE;log.info("Cleaning up your cloud resources will take a while to complete. Please be patient.");await new Promise((resolve)=>setTimeout(resolve,2000));const cleanupSteps=[{label:"retained S3 buckets",fn:deleteStacksBuckets},{label:"retained Lambda functions",fn:deleteStacksFunctions,ignoreErrors:["No stacks functions found"]},{label:"remaining Stacks logs",fn:deleteLogGroups},{label:"stored parameters",fn:deleteParameterStore},{label:"VPCs",fn:deleteVpcs},{label:"Subnets",fn:deleteSubnets},{label:"CDK remnants",fn:deleteCdkRemnants},{label:"IAM users",fn:deleteIamUsers}],errors=[];for(const step of cleanupSteps){log.info(`Removing any ${step.label}...`);try{const result=await step.fn();if(isResultError(result)){const errMsg=getResultError(result);if(!step.ignoreErrors?.includes(errMsg)){log.warn(`${step.label} cleanup issue: ${errMsg}`);errors.push({label:step.label,error:errMsg})}}else{const value=getResultValue(result);if(value)log.info(String(value))}}catch(e){const errMsg=e.message||"AWS SDK error";log.warn(`${step.label} cleanup skipped: ${errMsg}`);errors.push({label:step.label,error:errMsg})}}if(errors.length>0){log.warn(`Cleanup completed with ${errors.length} issue(s):`);for(const{label,error}of errors)log.warn(` - ${label}: ${error}`)}await outro("AWS resources have been removed",{startTime,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("cloud:invalidate-cache",descriptions.invalidateCache).option("--paths [paths]",descriptions.paths,{default:!1}).option("-y, --yes","Invalidate without asking first",{default:!1}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy cloud:invalidate-cache` ...",options);const startTime=await intro("buddy cloud:invalidate-cache");if(!await confirmInvalidation(options,"buddy cloud:invalidate-cache --yes","Would you like to invalidate the CloudFront cache?")){await outro("Exited",{startTime,useSeconds:!0});process.exit(ExitCode.Success)}log.info("Invalidating the CloudFront cache...");const distributionId=await getCloudFrontDistributionId();if(!distributionId){await outro("Could not resolve CloudFront distribution ID",{startTime,useSeconds:!0},"Ensure your cloud stack is deployed before invalidating cache.");process.exit(ExitCode.FatalError)}const paths=options.paths?String(options.paths):"/*",result=await runCommand(`aws cloudfront create-invalidation --distribution-id ${distributionId} --paths ${paths}`,{...options,cwd:p.projectPath(),stdin:"pipe",stdout:"pipe"});if(isResultError(result)){await outro("While running the cloud command, there was an issue",{startTime,useSeconds:!0},getResultError(result));process.exit(ExitCode.FatalError)}await outro("Exited",{startTime,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("cloud:diff",descriptions.diff).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy cloud:diff` ...",options);const startTime=await intro("buddy cloud:diff"),failure=await runCloudDiff();if(failure){await outro("While running the cloud diff command, there was an issue",{startTime,useSeconds:!0},failure);process.exit(ExitCode.FatalError)}await outro("Cloud diff complete",{startTime,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("cloud:dashboard",descriptions.dashboard).alias("cloud:cockpit").option("--host [host]",descriptions.host,{default:"127.0.0.1"}).option("--port [port]",descriptions.port,{default:"7676"}).option("--env [env]",descriptions.env).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy cloud:dashboard` ...",options);const startTime=await intro("buddy cloud:dashboard"),tsCloud=await import("@stacksjs/ts-cloud");if(typeof tsCloud.startLocalDashboardServer!=="function"){await outro("The installed @stacksjs/ts-cloud does not provide the local cockpit yet",{startTime,useSeconds:!0},"Update your dependencies (requires @stacksjs/ts-cloud >= 0.5.27).");process.exit(ExitCode.FatalError)}try{const server=await tsCloud.startLocalDashboardServer({host:options.host?String(options.host):void 0,port:options.port?Number(options.port):void 0,environment:options.env,verbose:!!options.verbose});log.success(`Stacks Cloud cockpit running at ${underline(server.url)}`);log.info(italic("Manage servers, sites, SSH keys and deploys. Press Ctrl+C to stop."));await new Promise(()=>{})}catch(error){await outro("While starting the cloud dashboard, there was an issue",{startTime,useSeconds:!0},error?.message??String(error));process.exit(ExitCode.FatalError)}});buddy.command("cloud:sites",descriptions.sites).option("--env [env]",descriptions.sitesEnv).option("--no-remote",descriptions.remote).option("-J, --json",descriptions.json,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy cloud:sites` ...",options);const{formatInventory,probeHostRoutes}=await import("@stacksjs/ts-cloud"),{loadTsCloudConfig}=await import("./deploy"),environment=String(options.env||process.env.APP_ENV||process.env.NODE_ENV||"production"),tsCloudConfig=await loadTsCloudConfig(options.env?environment:void 0),slug=tsCloudConfig?.project?.slug||"app";await assertFleetProvider(tsCloudConfig,"cloud:sites");const listing=await listFleet(tsCloudConfig);if(listing.problem)await log.error(listing.problem);const declared=await declaredSitesFor(tsCloudConfig,slug),probes=options.remote===!1?[]:await probeFleet(listing.servers),inventory={slug,servers:listing.servers,probes,declared};if(options.json)console.log(JSON.stringify({environment,...inventory},null,2));else for(const line of formatInventory(inventory))console.log(line);process.exit(listing.problem&&listing.servers.length===0?ExitCode.FatalError:ExitCode.Success);async function probeFleet(servers){const{sshExec}=await import("@stacksjs/ts-cloud"),probes=[];for(let index=0;index<servers.length;index+=6)probes.push(...await Promise.all(servers.slice(index,index+6).map((server)=>probeHostRoutes(server,(host,command)=>sshExec(host,command,{user:"root",connectTimeoutSec:10})))));return probes}});buddy.command("cloud:attach",descriptions.attach).option("--server <server>",descriptions.attachServer).option("--env [env]",descriptions.sitesEnv).option("--dry-run",descriptions.dryRun,{default:!1}).option("-J, --json",descriptions.json,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy cloud:attach` ...",options);const{attachConflicts,attachIsViable,attachPreconditions,formatAttachPlan,probeHostRoutes,resolveAttachTarget,setAttachToInCloudConfig,sshExec}=await import("@stacksjs/ts-cloud"),{loadTsCloudConfig}=await import("./deploy");if(!options.server)return await refuse("Which server? Pass --server <name|owner-slug>. `buddy cloud:sites` lists them.");const environment=String(options.env||process.env.APP_ENV||process.env.NODE_ENV||"production"),tsCloudConfig=await loadTsCloudConfig(options.env?environment:void 0),slug=tsCloudConfig?.project?.slug||"app";await assertFleetProvider(tsCloudConfig,"cloud:attach");const listing=await listFleet(tsCloudConfig);if(listing.problem)return await refuse(listing.problem);const target=resolveAttachTarget(listing.servers,String(options.server),environment);if("problem"in target)return await refuse(`${target.problem} \`buddy cloud:sites\` lists what is there.`);const server=target.server,preconditions=attachPreconditions(slug,server);if(preconditions.length>0)return await refuse(...preconditions);const declared=await declaredSitesFor(tsCloudConfig,slug),probe=await probeHostRoutes(server,(host,command)=>sshExec(host,command,{user:"root",connectTimeoutSec:10})),plan={slug,owner:server.project,server,declared,conflicts:probe.unavailable?[]:attachConflicts(slug,declared,probe.routes),registryRead:!probe.unavailable,registryProblem:probe.unavailable},viable=attachIsViable(plan),edit=viable?await applyAttachToConfig(plan.owner,Boolean(options.dryRun)):void 0;if(options.json)console.log(JSON.stringify({environment,...plan,edit},null,2));else{for(const line of formatAttachPlan(plan))console.log(line);if(edit)console.log(["",...describeAttachEdits(plan.slug,plan.owner,edit,Boolean(options.dryRun))].join(`
|
|
6
6
|
`))}process.exit(viable?ExitCode.Success:ExitCode.FatalError);async function applyAttachToConfig(owner,dryRun){const configPath=p.projectPath("config/cloud.ts"),{readFileSync,writeFileSync}=await import("node:fs"),before=readFileSync(configPath,"utf8");try{const after=setAttachToInCloudConfig({configText:before,owner});if(after===before)return{state:"already-set"};if(dryRun)return{state:"would-write"};writeFileSync(configPath,after);return{state:"written"}}catch(error){return{state:"refused",reason:error instanceof Error?error.message:String(error)}}}});buddy.command("cloud:rename <server>",descriptions.rename).option("--to <name>",descriptions.renameTo).option("--env [env]",descriptions.renameEnv).option("--dry-run",descriptions.dryRun,{default:!1}).option("-J, --json",descriptions.renameJson,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(server,options)=>{log.debug("Running `buddy cloud:rename` ...",options);const{applyPlan,formatPlan,pendingSteps,planServerRename,resolvePlan,validateServerName}=await import("@stacksjs/ts-cloud"),{loadTsCloudConfig}=await import("./deploy");if(!options.to)return await refuse("Rename it to what? Pass --to <name>.");const next=String(options.to);try{validateServerName(next)}catch(error){return await refuse(error instanceof Error?error.message:String(error))}const environment=String(options.env||process.env.APP_ENV||process.env.NODE_ENV||"production"),tsCloudConfig=await loadTsCloudConfig(options.env?environment:void 0);await assertFleetProvider(tsCloudConfig,"cloud:rename");const listing=await listFleet(tsCloudConfig);if(listing.problem)return await refuse(listing.problem);const current=listing.servers.find((s)=>s?.name===server);if(!current)return await refuse(`No server named ${server}. \`buddy cloud:sites\` lists what is there.`);if(server===next)return await refuse(`${server} is already called that.`);const plan=await planServerRename(server,next,await renameEffects(tsCloudConfig,listing.servers,current)),resolved=await resolvePlan(plan),pending=pendingSteps(resolved);if(options.json){console.log(JSON.stringify({operation:plan.operation,target:plan.target,to:next,steps:resolved.map((entry)=>({id:entry.step.id,title:entry.step.title,state:entry.state,reason:entry.reason,change:entry.step.change}))},null,2));return}console.log("");for(const line of formatPlan(plan,resolved))console.log(line);console.log("");if(options.dryRun){await log.info(`Dry run: ${pending.length} step(s) would run. Nothing changed.`);return}if(pending.length===0){await log.success(`${server} is already named ${next} everywhere. Nothing to do.`);return}const outcome=await applyPlan(plan,resolved,{log:(message)=>console.log(` ${message}`)});if(!outcome.success){const failed=outcome.steps.find((step)=>step.state==="failed");return await refuse(`Stopped at: ${failed?.title??"an unnamed step"}`,failed?.error??"The step gave no reason.",`Re-run \`buddy cloud:rename ${server} --to ${next}\` to continue: completed steps skip themselves.`)}await log.success(`Renamed ${server} to ${next}`)});buddy.command("cloud:destroy <server>",descriptions.destroy).option("--env [env]",descriptions.destroyEnv).option("--confirm <name>",descriptions.destroyConfirm).option("--discard-drained",descriptions.destroyDiscardDrained,{default:!1}).option("--dry-run",descriptions.dryRun,{default:!1}).option("-J, --json",descriptions.destroyJson,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(server,options)=>{log.debug("Running `buddy cloud:destroy` ...",options);const{applyPlan,buildDrainedSiteScanScript,formatDrainedSiteRefusal,formatPlan,parseDrainedSites,pendingSteps,resolvePlan,sshExec}=await import("@stacksjs/ts-cloud"),{loadTsCloudConfig}=await import("./deploy"),environment=String(options.env||process.env.APP_ENV||process.env.NODE_ENV||"production"),tsCloudConfig=await loadTsCloudConfig(options.env?environment:void 0);await assertFleetProvider(tsCloudConfig,"cloud:destroy");const listing=await listFleet(tsCloudConfig);if(listing.problem)return await refuse(listing.problem);const current=listing.servers.find((s)=>s?.name===server);if(!current)return await refuse(`No server named ${server}. \`buddy cloud:sites\` lists what is there.`);const slug=String(tsCloudConfig?.project?.slug||"app");if(current.ipv4&&!options.discardDrained){const scan=await sshExec(current.ipv4,buildDrainedSiteScanScript(slug).join(`
|
|
7
7
|
`),{user:"root",connectTimeoutSec:10});if(scan.code===0){const drained=parseDrainedSites(scan.stdout);if(drained.length>0)return await refuse(formatDrainedSiteRefusal(drained,slug,"--discard-drained"))}else if(!options.dryRun)return await refuse(`Could not scan ${server} for drained sites: ${scan.stderr.trim()||"the box did not answer over SSH."}`,"A box that cannot be scanned may still be holding a rollback. Re-run with --discard-drained if it is not.")}const plan=planServerDestroy(server,await destroyEffects(tsCloudConfig,current)),resolved=await resolvePlan(plan),pending=pendingSteps(resolved);if(options.json){console.log(JSON.stringify({operation:plan.operation,target:plan.target,steps:resolved.map((entry)=>({id:entry.step.id,title:entry.step.title,state:entry.state,reason:entry.reason,destructive:entry.step.destructive,change:entry.step.change}))},null,2));return}console.log("");for(const line of formatPlan(plan,resolved))console.log(line);console.log("");if(options.dryRun){await log.info(`Dry run: ${pending.length} step(s) would run. Nothing changed.`);return}if(pending.length===0){await log.success(`${server} is already gone, and nothing still points at it.`);return}if(options.confirm!==server)return await refuse(`Destroying ${server} cannot be undone.`,`Re-run with \`--confirm ${server}\` to go ahead.`);const outcome=await applyPlan(plan,resolved,{log:(message)=>console.log(` ${message}`),confirm:options.confirm});if(!outcome.success){const failed=outcome.steps.find((step)=>step.state==="failed");return await refuse(`Stopped at: ${failed?.title??"an unnamed step"}`,failed?.error??"The step gave no reason.",`Re-run \`buddy cloud:destroy ${server} --confirm ${server}\` to continue: completed steps skip themselves.`)}await log.success(`Destroyed ${server}`)});buddy.command("cloud:move <site>",descriptions.move).option("--to <server>",descriptions.moveTo).option("--from <server>",descriptions.moveFrom).option("--env [env]",descriptions.moveEnv).option("--confirm <site>",descriptions.moveConfirm).option("--dry-run",descriptions.dryRun,{default:!1}).option("-J, --json",descriptions.moveJson,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(site,options)=>{log.debug("Running `buddy cloud:move` ...",options);const{applyPlan,formatPlan,gatewayHostnames,pendingSteps,planSiteMove,probeHostRoutes,resolveAttachTarget,resolvePlan,siteInstallBase,sshExec}=await import("@stacksjs/ts-cloud"),{loadTsCloudConfig}=await import("./deploy");if(!options.to)return await refuse("Move it where? Pass --to <name|owner-slug>. `buddy cloud:sites` lists them.");const environment=String(options.env||process.env.APP_ENV||process.env.NODE_ENV||"production"),tsCloudConfig=await loadTsCloudConfig(options.env?environment:void 0),slug=String(tsCloudConfig?.project?.slug||"app");await assertFleetProvider(tsCloudConfig,"cloud:move");const rawSite=(tsCloudConfig?.sites??{})[site];if(!rawSite)return await refuse(`config/cloud.ts declares no site named '${site}'.`);const listing=await listFleet(tsCloudConfig);if(listing.problem)return await refuse(listing.problem);const resolvedTarget=resolveAttachTarget(listing.servers,String(options.to),environment);if("problem"in resolvedTarget)return await refuse(`${resolvedTarget.problem} \`buddy cloud:sites\` lists what is there.`);const target=resolvedTarget.server,hostnames=gatewayHostnames({[site]:rawSite},{autoWww:Boolean(tsCloudConfig?.infrastructure?.compute?.proxy?.autoWww)}),shared=sitesSharingHostnames(tsCloudConfig?.sites??{},site,hostnames,(name,declared)=>gatewayHostnames({[name]:declared},{autoWww:Boolean(tsCloudConfig?.infrastructure?.compute?.proxy?.autoWww)}));if(shared.length>0)return await refuse(`'${site}' answers on ${hostnames.join(", ")}, and so do ${shared.length} other site(s): ${shared.join(", ")}.`,"A move repoints the hostname, not the path - so this would carry one tree to the target and send every request for the others to a box that does not have them.","Give the site its own hostname first, or move the whole set to the target and deploy once.");const source=options.from?listing.servers.find((s)=>s?.name===options.from):await serverServing(listing.servers,hostnames,(candidate)=>probeHostRoutes(candidate,(host,command)=>sshExec(host,command,{user:"root",connectTimeoutSec:10})));if(!source)return await refuse(options.from?`No server named ${options.from}.`:`Could not tell which server serves '${site}'. Pass --from <name> to say.`);if(source.name===target.name)return await refuse(`'${site}' is already on ${target.name}.`);if(!source.ipv4||!target.ipv4)return await refuse(`A move needs an address for both boxes, and ${(source.ipv4?target:source).name} has none.`);let plan;try{plan=await planSiteMove({slug,siteName:site,appBase:siteInstallBase(slug,site),from:source.name,to:target.name,targetAddress:target.ipv4,port:Number(rawSite?.port)||void 0,database:await onBoxDatabase(tsCloudConfig)},await moveEffects({tsCloudConfig,slug,siteName:site,rawSite,source,target,hostnames}))}catch(error){return await refuse(error instanceof Error?error.message:String(error))}const resolved=await resolvePlan(plan),pending=pendingSteps(resolved);if(options.json){console.log(JSON.stringify({operation:plan.operation,target:plan.target,from:source.name,to:target.name,hostnames,steps:resolved.map((entry)=>({id:entry.step.id,title:entry.step.title,state:entry.state,reason:entry.reason,destructive:entry.step.destructive,change:entry.step.change}))},null,2));return}console.log("");for(const line of formatPlan(plan,resolved))console.log(line);console.log("");if(options.dryRun){await log.info(`Dry run: ${pending.length} step(s) would run. Nothing changed.`);return}if(pending.length===0){await log.success(`'${site}' is already on ${target.name}, serving and cut over.`);return}const outcome=await applyPlan(plan,resolved,{log:(message)=>console.log(` ${message}`),confirm:options.confirm});if(!outcome.success){const failed=outcome.steps.find((step)=>step.state==="failed");return await refuse(`Stopped at: ${failed?.title??"an unnamed step"}`,failed?.error??"The step gave no reason.",`The source still holds the site's files - that is the rollback. Re-run \`buddy cloud:move ${site} --to ${target.name}\` to continue: completed steps skip themselves.`)}await log.success(`Moved '${site}' from ${source.name} to ${target.name}`)});onUnknownSubcommand(buddy,"cloud")}export async function confirmInvalidation(options,retry,question){return confirmCloudChange(options,retry,question,"invalidate the CDN cache")}export async function confirmCloudChange(options,retry,question,action){if(options.yes)return!0;if(isCI||!hasTTY||!process.stdin.isTTY){log.syncError(`Refusing to ${action} from a non-interactive shell without confirmation.`);log.syncError(` \u27A1\uFE0F Re-run with \`--yes\`: \`${retry}\``);await log.flush();process.exit(ExitCode.FatalError)}return await prompts.confirm(question)}
|
|
@@ -100,6 +100,20 @@ export declare function buildUniversalPayload(input: UniversalPayloadInput, run?
|
|
|
100
100
|
* `build:desktop` uses, plus the cross-compilation target.
|
|
101
101
|
*/
|
|
102
102
|
export declare function launcherSliceCompileArgs(base: string[], architecture: DarwinArchitecture): string[];
|
|
103
|
+
/**
|
|
104
|
+
* The team's Mac App Store signing identities in `security find-identity -v`
|
|
105
|
+
* output: the app certificate under either of the names Apple has issued it
|
|
106
|
+
* under, and the installer certificate likewise. Only the team's own: a Mac
|
|
107
|
+
* holding certificates for several teams must not sign with the wrong one.
|
|
108
|
+
*/
|
|
109
|
+
export declare function parseDistributionIdentities(output: string, teamId: string): { app?: string, installer?: string };
|
|
110
|
+
/**
|
|
111
|
+
* Fill the signing identities from the keychain when no flag or variable named
|
|
112
|
+
* them. They are certificate names, not secrets, and the keychain already says
|
|
113
|
+
* which ones this Mac can sign with, so a release from the developer's own Mac
|
|
114
|
+
* needs no Apple variables beyond the App Store Connect key.
|
|
115
|
+
*/
|
|
116
|
+
export declare function withKeychainIdentities(config: AppleDesktopConfig, read?: (keychain?: string) => string): AppleDesktopConfig;
|
|
103
117
|
/**
|
|
104
118
|
* What a failure prints, as a value.
|
|
105
119
|
*
|
|
@@ -146,6 +160,8 @@ declare interface AppleDesktopOptions {
|
|
|
146
160
|
craftArm64?: string
|
|
147
161
|
craftX64?: string
|
|
148
162
|
keychain?: string
|
|
163
|
+
testflight?: string
|
|
164
|
+
whatsNew?: string
|
|
149
165
|
}
|
|
150
166
|
export declare interface AppleDesktopConfig {
|
|
151
167
|
appName: string
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import{createHash}from"node:crypto";import{chmodSync,copyFileSync,existsSync,mkdirSync,mkdtempSync,readdirSync,readFileSync,rmSync,writeFileSync}from"node:fs";import{tmpdir}from"node:os";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("'","'")}async function nativeAppleConfig(){const{loadMacosDesktop}=await import("@stacksjs/desktop-build");return loadMacosDesktop(projectPath(),packageMetadata().version)}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={},native){const metadata=packageMetadata(),provisioningProfile=options.provisioningProfile||env("APPLE_PROVISIONING_PROFILE"),apiKeyPath=options.apiKeyPath||env("APP_STORE_CONNECT_API_KEY_PATH"),craftArm64=options.craftArm64||env("CRAFT_BIN_ARM64"),craftX64=options.craftX64||env("CRAFT_BIN_X64"),keychain=options.keychain||env("APPLE_KEYCHAIN");return{appName:options.appName||env("APPLE_APP_NAME")||native?.appName||env("APP_NAME")||metadata.name,bundleId:options.bundleId||env("APPLE_BUNDLE_ID")||native?.bundleId||"",teamId:options.teamId||env("APPLE_TEAM_ID")||native?.teamId||"",version:options.appVersion||env("APPLE_APP_VERSION")||native?.version||metadata.version,buildNumber:options.buildNumber||env("APPLE_BUILD_NUMBER")||(native?native.buildNumber:"")||env("GITHUB_RUN_NUMBER")||String(Math.floor(Date.now()/1000)),minimumMacos:options.minimumMacos||env("APPLE_MINIMUM_MACOS")||native?.minimumSystemVersion||"13.0",category:options.category||env("APPLE_APP_CATEGORY")||native?.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")||(native?.icon?resolve(projectPath(),native.icon):void 0),universal:Boolean(options.universal)||env("APPLE_UNIVERSAL")==="true",craftArm64:craftArm64?resolve(craftArm64):void 0,craftX64:craftX64?resolve(craftX64):void 0,keychain:keychain?resolve(keychain):void 0}}export function withApiKeyFile(config){const pem=env("APP_STORE_CONNECT_API_KEY").replaceAll("\\n",`
|
|
2
2
|
`);if(config.apiKeyPath||!pem||!config.apiKeyId)return config;if(!pem.includes("-----BEGIN PRIVATE KEY-----"))throw Error("APP_STORE_CONNECT_API_KEY must hold the PEM text of the .p8 key");const directory=mkdtempSync(join(tmpdir(),"apple-api-key-")),apiKeyPath=join(directory,`AuthKey_${config.apiKeyId}.p8`);writeFileSync(apiKeyPath,`${pem}
|
|
3
|
-
`,{mode:384});process.on("exit",()=>rmSync(directory,{recursive:!0,force:!0}));return{...config,apiKeyPath}}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, or APP_STORE_CONNECT_API_KEY hold its text")}if(config.universal){for(const[variable,path]of[["CRAFT_BIN_ARM64",config.craftArm64],["CRAFT_BIN_X64",config.craftX64]])if(!path||!existsSync(path))errors.push(`${variable} must point to a native Craft runtime for a universal build`);else if(readFileSync(path).subarray(0,2).toString()==="#!")errors.push(`${variable} points at a script, not a native Craft runtime: ${path}`)}for(const tool of config.universal?[...REQUIRED_TOOLS,"lipo"]: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?`
|
|
3
|
+
`,{mode:384});process.on("exit",()=>rmSync(directory,{recursive:!0,force:!0}));return{...config,apiKeyPath}}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.buildNumber==="next")errors.push("--build-number next is resolved by desktop:apple:publish; give desktop:apple:package a number");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, or APP_STORE_CONNECT_API_KEY hold its text")}if(config.universal){for(const[variable,path]of[["CRAFT_BIN_ARM64",config.craftArm64],["CRAFT_BIN_X64",config.craftX64]])if(!path||!existsSync(path))errors.push(`${variable} must point to a native Craft runtime for a universal build`);else if(readFileSync(path).subarray(0,2).toString()==="#!")errors.push(`${variable} points at a script, not a native Craft runtime: ${path}`)}for(const tool of config.universal?[...REQUIRED_TOOLS,"lipo"]: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?`
|
|
4
4
|
<key>CFBundleIconFile</key>
|
|
5
5
|
<string>AppIcon</string>`:"";return`<?xml version="1.0" encoding="UTF-8"?>
|
|
6
6
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "https://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
@@ -96,7 +96,7 @@ jobs:
|
|
|
96
96
|
s3-prefix: \${{ vars.RELEASE_S3_PREFIX }}
|
|
97
97
|
s3-public-url: \${{ vars.RELEASE_S3_PUBLIC_URL }}
|
|
98
98
|
secrets: inherit
|
|
99
|
-
`}function command(args,cwd=projectPath(),extraEnv={}){const result=Bun.spawnSync(args,{cwd,env:{...process.env,...extraEnv},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,...input.keychain?["--keychain",input.keychain]:[],"--sign",input.identity,target];return[sign(input.helperEntitlements,input.helperPath),sign(input.appEntitlements,input.appPath)]}export const captureCommand=(args)=>{const result=Bun.spawnSync(args,{cwd:projectPath(),stdin:"inherit",stdout:"pipe",stderr:"inherit"});if(result.exitCode!==0)throw Error(`${args[0]} ${args.slice(1).join(" ")} exited with code ${result.exitCode}`);return result.stdout.toString()},MACH_O_ARCHITECTURE={arm64:"arm64",x64:"x86_64"};export function parseLipoArchs(output){return output.trim().split(/\s+/).filter(Boolean).sort()}export function mergeUniversalBinary(input,run=captureCommand){for(const architecture of["arm64","x64"]){const expected=MACH_O_ARCHITECTURE[architecture],found=parseLipoArchs(run(["lipo","-archs",input[architecture]]));if(found.length!==1||found[0]!==expected)throw Error(`${input.name}: the ${architecture} input ${input[architecture]} contains ${found.join(" + ")||"no architectures"}, expected only ${expected}`)}run(["lipo","-create","-output",input.output,input.arm64,input.x64]);const merged=parseLipoArchs(run(["lipo","-archs",input.output])),expected=Object.values(MACH_O_ARCHITECTURE).sort();if(merged.join(" ")!==expected.join(" "))throw Error(`${input.name}: the universal binary ${input.output} contains ${merged.join(" + ")||"no architectures"}, expected ${expected.join(" + ")}`);return merged}export function buildUniversalPayload(input,run=captureCommand){const slice=(architecture)=>join(input.directory,architecture,"stacks-desktop");for(const architecture of["arm64","x64"]){mkdirSync(join(input.directory,architecture),{recursive:!0});run(input.launcherCompileArgs(architecture,slice(architecture)))}const launcher=join(input.directory,"stacks-desktop"),runtime=join(input.directory,"craft-runtime");mergeUniversalBinary({name:"stacks-desktop",arm64:slice("arm64"),x64:slice("x64"),output:launcher},run);const architectures=mergeUniversalBinary({name:"craft-runtime",arm64:input.craft.arm64,x64:input.craft.x64,output:runtime},run);return{launcher,runtime,architectures}}export function launcherSliceCompileArgs(base,architecture){return["bun",...base,`--target=bun-darwin-${architecture}`]}function signingIdentityExists(identity,keychain){if(!identity)return!1;const result=Bun.spawnSync(["security","find-identity","-v",...keychain?[keychain]:[]],{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,native=null){const errors=validateAppleDesktopConfig(config,!1);if(config.keychain&&!existsSync(config.keychain))errors.push(`APPLE_KEYCHAIN does not exist: ${config.keychain}`);const where=config.keychain?` in ${config.keychain}`:"";if(!signingIdentityExists(config.appSigningIdentity,config.keychain))errors.push(`App signing identity is not installed${where}: ${config.appSigningIdentity}`);if(!signingIdentityExists(config.installerSigningIdentity,config.keychain))errors.push(`Installer signing identity is not installed${where}: ${config.installerSigningIdentity}`);if(!provisioningProfileMatches(config))errors.push(`Provisioning profile does not match ${config.teamId}.${config.bundleId}`);if(native&&config.universal)errors.push("--universal is not supported for a native macOS app yet: the AppKit host is built for this machine's architecture");if(errors.length)throw Error(errors.join(`
|
|
99
|
+
`}function command(args,cwd=projectPath(),extraEnv={}){const result=Bun.spawnSync(args,{cwd,env:{...process.env,...extraEnv},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,...input.keychain?["--keychain",input.keychain]:[],"--sign",input.identity,target];return[sign(input.helperEntitlements,input.helperPath),sign(input.appEntitlements,input.appPath)]}export const captureCommand=(args)=>{const result=Bun.spawnSync(args,{cwd:projectPath(),stdin:"inherit",stdout:"pipe",stderr:"inherit"});if(result.exitCode!==0)throw Error(`${args[0]} ${args.slice(1).join(" ")} exited with code ${result.exitCode}`);return result.stdout.toString()},MACH_O_ARCHITECTURE={arm64:"arm64",x64:"x86_64"};export function parseLipoArchs(output){return output.trim().split(/\s+/).filter(Boolean).sort()}export function mergeUniversalBinary(input,run=captureCommand){for(const architecture of["arm64","x64"]){const expected=MACH_O_ARCHITECTURE[architecture],found=parseLipoArchs(run(["lipo","-archs",input[architecture]]));if(found.length!==1||found[0]!==expected)throw Error(`${input.name}: the ${architecture} input ${input[architecture]} contains ${found.join(" + ")||"no architectures"}, expected only ${expected}`)}run(["lipo","-create","-output",input.output,input.arm64,input.x64]);const merged=parseLipoArchs(run(["lipo","-archs",input.output])),expected=Object.values(MACH_O_ARCHITECTURE).sort();if(merged.join(" ")!==expected.join(" "))throw Error(`${input.name}: the universal binary ${input.output} contains ${merged.join(" + ")||"no architectures"}, expected ${expected.join(" + ")}`);return merged}export function buildUniversalPayload(input,run=captureCommand){const slice=(architecture)=>join(input.directory,architecture,"stacks-desktop");for(const architecture of["arm64","x64"]){mkdirSync(join(input.directory,architecture),{recursive:!0});run(input.launcherCompileArgs(architecture,slice(architecture)))}const launcher=join(input.directory,"stacks-desktop"),runtime=join(input.directory,"craft-runtime");mergeUniversalBinary({name:"stacks-desktop",arm64:slice("arm64"),x64:slice("x64"),output:launcher},run);const architectures=mergeUniversalBinary({name:"craft-runtime",arm64:input.craft.arm64,x64:input.craft.x64,output:runtime},run);return{launcher,runtime,architectures}}export function launcherSliceCompileArgs(base,architecture){return["bun",...base,`--target=bun-darwin-${architecture}`]}export function parseDistributionIdentities(output,teamId){const named=(kinds)=>new RegExp(`"((?:${kinds}): [^"]+ \\(${teamId}\\))"`).exec(output)?.[1];return{app:named("Apple Distribution|3rd Party Mac Developer Application"),installer:named("3rd Party Mac Developer Installer|Mac Installer Distribution")}}export function withKeychainIdentities(config,read=readIdentities){if(config.appSigningIdentity&&config.installerSigningIdentity||!config.teamId)return config;const found=parseDistributionIdentities(read(config.keychain),config.teamId);return{...config,appSigningIdentity:config.appSigningIdentity||found.app||"",installerSigningIdentity:config.installerSigningIdentity||found.installer||""}}function readIdentities(keychain){const result=Bun.spawnSync(["security","find-identity","-v",...keychain?[keychain]:[]],{stdout:"pipe",stderr:"pipe"});return result.exitCode===0?result.stdout.toString():""}async function appStoreClient(config){if(!config.apiKeyId||!config.apiIssuerId||!config.apiKeyPath||!existsSync(config.apiKeyPath))throw Error("App Store Connect API key ID, issuer ID, and .p8 key (APP_STORE_CONNECT_API_KEY or APP_STORE_CONNECT_API_KEY_PATH) are required");const{AppStoreConnectClient}=await import("ts-pantry");return new AppStoreConnectClient({keyId:config.apiKeyId,issuerId:config.apiIssuerId,keyPath:config.apiKeyPath})}async function appStoreAppId(client,bundleId){const app=await client.findApp(bundleId);if(!app)throw Error(`App Store Connect has no app for ${bundleId}. Create it under Apps first.`);return app.id}async function withDownloadedProfile(config){if(config.provisioningProfile||!config.bundleId)return config;const client=await appStoreClient(config),bundle=await client.findBundleId(config.bundleId);if(!bundle)throw Error(`No Apple bundle id ${config.bundleId}. Run buddy desktop:apple:provision --apply first.`);const profile=(await client.listProfiles(bundle.id)).filter((item)=>item.attributes.profileState==="ACTIVE"&&item.attributes.profileContent).sort((a,b)=>String(b.attributes.expirationDate).localeCompare(String(a.attributes.expirationDate)))[0];if(!profile)throw Error(`No active Mac App Store profile for ${config.bundleId}. Run buddy desktop:apple:provision --apply first.`);const directory=storagePath("framework/desktop-dist/apple");mkdirSync(directory,{recursive:!0});const provisioningProfile=join(directory,`${config.bundleId}.provisionprofile`);writeFileSync(provisioningProfile,Buffer.from(String(profile.attributes.profileContent),"base64"),{mode:420});log.info(`Using the Mac App Store profile "${profile.attributes.name}" from App Store Connect`);return{...config,provisioningProfile}}async function withNextBuildNumber(config){if(config.buildNumber!=="next")return config;const client=await appStoreClient(config),{nextBuildNumber}=await import("ts-pantry"),buildNumber=await nextBuildNumber(client,await appStoreAppId(client,config.bundleId));log.info(`Building ${config.version} (${buildNumber})`);return{...config,buildNumber}}async function releaseConfig(options,native){const config=withKeychainIdentities(withApiKeyFile(resolveAppleDesktopConfig(options,native)));return await withNextBuildNumber(await withDownloadedProfile(config))}async function distributeToTestFlight(config,groups,whatsNew){const client=await appStoreClient(config),{distributeTestFlightBuild}=await import("ts-pantry");log.info(`Waiting for App Store Connect to process ${config.version} (${config.buildNumber})...`);const result=await distributeTestFlightBuild(client,{appId:await appStoreAppId(client,config.bundleId),version:config.version,buildNumber:config.buildNumber,groups,whatsNew});log.success(`${config.version} (${config.buildNumber}) is on TestFlight for ${result.groups.map((group)=>group.name).join(", ")}`)}function commitsSinceLastTag(){const run=(args)=>Bun.spawnSync(args,{cwd:projectPath(),stdout:"pipe",stderr:"pipe"}),tag=run(["git","describe","--tags","--abbrev=0"]),range=tag.exitCode===0?[`${tag.stdout.toString().trim()}..HEAD`]:[];return run(["git","log","--no-merges","--format=- %s","-20",...range]).stdout.toString().trim().slice(0,4000)}function signingIdentityExists(identity,keychain){if(!identity)return!1;const result=Bun.spawnSync(["security","find-identity","-v",...keychain?[keychain]:[]],{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,native=null){const errors=validateAppleDesktopConfig(config,!1);if(config.keychain&&!existsSync(config.keychain))errors.push(`APPLE_KEYCHAIN does not exist: ${config.keychain}`);const where=config.keychain?` in ${config.keychain}`:"";if(!signingIdentityExists(config.appSigningIdentity,config.keychain))errors.push(`App signing identity is not installed${where}: ${config.appSigningIdentity}`);if(!signingIdentityExists(config.installerSigningIdentity,config.keychain))errors.push(`Installer signing identity is not installed${where}: ${config.installerSigningIdentity}`);if(!provisioningProfileMatches(config))errors.push(`Provisioning profile does not match ${config.teamId}.${config.bundleId}`);if(native&&config.universal)errors.push("--universal is not supported for a native macOS app yet: the AppKit host is built for this machine's architecture");if(errors.length)throw Error(errors.join(`
|
|
100
100
|
`));if(native)return packageNativeApple(config,native);if(!skipBuild)await buildDesktop();const desktopDist=storagePath("framework/desktop-dist");let launcher=join(desktopDist,"stacks-desktop"),runtime=join(desktopDist,"craft-runtime");const manifest=join(desktopDist,"desktop.json");for(const path of[launcher,runtime,manifest])if(!existsSync(path))throw Error(`Desktop build artifact is missing: ${path}`);let architectures=[process.arch==="x64"?"x86_64":process.arch],craftSlices;if(config.universal){const universalDir=join(desktopDist,"universal");if(existsSync(universalDir))rmSync(universalDir,{recursive:!0});const{desktopLauncherCompileArgs,resolveDesktopLauncher}=await import("@stacksjs/desktop-build"),entry=resolveDesktopLauncher(projectPath()),minify=process.env.DESKTOP_MINIFY!=="false",payload=buildUniversalPayload({directory:universalDir,launcherCompileArgs:(architecture,outfile)=>launcherSliceCompileArgs(desktopLauncherCompileArgs({entry,outfile,minify}),architecture),craft:{arm64:config.craftArm64,x64:config.craftX64}});launcher=payload.launcher;runtime=payload.runtime;architectures=payload.architectures;craftSlices={arm64:config.craftArm64,x64:config.craftX64}}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"));if(config.universal){const parsed=JSON.parse(readFileSync(manifest,"utf8"));writeFileSync(join(resourcesDir,"desktop.json"),`${JSON.stringify({...parsed,architecture:"universal"},null,2)}
|
|
101
101
|
`)}else copyFileSync(manifest,join(resourcesDir,"desktop.json"));copyFileSync(config.provisioningProfile,join(contents,"embedded.provisionprofile"));chmodSync(join(contents,"embedded.provisionprofile"),420);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,keychain:config.keychain}))command(args);command(["codesign","--verify","--deep","--strict","--verbose=2",appPath]);command(["codesign","-d","--entitlements","-","--xml",appPath]);const findings=storeRejectionFindings(bundleFiles(appPath));if(findings.length>0)throw Error(`The app bundle would be rejected by App Review:
|
|
102
102
|
${findings.map((finding)=>` - ${finding}`).join(`
|
|
@@ -108,4 +108,4 @@ ${findings.map((finding)=>` - ${finding}`).join(`
|
|
|
108
108
|
`));const common=["--type","macos","--file",packagePath,"--apiKey",config.apiKeyId,"--apiIssuer",config.apiIssuerId],env={API_PRIVATE_KEYS_DIR:resolve(config.apiKeyPath,"..")};command(["xcrun","altool","--validate-app",...common],projectPath(),env);if(!validateOnly)command(["xcrun","altool","--upload-app",...common],projectPath(),env)}export function renderFailure(error){return`${error instanceof Error?error.message:String(error)}
|
|
109
109
|
`}function fail(error){process.stderr.write(renderFailure(error));process.exit(1)}export function desktopApple(buddy){buddy.command("desktop:apple:csr","Generate local private keys and CSRs for Mac App Store distribution certificates").option("--common-name <name>","Certificate request common name").option("--output <path>","Directory for private keys and certificate requests").action(async(options)=>{try{const metadata=packageMetadata(),outputDirectory=resolve(options.output||storagePath("framework/desktop-dist/apple/provisioning")),{generateMacCertificateRequests}=await import("ts-pantry");generateMacCertificateRequests({outputDirectory,commonName:options.commonName||metadata.name});log.success(`Generated Apple certificate requests and private keys in ${outputDirectory}`)}catch(error){fail(error)}});buddy.command("desktop:apple:provision","Plan or reconcile Apple Bundle ID, capabilities, certificates, and profile").option("--app-name <name>","Mac App Store display name").option("--bundle-id <id>","Reverse-DNS bundle identifier").option("--api-key-id <id>","App Store Connect API key ID").option("--api-issuer-id <id>","App Store Connect API issuer ID").option("--api-key-path <path>","App Store Connect AuthKey .p8 file").option("--capabilities <types>","Comma-separated Apple capability types").option("--app-certificate-csr <path>","CSR for a missing Mac App Distribution certificate").option("--installer-certificate-csr <path>","CSR for a missing Mac Installer Distribution certificate").option("--profile-name <name>","Provisioning profile name").option("--output <path>","Directory for the plan and downloaded Apple assets").option("--plan","Report the idempotent Apple resource diff without mutating it").option("--apply","Apply the Apple resource diff without revoking existing certificates").action(async(options)=>{try{if(options.plan&&options.apply)throw Error("Choose either --plan or --apply");const config=withApiKeyFile(resolveAppleDesktopConfig(options,await nativeAppleConfig()));if(!config.bundleId)throw Error("APPLE_BUNDLE_ID or --bundle-id is required");if(!config.apiKeyId||!config.apiIssuerId||!config.apiKeyPath||!existsSync(config.apiKeyPath))throw Error("App Store Connect API key ID, issuer ID, and existing .p8 key path are required");const readCsr=(file)=>{if(!file)return;const resolved=resolve(file);if(!existsSync(resolved))throw Error(`Apple certificate CSR does not exist: ${resolved}`);return readFileSync(resolved,"utf8")},{exportAppleCertificateP12,provisionMacApp}=await import("ts-pantry"),result=await provisionMacApp({identifier:config.bundleId,name:config.appName,capabilities:options.capabilities?.split(",").map((value)=>value.trim()).filter(Boolean),appCertificateCsr:readCsr(options.appCertificateCsr),installerCertificateCsr:readCsr(options.installerCertificateCsr),profileName:options.profileName,keyId:config.apiKeyId,issuerId:config.apiIssuerId,keyPath:config.apiKeyPath,checkOnly:!options.apply}),outputDirectory=resolve(options.output||storagePath("framework/desktop-dist/apple/provisioning"));mkdirSync(outputDirectory,{recursive:!0});for(const certificate of result.certificates){if(!certificate.certificateContent)continue;const fileName=certificate.type==="MAC_APP_DISTRIBUTION"?"mac-app-distribution.cer":"mac-installer-distribution.cer";writeFileSync(join(outputDirectory,fileName),Buffer.from(certificate.certificateContent,"base64"),{mode:384})}if(result.profile.profileContent)writeFileSync(join(outputDirectory,"mac-app-store.provisionprofile"),Buffer.from(result.profile.profileContent,"base64"),{mode:384});const certificatePassword=env("APPLE_CERTIFICATE_PASSWORD"),certificateExports=[{type:"MAC_APP_DISTRIBUTION",csr:options.appCertificateCsr,certificate:"mac-app-distribution.cer",output:"mac-app-distribution.p12",name:`${config.appName} Mac App Distribution`},{type:"MAC_INSTALLER_DISTRIBUTION",csr:options.installerCertificateCsr,certificate:"mac-installer-distribution.cer",output:"mac-installer-distribution.p12",name:`${config.appName} Mac Installer Distribution`}];for(const certificateExport of certificateExports){if(!result.certificates.find((item)=>item.type===certificateExport.type)?.certificateContent||!certificateExport.csr||!certificatePassword)continue;exportAppleCertificateP12({certificatePath:join(outputDirectory,certificateExport.certificate),privateKeyPath:resolve(certificateExport.csr.replace(/\.csr$/i,".key")),outputPath:join(outputDirectory,certificateExport.output),password:certificatePassword,name:certificateExport.name})}const certificates=result.certificates.map((item)=>{const certificate={...item};delete certificate.certificateContent;return certificate}),profile={...result.profile};delete profile.profileContent;const report={...result,certificates,profile},reportPath=join(outputDirectory,"provisioning-plan.json");writeFileSync(reportPath,`${JSON.stringify(report,null,2)}
|
|
110
110
|
`,{mode:384});log.success(`${options.apply?"Applied":"Planned"} Apple provisioning: ${reportPath}`);if(!result.appRecord.exists&&result.appRecord.manualAction)log.warn(result.appRecord.manualAction)}catch(error){fail(error)}});buddy.command("desktop:apple:doctor","Validate Mac App Store tooling, credentials, certificates, and project metadata").option("--app-name <name>","Mac App Store display name").option("--bundle-id <id>","Reverse-DNS bundle identifier").option("--team-id <id>","Apple Developer team ID").option("--app-signing-identity <identity>","Mac App Distribution signing identity").option("--installer-signing-identity <identity>","Mac Installer Distribution signing identity").option("--provisioning-profile <path>","Mac App Store provisioning profile").option("--api-key-id <id>","App Store Connect API key ID").option("--api-issuer-id <id>","App Store Connect API issuer ID").option("--api-key-path <path>","App Store Connect AuthKey .p8 file").option("--keychain <path>","Look for the signing identities in this keychain file only (or APPLE_KEYCHAIN)").action(async(options)=>{try{const config=withApiKeyFile(resolveAppleDesktopConfig(options,await nativeAppleConfig())),errors=validateAppleDesktopConfig(config,!0);if(!signingIdentityExists(config.appSigningIdentity,config.keychain))errors.push(`App signing identity is not installed: ${config.appSigningIdentity}`);if(!signingIdentityExists(config.installerSigningIdentity,config.keychain))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(`
|
|
111
|
-
`));log.success(`Mac App Store prerequisites are ready for ${config.bundleId}`)}catch(error){fail(error)}});buddy.command("desktop:apple:init","Create a GitHub Actions caller for the reusable Stacks Mac App Store workflow").option("--force","Replace an existing workflow").action((options)=>{try{const workflowPath=projectPath(".github/workflows/apple-app-store.yml");if(existsSync(workflowPath)&&!options.force)throw Error(`${workflowPath} already exists. Use --force to replace it.`);mkdirSync(resolve(workflowPath,".."),{recursive:!0});writeFileSync(workflowPath,renderAppleWorkflowCaller());log.success(`Created ${workflowPath}`)}catch(error){fail(error)}});const addSharedOptions=(commandBuilder)=>commandBuilder.option("--app-name <name>","Mac App Store display name").option("--bundle-id <id>","Reverse-DNS bundle identifier").option("--team-id <id>","Apple Developer team ID").option("--app-version <version>","Marketing version").option("--build-number <number>",
|
|
111
|
+
`));log.success(`Mac App Store prerequisites are ready for ${config.bundleId}`)}catch(error){fail(error)}});buddy.command("desktop:apple:init","Create a GitHub Actions caller for the reusable Stacks Mac App Store workflow").option("--force","Replace an existing workflow").action((options)=>{try{const workflowPath=projectPath(".github/workflows/apple-app-store.yml");if(existsSync(workflowPath)&&!options.force)throw Error(`${workflowPath} already exists. Use --force to replace it.`);mkdirSync(resolve(workflowPath,".."),{recursive:!0});writeFileSync(workflowPath,renderAppleWorkflowCaller());log.success(`Created ${workflowPath}`)}catch(error){fail(error)}});const addSharedOptions=(commandBuilder)=>commandBuilder.option("--app-name <name>","Mac App Store display name").option("--bundle-id <id>","Reverse-DNS bundle identifier").option("--team-id <id>","Apple Developer team ID").option("--app-version <version>","Marketing version").option("--build-number <number>",'Unique App Store build number, or "next" for one past the highest on App Store Connect (publish only)').option("--minimum-macos <version>","Minimum supported macOS version").option("--category <category>","LSApplicationCategoryType value").option("--app-signing-identity <identity>","Mac App Distribution signing identity").option("--installer-signing-identity <identity>","Mac Installer Distribution signing identity").option("--provisioning-profile <path>","Mac App Store provisioning profile").option("--icon <path>","Optional .icns app icon").option("--skip-build","Package existing storage/framework/desktop-dist artifacts").option("--universal","Ship one arm64 + x86_64 binary, merged with lipo").option("--craft-arm64 <path>","Thin arm64 Craft runtime for --universal (or CRAFT_BIN_ARM64)").option("--craft-x64 <path>","Thin x86_64 Craft runtime for --universal (or CRAFT_BIN_X64)").option("--keychain <path>","Sign with identities from this keychain file only (or APPLE_KEYCHAIN)");addSharedOptions(buddy.command("desktop:apple:package","Build, sandbox, sign, and package a Mac App Store desktop app")).action(async(options)=>{try{const native=await nativeAppleConfig(),packagePath=await packageAppleDesktop(withKeychainIdentities(resolveAppleDesktopConfig(options,native)),Boolean(options.skipBuild),native);log.success(`Created signed Mac App Store package ${packagePath}`)}catch(error){fail(error)}});addSharedOptions(buddy.command("desktop:apple:publish","Build and validate or upload a signed Mac App Store package")).option("--api-key-id <id>","App Store Connect API key ID").option("--api-issuer-id <id>","App Store Connect API issuer ID").option("--api-key-path <path>","App Store Connect AuthKey .p8 file").option("--validate-only","Validate with App Store Connect without uploading").option("--package-only","Create the signed package without contacting App Store Connect").option("--testflight <groups>","After the upload, wait for processing and give the build to these comma-separated TestFlight groups").option("--whats-new <text>",'TestFlight "What to Test" (default: the commit subjects since the last tag)').action(async(options)=>{try{const groups=options.testflight?.split(",").map((name)=>name.trim()).filter(Boolean)??[];if(groups.length&&(options.validateOnly||options.packageOnly))throw Error("--testflight needs an upload, so it cannot be combined with --validate-only or --package-only");const native=await nativeAppleConfig(),config=await releaseConfig(options,native),packagePath=await packageAppleDesktop(config,Boolean(options.skipBuild),native);if(!options.packageOnly)validateOrUpload(packagePath,config,Boolean(options.validateOnly));log.success(options.packageOnly?`Created signed Mac App Store package ${packagePath}`:options.validateOnly?`Validated ${packagePath} with App Store Connect`:`Uploaded ${packagePath} to App Store Connect`);if(groups.length)await distributeToTestFlight(config,groups,options.whatsNew??commitsSinceLastTag())}catch(error){fail(error)}})}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import{readFileSync,writeFileSync}from"node:fs";import{resolve}from"node:path";import{assertFrameworkRepo}from"./framework-repo";async function generators(){const{renderApiClient,generateOpenApi,renderOpenApiTypes}=await import("@stacksjs/api");return{renderApiClient,generateOpenApi,renderOpenApiTypes}}const root=resolve(import.meta.dir,"../../../../../../.."),openApiPath=resolve(root,"storage/framework/api/openapi.json"),apiTypesPath=resolve(root,"storage/framework/api/api-types.ts"),clientPath=resolve(root,"storage/framework/api/client.ts");export function validateOpenApi(document){const errors=[],paths=Object.entries(document.paths||{});if(paths.length<10)errors.push("OpenAPI document has fewer than 10 registered paths");const operationIds=new Set;for(const[route,item]of paths)for(const[
|
|
1
|
+
import{readFileSync,writeFileSync}from"node:fs";import{resolve}from"node:path";import{assertFrameworkRepo}from"./framework-repo";async function generators(){const{renderApiClient,generateOpenApi,renderOpenApiTypes}=await import("@stacksjs/api");return{renderApiClient,generateOpenApi,renderOpenApiTypes}}const root=resolve(import.meta.dir,"../../../../../../.."),openApiPath=resolve(root,"storage/framework/api/openapi.json"),apiTypesPath=resolve(root,"storage/framework/api/api-types.ts"),clientPath=resolve(root,"storage/framework/api/client.ts");export function validateOpenApi(document){const errors=[],paths=Object.entries(document.paths||{});if(paths.length<10)errors.push("OpenAPI document has fewer than 10 registered paths");const operationIds=new Set;for(const[route,item]of paths)for(const method of["get","put","post","delete","options","head","patch","trace"]){const operation=item[method];if(!operation)continue;if(!operation.operationId)errors.push(`${method.toUpperCase()} ${route}: operationId is missing`);else if(operationIds.has(operation.operationId))errors.push(`${method.toUpperCase()} ${route}: duplicate operationId ${operation.operationId}`);else operationIds.add(operation.operationId);for(const parameter of[...item.parameters??[],...operation.parameters??[]])if("in"in parameter&¶meter.in==="path"&&!parameter.required)errors.push(`${method.toUpperCase()} ${route}: path parameter ${parameter.name} must be required`)}return errors}async function expectedArtifacts(){const{renderApiClient,generateOpenApi,renderOpenApiTypes}=await generators(),document=await generateOpenApi({write:!1}),errors=validateOpenApi(document);if(errors.length)throw Error(errors.join(`
|
|
2
2
|
`));return{openApi:JSON.stringify(document,null,2),apiTypes:renderOpenApiTypes(document),client:renderApiClient(document,{name:document.info?.title})}}async function write(){const expected=await expectedArtifacts();writeFileSync(openApiPath,expected.openApi);writeFileSync(apiTypesPath,expected.apiTypes);writeFileSync(clientPath,expected.client);console.log("Generated OpenAPI, API type and client artifacts")}async function check(){const expected=await expectedArtifacts(),errors=[];if(readFileSync(openApiPath,"utf8")!==expected.openApi)errors.push("storage/framework/api/openapi.json is stale");if(readFileSync(apiTypesPath,"utf8")!==expected.apiTypes)errors.push("storage/framework/api/api-types.ts is stale");if(readFileSync(clientPath,"utf8")!==expected.client)errors.push("storage/framework/api/client.ts is stale");if(errors.length)throw Error(`${errors.join(`
|
|
3
3
|
`)}
|
|
4
4
|
Run bun run docs:artifacts and review the generated diff.`);console.log(`Generated API artifacts are current (${Object.keys(JSON.parse(expected.openApi).paths).length} paths)`)}export async function run(){assertFrameworkRepo(root,"docs:artifacts");try{if(process.argv.includes("--write"))await write();else if(process.argv.includes("--check"))await check();else{console.error("usage: bun storage/framework/core/buddy/src/commands/docs/generated-artifacts.ts --write | --check");process.exit(2)}}catch(error){console.error(error instanceof Error?error.message:String(error));process.exit(1)}}if(import.meta.main)await run();
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@stacksjs/buddy",
|
|
3
3
|
"type": "module",
|
|
4
4
|
"sideEffects": false,
|
|
5
|
-
"version": "0.75.
|
|
5
|
+
"version": "0.75.443",
|
|
6
6
|
"description": "Meet Buddy. The Stacks runtime.",
|
|
7
7
|
"author": "Chris Breuer",
|
|
8
8
|
"contributors": [
|
|
@@ -95,73 +95,73 @@
|
|
|
95
95
|
"prepublishOnly": "bun run build"
|
|
96
96
|
},
|
|
97
97
|
"dependencies": {
|
|
98
|
-
"@stacksjs/actions": "0.75.
|
|
99
|
-
"@stacksjs/ai": "0.75.
|
|
100
|
-
"@stacksjs/alias": "0.75.
|
|
101
|
-
"@stacksjs/analytics": "0.75.
|
|
102
|
-
"@stacksjs/api": "0.75.
|
|
103
|
-
"@stacksjs/arrays": "0.75.
|
|
104
|
-
"@stacksjs/auth": "0.75.
|
|
105
|
-
"@stacksjs/browser-extension": "0.75.
|
|
106
|
-
"@stacksjs/build": "0.75.
|
|
98
|
+
"@stacksjs/actions": "0.75.443",
|
|
99
|
+
"@stacksjs/ai": "0.75.443",
|
|
100
|
+
"@stacksjs/alias": "0.75.443",
|
|
101
|
+
"@stacksjs/analytics": "0.75.443",
|
|
102
|
+
"@stacksjs/api": "0.75.443",
|
|
103
|
+
"@stacksjs/arrays": "0.75.443",
|
|
104
|
+
"@stacksjs/auth": "0.75.443",
|
|
105
|
+
"@stacksjs/browser-extension": "0.75.443",
|
|
106
|
+
"@stacksjs/build": "0.75.443",
|
|
107
107
|
"@stacksjs/bumpx": "^0.2.26",
|
|
108
|
-
"@stacksjs/cache": "0.75.
|
|
109
|
-
"@stacksjs/chat": "0.75.
|
|
108
|
+
"@stacksjs/cache": "0.75.443",
|
|
109
|
+
"@stacksjs/chat": "0.75.443",
|
|
110
110
|
"@stacksjs/clapp": "^0.2.16",
|
|
111
|
-
"@stacksjs/cli": "0.75.
|
|
112
|
-
"@stacksjs/cloud": "0.75.
|
|
113
|
-
"@stacksjs/cms": "0.75.
|
|
114
|
-
"@stacksjs/collections": "0.75.
|
|
115
|
-
"@stacksjs/commerce": "0.75.
|
|
116
|
-
"@stacksjs/config": "0.75.
|
|
117
|
-
"@stacksjs/database": "0.75.
|
|
118
|
-
"@stacksjs/desktop-build": "0.75.
|
|
119
|
-
"@stacksjs/dns": "0.75.
|
|
111
|
+
"@stacksjs/cli": "0.75.443",
|
|
112
|
+
"@stacksjs/cloud": "0.75.443",
|
|
113
|
+
"@stacksjs/cms": "0.75.443",
|
|
114
|
+
"@stacksjs/collections": "0.75.443",
|
|
115
|
+
"@stacksjs/commerce": "0.75.443",
|
|
116
|
+
"@stacksjs/config": "0.75.443",
|
|
117
|
+
"@stacksjs/database": "0.75.443",
|
|
118
|
+
"@stacksjs/desktop-build": "0.75.443",
|
|
119
|
+
"@stacksjs/dns": "0.75.443",
|
|
120
120
|
"@stacksjs/dnsx": "^0.2.3",
|
|
121
|
-
"@stacksjs/email": "0.75.
|
|
122
|
-
"@stacksjs/enums": "0.75.
|
|
123
|
-
"@stacksjs/env": "0.75.
|
|
124
|
-
"@stacksjs/error-handling": "0.75.
|
|
125
|
-
"@stacksjs/events": "0.75.
|
|
126
|
-
"@stacksjs/features": "0.75.
|
|
127
|
-
"@stacksjs/git": "0.75.
|
|
121
|
+
"@stacksjs/email": "0.75.443",
|
|
122
|
+
"@stacksjs/enums": "0.75.443",
|
|
123
|
+
"@stacksjs/env": "0.75.443",
|
|
124
|
+
"@stacksjs/error-handling": "0.75.443",
|
|
125
|
+
"@stacksjs/events": "0.75.443",
|
|
126
|
+
"@stacksjs/features": "0.75.443",
|
|
127
|
+
"@stacksjs/git": "0.75.443",
|
|
128
128
|
"@stacksjs/gitit": "^0.2.5",
|
|
129
|
-
"@stacksjs/health": "0.75.
|
|
129
|
+
"@stacksjs/health": "0.75.443",
|
|
130
130
|
"@stacksjs/httx": "^0.1.10",
|
|
131
|
-
"@stacksjs/image": "0.75.
|
|
132
|
-
"@stacksjs/lint": "0.75.
|
|
133
|
-
"@stacksjs/logging": "0.75.
|
|
134
|
-
"@stacksjs/notifications": "0.75.
|
|
135
|
-
"@stacksjs/objects": "0.75.
|
|
136
|
-
"@stacksjs/orm": "0.75.
|
|
137
|
-
"@stacksjs/path": "0.75.
|
|
138
|
-
"@stacksjs/payments": "0.75.
|
|
139
|
-
"@stacksjs/realtime": "0.75.
|
|
140
|
-
"@stacksjs/router": "0.75.
|
|
131
|
+
"@stacksjs/image": "0.75.443",
|
|
132
|
+
"@stacksjs/lint": "0.75.443",
|
|
133
|
+
"@stacksjs/logging": "0.75.443",
|
|
134
|
+
"@stacksjs/notifications": "0.75.443",
|
|
135
|
+
"@stacksjs/objects": "0.75.443",
|
|
136
|
+
"@stacksjs/orm": "0.75.443",
|
|
137
|
+
"@stacksjs/path": "0.75.443",
|
|
138
|
+
"@stacksjs/payments": "0.75.443",
|
|
139
|
+
"@stacksjs/realtime": "0.75.443",
|
|
140
|
+
"@stacksjs/router": "0.75.443",
|
|
141
141
|
"@stacksjs/rpx": "^0.11.54",
|
|
142
|
-
"@stacksjs/scheduler": "0.75.
|
|
143
|
-
"@stacksjs/search-engine": "0.75.
|
|
144
|
-
"@stacksjs/security": "0.75.
|
|
145
|
-
"@stacksjs/server": "0.75.
|
|
146
|
-
"@stacksjs/sites": "0.75.
|
|
147
|
-
"@stacksjs/skills": "0.75.
|
|
148
|
-
"@stacksjs/socials": "0.75.
|
|
149
|
-
"@stacksjs/storage": "0.75.
|
|
150
|
-
"@stacksjs/strings": "0.75.
|
|
151
|
-
"@stacksjs/stx": "0.2.
|
|
152
|
-
"@stacksjs/testing": "0.75.
|
|
153
|
-
"@stacksjs/tinker": "0.75.
|
|
142
|
+
"@stacksjs/scheduler": "0.75.443",
|
|
143
|
+
"@stacksjs/search-engine": "0.75.443",
|
|
144
|
+
"@stacksjs/security": "0.75.443",
|
|
145
|
+
"@stacksjs/server": "0.75.443",
|
|
146
|
+
"@stacksjs/sites": "0.75.443",
|
|
147
|
+
"@stacksjs/skills": "0.75.443",
|
|
148
|
+
"@stacksjs/socials": "0.75.443",
|
|
149
|
+
"@stacksjs/storage": "0.75.443",
|
|
150
|
+
"@stacksjs/strings": "0.75.443",
|
|
151
|
+
"@stacksjs/stx": "0.2.787",
|
|
152
|
+
"@stacksjs/testing": "0.75.443",
|
|
153
|
+
"@stacksjs/tinker": "0.75.443",
|
|
154
154
|
"@stacksjs/tlsx": "^0.13.19",
|
|
155
155
|
"@stacksjs/ts-cloud": "^0.16.91",
|
|
156
|
-
"@stacksjs/tunnel": "0.75.
|
|
157
|
-
"@stacksjs/types": "0.75.
|
|
158
|
-
"@stacksjs/ui": "0.75.
|
|
159
|
-
"@stacksjs/utils": "0.75.
|
|
160
|
-
"@stacksjs/validation": "0.75.
|
|
156
|
+
"@stacksjs/tunnel": "0.75.443",
|
|
157
|
+
"@stacksjs/types": "0.75.443",
|
|
158
|
+
"@stacksjs/ui": "0.75.443",
|
|
159
|
+
"@stacksjs/utils": "0.75.443",
|
|
160
|
+
"@stacksjs/validation": "0.75.443",
|
|
161
161
|
"ajv": "^8.20.0",
|
|
162
162
|
"ajv-formats": "^3.0.1",
|
|
163
|
-
"bun-plugin-stx": "0.2.
|
|
164
|
-
"ts-pantry": "^0.11.
|
|
163
|
+
"bun-plugin-stx": "0.2.787",
|
|
164
|
+
"ts-pantry": "^0.11.161"
|
|
165
165
|
},
|
|
166
166
|
"devDependencies": {
|
|
167
167
|
"better-dx": "^0.2.24"
|