@stacksjs/buddy 0.75.408 → 0.75.417

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.
@@ -1,2 +1,2 @@
1
1
  import{existsSync,readFileSync}from"node:fs";import{nextBuildNumber}from"@stacksjs/bumpx";const API="https://api.appstoreconnect.apple.com";export function appStoreConnectCredentials(env=process.env){const keyId=env.APP_STORE_CONNECT_KEY_ID?.trim(),issuerId=env.APP_STORE_CONNECT_ISSUER_ID?.trim();let privateKey=env.APP_STORE_CONNECT_PRIVATE_KEY?.trim();const keyPath=env.APP_STORE_CONNECT_PRIVATE_KEY_PATH?.trim();if(!privateKey&&keyPath&&existsSync(keyPath))privateKey=readFileSync(keyPath,"utf8").trim();if(!keyId||!issuerId||!privateKey)return null;return{keyId,issuerId,privateKey:privateKey.replace(/\\n/g,`
2
- `)}}function base64url(bytes){return(typeof bytes==="string"?Buffer.from(bytes):Buffer.from(bytes)).toString("base64").replace(/=+$/,"").replace(/\+/g,"-").replace(/\//g,"_")}export async function appStoreConnectToken(credentials,now=Date.now()){const pem=credentials.privateKey.replace(/-----(BEGIN|END) PRIVATE KEY-----/g,"").replace(/\s+/g,""),key=await crypto.subtle.importKey("pkcs8",Buffer.from(pem,"base64"),{name:"ECDSA",namedCurve:"P-256"},!1,["sign"]),iat=Math.floor(now/1000),header=base64url(JSON.stringify({alg:"ES256",kid:credentials.keyId,typ:"JWT"})),payload=base64url(JSON.stringify({iss:credentials.issuerId,iat,exp:iat+1200,aud:"appstoreconnect-v1"})),signature=await crypto.subtle.sign({name:"ECDSA",hash:"SHA-256"},key,Buffer.from(`${header}.${payload}`));return`${header}.${payload}.${base64url(new Uint8Array(signature))}`}export class AppStoreConnect{token;fetchImpl;constructor(token,fetchImpl=fetch){this.token=token;this.fetchImpl=fetchImpl}static async connect(credentials,fetchImpl){return new AppStoreConnect(await appStoreConnectToken(credentials),fetchImpl)}async request(path,init={}){const response=await this.fetchImpl(`${API}${path}`,{...init,headers:{Authorization:`Bearer ${this.token}`,"Content-Type":"application/json",...init.headers}});if(!response.ok){const body=await response.text().catch(()=>"");throw Error(`App Store Connect ${init.method??"GET"} ${path} answered ${response.status}${body?`: ${body.slice(0,300)}`:""}`)}return response.status===204?null:await response.json()}async appId(bundleId){const id=(await this.request(`/v1/apps?filter[bundleId]=${encodeURIComponent(bundleId)}&limit=1`))?.data?.[0]?.id;if(!id)throw Error(`App Store Connect has no app with the bundle id ${bundleId}`);return id}async latestXcodeCloudRun(appId){const productId=(await this.request(`/v1/ciProducts?filter[app]=${appId}&limit=1`))?.data?.[0]?.id;if(!productId)return null;const runs=await this.request(`/v1/ciProducts/${productId}/buildRuns?sort=-number&limit=1&fields[ciBuildRuns]=number`),number=Number(runs?.data?.[0]?.attributes?.number);return Number.isSafeInteger(number)&&number>=0?number:null}async builds(appId){return((await this.request(`/v1/builds?filter[app]=${appId}&sort=-uploadedDate&limit=50&fields[builds]=version,uploadedDate,processingState,expired`))?.data??[]).map((build)=>({id:build.id,number:Number(build.attributes?.version),uploadedDate:String(build.attributes?.uploadedDate??""),processingState:String(build.attributes?.processingState??""),expired:Boolean(build.attributes?.expired)}))}async build(appId,version,number){const query=`filter[app]=${appId}&filter[version]=${encodeURIComponent(String(number))}&filter[preReleaseVersion.version]=${encodeURIComponent(version)}&limit=1&fields[builds]=version,uploadedDate,processingState,expired`,build=(await this.request(`/v1/builds?${query}`))?.data?.[0];return build?{id:build.id,number:Number(build.attributes?.version),uploadedDate:String(build.attributes?.uploadedDate??""),processingState:String(build.attributes?.processingState??""),expired:Boolean(build.attributes?.expired)}:null}async setWhatToTest(buildId,notes){const existing=await this.request(`/v1/builds/${buildId}/betaBuildLocalizations?limit=50&fields[betaBuildLocalizations]=locale,whatsNew`),byLocale=new Map((existing?.data??[]).map((row)=>[String(row.attributes?.locale),String(row.id)]));for(const[locale,whatsNew]of Object.entries(notes)){const id=byLocale.get(locale);if(id)await this.request(`/v1/betaBuildLocalizations/${id}`,{method:"PATCH",body:JSON.stringify({data:{type:"betaBuildLocalizations",id,attributes:{whatsNew}}})});else await this.request("/v1/betaBuildLocalizations",{method:"POST",body:JSON.stringify({data:{type:"betaBuildLocalizations",attributes:{locale,whatsNew},relationships:{build:{data:{type:"builds",id:buildId}}}}})})}}async expire(buildId){await this.request(`/v1/builds/${buildId}`,{method:"PATCH",body:JSON.stringify({data:{type:"builds",id:buildId,attributes:{expired:!0}}})})}}export function buildsToExpire(builds,keep=1){return builds.filter((build)=>!build.expired&&build.processingState==="VALID").sort((a,b)=>b.number-a.number).slice(Math.max(0,keep))}export function tagForXcodeCloudRun(version,latestRun){return`v${version}-build.${nextBuildNumber(latestRun)}`}export async function waitForBuild(api,appId,version,number,options){const sleep=options.sleep??((ms)=>new Promise((resolve)=>setTimeout(resolve,ms))),now=options.now??Date.now,interval=options.intervalMs??30000,start=now();for(;;){const build=await api.build(appId,version,number);if(build)return build;const elapsed=now()-start;if(elapsed+interval>options.timeoutMs)return null;options.onWait?.(elapsed);await sleep(interval)}}
2
+ `)}}function base64url(bytes){return(typeof bytes==="string"?Buffer.from(bytes):Buffer.from(bytes)).toString("base64").replace(/=+$/,"").replace(/\+/g,"-").replace(/\//g,"_")}export async function appStoreConnectToken(credentials,now=Date.now()){const pem=credentials.privateKey.replace(/-----(BEGIN|END) PRIVATE KEY-----/g,"").replace(/\s+/g,""),key=await crypto.subtle.importKey("pkcs8",Buffer.from(pem,"base64"),{name:"ECDSA",namedCurve:"P-256"},!1,["sign"]),iat=Math.floor(now/1000),header=base64url(JSON.stringify({alg:"ES256",kid:credentials.keyId,typ:"JWT"})),payload=base64url(JSON.stringify({iss:credentials.issuerId,iat,exp:iat+1200,aud:"appstoreconnect-v1"})),signature=await crypto.subtle.sign({name:"ECDSA",hash:"SHA-256"},key,Buffer.from(`${header}.${payload}`));return`${header}.${payload}.${base64url(new Uint8Array(signature))}`}export class AppStoreConnect{token;fetchImpl;tokenProvider;renewAt=Number.POSITIVE_INFINITY;renewing;constructor(token,fetchImpl=fetch){this.token=token;this.fetchImpl=fetchImpl}static async connect(credentials,fetchImpl){const{keyId,issuerId,privateKey}=credentials,issue=async()=>{const now=Date.now();return{token:await appStoreConnectToken({keyId,issuerId,privateKey},now),renewAt:now+900000}},issued=await issue(),api=new AppStoreConnect(issued.token,fetchImpl);api.tokenProvider=issue;api.renewAt=issued.renewAt;return api}async authorization(){if(this.tokenProvider&&Date.now()>=this.renewAt){if(!this.renewing)this.renewing=this.tokenProvider().then((issued)=>{this.token=issued.token;this.renewAt=issued.renewAt}).finally(()=>{this.renewing=void 0});await this.renewing}return`Bearer ${this.token}`}async request(path,init={}){const response=await this.fetchImpl(`${API}${path}`,{...init,headers:{Authorization:await this.authorization(),"Content-Type":"application/json",...init.headers}});if(!response.ok){const body=await response.text().catch(()=>"");throw Error(`App Store Connect ${init.method??"GET"} ${path} answered ${response.status}${body?`: ${body.slice(0,300)}`:""}`)}return response.status===204?null:await response.json()}async appId(bundleId){const id=(await this.request(`/v1/apps?filter[bundleId]=${encodeURIComponent(bundleId)}&limit=1`))?.data?.[0]?.id;if(!id)throw Error(`App Store Connect has no app with the bundle id ${bundleId}`);return id}async latestXcodeCloudRun(appId){const productId=(await this.request(`/v1/ciProducts?filter[app]=${appId}&limit=1`))?.data?.[0]?.id;if(!productId)return null;const runs=await this.request(`/v1/ciProducts/${productId}/buildRuns?sort=-number&limit=1&fields[ciBuildRuns]=number`),number=Number(runs?.data?.[0]?.attributes?.number);return Number.isSafeInteger(number)&&number>=0?number:null}async builds(appId){return((await this.request(`/v1/builds?filter[app]=${appId}&sort=-uploadedDate&limit=50&fields[builds]=version,uploadedDate,processingState,expired`))?.data??[]).map((build)=>({id:build.id,number:Number(build.attributes?.version),uploadedDate:String(build.attributes?.uploadedDate??""),processingState:String(build.attributes?.processingState??""),expired:Boolean(build.attributes?.expired)}))}async build(appId,version,number){const query=`filter[app]=${appId}&filter[version]=${encodeURIComponent(String(number))}&filter[preReleaseVersion.version]=${encodeURIComponent(version)}&limit=1&fields[builds]=version,uploadedDate,processingState,expired`,build=(await this.request(`/v1/builds?${query}`))?.data?.[0];return build?{id:build.id,number:Number(build.attributes?.version),uploadedDate:String(build.attributes?.uploadedDate??""),processingState:String(build.attributes?.processingState??""),expired:Boolean(build.attributes?.expired)}:null}async setWhatToTest(buildId,notes){const existing=await this.request(`/v1/builds/${buildId}/betaBuildLocalizations?limit=50&fields[betaBuildLocalizations]=locale,whatsNew`),byLocale=new Map((existing?.data??[]).map((row)=>[String(row.attributes?.locale),String(row.id)]));for(const[locale,whatsNew]of Object.entries(notes)){const id=byLocale.get(locale);if(id)await this.request(`/v1/betaBuildLocalizations/${id}`,{method:"PATCH",body:JSON.stringify({data:{type:"betaBuildLocalizations",id,attributes:{whatsNew}}})});else await this.request("/v1/betaBuildLocalizations",{method:"POST",body:JSON.stringify({data:{type:"betaBuildLocalizations",attributes:{locale,whatsNew},relationships:{build:{data:{type:"builds",id:buildId}}}}})})}}async expire(buildId){await this.request(`/v1/builds/${buildId}`,{method:"PATCH",body:JSON.stringify({data:{type:"builds",id:buildId,attributes:{expired:!0}}})})}}export function buildsToExpire(builds,keep=1){return builds.filter((build)=>!build.expired&&build.processingState==="VALID").sort((a,b)=>b.number-a.number).slice(Math.max(0,keep))}export function tagForXcodeCloudRun(version,latestRun){return`v${version}-build.${nextBuildNumber(latestRun)}`}export async function waitForBuild(api,appId,version,number,options){const sleep=options.sleep??((ms)=>new Promise((resolve)=>setTimeout(resolve,ms))),now=options.now??Date.now,interval=options.intervalMs??30000,start=now();for(;;){const build=await api.build(appId,version,number);if(build)return build;const elapsed=now()-start;if(elapsed+interval>options.timeoutMs)return null;options.onWait?.(elapsed);await sleep(interval)}}
@@ -1 +1 @@
1
- import process from"node:process";import{intro,log,multiselect,onUnknownSubcommand,outro}from"@stacksjs/cli";import{Action}from"@stacksjs/enums";import{hasTTY,isCI}from"@stacksjs/env";import{projectPath}from"@stacksjs/path";import{ExitCode}from"@stacksjs/types";import{resultFailed}from"../result";let _runAction;async function runAction(...args){if(!_runAction)_runAction=(await import("@stacksjs/actions")).runAction;return _runAction(...args)}export function build(buddy){const descriptions={build:"Build any of your libraries (packages) for production use",components:"Build your component library",webComponents:"Build your framework agnostic web component library",elements:"An alias to the -w flag",buddy:"Build the Buddy binary",functions:"Build your function library",libs:"Build every package configured in config/library.ts",desktop:"Build the Desktop Application",mobile:"Build the native iOS and Android applications",android:"Build the native Android application",ios:"Build the native iOS application",dmg:"Package the desktop build as a macOS .app inside a .dmg",pages:"Build your frontend",docs:"Build your documentation",framework:"Build Stacks framework",cli:"Automagically build the CLI",server:"Build the Stacks cloud server (Docker image)",frontendStatic:"Build the prerendered marketing/public static site (storage/framework/frontend-dist)",select:"What are you trying to build?",project:"Target a specific project",verbose:"Enable verbose output"};buddy.command("build [type]",descriptions.build).option("-c, --components",descriptions.components).option("-w, --web-components",descriptions.webComponents).option("-e, --elements",descriptions.elements).option("-f, --functions",descriptions.functions).option("-l, --libs",descriptions.libs).option("-k, --desktop",descriptions.desktop).option("-m, --mobile",descriptions.mobile).option("--android",descriptions.android).option("--ios",descriptions.ios).option("-p, --views",descriptions.pages).option("--pages",descriptions.pages).option("-d, --docs",descriptions.docs).option("-b, --buddy",descriptions.buddy,{default:!1}).option("-s, --stacks",descriptions.framework,{default:!1}).option("--project [project]",descriptions.project,{default:!1}).option("--server",descriptions.server,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(server,options)=>{log.debug("Running `buddy build` ...",options);applyBuildTarget(server,options);if(options.mobile){options.android=!0;options.ios=!0}if(hasNoOptions(options)){if(!isCI&&hasTTY&&process.stdin.isTTY){const answers=await multiselect({message:descriptions.select,choices:[{label:"Frontend (views)",value:"views"},{label:"Components",value:"components"},{label:"Web Components",value:"webComponents"},{label:"Functions",value:"functions"},{label:"All library packages",value:"libs"},{label:"Desktop application",value:"desktop"},{label:"Mobile applications (iOS + Android)",value:"mobile"},{label:"Android application",value:"android"},{label:"iOS application",value:"ios"},{label:"Documentation",value:"docs"},{label:"Stacks framework",value:"stacks"},{label:"Buddy CLI",value:"buddy"},{label:"Server (Docker image)",value:"server"}]}),selected=new Set(answers);if(selected.has("views"))options.views=!0;if(selected.has("components"))options.components=!0;if(selected.has("webComponents"))options.webComponents=!0;if(selected.has("functions"))options.functions=!0;if(selected.has("libs"))options.libs=!0;if(selected.has("desktop"))options.desktop=!0;if(selected.has("mobile")){options.mobile=!0;options.android=!0;options.ios=!0}if(selected.has("android"))options.android=!0;if(selected.has("ios"))options.ios=!0;if(selected.has("docs"))options.docs=!0;if(selected.has("stacks"))options.stacks=!0;if(selected.has("buddy"))options.buddy=!0;if(selected.has("server"))options.server=!0}if(hasNoOptions(options)){options.views=!0;log.info("No build target specified, defaulting to the frontend (views). See `buddy build --help` for all targets.")}}let succeeded=!0;if(options.docs)succeeded=await runBuildAction(Action.BuildDocs,"documentation",options)&&succeeded;if(options.components)succeeded=await runBuildAction(Action.BuildComponentLibs,"component libraries",options)&&succeeded;if(options.webComponents)succeeded=await runBuildAction(Action.BuildWebComponentLib,"web component library",options)&&succeeded;if(options.functions)succeeded=await runBuildAction(Action.BuildFunctionLib,"function library",options)&&succeeded;if(options.libs)succeeded=await runBuildAction(Action.BuildLibs,"library packages",options)&&succeeded;if(options.desktop)succeeded=await runBuildAction(Action.BuildDesktop,"desktop application",options)&&succeeded;if(options.android)succeeded=await runBuildAction(Action.BuildAndroid,"Android application",options)&&succeeded;if(options.ios)succeeded=await runBuildAction(Action.BuildIos,"iOS application",options)&&succeeded;if(options.views)succeeded=await runBuildAction(Action.BuildViews,"frontend",options)&&succeeded;if(options.stacks)succeeded=await runBuildAction(Action.BuildStacks,"Stacks framework",options)&&succeeded;if(options.buddy)succeeded=await runBuildAction(Action.BuildCli,"Buddy CLI",options)&&succeeded;if(options.server)succeeded=await runBuildAction(Action.BuildServer,"server",options)&&succeeded;if(!succeeded)process.exit(ExitCode.FatalError);process.exit(ExitCode.Success)});buddy.command("build:components","Automagically build component libraries for production use & npm/CDN distribution").alias("prod:components").option("-c, --components",descriptions.components,{default:!0}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy build:components` ...",options);if(!await runBuildAction(Action.BuildComponentLibs,"component libraries",options))process.exit(ExitCode.FatalError)});buddy.command("build:cli",descriptions.cli).alias("prod:cli").option("-b, --buddy",descriptions.buddy,{default:!0}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy build:cli` ...",options);await runAction(Action.BuildCli,options)});buddy.command("build:server",descriptions.server).alias("prod:server").alias("build:docker").option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy build:server` ...",options);await runAction(Action.BuildServer,options)});buddy.command("build:functions","Automagically build function library for npm/CDN distribution").option("-f, --functions",descriptions.functions,{default:!0}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy build:functions` ...",options);if(!await runBuildAction(Action.BuildFunctionLib,"function library",options))process.exit(ExitCode.FatalError)});buddy.command("build:libs",descriptions.libs).alias("build:libraries").alias("prod:libs").option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy build:libs` ...",options);if(!await runBuildAction(Action.BuildLibs,"library packages",options))process.exit(ExitCode.FatalError)});buddy.command("build:web-components","Automagically build Web Component library for npm/CDN distribution").alias("build:wc").alias("prod:web-components").alias("prod:wc").option("-w, --web-components",descriptions.webComponents,{default:!0}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy build:web-components` ...",options);if(!await runBuildAction(Action.BuildWebComponentLib,"web component library",options))process.exit(ExitCode.FatalError)});buddy.command("build:frontend",descriptions.pages).alias("build:pages").alias("build:views").alias("prod:frontend").option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy build:frontend` ...",options);await runAction(Action.BuildViews,options)});buddy.command("build:docs","Automagically build your documentation site.").alias("prod:docs").alias("build:documentation").alias("prod:documentation").option("-d, --docs",descriptions.docs,{default:!0}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy build:docs` ...",options);await runAction(Action.BuildDocs,options)});buddy.command("build:frontend-static",descriptions.frontendStatic).alias("build:public").alias("prod:frontend-static").option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy build:frontend-static` ...",options);await runAction(Action.BuildFrontendStatic,options)});buddy.command("build:core","Automagically build the Stacks core.").option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy build:core` ...",options);const startTime=await intro("buddy build:core"),result=await runAction(Action.BuildCore,options);if(resultFailed(result)){await log.error("Failed to build the Stacks core.",result.error);process.exit(ExitCode.FatalError)}await outro("Core packages built successfully",{startTime,useSeconds:!0})});buddy.command("build:desktop",descriptions.desktop).alias("prod:desktop").option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy build:desktop` ...",options);const perf=await intro("buddy build:desktop"),{hasMacosDesktop}=await import("@stacksjs/desktop-build"),result=await runAction(await hasMacosDesktop(projectPath())?Action.BuildDesktopMacos:Action.BuildDesktop,options);if(resultFailed(result)){await outro("While running the build:desktop command, there was an issue",{startTime:perf,useSeconds:!0},result.error);process.exit(ExitCode.FatalError)}console.log("");await outro("Exited",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("build:mobile",descriptions.mobile).alias("prod:mobile").option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy build:mobile` ...",options);const perf=await intro("buddy build:mobile"),androidSucceeded=await runBuildAction(Action.BuildAndroid,"Android application",options),iosSucceeded=await runBuildAction(Action.BuildIos,"iOS application",options);if(!androidSucceeded||!iosSucceeded){await outro("One or more mobile application builds failed",{startTime:perf,useSeconds:!0,type:"error"});process.exit(ExitCode.FatalError)}await outro("iOS and Android applications built",{startTime:perf,useSeconds:!0})});buddy.command("build:android",descriptions.android).alias("prod:android").option("-p, --project [project]",descriptions.project,{default:!1}).option("--bundle","Build a signed Android App Bundle (.aab) for Google Play (CRAFT_ANDROID_KEYSTORE and friends)",{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy build:android` ...",options);if(options.bundle)process.env.STACKS_ANDROID_BUNDLE="1";const perf=await intro("buddy build:android"),result=await runAction(Action.BuildAndroid,options);if(resultFailed(result)){await outro("While building the Android application, there was an issue",{startTime:perf,useSeconds:!0},result.error);process.exit(ExitCode.FatalError)}await outro("Android application built",{startTime:perf,useSeconds:!0})});buddy.command("build:ios",descriptions.ios).alias("prod:ios").option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy build:ios` ...",options);const perf=await intro("buddy build:ios"),result=await runAction(Action.BuildIos,options);if(resultFailed(result)){await outro("While building the iOS application, there was an issue",{startTime:perf,useSeconds:!0},result.error);process.exit(ExitCode.FatalError)}await outro("iOS application built",{startTime:perf,useSeconds:!0})});buddy.command("build:dmg",descriptions.dmg).option("--app-name <name>","Bundle display name (or DESKTOP_APP_NAME / APP_NAME)").option("--app-version <version>","Bundle version (or DESKTOP_APP_VERSION; defaults to the project's package.json)").option("--bundle-id <id>","Reverse-DNS bundle identifier (or DESKTOP_BUNDLE_ID)").option("--signing-identity <identity>","Developer ID identity to sign with (or DESKTOP_SIGNING_IDENTITY)").option("--notary-profile <profile>","notarytool keychain profile to notarize and staple with (or DESKTOP_NOTARY_PROFILE)").option("--volume-name <name>","Name the mounted image shows (or DESKTOP_VOLUME_NAME)").option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy build:dmg` ...",options);const perf=await intro("buddy build:dmg"),result=await runAction(Action.BuildDmg,options);if(resultFailed(result)){await outro("While running the build:dmg command, there was an issue",{startTime:perf,useSeconds:!0},result.error);process.exit(ExitCode.FatalError)}console.log("");await outro("Exited",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("build:stacks","Build the Stacks framework.").option("-s, --stacks",descriptions.framework,{default:!0}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy build:stacks` ...",options);const startTime=await intro("buddy build:stacks"),result=await runAction(Action.BuildStacks,options);if(resultFailed(result)){await log.error("Failed to build Stacks.",result.error);process.exit(ExitCode.FatalError)}await outro("Stacks built successfully",{startTime,useSeconds:!0})});onUnknownSubcommand(buddy,"build")}function hasNoOptions(options){return!options.components&&!options.webComponents&&!options.elements&&!options.functions&&!options.libs&&!options.desktop&&!options.mobile&&!options.android&&!options.ios&&!options.views&&!options.docs&&!options.stacks&&!options.buddy&&!options.server}export function applyBuildTarget(target,options){switch(target){case"components":options.components=!0;break;case"web-components":options.webComponents=!0;break;case"functions":options.functions=!0;break;case"libs":case"libraries":options.libs=!0;break;case"desktop":options.desktop=!0;break;case"mobile":options.mobile=!0;options.android=!0;options.ios=!0;break;case"android":options.android=!0;break;case"ios":options.ios=!0;break;case"views":options.views=!0;break;case"docs":options.docs=!0;break;case"buddy":case"cli":options.buddy=!0;break;case"stacks":options.stacks=!0;break;case"server":options.server=!0;break}}async function runBuildAction(action,target,options){const result=await runAction(action,options);if(resultFailed(result)){log.error(`Failed to build ${target}.`,result.error);return!1}return!0}
1
+ import process from"node:process";import{intro,log,multiselect,onUnknownSubcommand,outro}from"@stacksjs/cli";import{Action}from"@stacksjs/enums";import{hasTTY,isCI}from"@stacksjs/env";import{projectPath}from"@stacksjs/path";import{ExitCode}from"@stacksjs/types";import{resultFailed}from"../result";let _runAction;async function runAction(...args){if(!_runAction)_runAction=(await import("@stacksjs/actions")).runAction;return _runAction(...args)}export function build(buddy){const descriptions={build:"Build any of your libraries (packages) for production use",components:"Build your component library",webComponents:"Build your framework agnostic web component library",elements:"An alias to the -w flag",buddy:"Build the Buddy binary",functions:"Build your function library",libs:"Build every package configured in config/library.ts",desktop:"Build the Desktop Application",mobile:"Build the native iOS and Android applications",android:"Build the native Android application",ios:"Build the native iOS application",dmg:"Package the desktop build as a macOS .app inside a .dmg",pages:"Build your frontend",docs:"Build your documentation",framework:"Build Stacks framework",cli:"Automagically build the CLI",server:"Build the Stacks cloud server (Docker image)",frontendStatic:"Build the prerendered marketing/public static site (storage/framework/frontend-dist)",select:"What are you trying to build?",project:"Target a specific project",verbose:"Enable verbose output"};buddy.command("build [type]",descriptions.build).option("-c, --components",descriptions.components).option("-w, --web-components",descriptions.webComponents).option("-e, --elements",descriptions.elements).option("-f, --functions",descriptions.functions).option("-l, --libs",descriptions.libs).option("-k, --desktop",descriptions.desktop).option("-m, --mobile",descriptions.mobile).option("--android",descriptions.android).option("--ios",descriptions.ios).option("-p, --views",descriptions.pages).option("--pages",descriptions.pages).option("-d, --docs",descriptions.docs).option("-b, --buddy",descriptions.buddy,{default:!1}).option("-s, --stacks",descriptions.framework,{default:!1}).option("--project [project]",descriptions.project,{default:!1}).option("--server",descriptions.server,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(server,options)=>{log.debug("Running `buddy build` ...",options);applyBuildTarget(server,options);if(options.mobile){options.android=!0;options.ios=!0}if(hasNoOptions(options)){if(!isCI&&hasTTY&&process.stdin.isTTY){const answers=await multiselect({message:descriptions.select,choices:[{label:"Frontend (views)",value:"views"},{label:"Components",value:"components"},{label:"Web Components",value:"webComponents"},{label:"Functions",value:"functions"},{label:"All library packages",value:"libs"},{label:"Desktop application",value:"desktop"},{label:"Mobile applications (iOS + Android)",value:"mobile"},{label:"Android application",value:"android"},{label:"iOS application",value:"ios"},{label:"Documentation",value:"docs"},{label:"Stacks framework",value:"stacks"},{label:"Buddy CLI",value:"buddy"},{label:"Server (Docker image)",value:"server"}]}),selected=new Set(answers);if(selected.has("views"))options.views=!0;if(selected.has("components"))options.components=!0;if(selected.has("webComponents"))options.webComponents=!0;if(selected.has("functions"))options.functions=!0;if(selected.has("libs"))options.libs=!0;if(selected.has("desktop"))options.desktop=!0;if(selected.has("mobile")){options.mobile=!0;options.android=!0;options.ios=!0}if(selected.has("android"))options.android=!0;if(selected.has("ios"))options.ios=!0;if(selected.has("docs"))options.docs=!0;if(selected.has("stacks"))options.stacks=!0;if(selected.has("buddy"))options.buddy=!0;if(selected.has("server"))options.server=!0}if(hasNoOptions(options)){options.views=!0;log.info("No build target specified, defaulting to the frontend (views). See `buddy build --help` for all targets.")}}let succeeded=!0;if(options.docs)succeeded=await runBuildAction(Action.BuildDocs,"documentation",options)&&succeeded;if(options.components)succeeded=await runBuildAction(Action.BuildComponentLibs,"component libraries",options)&&succeeded;if(options.webComponents)succeeded=await runBuildAction(Action.BuildWebComponentLib,"web component library",options)&&succeeded;if(options.functions)succeeded=await runBuildAction(Action.BuildFunctionLib,"function library",options)&&succeeded;if(options.libs)succeeded=await runBuildAction(Action.BuildLibs,"library packages",options)&&succeeded;if(options.desktop)succeeded=await runBuildAction(Action.BuildDesktop,"desktop application",options)&&succeeded;if(options.android)succeeded=await runBuildAction(Action.BuildAndroid,"Android application",options)&&succeeded;if(options.ios)succeeded=await runBuildAction(Action.BuildIos,"iOS application",options)&&succeeded;if(options.views)succeeded=await runBuildAction(Action.BuildViews,"frontend",options)&&succeeded;if(options.stacks)succeeded=await runBuildAction(Action.BuildStacks,"Stacks framework",options)&&succeeded;if(options.buddy)succeeded=await runBuildAction(Action.BuildCli,"Buddy CLI",options)&&succeeded;if(options.server)succeeded=await runBuildAction(Action.BuildServer,"server",options)&&succeeded;if(!succeeded)process.exit(ExitCode.FatalError);process.exit(ExitCode.Success)});buddy.command("build:components","Automagically build component libraries for production use & npm/CDN distribution").alias("prod:components").option("-c, --components",descriptions.components,{default:!0}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy build:components` ...",options);if(!await runBuildAction(Action.BuildComponentLibs,"component libraries",options))process.exit(ExitCode.FatalError)});buddy.command("build:cli",descriptions.cli).alias("prod:cli").option("-b, --buddy",descriptions.buddy,{default:!0}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy build:cli` ...",options);await runAction(Action.BuildCli,options)});buddy.command("build:server",descriptions.server).alias("prod:server").alias("build:docker").option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy build:server` ...",options);await runAction(Action.BuildServer,options)});buddy.command("build:functions","Automagically build function library for npm/CDN distribution").option("-f, --functions",descriptions.functions,{default:!0}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy build:functions` ...",options);if(!await runBuildAction(Action.BuildFunctionLib,"function library",options))process.exit(ExitCode.FatalError)});buddy.command("build:libs",descriptions.libs).alias("build:libraries").alias("prod:libs").option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy build:libs` ...",options);if(!await runBuildAction(Action.BuildLibs,"library packages",options))process.exit(ExitCode.FatalError)});buddy.command("build:web-components","Automagically build Web Component library for npm/CDN distribution").alias("build:wc").alias("prod:web-components").alias("prod:wc").option("-w, --web-components",descriptions.webComponents,{default:!0}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy build:web-components` ...",options);if(!await runBuildAction(Action.BuildWebComponentLib,"web component library",options))process.exit(ExitCode.FatalError)});buddy.command("build:frontend",descriptions.pages).alias("build:pages").alias("build:views").alias("prod:frontend").option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy build:frontend` ...",options);await runAction(Action.BuildViews,options)});buddy.command("build:docs","Automagically build your documentation site.").alias("prod:docs").alias("build:documentation").alias("prod:documentation").option("-d, --docs",descriptions.docs,{default:!0}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy build:docs` ...",options);await runAction(Action.BuildDocs,options)});buddy.command("build:frontend-static",descriptions.frontendStatic).alias("build:public").alias("prod:frontend-static").option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy build:frontend-static` ...",options);await runAction(Action.BuildFrontendStatic,options)});buddy.command("build:core","Automagically build the Stacks core.").option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy build:core` ...",options);const startTime=await intro("buddy build:core"),result=await runAction(Action.BuildCore,options);if(resultFailed(result)){await log.error("Failed to build the Stacks core.",result.error);process.exit(ExitCode.FatalError)}await outro("Core packages built successfully",{startTime,useSeconds:!0})});buddy.command("build:desktop",descriptions.desktop).alias("prod:desktop").option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy build:desktop` ...",options);const perf=await intro("buddy build:desktop"),{hasMacosDesktop}=await import("@stacksjs/desktop-build"),result=await runAction(await hasMacosDesktop(projectPath())?Action.BuildDesktopMacos:Action.BuildDesktop,options);if(resultFailed(result)){await outro("While running the build:desktop command, there was an issue",{startTime:perf,useSeconds:!0},result.error);process.exit(ExitCode.FatalError)}console.log("");await outro("Exited",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("build:mobile",descriptions.mobile).alias("prod:mobile").option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy build:mobile` ...",options);const perf=await intro("buddy build:mobile"),androidSucceeded=await runBuildAction(Action.BuildAndroid,"Android application",options),iosSucceeded=await runBuildAction(Action.BuildIos,"iOS application",options);if(!androidSucceeded||!iosSucceeded){await outro("One or more mobile application builds failed",{startTime:perf,useSeconds:!0,type:"error"});process.exit(ExitCode.FatalError)}await outro("iOS and Android applications built",{startTime:perf,useSeconds:!0})});buddy.command("build:android",descriptions.android).alias("prod:android").option("-p, --project [project]",descriptions.project,{default:!1}).option("--bundle","Build a signed Android App Bundle (.aab) for Google Play (CRAFT_ANDROID_KEYSTORE and friends)",{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy build:android` ...",options);if(options.bundle)process.env.STACKS_ANDROID_BUNDLE="1";const perf=await intro("buddy build:android"),result=await runAction(Action.BuildAndroid,options);if(resultFailed(result)){await outro("While building the Android application, there was an issue",{startTime:perf,useSeconds:!0},result.error);process.exit(ExitCode.FatalError)}await outro("Android application built",{startTime:perf,useSeconds:!0})});buddy.command("build:ios",descriptions.ios).alias("prod:ios").option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy build:ios` ...",options);const perf=await intro("buddy build:ios"),result=await runAction(Action.BuildIos,options);if(resultFailed(result)){await outro("While building the iOS application, there was an issue",{startTime:perf,useSeconds:!0},result.error);process.exit(ExitCode.FatalError)}await outro("iOS application built",{startTime:perf,useSeconds:!0})});buddy.command("build:dmg",descriptions.dmg).option("--app-name <name>","Bundle display name (or DESKTOP_APP_NAME / APP_NAME)").option("--app-version <version>","Bundle version (or DESKTOP_APP_VERSION; defaults to the project's package.json)").option("--bundle-id <id>","Reverse-DNS bundle identifier (or DESKTOP_BUNDLE_ID)").option("--signing-identity <identity>","Developer ID identity to sign with (or DESKTOP_SIGNING_IDENTITY)").option("--notary-profile <profile>","notarytool keychain profile to notarize and staple with (or DESKTOP_NOTARY_PROFILE)").option("--notarize","Notarize and staple with an App Store Connect API key (or DESKTOP_NOTARIZE)").option("--api-key-id <id>","App Store Connect API key ID (or APP_STORE_CONNECT_API_KEY_ID)").option("--api-issuer-id <id>","App Store Connect API issuer ID (or APP_STORE_CONNECT_API_ISSUER_ID)").option("--api-key-path <path>","The AuthKey_*.p8 file (or APP_STORE_CONNECT_API_KEY_PATH, or its PEM in APP_STORE_CONNECT_API_KEY)").option("--volume-name <name>","Name the mounted image shows (or DESKTOP_VOLUME_NAME)").option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy build:dmg` ...",options);const perf=await intro("buddy build:dmg"),{hasMacosDesktop}=await import("@stacksjs/desktop-build"),result=await runAction(await hasMacosDesktop(projectPath())?Action.BuildDmgMacos:Action.BuildDmg,options);if(resultFailed(result)){await outro("While running the build:dmg command, there was an issue",{startTime:perf,useSeconds:!0},result.error);process.exit(ExitCode.FatalError)}console.log("");await outro("Exited",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("build:stacks","Build the Stacks framework.").option("-s, --stacks",descriptions.framework,{default:!0}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy build:stacks` ...",options);const startTime=await intro("buddy build:stacks"),result=await runAction(Action.BuildStacks,options);if(resultFailed(result)){await log.error("Failed to build Stacks.",result.error);process.exit(ExitCode.FatalError)}await outro("Stacks built successfully",{startTime,useSeconds:!0})});onUnknownSubcommand(buddy,"build")}function hasNoOptions(options){return!options.components&&!options.webComponents&&!options.elements&&!options.functions&&!options.libs&&!options.desktop&&!options.mobile&&!options.android&&!options.ios&&!options.views&&!options.docs&&!options.stacks&&!options.buddy&&!options.server}export function applyBuildTarget(target,options){switch(target){case"components":options.components=!0;break;case"web-components":options.webComponents=!0;break;case"functions":options.functions=!0;break;case"libs":case"libraries":options.libs=!0;break;case"desktop":options.desktop=!0;break;case"mobile":options.mobile=!0;options.android=!0;options.ios=!0;break;case"android":options.android=!0;break;case"ios":options.ios=!0;break;case"views":options.views=!0;break;case"docs":options.docs=!0;break;case"buddy":case"cli":options.buddy=!0;break;case"stacks":options.stacks=!0;break;case"server":options.server=!0;break}}async function runBuildAction(action,target,options){const result=await runAction(action,options);if(resultFailed(result)){log.error(`Failed to build ${target}.`,result.error);return!1}return!0}
@@ -166,6 +166,18 @@ export declare function assertFragmentIsOurs(where: string | SshTarget, tsCloudC
166
166
  */
167
167
  export declare function assertPortsAreFree(where: string | SshTarget, tsCloudConfig: any, log: { error: (m: string) => void, info: (m: string) => void }): Promise<void>;
168
168
  export declare function resolveDeployEnvValues(environment: 'production' | 'staging' | 'development', tsCloudConfig?: { project?: { slug?: string } }, fileOverride?: string): Promise<Record<string, string>>;
169
+ /**
170
+ * What a deploy that could not read its environment file should say beyond
171
+ * "restore its decryption key".
172
+ *
173
+ * Names only: the encrypted keys in the file and the private-key variable the
174
+ * env layer looked for, plus whether it was set. A lost key on 2026-10-31
175
+ * stopped a deploy with the generic message, and finding out that five keys
176
+ * were encrypted to a public key whose private half was nowhere took far
177
+ * longer than reading this would have. Values, ciphertext and key material
178
+ * are never included.
179
+ */
180
+ export declare function deployEnvFailureDetail(filePath: string, fileName: string): string;
169
181
  /**
170
182
  * Merge the deploy-target's resolved env values underneath each site's own
171
183
  * explicit `env` overrides, stripping a general `PORT` when the site
@@ -9,7 +9,7 @@ cloud-init may have failed: SSH in and check /var/log/cloud-init-output.log. Rai
9
9
  unit=$(systemctl status "$pid" 2>/dev/null | head -1 | grep -oE '[a-zA-Z0-9_.@-]+\\.service' | head -1)
10
10
  echo "$p \${unit:-unknown}"
11
11
  done`,encoding:"utf8",stdio:["pipe","pipe","pipe"]})}catch{return}const clashes=[];for(const line of listing.split(`
12
- `)){const[portText,unit="unknown"]=line.trim().split(/\s+/),port=Number(portText);if(!Number.isFinite(port)||!wanted.has(port))continue;if(unit.startsWith(`${slug}-`))continue;clashes.push(` ${port} (site '${wanted.get(port)}') is held by ${unit}`)}if(clashes.length===0)return;log.error("Another service on the box is already listening on a port this project wants:");for(const clash of clashes)log.error(clash);log.error("Two services on one port do not error: the kernel load-balances, and each domain serves the other's site about half the time.");log.info("Pick free ports in config/cloud.ts. `ss -lntp` on the box lists what is taken.");process.exit(ExitCode.FatalError)}export async function resolveDeployEnvValues(environment,tsCloudConfig,fileOverride){const fileName=fileOverride??(environment==="production"?".env.production":environment==="staging"?".env.staging":existsSync(p.projectPath(".env.development"))?".env.development":".env"),filePath=p.projectPath(fileName);if(!existsSync(filePath))return{};try{const{getEnv}=await import("@stacksjs/env"),result=getEnv(void 0,{file:fileName,format:"json"});if(!result.success||!result.output)throw Error("The environment could not be read completely");const parsed=JSON.parse(result.output),values={},undecrypted=[];for(const[key,value]of Object.entries(parsed)){if(/^DOTENV_(PUBLIC|PRIVATE)_KEY/.test(key))continue;values[key]=String(value);if(/^(?:encrypted|enc):/.test(values[key]))undecrypted.push(key)}if(undecrypted.length>0)throw Error("The environment still contains ciphertext");const{foreignTenantKeys,partitionTenantEnv}=await import("@stacksjs/env"),partition=partitionTenantEnv(values,{self:tsCloudConfig?.project?.slug,tenants:await resolveDeclaredTenants()});for(const{tenant,keys}of foreignTenantKeys(partition))log.warn(`[deploy] Skipping ${keys.length} '${tenant}' key(s) in ${fileName} - they belong to that tenant's own repository, and shipping them writes its secrets into this project's site .env files. Remove them with: buddy env:check --file ${fileName}. Keys: ${keys.join(", ")}`);return partition.own}catch{throw Error(`Cannot load deployment environment from ${fileName}; restore its decryption key and readable file before deploying.`)}}export function mergeSiteDeployEnv(sites,resolvedDeployEnv){return Object.fromEntries(Object.entries(sites).map(([siteName,site])=>{if(!site)return[siteName,site];const base=site.inheritEnv===!1?{}:{...resolvedDeployEnv};if(site.port!==void 0)delete base.PORT;return[siteName,{...site,env:{...base,...site.env||{}}}]}))}function looksLikeSqliteFile(value){return typeof value==="string"&&/\.(?:sqlite3?|db)$/i.test(value.trim())}export function siteSqlitePath(siteEnv={}){if(String(siteEnv.DB_CONNECTION??"sqlite").trim().toLowerCase()!=="sqlite")return null;const configured=typeof siteEnv.DB_DATABASE_PATH==="string"&&siteEnv.DB_DATABASE_PATH.trim()?siteEnv.DB_DATABASE_PATH:looksLikeSqliteFile(siteEnv.DB_DATABASE)?siteEnv.DB_DATABASE:"database/stacks.sqlite",path=String(configured).trim().replace(/^\.\//,"");if(!path||path.startsWith("/")||path.startsWith("~")||path.split("/").includes(".."))return null;return path}export function tsCloudPersistentStateSupport(buildSiteDeployScript){const missing=[];if(typeof buildSiteDeployScript!=="function")return{ok:!1,missing:["buildSiteDeployScript"]};const target="/var/www/probe-shared/database/probe.sqlite";let script="";try{script=buildSiteDeployScript({siteName:"probe",slug:"probe",appDir:"/var/www/probe-probe",artifactFetch:[],releaseId:"probe",execStart:"/bin/true",envEntries:{},port:3000,sharedPaths:[{path:"database/probe.sqlite",target,seed:!0}]}).join(`
12
+ `)){const[portText,unit="unknown"]=line.trim().split(/\s+/),port=Number(portText);if(!Number.isFinite(port)||!wanted.has(port))continue;if(unit.startsWith(`${slug}-`))continue;clashes.push(` ${port} (site '${wanted.get(port)}') is held by ${unit}`)}if(clashes.length===0)return;log.error("Another service on the box is already listening on a port this project wants:");for(const clash of clashes)log.error(clash);log.error("Two services on one port do not error: the kernel load-balances, and each domain serves the other's site about half the time.");log.info("Pick free ports in config/cloud.ts. `ss -lntp` on the box lists what is taken.");process.exit(ExitCode.FatalError)}export async function resolveDeployEnvValues(environment,tsCloudConfig,fileOverride){const fileName=fileOverride??(environment==="production"?".env.production":environment==="staging"?".env.staging":existsSync(p.projectPath(".env.development"))?".env.development":".env"),filePath=p.projectPath(fileName);if(!existsSync(filePath))return{};try{const{getEnv}=await import("@stacksjs/env"),result=getEnv(void 0,{file:fileName,format:"json"});if(!result.success||!result.output)throw Error("The environment could not be read completely");const parsed=JSON.parse(result.output),values={},undecrypted=[];for(const[key,value]of Object.entries(parsed)){if(/^DOTENV_(PUBLIC|PRIVATE)_KEY/.test(key))continue;values[key]=String(value);if(/^(?:encrypted|enc):/.test(values[key]))undecrypted.push(key)}if(undecrypted.length>0)throw Error("The environment still contains ciphertext");const{foreignTenantKeys,partitionTenantEnv}=await import("@stacksjs/env"),partition=partitionTenantEnv(values,{self:tsCloudConfig?.project?.slug,tenants:await resolveDeclaredTenants()});for(const{tenant,keys}of foreignTenantKeys(partition))log.warn(`[deploy] Skipping ${keys.length} '${tenant}' key(s) in ${fileName} - they belong to that tenant's own repository, and shipping them writes its secrets into this project's site .env files. Remove them with: buddy env:check --file ${fileName}. Keys: ${keys.join(", ")}`);return partition.own}catch{throw Error(`Cannot load deployment environment from ${fileName}; restore its decryption key and readable file before deploying.${deployEnvFailureDetail(filePath,fileName)}`)}}export function deployEnvFailureDetail(filePath,fileName){let text;try{text=readFileSync(filePath,"utf8")}catch{return""}const encrypted=[];let publicKeyName="";for(const line of text.split(/\r?\n/)){const match=line.match(/^\s*(?:export\s+)?([A-Z0-9_]+)\s*=\s*['"]?(.*)$/i);if(!match)continue;const key=match[1]??"",value=match[2]??"";if(/^DOTENV_PUBLIC_KEY/.test(key)){publicKeyName=key;continue}if(/^(?:encrypted|enc):/.test(value))encrypted.push(key)}if(encrypted.length===0)return"";const privateKeyName=publicKeyName?publicKeyName.replace("DOTENV_PUBLIC_KEY","DOTENV_PRIVATE_KEY"):"DOTENV_PRIVATE_KEY",keysFile=join(dirname(filePath),".env.keys"),source=process.env[privateKeyName]?`${privateKeyName} is set but does not decrypt them; it must be the private half of ${publicKeyName||"the public key in the file"}`:existsSync(keysFile)?`${privateKeyName} is not set in the environment, and .env.keys did not supply a matching one`:`${privateKeyName} is not set in the environment and there is no .env.keys beside ${fileName}`;return` Encrypted in ${fileName}: ${encrypted.join(", ")}. ${source}.`}export function mergeSiteDeployEnv(sites,resolvedDeployEnv){return Object.fromEntries(Object.entries(sites).map(([siteName,site])=>{if(!site)return[siteName,site];const base=site.inheritEnv===!1?{}:{...resolvedDeployEnv};if(site.port!==void 0)delete base.PORT;return[siteName,{...site,env:{...base,...site.env||{}}}]}))}function looksLikeSqliteFile(value){return typeof value==="string"&&/\.(?:sqlite3?|db)$/i.test(value.trim())}export function siteSqlitePath(siteEnv={}){if(String(siteEnv.DB_CONNECTION??"sqlite").trim().toLowerCase()!=="sqlite")return null;const configured=typeof siteEnv.DB_DATABASE_PATH==="string"&&siteEnv.DB_DATABASE_PATH.trim()?siteEnv.DB_DATABASE_PATH:looksLikeSqliteFile(siteEnv.DB_DATABASE)?siteEnv.DB_DATABASE:"database/stacks.sqlite",path=String(configured).trim().replace(/^\.\//,"");if(!path||path.startsWith("/")||path.startsWith("~")||path.split("/").includes(".."))return null;return path}export function tsCloudPersistentStateSupport(buildSiteDeployScript){const missing=[];if(typeof buildSiteDeployScript!=="function")return{ok:!1,missing:["buildSiteDeployScript"]};const target="/var/www/probe-shared/database/probe.sqlite";let script="";try{script=buildSiteDeployScript({siteName:"probe",slug:"probe",appDir:"/var/www/probe-probe",artifactFetch:[],releaseId:"probe",execStart:"/bin/true",envEntries:{},port:3000,sharedPaths:[{path:"database/probe.sqlite",target,seed:!0}]}).join(`
13
13
  `)}catch{return{ok:!1,missing:["shared paths with an explicit target"]}}if(!(script.includes("cp -a")&&script.includes("/current/")))missing.push("adoption of existing state into shared/");if(!script.includes(`ln -sfn ${target} `))missing.push("shared paths with an explicit target");return{ok:missing.length===0,missing}}export function projectDatabaseTarget(slug,relativePath){return`/var/www/${slug}-shared/${relativePath}`}function migrateIndex(site){if(!Array.isArray(site?.preStart))return-1;return site.preStart.findIndex((cmd)=>typeof cmd==="string"&&migratesDatabase(cmd))}function migratesDatabase(command){const withoutMessages=command.replace(/'[^']*'/g,"").replace(/"[^"]*"/g,"");if(/\bmigrate\b/.test(withoutMessages))return!0;return!/^\s*(?:echo|printf)\b/.test(command)&&/\bmigrate\b/.test(command)}export function applyAutomaticMigrations(sites){if(Object.values(sites).some((site)=>runsMigrations(site)))return sites;const isServerApp=(site)=>!!site&&typeof site.start==="string",owner=Object.entries(sites).filter(([,site])=>isServerApp(site)).find(([,site])=>site?.migrateOnDeploy!==!1)?.[0];if(!owner)return sites;const site=sites[owner],preStart=Array.isArray(site.preStart)?[...site.preStart]:[];preStart.push("./buddy migrate --no-generate");return{...sites,[owner]:{...site,preStart}}}function runsMigrations(site){return migrateIndex(site)!==-1}export function applyPersistentStatePaths(sites,slug){const isServerApp=(site)=>!!site&&typeof site.start==="string",appSites=Object.entries(sites).filter(([,site])=>isServerApp(site)),owners=new Map;for(const[name,site]of appSites){const sqlite=siteSqlitePath(site.env||{});if(!sqlite)continue;const migrates=runsMigrations(site),previous=owners.get(sqlite);if(!previous||!previous.migrates&&migrates)owners.set(sqlite,{name,migrates})}const out={};for(const[name,site]of Object.entries(sites)){if(!isServerApp(site)){out[name]=site;continue}const sqlite=siteSqlitePath(site.env||{}),framework=["storage/logs"];if(sqlite)framework.unshift({path:sqlite,target:projectDatabaseTarget(slug,sqlite),seed:name===owners.get(sqlite)?.name});const declared=Array.isArray(site.sharedPaths)?site.sharedPaths.filter(Boolean):[],byPath=new Map;for(const entry of[...declared,...framework])byPath.set(typeof entry==="string"?entry:entry.path,entry);out[name]={...site,sharedPaths:[...byPath.values()]}}return out}export function encryptedEnvFileNames(projectRoot){try{return readdirSync(projectRoot).filter((name)=>/^\.env\.[\w-]+$/.test(name)&&name!==".env.example")}catch{return[]}}export function declaresScheduledWork(schedulerFile){if(!existsSync(schedulerFile))return!1;const source=readFileSync(schedulerFile,"utf8").replace(/\/\*[\s\S]*?\*\//g,"").replace(/(^|[^:])\/\/.*$/gm,"$1");return/\bschedule\s*\.\s*(?:job|action|command|call|exec)\b/.test(source)}export function applyScheduledWork(sites,schedulerFile){if(Object.values(sites).some((site)=>site?.scheduler!==void 0))return sites;if(!declaresScheduledWork(schedulerFile))return sites;const appSites=Object.entries(sites).filter(([,site])=>typeof site?.start==="string"),owner=(appSites.find(([,site])=>runsMigrations(site))??appSites[0])?.[0];if(!owner)return sites;return{...sites,[owner]:{...sites[owner],scheduler:!0}}}const apiStartPattern=/\bserve:api\b|(?:^|[\s/"'])(?:serve|storage\/framework\/runtime\/production)\/api\.[cm]?[jt]s(?=$|[\s"';&|<>()])/;function servesApi(name,site){return name==="api"||typeof site?.start==="string"&&apiStartPattern.test(site.start)}function servesHttp(site){return Boolean(site?.port||site?.domain)}function describeSiteClassification(sites){const described=Object.entries(sites).filter(([,site])=>typeof site?.start==="string").map(([name,site])=>{if(servesApi(name,site))return`\`${name}\` (api)`;if(!servesHttp(site))return`\`${name}\` (headless, no HTTP surface)`;const env=site?.env??{},wiring=env.API_URL?"API_URL set":env.PORT_API?"PORT_API set":"no API_URL or PORT_API";return`\`${name}\` (page, ${wiring})`});return described.length>0?`Sites examined: ${described.join(", ")}.`:"No server-app sites were examined."}function isDashboardSite(name){return name==="dashboard"||name.startsWith("dashboard-")}export function apiDeploymentProblem(sites,hasApiRoutes){if(!hasApiRoutes)return;const entries=Object.entries(sites),appSites=entries.filter(([,site])=>typeof site?.start==="string");if(appSites.length===0)return;const api=entries.find(([name,site])=>servesApi(name,site)),pages=appSites.filter(([name,site])=>!servesApi(name,site)&&!isDashboardSite(name)&&servesHttp(site)),configured=(site)=>{const env=site?.env??{};return Boolean(env.API_URL||env.PORT_API)};if(!api){if(pages.every(([,site])=>configured(site)))return;return`This project declares API routes and no site serves them. \`/api/**\` will answer 502 on every request.
14
14
  ${describeSiteClassification(sites)}
15
15
  Add an \`api\` site to config/cloud.ts running \`buddy serve:api\` on its own port, and set \`PORT_API\` on the site that serves the pages so its proxy can find it.
@@ -1,4 +1,4 @@
1
- import process from"node:process";import{log}from"@stacksjs/cli";function parseSafariPlatforms(platform){if(!platform)return;if(platform==="all")return["macos","ios"];if(platform==="macos"||platform==="ios")return[platform];throw Error(`Invalid Safari platform ${platform}; use macos, ios, or all`)}async function refreshAppStoreScreenshots(){try{const{generateProjectImages}=await import("@stacksjs/actions");await generateProjectImages({only:["app-store"]})}catch(error){log.warn(`[extension:publish] using the committed screenshots - could not regenerate: ${error.message}`)}}async function syncFirefoxListing(config,credentials){if(!config.firefoxAddons?.screenshots?.length)return;try{const{syncFirefoxPreviews}=await import("@stacksjs/browser-extension"),previews=await syncFirefoxPreviews(config,credentials);if(previews.unchanged)log.info("Firefox listing screenshots already match");else log.success(`Synced ${previews.uploaded.length} Firefox listing screenshot(s), removed ${previews.removed.length}`)}catch(error){log.warn(`[extension:publish] Firefox listing screenshots not synced: ${error.message}`)}}export function extension(buddy){const load=async()=>{const{loadExtensionConfig}=await import("@stacksjs/browser-extension"),config=await loadExtensionConfig(process.cwd());if(!config){await log.error("No extension config found. Create `config/extension.ts` exporting `defineExtension({ \u2026 })`.");process.exit(1)}const pkg=await Bun.file(`${process.cwd()}/package.json`).json().catch(()=>({}));return{config,version:pkg.version??"0.0.0"}};buddy.command("extension:build","Build the browser extension (Chrome + Firefox + Safari) from config/extension.ts").option("--target <target>","Build a single target (chrome | firefox | safari); omit to build all").option("--version <version>","Override the extension version (defaults to package.json)").action(async(options)=>{const{buildExtension,buildAllTargets}=await import("@stacksjs/browser-extension"),{config,version}=await load(),v=options.version??version;if(options.target){const{outdir}=await buildExtension(config,{target:options.target,version:v});log.success(`Built ${config.name} ${v} (${options.target}) \u2192 ${outdir}`)}else{await buildAllTargets(config,{version:v});log.success(`Built ${config.name} ${v} for ${(config.targets??["chrome","firefox"]).join(", ")}`)}});buddy.command("extension:init","Scaffold a Chrome, Firefox, or Safari extension, including the Safari Xcode app").option("--name <name>","Extension display name").option("--target <target>","Scaffold chrome, firefox, safari, or all (default all)").option("--bundle-id <id>","Safari container bundle identifier").option("--team-id <id>","Apple Developer team used for Safari signing").option("--platform <platform>","Safari platform: macos, ios, or all (default all)").option("--force","Overwrite existing starter and Safari scaffold files").action(async(options)=>{const target=options.target??"all";if(!["chrome","firefox","safari","all"].includes(target))throw Error(`Invalid extension target ${target}; use chrome, firefox, safari, or all`);const{scaffoldExtensionProject}=await import("@stacksjs/browser-extension"),result=await scaffoldExtensionProject({name:options.name,target,bundleId:options.bundleId,teamId:options.teamId,platforms:parseSafariPlatforms(options.platform)??["macos","ios"],force:Boolean(options.force)});for(const file of result.written)log.success(`created ${file}`);for(const file of result.skipped)log.info(`skip (exists): ${file}`);if(result.safari)log.success(`Scaffolded the Safari container app \u2192 ${result.safari.dir}`);log.info("Next: add icons under public/icons, then run `buddy extension:build`.")});buddy.command("extension:package","Build + zip the browser extension into store-ready archives").option("--target <target>","Package a single target (chrome | firefox | safari); omit to package all").option("--version <version>","Override the extension version (defaults to package.json)").action(async(options)=>{const{packageExtension}=await import("@stacksjs/browser-extension"),{config,version}=await load(),v=options.version??version,targets=options.target?[options.target]:config.targets??["chrome","firefox"];for(const target of targets){const out=await packageExtension(config,{target,version:v});log.success(`Packaged ${config.name} (${target}) \u2192 ${out}`)}});buddy.command("extension:publish","Publish to every store this project is set up for - the release-tag entry point").option("--version <version>","Override the extension version (defaults to package.json)").option("--targets <targets>","Comma-separated subset of chrome,firefox,safari").option("--dry-run","Report the publish plan without uploading anything").action(async(options)=>{const{formatPublishPlan,planExtensionPublish,publishChromeExtension,publishFirefoxExtension,publishSafariApp}=await import("@stacksjs/browser-extension"),{config,version}=await load(),requested=options.targets?.split(",").map((value)=>value.trim()).filter(Boolean),plan=planExtensionPublish(config,process.env,requested?.length?requested:void 0);log.info(`Extension publish plan for v${options.version??version}:
1
+ import process from"node:process";import{log}from"@stacksjs/cli";function parseSafariPlatforms(platform){if(!platform)return;if(platform==="all")return["macos","ios"];if(platform==="macos"||platform==="ios")return[platform];throw Error(`Invalid Safari platform ${platform}; use macos, ios, or all`)}async function refreshAppStoreScreenshots(){try{const{generateProjectImages}=await import("@stacksjs/actions");await generateProjectImages({only:["app-store"]})}catch(error){log.warn(`[extension:publish] using the committed screenshots - could not regenerate: ${error.message}`)}}async function syncFirefoxListing(config,credentials){if(!config.firefoxAddons?.screenshots?.length)return;try{const{syncFirefoxPreviews}=await import("@stacksjs/browser-extension"),previews=await syncFirefoxPreviews(config,credentials);if(previews.unchanged)log.info("Firefox listing screenshots already match");else log.success(`Synced ${previews.uploaded.length} Firefox listing screenshot(s), removed ${previews.removed.length}`)}catch(error){log.warn(`[extension:publish] Firefox listing screenshots not synced: ${error.message}`)}}export function extension(buddy){const load=async()=>{const{loadExtensionConfig}=await import("@stacksjs/browser-extension"),config=await loadExtensionConfig(process.cwd());if(!config){await log.error("No extension config found. Create `config/extension.ts` exporting `defineExtension({ \u2026 })`.");process.exit(1)}const pkg=await Bun.file(`${process.cwd()}/package.json`).json().catch(()=>({}));return{config,version:pkg.version??"0.0.0"}};buddy.command("extension:build","Build the browser extension (Chrome + Firefox + Safari) from config/extension.ts").option("--target <target>","Build a single target (chrome | firefox | safari | webkit); omit to build all. webkit emits content-blocker lists and WKUserScripts for WKWebView apps").option("--version <version>","Override the extension version (defaults to package.json)").action(async(options)=>{const{buildExtension,buildAllTargets}=await import("@stacksjs/browser-extension"),{config,version}=await load(),v=options.version??version;if(options.target&&!["chrome","firefox","safari","webkit"].includes(options.target)){await log.error(`Unknown target ${options.target}; use chrome, firefox, safari or webkit`);process.exit(1)}if(options.target){const{outdir}=await buildExtension(config,{target:options.target,version:v});log.success(`Built ${config.name} ${v} (${options.target}) \u2192 ${outdir}`)}else{await buildAllTargets(config,{version:v});log.success(`Built ${config.name} ${v} for ${(config.targets??["chrome","firefox"]).join(", ")}`)}});buddy.command("extension:init","Scaffold a Chrome, Firefox, or Safari extension, including the Safari Xcode app").option("--name <name>","Extension display name").option("--target <target>","Scaffold chrome, firefox, safari, or all (default all)").option("--bundle-id <id>","Safari container bundle identifier").option("--team-id <id>","Apple Developer team used for Safari signing").option("--platform <platform>","Safari platform: macos, ios, or all (default all)").option("--force","Overwrite existing starter and Safari scaffold files").action(async(options)=>{const target=options.target??"all";if(!["chrome","firefox","safari","all"].includes(target))throw Error(`Invalid extension target ${target}; use chrome, firefox, safari, or all`);const{scaffoldExtensionProject}=await import("@stacksjs/browser-extension"),result=await scaffoldExtensionProject({name:options.name,target,bundleId:options.bundleId,teamId:options.teamId,platforms:parseSafariPlatforms(options.platform)??["macos","ios"],force:Boolean(options.force)});for(const file of result.written)log.success(`created ${file}`);for(const file of result.skipped)log.info(`skip (exists): ${file}`);if(result.safari)log.success(`Scaffolded the Safari container app \u2192 ${result.safari.dir}`);log.info("Next: add icons under public/icons, then run `buddy extension:build`.")});buddy.command("extension:package","Build + zip the browser extension into store-ready archives").option("--target <target>","Package a single target (chrome | firefox | safari); omit to package all").option("--version <version>","Override the extension version (defaults to package.json)").action(async(options)=>{const{packageExtension}=await import("@stacksjs/browser-extension"),{config,version}=await load(),v=options.version??version,targets=options.target?[options.target]:config.targets??["chrome","firefox"];for(const target of targets){const out=await packageExtension(config,{target,version:v});log.success(`Packaged ${config.name} (${target}) \u2192 ${out}`)}});buddy.command("extension:publish","Publish to every store this project is set up for - the release-tag entry point").option("--version <version>","Override the extension version (defaults to package.json)").option("--targets <targets>","Comma-separated subset of chrome,firefox,safari").option("--dry-run","Report the publish plan without uploading anything").action(async(options)=>{const{formatPublishPlan,planExtensionPublish,publishChromeExtension,publishFirefoxExtension,publishSafariApp}=await import("@stacksjs/browser-extension"),{config,version}=await load(),requested=options.targets?.split(",").map((value)=>value.trim()).filter(Boolean),plan=planExtensionPublish(config,process.env,requested?.length?requested:void 0);log.info(`Extension publish plan for v${options.version??version}:
2
2
  ${formatPublishPlan(plan)}`);const publishing=plan.filter((decision)=>decision.publish);if(options.dryRun||!publishing.length){if(!publishing.length)log.warn("No store is both configured and credentialed, so nothing was published.");return}const failures=[];for(const{target}of publishing)try{if(target==="chrome"){const result=await publishChromeExtension(config,{version:options.version??version,blockOnWarnings:!0});if(result.deferred)log.warn(`${result.deferred.reason}. Version ${options.version??version} remains queued for the next automated retry.`);else if(result.alreadyPublished)log.success(result.alreadyPublished.reason);else log.success(`Submitted Chrome Web Store item ${result.publish?.itemId??""}: ${result.publish?.state??"uploaded"}`)}else if(target==="firefox"){const result=await publishFirefoxExtension(config,{version:options.version??version});log.success(`Submitted Firefox extension (${result.channel}) \u2192 ${result.artifactsDir}`);await syncFirefoxListing(config,{})}else{await refreshAppStoreScreenshots();await publishSafariApp(config,{version:options.version??version});log.success("Uploaded Safari app to App Store Connect")}}catch(error){failures.push(`${target}: ${error.message}`);log.error(`[extension:publish] ${target} failed: ${error.message}`)}if(failures.length){await log.error(`Failed to publish ${failures.length} of ${publishing.length} store(s):
3
3
  ${failures.join(`
4
4
  `)}`);process.exit(1)}});buddy.command("extension:chrome:status","Fetch the Chrome Web Store item status").option("--service-account-path <path>","Google service-account JSON key path").option("--access-token <token>","Short-lived Chrome Web Store OAuth access token").action(async(options)=>{const{ChromeWebStoreClient}=await import("@stacksjs/browser-extension"),{config}=await load();if(!config.chromeWebStore)throw Error("Chrome status needs chromeWebStore.publisherId and chromeWebStore.itemId in config/extension.ts");const status=await new ChromeWebStoreClient(options).fetchStatus(config.chromeWebStore);log.info(`Chrome Web Store item ${status.itemId}`);log.info(`published: ${status.publishedItemRevisionStatus?.state??"none"}`);log.info(`submitted: ${status.submittedItemRevisionStatus?.state??"none"}`);if(status.warned)log.warn("Chrome has warned this item for a policy violation.");if(status.takenDown)log.error("Chrome has taken this item down for a policy violation.")});buddy.command("extension:chrome:publish","Build, upload, and submit the Chrome extension through Web Store API v2").option("--version <version>","Override the extension version (defaults to package.json)").option("--service-account-path <path>","Google service-account JSON key path").option("--access-token <token>","Short-lived Chrome Web Store OAuth access token").option("--upload-only","Upload without submitting the item for review").option("--allow-warnings","Submit even when Chrome reports validation warnings").action(async(options)=>{const{publishChromeExtension}=await import("@stacksjs/browser-extension"),{config,version}=await load(),result=await publishChromeExtension(config,{version:options.version??version,serviceAccountPath:options.serviceAccountPath,accessToken:options.accessToken,uploadOnly:Boolean(options.uploadOnly),blockOnWarnings:!options.allowWarnings});if(result.deferred){log.warn(`${result.deferred.reason}. Version ${options.version??version} remains queued for the next automated retry.`);return}if(result.alreadyPublished){log.success(result.alreadyPublished.reason);return}log.success(`Uploaded Chrome package ${result.packagePath} (${result.upload?.crxVersion??"processing complete"})`);if(result.publish)log.success(`Submitted Chrome Web Store item ${result.publish.itemId}: ${result.publish.state}`)});buddy.command("extension:firefox:previews","Sync the Firefox listing screenshots declared in config/extension.ts").option("--api-key <issuer>","AMO JWT issuer").option("--api-secret <secret>","AMO JWT secret").option("--dry-run","Report what would change without touching the listing").action(async(options)=>{const{syncFirefoxPreviews}=await import("@stacksjs/browser-extension"),{config}=await load(),result=await syncFirefoxPreviews(config,{issuer:options.apiKey,secret:options.apiSecret,dryRun:options.dryRun});if(result.unchanged)log.info("Firefox listing screenshots already match config/extension.ts");else if(options.dryRun)log.info(`Would replace ${result.removed.length} Firefox listing screenshot(s)`);else log.success(`Synced ${result.uploaded.length} Firefox listing screenshot(s), removed ${result.removed.length}`)});buddy.command("extension:firefox:publish","Build and submit the Firefox extension through Mozilla Add-ons").option("--version <version>","Override the extension version (defaults to package.json)").option("--api-key <issuer>","AMO JWT issuer").option("--api-secret <secret>","AMO JWT secret").option("--source-code <path>","Human-readable source archive for AMO review").option("--approval-timeout <milliseconds>","How long to wait for human approval (default 0)").action(async(options)=>{const{publishFirefoxExtension}=await import("@stacksjs/browser-extension"),{config,version}=await load(),result=await publishFirefoxExtension(config,{version:options.version??version,issuer:options.apiKey,secret:options.apiSecret,sourceCodePath:options.sourceCode,approvalTimeout:options.approvalTimeout===void 0?void 0:Number(options.approvalTimeout)});log.success(`Submitted Firefox extension (${result.channel}) \u2192 ${result.artifactsDir}`);if(config.firefoxAddons?.screenshots?.length)try{const{syncFirefoxPreviews}=await import("@stacksjs/browser-extension"),previews=await syncFirefoxPreviews(config,{issuer:options.apiKey,secret:options.apiSecret});if(previews.unchanged)log.info("Firefox listing screenshots already match");else log.success(`Synced ${previews.uploaded.length} Firefox listing screenshot(s), removed ${previews.removed.length}`)}catch(error){log.warn(`[extension:firefox:publish] listing screenshots left as they were: ${error.message}`)}if(result.artifacts.length)log.info(`new artifacts: ${result.artifacts.join(", ")}`)});buddy.command("extension:safari:provision","Register Safari Bundle IDs and check the App Store Connect app record").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>","Path to the App Store Connect AuthKey_*.p8 file").option("--check","Report missing resources without creating Bundle IDs").option("--version <version>","Create or align App Store versions (defaults to package.json)").option("--platform <platform>","Provision macos, ios, or all (defaults to config safariPlatforms)").action(async(options)=>{const{provisionSafariApp}=await import("@stacksjs/browser-extension"),{config,version}=await load(),result=await provisionSafariApp(config,{keyId:options.apiKeyId,issuerId:options.apiIssuerId,keyPath:options.apiKeyPath,checkOnly:Boolean(options.check),version:options.version??version,platforms:parseSafariPlatforms(options.platform)});for(const resource of[result.container,result.extension])if(resource.created)log.success(`Registered Bundle ID ${resource.identifier}`);else if(resource.exists)log.success(`Bundle ID exists: ${resource.identifier}`);else log.warn(`Bundle ID is missing: ${resource.identifier}`);if(result.appRecord.exists)log.success(`App Store Connect app record exists (${result.appRecord.id})`);else log.warn("App Store Connect app record is missing. Apple requires creating it in the App Store Connect website.");for(const appStoreVersion of result.appStoreVersions){const action=appStoreVersion.created?"Created":appStoreVersion.updated?"Updated":"Ready";log.success(`${action} Safari ${appStoreVersion.platform} App Store version ${appStoreVersion.version}`)}});buddy.command("extension:safari:init","Scaffold the Safari container app (Xcode project) from the template").option("--bundle-id <id>","Base bundle identifier (defaults to config safariBundleId)").option("--dir <dir>","Output directory for the Xcode project (default safari)").option("--force","Overwrite existing scaffold files").option("--team-id <id>","Apple Developer team used for signing").action(async(options)=>{const{scaffoldSafariApp}=await import("@stacksjs/browser-extension"),{config}=await load(),{dir,written,skipped}=await scaffoldSafariApp(config,{bundleId:options.bundleId,dir:options.dir,force:Boolean(options.force),teamId:options.teamId});log.success(`Scaffolded the Safari container app \u2192 ${dir} (${written.length} files)`);if(skipped.length)log.info(`kept ${skipped.length} existing files (use --force to overwrite)`)});buddy.command("extension:safari:app","Build the extension and its macOS, iPhone, and iPad Safari container apps").option("--release","Build the Release configuration (default Debug)").option("--signed","Sign locally against the Apple ID in Xcode (local builds only - see below)").option("--skip-xcodebuild","Only build + sync the extension payload").option("--version <version>","Override the extension version (defaults to package.json)").option("--platform <platform>","Build macos, ios, or all (defaults to config safariPlatforms)").action(async(options)=>{const{buildSafariApp,buildSafariUniversalApp}=await import("@stacksjs/browser-extension"),{config,version}=await load(),platforms=parseSafariPlatforms(options.platform)??config.safariPlatforms??["macos"];if(platforms.includes("ios")){const result=await buildSafariUniversalApp(config,{version:options.version??version,release:Boolean(options.release),signed:Boolean(options.signed),skipXcodebuild:Boolean(options.skipXcodebuild),platforms});for(const platform of platforms){const appPath=result.appPaths[platform];if(appPath)log.success(`Built Safari ${platform} app ${appPath}`)}if(options.skipXcodebuild)log.success(`Generated universal Safari project \u2192 ${result.project}`);if(result.appPaths.macos)log.info("Open the macOS app once, then enable the extension in Safari > Settings > Extensions.");if(result.appPaths.ios)log.info("Install the iOS app on an iPhone, iPad, or Simulator, then enable it in Settings > Apps > Safari > Extensions.");return}const{appPath,resources}=await buildSafariApp(config,{version:options.version??version,release:Boolean(options.release),signed:Boolean(options.signed),skipXcodebuild:Boolean(options.skipXcodebuild)});if(appPath){log.success(`Built ${appPath}`);log.info("Open the app once, then enable the extension in Safari > Settings > Extensions.")}else log.success(`Extension payload synced \u2192 ${resources}`)});buddy.command("extension:safari:publish","Archive and validate or upload the Safari app to App Store Connect").option("--version <version>","Override the marketing version (defaults to package.json)").option("--build-number <number>","CFBundleVersion (defaults to GITHUB_RUN_NUMBER or Unix time)").option("--team-id <id>","Apple Developer team (defaults to config safariTeamId)").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>","Path to the App Store Connect AuthKey_*.p8 file").option("--validate-only","Create and validate the archive without uploading it").option("--platform <platform>","Publish macos, ios, or all (defaults to config safariPlatforms)").option("--skip-screenshots","Do not regenerate the App Store screenshot set before publishing").action(async(options)=>{const{publishSafariApp}=await import("@stacksjs/browser-extension"),{config,version}=await load();if(!options.skipScreenshots)try{const{generateProjectImages}=await import("@stacksjs/actions");await generateProjectImages({only:["app-store"]})}catch(error){log.warn(`[extension:safari:publish] using the committed screenshots - could not regenerate: ${error.message}`)}const result=await publishSafariApp(config,{version:options.version??version,buildNumber:options.buildNumber,teamId:options.teamId,keyId:options.apiKeyId,issuerId:options.apiIssuerId,keyPath:options.apiKeyPath,validateOnly:Boolean(options.validateOnly),platforms:parseSafariPlatforms(options.platform)});for(const deferred of result.deferred)log.warn(`${deferred.reason}. Version ${deferred.version} remains queued for the next automated retry.`);for(const published of result.alreadyPublished)log.success(`Safari ${published.platform} version ${published.version} is already published${published.state?` (${published.state})`:""}`);if(result.artifacts.length)log.success(options.validateOnly?`Validated Safari ${result.artifacts.map((artifact)=>artifact.platform).join(" + ")} archives (build ${result.buildNumber})`:`Uploaded and selected Safari ${result.attachments.map((attachment)=>attachment.platform).join(" + ")} build ${result.buildNumber} in App Store Connect`);if(result.appStoreSubmission?.reviewSubmissionIds.length)log.success(`Submitted ${result.appStoreSubmission.reviewSubmissionIds.length} Safari version(s) to App Review`)});buddy.command("extension:safari:submit","Synchronize metadata and submit an existing Safari version to App Review").option("--version <version>","Marketing version to submit (defaults to package.json)").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>","Path to the App Store Connect AuthKey_*.p8 file").option("--platform <platform>","Submit macos, ios, or all (defaults to config safariPlatforms)").option("--prepare-only","Synchronize the listing without submitting it for review").action(async(options)=>{const{submitSafariAppStore}=await import("@stacksjs/browser-extension"),{config,version}=await load(),result=await submitSafariAppStore(config,{version:options.version??version,keyId:options.apiKeyId,issuerId:options.apiIssuerId,keyPath:options.apiKeyPath,platforms:parseSafariPlatforms(options.platform),submit:!options.prepareOnly});log.success(`Synchronized ${result.versions.map((item)=>item.platform).join(" + ")} App Store listings`);if(result.reviewSubmissionIds.length)log.success(`Submitted ${result.reviewSubmissionIds.length} Safari version(s) to App Review`)})}
@@ -1,13 +1,13 @@
1
- import{execFileSync}from"node:child_process";import{existsSync,readFileSync,renameSync,rmSync,writeFileSync}from"node:fs";import{join}from"node:path";import{pathToFileURL}from"node:url";import process from"node:process";import{resolveXcodegen}from"@stacksjs/actions/build/ios-toolchain";import{describeGitHubRelease,publishGitHubRelease,resolveGitHubToken}from"@stacksjs/actions/github-release";import{validateReleaseEvidencePlan,verifyReleaseEvidence}from"@stacksjs/actions/release-evidence";import{assertPublicationAllowed}from"@stacksjs/actions/publication-preflight";import{runAction}from"@stacksjs/actions/run-action";import{intro,italic,log,onUnknownSubcommand,outro}from"@stacksjs/cli";import{printAsync}from"@stacksjs/cli/output";import{Action}from"@stacksjs/enums";import{withholdEnv}from"@stacksjs/env";import{ExitCode}from"@stacksjs/types";import{AppStoreConnect,appStoreConnectCredentials,buildsToExpire,waitForBuild}from"../app-store-connect";import{bumpedVersion,iosReleaseSummary,iosVersionIn,nextBuildTag,unreleasableSite,withIosVersion}from"../release-ios";import{appStoreLocales,commitsBetween,committedWhatToTest,fitNotes,NO_VISIBLE_CHANGES,PLAY_NOTES_LIMIT,previousMobileTag,resolveNotes,TESTFLIGHT_NOTES_LIMIT,testerNotes,writeWhatToTest,XCODE_CLOUD_NOTES_LIMIT}from"@stacksjs/actions/release-notes";import{resultFailed}from"../result";const descriptions={release:"Release a new version of your libraries/packages",project:"Target a specific project",dryRun:"Run the release without actually releasing",bump:"Non-interactive bump: patch | minor | major | prepatch | preminor | premajor | prerelease | pre | release | build | calendar | x.y.z",verbose:"Enable verbose output",skipGithubRelease:"Push the tag without creating its GitHub release"},IOS_PROJECT_DIR="storage/framework/mobile/ios",DEFAULT_WAIT_MINUTES=60;async function loadMobileConfig(configPath){return(await import(pathToFileURL(configPath).href)).default??{}}export function waitMinutes(value){if(value===void 0||value===!1)return null;if(value===!0||value==="")return DEFAULT_WAIT_MINUTES;const minutes=Number(value);if(!Number.isFinite(minutes)||minutes<0)throw Error(`--wait takes minutes, not ${String(value)}`);return minutes}function buildNumberOf(tag){const match=/^v(\d+\.\d+\.\d+)-build\.(\d+)$/.exec(tag);if(!match)throw Error(`${tag} is not an iOS build tag (v<version>-build.<n>)`);return{version:match[1],number:Number(match[2])}}function notesLocales(mobile){return mobile.release?.locales?.length?[...new Set(mobile.release.locales)]:appStoreLocales(mobile.ios?.nativeMessages?.languages)}async function applyWhatToTest(store,tag,notes,locales,minutes){try{const{version,number}=buildNumberOf(tag);if(minutes>0)log.info(`Waiting up to ${minutes} min for TestFlight build ${number} to reach App Store Connect\u2026`);const build=await waitForBuild(store.api,store.appId,version,number,{timeoutMs:minutes*60000,onWait:(elapsed)=>log.debug(`TestFlight build ${number} not there yet (${Math.round(elapsed/1000)}s)`)});if(!build){log.warn(`TestFlight build ${number} is not in App Store Connect yet; once it is, run \`buddy release:notes ${tag} --testflight\`.`);return!1}const text=fitNotes(notes,TESTFLIGHT_NOTES_LIMIT);await store.api.setWhatToTest(build.id,Object.fromEntries(locales.map((locale)=>[locale,text])));log.info(`Set What to Test on TestFlight build ${number} (${locales.join(", ")})`);return!0}catch(error){log.warn(`Could not set What to Test: ${error instanceof Error?error.message:String(error)}. Retry with \`buddy release:notes ${tag} --testflight\`.`);return!1}}function githubSummary(summary,notes){return`${summary.trim()}
1
+ import{execFileSync}from"node:child_process";import{existsSync,readFileSync,renameSync,rmSync,writeFileSync}from"node:fs";import{join}from"node:path";import{pathToFileURL}from"node:url";import process from"node:process";import{resolveXcodegen}from"@stacksjs/actions/build/ios-toolchain";import{describeGitHubRelease,publishGitHubRelease,resolveGitHubToken}from"@stacksjs/actions/github-release";import{validateReleaseEvidencePlan,verifyReleaseEvidence}from"@stacksjs/actions/release-evidence";import{assertPublicationAllowed}from"@stacksjs/actions/publication-preflight";import{runAction}from"@stacksjs/actions/run-action";import{intro,italic,log,onUnknownSubcommand,outro}from"@stacksjs/cli";import{printAsync}from"@stacksjs/cli/output";import{Action}from"@stacksjs/enums";import{withholdEnv}from"@stacksjs/env";import{ExitCode}from"@stacksjs/types";import{AppStoreConnect,appStoreConnectCredentials,buildsToExpire,waitForBuild}from"../app-store-connect";import{bumpedVersion,iosReleaseSummary,iosVersionIn,nextBuildTag,pushIosReleaseTag,unreleasableSite,withIosVersion}from"../release-ios";import{appStoreLocales,commitsBetween,committedWhatToTest,fitNotes,NO_VISIBLE_CHANGES,PLAY_NOTES_LIMIT,previousMobileTag,resolveNotes,TESTFLIGHT_NOTES_LIMIT,testerNotes,writeWhatToTest,XCODE_CLOUD_NOTES_LIMIT}from"@stacksjs/actions/release-notes";import{resultFailed}from"../result";const descriptions={release:"Release a new version of your libraries/packages",project:"Target a specific project",dryRun:"Run the release without actually releasing",bump:"Non-interactive bump: patch | minor | major | prepatch | preminor | premajor | prerelease | pre | release | build | calendar | x.y.z",verbose:"Enable verbose output",skipGithubRelease:"Push the tag without creating its GitHub release"},IOS_PROJECT_DIR="storage/framework/mobile/ios",DEFAULT_WAIT_MINUTES=60;async function loadMobileConfig(configPath){return(await import(pathToFileURL(configPath).href)).default??{}}export function waitMinutes(value){if(value===void 0||value===!1)return null;if(value===!0||value==="")return DEFAULT_WAIT_MINUTES;const minutes=Number(value);if(!Number.isFinite(minutes)||minutes<0)throw Error(`--wait takes minutes, not ${String(value)}`);return minutes}function buildNumberOf(tag){const match=/^v(\d+\.\d+\.\d+)-build\.(\d+)$/.exec(tag);if(!match)throw Error(`${tag} is not an iOS build tag (v<version>-build.<n>)`);return{version:match[1],number:Number(match[2])}}function notesLocales(mobile){return mobile.release?.locales?.length?[...new Set(mobile.release.locales)]:appStoreLocales(mobile.ios?.nativeMessages?.languages)}async function applyWhatToTest(store,tag,notes,locales,minutes){try{const{version,number}=buildNumberOf(tag);if(minutes>0)log.info(`Waiting up to ${minutes} min for TestFlight build ${number} to reach App Store Connect\u2026`);const build=await waitForBuild(store.api,store.appId,version,number,{timeoutMs:minutes*60000,onWait:(elapsed)=>log.debug(`TestFlight build ${number} not there yet (${Math.round(elapsed/1000)}s)`)});if(!build){log.warn(`TestFlight build ${number} is not in App Store Connect yet; once it is, run \`buddy release:notes ${tag} --testflight\`.`);return!1}const text=fitNotes(notes,TESTFLIGHT_NOTES_LIMIT);await store.api.setWhatToTest(build.id,Object.fromEntries(locales.map((locale)=>[locale,text])));log.info(`Set What to Test on TestFlight build ${number} (${locales.join(", ")})`);return!0}catch(error){log.warn(`Could not set What to Test: ${error instanceof Error?error.message:String(error)}. Retry with \`buddy release:notes ${tag} --testflight\`.`);return!1}}function githubSummary(summary,notes){return`${summary.trim()}
2
2
 
3
3
  ## What to Test
4
4
 
5
5
  ${notes.trim()}`}function git(...args){return execFileSync("git",args,{encoding:"utf8",stdio:["ignore","pipe","pipe"]}).trim()}async function releaseIos(options){const root=process.cwd();if(!options.dryRun)await assertPublicationAllowed({cwd:root,channel:"ios"});const configPath=join(root,"config/mobile.ts");if(!existsSync(configPath))throw Error("config/mobile.ts not found: run this in a Stacks app with a mobile config");const xcodegen=await resolveXcodegen(root);log.info(`xcodegen ${xcodegen.version} from Pantry (${xcodegen.path})`);const branch=git("rev-parse","--abbrev-ref","HEAD");if(branch!=="main")throw Error(`releases are cut from main; this is ${branch}`);if(git("status","--porcelain","--untracked-files=no"))throw Error("commit or stash your changes first, so the release is exactly what is on main");if(!options.dryRun){git("pull","--quiet","--rebase","origin","main");git("fetch","--quiet","--tags","origin");await assertPublicationAllowed({cwd:root,channel:"ios"})}const source=readFileSync(configPath,"utf8"),current=iosVersionIn(source);if(!current)throw Error("config/mobile.ts declares no iOS `version`");const version=options.bump?bumpedVersion(current,options.bump):current,existingTags=git("tag","--list",`v${version}-build.*`).split(`
6
- `).filter(Boolean);let tag=nextBuildTag(version,existingTags);const wait=waitMinutes(options.wait),mobile=await loadMobileConfig(configPath),credentials=appStoreConnectCredentials();let store=null,numberedByXcodeCloud=!1;if(credentials){const bundleId=mobile.ios?.bundleId;if(!bundleId)throw Error("config/mobile.ts declares no ios.bundleId");const api=await AppStoreConnect.connect(credentials);store={api,appId:await api.appId(bundleId)};const latestRun=await api.latestXcodeCloudRun(store.appId);if(latestRun!==null){tag=nextBuildTag(version,existingTags,latestRun);numberedByXcodeCloud=!0}}else log.warn("No App Store Connect API key (APP_STORE_CONNECT_KEY_ID, _ISSUER_ID, _PRIVATE_KEY_PATH): the tag counts releases, not the build number TestFlight will show, and older builds stay listed.");log.info(`Releasing ${tag}${version===current?"":` (version ${current} \u2192 ${version})`}`);const keep=Math.max(0,Number(options.keepBuilds??1)||0),summary=iosReleaseSummary(tag,{numberedByXcodeCloud:store!==null}),releaseConfig=mobile.release??{},previous=previousMobileTag(root,"HEAD"),commits=commitsBetween(root,previous,"HEAD"),generateOptions={types:releaseConfig.noteTypes,excludeScopes:releaseConfig.excludeScopes},notes=resolveNotes({cwd:root,flags:options,config:releaseConfig,generate:()=>testerNotes(commits,generateOptions)}),githubNotes=notes.source==="generated"?testerNotes(commits,{...generateOptions,markdown:!0})||NO_VISIBLE_CHANGES:notes.text,locales=notesLocales(mobile),origin=notes.source==="generated"?`${commits.length} commits since ${previous??"the first commit"}`:{flag:"--notes",env:"RELEASE_NOTES",config:"config/mobile.ts release.notes"}[notes.source];if(wait!==null&&!numberedByXcodeCloud)log.warn("--wait needs an App Store Connect API key and an Xcode Cloud product to know the TestFlight build; Xcode Cloud still reads the What to Test file.");if(options.dryRun){log.info(`What to Test (${origin}; ${locales.map((locale)=>`TestFlight/WhatToTest.${locale}.txt`).join(", ")}):
6
+ `).filter(Boolean);let tag=nextBuildTag(version,existingTags);const wait=waitMinutes(options.wait),mobile=await loadMobileConfig(configPath),credentials=appStoreConnectCredentials();let store=null,numberedByXcodeCloud=!1;if(credentials){const bundleId=mobile.ios?.bundleId;if(!bundleId)throw Error("config/mobile.ts declares no ios.bundleId");const api=await AppStoreConnect.connect(credentials);store={api,appId:await api.appId(bundleId)};const latestRun=await api.latestXcodeCloudRun(store.appId);if(latestRun!==null){tag=nextBuildTag(version,existingTags,latestRun);numberedByXcodeCloud=!0}}else log.warn("No App Store Connect API key (APP_STORE_CONNECT_KEY_ID, _ISSUER_ID, _PRIVATE_KEY_PATH): the tag counts releases, not the build number TestFlight will show, and older builds stay listed.");log.info(`Releasing ${tag}${version===current?"":` (version ${current} \u2192 ${version})`}`);const keep=Math.max(0,Number(options.keepBuilds??1)||0),summary=iosReleaseSummary(tag,{numberedByXcodeCloud}),releaseConfig=mobile.release??{},previous=previousMobileTag(root,"HEAD"),commits=commitsBetween(root,previous,"HEAD"),generateOptions={types:releaseConfig.noteTypes,excludeScopes:releaseConfig.excludeScopes},notes=resolveNotes({cwd:root,flags:options,config:releaseConfig,generate:()=>testerNotes(commits,generateOptions)}),githubNotes=notes.source==="generated"?testerNotes(commits,{...generateOptions,markdown:!0})||NO_VISIBLE_CHANGES:notes.text,locales=notesLocales(mobile),origin=notes.source==="generated"?`${commits.length} commits since ${previous??"the first commit"}`:{flag:"--notes",env:"RELEASE_NOTES",config:"config/mobile.ts release.notes"}[notes.source];if(wait!==null&&!numberedByXcodeCloud)log.warn("--wait needs an App Store Connect API key and an Xcode Cloud product to know the TestFlight build; Xcode Cloud still reads the What to Test file.");if(options.dryRun){log.info(`What to Test (${origin}; ${locales.map((locale)=>`TestFlight/WhatToTest.${locale}.txt`).join(", ")}):
7
7
 
8
8
  ${fitNotes(notes.text,XCODE_CLOUD_NOTES_LIMIT)}`);if(store){const retiring=buildsToExpire(await store.api.builds(store.appId),keep);log.info(retiring.length?`Would expire TestFlight builds ${retiring.map((b)=>b.number).join(", ")}`:"No TestFlight builds to expire")}if(!options.skipGithubRelease){const preview=await publishGitHubRelease({cwd:root,tag,ref:"HEAD",name:summary.name,summary:githubSummary(summary.body,githubNotes),dryRun:!0});log.info(preview.draft?`Would create the GitHub release ${tag} (${preview.draft.name}):
9
9
 
10
- ${preview.draft.body}`:describeGitHubRelease(tag,preview))}return tag}const paths=["config/mobile.ts","storage/framework/mobile/ios"];if(version!==current){writeFileSync(configPath,withIosVersion(source,version));const pkgPath=join(root,"package.json");if(existsSync(pkgPath)){const pkg=readFileSync(pkgPath,"utf8");writeFileSync(pkgPath,pkg.replace(/("version"\s*:\s*)"[^"]*"/,`$1"${version}"`));paths.push("package.json")}}const devOverrides=["MOBILE_URL","IOS_APP_VERSION","IOS_BUILD_NUMBER"];for(const key of devOverrides)delete process.env[key];const result=await runAction(Action.BuildIos,{env:{...withholdEnv(devOverrides),NODE_ENV:"production"}});if(resultFailed(result))throw result.error??Error("the iOS build failed");const craftConfigPath=join(root,IOS_PROJECT_DIR,"craft.config.json");if(existsSync(craftConfigPath)){const problem=unreleasableSite(JSON.parse(readFileSync(craftConfigPath,"utf8")));if(problem)throw Error(`${problem}. Set ios.url in config/mobile.ts to the production site. Nothing was committed, tagged or pushed; restore the build output and any version bump with \`git checkout -- ${paths.join(" ")}\`.`)}const written=writeWhatToTest(root,IOS_PROJECT_DIR,notes.text,locales);log.info(`What to Test from ${origin}: ${written.join(", ")}`);git("add","--",...paths);if(git("diff","--cached","--name-only"))git("commit","--quiet","-m",`chore(release): ${tag}`);git("tag","-a",tag,"-m",tag);git("push","--quiet","--atomic","origin","main",tag);if(!options.skipGithubRelease){const published=await publishGitHubRelease({cwd:root,tag,name:summary.name,summary:githubSummary(summary.body,githubNotes)});if(published.status==="failed")log.warn(describeGitHubRelease(tag,published));else log.info(describeGitHubRelease(tag,published))}if(store){try{const retiring=buildsToExpire(await store.api.builds(store.appId),keep);for(const build of retiring)await store.api.expire(build.id);if(retiring.length)log.info(`Expired TestFlight builds ${retiring.map((b)=>b.number).join(", ")}`)}catch(error){log.warn(`Could not expire older TestFlight builds: ${error instanceof Error?error.message:String(error)}`)}if(wait!==null&&numberedByXcodeCloud)await applyWhatToTest(store,tag,notes.text,locales,wait)}return tag}function notesForTag(root,tag,mobile,options){const releaseConfig=mobile.release??{},generateOptions={types:releaseConfig.noteTypes,excludeScopes:releaseConfig.excludeScopes},from=options.from||previousMobileTag(root,`${tag}^`),commits=commitsBetween(root,from,tag),plainGenerated=testerNotes(commits,generateOptions)||NO_VISIBLE_CHANGES,committed=options.from?null:committedWhatToTest(root,tag,IOS_PROJECT_DIR),handWritten=committed&&committed.trim()!==fitNotes(plainGenerated,XCODE_CLOUD_NOTES_LIMIT)?committed.trim():null,notes=resolveNotes({cwd:root,flags:options,config:releaseConfig,generate:()=>handWritten??plainGenerated});if(notes.source!=="generated"||handWritten)return{text:notes.text,plain:notes.text};return{text:options.markdown?testerNotes(commits,{...generateOptions,markdown:!0})||NO_VISIBLE_CHANGES:plainGenerated,plain:plainGenerated}}async function releaseNotesCommand(tagArgument,options){const root=process.cwd(),configPath=join(root,"config/mobile.ts"),mobile=existsSync(configPath)?await loadMobileConfig(configPath):{},tag=tagArgument||previousMobileTag(root,"HEAD");if(!tag||!git("tag","--list",tag))throw Error(tagArgument?`there is no tag ${tagArgument}`:"there is no mobile release tag (v*-build.*) yet");const notes=notesForTag(root,tag,mobile,options);if(options.testflight){const credentials=appStoreConnectCredentials();if(!credentials)throw Error("--testflight needs an App Store Connect API key (APP_STORE_CONNECT_KEY_ID, _ISSUER_ID, _PRIVATE_KEY_PATH)");const bundleId=mobile.ios?.bundleId;if(!bundleId)throw Error("config/mobile.ts declares no ios.bundleId");const api=await AppStoreConnect.connect(credentials),store={api,appId:await api.appId(bundleId)};if(!await applyWhatToTest(store,tag,notes.plain,notesLocales(mobile),waitMinutes(options.wait)??0))process.exitCode=ExitCode.FatalError;return}const limit=options.play?PLAY_NOTES_LIMIT:options.markdown?Number.POSITIVE_INFINITY:TESTFLIGHT_NOTES_LIMIT;await printAsync(Number.isFinite(limit)?fitNotes(notes.text,limit):notes.text)}export function release(buddy){buddy.command("release",descriptions.release).option("--dry-run",descriptions.dryRun,{default:!1}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--bump <type>",descriptions.bump).option("--preid <identifier>","Prerelease channel, for example beta or rc").option("--skip-github-release",descriptions.skipGithubRelease,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy release` ...",options);if(options.dryRun)log.warn("Dry run enabled. No changes will be made or committed.");const startTime=await intro("buddy release"),result=await runAction(Action.Release,options);if(resultFailed(result)){await log.error("Failed to release",result.error);process.exit(ExitCode.FatalError)}await outro(options.dryRun?"Dry run complete. Nothing was committed, tagged or pushed.":"Triggered CI/CD Release via GitHub Actions",{startTime,useSeconds:!0});if(!options.dryRun)log.info(`Follow along: ${italic(resolveGitHubActionsUrl(readOriginRemote()))}`)});buddy.command("release:ios","Ship the iOS app: regenerate its project for production, commit, tag and push").option("--bump <type>","build | patch | minor | major | x.y.z (default: a new build of the current marketing version)").option("--keep-builds <n>","TestFlight builds to keep besides the new one; older ones are expired (needs an App Store Connect API key)",{default:1}).option("--skip-github-release",descriptions.skipGithubRelease,{default:!1}).option("--notes <text>","What to Test for this build, instead of the notes generated from the commits since the previous build").option("--notes-file <path>","Read What to Test from a file (also RELEASE_NOTES / RELEASE_NOTES_FILE, or release.notes in config/mobile.ts)").option("--wait [minutes]","Wait for the build to reach App Store Connect, then set its What to Test there too (default 60 minutes; needs an App Store Connect API key)").option("--dry-run",descriptions.dryRun,{default:!1}).action(async(options)=>{const startTime=await intro("buddy release:ios");try{const tag=await releaseIos(options);await outro(options.dryRun?`Dry run complete: would release ${tag}`:`Released ${tag}`,{startTime,useSeconds:!0});if(!options.dryRun)log.info('A CI that builds tags (Xcode Cloud: Tag Changes, prefix "v") turns it into a TestFlight build now.')}catch(error){await log.error("Failed to release the iOS app",error);process.exit(ExitCode.FatalError)}});buddy.command("release:notes [tag]","Print the tester notes of a mobile release tag (default: the newest v*-build.* tag), or set them as its TestFlight What to Test").option("--from <ref>","Generate from the commits since this ref instead of the previous build tag").option("--play","Fit the notes to Google Play's 500-character What's new",{default:!1}).option("--markdown","Markdown, as the GitHub release shows the notes",{default:!1}).option("--testflight","Set the notes as the TestFlight build's What to Test through App Store Connect",{default:!1}).option("--wait [minutes]","With --testflight: wait this long for the build to appear (default 60)").option("--notes <text>","Use this text instead").option("--notes-file <path>","Read the text from a file instead").action(async(tag,options)=>{try{await releaseNotesCommand(tag,options)}catch(error){await log.error("Failed to produce the release notes",error);process.exit(ExitCode.FatalError)}});buddy.command("release:github [tag]","Create the GitHub release for a pushed tag (default: the newest), with the changes since the tag before it").option("--dry-run","Print the release instead of creating it",{default:!1}).action(async(tag,options)=>{const startTime=await intro("buddy release:github");try{if(!options.dryRun)await assertPublicationAllowed({cwd:process.cwd(),channel:"github-release"});const target=tag||git("describe","--tags","--abbrev=0");if(!git("tag","--list",target))throw Error(`there is no tag ${target}`);const ios=/^v\d+\.\d+\.\d+-build\.\d+$/.test(target)?iosReleaseSummary(target,{numberedByXcodeCloud:appStoreConnectCredentials()!==null}):null;let summary=ios?.body;if(ios){const configPath=join(process.cwd(),"config/mobile.ts"),mobile=existsSync(configPath)?await loadMobileConfig(configPath):{};summary=githubSummary(ios.body,notesForTag(process.cwd(),target,mobile,{markdown:!0}).text)}const result=await publishGitHubRelease({cwd:process.cwd(),tag:target,name:ios?.name,summary,dryRun:options.dryRun});if(result.status==="failed")throw Error(describeGitHubRelease(target,result));log.info(options.dryRun&&result.draft?`Would create the GitHub release ${target} (${result.draft.name}):
10
+ ${preview.draft.body}`:describeGitHubRelease(tag,preview))}return tag}const paths=["config/mobile.ts","storage/framework/mobile/ios"];if(version!==current){writeFileSync(configPath,withIosVersion(source,version));const pkgPath=join(root,"package.json");if(existsSync(pkgPath)){const pkg=readFileSync(pkgPath,"utf8");writeFileSync(pkgPath,pkg.replace(/("version"\s*:\s*)"[^"]*"/,`$1"${version}"`));paths.push("package.json")}}const devOverrides=["MOBILE_URL","IOS_APP_VERSION","IOS_BUILD_NUMBER"];for(const key of devOverrides)delete process.env[key];const result=await runAction(Action.BuildIos,{env:{...withholdEnv(devOverrides),NODE_ENV:"production"}});if(resultFailed(result))throw result.error??Error("the iOS build failed");const craftConfigPath=join(root,IOS_PROJECT_DIR,"craft.config.json");if(existsSync(craftConfigPath)){const problem=unreleasableSite(JSON.parse(readFileSync(craftConfigPath,"utf8")));if(problem)throw Error(`${problem}. Set ios.url in config/mobile.ts to the production site. Nothing was committed, tagged or pushed; restore the build output and any version bump with \`git checkout -- ${paths.join(" ")}\`.`)}const written=writeWhatToTest(root,IOS_PROJECT_DIR,notes.text,locales);log.info(`What to Test from ${origin}: ${written.join(", ")}`);git("add","--",...paths);if(git("diff","--cached","--name-only"))git("commit","--quiet","-m",`chore(release): ${tag}`);pushIosReleaseTag(root,tag);if(!options.skipGithubRelease){const published=await publishGitHubRelease({cwd:root,tag,name:summary.name,summary:githubSummary(summary.body,githubNotes)});if(published.status==="failed")log.warn(describeGitHubRelease(tag,published));else log.info(describeGitHubRelease(tag,published))}if(store){try{const retiring=buildsToExpire(await store.api.builds(store.appId),keep);for(const build of retiring)await store.api.expire(build.id);if(retiring.length)log.info(`Expired TestFlight builds ${retiring.map((b)=>b.number).join(", ")}`)}catch(error){log.warn(`Could not expire older TestFlight builds: ${error instanceof Error?error.message:String(error)}`)}if(wait!==null&&numberedByXcodeCloud)await applyWhatToTest(store,tag,notes.text,locales,wait)}return tag}function notesForTag(root,tag,mobile,options){const releaseConfig=mobile.release??{},generateOptions={types:releaseConfig.noteTypes,excludeScopes:releaseConfig.excludeScopes},from=options.from||previousMobileTag(root,`${tag}^`),commits=commitsBetween(root,from,tag),plainGenerated=testerNotes(commits,generateOptions)||NO_VISIBLE_CHANGES,committed=options.from?null:committedWhatToTest(root,tag,IOS_PROJECT_DIR),handWritten=committed&&committed.trim()!==fitNotes(plainGenerated,XCODE_CLOUD_NOTES_LIMIT)?committed.trim():null,notes=resolveNotes({cwd:root,flags:options,config:releaseConfig,generate:()=>handWritten??plainGenerated});if(notes.source!=="generated"||handWritten)return{text:notes.text,plain:notes.text};return{text:options.markdown?testerNotes(commits,{...generateOptions,markdown:!0})||NO_VISIBLE_CHANGES:plainGenerated,plain:plainGenerated}}async function releaseNotesCommand(tagArgument,options){const root=process.cwd(),configPath=join(root,"config/mobile.ts"),mobile=existsSync(configPath)?await loadMobileConfig(configPath):{},tag=tagArgument||previousMobileTag(root,"HEAD");if(!tag||!git("tag","--list",tag))throw Error(tagArgument?`there is no tag ${tagArgument}`:"there is no mobile release tag (v*-build.*) yet");const notes=notesForTag(root,tag,mobile,options);if(options.testflight){const credentials=appStoreConnectCredentials();if(!credentials)throw Error("--testflight needs an App Store Connect API key (APP_STORE_CONNECT_KEY_ID, _ISSUER_ID, _PRIVATE_KEY_PATH)");const bundleId=mobile.ios?.bundleId;if(!bundleId)throw Error("config/mobile.ts declares no ios.bundleId");const api=await AppStoreConnect.connect(credentials),store={api,appId:await api.appId(bundleId)};if(!await applyWhatToTest(store,tag,notes.plain,notesLocales(mobile),waitMinutes(options.wait)??0))process.exitCode=ExitCode.FatalError;return}const limit=options.play?PLAY_NOTES_LIMIT:options.markdown?Number.POSITIVE_INFINITY:TESTFLIGHT_NOTES_LIMIT;await printAsync(Number.isFinite(limit)?fitNotes(notes.text,limit):notes.text)}export function release(buddy){buddy.command("release",descriptions.release).option("--dry-run",descriptions.dryRun,{default:!1}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--bump <type>",descriptions.bump).option("--preid <identifier>","Prerelease channel, for example beta or rc").option("--skip-github-release",descriptions.skipGithubRelease,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy release` ...",options);if(options.dryRun)log.warn("Dry run enabled. No changes will be made or committed.");const startTime=await intro("buddy release"),result=await runAction(Action.Release,options);if(resultFailed(result)){await log.error("Failed to release",result.error);process.exit(ExitCode.FatalError)}await outro(options.dryRun?"Dry run complete. Nothing was committed, tagged or pushed.":"Triggered CI/CD Release via GitHub Actions",{startTime,useSeconds:!0});if(!options.dryRun)log.info(`Follow along: ${italic(resolveGitHubActionsUrl(readOriginRemote()))}`)});buddy.command("release:ios","Ship the iOS app: regenerate its project for production, commit, tag and push").option("--bump <type>","build | patch | minor | major | x.y.z (default: a new build of the current marketing version)").option("--keep-builds <n>","TestFlight builds to keep besides the new one; older ones are expired (needs an App Store Connect API key)",{default:1}).option("--skip-github-release",descriptions.skipGithubRelease,{default:!1}).option("--notes <text>","What to Test for this build, instead of the notes generated from the commits since the previous build").option("--notes-file <path>","Read What to Test from a file (also RELEASE_NOTES / RELEASE_NOTES_FILE, or release.notes in config/mobile.ts)").option("--wait [minutes]","Wait for the build to reach App Store Connect, then set its What to Test there too (default 60 minutes; needs an App Store Connect API key)").option("--dry-run",descriptions.dryRun,{default:!1}).action(async(options)=>{const startTime=await intro("buddy release:ios");try{const tag=await releaseIos(options);await outro(options.dryRun?`Dry run complete: would release ${tag}`:`Released ${tag}`,{startTime,useSeconds:!0});if(!options.dryRun)log.info('A CI that builds tags (Xcode Cloud: Tag Changes, prefix "v") turns it into a TestFlight build now.')}catch(error){await log.error("Failed to release the iOS app",error);process.exit(ExitCode.FatalError)}});buddy.command("release:notes [tag]","Print the tester notes of a mobile release tag (default: the newest v*-build.* tag), or set them as its TestFlight What to Test").option("--from <ref>","Generate from the commits since this ref instead of the previous build tag").option("--play","Fit the notes to Google Play's 500-character What's new",{default:!1}).option("--markdown","Markdown, as the GitHub release shows the notes",{default:!1}).option("--testflight","Set the notes as the TestFlight build's What to Test through App Store Connect",{default:!1}).option("--wait [minutes]","With --testflight: wait this long for the build to appear (default 60)").option("--notes <text>","Use this text instead").option("--notes-file <path>","Read the text from a file instead").action(async(tag,options)=>{try{await releaseNotesCommand(tag,options)}catch(error){await log.error("Failed to produce the release notes",error);process.exit(ExitCode.FatalError)}});buddy.command("release:github [tag]","Create the GitHub release for a pushed tag (default: the newest), with the changes since the tag before it").option("--dry-run","Print the release instead of creating it",{default:!1}).action(async(tag,options)=>{const startTime=await intro("buddy release:github");try{if(!options.dryRun)await assertPublicationAllowed({cwd:process.cwd(),channel:"github-release"});const target=tag||git("describe","--tags","--abbrev=0");if(!git("tag","--list",target))throw Error(`there is no tag ${target}`);const ios=/^v\d+\.\d+\.\d+-build\.\d+$/.test(target)?iosReleaseSummary(target,{numberedByXcodeCloud:appStoreConnectCredentials()!==null}):null;let summary=ios?.body;if(ios){const configPath=join(process.cwd(),"config/mobile.ts"),mobile=existsSync(configPath)?await loadMobileConfig(configPath):{};summary=githubSummary(ios.body,notesForTag(process.cwd(),target,mobile,{markdown:!0}).text)}const result=await publishGitHubRelease({cwd:process.cwd(),tag:target,name:ios?.name,summary,dryRun:options.dryRun});if(result.status==="failed")throw Error(describeGitHubRelease(target,result));log.info(options.dryRun&&result.draft?`Would create the GitHub release ${target} (${result.draft.name}):
11
11
 
12
12
  ${result.draft.body}`:describeGitHubRelease(target,result));await outro(options.dryRun?"Dry run complete. Nothing was created.":`GitHub release for ${target}`,{startTime,useSeconds:!0})}catch(error){await log.error("Failed to create the GitHub release",error);process.exit(ExitCode.FatalError)}});buddy.command("release:verify <plan>","Verify exact source gates, published archives and installed consumer checks").option("--output <path>","Save the successful verification receipt as JSON").action(async(planPath,options)=>{let temporary;try{const plan=JSON.parse(readFileSync(planPath,"utf8"));validateReleaseEvidencePlan(plan);const receipt=await verifyReleaseEvidence(plan,{cwd:process.cwd(),token:resolveGitHubToken()??void 0}),json=`${JSON.stringify(receipt,null,2)}
13
13
  `;if(options.output){temporary=`${options.output}.${crypto.randomUUID()}.tmp`;writeFileSync(temporary,json,{flag:"wx",mode:384});renameSync(temporary,options.output);temporary=void 0;await printAsync(`Verified release evidence saved to ${options.output}`)}else await printAsync(json.trimEnd())}catch(error){if(temporary)rmSync(temporary,{force:!0});await log.error("Release verification failed",error);process.exitCode=ExitCode.FatalError}});onUnknownSubcommand(buddy,"release")}export function resolveGitHubActionsUrl(remoteUrl,repository=process.env.GITHUB_REPOSITORY,serverUrl=process.env.GITHUB_SERVER_URL??"https://github.com"){const repo=repository?.trim()||remoteUrl?.trim().match(/github\.com[/:]([^/\s]+\/[^/\s]+?)(?:\.git)?$/)?.[1];return repo?`${serverUrl.replace(/\/$/,"")}/${repo}/actions`:"https://github.com/stacksjs/stacks/actions"}function readOriginRemote(){try{return execFileSync("git",["config","--get","remote.origin.url"],{encoding:"utf8"}).trim()}catch{return}}
@@ -6,6 +6,8 @@ export declare function withIosVersion(source: string, version: string): string;
6
6
  export declare function bumpedVersion(current: string, bump: string): string;
7
7
  /** The next build's tag for `version`, after the tags that already exist. */
8
8
  export declare function nextBuildTag(version: string, tags: string[], latestRun?: number): string;
9
+ /** Atomically publish the candidate and its tag, retaining the commit on rejection. */
10
+ export declare function pushIosReleaseTag(cwd: string, tag: string): void;
9
11
  /**
10
12
  * The GitHub release's title and opening line for an iOS build tag.
11
13
  *
@@ -1 +1 @@
1
- import{incrementVersion,nextBuildNumber,SemVer}from"@stacksjs/bumpx";import{isLocalSite}from"@stacksjs/actions/build/mobile-target";function marketingVersion(version){const parsed=new SemVer(version);if(parsed.prerelease.length||parsed.build.length||parsed.toString()!==version)throw Error("An iOS marketing version must be a stable version such as 1.0.0");return parsed.toString()}function iosBlock(source){const start=source.search(/\bios\s*:\s*\{/);if(start===-1)return null;const after=source.slice(start+1).search(/\n\s*(?:android|desktop|watch|web)\s*:\s*\{/);return{start,end:after===-1?source.length:start+1+after}}const VERSION_ENTRY=/(\bversion\s*:\s*(?:[^,\n]*?\?\?\s*)?)(['"])(\d+\.\d+\.\d+)\2/;export function iosVersionIn(source){const block=iosBlock(source);if(!block)return null;return VERSION_ENTRY.exec(source.slice(block.start,block.end))?.[3]??null}export function withIosVersion(source,version){marketingVersion(version);const block=iosBlock(source);if(!block||!VERSION_ENTRY.test(source.slice(block.start,block.end)))throw Error("config/mobile.ts declares no iOS `version`");const ios=source.slice(block.start,block.end).replace(VERSION_ENTRY,(_,lead,quote)=>`${lead}${quote}${version}${quote}`);return source.slice(0,block.start)+ios+source.slice(block.end)}export function bumpedVersion(current,bump){marketingVersion(current);if(bump==="build")return current;if(["patch","minor","major"].includes(bump))return marketingVersion(incrementVersion(current,bump));return marketingVersion(bump)}export function nextBuildTag(version,tags,latestRun=0){marketingVersion(version);const prefix=`v${version}-build.`,builds=tags.filter((tag)=>tag.startsWith(prefix)).map((tag)=>tag.slice(prefix.length)).filter((number)=>/^[1-9]\d*$/.test(number)).map(Number).filter(Number.isSafeInteger);return`${prefix}${nextBuildNumber(latestRun,builds)}`}export function iosReleaseSummary(tag,options){const match=/^v(\d+\.\d+\.\d+)-build\.(\d+)$/.exec(tag);if(!match)throw Error(`${tag} is not an iOS build tag (v<version>-build.<n>)`);const[,version,build]=match;return{name:`${version} (build ${build})`,body:options.numberedByXcodeCloud?`Version ${version}, TestFlight build **${build}**: Xcode Cloud builds this tag, and its run number is the build number TestFlight shows.`:`Version ${version}, build tag ${build}. Xcode Cloud builds this tag; it was cut without an App Store Connect key, so the build number TestFlight shows may differ.`}}export function unreleasableSite(craftConfig){const site=craftConfig.devServerURL;if(typeof site!=="string"||!site)return null;let url;try{url=new URL(site)}catch{return`devServerURL ${JSON.stringify(site)} is not a URL`}return isLocalSite(url)?`devServerURL ${url.origin}${url.pathname} is a local address, which a tester's phone cannot reach`:null}
1
+ import{incrementVersion,nextBuildNumber,SemVer}from"@stacksjs/bumpx";import{isLocalSite}from"@stacksjs/actions/build/mobile-target";import{execFileSync}from"node:child_process";function marketingVersion(version){const parsed=new SemVer(version);if(parsed.prerelease.length||parsed.build.length||parsed.toString()!==version)throw Error("An iOS marketing version must be a stable version such as 1.0.0");return parsed.toString()}function iosBlock(source){const start=source.search(/\bios\s*:\s*\{/);if(start===-1)return null;const after=source.slice(start+1).search(/\n\s*(?:android|desktop|watch|web)\s*:\s*\{/);return{start,end:after===-1?source.length:start+1+after}}const VERSION_ENTRY=/(\bversion\s*:\s*(?:[^,\n]*?\?\?\s*)?)(['"])(\d+\.\d+\.\d+)\2/;export function iosVersionIn(source){const block=iosBlock(source);if(!block)return null;return VERSION_ENTRY.exec(source.slice(block.start,block.end))?.[3]??null}export function withIosVersion(source,version){marketingVersion(version);const block=iosBlock(source);if(!block||!VERSION_ENTRY.test(source.slice(block.start,block.end)))throw Error("config/mobile.ts declares no iOS `version`");const ios=source.slice(block.start,block.end).replace(VERSION_ENTRY,(_,lead,quote)=>`${lead}${quote}${version}${quote}`);return source.slice(0,block.start)+ios+source.slice(block.end)}export function bumpedVersion(current,bump){marketingVersion(current);if(bump==="build")return current;if(["patch","minor","major"].includes(bump))return marketingVersion(incrementVersion(current,bump));return marketingVersion(bump)}export function nextBuildTag(version,tags,latestRun){marketingVersion(version);const prefix=`v${version}-build.`;if(latestRun!==void 0){const tag=`${prefix}${nextBuildNumber(latestRun)}`;if(tags.includes(tag))throw Error(`${tag} already exists, but Xcode Cloud has not allocated that run. Reconcile the release tags with Xcode Cloud before releasing; a larger tag would misidentify the TestFlight build.`);return tag}const builds=tags.filter((tag)=>tag.startsWith(prefix)).map((tag)=>tag.slice(prefix.length)).filter((number)=>/^[1-9]\d*$/.test(number)).map(Number).filter(Number.isSafeInteger);return`${prefix}${nextBuildNumber(0,builds)}`}export function pushIosReleaseTag(cwd,tag){const git=(...args)=>execFileSync("git",args,{cwd,encoding:"utf8",stdio:["ignore","pipe","pipe"]}).trim();git("tag","-a",tag,"-m",tag);const createdTag=git("rev-parse",`refs/tags/${tag}`);try{git("push","--quiet","--atomic","origin","main",tag)}catch(error){try{if(!git("ls-remote","--tags","origin",`refs/tags/${tag}`))git("update-ref","-d",`refs/tags/${tag}`,createdTag)}catch{}throw error}}export function iosReleaseSummary(tag,options){const match=/^v(\d+\.\d+\.\d+)-build\.(\d+)$/.exec(tag);if(!match)throw Error(`${tag} is not an iOS build tag (v<version>-build.<n>)`);const[,version,build]=match;return{name:`${version} (build ${build})`,body:options.numberedByXcodeCloud?`Version ${version}, TestFlight build **${build}**: Xcode Cloud builds this tag, and its run number is the build number TestFlight shows.`:`Version ${version}, build tag ${build}. Xcode Cloud builds this tag; it was cut without an App Store Connect key, so the build number TestFlight shows may differ.`}}export function unreleasableSite(craftConfig){const site=craftConfig.devServerURL;if(typeof site!=="string"||!site)return null;let url;try{url=new URL(site)}catch{return`devServerURL ${JSON.stringify(site)} is not a URL`}return isLocalSite(url)?`devServerURL ${url.origin}${url.pathname} is a local address, which a tester's phone cannot reach`:null}
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.408",
5
+ "version": "0.75.417",
6
6
  "description": "Meet Buddy. The Stacks runtime.",
7
7
  "author": "Chris Breuer",
8
8
  "contributors": [
@@ -95,72 +95,72 @@
95
95
  "prepublishOnly": "bun run build"
96
96
  },
97
97
  "dependencies": {
98
- "@stacksjs/actions": "0.75.408",
99
- "@stacksjs/ai": "0.75.408",
100
- "@stacksjs/alias": "0.75.408",
101
- "@stacksjs/analytics": "0.75.408",
102
- "@stacksjs/api": "0.75.408",
103
- "@stacksjs/arrays": "0.75.408",
104
- "@stacksjs/auth": "0.75.408",
105
- "@stacksjs/browser-extension": "0.75.408",
106
- "@stacksjs/build": "0.75.408",
98
+ "@stacksjs/actions": "0.75.417",
99
+ "@stacksjs/ai": "0.75.417",
100
+ "@stacksjs/alias": "0.75.417",
101
+ "@stacksjs/analytics": "0.75.417",
102
+ "@stacksjs/api": "0.75.417",
103
+ "@stacksjs/arrays": "0.75.417",
104
+ "@stacksjs/auth": "0.75.417",
105
+ "@stacksjs/browser-extension": "0.75.417",
106
+ "@stacksjs/build": "0.75.417",
107
107
  "@stacksjs/bumpx": "^0.2.26",
108
- "@stacksjs/cache": "0.75.408",
109
- "@stacksjs/chat": "0.75.408",
108
+ "@stacksjs/cache": "0.75.417",
109
+ "@stacksjs/chat": "0.75.417",
110
110
  "@stacksjs/clapp": "^0.2.16",
111
- "@stacksjs/cli": "0.75.408",
112
- "@stacksjs/cloud": "0.75.408",
113
- "@stacksjs/cms": "0.75.408",
114
- "@stacksjs/collections": "0.75.408",
115
- "@stacksjs/commerce": "0.75.408",
116
- "@stacksjs/config": "0.75.408",
117
- "@stacksjs/database": "0.75.408",
118
- "@stacksjs/desktop-build": "0.75.408",
119
- "@stacksjs/dns": "0.75.408",
111
+ "@stacksjs/cli": "0.75.417",
112
+ "@stacksjs/cloud": "0.75.417",
113
+ "@stacksjs/cms": "0.75.417",
114
+ "@stacksjs/collections": "0.75.417",
115
+ "@stacksjs/commerce": "0.75.417",
116
+ "@stacksjs/config": "0.75.417",
117
+ "@stacksjs/database": "0.75.417",
118
+ "@stacksjs/desktop-build": "0.75.417",
119
+ "@stacksjs/dns": "0.75.417",
120
120
  "@stacksjs/dnsx": "^0.2.3",
121
- "@stacksjs/email": "0.75.408",
122
- "@stacksjs/enums": "0.75.408",
123
- "@stacksjs/env": "0.75.408",
124
- "@stacksjs/error-handling": "0.75.408",
125
- "@stacksjs/events": "0.75.408",
126
- "@stacksjs/features": "0.75.408",
127
- "@stacksjs/git": "0.75.408",
121
+ "@stacksjs/email": "0.75.417",
122
+ "@stacksjs/enums": "0.75.417",
123
+ "@stacksjs/env": "0.75.417",
124
+ "@stacksjs/error-handling": "0.75.417",
125
+ "@stacksjs/events": "0.75.417",
126
+ "@stacksjs/features": "0.75.417",
127
+ "@stacksjs/git": "0.75.417",
128
128
  "@stacksjs/gitit": "^0.2.5",
129
- "@stacksjs/health": "0.75.408",
129
+ "@stacksjs/health": "0.75.417",
130
130
  "@stacksjs/httx": "^0.1.10",
131
- "@stacksjs/image": "0.75.408",
132
- "@stacksjs/lint": "0.75.408",
133
- "@stacksjs/logging": "0.75.408",
134
- "@stacksjs/notifications": "0.75.408",
135
- "@stacksjs/objects": "0.75.408",
136
- "@stacksjs/orm": "0.75.408",
137
- "@stacksjs/path": "0.75.408",
138
- "@stacksjs/payments": "0.75.408",
139
- "@stacksjs/realtime": "0.75.408",
140
- "@stacksjs/router": "0.75.408",
131
+ "@stacksjs/image": "0.75.417",
132
+ "@stacksjs/lint": "0.75.417",
133
+ "@stacksjs/logging": "0.75.417",
134
+ "@stacksjs/notifications": "0.75.417",
135
+ "@stacksjs/objects": "0.75.417",
136
+ "@stacksjs/orm": "0.75.417",
137
+ "@stacksjs/path": "0.75.417",
138
+ "@stacksjs/payments": "0.75.417",
139
+ "@stacksjs/realtime": "0.75.417",
140
+ "@stacksjs/router": "0.75.417",
141
141
  "@stacksjs/rpx": "^0.11.54",
142
- "@stacksjs/scheduler": "0.75.408",
143
- "@stacksjs/search-engine": "0.75.408",
144
- "@stacksjs/security": "0.75.408",
145
- "@stacksjs/server": "0.75.408",
146
- "@stacksjs/sites": "0.75.408",
147
- "@stacksjs/skills": "0.75.408",
148
- "@stacksjs/socials": "0.75.408",
149
- "@stacksjs/storage": "0.75.408",
150
- "@stacksjs/strings": "0.75.408",
151
- "@stacksjs/stx": "0.2.779",
152
- "@stacksjs/testing": "0.75.408",
153
- "@stacksjs/tinker": "0.75.408",
142
+ "@stacksjs/scheduler": "0.75.417",
143
+ "@stacksjs/search-engine": "0.75.417",
144
+ "@stacksjs/security": "0.75.417",
145
+ "@stacksjs/server": "0.75.417",
146
+ "@stacksjs/sites": "0.75.417",
147
+ "@stacksjs/skills": "0.75.417",
148
+ "@stacksjs/socials": "0.75.417",
149
+ "@stacksjs/storage": "0.75.417",
150
+ "@stacksjs/strings": "0.75.417",
151
+ "@stacksjs/stx": "0.2.780",
152
+ "@stacksjs/testing": "0.75.417",
153
+ "@stacksjs/tinker": "0.75.417",
154
154
  "@stacksjs/tlsx": "^0.13.19",
155
155
  "@stacksjs/ts-cloud": "^0.16.91",
156
- "@stacksjs/tunnel": "0.75.408",
157
- "@stacksjs/types": "0.75.408",
158
- "@stacksjs/ui": "0.75.408",
159
- "@stacksjs/utils": "0.75.408",
160
- "@stacksjs/validation": "0.75.408",
156
+ "@stacksjs/tunnel": "0.75.417",
157
+ "@stacksjs/types": "0.75.417",
158
+ "@stacksjs/ui": "0.75.417",
159
+ "@stacksjs/utils": "0.75.417",
160
+ "@stacksjs/validation": "0.75.417",
161
161
  "ajv": "^8.20.0",
162
162
  "ajv-formats": "^3.0.1",
163
- "bun-plugin-stx": "0.2.779",
163
+ "bun-plugin-stx": "0.2.780",
164
164
  "ts-pantry": "^0.11.146"
165
165
  },
166
166
  "devDependencies": {