@stacksjs/buddy 0.75.399 → 0.75.400

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.
@@ -7,6 +7,19 @@ import type { ResolvedMacosDesktop } from '@stacksjs/desktop-build';
7
7
  * version, category and icon.
8
8
  */
9
9
  export declare function resolveAppleDesktopConfig(options?: AppleDesktopOptions, native?: ResolvedMacosDesktop | null): AppleDesktopConfig;
10
+ /**
11
+ * The App Store Connect key as a `.p8` file, from `APP_STORE_CONNECT_API_KEY`
12
+ * when no path was given.
13
+ *
14
+ * The key is a secret, so the place for it is an encrypted `.env.production`
15
+ * value, which is what the reusable workflow already does with its secret of the
16
+ * same name. `altool` and ts-pantry both want a file, and `altool` only finds one
17
+ * named `AuthKey_<id>.p8`, so the value is written once into a private temporary
18
+ * directory under that name and removed when the process ends. A value that went
19
+ * through a dotenv file keeps its line breaks as a literal `\n`; both spellings
20
+ * are accepted.
21
+ */
22
+ export declare function withApiKeyFile(config: AppleDesktopConfig): AppleDesktopConfig;
10
23
  export declare function validateAppleDesktopConfig(config: AppleDesktopConfig, includeApi?: boolean): string[];
11
24
  /**
12
25
  * Whether a string is shaped like an `LSApplicationCategoryType`.
@@ -1,4 +1,6 @@
1
- import{createHash}from"node:crypto";import{chmodSync,copyFileSync,existsSync,mkdirSync,readdirSync,readFileSync,rmSync,writeFileSync}from"node:fs";import{basename,join,resolve}from"node:path";import process from"node:process";import{log}from"@stacksjs/cli";import{projectPath,storagePath}from"@stacksjs/path";import{resultFailed}from"../result";const REQUIRED_TOOLS=["codesign","security","productbuild","pkgutil","xcrun"];function env(name){return process.env[name]?.trim()||""}function xml(value){return value.replaceAll("&","&amp;").replaceAll("<","&lt;").replaceAll(">","&gt;").replaceAll('"',"&quot;").replaceAll("'","&apos;")}async function nativeAppleConfig(){const{loadMacosDesktop}=await import("@stacksjs/desktop-build");return loadMacosDesktop(projectPath(),packageMetadata().version)}function packageMetadata(){const packageJson=JSON.parse(readFileSync(projectPath("package.json"),"utf8"));return{name:packageJson.name||"Stacks",version:packageJson.version||"0.1.0"}}export function resolveAppleDesktopConfig(options={},native){const metadata=packageMetadata(),provisioningProfile=options.provisioningProfile||env("APPLE_PROVISIONING_PROFILE"),apiKeyPath=options.apiKeyPath||env("APP_STORE_CONNECT_API_KEY_PATH"),craftArm64=options.craftArm64||env("CRAFT_BIN_ARM64"),craftX64=options.craftX64||env("CRAFT_BIN_X64"),keychain=options.keychain||env("APPLE_KEYCHAIN");return{appName:options.appName||env("APPLE_APP_NAME")||native?.appName||env("APP_NAME")||metadata.name,bundleId:options.bundleId||env("APPLE_BUNDLE_ID")||native?.bundleId||"",teamId:options.teamId||env("APPLE_TEAM_ID")||native?.teamId||"",version:options.appVersion||env("APPLE_APP_VERSION")||native?.version||metadata.version,buildNumber:options.buildNumber||env("APPLE_BUILD_NUMBER")||(native?native.buildNumber:"")||env("GITHUB_RUN_NUMBER")||String(Math.floor(Date.now()/1000)),minimumMacos:options.minimumMacos||env("APPLE_MINIMUM_MACOS")||native?.minimumSystemVersion||"13.0",category:options.category||env("APPLE_APP_CATEGORY")||native?.category||"public.app-category.productivity",appSigningIdentity:options.appSigningIdentity||env("APPLE_APP_SIGNING_IDENTITY"),installerSigningIdentity:options.installerSigningIdentity||env("APPLE_INSTALLER_SIGNING_IDENTITY"),provisioningProfile:provisioningProfile?resolve(provisioningProfile):"",apiKeyId:options.apiKeyId||env("APP_STORE_CONNECT_API_KEY_ID"),apiIssuerId:options.apiIssuerId||env("APP_STORE_CONNECT_API_ISSUER_ID"),apiKeyPath:apiKeyPath?resolve(apiKeyPath):"",icon:options.icon||env("APPLE_APP_ICON")||(native?.icon?resolve(projectPath(),native.icon):void 0),universal:Boolean(options.universal)||env("APPLE_UNIVERSAL")==="true",craftArm64:craftArm64?resolve(craftArm64):void 0,craftX64:craftX64?resolve(craftX64):void 0,keychain:keychain?resolve(keychain):void 0}}export function validateAppleDesktopConfig(config,includeApi=!0){const errors=[];if(process.platform!=="darwin")errors.push("Mac App Store packaging must run on macOS");if(!config.appName)errors.push("APPLE_APP_NAME or --app-name is required");if(!/^[A-Za-z0-9][A-Za-z0-9.-]+$/.test(config.bundleId))errors.push("APPLE_BUNDLE_ID must be a reverse-DNS bundle identifier");if(!/^[A-Z0-9]{10}$/.test(config.teamId))errors.push("APPLE_TEAM_ID must be the 10-character Apple Developer team ID");if(!/^\d+(?:\.\d+){0,2}$/.test(config.version))errors.push("The marketing version must contain one to three numeric components");if(!/^[A-Za-z0-9.-]+$/.test(config.buildNumber))errors.push("The build number must contain only letters, numbers, periods, and hyphens");if(!config.appSigningIdentity)errors.push("APPLE_APP_SIGNING_IDENTITY is required");if(!config.installerSigningIdentity)errors.push("APPLE_INSTALLER_SIGNING_IDENTITY is required");if(!config.provisioningProfile||!existsSync(config.provisioningProfile))errors.push("APPLE_PROVISIONING_PROFILE must point to an existing .provisionprofile file");if(config.icon&&!existsSync(resolve(config.icon)))errors.push(`Apple app icon does not exist: ${config.icon}`);if(!/^\d+(?:\.\d+){0,2}$/.test(config.minimumMacos))errors.push("APPLE_MINIMUM_MACOS must be a macOS version such as 13.0");if(!isAppleCategory(config.category))errors.push("APPLE_APP_CATEGORY must be an Apple LSApplicationCategoryType such as public.app-category.productivity");if(includeApi){if(!config.apiKeyId)errors.push("APP_STORE_CONNECT_API_KEY_ID is required");if(!config.apiIssuerId)errors.push("APP_STORE_CONNECT_API_ISSUER_ID is required");if(!config.apiKeyPath||!existsSync(config.apiKeyPath))errors.push("APP_STORE_CONNECT_API_KEY_PATH must point to an existing .p8 file")}if(config.universal){for(const[variable,path]of[["CRAFT_BIN_ARM64",config.craftArm64],["CRAFT_BIN_X64",config.craftX64]])if(!path||!existsSync(path))errors.push(`${variable} must point to a native Craft runtime for a universal build`);else if(readFileSync(path).subarray(0,2).toString()==="#!")errors.push(`${variable} points at a script, not a native Craft runtime: ${path}`)}for(const tool of config.universal?[...REQUIRED_TOOLS,"lipo"]:REQUIRED_TOOLS)if(!Bun.which(tool))errors.push(`Required Apple command is unavailable: ${tool}`);return errors}function bundleFiles(appPath){const walk=(dir,prefix)=>readdirSync(dir,{withFileTypes:!0}).flatMap((entry)=>{const relative=prefix?`${prefix}/${entry.name}`:entry.name;return entry.isDirectory()?walk(join(dir,entry.name),relative):[relative]});return walk(appPath,"")}export function isAppleCategory(value){return/^public\.app-category\.[a-z][a-z0-9-]*$/.test(value)}export function storeRejectionFindings(paths,executable="stacks-desktop"){const findings=[],has=(path)=>paths.includes(path);for(const required of["Contents/Info.plist",`Contents/MacOS/${executable}`,"Contents/embedded.provisionprofile"])if(!has(required))findings.push(`missing required bundle file: ${required}`);for(const path of paths){const base=path.split("/").pop()??"";if(base===".DS_Store"||path.startsWith("__MACOSX/")||base.startsWith("._"))findings.push(`forbidden file in the bundle: ${path}`);if(/\.(?:dylib|so|framework|app|xpc)$/.test(path)){if(!(path.startsWith("Contents/Frameworks/")||path.startsWith("Contents/MacOS/")||path.startsWith("Contents/XPCServices/")||path.startsWith("Contents/PlugIns/")))findings.push(`nested code outside a signed location: ${path}`)}if(path.startsWith("Contents/Resources/")&&/\.(?:sh|command|dylib|so)$/.test(path))findings.push(`executable content under Resources: ${path}`)}return findings}export function renderInfoPlist(config,executable="stacks-desktop"){const iconEntry=config.icon?`
1
+ import{createHash}from"node:crypto";import{chmodSync,copyFileSync,existsSync,mkdirSync,mkdtempSync,readdirSync,readFileSync,rmSync,writeFileSync}from"node:fs";import{tmpdir}from"node:os";import{basename,join,resolve}from"node:path";import process from"node:process";import{log}from"@stacksjs/cli";import{projectPath,storagePath}from"@stacksjs/path";import{resultFailed}from"../result";const REQUIRED_TOOLS=["codesign","security","productbuild","pkgutil","xcrun"];function env(name){return process.env[name]?.trim()||""}function xml(value){return value.replaceAll("&","&amp;").replaceAll("<","&lt;").replaceAll(">","&gt;").replaceAll('"',"&quot;").replaceAll("'","&apos;")}async function nativeAppleConfig(){const{loadMacosDesktop}=await import("@stacksjs/desktop-build");return loadMacosDesktop(projectPath(),packageMetadata().version)}function packageMetadata(){const packageJson=JSON.parse(readFileSync(projectPath("package.json"),"utf8"));return{name:packageJson.name||"Stacks",version:packageJson.version||"0.1.0"}}export function resolveAppleDesktopConfig(options={},native){const metadata=packageMetadata(),provisioningProfile=options.provisioningProfile||env("APPLE_PROVISIONING_PROFILE"),apiKeyPath=options.apiKeyPath||env("APP_STORE_CONNECT_API_KEY_PATH"),craftArm64=options.craftArm64||env("CRAFT_BIN_ARM64"),craftX64=options.craftX64||env("CRAFT_BIN_X64"),keychain=options.keychain||env("APPLE_KEYCHAIN");return{appName:options.appName||env("APPLE_APP_NAME")||native?.appName||env("APP_NAME")||metadata.name,bundleId:options.bundleId||env("APPLE_BUNDLE_ID")||native?.bundleId||"",teamId:options.teamId||env("APPLE_TEAM_ID")||native?.teamId||"",version:options.appVersion||env("APPLE_APP_VERSION")||native?.version||metadata.version,buildNumber:options.buildNumber||env("APPLE_BUILD_NUMBER")||(native?native.buildNumber:"")||env("GITHUB_RUN_NUMBER")||String(Math.floor(Date.now()/1000)),minimumMacos:options.minimumMacos||env("APPLE_MINIMUM_MACOS")||native?.minimumSystemVersion||"13.0",category:options.category||env("APPLE_APP_CATEGORY")||native?.category||"public.app-category.productivity",appSigningIdentity:options.appSigningIdentity||env("APPLE_APP_SIGNING_IDENTITY"),installerSigningIdentity:options.installerSigningIdentity||env("APPLE_INSTALLER_SIGNING_IDENTITY"),provisioningProfile:provisioningProfile?resolve(provisioningProfile):"",apiKeyId:options.apiKeyId||env("APP_STORE_CONNECT_API_KEY_ID"),apiIssuerId:options.apiIssuerId||env("APP_STORE_CONNECT_API_ISSUER_ID"),apiKeyPath:apiKeyPath?resolve(apiKeyPath):"",icon:options.icon||env("APPLE_APP_ICON")||(native?.icon?resolve(projectPath(),native.icon):void 0),universal:Boolean(options.universal)||env("APPLE_UNIVERSAL")==="true",craftArm64:craftArm64?resolve(craftArm64):void 0,craftX64:craftX64?resolve(craftX64):void 0,keychain:keychain?resolve(keychain):void 0}}export function withApiKeyFile(config){const pem=env("APP_STORE_CONNECT_API_KEY").replaceAll("\\n",`
2
+ `);if(config.apiKeyPath||!pem||!config.apiKeyId)return config;if(!pem.includes("-----BEGIN PRIVATE KEY-----"))throw Error("APP_STORE_CONNECT_API_KEY must hold the PEM text of the .p8 key");const directory=mkdtempSync(join(tmpdir(),"apple-api-key-")),apiKeyPath=join(directory,`AuthKey_${config.apiKeyId}.p8`);writeFileSync(apiKeyPath,`${pem}
3
+ `,{mode:384});process.on("exit",()=>rmSync(directory,{recursive:!0,force:!0}));return{...config,apiKeyPath}}export function validateAppleDesktopConfig(config,includeApi=!0){const errors=[];if(process.platform!=="darwin")errors.push("Mac App Store packaging must run on macOS");if(!config.appName)errors.push("APPLE_APP_NAME or --app-name is required");if(!/^[A-Za-z0-9][A-Za-z0-9.-]+$/.test(config.bundleId))errors.push("APPLE_BUNDLE_ID must be a reverse-DNS bundle identifier");if(!/^[A-Z0-9]{10}$/.test(config.teamId))errors.push("APPLE_TEAM_ID must be the 10-character Apple Developer team ID");if(!/^\d+(?:\.\d+){0,2}$/.test(config.version))errors.push("The marketing version must contain one to three numeric components");if(!/^[A-Za-z0-9.-]+$/.test(config.buildNumber))errors.push("The build number must contain only letters, numbers, periods, and hyphens");if(!config.appSigningIdentity)errors.push("APPLE_APP_SIGNING_IDENTITY is required");if(!config.installerSigningIdentity)errors.push("APPLE_INSTALLER_SIGNING_IDENTITY is required");if(!config.provisioningProfile||!existsSync(config.provisioningProfile))errors.push("APPLE_PROVISIONING_PROFILE must point to an existing .provisionprofile file");if(config.icon&&!existsSync(resolve(config.icon)))errors.push(`Apple app icon does not exist: ${config.icon}`);if(!/^\d+(?:\.\d+){0,2}$/.test(config.minimumMacos))errors.push("APPLE_MINIMUM_MACOS must be a macOS version such as 13.0");if(!isAppleCategory(config.category))errors.push("APPLE_APP_CATEGORY must be an Apple LSApplicationCategoryType such as public.app-category.productivity");if(includeApi){if(!config.apiKeyId)errors.push("APP_STORE_CONNECT_API_KEY_ID is required");if(!config.apiIssuerId)errors.push("APP_STORE_CONNECT_API_ISSUER_ID is required");if(!config.apiKeyPath||!existsSync(config.apiKeyPath))errors.push("APP_STORE_CONNECT_API_KEY_PATH must point to an existing .p8 file, or APP_STORE_CONNECT_API_KEY hold its text")}if(config.universal){for(const[variable,path]of[["CRAFT_BIN_ARM64",config.craftArm64],["CRAFT_BIN_X64",config.craftX64]])if(!path||!existsSync(path))errors.push(`${variable} must point to a native Craft runtime for a universal build`);else if(readFileSync(path).subarray(0,2).toString()==="#!")errors.push(`${variable} points at a script, not a native Craft runtime: ${path}`)}for(const tool of config.universal?[...REQUIRED_TOOLS,"lipo"]:REQUIRED_TOOLS)if(!Bun.which(tool))errors.push(`Required Apple command is unavailable: ${tool}`);return errors}function bundleFiles(appPath){const walk=(dir,prefix)=>readdirSync(dir,{withFileTypes:!0}).flatMap((entry)=>{const relative=prefix?`${prefix}/${entry.name}`:entry.name;return entry.isDirectory()?walk(join(dir,entry.name),relative):[relative]});return walk(appPath,"")}export function isAppleCategory(value){return/^public\.app-category\.[a-z][a-z0-9-]*$/.test(value)}export function storeRejectionFindings(paths,executable="stacks-desktop"){const findings=[],has=(path)=>paths.includes(path);for(const required of["Contents/Info.plist",`Contents/MacOS/${executable}`,"Contents/embedded.provisionprofile"])if(!has(required))findings.push(`missing required bundle file: ${required}`);for(const path of paths){const base=path.split("/").pop()??"";if(base===".DS_Store"||path.startsWith("__MACOSX/")||base.startsWith("._"))findings.push(`forbidden file in the bundle: ${path}`);if(/\.(?:dylib|so|framework|app|xpc)$/.test(path)){if(!(path.startsWith("Contents/Frameworks/")||path.startsWith("Contents/MacOS/")||path.startsWith("Contents/XPCServices/")||path.startsWith("Contents/PlugIns/")))findings.push(`nested code outside a signed location: ${path}`)}if(path.startsWith("Contents/Resources/")&&/\.(?:sh|command|dylib|so)$/.test(path))findings.push(`executable content under Resources: ${path}`)}return findings}export function renderInfoPlist(config,executable="stacks-desktop"){const iconEntry=config.icon?`
2
4
  <key>CFBundleIconFile</key>
3
5
  <string>AppIcon</string>`:"";return`<?xml version="1.0" encoding="UTF-8"?>
4
6
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "https://www.apple.com/DTDs/PropertyList-1.0.dtd">
@@ -104,6 +106,6 @@ ${findings.map((finding)=>` - ${finding}`).join(`
104
106
  `);writeFileSync(join(appleDir,"apple-provenance.json"),`${JSON.stringify({schemaVersion:"1.0.0",appName:config.appName,bundleId:config.bundleId,teamId:config.teamId,version:config.version,buildNumber:config.buildNumber,minimumMacos:config.minimumMacos,architectures:provenance.architectures,package:{name:basename(packagePath),sha256:packageHash},sourceRevision:Bun.spawnSync(["git","rev-parse","HEAD"],{cwd:projectPath()}).stdout.toString().trim(),craft:provenance.craft},null,2)}
105
107
  `);return packagePath}function validateOrUpload(packagePath,config,validateOnly){const errors=validateAppleDesktopConfig(config,!0);if(errors.length)throw Error(errors.join(`
106
108
  `));const common=["--type","macos","--file",packagePath,"--apiKey",config.apiKeyId,"--apiIssuer",config.apiIssuerId];process.env.API_PRIVATE_KEYS_DIR=resolve(config.apiKeyPath,"..");command(["xcrun","altool","--validate-app",...common]);if(!validateOnly)command(["xcrun","altool","--upload-app",...common])}export function renderFailure(error){return`${error instanceof Error?error.message:String(error)}
107
- `}function fail(error){process.stderr.write(renderFailure(error));process.exit(1)}export function desktopApple(buddy){buddy.command("desktop:apple:csr","Generate local private keys and CSRs for Mac App Store distribution certificates").option("--common-name <name>","Certificate request common name").option("--output <path>","Directory for private keys and certificate requests").action(async(options)=>{try{const metadata=packageMetadata(),outputDirectory=resolve(options.output||storagePath("framework/desktop-dist/apple/provisioning")),{generateMacCertificateRequests}=await import("ts-pantry");generateMacCertificateRequests({outputDirectory,commonName:options.commonName||metadata.name});log.success(`Generated Apple certificate requests and private keys in ${outputDirectory}`)}catch(error){fail(error)}});buddy.command("desktop:apple:provision","Plan or reconcile Apple Bundle ID, capabilities, certificates, and profile").option("--app-name <name>","Mac App Store display name").option("--bundle-id <id>","Reverse-DNS bundle identifier").option("--api-key-id <id>","App Store Connect API key ID").option("--api-issuer-id <id>","App Store Connect API issuer ID").option("--api-key-path <path>","App Store Connect AuthKey .p8 file").option("--capabilities <types>","Comma-separated Apple capability types").option("--app-certificate-csr <path>","CSR for a missing Mac App Distribution certificate").option("--installer-certificate-csr <path>","CSR for a missing Mac Installer Distribution certificate").option("--profile-name <name>","Provisioning profile name").option("--output <path>","Directory for the plan and downloaded Apple assets").option("--plan","Report the idempotent Apple resource diff without mutating it").option("--apply","Apply the Apple resource diff without revoking existing certificates").action(async(options)=>{try{if(options.plan&&options.apply)throw Error("Choose either --plan or --apply");const config=resolveAppleDesktopConfig(options,await nativeAppleConfig());if(!config.bundleId)throw Error("APPLE_BUNDLE_ID or --bundle-id is required");if(!config.apiKeyId||!config.apiIssuerId||!config.apiKeyPath||!existsSync(config.apiKeyPath))throw Error("App Store Connect API key ID, issuer ID, and existing .p8 key path are required");const readCsr=(file)=>{if(!file)return;const resolved=resolve(file);if(!existsSync(resolved))throw Error(`Apple certificate CSR does not exist: ${resolved}`);return readFileSync(resolved,"utf8")},{exportAppleCertificateP12,provisionMacApp}=await import("ts-pantry"),result=await provisionMacApp({identifier:config.bundleId,name:config.appName,capabilities:options.capabilities?.split(",").map((value)=>value.trim()).filter(Boolean),appCertificateCsr:readCsr(options.appCertificateCsr),installerCertificateCsr:readCsr(options.installerCertificateCsr),profileName:options.profileName,keyId:config.apiKeyId,issuerId:config.apiIssuerId,keyPath:config.apiKeyPath,checkOnly:!options.apply}),outputDirectory=resolve(options.output||storagePath("framework/desktop-dist/apple/provisioning"));mkdirSync(outputDirectory,{recursive:!0});for(const certificate of result.certificates){if(!certificate.certificateContent)continue;const fileName=certificate.type==="MAC_APP_DISTRIBUTION"?"mac-app-distribution.cer":"mac-installer-distribution.cer";writeFileSync(join(outputDirectory,fileName),Buffer.from(certificate.certificateContent,"base64"),{mode:384})}if(result.profile.profileContent)writeFileSync(join(outputDirectory,"mac-app-store.provisionprofile"),Buffer.from(result.profile.profileContent,"base64"),{mode:384});const certificatePassword=env("APPLE_CERTIFICATE_PASSWORD"),certificateExports=[{type:"MAC_APP_DISTRIBUTION",csr:options.appCertificateCsr,certificate:"mac-app-distribution.cer",output:"mac-app-distribution.p12",name:`${config.appName} Mac App Distribution`},{type:"MAC_INSTALLER_DISTRIBUTION",csr:options.installerCertificateCsr,certificate:"mac-installer-distribution.cer",output:"mac-installer-distribution.p12",name:`${config.appName} Mac Installer Distribution`}];for(const certificateExport of certificateExports){if(!result.certificates.find((item)=>item.type===certificateExport.type)?.certificateContent||!certificateExport.csr||!certificatePassword)continue;exportAppleCertificateP12({certificatePath:join(outputDirectory,certificateExport.certificate),privateKeyPath:resolve(certificateExport.csr.replace(/\.csr$/i,".key")),outputPath:join(outputDirectory,certificateExport.output),password:certificatePassword,name:certificateExport.name})}const certificates=result.certificates.map((item)=>{const certificate={...item};delete certificate.certificateContent;return certificate}),profile={...result.profile};delete profile.profileContent;const report={...result,certificates,profile},reportPath=join(outputDirectory,"provisioning-plan.json");writeFileSync(reportPath,`${JSON.stringify(report,null,2)}
108
- `,{mode:384});log.success(`${options.apply?"Applied":"Planned"} Apple provisioning: ${reportPath}`);if(!result.appRecord.exists&&result.appRecord.manualAction)log.warn(result.appRecord.manualAction)}catch(error){fail(error)}});buddy.command("desktop:apple:doctor","Validate Mac App Store tooling, credentials, certificates, and project metadata").option("--app-name <name>","Mac App Store display name").option("--bundle-id <id>","Reverse-DNS bundle identifier").option("--team-id <id>","Apple Developer team ID").option("--app-signing-identity <identity>","Mac App Distribution signing identity").option("--installer-signing-identity <identity>","Mac Installer Distribution signing identity").option("--provisioning-profile <path>","Mac App Store provisioning profile").option("--api-key-id <id>","App Store Connect API key ID").option("--api-issuer-id <id>","App Store Connect API issuer ID").option("--api-key-path <path>","App Store Connect AuthKey .p8 file").option("--keychain <path>","Look for the signing identities in this keychain file only (or APPLE_KEYCHAIN)").action(async(options)=>{try{const config=resolveAppleDesktopConfig(options,await nativeAppleConfig()),errors=validateAppleDesktopConfig(config,!0);if(!signingIdentityExists(config.appSigningIdentity,config.keychain))errors.push(`App signing identity is not installed: ${config.appSigningIdentity}`);if(!signingIdentityExists(config.installerSigningIdentity,config.keychain))errors.push(`Installer signing identity is not installed: ${config.installerSigningIdentity}`);if(!provisioningProfileMatches(config))errors.push(`Provisioning profile does not match ${config.teamId}.${config.bundleId}`);if(errors.length)throw Error(errors.join(`
109
- `));log.success(`Mac App Store prerequisites are ready for ${config.bundleId}`)}catch(error){fail(error)}});buddy.command("desktop:apple:init","Create a GitHub Actions caller for the reusable Stacks Mac App Store workflow").option("--force","Replace an existing workflow").action((options)=>{try{const workflowPath=projectPath(".github/workflows/apple-app-store.yml");if(existsSync(workflowPath)&&!options.force)throw Error(`${workflowPath} already exists. Use --force to replace it.`);mkdirSync(resolve(workflowPath,".."),{recursive:!0});writeFileSync(workflowPath,renderAppleWorkflowCaller());log.success(`Created ${workflowPath}`)}catch(error){fail(error)}});const addSharedOptions=(commandBuilder)=>commandBuilder.option("--app-name <name>","Mac App Store display name").option("--bundle-id <id>","Reverse-DNS bundle identifier").option("--team-id <id>","Apple Developer team ID").option("--app-version <version>","Marketing version").option("--build-number <number>","Unique App Store build number").option("--minimum-macos <version>","Minimum supported macOS version").option("--category <category>","LSApplicationCategoryType value").option("--app-signing-identity <identity>","Mac App Distribution signing identity").option("--installer-signing-identity <identity>","Mac Installer Distribution signing identity").option("--provisioning-profile <path>","Mac App Store provisioning profile").option("--icon <path>","Optional .icns app icon").option("--skip-build","Package existing storage/framework/desktop-dist artifacts").option("--universal","Ship one arm64 + x86_64 binary, merged with lipo").option("--craft-arm64 <path>","Thin arm64 Craft runtime for --universal (or CRAFT_BIN_ARM64)").option("--craft-x64 <path>","Thin x86_64 Craft runtime for --universal (or CRAFT_BIN_X64)").option("--keychain <path>","Sign with identities from this keychain file only (or APPLE_KEYCHAIN)");addSharedOptions(buddy.command("desktop:apple:package","Build, sandbox, sign, and package a Mac App Store desktop app")).action(async(options)=>{try{const native=await nativeAppleConfig(),packagePath=await packageAppleDesktop(resolveAppleDesktopConfig(options,native),Boolean(options.skipBuild),native);log.success(`Created signed Mac App Store package ${packagePath}`)}catch(error){fail(error)}});addSharedOptions(buddy.command("desktop:apple:publish","Build and validate or upload a signed Mac App Store package")).option("--api-key-id <id>","App Store Connect API key ID").option("--api-issuer-id <id>","App Store Connect API issuer ID").option("--api-key-path <path>","App Store Connect AuthKey .p8 file").option("--validate-only","Validate with App Store Connect without uploading").option("--package-only","Create the signed package without contacting App Store Connect").action(async(options)=>{try{const native=await nativeAppleConfig(),config=resolveAppleDesktopConfig(options,native),packagePath=await packageAppleDesktop(config,Boolean(options.skipBuild),native);if(!options.packageOnly)validateOrUpload(packagePath,config,Boolean(options.validateOnly));log.success(options.packageOnly?`Created signed Mac App Store package ${packagePath}`:options.validateOnly?`Validated ${packagePath} with App Store Connect`:`Uploaded ${packagePath} to App Store Connect`)}catch(error){fail(error)}})}
109
+ `}function fail(error){process.stderr.write(renderFailure(error));process.exit(1)}export function desktopApple(buddy){buddy.command("desktop:apple:csr","Generate local private keys and CSRs for Mac App Store distribution certificates").option("--common-name <name>","Certificate request common name").option("--output <path>","Directory for private keys and certificate requests").action(async(options)=>{try{const metadata=packageMetadata(),outputDirectory=resolve(options.output||storagePath("framework/desktop-dist/apple/provisioning")),{generateMacCertificateRequests}=await import("ts-pantry");generateMacCertificateRequests({outputDirectory,commonName:options.commonName||metadata.name});log.success(`Generated Apple certificate requests and private keys in ${outputDirectory}`)}catch(error){fail(error)}});buddy.command("desktop:apple:provision","Plan or reconcile Apple Bundle ID, capabilities, certificates, and profile").option("--app-name <name>","Mac App Store display name").option("--bundle-id <id>","Reverse-DNS bundle identifier").option("--api-key-id <id>","App Store Connect API key ID").option("--api-issuer-id <id>","App Store Connect API issuer ID").option("--api-key-path <path>","App Store Connect AuthKey .p8 file").option("--capabilities <types>","Comma-separated Apple capability types").option("--app-certificate-csr <path>","CSR for a missing Mac App Distribution certificate").option("--installer-certificate-csr <path>","CSR for a missing Mac Installer Distribution certificate").option("--profile-name <name>","Provisioning profile name").option("--output <path>","Directory for the plan and downloaded Apple assets").option("--plan","Report the idempotent Apple resource diff without mutating it").option("--apply","Apply the Apple resource diff without revoking existing certificates").action(async(options)=>{try{if(options.plan&&options.apply)throw Error("Choose either --plan or --apply");const config=withApiKeyFile(resolveAppleDesktopConfig(options,await nativeAppleConfig()));if(!config.bundleId)throw Error("APPLE_BUNDLE_ID or --bundle-id is required");if(!config.apiKeyId||!config.apiIssuerId||!config.apiKeyPath||!existsSync(config.apiKeyPath))throw Error("App Store Connect API key ID, issuer ID, and existing .p8 key path are required");const readCsr=(file)=>{if(!file)return;const resolved=resolve(file);if(!existsSync(resolved))throw Error(`Apple certificate CSR does not exist: ${resolved}`);return readFileSync(resolved,"utf8")},{exportAppleCertificateP12,provisionMacApp}=await import("ts-pantry"),result=await provisionMacApp({identifier:config.bundleId,name:config.appName,capabilities:options.capabilities?.split(",").map((value)=>value.trim()).filter(Boolean),appCertificateCsr:readCsr(options.appCertificateCsr),installerCertificateCsr:readCsr(options.installerCertificateCsr),profileName:options.profileName,keyId:config.apiKeyId,issuerId:config.apiIssuerId,keyPath:config.apiKeyPath,checkOnly:!options.apply}),outputDirectory=resolve(options.output||storagePath("framework/desktop-dist/apple/provisioning"));mkdirSync(outputDirectory,{recursive:!0});for(const certificate of result.certificates){if(!certificate.certificateContent)continue;const fileName=certificate.type==="MAC_APP_DISTRIBUTION"?"mac-app-distribution.cer":"mac-installer-distribution.cer";writeFileSync(join(outputDirectory,fileName),Buffer.from(certificate.certificateContent,"base64"),{mode:384})}if(result.profile.profileContent)writeFileSync(join(outputDirectory,"mac-app-store.provisionprofile"),Buffer.from(result.profile.profileContent,"base64"),{mode:384});const certificatePassword=env("APPLE_CERTIFICATE_PASSWORD"),certificateExports=[{type:"MAC_APP_DISTRIBUTION",csr:options.appCertificateCsr,certificate:"mac-app-distribution.cer",output:"mac-app-distribution.p12",name:`${config.appName} Mac App Distribution`},{type:"MAC_INSTALLER_DISTRIBUTION",csr:options.installerCertificateCsr,certificate:"mac-installer-distribution.cer",output:"mac-installer-distribution.p12",name:`${config.appName} Mac Installer Distribution`}];for(const certificateExport of certificateExports){if(!result.certificates.find((item)=>item.type===certificateExport.type)?.certificateContent||!certificateExport.csr||!certificatePassword)continue;exportAppleCertificateP12({certificatePath:join(outputDirectory,certificateExport.certificate),privateKeyPath:resolve(certificateExport.csr.replace(/\.csr$/i,".key")),outputPath:join(outputDirectory,certificateExport.output),password:certificatePassword,name:certificateExport.name})}const certificates=result.certificates.map((item)=>{const certificate={...item};delete certificate.certificateContent;return certificate}),profile={...result.profile};delete profile.profileContent;const report={...result,certificates,profile},reportPath=join(outputDirectory,"provisioning-plan.json");writeFileSync(reportPath,`${JSON.stringify(report,null,2)}
110
+ `,{mode:384});log.success(`${options.apply?"Applied":"Planned"} Apple provisioning: ${reportPath}`);if(!result.appRecord.exists&&result.appRecord.manualAction)log.warn(result.appRecord.manualAction)}catch(error){fail(error)}});buddy.command("desktop:apple:doctor","Validate Mac App Store tooling, credentials, certificates, and project metadata").option("--app-name <name>","Mac App Store display name").option("--bundle-id <id>","Reverse-DNS bundle identifier").option("--team-id <id>","Apple Developer team ID").option("--app-signing-identity <identity>","Mac App Distribution signing identity").option("--installer-signing-identity <identity>","Mac Installer Distribution signing identity").option("--provisioning-profile <path>","Mac App Store provisioning profile").option("--api-key-id <id>","App Store Connect API key ID").option("--api-issuer-id <id>","App Store Connect API issuer ID").option("--api-key-path <path>","App Store Connect AuthKey .p8 file").option("--keychain <path>","Look for the signing identities in this keychain file only (or APPLE_KEYCHAIN)").action(async(options)=>{try{const config=withApiKeyFile(resolveAppleDesktopConfig(options,await nativeAppleConfig())),errors=validateAppleDesktopConfig(config,!0);if(!signingIdentityExists(config.appSigningIdentity,config.keychain))errors.push(`App signing identity is not installed: ${config.appSigningIdentity}`);if(!signingIdentityExists(config.installerSigningIdentity,config.keychain))errors.push(`Installer signing identity is not installed: ${config.installerSigningIdentity}`);if(!provisioningProfileMatches(config))errors.push(`Provisioning profile does not match ${config.teamId}.${config.bundleId}`);if(errors.length)throw Error(errors.join(`
111
+ `));log.success(`Mac App Store prerequisites are ready for ${config.bundleId}`)}catch(error){fail(error)}});buddy.command("desktop:apple:init","Create a GitHub Actions caller for the reusable Stacks Mac App Store workflow").option("--force","Replace an existing workflow").action((options)=>{try{const workflowPath=projectPath(".github/workflows/apple-app-store.yml");if(existsSync(workflowPath)&&!options.force)throw Error(`${workflowPath} already exists. Use --force to replace it.`);mkdirSync(resolve(workflowPath,".."),{recursive:!0});writeFileSync(workflowPath,renderAppleWorkflowCaller());log.success(`Created ${workflowPath}`)}catch(error){fail(error)}});const addSharedOptions=(commandBuilder)=>commandBuilder.option("--app-name <name>","Mac App Store display name").option("--bundle-id <id>","Reverse-DNS bundle identifier").option("--team-id <id>","Apple Developer team ID").option("--app-version <version>","Marketing version").option("--build-number <number>","Unique App Store build number").option("--minimum-macos <version>","Minimum supported macOS version").option("--category <category>","LSApplicationCategoryType value").option("--app-signing-identity <identity>","Mac App Distribution signing identity").option("--installer-signing-identity <identity>","Mac Installer Distribution signing identity").option("--provisioning-profile <path>","Mac App Store provisioning profile").option("--icon <path>","Optional .icns app icon").option("--skip-build","Package existing storage/framework/desktop-dist artifacts").option("--universal","Ship one arm64 + x86_64 binary, merged with lipo").option("--craft-arm64 <path>","Thin arm64 Craft runtime for --universal (or CRAFT_BIN_ARM64)").option("--craft-x64 <path>","Thin x86_64 Craft runtime for --universal (or CRAFT_BIN_X64)").option("--keychain <path>","Sign with identities from this keychain file only (or APPLE_KEYCHAIN)");addSharedOptions(buddy.command("desktop:apple:package","Build, sandbox, sign, and package a Mac App Store desktop app")).action(async(options)=>{try{const native=await nativeAppleConfig(),packagePath=await packageAppleDesktop(resolveAppleDesktopConfig(options,native),Boolean(options.skipBuild),native);log.success(`Created signed Mac App Store package ${packagePath}`)}catch(error){fail(error)}});addSharedOptions(buddy.command("desktop:apple:publish","Build and validate or upload a signed Mac App Store package")).option("--api-key-id <id>","App Store Connect API key ID").option("--api-issuer-id <id>","App Store Connect API issuer ID").option("--api-key-path <path>","App Store Connect AuthKey .p8 file").option("--validate-only","Validate with App Store Connect without uploading").option("--package-only","Create the signed package without contacting App Store Connect").action(async(options)=>{try{const native=await nativeAppleConfig(),config=withApiKeyFile(resolveAppleDesktopConfig(options,native)),packagePath=await packageAppleDesktop(config,Boolean(options.skipBuild),native);if(!options.packageOnly)validateOrUpload(packagePath,config,Boolean(options.validateOnly));log.success(options.packageOnly?`Created signed Mac App Store package ${packagePath}`:options.validateOnly?`Validated ${packagePath} with App Store Connect`:`Uploaded ${packagePath} to App Store Connect`)}catch(error){fail(error)}})}
@@ -4,7 +4,7 @@ ${lines.join(`
4
4
  ${remedy}`);return!1}log.warn(message);log.info(remedy);return!0}catch(err){log.debug(`[migrate] duplicate migration number check skipped: ${err instanceof Error?err.message:String(err)}`);return!0}}async function reportHandWrittenMigrations(options={}){try{const{handWrittenMigrations}=await import("@stacksjs/database"),{files}=await handWrittenMigrations();if(files.length===0)return!0;log.warn(`${files.length} hand-written migration${files.length===1?"":"s"} - no \`-- qb:generated\` marker, so the models do not describe ${files.length===1?"it":"them"}${options.list?`:
5
5
  ${files.slice(0,20).map((file)=>` \u2022 ${file}`).join(`
6
6
  `)}${files.length>20?`
7
- \u2022 \u2026 +${files.length-20} more`:""}`:" (`./buddy migrate:status` lists them)."}`);log.info(" Move the change into app/Models and regenerate (`./buddy migrate:regenerate`), or allow the file in database.safety.allowHandWrittenMigrations.");return!1}catch(err){log.debug(`[migrate] hand-written migration check skipped: ${err instanceof Error?err.message:String(err)}`);return!0}}export function generatesMigrations(flag,interactive){return flag??interactive}function strictMode(options){return options.strict===!0||process.env.STACKS_MIGRATE_STRICT==="1"}async function reportMissingForeignKeys(){try{const{auditForeignKeys}=await import("@stacksjs/database"),result=await auditForeignKeys();if(result.absentTable.length>0)log.debug(`[migrate] ${result.absentTable.length} declared foreign key(s) belong to tables that are not in this database (${[...new Set(result.absentTable.map((fk)=>fk.fromTable))].join(", ")}); not reported as missing.`);if(result.missing.length===0)return;const sample=result.missing.slice(0,5).map((fk)=>` \u2022 ${fk.fromTable}.${fk.fromColumn} \u2192 ${fk.toTable}.${fk.toColumn} (${fk.model})`).join(`
7
+ \u2022 \u2026 +${files.length-20} more`:""}`:" (`./buddy migrate:status` lists them)."}`);log.info(" Move the change into app/Models and regenerate (`./buddy migrate:regenerate`), or allow the file in database.safety.allowHandWrittenMigrations.");return!1}catch(err){log.debug(`[migrate] hand-written migration check skipped: ${err instanceof Error?err.message:String(err)}`);return!0}}export function generatesMigrations(flag,interactive){return flag??interactive}function strictMode(options){return options.strict===!0||process.env.STACKS_MIGRATE_STRICT==="1"}async function addMissingEnumLabels(){try{const{alignSchema,ENUM_ALIGN_KINDS}=await import("@stacksjs/database"),result=await alignSchema({apply:!0,only:ENUM_ALIGN_KINDS});for(const action of result.plan.actions)log.info(` ${action.detail}`);if(result.applied.length>0)log.success(`Added ${result.applied.length} enum label${result.applied.length===1?"":"s"} the models declare and the database was missing.`)}catch(err){log.warn(`Could not check enum labels against the models: ${err instanceof Error?err.message:String(err)}`)}}async function reportMissingForeignKeys(){try{const{auditForeignKeys}=await import("@stacksjs/database"),result=await auditForeignKeys();if(result.absentTable.length>0)log.debug(`[migrate] ${result.absentTable.length} declared foreign key(s) belong to tables that are not in this database (${[...new Set(result.absentTable.map((fk)=>fk.fromTable))].join(", ")}); not reported as missing.`);if(result.missing.length===0)return;const sample=result.missing.slice(0,5).map((fk)=>` \u2022 ${fk.fromTable}.${fk.fromColumn} \u2192 ${fk.toTable}.${fk.toColumn} (${fk.model})`).join(`
8
8
  `),more=result.missing.length>5?`
9
9
  + ${result.missing.length-5} more - run \`./buddy doctor\` for the full list.`:"";log.warn(`${result.missing.length} of ${result.declared.length} declared foreign keys are missing from the live schema:
10
10
  ${sample}${more}
@@ -16,7 +16,7 @@ These were written under the old \`foreign_keys = OFF\` default (#1951). Review
16
16
  \u274C Error: ${validation.error}
17
17
  `);process.exit(ExitCode.FatalError)}const{pendingMigrationFiles:pendingForDialect}=await import("@stacksjs/database");let willRun;try{willRun=await pendingForDialect()}catch{willRun=void 0}const dialectCheck=validateMigrationDialect(process.cwd(),{only:willRun});if(!dialectCheck.valid){console.error(`
18
18
  \u274C Error: ${dialectCheck.error}
19
- `);process.exit(ExitCode.FatalError)}if(!await reportDuplicateMigrationNumbers({strict:strictMode(options)}))process.exit(ExitCode.FatalError);const applyRenames=options.rename===!1?!1:void 0;if(options.fromDb)process.env.STACKS_MIGRATE_FROM_DB="1";if(applyRenames===!1)process.env.STACKS_MIGRATE_NO_RENAME="1";const generate=generatesMigrations(options.generate,hasTTY&&!isCI);if(!generate)process.env.STACKS_MIGRATE_NO_GENERATE="1";if(options.generate===void 0&&!generate)log.info("Applying committed migrations only: this run is not interactive, so nothing is generated from your models. Run `./buddy generate:migrations` and commit the result, or pass --generate.");if(options.strict)process.env.STACKS_MIGRATE_STRICT="1";if(isMigratePreview(options)){const{auditSchemaDrift,formatSchemaDrift,pendingMigrationFiles,pendingMigrationOperations}=await import("@stacksjs/database");let failed=!1,files=[];try{files=await pendingMigrationFiles()}catch(error){failed=!0;await log.error("Could not read the migrations table:",error)}if(files.length>0){log.info(`${files.length} committed migration file${files.length===1?"":"s"} not applied yet - \`buddy migrate\` will run ${files.length===1?"it":"them"}:`);for(const file of files.slice(0,10))log.info(` \u2022 ${file}`);if(files.length>10)log.info(` \u2022 \u2026 +${files.length-10} more`)}let ops=[];try{ops=await pendingMigrationOperations({fromDb:options.fromDb,applyRenames})}catch(error){failed=!0;await log.error("Failed to diff the models against the migration snapshot:",error)}if(ops.length>0){log.info(`${ops.length} model change${ops.length===1?"":"s"} not in a migration file yet:`);for(const op of ops){log.info(` \u2022 ${describeOp(op)}${op.destructive?" [destructive]":""}`);if(op.warning)log.warn(` ${op.warning}`)}}const drift=await auditSchemaDrift(),modelsDrifted=!drift.skipped&&!drift.clean;if(modelsDrifted)log.warn(await formatSchemaDrift(drift));const drifted=modelsDrifted||!await reportMissingRecordedTables();if(failed){await outro("Diff incomplete - see the errors above. No changes applied.",{startTime:perf,useSeconds:!0,type:"error"});process.exit(ExitCode.FatalError)}if(files.length===0&&ops.length===0&&!drifted){log.info("No pending schema changes - your models match the database.");await outro("Diff complete - no changes applied.",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)}if(drifted&&files.length===0&&ops.length===0)logDriftRemedy();await outro("Diff complete - no changes applied.",{startTime:perf,useSeconds:!0,type:drifted?"warning":"success"});process.exit(drifted&&strictMode(options)?ExitCode.FatalError:ExitCode.Success)}await preflightDatabase({createDatabase:options.createDatabase,command:"migrate"});if((await resolveMigrationGuards()).confirmMigrate&&!options.force)if(isCI||!hasTTY)log.debug("[migrate] confirmMigrate guard skipped - non-interactive environment.");else{const APP_ENV=process.env.APP_ENV||"local";await log.flush();if(!await confirm({message:`Run migrations against the ${APP_ENV} database "${currentDatabaseLabel()}"?`,initial:!0})){await outro("Migration cancelled - no changes applied.",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)}}await ensureDatabaseOrExit();const lock=acquireMigrationLock();if(!lock.acquired){log.syncError("Another migration is already running (storage/framework/runtime/migrations.lock exists). Wait for it to finish, or remove the lockfile if it is stale.");process.exit(ExitCode.FatalError)}try{const{catchUpFrameworkRenames}=await import("@stacksjs/database");await catchUpFrameworkRenames()}catch(error){lock.release();log.syncError(error instanceof Error?error.message:String(error));process.exit(ExitCode.FatalError)}if(generate){if(!await confirmDestructiveMigrations({force:options.force,fromDb:options.fromDb,applyRenames})){lock.release();await outro("Migration cancelled - no changes applied.",{startTime:perf,useSeconds:!0});process.exit(isCI||!hasTTY?ExitCode.FatalError:ExitCode.Success)}}if(options.auth!==!1){log.debug("Migrating auth tables...");try{const{migrateAuthTables}=await import("@stacksjs/database"),authResult=await migrateAuthTables({verbose:options.verbose});if(!authResult.success)log.error(`Failed to migrate auth tables: ${authResult.error}`)}catch(error){log.error("Failed to migrate auth tables:",error)}}const result=await runAction(Action.Migrate,options).finally(()=>lock.release());if(resultFailed(result))log.error("Model migrations failed - applying notification/RBAC table guarantees before exiting.");let traitMigrationError;if(options.auth!==!1)try{const{ensureUtcDatetimeColumns,ensureUtcTimestampDefaults,migrateNotificationTables,migrateRbacTables,migrateTraitTables}=await import("@stacksjs/database"),notifResult=await migrateNotificationTables({verbose:options.verbose});if(!notifResult.success)log.error(`Failed to migrate notification tables: ${notifResult.error}`);const rbacResult=await migrateRbacTables({verbose:options.verbose});if(!rbacResult.success)log.error(`Failed to migrate RBAC tables: ${rbacResult.error}`);const{migrateEnabledImpersonationTables}=await import("@stacksjs/auth/impersonation-store");await migrateEnabledImpersonationTables();const traitResult=await migrateTraitTables({verbose:options.verbose});if(!traitResult.success){traitMigrationError=traitResult.error||"Polymorphic trait table migration failed";log.error(`Failed to migrate polymorphic trait tables: ${traitMigrationError}`)}const datetimeResult=await ensureUtcDatetimeColumns({verbose:options.verbose});if(!datetimeResult.success)log.error(`Failed to convert TIMESTAMP columns to DATETIME: ${datetimeResult.error}`);const defaultsResult=await ensureUtcTimestampDefaults({verbose:options.verbose});if(!defaultsResult.success)log.error(`Failed to pin timestamp defaults to UTC: ${defaultsResult.error}`)}catch(error){traitMigrationError=error instanceof Error?error.message:String(error);log.error("Failed to migrate notification/RBAC/trait tables:",error)}if(resultFailed(result)||traitMigrationError){await outro("While running the migrate command, there was an issue",{startTime:perf,useSeconds:!0},resultFailed(result)?result.error:traitMigrationError);await log.flush();process.exit(ExitCode.FatalError)}if(options.auth!==!1)try{const{ensureUsersAuthColumns,sqlHelpers}=await import("@stacksjs/database"),driver=process.env.DB_CONNECTION||"sqlite";await ensureUsersAuthColumns(sqlHelpers(driver),{verbose:options.verbose})}catch(error){log.error("Failed to ensure users auth columns post-migration:",error)}try{const{ensureUuidColumns,sqlHelpers}=await import("@stacksjs/database"),driver=process.env.DB_CONNECTION||"sqlite";await ensureUuidColumns(sqlHelpers(driver),{verbose:options.verbose})}catch(error){log.error("Failed to ensure uuid columns post-migration:",error)}await reportMissingForeignKeys();const modelsMatch=await reportSchemaDrift(),tablesPresent=await reportMissingRecordedTables(),schemaMatches=modelsMatch&&tablesPresent,allCommittable=await reportIgnoredMigrations(),allGenerated=await reportHandWrittenMigrations({list:strictMode(options)});if(strictMode(options)&&(!schemaMatches||!allCommittable||!allGenerated)){console.error(`
19
+ `);process.exit(ExitCode.FatalError)}if(!await reportDuplicateMigrationNumbers({strict:strictMode(options)}))process.exit(ExitCode.FatalError);const applyRenames=options.rename===!1?!1:void 0;if(options.fromDb)process.env.STACKS_MIGRATE_FROM_DB="1";if(applyRenames===!1)process.env.STACKS_MIGRATE_NO_RENAME="1";const generate=generatesMigrations(options.generate,hasTTY&&!isCI);if(!generate)process.env.STACKS_MIGRATE_NO_GENERATE="1";if(options.generate===void 0&&!generate)log.info("Applying committed migrations only: this run is not interactive, so nothing is generated from your models. Run `./buddy generate:migrations` and commit the result, or pass --generate.");if(options.strict)process.env.STACKS_MIGRATE_STRICT="1";if(isMigratePreview(options)){const{auditSchemaDrift,formatSchemaDrift,pendingMigrationFiles,pendingMigrationOperations}=await import("@stacksjs/database");let failed=!1,files=[];try{files=await pendingMigrationFiles()}catch(error){failed=!0;await log.error("Could not read the migrations table:",error)}if(files.length>0){log.info(`${files.length} committed migration file${files.length===1?"":"s"} not applied yet - \`buddy migrate\` will run ${files.length===1?"it":"them"}:`);for(const file of files.slice(0,10))log.info(` \u2022 ${file}`);if(files.length>10)log.info(` \u2022 \u2026 +${files.length-10} more`)}let ops=[];try{ops=await pendingMigrationOperations({fromDb:options.fromDb,applyRenames})}catch(error){failed=!0;await log.error("Failed to diff the models against the migration snapshot:",error)}if(ops.length>0){log.info(`${ops.length} model change${ops.length===1?"":"s"} not in a migration file yet:`);for(const op of ops){log.info(` \u2022 ${describeOp(op)}${op.destructive?" [destructive]":""}`);if(op.warning)log.warn(` ${op.warning}`)}}const drift=await auditSchemaDrift(),modelsDrifted=!drift.skipped&&!drift.clean;if(modelsDrifted)log.warn(await formatSchemaDrift(drift));const drifted=modelsDrifted||!await reportMissingRecordedTables();if(failed){await outro("Diff incomplete - see the errors above. No changes applied.",{startTime:perf,useSeconds:!0,type:"error"});process.exit(ExitCode.FatalError)}if(files.length===0&&ops.length===0&&!drifted){log.info("No pending schema changes - your models match the database.");await outro("Diff complete - no changes applied.",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)}if(drifted&&files.length===0&&ops.length===0)logDriftRemedy();await outro("Diff complete - no changes applied.",{startTime:perf,useSeconds:!0,type:drifted?"warning":"success"});process.exit(drifted&&strictMode(options)?ExitCode.FatalError:ExitCode.Success)}await preflightDatabase({createDatabase:options.createDatabase,command:"migrate"});if((await resolveMigrationGuards()).confirmMigrate&&!options.force)if(isCI||!hasTTY)log.debug("[migrate] confirmMigrate guard skipped - non-interactive environment.");else{const APP_ENV=process.env.APP_ENV||"local";await log.flush();if(!await confirm({message:`Run migrations against the ${APP_ENV} database "${currentDatabaseLabel()}"?`,initial:!0})){await outro("Migration cancelled - no changes applied.",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)}}await ensureDatabaseOrExit();const lock=acquireMigrationLock();if(!lock.acquired){log.syncError("Another migration is already running (storage/framework/runtime/migrations.lock exists). Wait for it to finish, or remove the lockfile if it is stale.");process.exit(ExitCode.FatalError)}try{const{catchUpFrameworkRenames}=await import("@stacksjs/database");await catchUpFrameworkRenames()}catch(error){lock.release();log.syncError(error instanceof Error?error.message:String(error));process.exit(ExitCode.FatalError)}if(generate){if(!await confirmDestructiveMigrations({force:options.force,fromDb:options.fromDb,applyRenames})){lock.release();await outro("Migration cancelled - no changes applied.",{startTime:perf,useSeconds:!0});process.exit(isCI||!hasTTY?ExitCode.FatalError:ExitCode.Success)}}if(options.auth!==!1){log.debug("Migrating auth tables...");try{const{migrateAuthTables}=await import("@stacksjs/database"),authResult=await migrateAuthTables({verbose:options.verbose});if(!authResult.success)log.error(`Failed to migrate auth tables: ${authResult.error}`)}catch(error){log.error("Failed to migrate auth tables:",error)}}const result=await runAction(Action.Migrate,options).finally(()=>lock.release());if(resultFailed(result))log.error("Model migrations failed - applying notification/RBAC table guarantees before exiting.");let traitMigrationError;if(options.auth!==!1)try{const{ensureUtcDatetimeColumns,ensureUtcTimestampDefaults,migrateNotificationTables,migrateRbacTables,migrateTraitTables}=await import("@stacksjs/database"),notifResult=await migrateNotificationTables({verbose:options.verbose});if(!notifResult.success)log.error(`Failed to migrate notification tables: ${notifResult.error}`);const rbacResult=await migrateRbacTables({verbose:options.verbose});if(!rbacResult.success)log.error(`Failed to migrate RBAC tables: ${rbacResult.error}`);const{migrateEnabledImpersonationTables}=await import("@stacksjs/auth/impersonation-store");await migrateEnabledImpersonationTables();const traitResult=await migrateTraitTables({verbose:options.verbose});if(!traitResult.success){traitMigrationError=traitResult.error||"Polymorphic trait table migration failed";log.error(`Failed to migrate polymorphic trait tables: ${traitMigrationError}`)}const datetimeResult=await ensureUtcDatetimeColumns({verbose:options.verbose});if(!datetimeResult.success)log.error(`Failed to convert TIMESTAMP columns to DATETIME: ${datetimeResult.error}`);const defaultsResult=await ensureUtcTimestampDefaults({verbose:options.verbose});if(!defaultsResult.success)log.error(`Failed to pin timestamp defaults to UTC: ${defaultsResult.error}`)}catch(error){traitMigrationError=error instanceof Error?error.message:String(error);log.error("Failed to migrate notification/RBAC/trait tables:",error)}if(resultFailed(result)||traitMigrationError){await outro("While running the migrate command, there was an issue",{startTime:perf,useSeconds:!0},resultFailed(result)?result.error:traitMigrationError);await log.flush();process.exit(ExitCode.FatalError)}if(options.auth!==!1)try{const{ensureUsersAuthColumns,sqlHelpers}=await import("@stacksjs/database"),driver=process.env.DB_CONNECTION||"sqlite";await ensureUsersAuthColumns(sqlHelpers(driver),{verbose:options.verbose})}catch(error){log.error("Failed to ensure users auth columns post-migration:",error)}try{const{ensureUuidColumns,sqlHelpers}=await import("@stacksjs/database"),driver=process.env.DB_CONNECTION||"sqlite";await ensureUuidColumns(sqlHelpers(driver),{verbose:options.verbose})}catch(error){log.error("Failed to ensure uuid columns post-migration:",error)}await addMissingEnumLabels();await reportMissingForeignKeys();const modelsMatch=await reportSchemaDrift(),tablesPresent=await reportMissingRecordedTables(),schemaMatches=modelsMatch&&tablesPresent,allCommittable=await reportIgnoredMigrations(),allGenerated=await reportHandWrittenMigrations({list:strictMode(options)});if(strictMode(options)&&(!schemaMatches||!allCommittable||!allGenerated)){console.error(`
20
20
  \u274C --strict: ${!allCommittable?"a migration is git-ignored":!allGenerated?"a migration was written by hand rather than generated from the models":"the live schema does not match the models"} (see above).
21
21
  `);process.exit(ExitCode.FatalError)}await reportFkOrphans();const APP_ENV=process.env.APP_ENV||"local",marker=readMigrateMarker(),authSuffix=options.auth!==!1?" (including auth tables)":"",applied=marker==null?`Migrated your ${APP_ENV} database.${authSuffix}`:marker.appliedCount===0?schemaMatches?`Nothing to migrate - your ${APP_ENV} database is already up to date.${authSuffix}`:`Nothing to migrate${authSuffix}.`:`Applied ${marker.appliedCount} migration${marker.appliedCount===1?"":"s"} to your ${APP_ENV} database.${authSuffix}`,outroMessage=schemaMatches?applied:`${applied} The database is still not in the state its migrations and models describe - see above.`;if(!schemaMatches)logDriftRemedy();await outro(outroMessage,{startTime:perf,useSeconds:!0,type:schemaMatches?"success":"warning"});process.exit(ExitCode.Success)});buddy.command("migrate:fresh",descriptions.fresh).alias("db:fresh").option("-d, --diff","Show what would be dropped and replayed, and change nothing",{default:!1}).option("--pretend","Same as --diff (and --dry-run): preview, change nothing",{default:!1}).option("--dry-run","Same as --diff: preview, change nothing",{default:!1}).option("-p, --project [project]",descriptions.project,{default:!1}).option("-s, --seed","Run database seeders after migration",{default:!1}).option("-a, --auth",descriptions.auth,{default:!0}).option("--no-auth","Skip auth/oauth table migrations").option("--create-database","Create the database if it does not exist, without asking",{default:!1}).option("-f, --force",'Skip the drop-database confirmation (only honored when the migrateFresh guard is "allow")',{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy migrate:fresh` ...",options);const perf=await intro("buddy migrate:fresh"),validation=validateModelsExist();if(!validation.valid){console.error(`
22
22
  \u274C Error: ${validation.error}
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.399",
5
+ "version": "0.75.400",
6
6
  "description": "Meet Buddy. The Stacks runtime.",
7
7
  "author": "Chris Breuer",
8
8
  "contributors": [
@@ -95,69 +95,69 @@
95
95
  "prepublishOnly": "bun run build"
96
96
  },
97
97
  "dependencies": {
98
- "@stacksjs/actions": "0.75.399",
99
- "@stacksjs/ai": "0.75.399",
100
- "@stacksjs/alias": "0.75.399",
101
- "@stacksjs/analytics": "0.75.399",
102
- "@stacksjs/api": "0.75.399",
103
- "@stacksjs/arrays": "0.75.399",
104
- "@stacksjs/auth": "0.75.399",
105
- "@stacksjs/browser-extension": "0.75.399",
106
- "@stacksjs/build": "0.75.399",
98
+ "@stacksjs/actions": "0.75.400",
99
+ "@stacksjs/ai": "0.75.400",
100
+ "@stacksjs/alias": "0.75.400",
101
+ "@stacksjs/analytics": "0.75.400",
102
+ "@stacksjs/api": "0.75.400",
103
+ "@stacksjs/arrays": "0.75.400",
104
+ "@stacksjs/auth": "0.75.400",
105
+ "@stacksjs/browser-extension": "0.75.400",
106
+ "@stacksjs/build": "0.75.400",
107
107
  "@stacksjs/bumpx": "^0.2.26",
108
- "@stacksjs/cache": "0.75.399",
109
- "@stacksjs/chat": "0.75.399",
108
+ "@stacksjs/cache": "0.75.400",
109
+ "@stacksjs/chat": "0.75.400",
110
110
  "@stacksjs/clapp": "^0.2.16",
111
- "@stacksjs/cli": "0.75.399",
112
- "@stacksjs/cloud": "0.75.399",
113
- "@stacksjs/cms": "0.75.399",
114
- "@stacksjs/collections": "0.75.399",
115
- "@stacksjs/commerce": "0.75.399",
116
- "@stacksjs/config": "0.75.399",
117
- "@stacksjs/database": "0.75.399",
118
- "@stacksjs/desktop-build": "0.75.399",
119
- "@stacksjs/dns": "0.75.399",
111
+ "@stacksjs/cli": "0.75.400",
112
+ "@stacksjs/cloud": "0.75.400",
113
+ "@stacksjs/cms": "0.75.400",
114
+ "@stacksjs/collections": "0.75.400",
115
+ "@stacksjs/commerce": "0.75.400",
116
+ "@stacksjs/config": "0.75.400",
117
+ "@stacksjs/database": "0.75.400",
118
+ "@stacksjs/desktop-build": "0.75.400",
119
+ "@stacksjs/dns": "0.75.400",
120
120
  "@stacksjs/dnsx": "^0.2.3",
121
- "@stacksjs/email": "0.75.399",
122
- "@stacksjs/enums": "0.75.399",
123
- "@stacksjs/env": "0.75.399",
124
- "@stacksjs/error-handling": "0.75.399",
125
- "@stacksjs/events": "0.75.399",
126
- "@stacksjs/features": "0.75.399",
127
- "@stacksjs/git": "0.75.399",
121
+ "@stacksjs/email": "0.75.400",
122
+ "@stacksjs/enums": "0.75.400",
123
+ "@stacksjs/env": "0.75.400",
124
+ "@stacksjs/error-handling": "0.75.400",
125
+ "@stacksjs/events": "0.75.400",
126
+ "@stacksjs/features": "0.75.400",
127
+ "@stacksjs/git": "0.75.400",
128
128
  "@stacksjs/gitit": "^0.2.5",
129
- "@stacksjs/health": "0.75.399",
129
+ "@stacksjs/health": "0.75.400",
130
130
  "@stacksjs/httx": "^0.1.10",
131
- "@stacksjs/image": "0.75.399",
132
- "@stacksjs/lint": "0.75.399",
133
- "@stacksjs/logging": "0.75.399",
134
- "@stacksjs/notifications": "0.75.399",
135
- "@stacksjs/objects": "0.75.399",
136
- "@stacksjs/orm": "0.75.399",
137
- "@stacksjs/path": "0.75.399",
138
- "@stacksjs/payments": "0.75.399",
139
- "@stacksjs/realtime": "0.75.399",
140
- "@stacksjs/router": "0.75.399",
131
+ "@stacksjs/image": "0.75.400",
132
+ "@stacksjs/lint": "0.75.400",
133
+ "@stacksjs/logging": "0.75.400",
134
+ "@stacksjs/notifications": "0.75.400",
135
+ "@stacksjs/objects": "0.75.400",
136
+ "@stacksjs/orm": "0.75.400",
137
+ "@stacksjs/path": "0.75.400",
138
+ "@stacksjs/payments": "0.75.400",
139
+ "@stacksjs/realtime": "0.75.400",
140
+ "@stacksjs/router": "0.75.400",
141
141
  "@stacksjs/rpx": "^0.11.54",
142
- "@stacksjs/scheduler": "0.75.399",
143
- "@stacksjs/search-engine": "0.75.399",
144
- "@stacksjs/security": "0.75.399",
145
- "@stacksjs/server": "0.75.399",
146
- "@stacksjs/sites": "0.75.399",
147
- "@stacksjs/skills": "0.75.399",
148
- "@stacksjs/socials": "0.75.399",
149
- "@stacksjs/storage": "0.75.399",
150
- "@stacksjs/strings": "0.75.399",
142
+ "@stacksjs/scheduler": "0.75.400",
143
+ "@stacksjs/search-engine": "0.75.400",
144
+ "@stacksjs/security": "0.75.400",
145
+ "@stacksjs/server": "0.75.400",
146
+ "@stacksjs/sites": "0.75.400",
147
+ "@stacksjs/skills": "0.75.400",
148
+ "@stacksjs/socials": "0.75.400",
149
+ "@stacksjs/storage": "0.75.400",
150
+ "@stacksjs/strings": "0.75.400",
151
151
  "@stacksjs/stx": "0.2.775",
152
- "@stacksjs/testing": "0.75.399",
153
- "@stacksjs/tinker": "0.75.399",
152
+ "@stacksjs/testing": "0.75.400",
153
+ "@stacksjs/tinker": "0.75.400",
154
154
  "@stacksjs/tlsx": "^0.13.19",
155
155
  "@stacksjs/ts-cloud": "^0.16.91",
156
- "@stacksjs/tunnel": "0.75.399",
157
- "@stacksjs/types": "0.75.399",
158
- "@stacksjs/ui": "0.75.399",
159
- "@stacksjs/utils": "0.75.399",
160
- "@stacksjs/validation": "0.75.399",
156
+ "@stacksjs/tunnel": "0.75.400",
157
+ "@stacksjs/types": "0.75.400",
158
+ "@stacksjs/ui": "0.75.400",
159
+ "@stacksjs/utils": "0.75.400",
160
+ "@stacksjs/validation": "0.75.400",
161
161
  "ajv": "^8.20.0",
162
162
  "ajv-formats": "^3.0.1",
163
163
  "bun-plugin-stx": "0.2.775",