@stacksjs/buddy 0.70.294 → 0.70.297
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/create.js +1 -1
- package/dist/commands/deploy.js +21 -2
- package/dist/commands/env.d.ts +0 -13
- package/dist/commands/env.js +1 -1
- package/dist/commands/migrate.js +20 -5
- package/dist/commands/publish.js +2 -2
- package/dist/production-server.d.ts +35 -1
- package/dist/production-server.js +1 -1
- package/package.json +43 -43
package/dist/commands/create.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{chmodSync,existsSync,readdirSync}from"node:fs";import process from"node:process";import{runAction}from"@stacksjs/actions";import{bold,cyan,dim,intro,log,onUnknownSubcommand,runCommand}from"@stacksjs/cli";import{Action}from"@stacksjs/enums";import{resolve}from"@stacksjs/path";import{isFolder}from"@stacksjs/storage";import{ExitCode}from"@stacksjs/types";import{uninstallAllFeatures}from"./features";import{ensurePantryDependencies,ensurePantryInstalled}from"./setup";import{resultFailed}from"../result";export function create(buddy){const descriptions={name:"The name of the project",command:"Create a new Stacks project",ui:"Are you building a UI?",components:"Are you building UI components?",webComponents:"Automagically built optimized custom elements/web components?",views:"How about views?",functions:"Are you developing functions/composables?",api:"Are you building an API?",database:"Do you need a database?",notifications:"Do you need notifications? e.g. email, SMS, push or chat notifications",cache:"Do you need caching?",email:"Do you need email?",project:"Target a specific project",minimal:"Skip optional feature bundles (cms, commerce, dashboard, marketing, monitoring, realtime, queue) \u2014 bare-bones API/SPA starter that can re-add them later via `./buddy <feature>:install`.",withCore:"Keep the framework vendored in `storage/framework/core` as a Bun workspace, for working ON Stacks. Apps that only work WITH Stacks want the default, which resolves every @stacksjs/* package from npm.",verbose:"Enable verbose output"};buddy.command("new [name]",descriptions.command).alias("create [name]").option("-n, --name [name]",descriptions.name,{default:!1}).option("-u, --ui",descriptions.ui,{default:!0}).option("-c, --components",descriptions.components,{default:!0}).option("-w, --web-components",descriptions.webComponents,{default:!0}).option("-p, --views",descriptions.views,{default:!0}).option("-f, --functions",descriptions.functions,{default:!0}).option("-a, --api",descriptions.api,{default:!0}).option("-d, --database",descriptions.database,{default:!0}).option("-ca, --cache",descriptions.cache,{default:!1}).option("-e, --email",descriptions.email,{default:!1}).option("-P, --project [project]",descriptions.project,{default:!1}).option("-m, --minimal",descriptions.minimal,{default:!1}).option("--with-core",descriptions.withCore,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(name,options)=>{log.debug("Running `buddy new <name>` ...",options);const startTime=await intro("buddy new");name=name??options.name;const path=resolve(process.cwd(),name);isFolderCheck(path);await onlineCheck();const result=await download(name,path,options);if(resultFailed(result)){log.error(result.error);process.exit(ExitCode.FatalError)}ensureExecutableScripts(path);await ensureEnv(path,options);await install(path,options);if(!options.withCore)await unvendorCore(path,options);if(options.minimal)await stripFeatures(path);if(startTime){const time=performance.now()-startTime;log.success(dim(`[${time.toFixed(2)}ms] Completed`))}log.info(bold("Welcome to the Stacks Framework! \u269B\uFE0F"));log.info(`Get started: ${cyan(`cd ${name}`)} and then ${cyan("./buddy dev")}`);log.info(`Run ${cyan("./buddy doctor")} anytime to check your setup`);log.info("To learn more, visit https://stacksjs.com");process.exit(ExitCode.Success)});onUnknownSubcommand(buddy,"new")}function isFolderCheck(path){if(!isFolder(path))return;if(readdirSync(path).filter((entry)=>entry!==".git").length===0)return;log.error(`Path ${path} already exists`);process.exit(ExitCode.FatalError)}async function onlineCheck(){if(await isOnline())return;log.info("It appears you are disconnected from the internet.");log.info("Creating a new project requires a brief internet connection to download the template and install dependencies.");process.exit(ExitCode.FatalError)}async function isOnline(){try{return(await fetch("https://github.com",{method:"HEAD",signal:AbortSignal.timeout(3000)})).ok}catch{return!1}}async function download(name,path,_options){log.info("Setting up your stack.");try{const{downloadTemplate}=await import("@stacksjs/gitit");await downloadTemplate("gh:stacksjs/stacks",{dir:name,force:!0});log.success(`Successfully scaffolded your project at ${cyan(path)}`);return{isErr:!1}}catch(error){return{isErr:!0,error:error instanceof Error?error.message:String(error)}}}function ensureExecutableScripts(path){for(const script of["buddy","bootstrap"])try{chmodSync(resolve(path,script),493)}catch{}}async function ensureEnv(path,_options){log.info("Ensuring your environment is ready...");await ensurePantryInstalled();await ensurePantryDependencies(path);log.success("Environment is ready")}async function install(path,options){log.info("Installing & setting up Stacks");log.info("Copying .env.example \u2192 .env");let result=await runCommand("cp .env.example .env",{...options,cwd:path});if(resultFailed(result)){log.error(result.error);process.exit(ExitCode.FatalError)}log.info("Removing template-encrypted env files...");const{rm}=await import("node:fs/promises");for(const stale of[".env.development",".env.staging",".env.production",".env.keys"])await rm(`${path}/${stale}`,{force:!0});log.info("Generating application key...");const keyResult=await runAction(Action.KeyGenerate,{...options,cwd:path});if(resultFailed(keyResult)){log.error(keyResult.error);process.exit(ExitCode.FatalError)}if(existsSync(resolve(path,".git")))log.info("Existing git repository detected, skipping git init");else{log.info("Initializing git repository...");result=await runCommand("git init",{...options,cwd:path});if(resultFailed(result)){log.error(result.error);process.exit(ExitCode.FatalError)}}log.success("Installed & set-up \uD83D\uDE80")}async function unvendorCore(path,options){log.info("Resolving the framework from npm (pass --with-core to keep it vendored)...");const result=await runCommand("./buddy unpublish:core --all --force",{...options,cwd:path});if(resultFailed(result)){log.error(result.error);process.exit(ExitCode.FatalError)}log.success("Framework resolved from npm")}async function stripFeatures(path){log.info("Stripping optional feature bundles (--minimal)...");const results=await uninstallAllFeatures({root:path});let strippedAny=!1;for(const{feature,configOutcome,filesRemoved}of results)if(configOutcome==="flipped"||filesRemoved.length>0){strippedAny=!0;const fileSummary=filesRemoved.length>0?` (${filesRemoved.length} path${filesRemoved.length===1?"":"s"})`:"";log.info(` - ${feature}${fileSummary}`)}if(!strippedAny)log.info(" \u2192 no feature scaffolding present; nothing to strip.");else log.success("Minimal skeleton ready \u2014 run `./buddy <feature>:install` to add features back.")}
|
|
1
|
+
import{chmodSync,cpSync,existsSync,readdirSync,rmSync}from"node:fs";import process from"node:process";import{runAction}from"@stacksjs/actions";import{bold,cyan,dim,intro,log,onUnknownSubcommand,runCommand}from"@stacksjs/cli";import{Action}from"@stacksjs/enums";import{resolve}from"@stacksjs/path";import{isFolder}from"@stacksjs/storage";import{ExitCode}from"@stacksjs/types";import{uninstallAllFeatures}from"./features";import{ensurePantryDependencies,ensurePantryInstalled}from"./setup";import{resultFailed}from"../result";export function create(buddy){const descriptions={name:"The name of the project",command:"Create a new Stacks project",ui:"Are you building a UI?",components:"Are you building UI components?",webComponents:"Automagically built optimized custom elements/web components?",views:"How about views?",functions:"Are you developing functions/composables?",api:"Are you building an API?",database:"Do you need a database?",notifications:"Do you need notifications? e.g. email, SMS, push or chat notifications",cache:"Do you need caching?",email:"Do you need email?",project:"Target a specific project",minimal:"Skip optional feature bundles (cms, commerce, dashboard, marketing, monitoring, realtime, queue) \u2014 bare-bones API/SPA starter that can re-add them later via `./buddy <feature>:install`.",withCore:"Keep the framework vendored in `storage/framework/core` as a Bun workspace, for working ON Stacks. Apps that only work WITH Stacks want the default, which resolves every @stacksjs/* package from npm.",verbose:"Enable verbose output"};buddy.command("new [name]",descriptions.command).alias("create [name]").option("-n, --name [name]",descriptions.name,{default:!1}).option("-u, --ui",descriptions.ui,{default:!0}).option("-c, --components",descriptions.components,{default:!0}).option("-w, --web-components",descriptions.webComponents,{default:!0}).option("-p, --views",descriptions.views,{default:!0}).option("-f, --functions",descriptions.functions,{default:!0}).option("-a, --api",descriptions.api,{default:!0}).option("-d, --database",descriptions.database,{default:!0}).option("-ca, --cache",descriptions.cache,{default:!1}).option("-e, --email",descriptions.email,{default:!1}).option("-P, --project [project]",descriptions.project,{default:!1}).option("-m, --minimal",descriptions.minimal,{default:!1}).option("--with-core",descriptions.withCore,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(name,options)=>{log.debug("Running `buddy new <name>` ...",options);const startTime=await intro("buddy new");name=name??options.name;const path=resolve(process.cwd(),name);isFolderCheck(path);await onlineCheck();const result=await download(name,path,options);if(resultFailed(result)){log.error(result.error);process.exit(ExitCode.FatalError)}ensureExecutableScripts(path);applyAppVcsTemplate(path);await ensureEnv(path,options);await install(path,options);if(!options.withCore)await unvendorCore(path,options);if(options.minimal)await stripFeatures(path);if(startTime){const time=performance.now()-startTime;log.success(dim(`[${time.toFixed(2)}ms] Completed`))}log.info(bold("Welcome to the Stacks Framework! \u269B\uFE0F"));log.info(`Get started: ${cyan(`cd ${name}`)} and then ${cyan("./buddy dev")}`);log.info(`Run ${cyan("./buddy doctor")} anytime to check your setup`);log.info("To learn more, visit https://stacksjs.com");process.exit(ExitCode.Success)});onUnknownSubcommand(buddy,"new")}function isFolderCheck(path){if(!isFolder(path))return;if(readdirSync(path).filter((entry)=>entry!==".git").length===0)return;log.error(`Path ${path} already exists`);process.exit(ExitCode.FatalError)}async function onlineCheck(){if(await isOnline())return;log.info("It appears you are disconnected from the internet.");log.info("Creating a new project requires a brief internet connection to download the template and install dependencies.");process.exit(ExitCode.FatalError)}async function isOnline(){try{return(await fetch("https://github.com",{method:"HEAD",signal:AbortSignal.timeout(3000)})).ok}catch{return!1}}async function download(name,path,_options){log.info("Setting up your stack.");try{const{downloadTemplate}=await import("@stacksjs/gitit");await downloadTemplate("gh:stacksjs/stacks",{dir:name,force:!0});log.success(`Successfully scaffolded your project at ${cyan(path)}`);return{isErr:!1}}catch(error){return{isErr:!0,error:error instanceof Error?error.message:String(error)}}}function applyAppVcsTemplate(path){const source=resolve(path,"storage/framework/defaults/vcs/github"),destination=resolve(path,".github");if(!existsSync(source)){log.warn("No app CI template found at storage/framework/defaults/vcs/github \u2014 leaving .github as downloaded.");return}log.info("Installing app-shaped GitHub workflows...");try{rmSync(destination,{recursive:!0,force:!0});cpSync(source,destination,{recursive:!0});log.success("App CI installed")}catch(error){log.warn(`Could not install the app CI template: ${error instanceof Error?error.message:String(error)}`)}}function ensureExecutableScripts(path){for(const script of["buddy","bootstrap"])try{chmodSync(resolve(path,script),493)}catch{}}async function ensureEnv(path,_options){log.info("Ensuring your environment is ready...");await ensurePantryInstalled();await ensurePantryDependencies(path);log.success("Environment is ready")}async function install(path,options){log.info("Installing & setting up Stacks");log.info("Copying .env.example \u2192 .env");let result=await runCommand("cp .env.example .env",{...options,cwd:path});if(resultFailed(result)){log.error(result.error);process.exit(ExitCode.FatalError)}log.info("Removing template-encrypted env files...");const{rm}=await import("node:fs/promises");for(const stale of[".env.development",".env.staging",".env.production",".env.keys"])await rm(`${path}/${stale}`,{force:!0});log.info("Generating application key...");const keyResult=await runAction(Action.KeyGenerate,{...options,cwd:path});if(resultFailed(keyResult)){log.error(keyResult.error);process.exit(ExitCode.FatalError)}if(existsSync(resolve(path,".git")))log.info("Existing git repository detected, skipping git init");else{log.info("Initializing git repository...");result=await runCommand("git init",{...options,cwd:path});if(resultFailed(result)){log.error(result.error);process.exit(ExitCode.FatalError)}}log.success("Installed & set-up \uD83D\uDE80")}async function unvendorCore(path,options){log.info("Resolving the framework from npm (pass --with-core to keep it vendored)...");const result=await runCommand("./buddy unpublish:core --all --force",{...options,cwd:path});if(resultFailed(result)){log.error(result.error);process.exit(ExitCode.FatalError)}log.success("Framework resolved from npm")}async function stripFeatures(path){log.info("Stripping optional feature bundles (--minimal)...");const results=await uninstallAllFeatures({root:path});let strippedAny=!1;for(const{feature,configOutcome,filesRemoved}of results)if(configOutcome==="flipped"||filesRemoved.length>0){strippedAny=!0;const fileSummary=filesRemoved.length>0?` (${filesRemoved.length} path${filesRemoved.length===1?"":"s"})`:"";log.info(` - ${feature}${fileSummary}`)}if(!strippedAny)log.info(" \u2192 no feature scaffolding present; nothing to strip.");else log.success("Minimal skeleton ready \u2014 run `./buddy <feature>:install` to add features back.")}
|
package/dist/commands/deploy.js
CHANGED
|
@@ -187,8 +187,27 @@ fi
|
|
|
187
187
|
# renewed in place, was actively harmful: each extra name made the renewal open
|
|
188
188
|
# another certificate file, and two files sharing a CN meant one overwrote the
|
|
189
189
|
# other.
|
|
190
|
+
#
|
|
191
|
+
# The CLI is resolved rather than hardcoded. This step used to run
|
|
192
|
+
# 'cd /opt/tlsx && bun run packages/tlsx/bin/cli.ts', which requires a source
|
|
193
|
+
# checkout at one exact path and silently does nothing when it is absent -
|
|
194
|
+
# there is no other way to satisfy the guard. On the box this was found on, that
|
|
195
|
+
# checkout had been pinned to a two-month-old tag nobody was updating, so the
|
|
196
|
+
# deploy kept invoking a build whose renewal logic had a known data-loss bug.
|
|
197
|
+
# Preferring an installed 'tlsx' on PATH lets a package manager own the version;
|
|
198
|
+
# the checkout stays as the fallback so existing boxes keep working.
|
|
199
|
+
tlsx_cli() {
|
|
200
|
+
if command -v tlsx >/dev/null 2>&1; then
|
|
201
|
+
tlsx "$@"
|
|
202
|
+
else
|
|
203
|
+
(cd /opt/tlsx && /usr/local/bin/bun run packages/tlsx/bin/cli.ts "$@")
|
|
204
|
+
fi
|
|
205
|
+
}
|
|
206
|
+
have_tlsx() {
|
|
207
|
+
command -v tlsx >/dev/null 2>&1 || { [ -x /usr/local/bin/bun ] && [ -f /opt/tlsx/packages/tlsx/bin/cli.ts ]; }
|
|
208
|
+
}
|
|
190
209
|
CERTFILE=/etc/bun-gateway/certs/mail.stacksjs.com.crt
|
|
191
|
-
if [ -n "$DOMAIN" ] && [ -f "$CERTFILE" ] &&
|
|
210
|
+
if [ -n "$DOMAIN" ] && [ -f "$CERTFILE" ] && have_tlsx; then
|
|
192
211
|
MAILHOSTNAME="mail.$DOMAIN"
|
|
193
212
|
CERTNAME=$(basename "$CERTFILE" .crt)
|
|
194
213
|
CERTKEY=/etc/bun-gateway/certs/mail.stacksjs.com.key
|
|
@@ -203,7 +222,7 @@ if [ -n "$DOMAIN" ] && [ -f "$CERTFILE" ] && [ -d /opt/tlsx ]; then
|
|
|
203
222
|
SAFE=$(mktemp -d)
|
|
204
223
|
cp -a "$CERTFILE" "$SAFE/cert" 2>/dev/null || true
|
|
205
224
|
cp -a "$CERTKEY" "$SAFE/key" 2>/dev/null || true
|
|
206
|
-
if
|
|
225
|
+
if tlsx_cli acme:issue -d "$ALL" --cert-name "$CERTNAME" --method http-01 --webroot /var/www/acme-challenge --dir /etc/bun-gateway/certs --prod >/tmp/.mailtenant-cert 2>&1; then
|
|
207
226
|
# Verify rather than assume. The old code reported CERTHOST on a zero
|
|
208
227
|
# exit alone, which is how an issuance that wrote somewhere else was
|
|
209
228
|
# reported as "added to the mail certificate" for weeks.
|
package/dist/commands/env.d.ts
CHANGED
|
@@ -1,16 +1,3 @@
|
|
|
1
1
|
import type { CLI } from '@stacksjs/types';
|
|
2
2
|
export declare function env(buddy: CLI): void;
|
|
3
|
-
/**
|
|
4
|
-
* Parses `KEY=value` assignments out of a dotenv file into a flat map.
|
|
5
|
-
*
|
|
6
|
-
* Deliberately tolerant of what real `.env` files contain: comments, blank
|
|
7
|
-
* lines, `export ` prefixes, quoted values, and - the case that matters here -
|
|
8
|
-
* dotenvx ciphertext, which wraps across many lines inside its quotes. A naive
|
|
9
|
-
* `split('\n')` treats each wrapped fragment as its own line and loses the key
|
|
10
|
-
* it belongs to.
|
|
11
|
-
*
|
|
12
|
-
* Values are returned raw (still encrypted, if they were). `env:check` only
|
|
13
|
-
* needs the key names and whether a value is non-empty, so nothing here has to
|
|
14
|
-
* decrypt - which also means it works without the private key present.
|
|
15
|
-
*/
|
|
16
3
|
export declare function parseEnvAssignments(content: string): Record<string, string>;
|
package/dist/commands/env.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import process from"node:process";import{log,onUnknownSubcommand}from"@stacksjs/cli";import{decryptEnv,encryptEnv,getEnv,getKeypair,resolveEnvFile,rotateKeypair,setEnv}from"@stacksjs/env";import{ExitCode}from"@stacksjs/types";export function env(buddy){const descriptions={env:"Interact with your environment variables",get:"Get an environment variable",set:"Set an environment variable",encrypt:"Encrypt a value",decrypt:"Decrypt a value",check:"Check environment configuration and validate setup",pretty:"Pretty print the result",stdout:"Output the result to stdout",keypair:"Generate a keypair",fileKeys:"The path to the file containing the keys",excludeKey:"The key to exclude from encryption",rotate:"Rotate a keypair",format:"The format to output the result (json, shell, eval)",all:"Get all environment variables",file:"The environment file to use",project:"Target a specific project",verbose:"Enable verbose output"};buddy.command("env:get [key]",descriptions.get).option("-f, --file [file]",descriptions.file,{default:""}).option("--format [format]",descriptions.format,{default:"json"}).option("-a, --all",descriptions.all,{default:!1}).option("-p, --pretty",descriptions.pretty,{default:!1}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).example("buddy env:get SECRET").example("buddy env:get SECRET --file .env.production").example("buddy env:get --all --pretty").example("buddy env:get --format shell").example("buddy env:get --format eval").example("buddy env:get --format json").action(async(key,options)=>{log.debug("Running `buddy env:get` ...",options);const result=getEnv(key,{file:resolveEnvFile(options.file,options.env),keysFile:options.fileKeys,all:options.all,format:options.format,prettyPrint:options.pretty});if(result.success){console.log(result.output);process.exit(ExitCode.Success)}else{console.error(result.error);process.exit(ExitCode.FatalError)}});buddy.command("env:set [key] [value]",descriptions.set).option("-f, --file [file]",descriptions.file,{default:""}).option("-fk, --file-keys [fileKeys]",descriptions.fileKeys,{default:""}).option("--plain","Don't encrypt the value",{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).example("buddy env:set SECRET=value").example("buddy env:set SECRET value").example("buddy env:set SECRET value --file .env.production").example("buddy env:set SECRET value --plain").action(async(key,value,options)=>{log.debug("Running `buddy env:set` ...",options);if(!key||!value){console.error("Both key and value are required");process.exit(ExitCode.FatalError)}const result=setEnv(key,value,{file:resolveEnvFile(options.file,options.env),keysFile:options.fileKeys,plain:options.plain});if(result.success){console.log(result.output);process.exit(ExitCode.Success)}else{console.error(result.error);process.exit(ExitCode.FatalError)}});buddy.command("env:encrypt [key]",descriptions.encrypt).option("-f, --file [file]",descriptions.file,{default:""}).option("-fk, --file-keys [fileKeys]",descriptions.fileKeys,{default:""}).option("-o, --stdout",descriptions.stdout,{default:!1}).option("-ek, --exclude-key [excludeKey]",descriptions.excludeKey,{default:""}).example("buddy env:encrypt").example("buddy env:encrypt --file .env.production").example('buddy env:encrypt -k "SECRET_*"').action(async(key,options)=>{log.debug("Running `buddy env:encrypt` ...",options);const result=encryptEnv({file:resolveEnvFile(options.file,options.env),keysFile:options.fileKeys,key,excludeKey:options.excludeKey,stdout:options.stdout});if(result.success){console.log(result.output);process.exit(ExitCode.Success)}else{console.error(result.error);process.exit(ExitCode.FatalError)}});buddy.command("env:decrypt [key]",descriptions.decrypt).option("-f, --file [file]",descriptions.file,{default:""}).option("-fk, --file-keys [fileKeys]",descriptions.fileKeys,{default:""}).option("-o, --stdout",descriptions.stdout,{default:!1}).example("buddy env:decrypt").example("buddy env:decrypt --file .env.production").example('buddy env:decrypt -k "SECRET_*"').action(async(key,options)=>{log.debug("Running `buddy env:decrypt` ...",options);const result=decryptEnv({file:resolveEnvFile(options.file,options.env),keysFile:options.fileKeys,key,stdout:options.stdout});if(result.success){console.log(result.output);process.exit(ExitCode.Success)}else{console.error(result.error);process.exit(ExitCode.FatalError)}});buddy.command("env:keypair [key]",descriptions.keypair).option("-f, --file [file]",descriptions.file,{default:""}).option("-fk, --file-keys [fileKeys]",descriptions.fileKeys,{default:""}).option("--format [format]",descriptions.format,{default:"json"}).example("buddy env:keypair").example("buddy env:keypair --file .env.production").example("buddy env:keypair DOTENV_PRIVATE_KEY").action(async(key,options)=>{log.debug("Running `buddy env:keypair` ...",options);const result=getKeypair(key,{file:resolveEnvFile(options.file,options.env),keysFile:options.fileKeys,format:options.format});if(result.success){console.log(result.output);process.exit(ExitCode.Success)}else{console.error(result.error);process.exit(ExitCode.FatalError)}});buddy.command("env:rotate [key]",descriptions.rotate).option("-f, --file [file]",descriptions.file,{default:""}).option("-fk, --file-keys [fileKeys]",descriptions.fileKeys,{default:""}).option("-o, --stdout",descriptions.stdout,{default:!1}).option("-ek, --exclude-key [excludeKey]",descriptions.excludeKey,{default:""}).example("buddy env:rotate").example("buddy env:rotate --file .env.production").action(async(key,options)=>{log.debug("Running `buddy env:rotate` ...",options);const result=rotateKeypair({file:resolveEnvFile(options.file,options.env),keysFile:options.fileKeys,key,excludeKey:options.excludeKey,stdout:options.stdout});if(result.success){console.log(result.output);process.exit(ExitCode.Success)}else{console.error(result.error);process.exit(ExitCode.FatalError)}});buddy.command("env:check",descriptions.check).option("-f, --file [file]",descriptions.file,{default:""}).option("--verbose",descriptions.verbose,{default:!1}).example("buddy env:check").example("buddy env:check --file .env.production").action(async(options)=>{log.debug("Running `buddy env:check` ...",options);const{bold,dim,green,red,yellow,intro}=await import("@stacksjs/cli"),{storage}=await import("@stacksjs/storage"),{existsSync}=await import("node:fs"),{resolve}=await import("node:path");await intro("buddy env:check");const checks=[],envFile=options.file||".env",envPath=resolve(process.cwd(),envFile);if(existsSync(envPath)){checks.push({name:`${envFile} file`,status:"pass",message:"Found"});try{const envContent=await storage.readTextFile(envPath),contentStr=typeof envContent==="string"?envContent:envContent.data,values=parseEnvAssignments(contentStr),keys=Object.keys(values),varCount=keys.length;checks.push({name:"Environment variables",status:"pass",message:`${varCount} variables defined`});if("APP_KEY"in values)if((values.APP_KEY??"").length>0)checks.push({name:"APP_KEY",status:"pass",message:"Set"});else checks.push({name:"APP_KEY",status:"warn",message:"Empty (run: buddy key:generate)"});else checks.push({name:"APP_KEY",status:"warn",message:"Not found (run: buddy key:generate)"});const hasPublicKey=keys.some((key)=>key.startsWith("DOTENV_PUBLIC_KEY")),hasPrivateKey=keys.some((key)=>key.startsWith("DOTENV_PRIVATE_KEY"))||existsSync(resolve(process.cwd(),".env.keys"));if(hasPublicKey&&hasPrivateKey)checks.push({name:"Encryption keys",status:"pass",message:"Public and private keys configured"});else if(hasPublicKey||hasPrivateKey)checks.push({name:"Encryption keys",status:"warn",message:"Incomplete keypair (run: buddy env:keypair)"});else checks.push({name:"Encryption keys",status:"warn",message:"Not configured (optional)"});const declaredTenants=await resolveDeclaredTenants();if(declaredTenants.tenants.length===0)checks.push({name:"Tenant isolation",status:"pass",message:"No tenants declared (cloud.tenants)"});else{const{foreignTenantKeys,partitionTenantEnv}=await import("@stacksjs/env"),foreign=foreignTenantKeys(partitionTenantEnv(values,declaredTenants)),isShipped=/^\.env\.[a-z]+$/.test(envFile),total=foreign.reduce((sum,entry)=>sum+entry.keys.length,0);if(foreign.length===0)checks.push({name:"Tenant isolation",status:"pass",message:`No foreign keys (checked ${declaredTenants.tenants.join(", ")})`});else if(!isShipped)checks.push({name:"Tenant isolation",status:"pass",message:`${total} archived key(s) \u2014 ${envFile} is local-only and never deployed`});else{checks.push({name:"Tenant isolation",status:"warn",message:`${total} key(s) belong to another tenant \u2014 move them to .env`});for(const{tenant,keys}of foreign)checks.push({name:` ${tenant}`,status:"warn",message:keys.join(", ")})}}}catch(error){checks.push({name:`${envFile} content`,status:"fail",message:`Cannot read file: ${error}`})}}else checks.push({name:`${envFile} file`,status:"fail",message:"Not found"});const keysPath=resolve(process.cwd(),".env.keys");if(existsSync(keysPath))checks.push({name:".env.keys file",status:"pass",message:"Found"});else checks.push({name:".env.keys file",status:"warn",message:"Not found (optional for encryption)"});console.log("");console.log(bold("Environment Configuration Check:"));console.log(dim("\u2500".repeat(60)));console.log("");let hasFailures=!1,hasWarnings=!1;for(const check of checks){let statusIcon="",statusColor=(text)=>text;if(check.status==="pass"){statusIcon="\u2713";statusColor=green}else if(check.status==="warn"){statusIcon="\u26A0";statusColor=yellow;hasWarnings=!0}else{statusIcon="\u2717";statusColor=red;hasFailures=!0}console.log(`${statusColor(statusIcon)} ${bold(check.name.padEnd(25))} ${dim(check.message)}`)}console.log("");console.log(dim("\u2500".repeat(60)));console.log("");if(hasFailures){console.log(red("\u2717 Some critical checks failed. Please address the issues above."));process.exit(ExitCode.FatalError)}else if(hasWarnings){console.log(yellow("\u26A0 Some checks have warnings. Your environment should work but may have issues."));process.exit(ExitCode.Success)}else{console.log(green("\u2713 All checks passed! Your environment configuration looks healthy."));process.exit(ExitCode.Success)}});onUnknownSubcommand(buddy,"env")}async function resolveDeclaredTenants(){try{const{config}=await import("@stacksjs/config"),cloud=config.cloud,app=config.app,tenants=Array.isArray(cloud?.tenants)?cloud.tenants.filter((slug)=>typeof slug==="string"):[];return{self:app?.name,tenants}}catch{return{tenants:[]}}}export function parseEnvAssignments(content){const values={},assignment=/^\s*(?:export\s+)?([A-Z_][A-Z0-9_]*)\s*=\s*(.*)$/i,lines=content.split(/\r?\n/);for(let i=0;i<lines.length;i++){const line=lines[i];if(!line.trim()||line.trimStart().startsWith("#"))continue;const match=line.match(assignment);if(!match)continue;const[,key,first]=match;let raw=first.trim();const quote=raw[0]==='"'||raw[0]==="'"?raw[0]:void 0;if(quote){raw=raw.slice(1);while(!raw.endsWith(quote)&&i+1<lines.length){i++;raw+=lines[i]}raw=raw.endsWith(quote)?raw.slice(0,-1):raw}else raw=raw.replace(/\s+#.*$/,"").trim();values[key]=raw}return values}
|
|
1
|
+
import process from"node:process";import{log,onUnknownSubcommand}from"@stacksjs/cli";import{decryptEnv,encryptEnv,getEnv,getKeypair,resolveEnvFile,rotateKeypair,setEnv}from"@stacksjs/env";import{ExitCode}from"@stacksjs/types";export function env(buddy){const descriptions={env:"Interact with your environment variables",get:"Get an environment variable",set:"Set an environment variable",encrypt:"Encrypt a value",decrypt:"Decrypt a value",check:"Check environment configuration and validate setup",pretty:"Pretty print the result",stdout:"Output the result to stdout",keypair:"Generate a keypair",fileKeys:"The path to the file containing the keys",excludeKey:"The key to exclude from encryption",rotate:"Rotate a keypair",format:"The format to output the result (json, shell, eval)",all:"Get all environment variables",file:"The environment file to use",project:"Target a specific project",verbose:"Enable verbose output"};buddy.command("env:get [key]",descriptions.get).option("-f, --file [file]",descriptions.file,{default:""}).option("--format [format]",descriptions.format,{default:"json"}).option("-a, --all",descriptions.all,{default:!1}).option("-p, --pretty",descriptions.pretty,{default:!1}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).example("buddy env:get SECRET").example("buddy env:get SECRET --file .env.production").example("buddy env:get --all --pretty").example("buddy env:get --format shell").example("buddy env:get --format eval").example("buddy env:get --format json").action(async(key,options)=>{log.debug("Running `buddy env:get` ...",options);const result=getEnv(key,{file:resolveEnvFile(options.file,options.env),keysFile:options.fileKeys,all:options.all,format:options.format,prettyPrint:options.pretty});if(result.success){console.log(result.output);process.exit(ExitCode.Success)}else{console.error(result.error);process.exit(ExitCode.FatalError)}});buddy.command("env:set [key] [value]",descriptions.set).option("-f, --file [file]",descriptions.file,{default:""}).option("-fk, --file-keys [fileKeys]",descriptions.fileKeys,{default:""}).option("--plain","Don't encrypt the value",{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).example("buddy env:set SECRET=value").example("buddy env:set SECRET value").example("buddy env:set SECRET value --file .env.production").example("buddy env:set SECRET value --plain").action(async(key,value,options)=>{log.debug("Running `buddy env:set` ...",options);if(!key||!value){console.error("Both key and value are required");process.exit(ExitCode.FatalError)}const result=setEnv(key,value,{file:resolveEnvFile(options.file,options.env),keysFile:options.fileKeys,plain:options.plain});if(result.success){console.log(result.output);process.exit(ExitCode.Success)}else{console.error(result.error);process.exit(ExitCode.FatalError)}});buddy.command("env:encrypt [key]",descriptions.encrypt).option("-f, --file [file]",descriptions.file,{default:""}).option("-fk, --file-keys [fileKeys]",descriptions.fileKeys,{default:""}).option("-o, --stdout",descriptions.stdout,{default:!1}).option("-ek, --exclude-key [excludeKey]",descriptions.excludeKey,{default:""}).example("buddy env:encrypt").example("buddy env:encrypt --file .env.production").example('buddy env:encrypt -k "SECRET_*"').action(async(key,options)=>{log.debug("Running `buddy env:encrypt` ...",options);const result=encryptEnv({file:resolveEnvFile(options.file,options.env),keysFile:options.fileKeys,key,excludeKey:options.excludeKey,stdout:options.stdout});if(result.success){console.log(result.output);process.exit(ExitCode.Success)}else{console.error(result.error);process.exit(ExitCode.FatalError)}});buddy.command("env:decrypt [key]",descriptions.decrypt).option("-f, --file [file]",descriptions.file,{default:""}).option("-fk, --file-keys [fileKeys]",descriptions.fileKeys,{default:""}).option("-o, --stdout",descriptions.stdout,{default:!1}).example("buddy env:decrypt").example("buddy env:decrypt --file .env.production").example('buddy env:decrypt -k "SECRET_*"').action(async(key,options)=>{log.debug("Running `buddy env:decrypt` ...",options);const result=decryptEnv({file:resolveEnvFile(options.file,options.env),keysFile:options.fileKeys,key,stdout:options.stdout});if(result.success){console.log(result.output);process.exit(ExitCode.Success)}else{console.error(result.error);process.exit(ExitCode.FatalError)}});buddy.command("env:keypair [key]",descriptions.keypair).option("-f, --file [file]",descriptions.file,{default:""}).option("-fk, --file-keys [fileKeys]",descriptions.fileKeys,{default:""}).option("--format [format]",descriptions.format,{default:"json"}).example("buddy env:keypair").example("buddy env:keypair --file .env.production").example("buddy env:keypair DOTENV_PRIVATE_KEY").action(async(key,options)=>{log.debug("Running `buddy env:keypair` ...",options);const result=getKeypair(key,{file:resolveEnvFile(options.file,options.env),keysFile:options.fileKeys,format:options.format});if(result.success){console.log(result.output);process.exit(ExitCode.Success)}else{console.error(result.error);process.exit(ExitCode.FatalError)}});buddy.command("env:rotate [key]",descriptions.rotate).option("-f, --file [file]",descriptions.file,{default:""}).option("-fk, --file-keys [fileKeys]",descriptions.fileKeys,{default:""}).option("-o, --stdout",descriptions.stdout,{default:!1}).option("-ek, --exclude-key [excludeKey]",descriptions.excludeKey,{default:""}).example("buddy env:rotate").example("buddy env:rotate --file .env.production").action(async(key,options)=>{log.debug("Running `buddy env:rotate` ...",options);const result=rotateKeypair({file:resolveEnvFile(options.file,options.env),keysFile:options.fileKeys,key,excludeKey:options.excludeKey,stdout:options.stdout});if(result.success){console.log(result.output);process.exit(ExitCode.Success)}else{console.error(result.error);process.exit(ExitCode.FatalError)}});buddy.command("env:check",descriptions.check).option("-f, --file [file]",descriptions.file,{default:""}).option("--strict","Require every value in a committed env file to be encrypted, not just secret-shaped ones",{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).example("buddy env:check").example("buddy env:check --file .env.production").example("buddy env:check --file .env.production --strict").action(async(options)=>{log.debug("Running `buddy env:check` ...",options);const{bold,dim,green,red,yellow,intro}=await import("@stacksjs/cli"),{storage}=await import("@stacksjs/storage"),{existsSync}=await import("node:fs"),{resolve}=await import("node:path");await intro("buddy env:check");const checks=[],envFile=options.file||".env",envPath=resolve(process.cwd(),envFile);if(existsSync(envPath)){checks.push({name:`${envFile} file`,status:"pass",message:"Found"});try{const envContent=await storage.readTextFile(envPath),contentStr=typeof envContent==="string"?envContent:envContent.data,values=parseEnvAssignments(contentStr),keys=Object.keys(values),varCount=keys.length;checks.push({name:"Environment variables",status:"pass",message:`${varCount} variables defined`});if("APP_KEY"in values)if((values.APP_KEY??"").length>0)checks.push({name:"APP_KEY",status:"pass",message:"Set"});else checks.push({name:"APP_KEY",status:"warn",message:"Empty (run: buddy key:generate)"});else checks.push({name:"APP_KEY",status:"warn",message:"Not found (run: buddy key:generate)"});const hasPublicKey=keys.some((key)=>key.startsWith("DOTENV_PUBLIC_KEY")),hasPrivateKey=keys.some((key)=>key.startsWith("DOTENV_PRIVATE_KEY"))||existsSync(resolve(process.cwd(),".env.keys"));if(hasPublicKey&&hasPrivateKey)checks.push({name:"Encryption keys",status:"pass",message:"Public and private keys configured"});else if(hasPublicKey||hasPrivateKey)checks.push({name:"Encryption keys",status:"warn",message:"Incomplete keypair (run: buddy env:keypair)"});else checks.push({name:"Encryption keys",status:"warn",message:"Not configured (optional)"});const declaredTenants=await resolveDeclaredTenants();if(declaredTenants.tenants.length===0)checks.push({name:"Tenant isolation",status:"pass",message:"No tenants declared (cloud.tenants)"});else{const{foreignTenantKeys,partitionTenantEnv}=await import("@stacksjs/env"),foreign=foreignTenantKeys(partitionTenantEnv(values,declaredTenants)),isShipped=/^\.env\.[a-z]+$/.test(envFile),total=foreign.reduce((sum,entry)=>sum+entry.keys.length,0);if(foreign.length===0)checks.push({name:"Tenant isolation",status:"pass",message:`No foreign keys (checked ${declaredTenants.tenants.join(", ")})`});else if(!isShipped)checks.push({name:"Tenant isolation",status:"pass",message:`${total} archived key(s) \u2014 ${envFile} is local-only and never deployed`});else{checks.push({name:"Tenant isolation",status:"warn",message:`${total} key(s) belong to another tenant \u2014 move them to .env`});for(const{tenant,keys}of foreign)checks.push({name:` ${tenant}`,status:"warn",message:keys.join(", ")})}}const{plaintextSecrets,trackedEnvFiles}=await import("@stacksjs/env");if(!trackedEnvFiles(gitLsFiles()).includes(envFile))checks.push({name:"Committed secrets",status:"pass",message:`${envFile} is not committed; plaintext here stays local`});else{let placeholders={};try{const examplePath=resolve(process.cwd(),".env.example");if(existsSync(examplePath))placeholders=parseEnvAssignments(await storage.readTextFile(examplePath).then((f)=>f.data))}catch{}const leaked=plaintextSecrets(values,{placeholders,strict:options.strict});if(leaked.length===0)checks.push({name:"Committed secrets",status:"pass",message:`No unencrypted secrets in ${envFile}`});else checks.push({name:"Committed secrets",status:"fail",message:`${leaked.length} unencrypted secret${leaked.length===1?"":"s"} in committed ${envFile}: ${leaked.map((f)=>f.key).join(", ")}. Run \`buddy env:encrypt\`, then rotate them \u2014 they are in git history.`})}}catch(error){checks.push({name:`${envFile} content`,status:"fail",message:`Cannot read file: ${error}`})}}else checks.push({name:`${envFile} file`,status:"fail",message:"Not found"});const keysPath=resolve(process.cwd(),".env.keys");if(existsSync(keysPath))checks.push({name:".env.keys file",status:"pass",message:"Found"});else checks.push({name:".env.keys file",status:"warn",message:"Not found (optional for encryption)"});console.log("");console.log(bold("Environment Configuration Check:"));console.log(dim("\u2500".repeat(60)));console.log("");let hasFailures=!1,hasWarnings=!1;for(const check of checks){let statusIcon="",statusColor=(text)=>text;if(check.status==="pass"){statusIcon="\u2713";statusColor=green}else if(check.status==="warn"){statusIcon="\u26A0";statusColor=yellow;hasWarnings=!0}else{statusIcon="\u2717";statusColor=red;hasFailures=!0}console.log(`${statusColor(statusIcon)} ${bold(check.name.padEnd(25))} ${dim(check.message)}`)}console.log("");console.log(dim("\u2500".repeat(60)));console.log("");if(hasFailures){console.log(red("\u2717 Some critical checks failed. Please address the issues above."));process.exit(ExitCode.FatalError)}else if(hasWarnings){console.log(yellow("\u26A0 Some checks have warnings. Your environment should work but may have issues."));process.exit(ExitCode.Success)}else{console.log(green("\u2713 All checks passed! Your environment configuration looks healthy."));process.exit(ExitCode.Success)}});onUnknownSubcommand(buddy,"env")}async function resolveDeclaredTenants(){try{const{config}=await import("@stacksjs/config"),cloud=config.cloud,app=config.app,tenants=Array.isArray(cloud?.tenants)?cloud.tenants.filter((slug)=>typeof slug==="string"):[];return{self:app?.name,tenants}}catch{return{tenants:[]}}}function gitLsFiles(){try{const result=Bun.spawnSync(["git","ls-files"],{cwd:process.cwd(),stdout:"pipe",stderr:"ignore"});return result.success?result.stdout.toString():""}catch{return""}}export function parseEnvAssignments(content){const values={},assignment=/^\s*(?:export\s+)?([A-Z_][A-Z0-9_]*)\s*=\s*(.*)$/i,lines=content.split(/\r?\n/);for(let i=0;i<lines.length;i++){const line=lines[i];if(!line.trim()||line.trimStart().startsWith("#"))continue;const match=line.match(assignment);if(!match)continue;const[,key,first]=match;let raw=first.trim();const quote=raw[0]==='"'||raw[0]==="'"?raw[0]:void 0;if(quote){raw=raw.slice(1);while(!raw.endsWith(quote)&&i+1<lines.length){i++;raw+=lines[i]}raw=raw.endsWith(quote)?raw.slice(0,-1):raw}else raw=raw.replace(/\s+#.*$/,"").trim();values[key]=raw}return values}
|
package/dist/commands/migrate.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import{closeSync,existsSync,mkdirSync,openSync,readdirSync,readFileSync,rmSync}from"node:fs";import process from"node:process";import{confirm,intro,log,onUnknownSubcommand,outro,text}from"@stacksjs/cli";import{Action}from"@stacksjs/enums";import{hasTTY,isCI}from"@stacksjs/env";import{appPath,frameworkPath,frameworkRuntimePath,projectPath}from"@stacksjs/path";import{ExitCode}from"@stacksjs/types";import{preflightDatabase}from"../database-preflight";import{DDL_CONSTRAINT_OVERRIDE_ENV,DIALECT_OVERRIDE_ENV,auditDdlConstraints,auditMigrationCorpus,dialectCapabilities,formatDdlConstraintError,formatMigrationDialectError,relativeMigrationDirectory,resolveMigrationDirectory,stripSqlNoise}from"@stacksjs/database";import{resultFailed}from"../result";let _runAction;async function runAction(...args){if(!_runAction)_runAction=(await import("@stacksjs/actions")).runAction;return _runAction(...args)}function acquireMigrationLock(){const lockDir=frameworkRuntimePath(),lockFile=`${lockDir}/migrations.lock`;try{if(!existsSync(lockDir))mkdirSync(lockDir,{recursive:!0});const fd=openSync(lockFile,"wx");try{closeSync(fd)}catch{}return{acquired:!0,release:()=>{try{rmSync(lockFile,{force:!0})}catch{}}}}catch{return{acquired:!1,release:()=>{}}}}async function ensureDatabaseOrExit(){try{const{ensureDatabaseReady}=await import("@stacksjs/database");await ensureDatabaseReady()}catch(error){log.syncError(error instanceof Error?error.message:String(error));process.exit(ExitCode.FatalError)}}function readMigrateMarker(){const file=frameworkRuntimePath("last-migrate-result.json");if(!existsSync(file))return null;try{const raw=readFileSync(file,"utf8"),parsed=JSON.parse(raw),n=typeof parsed.appliedCount==="number"?parsed.appliedCount:null;if(n===null||!Number.isFinite(n))return null;return{appliedCount:Math.max(0,Math.floor(n))}}catch{return null}finally{try{rmSync(file,{force:!0})}catch{}}}function countModelFiles(dir){if(!existsSync(dir))return 0;let count=0;const entries=readdirSync(dir,{withFileTypes:!0});for(const entry of entries)if(entry.isDirectory())count+=countModelFiles(`${dir}/${entry.name}`);else if(entry.name.endsWith(".ts")&&!entry.name.startsWith(".")&&!entry.name.startsWith("index"))count++;return count}function validateModelsExist(){const userModelsPath=appPath("Models"),defaultModelsPath=frameworkPath("defaults/app/Models"),userModelCount=countModelFiles(userModelsPath),defaultModelCount=countModelFiles(defaultModelsPath);if(userModelCount===0&&defaultModelCount===0)return{valid:!1,error:"No models found. Please create models in app/Models or ensure framework defaults exist."};return{valid:!0}}export function validateMigrationDialect(cwd=process.cwd()){const driver=String(process.env.DB_CONNECTION||"sqlite").toLowerCase(),dir=resolveMigrationDirectory(driver,{cwd}),relativeDir=relativeMigrationDirectory(dir,cwd);if(process.env[DIALECT_OVERRIDE_ENV]!=="1"){const caps=dialectCapabilities(driver),target=caps.wire==="mysql"?"mysql":caps.wire==="postgres"?"postgres":"sqlite";if(driver==="sqlite"||driver==="postgres"||caps.wire==="mysql"){const audit=auditMigrationCorpus({dir,target});if(!audit.empty&&audit.incompatible.length>0)return{valid:!1,error:formatMigrationDialectError(audit,target,relativeDir)}}}if(process.env[DDL_CONSTRAINT_OVERRIDE_ENV]!=="1"){const constraints=auditDdlConstraints({dir,dialect:driver});if(!constraints.empty&&constraints.violations.length>0)return{valid:!1,error:formatDdlConstraintError(constraints,driver,relativeDir)}}return{valid:!0}}async function reportMissingForeignKeys(){try{const{auditForeignKeys}=await import("@stacksjs/database"),result=await auditForeignKeys();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(`
|
|
1
|
+
import{closeSync,existsSync,mkdirSync,openSync,readdirSync,readFileSync,rmSync}from"node:fs";import{relative}from"node:path";import process from"node:process";import{confirm,intro,log,onUnknownSubcommand,outro,text}from"@stacksjs/cli";import{Action}from"@stacksjs/enums";import{hasTTY,isCI}from"@stacksjs/env";import{appPath,frameworkPath,frameworkRuntimePath,projectPath}from"@stacksjs/path";import{ExitCode}from"@stacksjs/types";import{preflightDatabase}from"../database-preflight";import{DDL_CONSTRAINT_OVERRIDE_ENV,DIALECT_OVERRIDE_ENV,auditDdlConstraints,auditMigrationCorpus,dialectCapabilities,formatDdlConstraintError,formatMigrationDialectError,relativeMigrationDirectory,resolveMigrationDirectory,stripSqlNoise}from"@stacksjs/database";import{resultFailed}from"../result";let _runAction;async function runAction(...args){if(!_runAction)_runAction=(await import("@stacksjs/actions")).runAction;return _runAction(...args)}function acquireMigrationLock(){const lockDir=frameworkRuntimePath(),lockFile=`${lockDir}/migrations.lock`;try{if(!existsSync(lockDir))mkdirSync(lockDir,{recursive:!0});const fd=openSync(lockFile,"wx");try{closeSync(fd)}catch{}return{acquired:!0,release:()=>{try{rmSync(lockFile,{force:!0})}catch{}}}}catch{return{acquired:!1,release:()=>{}}}}async function ensureDatabaseOrExit(){try{const{ensureDatabaseReady}=await import("@stacksjs/database");await ensureDatabaseReady()}catch(error){log.syncError(error instanceof Error?error.message:String(error));process.exit(ExitCode.FatalError)}}function readMigrateMarker(){const file=frameworkRuntimePath("last-migrate-result.json");if(!existsSync(file))return null;try{const raw=readFileSync(file,"utf8"),parsed=JSON.parse(raw),n=typeof parsed.appliedCount==="number"?parsed.appliedCount:null;if(n===null||!Number.isFinite(n))return null;return{appliedCount:Math.max(0,Math.floor(n))}}catch{return null}finally{try{rmSync(file,{force:!0})}catch{}}}function countModelFiles(dir){if(!existsSync(dir))return 0;let count=0;const entries=readdirSync(dir,{withFileTypes:!0});for(const entry of entries)if(entry.isDirectory())count+=countModelFiles(`${dir}/${entry.name}`);else if(entry.name.endsWith(".ts")&&!entry.name.startsWith(".")&&!entry.name.startsWith("index"))count++;return count}function validateModelsExist(){const userModelsPath=appPath("Models"),defaultModelsPath=frameworkPath("defaults/app/Models"),userModelCount=countModelFiles(userModelsPath),defaultModelCount=countModelFiles(defaultModelsPath);if(userModelCount===0&&defaultModelCount===0)return{valid:!1,error:"No models found. Please create models in app/Models or ensure framework defaults exist."};return{valid:!0}}export function validateMigrationDialect(cwd=process.cwd()){const driver=String(process.env.DB_CONNECTION||"sqlite").toLowerCase(),dir=resolveMigrationDirectory(driver,{cwd}),relativeDir=relativeMigrationDirectory(dir,cwd);if(process.env[DIALECT_OVERRIDE_ENV]!=="1"){const caps=dialectCapabilities(driver),target=caps.wire==="mysql"?"mysql":caps.wire==="postgres"?"postgres":"sqlite";if(driver==="sqlite"||driver==="postgres"||caps.wire==="mysql"){const audit=auditMigrationCorpus({dir,target});if(!audit.empty&&audit.incompatible.length>0)return{valid:!1,error:formatMigrationDialectError(audit,target,relativeDir)}}}if(process.env[DDL_CONSTRAINT_OVERRIDE_ENV]!=="1"){const constraints=auditDdlConstraints({dir,dialect:driver});if(!constraints.empty&&constraints.violations.length>0)return{valid:!1,error:formatDdlConstraintError(constraints,driver,relativeDir)}}return{valid:!0}}async function reportMissingForeignKeys(){try{const{auditForeignKeys}=await import("@stacksjs/database"),result=await auditForeignKeys();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(`
|
|
2
2
|
`),more=result.missing.length>5?`
|
|
3
3
|
+ ${result.missing.length-5} more \u2014 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:
|
|
4
4
|
${sample}${more}
|
|
@@ -41,15 +41,30 @@ ${sample}${more}
|
|
|
41
41
|
2. (Optional) Export data from the current ${current} database.
|
|
42
42
|
3. ${switchBlocked?`Regenerate the migration files for ${target} FIRST \u2014 \`./buddy migrate\` will refuse until then.`:"Run `./buddy migrate` (or `migrate:fresh` to start clean)."}
|
|
43
43
|
4. The post-migrate FK audit will report any constraints that didn't replay.
|
|
44
|
-
`);await outro("Plan rendered. Re-run after updating .env to actually switch.",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("migrate:regenerate [dialect]","Rebuild database/migrations from your models for a given dialect").option("--dry-run","Show what would change without writing anything",{default:!1}).option("-f, --force","Regenerate even though the database already has migrations recorded",{default:!1}).action(async(dialect,options)=>{const perf=await intro("buddy migrate:regenerate"),target=(dialect||process.env.DB_CONNECTION||"sqlite").toLowerCase();if(!new Set(["sqlite","mysql","vitess","postgres","singlestore"]).has(target)){log.syncError(`Unknown dialect "${target}". Allowed: sqlite, mysql, vitess, postgres, singlestore.`);process.exit(ExitCode.FatalError)}const{countAppliedMigrations,regenerateMigrationCorpus}=await import("@stacksjs/database");let applied=0;try{applied=await countAppliedMigrations()}catch{applied=0}if(applied>0&&!options.force&&!options.dryRun){log.syncError(`This database already has ${applied} migration(s) recorded.`);log.syncError("Regenerating renumbers every file, and the migrations table keys on the filename,");log.syncError("so already-applied migrations would look pending and run a second time.");log.syncError(" Point at an empty database, or re-run with --force if you know it is safe.");process.exit(ExitCode.FatalError)}const plan=await regenerateMigrationCorpus({dialect:target,dryRun:!0});if(resultFailed(plan)){log.syncError(plan.error.message);process.exit(ExitCode.FatalError)}const{files,removed,models}=plan.value
|
|
44
|
+
`);await outro("Plan rendered. Re-run after updating .env to actually switch.",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("migrate:regenerate [dialect]","Rebuild database/migrations from your models for a given dialect").option("--dry-run","Show what would change without writing anything",{default:!1}).option("-f, --force","Regenerate even though the database already has migrations recorded",{default:!1}).option("--replace-unmarked","Also delete migrations carrying no @generated marker (pre-marker corpora only)",{default:!1}).action(async(dialect,options)=>{const perf=await intro("buddy migrate:regenerate"),target=(dialect||process.env.DB_CONNECTION||"sqlite").toLowerCase();if(!new Set(["sqlite","mysql","vitess","postgres","singlestore"]).has(target)){log.syncError(`Unknown dialect "${target}". Allowed: sqlite, mysql, vitess, postgres, singlestore.`);process.exit(ExitCode.FatalError)}const{countAppliedMigrations,regenerateMigrationCorpus}=await import("@stacksjs/database");let applied=0;try{applied=await countAppliedMigrations()}catch{applied=0}if(applied>0&&!options.force&&!options.dryRun){log.syncError(`This database already has ${applied} migration(s) recorded.`);log.syncError("Regenerating renumbers every file, and the migrations table keys on the filename,");log.syncError("so already-applied migrations would look pending and run a second time.");log.syncError(" Point at an empty database, or re-run with --force if you know it is safe.");process.exit(ExitCode.FatalError)}const plan=await regenerateMigrationCorpus({dialect:target,dryRun:!0,replaceUnmarked:options.replaceUnmarked});if(resultFailed(plan)){log.syncError(plan.error.message);process.exit(ExitCode.FatalError)}const{files,removed,preserved,preservedOutOfScope,models,modelRoots}=plan.value,outOfScope=new Set(preservedOutOfScope),unmarked=preserved.filter((f)=>!outOfScope.has(f)),unmarkedBlock=unmarked.length===0?"":`
|
|
45
|
+
\u2022 ${unmarked.length} file(s) carry no @generated marker and will be KEPT:
|
|
46
|
+
${unmarked.map((f)=>` ${f}`).join(`
|
|
47
|
+
`)}
|
|
48
|
+
Hand-authored migrations cannot be regenerated, so they are never deleted.
|
|
49
|
+
If these are output from a Stacks version that predated the marker, re-run
|
|
50
|
+
with --replace-unmarked to replace them too.`,OUT_OF_SCOPE_SHOWN=20,outOfScopeMore=preservedOutOfScope.length-OUT_OF_SCOPE_SHOWN,outOfScopeBlock=preservedOutOfScope.length===0?"":`
|
|
51
|
+
\u2022 ${preservedOutOfScope.length} file(s) describe tables this corpus does not rebuild, and will be KEPT:
|
|
52
|
+
${preservedOutOfScope.slice(0,OUT_OF_SCOPE_SHOWN).map((f)=>` ${f}`).join(`
|
|
53
|
+
`)}${outOfScopeMore>0?`
|
|
54
|
+
... and ${outOfScopeMore} more`:""}
|
|
55
|
+
Nothing in scope regenerates these, so removing them would leave the app
|
|
56
|
+
with no definition for those tables at all. If they belong to framework
|
|
57
|
+
models your app relies on without declaring (users, jobs, payments, ...),
|
|
58
|
+
either publish them with \`buddy publish model <Name>\` or set
|
|
59
|
+
database.models.includeFrameworkDefaults, then regenerate again.`,rootList=modelRoots.length===0?"no model directory":modelRoots.map((root)=>relative(process.cwd(),root)||root).join(" and ");console.log(`
|
|
45
60
|
Regenerate plan: ${target}
|
|
46
61
|
\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
47
|
-
\u2022 ${models} model(s) read from
|
|
62
|
+
\u2022 ${models} model(s) read from ${rootList}
|
|
48
63
|
\u2022 ${files.length} migration file(s) will be written
|
|
49
|
-
\u2022 ${removed.length} existing file(s) will be removed
|
|
64
|
+
\u2022 ${removed.length} existing file(s) will be removed${unmarkedBlock}${outOfScopeBlock}
|
|
50
65
|
\u2022 These files are tracked in git, so review with \`git diff\` afterwards
|
|
51
66
|
\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
52
|
-
`);if(options.dryRun){await outro("Dry run. Nothing was written.",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)}if(!isCI&&hasTTY&&process.stdin.isTTY){await log.flush();if(!await confirm({message:`Replace ${removed.length} migration file(s) with ${files.length} generated for ${target}?`,initial:!1})){await outro("Cancelled. Nothing was written.",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)}}const result=await regenerateMigrationCorpus({dialect:target});if(resultFailed(result)){log.syncError(result.error.message);process.exit(ExitCode.FatalError)}log.success(`Wrote ${result.value.files.length} ${target} migration file(s) to database/migrations.`);if(applied>0){const{reconcileMigrationLedger}=await import("@stacksjs/database"),fixed=await reconcileMigrationLedger();if(fixed.remapped.length>0)log.info(`Repointed ${fixed.remapped.length} ledger row(s) at their renumbered file.`);if(fixed.recorded.length>0)log.info(`Recorded ${fixed.recorded.length} migration(s) already present in the schema.`);if(fixed.skipped.length>0)log.warn(`${fixed.skipped.length} ledger entr(ies) need a look \u2014 run \`./buddy migrate:status\`.`)}log.info("Review the change with `git diff`, then run `./buddy migrate`.");await outro("Regenerated.",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("migrate:status","Compare database/migrations, the migrations ledger, and the live schema").option("--reconcile","Repair the ledger where the schema proves what happened",{default:!1}).option("--include-partial","With --reconcile, also record half-applied migrations",{default:!1}).option("--json","Emit the audit as JSON",{default:!1}).action(async(options)=>{const perf=options.json?void 0:await intro("buddy migrate:status"),{auditMigrationLedger,reconcileMigrationLedger}=await import("@stacksjs/database"),audit=await auditMigrationLedger();if(options.json){console.log(JSON.stringify(audit,(_k,v)=>v instanceof Set?[...v]:v,2));process.exit(audit.drift?ExitCode.FatalError:ExitCode.Success)}if(!audit.supported){log.info(`Dialect "${audit.dialect}" is not audited. Nothing to compare.`);await outro("Skipped.",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)}const{counts,entries,orphans}=audit,list=(status)=>entries.filter((e)=>e.status===status).map((e)=>e.file),report=[],section=(heading,files)=>{if(files.length===0)return;if(heading)report.push(` ${heading}`);for(const file of files.slice(0,8))report.push(` ${file}`);if(files.length>8)report.push(` \u2026 +${files.length-8} more`);report.push("")};report.push("");report.push(` Migration status: ${audit.dialect}`);report.push(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");report.push(` ${entries.length} file(s) on disk \xB7 ${audit.recordedCount} recorded in the ledger`);report.push("");if(counts.applied>0)report.push(` ${counts.applied} applied - recorded, and present in the schema.`,"");if(counts.unverifiable>0)report.push(` ${counts.unverifiable} unverifiable - data migrations with no schema trace to check.`,"");section(`${counts.pending} pending - not applied yet, will run on the next \`buddy migrate\`:`,list("pending"));if(counts.stranded>0){report.push(` ${counts.stranded} STRANDED - already applied to the schema, but missing from the ledger.`);report.push(" These re-run on the next `buddy migrate`, which is unsafe for anything not idempotent.");section("",list("stranded"))}section(`${counts.partial} PARTIAL - some effects present, some missing. Needs a human:`,list("partial"));section(`${counts.reverted} REVERTED - recorded as applied, but the effects are gone from the schema:`,list("reverted"));if(orphans.length>0)section(`${orphans.length} orphaned ledger row(s) - recorded, but no such file on disk:`,orphans.map((o)=>`${o.migration}${o.renamedTo?` -> renumbered to ${o.renamedTo}`:" (no counterpart; migration deleted?)"}`));console.log(report.join(`
|
|
67
|
+
`);if(options.dryRun){await outro("Dry run. Nothing was written.",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)}if(!isCI&&hasTTY&&process.stdin.isTTY){await log.flush();if(!await confirm({message:`Replace ${removed.length} migration file(s) with ${files.length} generated for ${target}?`,initial:!1})){await outro("Cancelled. Nothing was written.",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)}}const result=await regenerateMigrationCorpus({dialect:target,replaceUnmarked:options.replaceUnmarked});if(resultFailed(result)){log.syncError(result.error.message);process.exit(ExitCode.FatalError)}log.success(`Wrote ${result.value.files.length} ${target} migration file(s) to database/migrations.`);if(applied>0){const{reconcileMigrationLedger}=await import("@stacksjs/database"),fixed=await reconcileMigrationLedger();if(fixed.remapped.length>0)log.info(`Repointed ${fixed.remapped.length} ledger row(s) at their renumbered file.`);if(fixed.recorded.length>0)log.info(`Recorded ${fixed.recorded.length} migration(s) already present in the schema.`);if(fixed.skipped.length>0)log.warn(`${fixed.skipped.length} ledger entr(ies) need a look \u2014 run \`./buddy migrate:status\`.`)}log.info("Review the change with `git diff`, then run `./buddy migrate`.");await outro("Regenerated.",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("migrate:status","Compare database/migrations, the migrations ledger, and the live schema").option("--reconcile","Repair the ledger where the schema proves what happened",{default:!1}).option("--include-partial","With --reconcile, also record half-applied migrations",{default:!1}).option("--json","Emit the audit as JSON",{default:!1}).action(async(options)=>{const perf=options.json?void 0:await intro("buddy migrate:status"),{auditMigrationLedger,reconcileMigrationLedger}=await import("@stacksjs/database"),audit=await auditMigrationLedger();if(options.json){console.log(JSON.stringify(audit,(_k,v)=>v instanceof Set?[...v]:v,2));process.exit(audit.drift?ExitCode.FatalError:ExitCode.Success)}if(!audit.supported){log.info(`Dialect "${audit.dialect}" is not audited. Nothing to compare.`);await outro("Skipped.",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)}const{counts,entries,orphans}=audit,list=(status)=>entries.filter((e)=>e.status===status).map((e)=>e.file),report=[],section=(heading,files)=>{if(files.length===0)return;if(heading)report.push(` ${heading}`);for(const file of files.slice(0,8))report.push(` ${file}`);if(files.length>8)report.push(` \u2026 +${files.length-8} more`);report.push("")};report.push("");report.push(` Migration status: ${audit.dialect}`);report.push(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");report.push(` ${entries.length} file(s) on disk \xB7 ${audit.recordedCount} recorded in the ledger`);report.push("");if(counts.applied>0)report.push(` ${counts.applied} applied - recorded, and present in the schema.`,"");if(counts.unverifiable>0)report.push(` ${counts.unverifiable} unverifiable - data migrations with no schema trace to check.`,"");section(`${counts.pending} pending - not applied yet, will run on the next \`buddy migrate\`:`,list("pending"));if(counts.stranded>0){report.push(` ${counts.stranded} STRANDED - already applied to the schema, but missing from the ledger.`);report.push(" These re-run on the next `buddy migrate`, which is unsafe for anything not idempotent.");section("",list("stranded"))}section(`${counts.partial} PARTIAL - some effects present, some missing. Needs a human:`,list("partial"));section(`${counts.reverted} REVERTED - recorded as applied, but the effects are gone from the schema:`,list("reverted"));if(orphans.length>0)section(`${orphans.length} orphaned ledger row(s) - recorded, but no such file on disk:`,orphans.map((o)=>`${o.migration}${o.renamedTo?` -> renumbered to ${o.renamedTo}`:" (no counterpart; migration deleted?)"}`));console.log(report.join(`
|
|
53
68
|
`));if(!audit.drift){await outro("Ledger matches the corpus and the schema.",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)}if(!options.reconcile){console.log(`
|
|
54
69
|
Repair with: ./buddy migrate:status --reconcile
|
|
55
70
|
That repoints renumbered ledger rows and records migrations the schema
|
package/dist/commands/publish.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import{existsSync,mkdirSync}from"node:fs";import{cp,readdir,stat}from"node:fs/promises";import{homedir}from"node:os";import{dirname,join,resolve}from"node:path";import process from"node:process";import{italic,log,onUnknownSubcommand}from"@stacksjs/cli";import{path}from"@stacksjs/path";import{fs,globSync}from"@stacksjs/storage";import{ExitCode}from"@stacksjs/types";export function publish(buddy){const descriptions={command:"Publish a Stacks default into your userland (app/) directory so you can customize it",model:"Publish a default model from storage/framework/defaults/app/Models/ to app/Models/",controller:"Publish a default controller from storage/framework/defaults/app/Controllers/ to app/Controllers/",middleware:"Publish a default middleware from storage/framework/defaults/app/Middleware/ to app/Middleware/",action:"Publish a default action from storage/framework/defaults/app/Actions/ to app/Actions/",core:"Publish a framework package source from node_modules/@stacksjs/<pkg>/ into storage/framework/core/<pkg>/ for editing",unpublishCore:"Drop a vendored storage/framework/core/<pkg>/ and go back to the installed @stacksjs/<pkg>",all:"Unvendor the whole framework: remove storage/framework/core and resolve every @stacksjs package from the `stacks` dependency in package.json",publishAll:"Vendor the whole framework: copy storage/framework/core out of a local Stacks checkout and wire it up as a Bun workspace",frameworkPath:"The Stacks checkout to vendor from (defaults to $STACKS_FRAMEWORK_PATH, ../stacks, then ~/Code/stacks)",coreStatus:"Report whether this project runs on a vendored storage/framework/core or on the published packages",name:"The name of the resource to publish (e.g. Cart, User)",pkg:"The name of the framework package (e.g. router, orm, faker \u2014 without @stacksjs/ prefix)",force:"Overwrite an existing userland file",forceUnpublish:"Delete the vendored source even when it has uncommitted changes",verbose:"Enable verbose output"};buddy.command("publish:model <name>",descriptions.model).option("--force",descriptions.force,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(name,options)=>{await publishResource({kind:"model",name,defaultsDir:path.frameworkPath("defaults/app/Models"),userDir:path.userModelsPath(),force:!!options.force})});buddy.command("publish:controller <name>",descriptions.controller).option("--force",descriptions.force,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(name,options)=>{await publishResource({kind:"controller",name,defaultsDir:path.frameworkPath("defaults/app/Controllers"),userDir:path.userControllersPath(),force:!!options.force})});buddy.command("publish:middleware <name>",descriptions.middleware).option("--force",descriptions.force,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(name,options)=>{await publishResource({kind:"middleware",name,defaultsDir:path.frameworkPath("defaults/app/Middleware"),userDir:path.userMiddlewarePath(),force:!!options.force})});buddy.command("publish:action <name>",descriptions.action).option("--force",descriptions.force,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(name,options)=>{await publishResource({kind:"action",name,defaultsDir:path.frameworkPath("defaults/app/Actions"),userDir:path.userActionsPath(),force:!!options.force})});buddy.command("publish:core [pkg]",descriptions.core).option("--all",descriptions.publishAll,{default:!1}).option("--path <path>",descriptions.frameworkPath).option("--force",descriptions.force,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).example("buddy publish:core router").example("buddy publish:core --all").example("buddy publish:core --all --path ../stacks").action(async(pkg,options)=>{if(options.all){await vendorFramework(options.path,!!options.force);return}if(!pkg){log.error("Usage: buddy publish:core <pkg> (or --all to vendor the whole framework as a workspace)");process.exit(ExitCode.FatalError)}await publishCorePackage(pkg,!!options.force)});buddy.command("core:status",descriptions.coreStatus).alias("publish:core:status").option("--verbose",descriptions.verbose,{default:!1}).example("buddy core:status").action(async()=>{await reportCoreStatus()});buddy.command("unpublish:core [pkg]",descriptions.unpublishCore).option("--all",descriptions.all,{default:!1}).option("--force",descriptions.forceUnpublish,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(pkg,options)=>{if(options.all){await unvendorFramework(!!options.force);return}if(!pkg){log.error("Usage: buddy unpublish:core <pkg> (or --all to move the whole framework to node_modules)");process.exit(ExitCode.FatalError)}await unpublishCorePackage(pkg,!!options.force)});buddy.command("publish [resource] [name]",descriptions.command).option("--force",descriptions.force,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(resource,name,options)=>{if(!resource||!name){log.error("Usage: buddy publish:<resource> <Name> (e.g. buddy publish:model Cart)");process.exit(ExitCode.FatalError)}const handler={model:()=>publishResource({kind:"model",name,defaultsDir:path.frameworkPath("defaults/app/Models"),userDir:path.userModelsPath(),force:!!options.force}),controller:()=>publishResource({kind:"controller",name,defaultsDir:path.frameworkPath("defaults/app/Controllers"),userDir:path.userControllersPath(),force:!!options.force}),middleware:()=>publishResource({kind:"middleware",name,defaultsDir:path.frameworkPath("defaults/app/Middleware"),userDir:path.userMiddlewarePath(),force:!!options.force}),action:()=>publishResource({kind:"action",name,defaultsDir:path.frameworkPath("defaults/app/Actions"),userDir:path.userActionsPath(),force:!!options.force}),core:()=>publishCorePackage(name,!!options.force)}[resource.toLowerCase()];if(!handler){log.error(`Unknown publishable resource: ${italic(resource)}`);log.info("Available: model, controller, middleware, action, core");process.exit(ExitCode.FatalError)}await handler()});onUnknownSubcommand(buddy,"publish")}async function publishCorePackage(pkg,force){const shortName=pkg.replace(/^@stacksjs\//,"").replace(/^core\//,""),fail=(msg,hint)=>{process.stderr.write(`${msg}
|
|
2
2
|
`);if(hint)process.stderr.write(` ${hint}
|
|
3
3
|
`);process.exit(ExitCode.FatalError)};if(!shortName||shortName.includes("/")||shortName.includes(".."))fail(`Invalid package name: ${pkg}`,"Use a short name like `router` or the fully qualified `@stacksjs/router`.");const sourceDir=resolve(process.cwd(),"node_modules","@stacksjs",shortName),targetDir=path.frameworkPath(`core/${shortName}`);try{if(!(await stat(sourceDir)).isDirectory())fail(`${sourceDir} is not a directory.`)}catch{fail(`Could not find @stacksjs/${shortName} in node_modules.`,"Run `bun install` first, or check the package name.")}if(existsSync(targetDir)&&!force)fail(`Already published: ${targetDir.replace(`${process.cwd()}/`,"")}`,"Pass --force to overwrite.");mkdirSync(dirname(targetDir),{recursive:!0});const SKIP=new Set(["node_modules","dist",".bun",".cache","tsconfig.tsbuildinfo"]);let copied=0;await copyTreeFiltered(sourceDir,targetDir,SKIP,()=>copied++);log.success(`Published @stacksjs/${shortName} \u2192 ${italic(targetDir.replace(`${process.cwd()}/`,""))} (${copied} files)`);log.info("Edit freely \u2014 local changes win over the installed package.")}async function copyTreeFiltered(sourceDir,targetDir,skip,onFile){mkdirSync(targetDir,{recursive:!0});const entries=await readdir(sourceDir,{withFileTypes:!0});for(const entry of entries){if(skip.has(entry.name))continue;const src=`${sourceDir}/${entry.name}`,dst=`${targetDir}/${entry.name}`;if(entry.isDirectory()){await copyTreeFiltered(src,dst,skip,onFile);continue}await cp(src,dst,{force:!0,dereference:!0});onFile()}}async function publishResource(ctx){const{kind,name,defaultsDir,userDir,force}=ctx,fileName=name.endsWith(".ts")?name:`${name}.ts`,matches=globSync([`${defaultsDir}/**/${fileName}`],{absolute:!0});if(!matches.length){log.error(`Could not find default ${kind}: ${italic(fileName)}`);log.info(`Looked under: ${italic(defaultsDir)}`);process.exit(ExitCode.FatalError)}if(matches.length>1){log.warn(`Multiple defaults match ${italic(fileName)}; using the first:`);for(const m of matches)log.info(` ${m}`)}const sourcePath=matches[0];if(!sourcePath)throw Error(`Could not resolve default ${kind}: ${fileName}`);const targetPath=`${userDir.replace(/\/$/,"")}/${fileName}`;if(existsSync(targetPath)&&!force){log.error(`Already exists: ${italic(targetPath)}`);log.info("Pass --force to overwrite.");process.exit(ExitCode.FatalError)}mkdirSync(dirname(targetPath),{recursive:!0});await fs.promises.copyFile(sourcePath,targetPath);log.success(`Published ${kind} ${italic(name)} \u2192 ${italic(targetPath.replace(`${process.cwd()}/`,""))}`)}async function vendorFramework(explicitPath,force){const coreDir=path.frameworkPath("core"),rel=(p)=>p.replace(`${process.cwd()}/`,"");if(existsSync(coreDir)&&!force){log.info(`Already vendored: ${italic(rel(coreDir))}`);log.info("Pass --force to replace it with a fresh copy of the checkout.");return}const framework=resolveFrameworkCheckout(explicitPath);if(!framework){log.error("No Stacks checkout found to vendor from.");log.info("Pass one with `--path <dir>`, or set STACKS_FRAMEWORK_PATH.");log.info("A checkout is required: the published packages ship `dist` only, so a copy of them would not be editable.");process.exit(ExitCode.FatalError)}const sourceCore=join(framework,"storage/framework/core"),corePkgPath=resolve(sourceCore,"package.json");if(!existsSync(corePkgPath)){log.error(`${sourceCore} has no package.json \u2014 that does not look like a Stacks checkout.`);process.exit(ExitCode.FatalError)}const depName=JSON.parse(await fs.promises.readFile(corePkgPath,"utf-8")).name??"stacks";log.info(`Vendoring the framework from ${italic(framework)}...`);if(existsSync(coreDir))await fs.promises.rm(coreDir,{recursive:!0,force:!0});const SKIP=new Set(["node_modules",".bun",".cache","tsconfig.tsbuildinfo"]);let copied=0;await copyTreeFiltered(sourceCore,coreDir,SKIP,()=>copied++);const rootPkgPath=resolve(process.cwd(),"package.json"),rootPkg=JSON.parse(await fs.promises.readFile(rootPkgPath,"utf-8")),provided=new Set([depName]);for(const entry of await readdir(coreDir,{withFileTypes:!0})){if(!entry.isDirectory())continue;if(existsSync(resolve(coreDir,entry.name,"package.json")))provided.add(`@stacksjs/${entry.name}`)}let repointed=0;for(const field of["dependencies","devDependencies"]){const deps=rootPkg[field];if(!deps)continue;for(const name of Object.keys(deps)){if(!provided.has(name)||deps[name].startsWith("workspace:"))continue;deps[name]="workspace:*";repointed++}}if(!rootPkg.dependencies?.[depName]&&!rootPkg.devDependencies?.[depName])rootPkg.dependencies={...rootPkg.dependencies,[depName]:"workspace:*"};const workspaces=rootPkg.workspaces??[];let addedGlobs=0;for(const glob of["storage/framework/core","storage/framework/core/*"]){if(workspaces.some((existing)=>existing.replace(/^\.\//,"").replace(/\/$/,"")===glob))continue;workspaces.push(glob);addedGlobs++}rootPkg.workspaces=workspaces;await fs.promises.writeFile(rootPkgPath,`${JSON.stringify(rootPkg,null,2)}
|
|
4
|
-
`);const bunfigPath=resolve(process.cwd(),"bunfig.toml");let rewrittenPreloads=0;if(existsSync(bunfigPath)){const bunfig=await fs.promises.readFile(bunfigPath,"utf-8"),next=bunfig.replace(/(["'])@stacksjs\/([\w-]+)\/([^"']+?)\.js\1/g,(match,quote,pkgName,subpath)=>{if(!provided.has(`@stacksjs/${pkgName}`))return match;rewrittenPreloads++;return`${quote}./storage/framework/core/${pkgName}/${subpath}.ts${quote}`});if(next!==bunfig)await fs.promises.writeFile(bunfigPath,next)}const tsconfigPath=resolve(process.cwd(),"tsconfig.json");let rewroteTsconfig=!1;if(existsSync(tsconfigPath)){const raw=await fs.promises.readFile(tsconfigPath,"utf-8"),next=raw.replace(/"extends"\s*:\s*"\.\/storage\/framework\/tsconfig\.app\.json"/,'"extends": "./storage/framework/core/tsconfig.json"');if(next!==raw){await fs.promises.writeFile(tsconfigPath,next);rewroteTsconfig=!0}}log.success(`Vendored ${copied} files into ${italic(rel(coreDir))}`);log.info(`package.json now depends on ${depName}@workspace:*`);if(repointed>0)log.info(`Repointed ${repointed} version range${repointed===1?"":"s"} to workspace:*`);if(addedGlobs>0)log.info(`Added ${addedGlobs} workspace glob${addedGlobs===1?"":"s"} for the vendored packages`);if(rewrittenPreloads>0)log.info(`Rewrote ${rewrittenPreloads} bunfig.toml preload path${rewrittenPreloads===1?"":"s"} to the vendored source`);if(rewroteTsconfig)log.info("tsconfig.json now extends storage/framework/core/tsconfig.json");log.info("Linking the workspace...");if(await Bun.spawn(["bun","install"],{cwd:process.cwd(),stdout:"inherit",stderr:"inherit"}).exited!==0){log.error("`bun install` failed. The files are in place; re-run the install once the failure is resolved.");process.exit(ExitCode.FatalError)}log.success("This project now runs on the vendored framework \u2014 edits under storage/framework/core are live.");log.info("Go back to the published packages any time with `buddy unpublish:core --all`.")}function resolveFrameworkCheckout(explicit){const candidates=[explicit,process.env.STACKS_FRAMEWORK_PATH,resolve(process.cwd(),"../stacks"),join(homedir(),"Code/stacks")].filter(Boolean);for(const candidate of candidates){const full=resolve(candidate);if(full===resolve(process.cwd()))continue;if(existsSync(join(full,"storage/framework/core/package.json")))return full}return null}async function reportCoreStatus(){const coreDir=path.frameworkPath("core"),rel=(p)=>p.replace(`${process.cwd()}/`,""),vendored=existsSync(resolve(coreDir,"package.json")),rootPkgPath=resolve(process.cwd(),"package.json"),rootPkg=existsSync(rootPkgPath)?JSON.parse(await fs.promises.readFile(rootPkgPath,"utf-8")):{},declared=rootPkg.dependencies?.stacks??rootPkg.devDependencies?.stacks;if(vendored){const corePkg=JSON.parse(await fs.promises.readFile(resolve(coreDir,"package.json"),"utf-8")),packages=(await readdir(coreDir,{withFileTypes:!0})).filter((entry)=>entry.isDirectory()&&existsSync(resolve(coreDir,entry.name,"package.json")));log.info(`Layout: vendored \u2014 ${italic(rel(coreDir))} (${packages.length} packages, v${corePkg.version??"unknown"})`);log.info(`Declared: stacks@${declared??"(not declared)"}`);if(declared&&!declared.startsWith("workspace:"))log.warn(`The vendored copy is not linked: stacks is declared as ${declared}, not workspace:*. Run \`buddy publish:core --all --force\` to relink, or \`buddy unpublish:core --all\` to remove it.`);log.info("Move to the published packages with `buddy unpublish:core --all`.");return}const installed=resolve(process.cwd(),"node_modules/@stacksjs/buddy/package.json"),installedVersion=existsSync(installed)?JSON.parse(await fs.promises.readFile(installed,"utf-8")).version:void 0;log.info("Layout: published packages \u2014 no storage/framework/core in this project");log.info(`Declared: stacks@${declared??"(not declared)"}`);log.info(`Installed: ${installedVersion?`v${installedVersion}`:"(run bun install)"}`);log.info("Vendor the framework for local development with `buddy publish:core --all`.")}async function unpublishCorePackage(pkg,force){const shortName=normalizeCoreName(pkg),targetDir=path.frameworkPath(`core/${shortName}`),rel=(p)=>p.replace(`${process.cwd()}/`,"");if(!existsSync(targetDir)){log.info(`Not vendored: ${italic(rel(targetDir))} \u2014 nothing to do.`);return}const installed=resolve(process.cwd(),"node_modules","@stacksjs",shortName);if(!existsSync(installed)&&!force){log.error(`@stacksjs/${shortName} is not installed, so removing the vendored copy would leave nothing to resolve.`);log.info(`Run \`bun add @stacksjs/${shortName}\` first, or pass --force to remove it anyway.`);process.exit(ExitCode.FatalError)}await assertNoUncommittedChanges(targetDir,force);await fs.promises.rm(targetDir,{recursive:!0,force:!0});log.success(`Unpublished ${italic(rel(targetDir))} \u2014 @stacksjs/${shortName} now resolves from node_modules.`)}async function unvendorFramework(force){const coreDir=path.frameworkPath("core"),rel=(p)=>p.replace(`${process.cwd()}/`,"");if(!existsSync(coreDir)){log.info("No storage/framework/core in this project \u2014 already on the installed packages.");return}const corePkgPath=resolve(coreDir,"package.json");if(!existsSync(corePkgPath)){log.error(`${rel(coreDir)} has no package.json, so its version cannot be determined.`);log.info("Unvendor the packages individually with `buddy unpublish:core <pkg>` instead.");process.exit(ExitCode.FatalError)}const corePkg=JSON.parse(await fs.promises.readFile(corePkgPath,"utf-8")),version=corePkg.version;if(!version){log.error(`${rel(corePkgPath)} has no version field.`);process.exit(ExitCode.FatalError)}await assertNoUncommittedChanges(coreDir,force);const rootPkgPath=resolve(process.cwd(),"package.json"),rootPkgRaw=await fs.promises.readFile(rootPkgPath,"utf-8"),rootPkg=JSON.parse(rootPkgRaw),
|
|
4
|
+
`);const bunfigPath=resolve(process.cwd(),"bunfig.toml");let rewrittenPreloads=0;if(existsSync(bunfigPath)){const bunfig=await fs.promises.readFile(bunfigPath,"utf-8"),next=bunfig.replace(/(["'])@stacksjs\/([\w-]+)\/([^"']+?)\.js\1/g,(match,quote,pkgName,subpath)=>{if(!provided.has(`@stacksjs/${pkgName}`))return match;rewrittenPreloads++;return`${quote}./storage/framework/core/${pkgName}/${subpath}.ts${quote}`});if(next!==bunfig)await fs.promises.writeFile(bunfigPath,next)}const tsconfigPath=resolve(process.cwd(),"tsconfig.json");let rewroteTsconfig=!1;if(existsSync(tsconfigPath)){const raw=await fs.promises.readFile(tsconfigPath,"utf-8"),next=raw.replace(/"extends"\s*:\s*"\.\/storage\/framework\/tsconfig\.app\.json"/,'"extends": "./storage/framework/core/tsconfig.json"');if(next!==raw){await fs.promises.writeFile(tsconfigPath,next);rewroteTsconfig=!0}}log.success(`Vendored ${copied} files into ${italic(rel(coreDir))}`);log.info(`package.json now depends on ${depName}@workspace:*`);if(repointed>0)log.info(`Repointed ${repointed} version range${repointed===1?"":"s"} to workspace:*`);if(addedGlobs>0)log.info(`Added ${addedGlobs} workspace glob${addedGlobs===1?"":"s"} for the vendored packages`);if(rewrittenPreloads>0)log.info(`Rewrote ${rewrittenPreloads} bunfig.toml preload path${rewrittenPreloads===1?"":"s"} to the vendored source`);if(rewroteTsconfig)log.info("tsconfig.json now extends storage/framework/core/tsconfig.json");log.info("Linking the workspace...");if(await Bun.spawn(["bun","install"],{cwd:process.cwd(),stdout:"inherit",stderr:"inherit"}).exited!==0){log.error("`bun install` failed. The files are in place; re-run the install once the failure is resolved.");process.exit(ExitCode.FatalError)}log.success("This project now runs on the vendored framework \u2014 edits under storage/framework/core are live.");log.info("Go back to the published packages any time with `buddy unpublish:core --all`.")}function resolveFrameworkCheckout(explicit){const candidates=[explicit,process.env.STACKS_FRAMEWORK_PATH,resolve(process.cwd(),"../stacks"),join(homedir(),"Code/stacks")].filter(Boolean);for(const candidate of candidates){const full=resolve(candidate);if(full===resolve(process.cwd()))continue;if(existsSync(join(full,"storage/framework/core/package.json")))return full}return null}async function reportCoreStatus(){const coreDir=path.frameworkPath("core"),rel=(p)=>p.replace(`${process.cwd()}/`,""),vendored=existsSync(resolve(coreDir,"package.json")),rootPkgPath=resolve(process.cwd(),"package.json"),rootPkg=existsSync(rootPkgPath)?JSON.parse(await fs.promises.readFile(rootPkgPath,"utf-8")):{},declared=rootPkg.dependencies?.stacks??rootPkg.devDependencies?.stacks;if(vendored){const corePkg=JSON.parse(await fs.promises.readFile(resolve(coreDir,"package.json"),"utf-8")),packages=(await readdir(coreDir,{withFileTypes:!0})).filter((entry)=>entry.isDirectory()&&existsSync(resolve(coreDir,entry.name,"package.json")));log.info(`Layout: vendored \u2014 ${italic(rel(coreDir))} (${packages.length} packages, v${corePkg.version??"unknown"})`);log.info(`Declared: stacks@${declared??"(not declared)"}`);if(declared&&!declared.startsWith("workspace:"))log.warn(`The vendored copy is not linked: stacks is declared as ${declared}, not workspace:*. Run \`buddy publish:core --all --force\` to relink, or \`buddy unpublish:core --all\` to remove it.`);log.info("Move to the published packages with `buddy unpublish:core --all`.");return}const installed=resolve(process.cwd(),"node_modules/@stacksjs/buddy/package.json"),installedVersion=existsSync(installed)?JSON.parse(await fs.promises.readFile(installed,"utf-8")).version:void 0;log.info("Layout: published packages \u2014 no storage/framework/core in this project");log.info(`Declared: stacks@${declared??"(not declared)"}`);log.info(`Installed: ${installedVersion?`v${installedVersion}`:"(run bun install)"}`);log.info("Vendor the framework for local development with `buddy publish:core --all`.")}async function unpublishCorePackage(pkg,force){const shortName=normalizeCoreName(pkg),targetDir=path.frameworkPath(`core/${shortName}`),rel=(p)=>p.replace(`${process.cwd()}/`,"");if(!existsSync(targetDir)){log.info(`Not vendored: ${italic(rel(targetDir))} \u2014 nothing to do.`);return}const installed=resolve(process.cwd(),"node_modules","@stacksjs",shortName);if(!existsSync(installed)&&!force){log.error(`@stacksjs/${shortName} is not installed, so removing the vendored copy would leave nothing to resolve.`);log.info(`Run \`bun add @stacksjs/${shortName}\` first, or pass --force to remove it anyway.`);process.exit(ExitCode.FatalError)}await assertNoUncommittedChanges(targetDir,force);await fs.promises.rm(targetDir,{recursive:!0,force:!0});log.success(`Unpublished ${italic(rel(targetDir))} \u2014 @stacksjs/${shortName} now resolves from node_modules.`)}async function unvendorFramework(force){const coreDir=path.frameworkPath("core"),rel=(p)=>p.replace(`${process.cwd()}/`,"");if(!existsSync(coreDir)){log.info("No storage/framework/core in this project \u2014 already on the installed packages.");return}const corePkgPath=resolve(coreDir,"package.json");if(!existsSync(corePkgPath)){log.error(`${rel(coreDir)} has no package.json, so its version cannot be determined.`);log.info("Unvendor the packages individually with `buddy unpublish:core <pkg>` instead.");process.exit(ExitCode.FatalError)}const corePkg=JSON.parse(await fs.promises.readFile(corePkgPath,"utf-8")),version=corePkg.version;if(!version){log.error(`${rel(corePkgPath)} has no version field.`);process.exit(ExitCode.FatalError)}await assertNoUncommittedChanges(coreDir,force);const depName=corePkg.name??"stacks",range=`^${await resolvePublishedVersion(depName,version)}`,rootPkgPath=resolve(process.cwd(),"package.json"),rootPkgRaw=await fs.promises.readFile(rootPkgPath,"utf-8"),rootPkg=JSON.parse(rootPkgRaw),provided=new Set([depName,...Object.keys(corePkg.dependencies??{}).filter((name)=>name.startsWith("@stacksjs/"))]);for(const entry of await readdir(coreDir,{withFileTypes:!0}))if(entry.isDirectory())provided.add(`@stacksjs/${entry.name}`);let repointed=0;const repointWorkspaceRanges=(pkg)=>{let touched=!1;for(const field of["dependencies","devDependencies","peerDependencies"]){const deps=pkg[field];if(!deps)continue;for(const[name,spec]of Object.entries(deps)){if(!spec.startsWith("workspace:")||!provided.has(name))continue;deps[name]=range;touched=!0;repointed++}}return touched};repointWorkspaceRanges(rootPkg);if(!rootPkg.dependencies?.[depName]&&!rootPkg.devDependencies?.[depName])rootPkg.dependencies={...rootPkg.dependencies,[depName]:range};let rewrittenScripts=0;for(const[name,script]of Object.entries(rootPkg.scripts??{})){if(!/storage\/framework\/core\/buddy\/src\/cli\.ts/.test(script))continue;rootPkg.scripts[name]=script.replace(/\bbunx?\s+(?:--bun\s+)?\.?\/?storage\/framework\/core\/buddy\/src\/cli\.ts/g,"./buddy");rewrittenScripts++}if(Array.isArray(rootPkg.workspaces)){rootPkg.workspaces=rootPkg.workspaces.filter((glob)=>!isCoreWorkspaceGlob(glob));if(rootPkg.workspaces.length===0)delete rootPkg.workspaces}await fs.promises.writeFile(rootPkgPath,`${JSON.stringify(rootPkg,null,2)}
|
|
5
5
|
`);for(const glob of rootPkg.workspaces??[])for(const memberPkgPath of globSync(`${glob.replace(/\/$/,"")}/package.json`,{cwd:process.cwd(),absolute:!0})){const raw=await fs.promises.readFile(memberPkgPath,"utf-8"),memberPkg=JSON.parse(raw);if(repointWorkspaceRanges(memberPkg))await fs.promises.writeFile(memberPkgPath,`${JSON.stringify(memberPkg,null,2)}
|
|
6
|
-
`)}const bunfigPath=resolve(process.cwd(),"bunfig.toml");let rewrittenPreloads=0;if(existsSync(bunfigPath)){const bunfig=await fs.promises.readFile(bunfigPath,"utf-8"),next=bunfig.replace(/(["'])\.?\/?storage\/framework\/core\/([\w-]+)\/([^"']+?)\.ts\1/g,(_match,quote,pkgName,subpath)=>{rewrittenPreloads++;return`${quote}@stacksjs/${pkgName}/${subpath}.js${quote}`});if(next!==bunfig)await fs.promises.writeFile(bunfigPath,next)}const tsconfigPath=resolve(process.cwd(),"tsconfig.json");let rewroteTsconfig=!1;if(existsSync(tsconfigPath)){const raw=await fs.promises.readFile(tsconfigPath,"utf-8"),next=raw.replace(/"extends"\s*:\s*"\.\/storage\/framework\/core\/tsconfig\.json"/,'"extends": "./storage/framework/tsconfig.app.json"');if(next!==raw){await fs.promises.writeFile(tsconfigPath,next);rewroteTsconfig=!0}}await fs.promises.rm(coreDir,{recursive:!0,force:!0});const scopedDir=resolve(process.cwd(),"node_modules/@stacksjs");let danglingRemoved=0;if(existsSync(scopedDir))for(const entry of await readdir(scopedDir,{withFileTypes:!0})){if(!entry.isSymbolicLink())continue;const link=resolve(scopedDir,entry.name);if(existsSync(link))continue;await fs.promises.rm(link,{force:!0});danglingRemoved++}log.success(`Removed ${italic(rel(coreDir))}`);log.info(`package.json now depends on ${depName}@${range}`);if(repointed>0)log.info(`Repointed ${repointed} workspace: range${repointed===1?"":"s"} to ${range}`);if(danglingRemoved>0)log.info(`Removed ${danglingRemoved} node_modules symlink${danglingRemoved===1?"":"s"} left pointing into it`);if(rewrittenScripts>0)log.info(`Repointed ${rewrittenScripts} package.json script${rewrittenScripts===1?"":"s"} to ./buddy`);if(rewrittenPreloads>0)log.info(`Rewrote ${rewrittenPreloads} bunfig.toml preload path${rewrittenPreloads===1?"":"s"} to package specifiers`);if(rewroteTsconfig)log.info("tsconfig.json now extends storage/framework/tsconfig.app.json");log.info("Installing the published packages...");if(await Bun.spawn(["bun","install"],{cwd:process.cwd(),stdout:"inherit",stderr:"inherit"}).exited!==0){log.error("`bun install` failed. package.json and bunfig.toml were updated; re-run the install once the failure is resolved.");process.exit(ExitCode.FatalError)}log.success("This project now runs on the published Stacks packages.");log.info("Vendor an individual package again any time with `buddy publish:core <pkg>`.")}function normalizeCoreName(pkg){const shortName=pkg.replace(/^@stacksjs\//,"").replace(/^core\//,"");if(!shortName||shortName.includes("/")||shortName.includes("..")){process.stderr.write(`Invalid package name: ${pkg}
|
|
6
|
+
`)}const bunfigPath=resolve(process.cwd(),"bunfig.toml");let rewrittenPreloads=0;if(existsSync(bunfigPath)){const bunfig=await fs.promises.readFile(bunfigPath,"utf-8"),next=bunfig.replace(/(["'])\.?\/?storage\/framework\/core\/([\w-]+)\/([^"']+?)\.ts\1/g,(_match,quote,pkgName,subpath)=>{rewrittenPreloads++;return`${quote}@stacksjs/${pkgName}/${subpath}.js${quote}`});if(next!==bunfig)await fs.promises.writeFile(bunfigPath,next)}const tsconfigPath=resolve(process.cwd(),"tsconfig.json");let rewroteTsconfig=!1;if(existsSync(tsconfigPath)){const raw=await fs.promises.readFile(tsconfigPath,"utf-8"),next=raw.replace(/"extends"\s*:\s*"\.\/storage\/framework\/core\/tsconfig\.json"/,'"extends": "./storage/framework/tsconfig.app.json"');if(next!==raw){await fs.promises.writeFile(tsconfigPath,next);rewroteTsconfig=!0}}await fs.promises.rm(coreDir,{recursive:!0,force:!0});const scopedDir=resolve(process.cwd(),"node_modules/@stacksjs");let danglingRemoved=0;if(existsSync(scopedDir))for(const entry of await readdir(scopedDir,{withFileTypes:!0})){if(!entry.isSymbolicLink())continue;const link=resolve(scopedDir,entry.name);if(existsSync(link))continue;await fs.promises.rm(link,{force:!0});danglingRemoved++}log.success(`Removed ${italic(rel(coreDir))}`);log.info(`package.json now depends on ${depName}@${range}`);if(repointed>0)log.info(`Repointed ${repointed} workspace: range${repointed===1?"":"s"} to ${range}`);if(danglingRemoved>0)log.info(`Removed ${danglingRemoved} node_modules symlink${danglingRemoved===1?"":"s"} left pointing into it`);if(rewrittenScripts>0)log.info(`Repointed ${rewrittenScripts} package.json script${rewrittenScripts===1?"":"s"} to ./buddy`);if(rewrittenPreloads>0)log.info(`Rewrote ${rewrittenPreloads} bunfig.toml preload path${rewrittenPreloads===1?"":"s"} to package specifiers`);if(rewroteTsconfig)log.info("tsconfig.json now extends storage/framework/tsconfig.app.json");log.info("Installing the published packages...");if(await Bun.spawn(["bun","install"],{cwd:process.cwd(),stdout:"inherit",stderr:"inherit"}).exited!==0){log.error("`bun install` failed. package.json and bunfig.toml were updated; re-run the install once the failure is resolved.");process.exit(ExitCode.FatalError)}log.success("This project now runs on the published Stacks packages.");log.info("Vendor an individual package again any time with `buddy publish:core <pkg>`.")}async function resolvePublishedVersion(depName,vendored){let published;try{published=await fetchPublishedVersions(depName)}catch(error){log.warn(`Could not reach the npm registry to check ${depName} versions (${error instanceof Error?error.message:String(error)}).`);log.info(`Pinning the vendored version, ${depName}@^${vendored}.`);return vendored}if(published.versions.has(vendored))return vendored;if(!published.latest){log.warn(`${depName}@${vendored} is not published and the registry reports no latest version.`);return vendored}log.warn(`${depName}@${vendored} is not published yet \u2014 the vendored copy is ahead of npm.`);log.info(`Pinning the newest published version instead, ${depName}@^${published.latest}.`);return published.latest}async function fetchPublishedVersions(depName){const response=await fetch(`https://registry.npmjs.org/${depName.replace("/","%2F")}`,{headers:{accept:"application/vnd.npm.install-v1+json"}});if(!response.ok)throw Error(`registry responded ${response.status}`);const packument=await response.json();return{latest:packument["dist-tags"]?.latest,versions:new Set(Object.keys(packument.versions??{}))}}function normalizeCoreName(pkg){const shortName=pkg.replace(/^@stacksjs\//,"").replace(/^core\//,"");if(!shortName||shortName.includes("/")||shortName.includes("..")){process.stderr.write(`Invalid package name: ${pkg}
|
|
7
7
|
`);process.stderr.write(" Use a short name like `router` or the fully qualified `@stacksjs/router`.\n");process.exit(ExitCode.FatalError)}return shortName}function isCoreWorkspaceGlob(glob){const normalized=glob.replace(/^\.\//,"").replace(/\/$/,"");return normalized==="storage/framework/core"||normalized.startsWith("storage/framework/core/")}async function assertNoUncommittedChanges(dir,force){if(force)return;try{const proc=Bun.spawn(["git","status","--porcelain","--",dir],{cwd:process.cwd(),stdout:"pipe",stderr:"ignore"}),output=await new Response(proc.stdout).text();if(await proc.exited!==0)return;const changed=output.split(`
|
|
8
8
|
`).filter(Boolean);if(changed.length===0)return;log.error(`${changed.length} uncommitted change${changed.length===1?"":"s"} under ${italic(dir.replace(`${process.cwd()}/`,""))}:`);for(const line of changed.slice(0,10))log.info(` ${line}`);if(changed.length>10)log.info(` ... and ${changed.length-10} more`);log.info("Commit or stash them first, or pass --force to delete them anyway.");process.exit(ExitCode.FatalError)}catch{}}
|
|
@@ -1 +1,35 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Resolve the project's includes directory.
|
|
3
|
+
*
|
|
4
|
+
* A configured `config/stx.ts#partialsDir` wins outright. The convention list
|
|
5
|
+
* below is only a fallback for apps that never set one, and it cannot stand in
|
|
6
|
+
* for the config: the candidates are probed for existence in order, so an app
|
|
7
|
+
* that keeps its includes in `resources/components` but also has an unrelated
|
|
8
|
+
* `resources/partials` directory silently resolved to the wrong one and every
|
|
9
|
+
* `@include` failed with ENOENT at runtime.
|
|
10
|
+
*
|
|
11
|
+
* The configured value is relative to the stx root (`resources`), matching how
|
|
12
|
+
* stx itself reads it, but an app-root-relative path is accepted too so either
|
|
13
|
+
* spelling resolves.
|
|
14
|
+
*/
|
|
15
|
+
export declare function resolveUserPartialsPath(cwd?: unknown, configuredDir?: string): string | undefined;
|
|
16
|
+
/**
|
|
17
|
+
* Read `partialsDir` off the app's stx config. Failure is non-fatal: without
|
|
18
|
+
* it {@link resolveUserPartialsPath} falls back to the conventions.
|
|
19
|
+
*/
|
|
20
|
+
export declare function loadStxPartialsDir(cwd?: unknown): Promise<string | undefined>;
|
|
21
|
+
/**` proxy target, or `null` when it cannot be
|
|
22
|
+
* known safely.
|
|
23
|
+
*
|
|
24
|
+
* `API_URL` and `PORT_API` are explicit operator intent and always win. The
|
|
25
|
+
* framework-wide `ports.api` default is only trusted *outside* deployed
|
|
26
|
+
* environments: locally one app owns the machine, so `127.0.0.1:3008` really is
|
|
27
|
+
* its own API. On a deployed box that assumption does not hold — ts-cloud runs
|
|
28
|
+
* many SSR sites side by side, each on its own port, and an unconfigured
|
|
29
|
+
* default resolves to whichever tenant happens to own it.
|
|
30
|
+
*
|
|
31
|
+
* Returning `null` makes the caller answer 502. That is the safe failure: the
|
|
32
|
+
* alternative is proxying authenticated requests into another app's process.
|
|
33
|
+
*/
|
|
34
|
+
export declare function resolveApiBase(configuredPort?: number, env?: NodeJS.ProcessEnv): string | null;
|
|
35
|
+
export declare function startProductionServer(options?: { port?: string | number, verbose?: boolean }): Promise<void>;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{existsSync}from"node:fs";import{homedir}from"node:os";import{dirname,join}from"node:path";import process from"node:process";import{log}from"@stacksjs/logging";
|
|
1
|
+
import{existsSync}from"node:fs";import{homedir}from"node:os";import{dirname,join}from"node:path";import process from"node:process";import{installRequestContext,parseCookieHeader}from"@stacksjs/config";import{log}from"@stacksjs/logging";function productionRequestSnapshot(){const{__stxServeContext:snapshot,__stxServeCookies:legacyCookies,__stxServeSearch:legacySearch}=globalThis;if(!snapshot&&!legacyCookies&&legacySearch===void 0)return;return{...snapshot,cookies:snapshot?.cookies??legacyCookies??{},url:snapshot?.url||snapshot?.search||legacySearch||"",search:snapshot?.search??legacySearch??""}}installRequestContext(productionRequestSnapshot);function parseCookies(req){return parseCookieHeader(req.headers.get("cookie"))}export function resolveUserPartialsPath(cwd=process.cwd(),configuredDir){if(configuredDir){const configured=[join(cwd,"resources",configuredDir),join(cwd,configuredDir)].find((candidate)=>existsSync(candidate));if(configured)return configured}const existing=["resources/partials","resources/views/partials","partials","resources/components"].filter((candidate)=>existsSync(join(cwd,candidate)));if(existing.length===0)return;const populated=existing.find((candidate)=>containsTemplates(join(cwd,candidate)));return join(cwd,populated??existing[0])}function containsTemplates(dir){try{return[...new Bun.Glob("**/*.stx").scanSync({cwd:dir,onlyFiles:!0})].length>0}catch{return!1}}export async function loadStxPartialsDir(cwd=process.cwd()){const configPath=join(cwd,"config/stx.ts");if(!existsSync(configPath))return;try{const dir=(await import(configPath)).default?.partialsDir;return typeof dir==="string"&&dir.length>0?dir:void 0}catch{return}}function resolveCsrfMiddlewarePath(){const rel="app/Middleware/Csrf.ts",vendored=join(process.cwd(),"storage/framework/defaults",rel);if(existsSync(vendored))return vendored;try{const pkgJson=Bun.resolveSync("@stacksjs/defaults/package.json",process.cwd());return`${pkgJson.slice(0,pkgJson.lastIndexOf("/"))}/${rel}`}catch{return vendored}}export function resolveApiBase(configuredPort,env=process.env){if(env.API_URL)return env.API_URL;const explicitPort=Number(env.PORT_API);if(explicitPort)return`http://127.0.0.1:${explicitPort}`;if(["production","staging","development"].includes((env.APP_ENV||"").toLowerCase()))return null;return`http://127.0.0.1:${configuredPort||3008}`}export async function startProductionServer(options){if(options?.port)process.env.PORT=String(options.port);process.env.APP_ENV=process.env.APP_ENV||"production";const port=Number(process.env.PORT)||3000,{config,overridesReady,resolveViewPatterns}=await import("@stacksjs/config");await overridesReady;const{describeApiProxyRules,injectGlobalAutoImports,resolveApiProxyRules}=await import("@stacksjs/server"),{stxPageAuthMiddleware}=await import("@stacksjs/auth");await injectGlobalAutoImports();let stxServe;const serveCandidates=[join(homedir(),"Code/Tools/stx/packages/bun-plugin/dist/serve.js"),join(process.cwd(),"pantry/bun-plugin-stx/dist/serve.js")];for(const entry of serveCandidates)try{if(existsSync(entry)){({serve:stxServe}=await import(entry));break}}catch{}if(!stxServe)({serve:stxServe}=await import("bun-plugin-stx/serve"));const stxModule=await resolveVendoredStxModule(),{site:siteConfig,i18n:i18nConfig}=await loadStxSiteConfig(),userViewsPath="resources/views",defaultsResources=resolveDefaultsResources(),defaultViewsPath=join(defaultsResources,"views"),userLayoutsPath=existsSync("resources/views/layouts")?"resources/views/layouts":"resources/layouts",userPartialsPath=resolveUserPartialsPath(process.cwd(),await loadStxPartialsDir()),apiBase=resolveApiBase(config.ports?.api),viewPatterns=resolveViewPatterns(userViewsPath,defaultViewsPath,config?.ui?.defaultViews);for(const name of viewPatterns.missing)log.warn(`ui.defaultViews lists "${name}", which does not exist under ${defaultViewsPath} \u2014 ignoring.`);const apiProxyRules=resolveApiProxyRules(config.server?.proxy);if(apiProxyRules.paths.length>0||apiProxyRules.prefixes.length>1)log.info(`API proxy: ${describeApiProxyRules(apiProxyRules)}`);log.info(`Starting production server on port ${port}...`);await stxServe({patterns:viewPatterns.patterns,port,autoIncrementPort:!1,reusePort:["production","staging","development"].includes((process.env.APP_ENV||"").toLowerCase()),componentsDir:join(defaultsResources,"components"),layoutsDir:userLayoutsPath,...userPartialsPath&&{partialsDir:userPartialsPath},fallbackLayoutsDir:join(defaultsResources,"layouts"),fallbackPartialsDir:defaultViewsPath,quiet:options?.verbose!==!0,...stxModule&&{stxModule},...i18nConfig&&{i18n:i18nConfig},...siteConfig?.url&&{site:siteConfig},middleware:stxPageAuthMiddleware(),onRequest:async(req)=>{const{maintenanceGate,isApiBoundRequest:isApiBound,proxyToBackend}=await import("@stacksjs/server"),gated=await maintenanceGate(req);if(gated)return gated;const url=new URL(req.url);if(isApiBound(req,url.pathname,apiProxyRules)){if(!apiBase){log.error(`No API target configured for ${url.pathname}. This app shares its host with other `+"deployments, so there is no safe default port to guess \u2014 refusing to proxy. Set "+"PORT_API (or API_URL) for this site, and deploy an `api` site on its own port.");return new Response("Bad Gateway",{status:502})}try{return await proxyToBackend(req,apiBase)}catch(error){log.error(`API proxy to ${apiBase} failed: ${error.message}`);return new Response("Bad Gateway",{status:502})}}if(existsSync(join(process.cwd(),"resources/views/blog.stx"))){const{renderBlogFeed}=await import("@stacksjs/actions/blog"),feed=await renderBlogFeed(req);if(feed)return feed}globalThis.__stxServeSearch=url.search;globalThis.__stxServeCookies=parseCookies(req);return},onResponse:async(req,response)=>{const method=req.method.toUpperCase();if(method!=="GET"&&method!=="HEAD")return;try{const{seedCsrfCookieIfMissing}=await import(resolveCsrfMiddlewarePath());return seedCsrfCookieIfMissing(req,response)}catch(error){log.debug(`CSRF cookie seeding skipped: ${error.message}`)}}});log.success(`Production server listening on http://0.0.0.0:${port}`)}function resolveDefaultsResources(){const vendored="storage/framework/defaults/resources";if(existsSync(vendored))return vendored;try{const pkgJson=Bun.resolveSync("@stacksjs/defaults/package.json",process.cwd());return join(dirname(pkgJson),"resources")}catch{return vendored}}async function resolveVendoredStxModule(){const candidates=[join(homedir(),"Code/Tools/stx/packages/stx/dist/index.js"),join(process.cwd(),"pantry/@stacksjs/stx/dist/index.js")];for(const entry of candidates)try{if(existsSync(entry))return await import(entry)}catch{}try{return await import("@stacksjs/stx")}catch{}return}function fallbackI18nFromSite(site){const locales=site.i18n.locales,defaultLocale=site.i18n.defaultLocale??locales[0];return{locales,defaultLocale,labels:site.i18n.labels??Object.fromEntries(locales.map((c)=>[c,c.toUpperCase()])),translations:{},pickerSelector:site.i18n.pickerSelector??"#lang-picker"}}async function resolveSiteI18n(site){const resolverPaths=[join(homedir(),"Code/Tools/stx/packages/stx/src/site-builder/i18n.ts"),join(homedir(),"Code/Tools/stx/packages/stx/dist/index.js"),join(process.cwd(),"pantry/@stacksjs/stx/dist/index.js")];for(const resolverPath of resolverPaths)try{if(!existsSync(resolverPath))continue;const resolved=await import(resolverPath);if(typeof resolved.resolveI18n!=="function")continue;const i18n=resolved.resolveI18n(site,process.cwd());if(i18n)return i18n}catch{}try{const resolved=await import("@stacksjs/stx");if(typeof resolved.resolveI18n==="function"){const i18n=resolved.resolveI18n(site,process.cwd());if(i18n)return i18n}}catch{}return fallbackI18nFromSite(site)}async function loadStxSiteConfig(){const sitePath=join(process.cwd(),"site.config.ts");if(!existsSync(sitePath))return{};try{const site=(await import(sitePath)).default;if(!site)return{};if(!site.i18n)return{site};const i18n=await resolveSiteI18n(site);return{site,i18n}}catch{}return{}}
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@stacksjs/buddy",
|
|
3
3
|
"type": "module",
|
|
4
4
|
"sideEffects": false,
|
|
5
|
-
"version": "0.70.
|
|
5
|
+
"version": "0.70.297",
|
|
6
6
|
"description": "Meet Buddy. The Stacks runtime.",
|
|
7
7
|
"author": "Chris Breuer",
|
|
8
8
|
"contributors": [
|
|
@@ -95,53 +95,53 @@
|
|
|
95
95
|
"prepublishOnly": "bun run build"
|
|
96
96
|
},
|
|
97
97
|
"dependencies": {
|
|
98
|
-
"@stacksjs/actions": "^0.70.
|
|
99
|
-
"@stacksjs/ai": "^0.70.
|
|
100
|
-
"@stacksjs/alias": "^0.70.
|
|
101
|
-
"@stacksjs/arrays": "^0.70.
|
|
102
|
-
"@stacksjs/auth": "^0.70.
|
|
103
|
-
"@stacksjs/build": "^0.70.
|
|
104
|
-
"@stacksjs/cache": "^0.70.
|
|
105
|
-
"@stacksjs/cli": "^0.70.
|
|
98
|
+
"@stacksjs/actions": "^0.70.297",
|
|
99
|
+
"@stacksjs/ai": "^0.70.297",
|
|
100
|
+
"@stacksjs/alias": "^0.70.297",
|
|
101
|
+
"@stacksjs/arrays": "^0.70.297",
|
|
102
|
+
"@stacksjs/auth": "^0.70.297",
|
|
103
|
+
"@stacksjs/build": "^0.70.297",
|
|
104
|
+
"@stacksjs/cache": "^0.70.297",
|
|
105
|
+
"@stacksjs/cli": "^0.70.297",
|
|
106
106
|
"@stacksjs/clapp": "^0.2.12",
|
|
107
|
-
"@stacksjs/cloud": "^0.70.
|
|
108
|
-
"@stacksjs/collections": "^0.70.
|
|
109
|
-
"@stacksjs/config": "^0.70.
|
|
110
|
-
"@stacksjs/database": "^0.70.
|
|
111
|
-
"@stacksjs/desktop-build": "^0.70.
|
|
112
|
-
"@stacksjs/dns": "^0.70.
|
|
113
|
-
"@stacksjs/email": "^0.70.
|
|
114
|
-
"@stacksjs/enums": "^0.70.
|
|
115
|
-
"@stacksjs/error-handling": "^0.70.
|
|
116
|
-
"@stacksjs/events": "^0.70.
|
|
117
|
-
"@stacksjs/git": "^0.70.
|
|
107
|
+
"@stacksjs/cloud": "^0.70.297",
|
|
108
|
+
"@stacksjs/collections": "^0.70.297",
|
|
109
|
+
"@stacksjs/config": "^0.70.297",
|
|
110
|
+
"@stacksjs/database": "^0.70.297",
|
|
111
|
+
"@stacksjs/desktop-build": "^0.70.297",
|
|
112
|
+
"@stacksjs/dns": "^0.70.297",
|
|
113
|
+
"@stacksjs/email": "^0.70.297",
|
|
114
|
+
"@stacksjs/enums": "^0.70.297",
|
|
115
|
+
"@stacksjs/error-handling": "^0.70.297",
|
|
116
|
+
"@stacksjs/events": "^0.70.297",
|
|
117
|
+
"@stacksjs/git": "^0.70.297",
|
|
118
118
|
"@stacksjs/gitit": "^0.2.5",
|
|
119
|
-
"@stacksjs/health": "^0.70.
|
|
119
|
+
"@stacksjs/health": "^0.70.297",
|
|
120
120
|
"@stacksjs/dnsx": "^0.2.3",
|
|
121
121
|
"@stacksjs/httx": "^0.1.10",
|
|
122
|
-
"@stacksjs/image": "^0.70.
|
|
123
|
-
"@stacksjs/lint": "^0.70.
|
|
124
|
-
"@stacksjs/logging": "^0.70.
|
|
125
|
-
"@stacksjs/notifications": "^0.70.
|
|
126
|
-
"@stacksjs/objects": "^0.70.
|
|
127
|
-
"@stacksjs/orm": "^0.70.
|
|
128
|
-
"@stacksjs/path": "^0.70.
|
|
129
|
-
"@stacksjs/skills": "^0.70.
|
|
130
|
-
"@stacksjs/payments": "^0.70.
|
|
131
|
-
"@stacksjs/realtime": "^0.70.
|
|
132
|
-
"@stacksjs/router": "^0.70.
|
|
122
|
+
"@stacksjs/image": "^0.70.297",
|
|
123
|
+
"@stacksjs/lint": "^0.70.297",
|
|
124
|
+
"@stacksjs/logging": "^0.70.297",
|
|
125
|
+
"@stacksjs/notifications": "^0.70.297",
|
|
126
|
+
"@stacksjs/objects": "^0.70.297",
|
|
127
|
+
"@stacksjs/orm": "^0.70.297",
|
|
128
|
+
"@stacksjs/path": "^0.70.297",
|
|
129
|
+
"@stacksjs/skills": "^0.70.297",
|
|
130
|
+
"@stacksjs/payments": "^0.70.297",
|
|
131
|
+
"@stacksjs/realtime": "^0.70.297",
|
|
132
|
+
"@stacksjs/router": "^0.70.297",
|
|
133
133
|
"@stacksjs/rpx": "^0.11.42",
|
|
134
|
-
"@stacksjs/search-engine": "^0.70.
|
|
135
|
-
"@stacksjs/security": "^0.70.
|
|
136
|
-
"@stacksjs/server": "^0.70.
|
|
137
|
-
"@stacksjs/storage": "^0.70.
|
|
138
|
-
"@stacksjs/strings": "^0.70.
|
|
139
|
-
"@stacksjs/testing": "^0.70.
|
|
140
|
-
"@stacksjs/tunnel": "^0.70.
|
|
141
|
-
"@stacksjs/types": "^0.70.
|
|
142
|
-
"@stacksjs/ui": "^0.70.
|
|
143
|
-
"@stacksjs/utils": "^0.70.
|
|
144
|
-
"@stacksjs/validation": "^0.70.
|
|
134
|
+
"@stacksjs/search-engine": "^0.70.297",
|
|
135
|
+
"@stacksjs/security": "^0.70.297",
|
|
136
|
+
"@stacksjs/server": "^0.70.297",
|
|
137
|
+
"@stacksjs/storage": "^0.70.297",
|
|
138
|
+
"@stacksjs/strings": "^0.70.297",
|
|
139
|
+
"@stacksjs/testing": "^0.70.297",
|
|
140
|
+
"@stacksjs/tunnel": "^0.70.297",
|
|
141
|
+
"@stacksjs/types": "^0.70.297",
|
|
142
|
+
"@stacksjs/ui": "^0.70.297",
|
|
143
|
+
"@stacksjs/utils": "^0.70.297",
|
|
144
|
+
"@stacksjs/validation": "^0.70.297",
|
|
145
145
|
"@stacksjs/ts-cloud": "^0.7.103",
|
|
146
146
|
"ajv": "^8.20.0",
|
|
147
147
|
"ajv-formats": "^3.0.1",
|