@stacksjs/buddy 0.74.11 → 0.74.12

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,6 +1,6 @@
1
- import{createWriteStream,existsSync,mkdirSync,statSync}from"node:fs";import{homedir}from"node:os";import{dirname,join}from"node:path";import process from"node:process";import{intro,onUnknownSubcommand,outro,prompts}from"@stacksjs/cli";import{ExitCode}from"@stacksjs/types";import{loadTsCloudConfig,loadTsCloudDeployApi,resolveProvider}from"./deploy";import{mergeSshStatePin,resolveSshTarget,sshCliArgs,sshStatePin}from"./deploy-ssh-target";import{describeDisk,flashRefusalReason,parseDnsSdBrowse,parseOsCatalogue,resolveBootVolume,selectImage}from"./server-image";import{CA_MISSING_EXIT,caCopyPath,caReadScript,DEFAULT_LAN_CA_PATH,mobileconfigInstructions,resolveCaPath,trustSummary}from"./server-trust";const log={info:(...args)=>console.log("\u2139",...args),success:(...args)=>console.log("\u2713",...args),warn:(...args)=>console.log("\u26A0",...args),error:(...args)=>console.error("\u2717",...args)},OS_CATALOGUE_URL="https://downloads.raspberrypi.com/os_list_imagingutility_v4.json";function imageCacheDir(){return join(homedir(),".cache","stacks","images")}async function readDiskInfo(device){const proc=Bun.spawn(["diskutil","info","-plist",device],{stdout:"pipe",stderr:"pipe"}),[out,code]=await Promise.all([new Response(proc.stdout).text(),proc.exited]);if(code!==0||!out.trim())return null;const bool=(key)=>{const match=new RegExp(`<key>${key}</key>\\s*<(true|false)/>`).exec(out);return match?match[1]==="true":void 0},str=(key)=>{return new RegExp(`<key>${key}</key>\\s*<string>([^<]*)</string>`).exec(out)?.[1]},num=(key)=>{const match=new RegExp(`<key>${key}</key>\\s*<integer>(\\d+)</integer>`).exec(out);return match?Number(match[1]):void 0};return{DeviceIdentifier:str("DeviceIdentifier"),DeviceNode:str("DeviceNode"),MediaName:str("MediaName"),Size:num("Size"),WholeDisk:bool("WholeDisk"),Internal:bool("Internal"),Ejectable:bool("Ejectable"),Removable:bool("Removable"),RemovableMediaOrExternalDevice:bool("RemovableMediaOrExternalDevice"),SystemImage:bool("SystemImage"),BusProtocol:str("BusProtocol")}}async function listFlashableDisks(){const proc=Bun.spawn(["diskutil","list","-plist"],{stdout:"pipe",stderr:"pipe"}),[out]=await Promise.all([new Response(proc.stdout).text(),proc.exited]),whole=[...out.matchAll(/<string>(disk\d+)<\/string>/g)].map((match)=>match[1]),found=[];for(const id of[...new Set(whole)]){const info=await readDiskInfo(`/dev/${id}`);if(info&&flashRefusalReason(info)===null)found.push(info)}return found}async function resolveImage(os){const response=await fetch(OS_CATALOGUE_URL);if(!response.ok)throw Error(`Could not read the image catalogue (HTTP ${response.status}). Check the network and try again.`);return selectImage(parseOsCatalogue(await response.json()),os)}async function downloadImage(image){const dir=imageCacheDir();mkdirSync(dir,{recursive:!0});const target=join(dir,image.url.split("/").pop()||`${image.id}.img.xz`);if(existsSync(target)&&image.downloadSize&&statSync(target).size===image.downloadSize){log.info(`Using the cached download at ${target}`);return target}const size=image.downloadSize?` (${(image.downloadSize/1e9).toFixed(2)} GB)`:"";log.info(`Downloading ${image.name}${size}...`);const response=await fetch(image.url);if(!response.ok||!response.body)throw Error(`Download failed (HTTP ${response.status}) for ${image.url}`);const file=createWriteStream(`${target}.part`);await Bun.write(Bun.file(`${target}.part`),response);file.close();await Bun.$`mv ${`${target}.part`} ${target}`.quiet();return target}async function resolveDecompressor(){for(const candidate of["xz","unxz"])if(Bun.spawnSync(["which",candidate]).exitCode===0)return[candidate,"-dc"];throw Error("No xz decompressor found, and the images are .img.xz.\n Install one with: brew install xz\n Or flash the card with Raspberry Pi Imager, then run `buddy server:first-boot` against the mounted card.")}async function loadSshApi(){const api=await loadTsCloudDeployApi(),missing=["SshDriver","buildCloudInitFirstBoot","buildSshBootstrapScript","evaluatePreflight","formatPreflightFindings"].filter((name)=>typeof api[name]!=="function");if(missing.length>0){await log.error("This @stacksjs/ts-cloud does not support deploying to a host over SSH.");await log.error(`Missing: ${missing.join(", ")}.`);log.info("Upgrade with `bun update @stacksjs/ts-cloud`, or point TS_CLOUD_MODULE at a build that has it.");process.exit(ExitCode.FatalError)}return api}async function loadSshProject(environment){const config=await loadTsCloudConfig(environment);if(!config){await log.error("No ts-cloud configuration found. Expected a `tsCloud` export from config/cloud.ts.");process.exit(ExitCode.FatalError)}const target=resolveSshTarget(config);if(!target){await log.error("No SSH host configured.");log.info("Add one to config/cloud.ts: ssh: { hosts: [{ host: 'pi-stacks.local', user: 'pi' }] }");log.info("Or set TS_CLOUD_SSH_HOST (with TS_CLOUD_SSH_USER / TS_CLOUD_SSH_PORT / TS_CLOUD_SSH_KEY).");process.exit(ExitCode.FatalError)}if(resolveProvider(config)!=="ssh")log.warn(`config/cloud.ts sets provider '${resolveProvider(config)}'. Set it to 'ssh' before \`buddy deploy\` will use this host.`);return{config,target}}async function discoverHosts(seconds=4){if(process.platform!=="darwin")return[];const proc=Bun.spawn(["dns-sd","-B","_ssh._tcp","local."],{stdout:"pipe",stderr:"ignore"}),timer=setTimeout(()=>proc.kill(),seconds*1000);try{return parseDnsSdBrowse(await new Response(proc.stdout).text())}catch{return[]}finally{clearTimeout(timer)}}async function reportPreflight(api,target,asJson){const driver=new api.SshDriver({hosts:[{host:target.host,user:target.user,port:target.port,privateKeyPath:target.identityFile}],hostKey:target.hostKey,profile:target.profile});let facts,findings;try{({facts,findings}=await driver.preflight(target.host))}catch(err){const detail=err instanceof Error?err.message:String(err),unreachable={code:"ssh.unreachable",severity:"error",message:`Could not reach ${target.user}@${target.host} over SSH.`,remediation:"Check the board is powered on and on this network, that SSH is enabled, and that your key is authorised. A board that has just booted can take a minute to answer.",detail};if(asJson)console.log(JSON.stringify({host:target.host,facts:null,findings:[unreachable]},null,2));else{log.error(unreachable.message);log.info(unreachable.remediation);log.info(detail.split(`
2
- `).find((line)=>line.trim()&&!line.startsWith("Remote SSH"))||detail)}return!1}if(asJson)console.log(JSON.stringify({host:target.host,facts,findings},null,2));else{const text=api.formatPreflightFindings(findings);if(text.trim())console.log(text);else log.success("No problems found.")}return!(typeof api.preflightFailed==="function"?api.preflightFailed(findings):findings.some((finding)=>finding.severity==="error"))}function buildBootstrapOrExit(api,config,environment,sudoUser){const profile=config.ssh?.profile==="generic"?"generic":"raspberry-pi";try{return api.buildSshBootstrapScript({config,environment,profile,sudoUser,lan:config.ssh?.lan})}catch(err){console.error(err instanceof Error?err.message:String(err));log.info("Edit config/cloud.ts and run this again.");process.exit(ExitCode.FatalError)}}async function readRemoteCa(target,caPath){const proc=Bun.spawn(["ssh",...sshCliArgs(target,{connectTimeoutSec:20}),"sh","-s"],{stdin:new TextEncoder().encode(caReadScript(caPath)),stdout:"pipe",stderr:"pipe"}),[out,err,code]=await Promise.all([new Response(proc.stdout).text(),new Response(proc.stderr).text(),proc.exited]);if(code===CA_MISSING_EXIT)return{failure:"missing",detail:""};if(code!==0)return{failure:err.toLowerCase().includes("sudo")?"unreadable":"unreachable",detail:err.trim()};if(!out.includes("-----BEGIN CERTIFICATE-----"))return{failure:"unreadable",detail:""};return{pem:out}}function trustPlatform(){if(process.platform==="darwin")return"macos";return process.platform==="win32"?"windows":"debian"}export function server(buddy){const descriptions={flash:"Write a Linux OS image to an SD card or USB disk",os:"Which image to write: raspberry-pi-os-lite, raspberry-pi-os, ubuntu-24.04, ubuntu-26.04",device:"The whole disk to write to, for example /dev/disk4",verbose:"Enable verbose output"};buddy.command("server:flash",descriptions.flash).option("--os <name>",descriptions.os,{default:"raspberry-pi-os-lite"}).option("--device <path>",descriptions.device,{default:void 0}).option("--list","List the disks that could be written to, and exit",{default:!1}).option("--dry-run","Say what would happen without writing anything",{default:!1}).option("--yes","Do not ask for confirmation before writing",{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{const perf=await intro("buddy server:flash");if(process.platform!=="darwin"){await log.error("`buddy server:flash` currently supports macOS only.");log.info("On Linux, write the image with `dd`, then run `buddy server:first-boot` against the mounted boot partition.");process.exit(ExitCode.FatalError)}const disks=await listFlashableDisks();if(options.list){if(disks.length===0)log.info("No removable disks are attached.");for(const disk of disks)log.info(` ${describeDisk(disk)}`);await outro("Exited",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)}let image;try{image=await resolveImage(options.os)}catch(err){await log.error(err instanceof Error?err.message:String(err));process.exit(ExitCode.FatalError)}log.info(`Image: ${image.name} (${image.releaseDate??"unknown date"})`);if(!image.supportsPi5)log.warn("This image is not listed as supporting the Raspberry Pi 5.");let device=options.device;if(!device){if(disks.length===0){await log.error("No removable disk is attached. Insert the card and try again, or pass --device.");process.exit(ExitCode.FatalError)}if(disks.length>1){log.error("Several removable disks are attached, so buddy will not pick one:");for(const disk of disks)await log.error(` ${describeDisk(disk)}`);log.info("Re-run with --device /dev/diskN naming the one you mean.");process.exit(ExitCode.FatalError)}device=disks[0]?.DeviceNode}const info=device?await readDiskInfo(device):null;if(!info){await log.error(`Could not read ${device}. Check the device path with \`diskutil list\`.`);process.exit(ExitCode.FatalError)}const refusal=flashRefusalReason(info);if(refusal){await log.error(refusal);process.exit(ExitCode.FatalError)}let decompressor;try{decompressor=await resolveDecompressor()}catch(err){await log.error(err instanceof Error?err.message:String(err));process.exit(ExitCode.FatalError)}log.info(`Target: ${describeDisk(info)}`);if(options.dryRun){log.info("Dry run: nothing was downloaded and nothing was written.");await outro("Exited",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)}if(!options.yes){if(await prompts.confirm({message:`Erase ${describeDisk(info)} and write ${image.name}?`,initial:!1})!==!0){log.info("Nothing was written.");process.exit(ExitCode.Success)}}let download;try{download=await downloadImage(image)}catch(err){await log.error(err instanceof Error?err.message:String(err));process.exit(ExitCode.FatalError)}const raw=info.DeviceNode.replace("/dev/disk","/dev/rdisk");log.info(`Unmounting ${info.DeviceNode}...`);await Bun.$`diskutil unmountDisk ${info.DeviceNode}`.nothrow();log.info("Writing the image. This needs your password, and takes a few minutes.");log.info(` ${decompressor.join(" ")} ${download} | sudo dd of=${raw} bs=4m status=progress`);if(await Bun.spawn(["sh","-c",`${decompressor[0]} -dc ${JSON.stringify(download)} | sudo dd of=${JSON.stringify(raw)} bs=4m status=progress`],{stdin:"inherit",stdout:"inherit",stderr:"inherit"}).exited!==0){await log.error("Writing the image failed. The card is probably unusable until it is written again.");process.exit(ExitCode.FatalError)}await Bun.$`sync`.nothrow();log.success("Image written.");const boot=resolveBootVolume(image,existsSync);if(boot)log.info(`Boot partition mounted at ${boot}`);else log.info(`Re-insert the card if it does not mount, then look for /Volumes/${image.bootVolume}`);log.info("Next: `buddy server:first-boot --hostname <name> --user <name>` to configure the first boot.");await outro("Done",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("server:first-boot","Write the first-boot configuration onto a freshly flashed card").option("--hostname <name>","The name the board answers to on the network",{default:"pi-stacks"}).option("--user <name>","The login to create, which the deploy then uses",{default:"pi"}).option("--ssh-key <path>","Public key to authorise",{default:void 0}).option("--os <name>",descriptions.os,{default:"raspberry-pi-os-lite"}).option("--out <dir>","Write the files here instead of the mounted boot partition",{default:void 0}).option("--wifi-ssid <ssid>","Join this wireless network on first boot",{default:void 0}).option("--wifi-country <code>","Two-letter regulatory domain, required with wifi",{default:void 0}).option("--timezone <tz>","IANA timezone for the board",{default:void 0}).option("--env <name>","Environment whose configuration to bootstrap",{default:"production"}).option("--force","Overwrite first-boot files already on the card",{default:!1}).action(async(options)=>{const perf=await intro("buddy server:first-boot"),api=await loadSshApi(),{config}=await loadSshProject(options.env),keyPath=options.sshKey||join(homedir(),".ssh","id_ed25519.pub");if(!existsSync(keyPath)){await log.error(`No public key at ${keyPath}.`);log.info("Generate one with: ssh-keygen -t ed25519");log.info("Or point at an existing key with --ssh-key.");process.exit(ExitCode.FatalError)}let wifi;if(options.wifiSsid){if(!options.wifiCountry){await log.error("--wifi-country is required with --wifi-ssid. It sets the radio regulatory domain.");process.exit(ExitCode.FatalError)}const passphrase=process.env.WIFI_PASSWORD||await prompts.password({message:`Passphrase for ${options.wifiSsid}`});if(typeof passphrase!=="string"||!passphrase){await log.error("No wireless passphrase given.");process.exit(ExitCode.FatalError)}wifi={ssid:options.wifiSsid,passphrase,country:String(options.wifiCountry).toUpperCase()}}const bootstrap=buildBootstrapOrExit(api,config,options.env,options.user==="root"?void 0:options.user),os=String(options.os).startsWith("ubuntu")?"ubuntu":"raspberry-pi-os",bundle=api.buildCloudInitFirstBoot({hostname:options.hostname,user:options.user,publicKey:(await Bun.file(keyPath).text()).trim(),timezone:options.timezone,wifi},bootstrap,{os});let destination=options.out;if(!destination){const image=await resolveImage(options.os).catch(()=>null);destination=image?resolveBootVolume(image,existsSync):null;if(!destination){await log.error("The card does not appear to be mounted.");log.info("Insert the freshly written card and try again, or pass --out <dir> to write the files elsewhere.");process.exit(ExitCode.FatalError)}}mkdirSync(destination,{recursive:!0});for(const name of Object.keys(bundle.files)){const path=join(destination,name);if(existsSync(path)&&!options.force){await log.error(`${path} already exists. Re-run with --force to replace it.`);process.exit(ExitCode.FatalError)}}for(const[name,contents]of Object.entries(bundle.files)){await Bun.write(join(destination,name),contents);log.success(`Wrote ${join(destination,name)}`)}if(bundle.instructions)console.log(`
3
- ${bundle.instructions}`);log.info(`Next: eject the card, boot the board, then \`buddy server:doctor ${options.hostname}.local\``);await outro("Done",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("server:doctor [host]","Check that a host can run this application before deploying to it").option("--env <name>","Environment whose configuration to check against",{default:"production"}).option("--discover","Browse the local network for hosts advertising SSH",{default:!1}).option("--json","Print the findings as JSON",{default:!1}).action(async(host,options)=>{const perf=options.json?void 0:await intro("buddy server:doctor"),api=await loadSshApi(),{config,target}=await loadSshProject(options.env);let host_=host;if(!host_&&options.discover){const found=await discoverHosts();if(found.length===0)log.info("No hosts advertising SSH were found on this network.");for(const entry of found)log.info(` ${entry.hostname}`);host_=found[0]?.hostname}const checked=host_?{...target,host:host_}:target;log.info(`Checking ${checked.user}@${checked.host}${checked.port===22?"":`:${checked.port}`}...`);const ok=await reportPreflight(api,checked,options.json===!0);if(perf)await outro(ok?"Ready":"Not ready",{startTime:perf,useSeconds:!0});process.exit(ok?ExitCode.Success:ExitCode.FatalError)});buddy.command("server:setup [host]","Adopt a host: check it, then install what the deploy needs").option("--env <name>","Environment whose configuration to bootstrap",{default:"production"}).option("--discover","Browse the local network for hosts advertising SSH",{default:!1}).option("--dry-run","Run the checks and stop before changing the host",{default:!1}).action(async(host,options)=>{const perf=await intro("buddy server:setup"),api=await loadSshApi(),{config,target}=await loadSshProject(options.env);let host_=host;if(!host_&&options.discover){const found=await discoverHosts();for(const entry of found)log.info(` ${entry.hostname}`);host_=found[0]?.hostname}const adopted=host_?{...target,host:host_}:target;log.info(`Adopting ${adopted.user}@${adopted.host}${adopted.port===22?"":`:${adopted.port}`}`);if(!await reportPreflight(api,adopted,!1)){await log.error("The host is not ready. Nothing was changed on it.");process.exit(ExitCode.FatalError)}if(options.dryRun){log.info("Dry run: the host passed its checks and was not modified.");await outro("Done",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)}const driver=api.createCloudDriver({config,provider:"ssh"});if(!driver.provisionComputeInfrastructure){await log.error("This ts-cloud cannot bootstrap an SSH host (update @stacksjs/ts-cloud).");process.exit(ExitCode.FatalError)}buildBootstrapOrExit(api,config,options.env,adopted.user==="root"?void 0:adopted.user);log.info("Installing the runtime, gateway and service units if they are missing...");let outputs;try{outputs=await driver.provisionComputeInfrastructure({config,environment:options.env})}catch(err){await log.error("Bootstrapping the host failed.");await log.error(err instanceof Error?err.message:String(err));process.exit(ExitCode.FatalError)}const stackName=config.project?.stackName||`${config.project?.slug||"app"}-${options.env}`,dir=join(process.cwd(),"storage","cloud","state"),statePath=join(dir,`${stackName}.json`);let recorded=null;try{recorded=existsSync(statePath)?JSON.parse(await Bun.file(statePath).text()):null}catch{recorded=null}mkdirSync(dir,{recursive:!0});await Bun.write(statePath,`${JSON.stringify(mergeSshStatePin(recorded,sshStatePin({stackName,target:adopted,deployStoragePath:outputs?.deployStoragePath})),null,2)}
1
+ import{createWriteStream,existsSync,mkdirSync,statSync}from"node:fs";import{homedir}from"node:os";import{dirname,join}from"node:path";import process from"node:process";import{intro,onUnknownSubcommand,outro,prompts}from"@stacksjs/cli";import{ExitCode}from"@stacksjs/types";import{loadTsCloudConfig,loadTsCloudDeployApi,resolveProvider}from"./deploy";import{mergeSshStatePin,resolveSshTarget,sshCliArgs,sshStatePin}from"./deploy-ssh-target";import{describeDisk,flashRefusalReason,parseDnsSdBrowse,parseOsCatalogue,resolveBootVolume,selectImage}from"./server-image";import{CA_MISSING_EXIT,caCopyPath,caReadScript,DEFAULT_LAN_CA_PATH,mobileconfigInstructions,resolveCaPath,trustSummary}from"./server-trust";const log={info:(...args)=>console.log("\u2139",...args),success:(...args)=>console.log("\u2713",...args),warn:(...args)=>console.log("\u26A0",...args),error:(...args)=>console.error("\u2717",...args)},OS_CATALOGUE_URL="https://downloads.raspberrypi.com/os_list_imagingutility_v4.json";function imageCacheDir(){return join(homedir(),".cache","stacks","images")}async function readDiskInfo(device){const proc=Bun.spawn(["diskutil","info","-plist",device],{stdout:"pipe",stderr:"pipe"}),[out,code]=await Promise.all([new Response(proc.stdout).text(),proc.exited]);if(code!==0||!out.trim())return null;const bool=(key)=>{const match=new RegExp(`<key>${key}</key>\\s*<(true|false)/>`).exec(out);return match?match[1]==="true":void 0},str=(key)=>{return new RegExp(`<key>${key}</key>\\s*<string>([^<]*)</string>`).exec(out)?.[1]},num=(key)=>{const match=new RegExp(`<key>${key}</key>\\s*<integer>(\\d+)</integer>`).exec(out);return match?Number(match[1]):void 0};return{DeviceIdentifier:str("DeviceIdentifier"),DeviceNode:str("DeviceNode"),MediaName:str("MediaName"),Size:num("Size"),WholeDisk:bool("WholeDisk"),Internal:bool("Internal"),Ejectable:bool("Ejectable"),Removable:bool("Removable"),RemovableMediaOrExternalDevice:bool("RemovableMediaOrExternalDevice"),SystemImage:bool("SystemImage"),BusProtocol:str("BusProtocol")}}async function listFlashableDisks(){const proc=Bun.spawn(["diskutil","list","-plist"],{stdout:"pipe",stderr:"pipe"}),[out]=await Promise.all([new Response(proc.stdout).text(),proc.exited]),whole=[...out.matchAll(/<string>(disk\d+)<\/string>/g)].map((match)=>match[1]),found=[];for(const id of[...new Set(whole)]){const info=await readDiskInfo(`/dev/${id}`);if(info&&flashRefusalReason(info)===null)found.push(info)}return found}async function resolveImage(os){const response=await fetch(OS_CATALOGUE_URL);if(!response.ok)throw Error(`Could not read the image catalogue (HTTP ${response.status}). Check the network and try again.`);return selectImage(parseOsCatalogue(await response.json()),os)}async function downloadImage(image){const dir=imageCacheDir();mkdirSync(dir,{recursive:!0});const target=join(dir,image.url.split("/").pop()||`${image.id}.img.xz`);if(existsSync(target)&&image.downloadSize&&statSync(target).size===image.downloadSize){log.info(`Using the cached download at ${target}`);return target}const size=image.downloadSize?` (${(image.downloadSize/1e9).toFixed(2)} GB)`:"";log.info(`Downloading ${image.name}${size}...`);const response=await fetch(image.url);if(!response.ok||!response.body)throw Error(`Download failed (HTTP ${response.status}) for ${image.url}`);const file=createWriteStream(`${target}.part`);await Bun.write(Bun.file(`${target}.part`),response);file.close();await Bun.$`mv ${`${target}.part`} ${target}`.quiet();return target}async function resolveDecompressor(){for(const candidate of["xz","unxz"])if(Bun.spawnSync(["which",candidate]).exitCode===0)return[candidate,"-dc"];throw Error("No xz decompressor found, and the images are .img.xz.\n Install one with: brew install xz\n Or flash the card with Raspberry Pi Imager, then run `buddy server:first-boot` against the mounted card.")}async function loadSshApi(){const api=await loadTsCloudDeployApi(),missing=["SshDriver","buildCloudInitFirstBoot","buildSshBootstrapScript","evaluatePreflight","formatPreflightFindings"].filter((name)=>typeof api[name]!=="function");if(missing.length>0){log.error("This @stacksjs/ts-cloud does not support deploying to a host over SSH.");log.error(`Missing: ${missing.join(", ")}.`);log.info("Upgrade with `bun update @stacksjs/ts-cloud`, or point TS_CLOUD_MODULE at a build that has it.");process.exit(ExitCode.FatalError)}return api}async function loadSshProject(environment){const config=await loadTsCloudConfig(environment);if(!config){log.error("No ts-cloud configuration found. Expected a `tsCloud` export from config/cloud.ts.");process.exit(ExitCode.FatalError)}const target=resolveSshTarget(config);if(!target){log.error("No SSH host configured.");log.info("Add one to config/cloud.ts: ssh: { hosts: [{ host: 'pi-stacks.local', user: 'pi' }] }");log.info("Or set TS_CLOUD_SSH_HOST (with TS_CLOUD_SSH_USER / TS_CLOUD_SSH_PORT / TS_CLOUD_SSH_KEY).");process.exit(ExitCode.FatalError)}if(resolveProvider(config)!=="ssh")log.warn(`config/cloud.ts sets provider '${resolveProvider(config)}'. Set it to 'ssh' before \`buddy deploy\` will use this host.`);return{config,target}}async function discoverHosts(seconds=4){if(process.platform!=="darwin")return[];const proc=Bun.spawn(["dns-sd","-B","_ssh._tcp","local."],{stdout:"pipe",stderr:"ignore"}),timer=setTimeout(()=>proc.kill(),seconds*1000);try{return parseDnsSdBrowse(await new Response(proc.stdout).text())}catch{return[]}finally{clearTimeout(timer)}}async function reportPreflight(api,target,asJson){const driver=new api.SshDriver({hosts:[{host:target.host,user:target.user,port:target.port,privateKeyPath:target.identityFile}],hostKey:target.hostKey,profile:target.profile});let facts,findings;try{({facts,findings}=await driver.preflight(target.host))}catch(err){const detail=err instanceof Error?err.message:String(err),unreachable={code:"ssh.unreachable",severity:"error",message:`Could not reach ${target.user}@${target.host} over SSH.`,remediation:"Check the board is powered on and on this network, that SSH is enabled, and that your key is authorised. A board that has just booted can take a minute to answer.",detail};if(asJson)console.log(JSON.stringify({host:target.host,facts:null,findings:[unreachable]},null,2));else{log.error(unreachable.message);log.info(unreachable.remediation);log.info(detail.split(`
2
+ `).find((line)=>line.trim()&&!line.startsWith("Remote SSH"))||detail)}return!1}if(asJson)console.log(JSON.stringify({host:target.host,facts,findings},null,2));else{const text=api.formatPreflightFindings(findings);if(text.trim())console.log(text);else log.success("No problems found.")}return!(typeof api.preflightFailed==="function"?api.preflightFailed(findings):findings.some((finding)=>finding.severity==="error"))}function buildBootstrapOrExit(api,config,environment,sudoUser){const profile=config.ssh?.profile==="generic"?"generic":"raspberry-pi";try{return api.buildSshBootstrapScript({config,environment,profile,sudoUser,lan:config.ssh?.lan})}catch(err){console.error(err instanceof Error?err.message:String(err));log.info("Edit config/cloud.ts and run this again.");process.exit(ExitCode.FatalError)}}async function readRemoteCa(target,caPath){const proc=Bun.spawn(["ssh",...sshCliArgs(target,{connectTimeoutSec:20}),"sh","-s"],{stdin:new TextEncoder().encode(caReadScript(caPath)),stdout:"pipe",stderr:"pipe"}),[out,err,code]=await Promise.all([new Response(proc.stdout).text(),new Response(proc.stderr).text(),proc.exited]);if(code===CA_MISSING_EXIT)return{failure:"missing",detail:""};if(code!==0)return{failure:err.toLowerCase().includes("sudo")?"unreadable":"unreachable",detail:err.trim()};if(!out.includes("-----BEGIN CERTIFICATE-----"))return{failure:"unreadable",detail:""};return{pem:out}}function trustPlatform(){if(process.platform==="darwin")return"macos";return process.platform==="win32"?"windows":"debian"}export function server(buddy){const descriptions={flash:"Write a Linux OS image to an SD card or USB disk",os:"Which image to write: raspberry-pi-os-lite, raspberry-pi-os, ubuntu-24.04, ubuntu-26.04",device:"The whole disk to write to, for example /dev/disk4",verbose:"Enable verbose output"};buddy.command("server:flash",descriptions.flash).option("--os <name>",descriptions.os,{default:"raspberry-pi-os-lite"}).option("--device <path>",descriptions.device,{default:void 0}).option("--list","List the disks that could be written to, and exit",{default:!1}).option("--dry-run","Say what would happen without writing anything",{default:!1}).option("--yes","Do not ask for confirmation before writing",{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{const perf=await intro("buddy server:flash");if(process.platform!=="darwin"){log.error("`buddy server:flash` currently supports macOS only.");log.info("On Linux, write the image with `dd`, then run `buddy server:first-boot` against the mounted boot partition.");process.exit(ExitCode.FatalError)}const disks=await listFlashableDisks();if(options.list){if(disks.length===0)log.info("No removable disks are attached.");for(const disk of disks)log.info(` ${describeDisk(disk)}`);await outro("Exited",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)}let image;try{image=await resolveImage(options.os)}catch(err){log.error(err instanceof Error?err.message:String(err));process.exit(ExitCode.FatalError)}log.info(`Image: ${image.name} (${image.releaseDate??"unknown date"})`);if(!image.supportsPi5)log.warn("This image is not listed as supporting the Raspberry Pi 5.");let device=options.device;if(!device){if(disks.length===0){log.error("No removable disk is attached. Insert the card and try again, or pass --device.");process.exit(ExitCode.FatalError)}if(disks.length>1){log.error("Several removable disks are attached, so buddy will not pick one:");for(const disk of disks)log.error(` ${describeDisk(disk)}`);log.info("Re-run with --device /dev/diskN naming the one you mean.");process.exit(ExitCode.FatalError)}device=disks[0]?.DeviceNode}const info=device?await readDiskInfo(device):null;if(!info){log.error(`Could not read ${device}. Check the device path with \`diskutil list\`.`);process.exit(ExitCode.FatalError)}const refusal=flashRefusalReason(info);if(refusal){log.error(refusal);process.exit(ExitCode.FatalError)}let decompressor;try{decompressor=await resolveDecompressor()}catch(err){log.error(err instanceof Error?err.message:String(err));process.exit(ExitCode.FatalError)}log.info(`Target: ${describeDisk(info)}`);if(options.dryRun){log.info("Dry run: nothing was downloaded and nothing was written.");await outro("Exited",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)}if(!options.yes){if(await prompts.confirm({message:`Erase ${describeDisk(info)} and write ${image.name}?`,initial:!1})!==!0){log.info("Nothing was written.");process.exit(ExitCode.Success)}}let download;try{download=await downloadImage(image)}catch(err){log.error(err instanceof Error?err.message:String(err));process.exit(ExitCode.FatalError)}const raw=info.DeviceNode.replace("/dev/disk","/dev/rdisk");log.info(`Unmounting ${info.DeviceNode}...`);await Bun.$`diskutil unmountDisk ${info.DeviceNode}`.nothrow();log.info("Writing the image. This needs your password, and takes a few minutes.");log.info(` ${decompressor.join(" ")} ${download} | sudo dd of=${raw} bs=4m status=progress`);if(await Bun.spawn(["sh","-c",`${decompressor[0]} -dc ${JSON.stringify(download)} | sudo dd of=${JSON.stringify(raw)} bs=4m status=progress`],{stdin:"inherit",stdout:"inherit",stderr:"inherit"}).exited!==0){log.error("Writing the image failed. The card is probably unusable until it is written again.");process.exit(ExitCode.FatalError)}await Bun.$`sync`.nothrow();log.success("Image written.");const boot=resolveBootVolume(image,existsSync);if(boot)log.info(`Boot partition mounted at ${boot}`);else log.info(`Re-insert the card if it does not mount, then look for /Volumes/${image.bootVolume}`);log.info("Next: `buddy server:first-boot --hostname <name> --user <name>` to configure the first boot.");await outro("Done",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("server:first-boot","Write the first-boot configuration onto a freshly flashed card").option("--hostname <name>","The name the board answers to on the network",{default:"pi-stacks"}).option("--user <name>","The login to create, which the deploy then uses",{default:"pi"}).option("--ssh-key <path>","Public key to authorise",{default:void 0}).option("--os <name>",descriptions.os,{default:"raspberry-pi-os-lite"}).option("--out <dir>","Write the files here instead of the mounted boot partition",{default:void 0}).option("--wifi-ssid <ssid>","Join this wireless network on first boot",{default:void 0}).option("--wifi-country <code>","Two-letter regulatory domain, required with wifi",{default:void 0}).option("--timezone <tz>","IANA timezone for the board",{default:void 0}).option("--env <name>","Environment whose configuration to bootstrap",{default:"production"}).option("--force","Overwrite first-boot files already on the card",{default:!1}).action(async(options)=>{const perf=await intro("buddy server:first-boot"),api=await loadSshApi(),{config}=await loadSshProject(options.env),keyPath=options.sshKey||join(homedir(),".ssh","id_ed25519.pub");if(!existsSync(keyPath)){log.error(`No public key at ${keyPath}.`);log.info("Generate one with: ssh-keygen -t ed25519");log.info("Or point at an existing key with --ssh-key.");process.exit(ExitCode.FatalError)}let wifi;if(options.wifiSsid){if(!options.wifiCountry){log.error("--wifi-country is required with --wifi-ssid. It sets the radio regulatory domain.");process.exit(ExitCode.FatalError)}const passphrase=process.env.WIFI_PASSWORD||await prompts.password({message:`Passphrase for ${options.wifiSsid}`});if(typeof passphrase!=="string"||!passphrase){log.error("No wireless passphrase given.");process.exit(ExitCode.FatalError)}wifi={ssid:options.wifiSsid,passphrase,country:String(options.wifiCountry).toUpperCase()}}const bootstrap=buildBootstrapOrExit(api,config,options.env,options.user==="root"?void 0:options.user),os=String(options.os).startsWith("ubuntu")?"ubuntu":"raspberry-pi-os",bundle=api.buildCloudInitFirstBoot({hostname:options.hostname,user:options.user,publicKey:(await Bun.file(keyPath).text()).trim(),timezone:options.timezone,wifi},bootstrap,{os});let destination=options.out;if(!destination){const image=await resolveImage(options.os).catch(()=>null);destination=image?resolveBootVolume(image,existsSync):null;if(!destination){log.error("The card does not appear to be mounted.");log.info("Insert the freshly written card and try again, or pass --out <dir> to write the files elsewhere.");process.exit(ExitCode.FatalError)}}mkdirSync(destination,{recursive:!0});for(const name of Object.keys(bundle.files)){const path=join(destination,name);if(existsSync(path)&&!options.force){log.error(`${path} already exists. Re-run with --force to replace it.`);process.exit(ExitCode.FatalError)}}for(const[name,contents]of Object.entries(bundle.files)){await Bun.write(join(destination,name),contents);log.success(`Wrote ${join(destination,name)}`)}if(bundle.instructions)console.log(`
3
+ ${bundle.instructions}`);log.info(`Next: eject the card, boot the board, then \`buddy server:doctor ${options.hostname}.local\``);await outro("Done",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("server:doctor [host]","Check that a host can run this application before deploying to it").option("--env <name>","Environment whose configuration to check against",{default:"production"}).option("--discover","Browse the local network for hosts advertising SSH",{default:!1}).option("--json","Print the findings as JSON",{default:!1}).action(async(host,options)=>{const perf=options.json?void 0:await intro("buddy server:doctor"),api=await loadSshApi(),{config,target}=await loadSshProject(options.env);let host_=host;if(!host_&&options.discover){const found=await discoverHosts();if(found.length===0)log.info("No hosts advertising SSH were found on this network.");for(const entry of found)log.info(` ${entry.hostname}`);host_=found[0]?.hostname}const checked=host_?{...target,host:host_}:target;log.info(`Checking ${checked.user}@${checked.host}${checked.port===22?"":`:${checked.port}`}...`);const ok=await reportPreflight(api,checked,options.json===!0);if(perf)await outro(ok?"Ready":"Not ready",{startTime:perf,useSeconds:!0});process.exit(ok?ExitCode.Success:ExitCode.FatalError)});buddy.command("server:setup [host]","Adopt a host: check it, then install what the deploy needs").option("--env <name>","Environment whose configuration to bootstrap",{default:"production"}).option("--discover","Browse the local network for hosts advertising SSH",{default:!1}).option("--dry-run","Run the checks and stop before changing the host",{default:!1}).action(async(host,options)=>{const perf=await intro("buddy server:setup"),api=await loadSshApi(),{config,target}=await loadSshProject(options.env);let host_=host;if(!host_&&options.discover){const found=await discoverHosts();for(const entry of found)log.info(` ${entry.hostname}`);host_=found[0]?.hostname}const adopted=host_?{...target,host:host_}:target;log.info(`Adopting ${adopted.user}@${adopted.host}${adopted.port===22?"":`:${adopted.port}`}`);if(!await reportPreflight(api,adopted,!1)){log.error("The host is not ready. Nothing was changed on it.");process.exit(ExitCode.FatalError)}if(options.dryRun){log.info("Dry run: the host passed its checks and was not modified.");await outro("Done",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)}const driver=api.createCloudDriver({config,provider:"ssh"});if(!driver.provisionComputeInfrastructure){log.error("This ts-cloud cannot bootstrap an SSH host (update @stacksjs/ts-cloud).");process.exit(ExitCode.FatalError)}buildBootstrapOrExit(api,config,options.env,adopted.user==="root"?void 0:adopted.user);log.info("Installing the runtime, gateway and service units if they are missing...");let outputs;try{outputs=await driver.provisionComputeInfrastructure({config,environment:options.env})}catch(err){log.error("Bootstrapping the host failed.");log.error(err instanceof Error?err.message:String(err));process.exit(ExitCode.FatalError)}const stackName=config.project?.stackName||`${config.project?.slug||"app"}-${options.env}`,dir=join(process.cwd(),"storage","cloud","state"),statePath=join(dir,`${stackName}.json`);let recorded=null;try{recorded=existsSync(statePath)?JSON.parse(await Bun.file(statePath).text()):null}catch{recorded=null}mkdirSync(dir,{recursive:!0});await Bun.write(statePath,`${JSON.stringify(mergeSshStatePin(recorded,sshStatePin({stackName,target:adopted,deployStoragePath:outputs?.deployStoragePath})),null,2)}
4
4
  `);log.success(`Host adopted. Recorded at storage/cloud/state/${stackName}.json`);log.info("Next: `buddy deploy --prod`");await outro("Done",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("server:trust [host]","Trust the host's own certificate authority on this machine").option("--env <name>","Environment whose configuration names the host",{default:"production"}).option("--discover","Browse the local network for hosts advertising SSH",{default:!1}).option("--ca-path <path>","Where the authority lives on the host",{default:DEFAULT_LAN_CA_PATH}).option("--mobileconfig <path>","Also write an Apple configuration profile for an iPhone or iPad",{default:void 0}).option("--export-only","Save the certificate without changing this machine, and say how to trust it by hand",{default:!1}).option("--json","Print the result as JSON",{default:!1}).action(async(host,options)=>{const asJson=options.json===!0,perf=asJson?void 0:await intro("buddy server:trust"),{target}=await loadSshProject(options.env);let host_=host;if(!host_&&options.discover){const found=await discoverHosts();if(found.length===0&&!asJson)log.info("No hosts advertising SSH were found on this network.");for(const entry of found)if(!asJson)log.info(` ${entry.hostname}`);host_=found[0]?.hostname}const checked=host_?{...target,host:host_}:target,caPath=resolveCaPath(options.caPath),fail=(reason,message,remediation,detail)=>{if(asJson)console.log(JSON.stringify({host:checked.host,caPath,error:reason,message,remediation,...detail?{detail}:{}},null,2));else{log.error(message);log.info(remediation);if(detail)log.info(detail)}process.exit(ExitCode.FatalError)};if(!asJson)log.info(`Reading ${caPath} from ${checked.user}@${checked.host}${checked.port===22?"":`:${checked.port}`}...`);const read=await readRemoteCa(checked,caPath);if("failure"in read){if(read.failure==="missing")return fail("ca.absent",`There is no certificate authority at ${caPath} on ${checked.host}.`,"That host is not serving LAN HTTPS from its own authority. Set `ssh: { lan: { tls: 'local-ca' } }` in config/cloud.ts and run `buddy deploy --prod`, which is what creates it. Nothing was created on the host. If the authority lives elsewhere, name it with --ca-path.");if(read.failure==="unreadable")return fail("ca.unreadable",`${caPath} on ${checked.host} exists but could not be read as a certificate.`,`Check it by hand with \`ssh ${checked.user}@${checked.host} sudo cat ${caPath}\`. Reading it falls back to \`sudo -n\`, so a host whose sudo asks for a password cannot serve it to this command.`,read.detail||void 0);return fail("ssh.unreachable",`Could not reach ${checked.user}@${checked.host} over SSH.`,"Check the host is powered on and on this network, that SSH is enabled, and that your key is authorised. `buddy server:doctor` reports on all three.",read.detail.split(`
5
5
  `).find((line)=>line.trim())||void 0)}const pem=read.pem,{exportCA,getCertSha256Fingerprint,isCertTrusted,trustInstructions}=await import("@stacksjs/tlsx");let fingerprint;try{fingerprint=getCertSha256Fingerprint(pem)}catch(err){return fail("ca.unparseable",`The file at ${caPath} on ${checked.host} is not a certificate this can read.`,"Point --ca-path at the root certificate rpx writes, which is a PEM.",err instanceof Error?err.message:String(err))}const savedPath=caCopyPath(checked.host);mkdirSync(dirname(savedPath),{recursive:!0});await Bun.write(savedPath,pem.endsWith(`
6
6
  `)?pem:`${pem}
@@ -1 +1 @@
1
- import process from"node:process";import{installStack,listStacks,uninstallStack}from"@stacksjs/actions";import{intro,italic,onUnknownSubcommand,outro}from"@stacksjs/cli";import{log}from"@stacksjs/logging";import{ExitCode}from"@stacksjs/types";export function stacks(buddy){const descriptions={install:"Install a stack into your project",uninstall:"Uninstall a stack from your project",list:"List available and installed stacks",force:"Force overwrite existing files",dryRun:"Show what would be installed without making changes",conflict:"Conflict resolution strategy: skip, overwrite, or backup",verbose:"Enable verbose output",project:"Target a specific Stacks project"};buddy.command("stack:install <name>",descriptions.install).option("--force",descriptions.force,{default:!1}).option("--dry-run",descriptions.dryRun,{default:!1}).option("--conflict <strategy>",descriptions.conflict,{default:"skip"}).option("-p, --project <path>",descriptions.project).option("--verbose",descriptions.verbose,{default:!1}).example("buddy add calendar").example("buddy add table --force").example("buddy add calendar --conflict backup --verbose").example("buddy add table --dry-run").action(async(name,options)=>{const perf=await intro("buddy stack:install");if(!name){await log.error("You need to specify a stack name.");log.info("Example: buddy add calendar");process.exit(ExitCode.FatalError)}if(!await installStack({name,force:options.force,dryRun:options.dryRun,conflict:options.conflict||"skip",project:options.project,verbose:options.verbose})&&!options.dryRun){await outro("Failed to install stack",{startTime:perf,useSeconds:!0});process.exit(ExitCode.FatalError)}await outro(`Stack ${italic(name)} installed.`,{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("stack:uninstall <name>",descriptions.uninstall).option("--force",descriptions.force,{default:!1}).option("-p, --project <path>",descriptions.project).option("--verbose",descriptions.verbose,{default:!1}).example("buddy stack:uninstall blog").example("buddy stack:uninstall blog --force").action(async(name,options)=>{const perf=await intro("buddy stack:uninstall");if(!name){await log.error("You need to specify a stack name.");process.exit(ExitCode.FatalError)}if(!await uninstallStack({name,force:options.force,project:options.project,verbose:options.verbose})){await outro("Failed to uninstall stack",{startTime:perf,useSeconds:!0});process.exit(ExitCode.FatalError)}await outro(`Stack ${italic(name)} uninstalled.`,{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("stack:list",descriptions.list).alias("stack:ls").option("-p, --project <path>",descriptions.project).example("buddy stack:list").action(async(options)=>{const perf=await intro("buddy stack:list"),entries=await listStacks(options.project);if(entries.length===0)log.info("No stacks found. Install one with: buddy add <name>");else{log.info(`Found ${entries.length} stack(s):`);log.info("");for(const entry of entries){const status=entry.installed?"[installed]":"[available]",desc=entry.description?` - ${entry.description}`:"",files=entry.fileCount?` (${entry.fileCount} files)`:"";log.info(` ${entry.name} ${italic(`v${entry.version}`)} ${status}${files}${desc}`)}}await outro("",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});onUnknownSubcommand(buddy,"stack")}
1
+ import process from"node:process";import{installStack,listStacks,uninstallStack}from"@stacksjs/actions";import{intro,italic,onUnknownSubcommand,outro}from"@stacksjs/cli";import{log}from"@stacksjs/logging";import{ExitCode}from"@stacksjs/types";export function stacks(buddy){const descriptions={install:"Install a stack into your project",uninstall:"Uninstall a stack from your project",list:"List available and installed stacks",force:"Force overwrite existing files",dryRun:"Show what would be installed without making changes",conflict:"Conflict resolution strategy: skip, overwrite, or backup",verbose:"Enable verbose output",project:"Target a specific Stacks project"};buddy.command("stack:install <name>",descriptions.install).option("--force",descriptions.force,{default:!1}).option("--dry-run",descriptions.dryRun,{default:!1}).option("--conflict <strategy>",descriptions.conflict,{default:"skip"}).option("-p, --project <path>",descriptions.project).option("--verbose",descriptions.verbose,{default:!1}).example("buddy add calendar").example("buddy add table --force").example("buddy add calendar --conflict backup --verbose").example("buddy add table --dry-run").action(async(name,options)=>{const perf=await intro("buddy stack:install");if(!name){await log.error("You need to specify a stack name.");log.info("Example: buddy add calendar");await log.flush();process.exit(ExitCode.FatalError)}if(!await installStack({name,force:options.force,dryRun:options.dryRun,conflict:options.conflict||"skip",project:options.project,verbose:options.verbose})&&!options.dryRun){await outro("Failed to install stack",{startTime:perf,useSeconds:!0});process.exit(ExitCode.FatalError)}await outro(`Stack ${italic(name)} installed.`,{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("stack:uninstall <name>",descriptions.uninstall).option("--force",descriptions.force,{default:!1}).option("-p, --project <path>",descriptions.project).option("--verbose",descriptions.verbose,{default:!1}).example("buddy stack:uninstall blog").example("buddy stack:uninstall blog --force").action(async(name,options)=>{const perf=await intro("buddy stack:uninstall");if(!name){await log.error("You need to specify a stack name.");process.exit(ExitCode.FatalError)}if(!await uninstallStack({name,force:options.force,project:options.project,verbose:options.verbose})){await outro("Failed to uninstall stack",{startTime:perf,useSeconds:!0});process.exit(ExitCode.FatalError)}await outro(`Stack ${italic(name)} uninstalled.`,{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("stack:list",descriptions.list).alias("stack:ls").option("-p, --project <path>",descriptions.project).example("buddy stack:list").action(async(options)=>{const perf=await intro("buddy stack:list"),entries=await listStacks(options.project);if(entries.length===0)log.info("No stacks found. Install one with: buddy add <name>");else{log.info(`Found ${entries.length} stack(s):`);log.info("");for(const entry of entries){const status=entry.installed?"[installed]":"[available]",desc=entry.description?` - ${entry.description}`:"",files=entry.fileCount?` (${entry.fileCount} files)`:"";log.info(` ${entry.name} ${italic(`v${entry.version}`)} ${status}${files}${desc}`)}}await outro("",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});onUnknownSubcommand(buddy,"stack")}
@@ -1 +1 @@
1
- import process from"node:process";import{runAction}from"@stacksjs/actions";import{intro,log,onUnknownSubcommand,outro}from"@stacksjs/cli";import{Action}from"@stacksjs/enums";import{ExitCode}from"@stacksjs/types";import{resultFailed}from"../result";export function upgrade(buddy){const descriptions={upgrade:"Upgrade the Stacks framework to the latest version",version:"Install a specific version (e.g., 0.70.23)",canary:"Upgrade to the latest canary (bleeding-edge `main`) build",stable:"Switch to the latest vetted stable release",dryRun:"Preview the upgrade (which dependencies would change) without writing or installing",force:"Force re-download, bypassing cache and version checks",from:"Sync from a local stacks checkout (e.g. ~/Code/stacks). Skips GitHub.",noPostinstall:"Skip post-sync hooks (auto-imports, bun install, migrate)",verbose:"Enable verbose output",dependencies:"Upgrade your dependencies (pantry.yaml & package.json)",bun:"Upgrade Bun to the latest version",shell:"Upgrade the shell integration (currently only supports Oh My Zsh)",binary:"Upgrade the `stacks` binary to the latest version",project:"Target a specific project",all:"Upgrade framework, dependencies, Bun, and binary"};buddy.command("upgrade",descriptions.upgrade).option("-V, --version <version>",descriptions.version).option("--canary",descriptions.canary,{default:!1}).option("--stable",descriptions.stable,{default:!1}).option("--dry-run",descriptions.dryRun,{default:!1}).option("-f, --force",descriptions.force,{default:!1}).option("--from <path>",descriptions.from).option("--no-postinstall",descriptions.noPostinstall).option("--verbose",descriptions.verbose,{default:!1}).alias("update").example("buddy upgrade").example("buddy update").example("buddy upgrade --from ~/Code/stacks").example("buddy upgrade --version 0.70.23").example("buddy upgrade --dry-run").example("buddy upgrade --canary").example("buddy upgrade --stable").example("buddy upgrade --force").action(async(options)=>{log.debug("Running `buddy upgrade` ...",options);const opts={...options};if(opts.postinstall===!1){delete opts.postinstall;opts.noPostinstall=!0}const perf=await intro("buddy upgrade"),result=await runAction(Action.UpgradeFramework,opts);if(resultFailed(result)){await outro("While running buddy upgrade, there was an issue",{startTime:perf,useSeconds:!0},result.error);process.exit(ExitCode.FatalError)}await outro("Upgrade complete.",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("upgrade:all",descriptions.all).option("--canary",descriptions.canary,{default:!1}).option("-f, --force",descriptions.force,{default:!1}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).example("buddy upgrade:all").action(async(options)=>{log.debug("Running `buddy upgrade:all` ...",options);const perf=await intro("buddy upgrade:all");options.all=!0;const result=await runAction(Action.Upgrade,options);if(resultFailed(result)){await outro("While running buddy upgrade:all, there was an issue",{startTime:perf,useSeconds:!0},result.error);process.exit(ExitCode.FatalError)}await outro("All upgrades complete.",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("upgrade:dependencies",descriptions.dependencies).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).alias("upgrade:deps").example("buddy upgrade:dependencies").action(async(options)=>{log.debug("Running `buddy upgrade:dependencies` ...",options);const perf=await intro("buddy upgrade:dependencies");options.dependencies=!0;const result=await runAction(Action.Upgrade,options);if(resultFailed(result)){await outro("While running buddy upgrade:dependencies, there was an issue",{startTime:perf,useSeconds:!0},result.error);process.exit(ExitCode.FatalError)}await outro("Dependencies upgraded.",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("upgrade:bun",descriptions.bun).option("--verbose",descriptions.verbose,{default:!1}).example("buddy upgrade:bun").action(async(options)=>{log.debug("Running `buddy upgrade:bun` ...",options);const perf=await intro("buddy upgrade:bun"),result=await runAction(Action.UpgradeBun,options);if(resultFailed(result)){await outro("While running buddy upgrade:bun, there was an issue",{startTime:perf,useSeconds:!0},result.error);process.exit(ExitCode.FatalError)}await outro("Bun upgraded.",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("upgrade:shell",descriptions.shell).option("--verbose",descriptions.verbose,{default:!1}).example("buddy upgrade:shell").action(async(options)=>{log.debug("Running `buddy upgrade:shell` ...",options);const perf=await intro("buddy upgrade:shell"),result=await runAction(Action.UpgradeShell,options);if(resultFailed(result)){await outro("While running buddy upgrade:shell, there was an issue",{startTime:perf,useSeconds:!0},result.error);process.exit(ExitCode.FatalError)}await outro("Shell integration upgraded.",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("upgrade:binary",descriptions.binary).option("--verbose",descriptions.verbose,{default:!1}).example("buddy upgrade:binary").action(async(options)=>{if(process.getuid&&process.getuid()!==0){log.warn("To upgrade the binary, you need to run this command with sudo, or as root.");process.exit(ExitCode.FatalError)}log.debug("Running `buddy upgrade:binary` ...",options);const perf=await intro("buddy upgrade:binary"),result=await runAction(Action.UpgradeBinary,options);if(resultFailed(result)){await outro("While running buddy upgrade:binary, there was an issue",{startTime:perf,useSeconds:!0},result.error);process.exit(ExitCode.FatalError)}await outro("Binary upgraded.",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});onUnknownSubcommand(buddy,"upgrade")}
1
+ import process from"node:process";import{runAction}from"@stacksjs/actions";import{intro,log,onUnknownSubcommand,outro}from"@stacksjs/cli";import{Action}from"@stacksjs/enums";import{ExitCode}from"@stacksjs/types";import{resultFailed}from"../result";export function upgrade(buddy){const descriptions={upgrade:"Upgrade the Stacks framework to the latest version",version:"Install a specific version (e.g., 0.70.23)",canary:"Upgrade to the latest canary (bleeding-edge `main`) build",stable:"Switch to the latest vetted stable release",dryRun:"Preview the upgrade (which dependencies would change) without writing or installing",force:"Force re-download, bypassing cache and version checks",from:"Sync from a local stacks checkout (e.g. ~/Code/stacks). Skips GitHub.",noPostinstall:"Skip post-sync hooks (auto-imports, bun install, migrate)",verbose:"Enable verbose output",dependencies:"Upgrade your dependencies (pantry.yaml & package.json)",bun:"Upgrade Bun to the latest version",shell:"Upgrade the shell integration (currently only supports Oh My Zsh)",binary:"Upgrade the `stacks` binary to the latest version",project:"Target a specific project",all:"Upgrade framework, dependencies, Bun, and binary"};buddy.command("upgrade",descriptions.upgrade).option("-V, --version <version>",descriptions.version).option("--canary",descriptions.canary,{default:!1}).option("--stable",descriptions.stable,{default:!1}).option("--dry-run",descriptions.dryRun,{default:!1}).option("-f, --force",descriptions.force,{default:!1}).option("--from <path>",descriptions.from).option("--no-postinstall",descriptions.noPostinstall).option("--verbose",descriptions.verbose,{default:!1}).alias("update").example("buddy upgrade").example("buddy update").example("buddy upgrade --from ~/Code/stacks").example("buddy upgrade --version 0.70.23").example("buddy upgrade --dry-run").example("buddy upgrade --canary").example("buddy upgrade --stable").example("buddy upgrade --force").action(async(options)=>{log.debug("Running `buddy upgrade` ...",options);const opts={...options};if(opts.postinstall===!1){delete opts.postinstall;opts.noPostinstall=!0}const perf=await intro("buddy upgrade"),result=await runAction(Action.UpgradeFramework,opts);if(resultFailed(result)){await outro("While running buddy upgrade, there was an issue",{startTime:perf,useSeconds:!0},result.error);process.exit(ExitCode.FatalError)}await outro("Upgrade complete.",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("upgrade:all",descriptions.all).option("--canary",descriptions.canary,{default:!1}).option("-f, --force",descriptions.force,{default:!1}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).example("buddy upgrade:all").action(async(options)=>{log.debug("Running `buddy upgrade:all` ...",options);const perf=await intro("buddy upgrade:all");options.all=!0;const result=await runAction(Action.Upgrade,options);if(resultFailed(result)){await outro("While running buddy upgrade:all, there was an issue",{startTime:perf,useSeconds:!0},result.error);process.exit(ExitCode.FatalError)}await outro("All upgrades complete.",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("upgrade:dependencies",descriptions.dependencies).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).alias("upgrade:deps").example("buddy upgrade:dependencies").action(async(options)=>{log.debug("Running `buddy upgrade:dependencies` ...",options);const perf=await intro("buddy upgrade:dependencies");options.dependencies=!0;const result=await runAction(Action.Upgrade,options);if(resultFailed(result)){await outro("While running buddy upgrade:dependencies, there was an issue",{startTime:perf,useSeconds:!0},result.error);process.exit(ExitCode.FatalError)}await outro("Dependencies upgraded.",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("upgrade:bun",descriptions.bun).option("--verbose",descriptions.verbose,{default:!1}).example("buddy upgrade:bun").action(async(options)=>{log.debug("Running `buddy upgrade:bun` ...",options);const perf=await intro("buddy upgrade:bun"),result=await runAction(Action.UpgradeBun,options);if(resultFailed(result)){await outro("While running buddy upgrade:bun, there was an issue",{startTime:perf,useSeconds:!0},result.error);process.exit(ExitCode.FatalError)}await outro("Bun upgraded.",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("upgrade:shell",descriptions.shell).option("--verbose",descriptions.verbose,{default:!1}).example("buddy upgrade:shell").action(async(options)=>{log.debug("Running `buddy upgrade:shell` ...",options);const perf=await intro("buddy upgrade:shell"),result=await runAction(Action.UpgradeShell,options);if(resultFailed(result)){await outro("While running buddy upgrade:shell, there was an issue",{startTime:perf,useSeconds:!0},result.error);process.exit(ExitCode.FatalError)}await outro("Shell integration upgraded.",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("upgrade:binary",descriptions.binary).option("--verbose",descriptions.verbose,{default:!1}).example("buddy upgrade:binary").action(async(options)=>{if(process.getuid&&process.getuid()!==0){log.warn("To upgrade the binary, you need to run this command with sudo, or as root.");await log.flush();process.exit(ExitCode.FatalError)}log.debug("Running `buddy upgrade:binary` ...",options);const perf=await intro("buddy upgrade:binary"),result=await runAction(Action.UpgradeBinary,options);if(resultFailed(result)){await outro("While running buddy upgrade:binary, there was an issue",{startTime:perf,useSeconds:!0},result.error);process.exit(ExitCode.FatalError)}await outro("Binary upgraded.",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});onUnknownSubcommand(buddy,"upgrade")}
@@ -1 +1 @@
1
- import process from"node:process";import{confirmOrNull,log}from"@stacksjs/cli";import{ExitCode}from"@stacksjs/types";export function canPromptInteractively(){if(process.env.CI||process.env.CONTINUOUS_INTEGRATION||process.env.BUILD_NUMBER||process.env.RUN_ID)return!1;return Boolean(process.stdin.isTTY&&process.stdout.isTTY)}export function resolveCreatePolicy(){const value=String(process.env.DB_CREATE_DATABASE||"").toLowerCase();if(value==="always"||value==="true"||value==="1")return"always";if(value==="never"||value==="false"||value==="0")return"never";return"prompt"}function decide(create){process.env.STACKS_CREATE_DATABASE=create?"1":"0"}const UNANSWERED=Symbol("unanswered");async function confirmWithTimeout(message,timeoutMs){let timer;try{return await Promise.race([confirmOrNull({message,initial:!0}).then((v)=>v===null?UNANSWERED:v),new Promise((resolve)=>{timer=setTimeout(()=>resolve(UNANSWERED),timeoutMs)})])}finally{if(timer)clearTimeout(timer)}}export async function preflightDatabase(options={}){const commandName=options.command||"migrate",{canCreateDatabases,describeTarget,manualCreateHint,probeTargetDatabase,resolveConnectionTarget}=await import("@stacksjs/database"),target=resolveConnectionTarget();if(!target)return;const probe=await probeTargetDatabase(target);if(probe.ok||probe.kind!=="missing-database")return;const where=describeTarget(target),standing=resolveCreatePolicy();if(standing==="never"){decide(!1);return}if(options.createDatabase||standing==="always"){decide(!0);return}if(!canPromptInteractively()){log.syncError(`The database "${target.database}" does not exist on ${where}.`);log.syncError("Refusing to create it in a non-interactive shell.");log.syncError(" Create it ahead of time, re-run with --create-database, or set DB_CREATE_DATABASE=always.");log.syncError(` ${manualCreateHint(target)}`);process.exit(ExitCode.FatalError)}if(await canCreateDatabases(target)===!1){log.syncError(`The database "${target.database}" does not exist on ${where}.`);log.syncError(`The user "${target.username}" is not allowed to create databases, so I cannot create it for you.`);log.syncError(` Grant it with: ALTER ROLE "${target.username}" CREATEDB;`);log.syncError(` Or create the database yourself with: ${manualCreateHint(target)}`);process.exit(ExitCode.FatalError)}log.warn(`The database "${target.database}" does not exist on ${where}.`);await log.flush();const timeoutMs=Number(process.env.DB_CREATE_DATABASE_TIMEOUT_MS||120000),answer=await confirmWithTimeout("Would you like to create it?",timeoutMs);if(answer===UNANSWERED){log.syncError(`No answer after ${Math.round(timeoutMs/1000)}s, so nothing was created.`);log.syncError(" Re-run with --create-database, or set DB_CREATE_DATABASE=always, to skip this question.");log.syncError(` ${manualCreateHint(target)}`);process.exit(ExitCode.FatalError)}if(!answer){decide(!1);log.info("Nothing was changed. Create it yourself with:");log.info(` ${manualCreateHint(target)}`);log.info(`Then re-run \`buddy ${commandName}\`, or re-run with --create-database to skip this question.`);process.exit(ExitCode.Success)}decide(!0)}
1
+ import process from"node:process";import{confirmOrNull,log}from"@stacksjs/cli";import{ExitCode}from"@stacksjs/types";export function canPromptInteractively(){if(process.env.CI||process.env.CONTINUOUS_INTEGRATION||process.env.BUILD_NUMBER||process.env.RUN_ID)return!1;return Boolean(process.stdin.isTTY&&process.stdout.isTTY)}export function resolveCreatePolicy(){const value=String(process.env.DB_CREATE_DATABASE||"").toLowerCase();if(value==="always"||value==="true"||value==="1")return"always";if(value==="never"||value==="false"||value==="0")return"never";return"prompt"}function decide(create){process.env.STACKS_CREATE_DATABASE=create?"1":"0"}const UNANSWERED=Symbol("unanswered");async function confirmWithTimeout(message,timeoutMs){let timer;try{return await Promise.race([confirmOrNull({message,initial:!0}).then((v)=>v===null?UNANSWERED:v),new Promise((resolve)=>{timer=setTimeout(()=>resolve(UNANSWERED),timeoutMs)})])}finally{if(timer)clearTimeout(timer)}}export async function preflightDatabase(options={}){const commandName=options.command||"migrate",{canCreateDatabases,describeTarget,manualCreateHint,probeTargetDatabase,resolveConnectionTarget}=await import("@stacksjs/database"),target=resolveConnectionTarget();if(!target)return;const probe=await probeTargetDatabase(target);if(probe.ok||probe.kind!=="missing-database")return;const where=describeTarget(target),standing=resolveCreatePolicy();if(standing==="never"){decide(!1);return}if(options.createDatabase||standing==="always"){decide(!0);return}if(!canPromptInteractively()){log.syncError(`The database "${target.database}" does not exist on ${where}.`);log.syncError("Refusing to create it in a non-interactive shell.");log.syncError(" Create it ahead of time, re-run with --create-database, or set DB_CREATE_DATABASE=always.");log.syncError(` ${manualCreateHint(target)}`);process.exit(ExitCode.FatalError)}if(await canCreateDatabases(target)===!1){log.syncError(`The database "${target.database}" does not exist on ${where}.`);log.syncError(`The user "${target.username}" is not allowed to create databases, so I cannot create it for you.`);log.syncError(` Grant it with: ALTER ROLE "${target.username}" CREATEDB;`);log.syncError(` Or create the database yourself with: ${manualCreateHint(target)}`);process.exit(ExitCode.FatalError)}log.warn(`The database "${target.database}" does not exist on ${where}.`);await log.flush();const timeoutMs=Number(process.env.DB_CREATE_DATABASE_TIMEOUT_MS||120000),answer=await confirmWithTimeout("Would you like to create it?",timeoutMs);if(answer===UNANSWERED){log.syncError(`No answer after ${Math.round(timeoutMs/1000)}s, so nothing was created.`);log.syncError(" Re-run with --create-database, or set DB_CREATE_DATABASE=always, to skip this question.");log.syncError(` ${manualCreateHint(target)}`);process.exit(ExitCode.FatalError)}if(!answer){decide(!1);log.info("Nothing was changed. Create it yourself with:");log.info(` ${manualCreateHint(target)}`);log.info(`Then re-run \`buddy ${commandName}\`, or re-run with --create-database to skip this question.`);await log.flush();process.exit(ExitCode.Success)}decide(!0)}
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@stacksjs/buddy",
3
3
  "type": "module",
4
4
  "sideEffects": false,
5
- "version": "0.74.11",
5
+ "version": "0.74.12",
6
6
  "description": "Meet Buddy. The Stacks runtime.",
7
7
  "author": "Chris Breuer",
8
8
  "contributors": [
@@ -95,66 +95,66 @@
95
95
  "prepublishOnly": "bun run build"
96
96
  },
97
97
  "dependencies": {
98
- "@stacksjs/actions": "^0.74.11",
99
- "@stacksjs/ai": "^0.74.11",
100
- "@stacksjs/alias": "^0.74.11",
101
- "@stacksjs/analytics": "^0.74.11",
102
- "@stacksjs/api": "^0.74.11",
103
- "@stacksjs/arrays": "^0.74.11",
104
- "@stacksjs/auth": "^0.74.11",
105
- "@stacksjs/browser-extension": "^0.74.11",
106
- "@stacksjs/build": "^0.74.11",
107
- "@stacksjs/cache": "^0.74.11",
108
- "@stacksjs/chat": "^0.74.11",
98
+ "@stacksjs/actions": "^0.74.12",
99
+ "@stacksjs/ai": "^0.74.12",
100
+ "@stacksjs/alias": "^0.74.12",
101
+ "@stacksjs/analytics": "^0.74.12",
102
+ "@stacksjs/api": "^0.74.12",
103
+ "@stacksjs/arrays": "^0.74.12",
104
+ "@stacksjs/auth": "^0.74.12",
105
+ "@stacksjs/browser-extension": "^0.74.12",
106
+ "@stacksjs/build": "^0.74.12",
107
+ "@stacksjs/cache": "^0.74.12",
108
+ "@stacksjs/chat": "^0.74.12",
109
109
  "@stacksjs/clapp": "^0.2.12",
110
- "@stacksjs/cli": "^0.74.11",
111
- "@stacksjs/cloud": "^0.74.11",
112
- "@stacksjs/cms": "^0.74.11",
113
- "@stacksjs/collections": "^0.74.11",
114
- "@stacksjs/config": "^0.74.11",
115
- "@stacksjs/database": "^0.74.11",
116
- "@stacksjs/desktop-build": "^0.74.11",
117
- "@stacksjs/dns": "^0.74.11",
110
+ "@stacksjs/cli": "^0.74.12",
111
+ "@stacksjs/cloud": "^0.74.12",
112
+ "@stacksjs/cms": "^0.74.12",
113
+ "@stacksjs/collections": "^0.74.12",
114
+ "@stacksjs/config": "^0.74.12",
115
+ "@stacksjs/database": "^0.74.12",
116
+ "@stacksjs/desktop-build": "^0.74.12",
117
+ "@stacksjs/dns": "^0.74.12",
118
118
  "@stacksjs/dnsx": "^0.2.3",
119
- "@stacksjs/email": "^0.74.11",
120
- "@stacksjs/enums": "^0.74.11",
121
- "@stacksjs/env": "^0.74.11",
122
- "@stacksjs/error-handling": "^0.74.11",
123
- "@stacksjs/events": "^0.74.11",
124
- "@stacksjs/features": "^0.74.11",
125
- "@stacksjs/git": "^0.74.11",
119
+ "@stacksjs/email": "^0.74.12",
120
+ "@stacksjs/enums": "^0.74.12",
121
+ "@stacksjs/env": "^0.74.12",
122
+ "@stacksjs/error-handling": "^0.74.12",
123
+ "@stacksjs/events": "^0.74.12",
124
+ "@stacksjs/features": "^0.74.12",
125
+ "@stacksjs/git": "^0.74.12",
126
126
  "@stacksjs/gitit": "^0.2.5",
127
- "@stacksjs/health": "^0.74.11",
127
+ "@stacksjs/health": "^0.74.12",
128
128
  "@stacksjs/httx": "^0.1.10",
129
- "@stacksjs/image": "^0.74.11",
130
- "@stacksjs/lint": "^0.74.11",
131
- "@stacksjs/logging": "^0.74.11",
132
- "@stacksjs/notifications": "^0.74.11",
133
- "@stacksjs/objects": "^0.74.11",
134
- "@stacksjs/orm": "^0.74.11",
135
- "@stacksjs/path": "^0.74.11",
136
- "@stacksjs/payments": "^0.74.11",
137
- "@stacksjs/realtime": "^0.74.11",
138
- "@stacksjs/router": "^0.74.11",
129
+ "@stacksjs/image": "^0.74.12",
130
+ "@stacksjs/lint": "^0.74.12",
131
+ "@stacksjs/logging": "^0.74.12",
132
+ "@stacksjs/notifications": "^0.74.12",
133
+ "@stacksjs/objects": "^0.74.12",
134
+ "@stacksjs/orm": "^0.74.12",
135
+ "@stacksjs/path": "^0.74.12",
136
+ "@stacksjs/payments": "^0.74.12",
137
+ "@stacksjs/realtime": "^0.74.12",
138
+ "@stacksjs/router": "^0.74.12",
139
139
  "@stacksjs/rpx": "^0.11.42",
140
- "@stacksjs/scheduler": "^0.74.11",
141
- "@stacksjs/search-engine": "^0.74.11",
142
- "@stacksjs/security": "^0.74.11",
143
- "@stacksjs/server": "^0.74.11",
144
- "@stacksjs/sites": "^0.74.11",
145
- "@stacksjs/skills": "^0.74.11",
146
- "@stacksjs/storage": "^0.74.11",
147
- "@stacksjs/strings": "^0.74.11",
140
+ "@stacksjs/scheduler": "^0.74.12",
141
+ "@stacksjs/search-engine": "^0.74.12",
142
+ "@stacksjs/security": "^0.74.12",
143
+ "@stacksjs/server": "^0.74.12",
144
+ "@stacksjs/sites": "^0.74.12",
145
+ "@stacksjs/skills": "^0.74.12",
146
+ "@stacksjs/storage": "^0.74.12",
147
+ "@stacksjs/strings": "^0.74.12",
148
148
  "@stacksjs/stx": "^0.2.269",
149
- "@stacksjs/testing": "^0.74.11",
150
- "@stacksjs/tinker": "^0.74.11",
149
+ "@stacksjs/testing": "^0.74.12",
150
+ "@stacksjs/tinker": "^0.74.12",
151
151
  "@stacksjs/tlsx": "^0.13.19",
152
152
  "@stacksjs/ts-cloud": "^0.12.10",
153
- "@stacksjs/tunnel": "^0.74.11",
154
- "@stacksjs/types": "^0.74.11",
155
- "@stacksjs/ui": "^0.74.11",
156
- "@stacksjs/utils": "^0.74.11",
157
- "@stacksjs/validation": "^0.74.11",
153
+ "@stacksjs/tunnel": "^0.74.12",
154
+ "@stacksjs/types": "^0.74.12",
155
+ "@stacksjs/ui": "^0.74.12",
156
+ "@stacksjs/utils": "^0.74.12",
157
+ "@stacksjs/validation": "^0.74.12",
158
158
  "ajv": "^8.20.0",
159
159
  "ajv-formats": "^3.0.1",
160
160
  "bun-plugin-stx": "^0.2.269",