@stacksjs/buddy 0.75.382 → 0.75.387
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/cloud-tenants.d.ts +27 -0
- package/dist/cloud-tenants.js +1 -0
- package/dist/commands/db.d.ts +6 -0
- package/dist/commands/db.js +1 -1
- package/dist/commands/deploy.js +2 -2
- package/dist/commands/dev.d.ts +13 -0
- package/dist/commands/dev.js +1 -1
- package/dist/commands/env.js +1 -1
- package/dist/database-backup.d.ts +43 -5
- package/dist/database-backup.js +2 -2
- package/package.json +55 -55
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/** Where a private tenant list may live, in the order they are read. */
|
|
2
|
+
export declare function privateTenantFiles(options?: PrivateTenantOptions): string[];
|
|
3
|
+
/** The merged, de-duplicated tenant slugs from every private source and config. */
|
|
4
|
+
export declare function resolvePrivateTenants(options?: PrivateTenantOptions): string[];
|
|
5
|
+
/**
|
|
6
|
+
* Which projects attach to this box is operator knowledge, not framework
|
|
7
|
+
* source. Naming them in a tracked `config/cloud.ts` publishes the customer
|
|
8
|
+
* list of a shared server to everyone who can read the repository, so the list
|
|
9
|
+
* is read from places that never reach it:
|
|
10
|
+
*
|
|
11
|
+
* 1. `STACKS_CLOUD_TENANTS` - comma or whitespace separated slugs. This is
|
|
12
|
+
* what CI supplies from a secret.
|
|
13
|
+
* 2. `$STACKS_PRIVATE_DIR/tenants.json`, `~/.config/stacks/tenants.json`, then
|
|
14
|
+
* `storage/cloud/tenants.json` (gitignored, like the rest of that folder).
|
|
15
|
+
* A JSON array of slugs, or `{ "tenants": [...] }`.
|
|
16
|
+
* 3. `cloud.tenants` in config, still honoured so an existing private fork
|
|
17
|
+
* keeps working. A public repository should not declare it.
|
|
18
|
+
*
|
|
19
|
+
* Every source is merged; a file that is missing or malformed contributes
|
|
20
|
+
* nothing, because a list that cannot be read must not stop a deploy.
|
|
21
|
+
*/
|
|
22
|
+
export declare interface PrivateTenantOptions {
|
|
23
|
+
env?: Record<string, string | undefined>
|
|
24
|
+
cwd?: string
|
|
25
|
+
home?: string
|
|
26
|
+
declared?: unknown
|
|
27
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{existsSync,readFileSync}from"node:fs";import{homedir}from"node:os";import{join}from"node:path";import process from"node:process";const SLUG=/^[a-z0-9][a-z0-9-]*$/;function parseSlugs(value){return(typeof value==="string"?value.split(/[\s,]+/):Array.isArray(value)?value:[]).filter((slug)=>typeof slug==="string").map((slug)=>slug.trim().toLowerCase()).filter((slug)=>SLUG.test(slug))}function readTenantFile(path){if(!existsSync(path))return[];try{const parsed=JSON.parse(readFileSync(path,"utf8"));return parseSlugs(Array.isArray(parsed)?parsed:parsed?.tenants)}catch{return[]}}export function privateTenantFiles(options={}){const env=options.env??process.env,files=[];if(env.STACKS_PRIVATE_DIR)files.push(join(env.STACKS_PRIVATE_DIR,"tenants.json"));files.push(join(options.home??homedir(),".config","stacks","tenants.json"));files.push(join(options.cwd??process.cwd(),"storage","cloud","tenants.json"));return files}export function resolvePrivateTenants(options={}){const env=options.env??process.env,slugs=[...parseSlugs(env.STACKS_CLOUD_TENANTS),...privateTenantFiles(options).flatMap(readTenantFile),...parseSlugs(options.declared)];return[...new Set(slugs)].sort()}
|
package/dist/commands/db.d.ts
CHANGED
|
@@ -52,4 +52,10 @@ export declare function backupTarget(): Promise<BackupTarget | null>;
|
|
|
52
52
|
* that configured nothing, so the dump quietly stays on one disk.
|
|
53
53
|
*/
|
|
54
54
|
export declare function backupDestination(override?: string): Promise<BackupDestination | null>;
|
|
55
|
+
/**
|
|
56
|
+
* How many dumps to keep, behind the same config barrier as
|
|
57
|
+
* {@link backupTarget}: read early, `backups.retain` in `config/database.ts`
|
|
58
|
+
* would look unset and the default would quietly win.
|
|
59
|
+
*/
|
|
60
|
+
export declare function backupRetain(flag?: string): Promise<number>;
|
|
55
61
|
export declare function db(buddy: CLI): void;
|
package/dist/commands/db.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{existsSync,mkdirSync,readdirSync,rmSync,statSync}from"node:fs";import{isAbsolute,join,resolve}from"node:path";import process from"node:process";import{confirmOrNull,intro,log,outro}from"@stacksjs/cli";import{ExitCode}from"@stacksjs/types";import{backupFileName,backupObjectKey,describeCommand,dumpCommand,dumpSqlite,isBackupFileName,prunableBackups,resolveBackupDestination,resolveBackupTarget,parseBackupDestination,restoreCommand,restoreSqlite,
|
|
1
|
+
import{existsSync,mkdirSync,readdirSync,rmSync,statSync}from"node:fs";import{isAbsolute,join,resolve}from"node:path";import process from"node:process";import{confirmOrNull,intro,log,outro}from"@stacksjs/cli";import{ExitCode}from"@stacksjs/types";import{backupFileName,backupObjectKey,DEFAULT_RETAIN,describeCommand,dumpCommand,dumpSqlite,isBackupFileName,prunableBackups,resolveBackupDestination,resolveBackupTarget,parseBackupDestination,readDump,resolveBackupRetain,restoreCommand,restoreSqlite,writeDump}from"../database-backup";const DEFAULT_BACKUP_DIR="storage/backups/database";export async function backupTarget(){const{config,overridesReady}=await import("@stacksjs/config");await overridesReady.catch(()=>{});return resolveBackupTarget(config?.database)}export async function backupDestination(override){if(override?.trim())return parseBackupDestination(override);const{config,overridesReady}=await import("@stacksjs/config");await overridesReady.catch(()=>{});return resolveBackupDestination(config?.database,process.env)}export async function backupRetain(flag){const{config,overridesReady}=await import("@stacksjs/config");await overridesReady.catch(()=>{});return resolveBackupRetain(config?.database,process.env,flag)}async function uploadBackup(destination,file,fileName){const{Storage}=await import("@stacksjs/storage"),key=backupObjectKey(destination,fileName),diskName=destination.kind==="disk"?destination.target:"s3",disk=Storage.disk(diskName);if(typeof disk.putStream!=="function")throw TypeError(`The '${diskName}' disk cannot stream uploads, so a database dump cannot be copied to it. Use an S3-backed disk for \`backups.destination\`.`);await disk.putStream(key,Bun.file(file).stream());return destination.kind==="disk"?`disk://${destination.target}/${key}`:`s3://${destination.target}/${key}`}function backupDir(out){const dir=out?.trim()||DEFAULT_BACKUP_DIR;return isAbsolute(dir)?dir:resolve(process.cwd(),dir)}function existingBackups(dir){if(!existsSync(dir))return[];return readdirSync(dir).filter(isBackupFileName).sort()}function sqliteDatabaseExists(target){const path=sqliteFile(target);return existsSync(path)&&statSync(path).size>0}function sqliteFile(target){return isAbsolute(target.database)?target.database:resolve(process.cwd(),target.database)}function prune(dir,retain){const removed=prunableBackups(existingBackups(dir),retain);for(const name of removed)rmSync(join(dir,name),{force:!0});return removed}export function db(buddy){buddy.command("db:backup","Dump the application database to a file").option("--out [dir]",`Where to write the dump (default: ${DEFAULT_BACKUP_DIR})`).option("--retain [count]",`How many dumps to keep (default: DB_BACKUP_RETAIN, backups.retain, or ${DEFAULT_RETAIN})`).option("--before-migrations","Deploy mode: succeed quietly when there is no database yet",{default:!1}).option("--destination [uri]","Copy the dump offsite: s3://bucket/prefix or disk://name/prefix").option("--verbose","Enable verbose output",{default:!1}).example("buddy db:backup").example("buddy db:backup --out /var/backups/app --retain 30").example("buddy db:backup --destination disk://backups/daily").action(async(options)=>{const perf=await intro("buddy db:backup");try{const target=await backupTarget();if(!target){await outro("No dumpable database is configured; nothing to back up.",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)}if(target.engine==="sqlite"&&!sqliteDatabaseExists(target)){const message=`No database at ${target.database} yet; nothing to back up.`;if(options.beforeMigrations){await outro(message,{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)}log.warn(message);await outro("Nothing backed up.",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)}const retain=await backupRetain(options.retain),dir=backupDir(options.out);mkdirSync(dir,{recursive:!0});const name=backupFileName(target,new Date),destination=join(dir,name),command=dumpCommand(target);if(command){if(options.verbose)log.info(describeCommand(command));await writeDump(command,destination,target.password)}else await dumpSqlite(sqliteFile(target),destination);const size=existsSync(destination)?statSync(destination).size:0;log.success(`Wrote ${destination} (${(size/1024).toFixed(1)} KB)`);const removed=prune(dir,retain);if(removed.length)log.info(`Pruned ${removed.length} older dump(s), keeping ${retain}.`);const offsite=await backupDestination(options.destination);if(offsite){const uploaded=await uploadBackup(offsite,destination,name);log.success(`Copied to ${uploaded}`)}else log.info("This dump is on the same disk as the database. Set `backups.destination` in config/database.ts (or DB_BACKUP_DESTINATION) to copy it off the box.");await outro("Database backed up",{startTime:perf,useSeconds:!0})}catch(error){await log.error("Database backup failed:",error);process.exit(ExitCode.FatalError)}process.exit(ExitCode.Success)});buddy.command("db:backups","List the database dumps that have been taken").option("--out [dir]",`Where dumps are kept (default: ${DEFAULT_BACKUP_DIR})`).example("buddy db:backups").action(async(options)=>{const dir=backupDir(options.out),backups=existingBackups(dir);if(!backups.length){log.info(`No dumps in ${dir}.`);await log.flush();process.exit(ExitCode.Success)}log.info(`${backups.length} dump(s) in ${dir}, oldest first:`);for(const name of backups){const size=statSync(join(dir,name)).size;log.info(` ${name} ${(size/1024).toFixed(1)} KB`)}await log.flush();process.exit(ExitCode.Success)});buddy.command("db:restore [file]","Restore the application database from a dump").option("--out [dir]",`Where dumps are kept (default: ${DEFAULT_BACKUP_DIR})`).option("--force","Skip the confirmation prompt",{default:!1}).option("--verbose","Enable verbose output",{default:!1}).example("buddy db:restore").example("buddy db:restore 2026-08-13T09-00-00-000.sqlite.sqlite --force").action(async(file,options)=>{const perf=await intro("buddy db:restore");try{const target=await backupTarget();if(!target){await log.error("No restorable database is configured.");process.exit(ExitCode.FatalError)}const dir=backupDir(options.out),backups=existingBackups(dir),name=file??backups[backups.length-1];if(!name){await log.error(`No dumps found in ${dir}.`);process.exit(ExitCode.FatalError)}const source=isAbsolute(name)?name:join(dir,name);if(!existsSync(source)){await log.error(`No such dump: ${source}`);process.exit(ExitCode.FatalError)}if(!options.force){if(await confirmOrNull({message:`Replace ${target.engine} database "${target.database}" with ${name}? The current contents are lost.`,initial:!1})!==!0){await outro("Restore cancelled.",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)}}const command=restoreCommand(target);if(command){if(options.verbose)log.info(describeCommand(command));await readDump(command,source,target.password)}else{const displaced=await restoreSqlite(source,sqliteFile(target),Date.now());if(displaced)log.info(`The database that was there is kept at ${displaced}`)}log.success(`Restored ${target.database} from ${name}`);await outro("Database restored",{startTime:perf,useSeconds:!0})}catch(error){await log.error("Database restore failed:",error);process.exit(ExitCode.FatalError)}process.exit(ExitCode.Success)})}
|
package/dist/commands/deploy.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import{existsSync,mkdirSync,mkdtempSync,readFileSync,readdirSync,rmSync,statSync,writeFileSync}from"node:fs";import{homedir}from"node:os";import{dirname,isAbsolute,join,relative,resolve}from"node:path";import process from"node:process";import{pathToFileURL}from"node:url";import{runAction}from"@stacksjs/actions/run-action";import{assertPublicationAllowed}from"@stacksjs/actions/publication-preflight";import{runBeforeDeployHook}from"@stacksjs/actions/deploy/hooks";import{italic,onUnknownSubcommand,outro,prompts}from"@stacksjs/cli";import{app,dns as dnsConfig,email as emailConfig}from"@stacksjs/config";import{addDomain,hasUserDomainBeenAddedToCloud,syncDnsConfig}from"@stacksjs/dns";import{loadProjectDnsConfig}from"../config";import{env}from"@stacksjs/env";import{Action}from"@stacksjs/enums";import{path as p}from"@stacksjs/path";import{ExitCode}from"@stacksjs/types";import{getErrorCode,getErrorMessage}from"@stacksjs/utils";import{withDeployNotification}from"../deploy-notify";import{assertCurrentDeploymentRevision,assertNotBehindLiveRelease,automatedDeploymentRevision,liveReleasesCommand}from"../deployment-revision";import{dnsCredentialFix,envFileForEnvironment,resolveZoneNameservers}from"../dns-credentials";import{assertComputeResidency}from"@stacksjs/ts-cloud";import{resultFailed}from"../result";import{findUnbackedManagedServices,hasOffsiteBackupDestination,unbackedDataMessage}from"../unbacked-data";import{applyDeploymentDomainOverride,createDeploymentPreview,deploymentPreviewJsonPrefix,formatDeploymentPreview,resolveDeploymentEnvironment}from"./deploy-preview";import{buildPreviewTeardownScript,derivePreviewConfig,previewDomain,previewPort,previewTarget}from"./preview-deploy";import{deployTargetLabel,dnsPublishingAllowed,hetznerTarget,isSshPipelineProvider,lanUrls,mergeSshStatePin,remoteExecOptions,resolveSshTarget,sshCliArgs,sshStatePin,toSshTarget}from"./deploy-ssh-target";const log={info:(...args)=>console.log("\u2139",...args),success:(...args)=>console.log("\u2713",...args),warn:(...args)=>console.log("\u26A0",...args),error:(...args)=>console.error("\u2717",...args),debug:(...args)=>{if(process.argv.includes("--verbose")||process.argv.includes("-v"))console.log("\uD83D\uDD0D",...args)}},MAIL_PACKAGE_DOMAIN="github.com/mail-os/mail",MAIL_PACKAGE_SPEC=`${MAIL_PACKAGE_DOMAIN}@0.1.0`,MAIL_TARGET_PLATFORM="linux-x86_64",MAIL_BINARY_NAMES=["mail","mail-x86_64-linux","mail-x86_64-linux-gnu"];export const ROLLBACK_FLAGS_SINCE="0.16.34";export function installedTsCloudVersion(cliPath=resolveTsCloudCliPath()){try{const pkg=JSON.parse(readFileSync(resolve(dirname(cliPath),"..","..","package.json"),"utf8"));return typeof pkg.version==="string"?pkg.version:null}catch{return null}}export function versionAtLeast(a,b){const pa=a.split(".").map((n)=>Number.parseInt(n,10)||0),pb=b.split(".").map((n)=>Number.parseInt(n,10)||0);for(let i=0;i<3;i++)if((pa[i]??0)!==(pb[i]??0))return(pa[i]??0)>(pb[i]??0);return!0}export function resolveTsCloudCliPath(tsCloudEntry=import.meta.resolve("@stacksjs/ts-cloud")){return resolve(dirname(new URL(tsCloudEntry).pathname),"bin/cli.js")}export async function runDeployRollback(site,options,execute=async(command)=>{return await Bun.spawn(command,{cwd:p.projectPath(),env:process.env,stdin:"inherit",stdout:"inherit",stderr:"inherit"}).exited},tsCloudVersion=installedTsCloudVersion()){const environment=resolveDeploymentEnvironment({option:options.env}),command=[process.execPath,resolveTsCloudCliPath(),"deploy:rollback"],newFlags=[options.dryRun&&"--dry-run",options.all&&"--all",options.onlyIfLive&&"--only-if-live"].filter(Boolean);if(newFlags.length>0&&!(tsCloudVersion&&versionAtLeast(tsCloudVersion,ROLLBACK_FLAGS_SINCE))){log.error(`${newFlags.join(", ")} needs @stacksjs/ts-cloud ${ROLLBACK_FLAGS_SINCE} or newer (installed: ${tsCloudVersion??"unknown"}). An older one ignores it and rolls back for real.`);log.info(` \u27A1\uFE0F bun add @stacksjs/ts-cloud@^${ROLLBACK_FLAGS_SINCE}`);return ExitCode.InvalidArgument}if(site)command.push(site);command.push("--env",environment);if(options.to)command.push("--to",options.to);if(options.dryRun)command.push("--dry-run");if(options.all)command.push("--all");if(options.onlyIfLive)command.push("--only-if-live",options.onlyIfLive);if(options.verbose)command.push("--verbose");return await execute(command)}function collectMatchingFiles(root,names,maxDepth=8){const nameSet=new Set(names),matches=[];if(!existsSync(root))return matches;function walk(dir,depth){if(depth>maxDepth)return;for(const entry of readdirSync(dir)){if(entry===".git"||entry==="node_modules")continue;const fullPath=join(dir,entry),stat=statSync(fullPath);if(stat.isDirectory())walk(fullPath,depth+1);else if(stat.isFile()&&nameSet.has(entry))matches.push(fullPath)}}walk(root,0);return matches}function isElfBinary(filePath){const header=readFileSync(filePath).slice(0,4);return header[0]===127&&header[1]===69&&header[2]===76&&header[3]===70}function resolvePantryInstallCommand(){const localPantryCli=join(homedir(),"Code","Tools","pantry","packages","ts-pantry","bin","cli.ts");if(existsSync(localPantryCli))return{command:"bun",args:[localPantryCli]};const projectPantry=p.projectPath("pantry/.bin/pantry");if(existsSync(projectPantry))return{command:projectPantry,args:[]};const globalPantry=join(homedir(),".local","share","pantry","global","bin","pantry");if(existsSync(globalPantry))return{command:globalPantry,args:[]};return{command:"pantry",args:[]}}async function installMailBinaryWithPantry(){const{execFileSync}=await import("node:child_process"),pantry=resolvePantryInstallCommand();execFileSync(pantry.command,[...pantry.args,"install",MAIL_PACKAGE_SPEC,"--install-dir",p.projectPath("pantry"),"--platform",MAIL_TARGET_PLATFORM,"--quiet"],{cwd:p.projectPath(),stdio:process.argv.includes("--verbose")||process.argv.includes("-v")?"inherit":"pipe",env:process.env})}async function findPantryMailBinary(){const directCandidates=[...MAIL_BINARY_NAMES.map((name)=>p.projectPath(`pantry/.bin/${name}`)),...MAIL_BINARY_NAMES.map((name)=>join(homedir(),".local","share","pantry","global","bin",name))];for(const candidate of directCandidates)if(existsSync(candidate)&&isElfBinary(candidate))return candidate;for(const root of[p.projectPath("pantry"),join(homedir(),".local","share","pantry")])for(const candidate of collectMatchingFiles(root,MAIL_BINARY_NAMES))if(isElfBinary(candidate))return candidate;return null}async function ensureDeployPrerequisites(verbose=!1,environment="production"){const{ensureAppKey,ensureDeployEnvIsSet,ensureEnvIsSet}=await import("./setup"),cwd=p.projectPath();await ensureEnvIsSet({cwd,verbose});await ensureDeployEnvIsSet(cwd,environment);await ensureAppKey(cwd)}function loadAwsCredentialsFromFile(){const credentialsPath=join(homedir(),".aws","credentials"),configPath=join(homedir(),".aws","config");if(!existsSync(credentialsPath))return{};try{const lines=readFileSync(credentialsPath,"utf-8").split(`
|
|
1
|
+
import{existsSync,mkdirSync,mkdtempSync,readFileSync,readdirSync,rmSync,statSync,writeFileSync}from"node:fs";import{homedir}from"node:os";import{dirname,isAbsolute,join,relative,resolve}from"node:path";import process from"node:process";import{pathToFileURL}from"node:url";import{runAction}from"@stacksjs/actions/run-action";import{assertPublicationAllowed}from"@stacksjs/actions/publication-preflight";import{runBeforeDeployHook}from"@stacksjs/actions/deploy/hooks";import{italic,onUnknownSubcommand,outro,prompts}from"@stacksjs/cli";import{app,dns as dnsConfig,email as emailConfig}from"@stacksjs/config";import{resolvePrivateTenants}from"../cloud-tenants";import{addDomain,hasUserDomainBeenAddedToCloud,syncDnsConfig}from"@stacksjs/dns";import{loadProjectDnsConfig}from"../config";import{env}from"@stacksjs/env";import{Action}from"@stacksjs/enums";import{path as p}from"@stacksjs/path";import{ExitCode}from"@stacksjs/types";import{getErrorCode,getErrorMessage}from"@stacksjs/utils";import{withDeployNotification}from"../deploy-notify";import{assertCurrentDeploymentRevision,assertNotBehindLiveRelease,automatedDeploymentRevision,liveReleasesCommand}from"../deployment-revision";import{dnsCredentialFix,envFileForEnvironment,resolveZoneNameservers}from"../dns-credentials";import{assertComputeResidency}from"@stacksjs/ts-cloud";import{resultFailed}from"../result";import{findUnbackedManagedServices,hasOffsiteBackupDestination,unbackedDataMessage}from"../unbacked-data";import{applyDeploymentDomainOverride,createDeploymentPreview,deploymentPreviewJsonPrefix,formatDeploymentPreview,resolveDeploymentEnvironment}from"./deploy-preview";import{buildPreviewTeardownScript,derivePreviewConfig,previewDomain,previewPort,previewTarget}from"./preview-deploy";import{deployTargetLabel,dnsPublishingAllowed,hetznerTarget,isSshPipelineProvider,lanUrls,mergeSshStatePin,remoteExecOptions,resolveSshTarget,sshCliArgs,sshStatePin,toSshTarget}from"./deploy-ssh-target";const log={info:(...args)=>console.log("\u2139",...args),success:(...args)=>console.log("\u2713",...args),warn:(...args)=>console.log("\u26A0",...args),error:(...args)=>console.error("\u2717",...args),debug:(...args)=>{if(process.argv.includes("--verbose")||process.argv.includes("-v"))console.log("\uD83D\uDD0D",...args)}},MAIL_PACKAGE_DOMAIN="github.com/mail-os/mail",MAIL_PACKAGE_SPEC=`${MAIL_PACKAGE_DOMAIN}@0.1.0`,MAIL_TARGET_PLATFORM="linux-x86_64",MAIL_BINARY_NAMES=["mail","mail-x86_64-linux","mail-x86_64-linux-gnu"];export const ROLLBACK_FLAGS_SINCE="0.16.34";export function installedTsCloudVersion(cliPath=resolveTsCloudCliPath()){try{const pkg=JSON.parse(readFileSync(resolve(dirname(cliPath),"..","..","package.json"),"utf8"));return typeof pkg.version==="string"?pkg.version:null}catch{return null}}export function versionAtLeast(a,b){const pa=a.split(".").map((n)=>Number.parseInt(n,10)||0),pb=b.split(".").map((n)=>Number.parseInt(n,10)||0);for(let i=0;i<3;i++)if((pa[i]??0)!==(pb[i]??0))return(pa[i]??0)>(pb[i]??0);return!0}export function resolveTsCloudCliPath(tsCloudEntry=import.meta.resolve("@stacksjs/ts-cloud")){return resolve(dirname(new URL(tsCloudEntry).pathname),"bin/cli.js")}export async function runDeployRollback(site,options,execute=async(command)=>{return await Bun.spawn(command,{cwd:p.projectPath(),env:process.env,stdin:"inherit",stdout:"inherit",stderr:"inherit"}).exited},tsCloudVersion=installedTsCloudVersion()){const environment=resolveDeploymentEnvironment({option:options.env}),command=[process.execPath,resolveTsCloudCliPath(),"deploy:rollback"],newFlags=[options.dryRun&&"--dry-run",options.all&&"--all",options.onlyIfLive&&"--only-if-live"].filter(Boolean);if(newFlags.length>0&&!(tsCloudVersion&&versionAtLeast(tsCloudVersion,ROLLBACK_FLAGS_SINCE))){log.error(`${newFlags.join(", ")} needs @stacksjs/ts-cloud ${ROLLBACK_FLAGS_SINCE} or newer (installed: ${tsCloudVersion??"unknown"}). An older one ignores it and rolls back for real.`);log.info(` \u27A1\uFE0F bun add @stacksjs/ts-cloud@^${ROLLBACK_FLAGS_SINCE}`);return ExitCode.InvalidArgument}if(site)command.push(site);command.push("--env",environment);if(options.to)command.push("--to",options.to);if(options.dryRun)command.push("--dry-run");if(options.all)command.push("--all");if(options.onlyIfLive)command.push("--only-if-live",options.onlyIfLive);if(options.verbose)command.push("--verbose");return await execute(command)}function collectMatchingFiles(root,names,maxDepth=8){const nameSet=new Set(names),matches=[];if(!existsSync(root))return matches;function walk(dir,depth){if(depth>maxDepth)return;for(const entry of readdirSync(dir)){if(entry===".git"||entry==="node_modules")continue;const fullPath=join(dir,entry),stat=statSync(fullPath);if(stat.isDirectory())walk(fullPath,depth+1);else if(stat.isFile()&&nameSet.has(entry))matches.push(fullPath)}}walk(root,0);return matches}function isElfBinary(filePath){const header=readFileSync(filePath).slice(0,4);return header[0]===127&&header[1]===69&&header[2]===76&&header[3]===70}function resolvePantryInstallCommand(){const localPantryCli=join(homedir(),"Code","Tools","pantry","packages","ts-pantry","bin","cli.ts");if(existsSync(localPantryCli))return{command:"bun",args:[localPantryCli]};const projectPantry=p.projectPath("pantry/.bin/pantry");if(existsSync(projectPantry))return{command:projectPantry,args:[]};const globalPantry=join(homedir(),".local","share","pantry","global","bin","pantry");if(existsSync(globalPantry))return{command:globalPantry,args:[]};return{command:"pantry",args:[]}}async function installMailBinaryWithPantry(){const{execFileSync}=await import("node:child_process"),pantry=resolvePantryInstallCommand();execFileSync(pantry.command,[...pantry.args,"install",MAIL_PACKAGE_SPEC,"--install-dir",p.projectPath("pantry"),"--platform",MAIL_TARGET_PLATFORM,"--quiet"],{cwd:p.projectPath(),stdio:process.argv.includes("--verbose")||process.argv.includes("-v")?"inherit":"pipe",env:process.env})}async function findPantryMailBinary(){const directCandidates=[...MAIL_BINARY_NAMES.map((name)=>p.projectPath(`pantry/.bin/${name}`)),...MAIL_BINARY_NAMES.map((name)=>join(homedir(),".local","share","pantry","global","bin",name))];for(const candidate of directCandidates)if(existsSync(candidate)&&isElfBinary(candidate))return candidate;for(const root of[p.projectPath("pantry"),join(homedir(),".local","share","pantry")])for(const candidate of collectMatchingFiles(root,MAIL_BINARY_NAMES))if(isElfBinary(candidate))return candidate;return null}async function ensureDeployPrerequisites(verbose=!1,environment="production"){const{ensureAppKey,ensureDeployEnvIsSet,ensureEnvIsSet}=await import("./setup"),cwd=p.projectPath();await ensureEnvIsSet({cwd,verbose});await ensureDeployEnvIsSet(cwd,environment);await ensureAppKey(cwd)}function loadAwsCredentialsFromFile(){const credentialsPath=join(homedir(),".aws","credentials"),configPath=join(homedir(),".aws","config");if(!existsSync(credentialsPath))return{};try{const lines=readFileSync(credentialsPath,"utf-8").split(`
|
|
2
2
|
`),profiles=["default","stacks"];let currentProfile="",credentials={};const profileCredentials={};for(const line of lines){const trimmed=line.trim(),profileMatch=trimmed.match(/^\[(.+)\]$/);if(profileMatch?.[1]){currentProfile=profileMatch[1];profileCredentials[currentProfile]={};continue}const keyValue=trimmed.match(/^(\w+)\s*=\s*(.+)$/);if(keyValue&¤tProfile){const[,key,value]=keyValue,target=profileCredentials[currentProfile];if(!target||value===void 0)continue;if(key==="aws_access_key_id")target.accessKeyId=value;else if(key==="aws_secret_access_key")target.secretAccessKey=value}}for(const profile of profiles)if(profileCredentials[profile]?.accessKeyId&&profileCredentials[profile]?.secretAccessKey){credentials=profileCredentials[profile];log.debug(`Using AWS credentials from ~/.aws/credentials [${profile}] profile`);break}if(!credentials.accessKeyId){for(const[profile,creds]of Object.entries(profileCredentials))if(creds.accessKeyId&&creds.secretAccessKey){credentials=creds;log.debug(`Using AWS credentials from ~/.aws/credentials [${profile}] profile`);break}}let region;if(existsSync(configPath)){const regionMatch=readFileSync(configPath,"utf-8").match(/region\s*=\s*(.+)/);if(regionMatch?.[1])region=regionMatch[1].trim()}return{...credentials,region}}catch(error){log.debug("Failed to read AWS credentials file:",error);return{}}}async function setupEmailDnsRecords(emailDomain,region,logger,options){logger.info("Setting up email DNS records...");try{const{SESClient}=await import("@stacksjs/ts-cloud"),{Route53Client}=await import("@stacksjs/ts-cloud"),ses=new SESClient(region),route53=new Route53Client(region);logger.info(`Getting DKIM tokens for ${emailDomain}...`);const tokens=(await ses.getEmailIdentity(emailDomain)).DkimAttributes?.Tokens||[];if(tokens.length===0){logger.warn("No DKIM tokens found - domain may not be set up in SES yet");return}logger.info(`Found ${tokens.length} DKIM tokens`);const zone=(await route53.listHostedZones()).HostedZones?.find((z)=>z.Name===`${emailDomain}.`);if(!zone){logger.warn(`Hosted zone not found for ${emailDomain} - DNS records must be added manually`);logger.info("DKIM records needed:");for(const token of tokens)logger.info(` CNAME: ${token}._domainkey.${emailDomain} -> ${token}.dkim.amazonses.com`);logger.info(` MX: ${emailDomain} -> 10 inbound-smtp.${region}.amazonaws.com`);return}const hostedZoneId=zone.Id?.replace("/hostedzone/","");logger.info(`Found hosted zone: ${hostedZoneId}`);for(const token of tokens){const recordName=`${token}._domainkey.${emailDomain}`,recordValue=`${token}.dkim.amazonses.com`;try{await route53.changeResourceRecordSets({HostedZoneId:hostedZoneId,ChangeBatch:{Changes:[{Action:"UPSERT",ResourceRecordSet:{Name:recordName,Type:"CNAME",TTL:300,ResourceRecords:[{Value:recordValue}]}}]}});logger.success(`Added DKIM record: ${token}._domainkey`)}catch(e){logger.warn(`Failed to add DKIM record: ${getErrorMessage(e)}`)}}const mailSubdomain=options?.mailSubdomain||"mail",mxTarget=options?.mode==="server"?`10 ${mailSubdomain}.${emailDomain}`:`10 inbound-smtp.${region}.amazonaws.com`;try{await route53.changeResourceRecordSets({HostedZoneId:hostedZoneId,ChangeBatch:{Changes:[{Action:"UPSERT",ResourceRecordSet:{Name:emailDomain,Type:"MX",TTL:300,ResourceRecords:[{Value:mxTarget}]}}]}});logger.success(`Added MX record: ${mxTarget}`)}catch(e){logger.warn(`Failed to add MX record: ${getErrorMessage(e)}`)}try{await route53.changeResourceRecordSets({HostedZoneId:hostedZoneId,ChangeBatch:{Changes:[{Action:"UPSERT",ResourceRecordSet:{Name:emailDomain,Type:"TXT",TTL:300,ResourceRecords:[{Value:'"v=spf1 include:amazonses.com ~all"'}]}}]}});logger.success("Added SPF record")}catch(e){logger.warn(`Failed to add SPF record: ${getErrorMessage(e)}`)}try{await route53.changeResourceRecordSets({HostedZoneId:hostedZoneId,ChangeBatch:{Changes:[{Action:"UPSERT",ResourceRecordSet:{Name:`_dmarc.${emailDomain}`,Type:"TXT",TTL:300,ResourceRecords:[{Value:`"v=DMARC1;p=quarantine;pct=25;rua=mailto:dmarcreports@${emailDomain}"`}]}}]}});logger.success("Added DMARC record")}catch(e){logger.warn(`Failed to add DMARC record: ${getErrorMessage(e)}`)}try{const ruleSetName=`${process.env.APP_NAME?.toLowerCase().replace(/[^a-z0-9]/g,"-")||"stacks"}-email-rules`;await ses.setActiveReceiptRuleSet(ruleSetName);logger.success(`Activated email receipt rule set: ${ruleSetName}`)}catch(e){logger.warn(`Failed to activate receipt rule set: ${getErrorMessage(e)}`)}logger.success("Email DNS records configured!");logger.info("Note: DKIM verification may take 5-15 minutes to complete")}catch(error){logger.warn(`Failed to set up email DNS records: ${getErrorMessage(error)}`);logger.info("You can manually set up DNS records using: buddy email:verify")}}async function createDefaultMailUser(appName,emailDomain,region,logger){try{const{DynamoDBClient}=await import("@stacksjs/ts-cloud"),crypto=await import("crypto"),dynamodb=new DynamoDBClient(region),tableName=`${appName}-mail-users`,mailboxes=emailConfig?.mailboxes||[];if(mailboxes.length===0){const defaultEmail=`admin@${emailDomain}`,defaultPassword=crypto.randomBytes(16).toString("hex"),passwordHash=crypto.createHash("sha256").update(defaultPassword).digest("hex");try{await dynamodb.putItem({TableName:tableName,Item:{email:{S:defaultEmail},passwordHash:{S:passwordHash},createdAt:{S:new Date().toISOString()},displayName:{S:"Admin"}}});logger.success(`Created default mail user: ${defaultEmail}`);logger.info(`Password: ${defaultPassword}`);logger.info("Save this password - it will not be shown again!")}catch(e){const msg=getErrorMessage(e);if(msg.includes("ConditionalCheckFailedException")||msg.includes("already exists"))logger.debug("Default mail user already exists");else throw e}}else for(const mailbox of mailboxes){const mb=typeof mailbox==="object"&&mailbox!==null?mailbox:null,configured=mb?.email,email=mb?configured?.includes("@")?configured:`${configured??""}@${emailDomain}`:`${mailbox}@${emailDomain}`,password=mb?.password?mb.password:crypto.randomBytes(16).toString("hex"),passwordHash=crypto.createHash("sha256").update(password).digest("hex");try{await dynamodb.putItem({TableName:tableName,Item:{email:{S:email},passwordHash:{S:passwordHash},createdAt:{S:new Date().toISOString()},displayName:{S:mb?mb.displayName||email:String(mailbox)}}});logger.success(`Created mail user: ${email}`);if(!mb?.password)logger.info(` Password: ${password}`)}catch(e){const msg=getErrorMessage(e);if(msg.includes("ConditionalCheckFailedException"))logger.debug(`Mail user ${email} already exists`);else logger.warn(`Failed to create mail user ${email}: ${msg}`)}}}catch(error){logger.warn(`Failed to create mail users: ${getErrorMessage(error)}`)}}async function uploadMailServerToS3(bucketName,region,mode){try{const{S3Client:S3}=await import("@stacksjs/ts-cloud"),s3Client=new S3(region);if(mode==="serverless"){const serverTsPath=p.frameworkPath("core/mail-server/server.ts");if(existsSync(serverTsPath)){const serverCode=readFileSync(serverTsPath,"utf-8");await s3Client.putObject({bucket:bucketName,key:"mail-server/server.ts",body:serverCode,contentType:"text/typescript"});log.success("Uploaded serverless mail server code to S3")}const pkgPath=p.frameworkPath("core/mail-server/package.json");if(existsSync(pkgPath)){const pkgJson=readFileSync(pkgPath,"utf-8");await s3Client.putObject({bucket:bucketName,key:"mail-server/package.json",body:pkgJson,contentType:"application/json"})}return}let binaryUploaded=!1;try{await installMailBinaryWithPantry();const linuxBinaryPath=await findPantryMailBinary();if(linuxBinaryPath&&existsSync(linuxBinaryPath)&&isElfBinary(linuxBinaryPath)){log.info(`Uploading Pantry mail binary: ${linuxBinaryPath}`);const binaryContent=readFileSync(linuxBinaryPath);await s3Client.putObject({bucket:bucketName,key:"mail-server/smtp-server",body:binaryContent,contentType:"application/octet-stream"});log.success("Uploaded Linux x86_64 mail server binary to S3");binaryUploaded=!0}}catch(error){log.debug(`Pantry mail install failed: ${getErrorMessage(error)}`)}if(!binaryUploaded)log.warn(`No Pantry-provided ${MAIL_TARGET_PLATFORM} mail binary found. Release ${MAIL_PACKAGE_DOMAIN}, then run the Pantry binary sync for that package.`)}catch(uploadErr){log.debug(`Could not upload mail server to S3 (bucket may not exist yet): ${uploadErr.message}`)}}export async function loadTsCloudConfig(envName){try{const base=p.projectPath("config/cloud.ts");return(await import(envName?`${base}?env=${envName}`:base)).tsCloud}catch(err){log.debug("Could not load config/cloud.ts tsCloud export:",err);return}}export function resolveProvider(tsCloudConfig){return tsCloudConfig?.cloud?.provider||process.env.CLOUD_PROVIDER||"aws"}function readWaitSecs(name,defaultSecs){const secs=process.env[name]?Number.parseInt(process.env[name],10):Number.NaN;return Number.isFinite(secs)&&secs>0?secs:defaultSecs}function fmtDuration(secs){const m=Math.floor(secs/60),s=secs%60;return m?s?`${m}m${s}s`:`${m}m`:`${s}s`}export function pollFailureDetail(error){const collapsed=(error instanceof Error?error.message:typeof error==="string"?error:"").replace(/\s+/g," ").trim();if(!collapsed)return;return collapsed.length>300?`${collapsed.slice(0,297)}...`:collapsed}export function sshUnreachableMessage(opts){const detail=pollFailureDetail(opts.lastError);return`SSH did not become reachable on ${opts.ip} within ${fmtDuration(opts.waitSecs)} (waited ${opts.elapsedSecs}s).`+(detail?`
|
|
3
3
|
Last attempt: ${detail}`:"")+`
|
|
4
4
|
A connection timeout means the box is probably still booting, so raise TS_CLOUD_SSH_WAIT_SECS and retry. "Permission denied" means the key is not authorized, and a refused or reset connection (especially after earlier attempts got further) usually means fail2ban banned this IP. Waiting longer fixes neither.`}export function bunRuntimeMissingMessage(opts){const detail=pollFailureDetail(opts.lastError);return`bun runtime did not appear at /usr/local/bin/bun within ${fmtDuration(opts.waitSecs)} (waited ${opts.elapsedSecs}s).`+(detail?`
|
|
5
5
|
Last attempt: ${detail}`:"")+`
|
|
6
|
-
cloud-init may have failed: SSH in and check /var/log/cloud-init-output.log. Raise TS_CLOUD_BOOT_WAIT_SECS for slow regions.`}export async function pollUntil(opts){log.info(`${opts.label} (up to ${fmtDuration(opts.timeoutSecs)})...`);const started=Date.now(),deadline=started+opts.timeoutSecs*1000;let lastHeartbeat=0,lastError;for(;;)try{await opts.check();return}catch(err){lastError=err;const elapsedSecs=Math.floor((Date.now()-started)/1000);if(Date.now()>deadline)throw Error(opts.timeoutMessage(elapsedSecs,lastError));if(elapsedSecs-lastHeartbeat>=30){log.info(` \u2026 still waiting (${elapsedSecs}s elapsed)`);lastHeartbeat=elapsedSecs}await new Promise((r)=>setTimeout(r,opts.intervalMs??5000))}}async function waitForRemoteReady(where){const{sshExecOrThrow}=await import("@stacksjs/ts-cloud"),target=toSshTarget(where),ip=target.host,run=(remote)=>sshExecOrThrow(ip,remote,remoteExecOptions(target,10)),sshWaitSecs=readWaitSecs("TS_CLOUD_SSH_WAIT_SECS",480);await pollUntil({label:"Waiting for SSH to come up",timeoutSecs:sshWaitSecs,check:()=>run("true"),timeoutMessage:(elapsed,lastError)=>sshUnreachableMessage({ip,waitSecs:sshWaitSecs,elapsedSecs:elapsed,lastError})});log.success("SSH is up");let hasCloudInit=!0;try{await run("command -v cloud-init >/dev/null 2>&1")}catch{hasCloudInit=!1}if(hasCloudInit){log.info("Waiting for cloud-init (installing bun + caddy)...");try{await run("cloud-init status --wait || true")}catch(err){log.debug("cloud-init status --wait returned non-zero (continuing):",err)}}else log.info("No cloud-init on this host; skipping the first-boot wait.");const bootWaitSecs=readWaitSecs("TS_CLOUD_BOOT_WAIT_SECS",720);await pollUntil({label:"Waiting for the bun runtime",timeoutSecs:bootWaitSecs,check:()=>run("test -x /usr/local/bin/bun"),timeoutMessage:(elapsed,lastError)=>bunRuntimeMissingMessage({waitSecs:bootWaitSecs,elapsedSecs:elapsed,lastError})});log.success("Server is ready (bun installed)")}async function resolveDeclaredTenants(){try{const{config}=await import("@stacksjs/config")
|
|
6
|
+
cloud-init may have failed: SSH in and check /var/log/cloud-init-output.log. Raise TS_CLOUD_BOOT_WAIT_SECS for slow regions.`}export async function pollUntil(opts){log.info(`${opts.label} (up to ${fmtDuration(opts.timeoutSecs)})...`);const started=Date.now(),deadline=started+opts.timeoutSecs*1000;let lastHeartbeat=0,lastError;for(;;)try{await opts.check();return}catch(err){lastError=err;const elapsedSecs=Math.floor((Date.now()-started)/1000);if(Date.now()>deadline)throw Error(opts.timeoutMessage(elapsedSecs,lastError));if(elapsedSecs-lastHeartbeat>=30){log.info(` \u2026 still waiting (${elapsedSecs}s elapsed)`);lastHeartbeat=elapsedSecs}await new Promise((r)=>setTimeout(r,opts.intervalMs??5000))}}async function waitForRemoteReady(where){const{sshExecOrThrow}=await import("@stacksjs/ts-cloud"),target=toSshTarget(where),ip=target.host,run=(remote)=>sshExecOrThrow(ip,remote,remoteExecOptions(target,10)),sshWaitSecs=readWaitSecs("TS_CLOUD_SSH_WAIT_SECS",480);await pollUntil({label:"Waiting for SSH to come up",timeoutSecs:sshWaitSecs,check:()=>run("true"),timeoutMessage:(elapsed,lastError)=>sshUnreachableMessage({ip,waitSecs:sshWaitSecs,elapsedSecs:elapsed,lastError})});log.success("SSH is up");let hasCloudInit=!0;try{await run("command -v cloud-init >/dev/null 2>&1")}catch{hasCloudInit=!1}if(hasCloudInit){log.info("Waiting for cloud-init (installing bun + caddy)...");try{await run("cloud-init status --wait || true")}catch(err){log.debug("cloud-init status --wait returned non-zero (continuing):",err)}}else log.info("No cloud-init on this host; skipping the first-boot wait.");const bootWaitSecs=readWaitSecs("TS_CLOUD_BOOT_WAIT_SECS",720);await pollUntil({label:"Waiting for the bun runtime",timeoutSecs:bootWaitSecs,check:()=>run("test -x /usr/local/bin/bun"),timeoutMessage:(elapsed,lastError)=>bunRuntimeMissingMessage({waitSecs:bootWaitSecs,elapsedSecs:elapsed,lastError})});log.success("Server is ready (bun installed)")}async function resolveDeclaredTenants(){try{const{config}=await import("@stacksjs/config");return resolvePrivateTenants({declared:config.cloud?.tenants})}catch{return resolvePrivateTenants()}}export function normalizeDomains(domains){return domains.map((domain)=>String(domain??"").trim().toLowerCase()).filter(Boolean)}export function orphanedFragmentDomains(fragment,ours,retired=[]){const declared=new Set(normalizeDomains([...ours])),givenUp=new Set(normalizeDomains([...retired])),accountedFor=(domain,set)=>set.has(domain)||set.has(domain.replace(/^www\./,""));return[...new Set([...fragment.matchAll(/"(?:domain|to|from)"\s*:\s*"([a-z0-9.*-]+\.[a-z]{2,})"/gi)].map((match)=>String(match[1]).toLowerCase()).filter(Boolean))].filter((domain)=>!accountedFor(domain,declared)&&!accountedFor(domain,givenUp))}export function declaredSiteDomains(sites){const out=[];for(const site of Object.values(sites??{})){if(!site?.domain)continue;out.push(String(site.domain));if(Array.isArray(site.aliases))out.push(...site.aliases.map(String))}return normalizeDomains(out)}export async function assertFragmentIsOurs(where,tsCloudConfig,log){const slug=tsCloudConfig.project?.slug||"app",ours=new Set(declaredSiteDomains(tsCloudConfig.sites));let remote="";try{const{execSync}=await import("node:child_process"),args=sshCliArgs(toSshTarget(where));remote=execSync(`ssh ${args.map((a)=>`'${a}'`).join(" ")} bash -s`,{input:`cat /etc/rpx/sites.d/${slug}.json 2>/dev/null || true`,encoding:"utf8",stdio:["pipe","pipe","pipe"]})}catch{return}if(!remote.trim())return;const retired=normalizeDomains(Array.isArray(tsCloudConfig.cloud?.retiredDomains)?tsCloudConfig.cloud.retiredDomains:[]),orphaned=orphanedFragmentDomains(remote,ours,retired);if(orphaned.length===0){if(retired.length>0)log.info(`Retiring ${retired.length} domain(s) this project no longer serves: ${retired.join(", ")}`);return}log.error(`/etc/rpx/sites.d/${slug}.json on the box already serves ${orphaned.length} domain(s) this project does not declare:`);for(const domain of orphaned.slice(0,8))log.error(` ${domain}`);log.error("Deploying would replace that fragment and take those domains down.");log.info(`Either the slug '${slug}' belongs to another project (pick a different project.slug), or those domains belong here and should be in config/cloud.ts sites.`);log.info("If you mean to stop serving them, list them in `cloud.retiredDomains` in config/cloud.ts.");process.exit(ExitCode.FatalError)}export async function assertPortsAreFree(where,tsCloudConfig,log){const slug=tsCloudConfig.project?.slug||"app",wanted=new Map;for(const[name,site]of Object.entries(tsCloudConfig.sites??{})){const port=Number(site?.port);if(Number.isFinite(port)&&port>0)wanted.set(port,name)}if(wanted.size===0)return;let listing="";try{const{execSync}=await import("node:child_process"),args=sshCliArgs(toSshTarget(where));listing=execSync(`ssh ${args.map((a)=>`'${a}'`).join(" ")} bash -s`,{input:`for p in ${[...wanted.keys()].join(" ")}; do
|
|
7
7
|
pid=$(ss -lntpH "sport = :$p" 2>/dev/null | grep -oE 'pid=[0-9]+' | head -1 | cut -d= -f2)
|
|
8
8
|
[ -n "$pid" ] || continue
|
|
9
9
|
unit=$(systemctl status "$pid" 2>/dev/null | head -1 | grep -oE '[a-zA-Z0-9_.@-]+\\.service' | head -1)
|
package/dist/commands/dev.d.ts
CHANGED
|
@@ -1,4 +1,15 @@
|
|
|
1
1
|
import type { CLI, DevOptions } from '@stacksjs/types';
|
|
2
|
+
/**
|
|
3
|
+
* The line a boot prints while it waits, or null when it should say nothing.
|
|
4
|
+
*
|
|
5
|
+
* A warm boot is ready in a few seconds and prints nothing between the header
|
|
6
|
+
* and the banner, which is right. A slow one used to print nothing either, for
|
|
7
|
+
* as long as it took, so "still building" looked exactly like "dead" (#3077).
|
|
8
|
+
* Every ten seconds it now says how long and what it is waiting for. A server
|
|
9
|
+
* that crashed under `bun --watch` does not exit, so the count climbing is the
|
|
10
|
+
* only sign of it, and its own output is hidden unless `--verbose` is on.
|
|
11
|
+
*/
|
|
12
|
+
export declare function devBootProgressLine(elapsedSeconds: number, waitingOn: Array<{ name: string, port: number }>, verbose?: boolean): string | null;
|
|
2
13
|
export declare function dispatchInteractiveDevSelection(selection: string, runners: InteractiveDevRunners): Promise<boolean>;
|
|
3
14
|
export declare function resolvePrettyDevDomain(appUrl: string | undefined, nativeMode?: boolean): string | null;
|
|
4
15
|
export declare function shouldUsePrettyDevUrls(input: {
|
|
@@ -34,6 +45,8 @@ export declare function setupPrettyDevEnvironment(input: {
|
|
|
34
45
|
skipTrust?: boolean
|
|
35
46
|
verbose?: boolean
|
|
36
47
|
}): Promise<boolean>;
|
|
48
|
+
/** Seconds between the lines a slow boot prints, and before the first one. */
|
|
49
|
+
export declare const DEV_BOOT_PROGRESS_EVERY_SECONDS: 10;
|
|
37
50
|
export declare const interactiveDevChoices: readonly [{
|
|
38
51
|
value: 'all';
|
|
39
52
|
title: 'All'
|
package/dist/commands/dev.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import{execSync}from"node:child_process";import{existsSync,readdirSync,readFileSync}from"node:fs";import readline from"node:readline";import process from"node:process";import{bold,cyan,dim,green,intro,log,onUnknownSubcommand,outro,prompts,runCommand,yellow}from"@stacksjs/cli";import{homedir}from"node:os";import{dirname,join,relative}from"node:path";import{fileURLToPath}from"node:url";import{Action}from"@stacksjs/enums";import{inspectDefaultsProvenance,libsPath,projectPath,stxPath}from"@stacksjs/path";import{ExitCode}from"@stacksjs/types";import{version}from"../../package.json";import{resultFailed}from"../result";const DEV_BOOT_STARTING_LINE_COUNT=3;function eraseDevBootStartingLines(){if(!process.stdout.isTTY)return;for(let i=0;i<DEV_BOOT_STARTING_LINE_COUNT;i++){readline.moveCursor(process.stdout,0,-1);readline.clearLine(process.stdout,0)}}let developmentRpx;function resolveRpxEntryPath(){return[projectPath("node_modules/@stacksjs/rpx/dist/index.js"),join(homedir(),"Code/Tools/rpx/packages/rpx/dist/index.js"),projectPath("pantry/@stacksjs/rpx/dist/src/index.js")].find((entry)=>existsSync(entry))??null}async function importDevelopmentRpx(){if(developmentRpx)return developmentRpx;const entry=resolveRpxEntryPath();developmentRpx=entry?await import(entry):await import("@stacksjs/rpx");return developmentRpx}const activeRpxRegistryIds=[];let _actions;async function actions(){if(!_actions)_actions=await import("@stacksjs/actions");return _actions}async function checkFrameworkDefaults(){let skew;try{skew=inspectDefaultsProvenance(projectPath())}catch{return}if(skew.status==="not-applicable"||skew.status==="current")return;if(skew.status==="stale")throw Error(`Framework defaults version mismatch: storage/framework/defaults is from ${skew.vendored}, but @stacksjs/defaults ${skew.installed} is installed. Run \`buddy upgrade\` to synchronize them before starting development servers.`);try{const{measureDefaultsDrift}=await actions(),drift=measureDefaultsDrift(projectPath(),{shallow:!0});if(!drift||drift.length===0)return;log.warn(`storage/framework/defaults does not match the installed @stacksjs/defaults ${skew.installed}.`);log.warn("The app runs the vendored copy. Run `buddy upgrade` to sync it, or `buddy doctor` for the file counts.")}catch{}}export const interactiveDevChoices=[{value:"all",title:"All"},{value:"frontend",title:"Frontend"},{value:"api",title:"Backend"},{value:"dashboard",title:"Dashboard"},{value:"desktop",title:"Desktop"},{value:"native",title:"Native App"},{value:"components",title:"Components"},{value:"docs",title:"Documentation"}];export async function dispatchInteractiveDevSelection(selection,runners){if(!Object.hasOwn(runners,selection))return!1;await runners[selection]();return!0}export function resolvePrettyDevDomain(appUrl,nativeMode=!1){if(!appUrl||nativeMode)return null;try{const hostname=new URL(/^https?:\/\//i.test(appUrl)?appUrl:`https://${appUrl}`).hostname.toLowerCase();if(hostname==="localhost"||hostname==="127.0.0.1"||hostname==="::1"||hostname==="0.0.0.0")return null;return hostname}catch{return null}}export function shouldUsePrettyDevUrls(input){return input.domain!==null&&!input.localhostOnly&&(input.proxyManagedExternally||input.systemAuthorized)}const APPLICATION_VIEW_CANDIDATES=["app/index","app","dashboard/index","dashboard","home/index","home","composer","workspace/index","workspace","feed","portal","admin","account/index","account","profile/index","profile"],SITE_LAYOUT_NAMES=new Set(["default","guest","marketing","site"]);export function normalizeDevelopmentEntryPath(path){const value=path?.trim();if(!value||value==="/")return"/";try{const parsed=new URL(value,"http://stacks.localhost");return`${parsed.pathname}${parsed.search}${parsed.hash}`}catch{return"/"}}function configuredApplicationPath(root){if(process.env.APP_PATH)return process.env.APP_PATH;try{return readFileSync(join(root,"config/app.ts"),"utf8").match(/^[ \t]*appPath\s*:\s*(['"`])([^'"`]+)\1/m)?.[2]}catch{return}}function configuredDevelopmentLaunch(root){const environmentValue=process.env.STACKS_DEV_LAUNCH?.trim().toLowerCase();if(environmentValue==="browser"||environmentValue==="native")return environmentValue;try{const value=readFileSync(join(root,"config/app.ts"),"utf8").match(/\bdevLaunch\s*:\s*(['"`])(browser|native)\1/)?.[2];return value==="browser"||value==="native"?value:void 0}catch{return}}export function resolveDevelopmentLaunch(input={}){if(input.browser||input.site)return"browser";if(input.native)return"native";const root=input.root??projectPath();return input.configuredLaunch??configuredDevelopmentLaunch(root)??"browser"}function viewSource(root,route){const base=join(root,"resources/views",route);for(const extension of["stx","vue","html"]){const path=`${base}.${extension}`;if(existsSync(path))try{return readFileSync(path,"utf8")}catch{return""}}return}function usesApplicationLayout(source){const layout=source.match(/@extends\(\s*['"]layouts\/([^'"]+)['"]\s*\)/)?.[1]??source.match(/@layout\(\s*['"]layouts\/([^'"]+)['"]\s*\)/)?.[1];if(!layout)return!1;return!SITE_LAYOUT_NAMES.has(layout.split("/").at(-1)??layout)}export function resolveDevelopmentEntryPath(input={}){if(input.site)return"/";const root=input.root??projectPath(),configuredPath=input.configuredPath??configuredApplicationPath(root);if(configuredPath)return normalizeDevelopmentEntryPath(configuredPath);const loginExists=viewSource(root,"login")!==void 0||viewSource(root,"auth/login")!==void 0,candidates=APPLICATION_VIEW_CANDIDATES.map((route)=>({route,source:viewSource(root,route)})).filter((candidate)=>candidate.source!==void 0),appCandidate=candidates.find((candidate)=>usesApplicationLayout(candidate.source))??(loginExists?candidates[0]:void 0);if(appCandidate)return normalizeDevelopmentEntryPath(`/${appCandidate.route.replace(/\/index$/,"")}`);return loginExists?"/login":"/"}export function developmentUrl(baseUrl,entryPath){const normalized=normalizeDevelopmentEntryPath(entryPath);if(normalized==="/")return baseUrl.replace(/\/$/,"");return new URL(normalized,`${baseUrl.replace(/\/$/,"")}/`).toString()}export function developmentBrowserCommand(url,platform=process.platform){if(platform==="darwin")return["open",url];if(platform==="win32")return["cmd","/c","start","",url];return["xdg-open",url]}function openDevelopmentBrowser(url){if(!process.stdout.isTTY||process.env.CI||process.env.STACKS_DEV_NO_OPEN==="1")return;try{Bun.spawn(developmentBrowserCommand(url),{stdin:"ignore",stdout:"ignore",stderr:"ignore"}).unref()}catch{}}async function canStartPrettyDevProxy(){if(await waitForHttpsProxy(443,150))return!0;try{const{authorizeSystemAccess}=await importDevelopmentRpx();return authorizeSystemAccess({interactive:!1})}catch{return!1}}export function dev(buddy){const descriptions={dev:"Start development server",frontend:"Start the frontend development server",components:"Start the Components development server",desktop:"Start the Desktop App development server",native:"Start the app in a native Craft window",dashboard:"Start the Dashboard development server",api:"Start the local API development server",docs:"Start the Documentation development server",systemTray:"Start the System Tray development server",interactive:"Get asked which development server to start",select:"Which development server are you trying to start?",withLocalhost:"Include the localhost URL in the output",browser:"Open the application in a browser instead of its configured native window",site:"Open the marketing site instead of the application",project:"Target a specific project",verbose:"Enable verbose output"};buddy.command("dev [server]",descriptions.dev).option("-f, --frontend",descriptions.frontend).option("-a, --api",descriptions.api).option("-c, --components",descriptions.components).option("-d, --dashboard",descriptions.dashboard).option("-k, --desktop",descriptions.desktop).option("-n, --native",descriptions.native).option("-o, --docs",descriptions.docs).option("-s, --system-tray",descriptions.systemTray).option("-i, --interactive",descriptions.interactive,{default:!1}).option("-l, --with-localhost",descriptions.withLocalhost,{default:!1}).option("--browser",descriptions.browser,{default:!1}).option("--site",descriptions.site,{default:!1}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(server,options)=>{const perf=Bun.nanoseconds();process.env.STACKS_DEV_ENTRY_PATH=resolveDevelopmentEntryPath({site:options.site});await checkFrameworkDefaults();const target=server||(options.frontend?"frontend":void 0)||(options.api?"api":void 0)||(options.components?"components":void 0)||(options.dashboard?"dashboard":void 0)||(options.desktop?"desktop":void 0)||(options.native?"native":void 0)||(options.systemTray||options["system-tray"]?"system-tray":void 0)||(options.docs?"docs":void 0);if(target){const serverOptions={...options},a=await actions();switch(target){case"native":await startDevelopmentServer({...serverOptions,native:!0},perf);break;case"frontend":await a.runFrontendDevServer(serverOptions);break;case"api":await a.runApiDevServer(serverOptions);break;case"components":await a.runComponentsDevServer(serverOptions);break;case"dashboard":await a.runDashboardDevServer(serverOptions);break;case"desktop":await startDevelopmentServer({...serverOptions,native:!0},perf);break;case"system-tray":await a.runSystemTrayDevServer(serverOptions);break;case"docs":await a.runDocsDevServer(serverOptions);break;default:await log.error(`Unknown server: ${target}`);process.exit(ExitCode.InvalidArgument)}}else if(wantsInteractive(options)){const selectedValue=await prompts.select({message:descriptions.select,choices:interactiveDevChoices.map((choice)=>({value:choice.value,label:choice.title}))}),a=await actions();if(!await dispatchInteractiveDevSelection(selectedValue,{all:()=>startDevelopmentServer(options,perf),frontend:()=>a.runFrontendDevServer(options),api:()=>a.runApiDevServer(options),dashboard:()=>a.runDashboardDevServer(options),desktop:()=>startDevelopmentServer({...options,native:!0},perf),native:()=>startDevelopmentServer({...options,native:!0},perf),components:()=>a.runComponentsDevServer(options),docs:()=>a.runDocsDevServer(options)})){await log.error("Invalid option during interactive mode");process.exit(ExitCode.InvalidArgument)}}else await startDevelopmentServer(options,perf);outro("Exited",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("dev:components",descriptions.components).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{await checkFrameworkDefaults();const perf=await intro("buddy dev:components"),result=await runCommand("bun run dev",{cwd:libsPath("components/stx")});if(options.verbose)log.info("buddy dev:components result",result);if(resultFailed(result)){await outro("While running the dev:components command, there was an issue",{startTime:perf,useSeconds:!0},result.error);process.exit(ExitCode.FatalError)}console.log("");await outro("Exited",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("dev:docs",descriptions.docs).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{await checkFrameworkDefaults();const perf=await intro("buddy dev:docs"),result=await(await actions()).runAction(Action.DevDocs,options);if(resultFailed(result)){await outro("While running the dev:docs command, there was an issue",{startTime:perf,useSeconds:!0},result.error);process.exit(ExitCode.FatalError)}console.log("");await outro("Exited",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("dev:native",descriptions.native).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{await checkFrameworkDefaults();const perf=await intro("buddy dev:native");await startDevelopmentServer({...options,native:!0},perf)});buddy.command("dev:desktop",descriptions.desktop).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{await checkFrameworkDefaults();const perf=await intro("buddy dev:desktop");await startDevelopmentServer({...options,native:!0},perf)});buddy.command("dev:api",descriptions.api).option("-p, --project [project]",descriptions.project,{default:!1}).option("--no-watch-types","Skip the model/config type-regeneration watcher").option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{await checkFrameworkDefaults();const a=await actions();if(options.watchTypes!==!1)a.watchTypes(options).catch((err)=>{log.warn("[dev:api] type watcher exited:",err)});await a.runApiDevServer(options)});buddy.command("dev:frontend",descriptions.frontend).alias("dev:pages").alias("dev:views").option("-p, --project [project]",descriptions.project,{default:!1}).option("--site",descriptions.site,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{await checkFrameworkDefaults();process.env.STACKS_DEV_ENTRY_PATH=resolveDevelopmentEntryPath({site:options.site});await(await actions()).runFrontendDevServer(options)});buddy.command("dev:dashboard",descriptions.dashboard).alias("dev:admin").option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{await checkFrameworkDefaults();await(await actions()).runDashboardDevServer(options)});buddy.command("dev:system-tray",descriptions.systemTray).alias("dev:tray").option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{await checkFrameworkDefaults();await(await actions()).runSystemTrayDevServer(options)});onUnknownSubcommand(buddy,"dev")}export async function startDevelopmentServer(_options,_startTime){const options=_options,startedAt=_startTime,appUrl=process.env.APP_URL??"stacks.localhost",nativeMode=resolveDevelopmentLaunch({browser:options.browser,native:options.native,site:options.site})==="native",entryPath=resolveDevelopmentEntryPath({site:options.site});process.env.STACKS_DEV_ENTRY_PATH=entryPath;const proxyManagedExternally=process.env.STACKS_PROXY_MANAGED==="1",preferredFrontendPort=Number(process.env.PORT)||3000,preferredApiPort=Number(process.env.PORT_API)||3008,preferredDocsPort=Number(process.env.PORT_DOCS)||3006,preferredDashboardPort=Number(process.env.PORT_ADMIN)||3002;if(process.env.STACKS_DEV_NO_KILL!=="1")await cleanupStaleDevProcesses([preferredFrontendPort,preferredApiPort,preferredDocsPort,preferredDashboardPort]);const portShifts=[],claimPort=async(label,preferred)=>{if(proxyManagedExternally)return preferred;const port=await findAvailablePort(preferred);if(port!==preferred)portShifts.push(`${label} :${preferred} \u2192 :${port}`);return port},frontendPort=await claimPort("Frontend",preferredFrontendPort),apiPort=await claimPort("API",preferredApiPort),docsPort=await claimPort("Docs",preferredDocsPort),dashboardPort=await claimPort("Dashboard",preferredDashboardPort);process.env.PORT=String(frontendPort);process.env.PORT_API=String(apiPort);process.env.PORT_DOCS=String(docsPort);process.env.PORT_ADMIN=String(dashboardPort);const includeDashboard=process.env.STACKS_DEV_DASHBOARD==="1",domain=resolvePrettyDevDomain(appUrl,nativeMode),appLooksCustom=domain!==null,localhostOnly=process.env.STACKS_DEV_LOCALHOST==="1",prettyUrlsRequested=appLooksCustom&&!localhostOnly,systemAuthorized=!prettyUrlsRequested||proxyManagedExternally?!0:await canStartPrettyDevProxy(),usePrettyUrls=shouldUsePrettyDevUrls({domain,localhostOnly,proxyManagedExternally,systemAuthorized}),prettySetupRequired=prettyUrlsRequested&&!usePrettyUrls,hasCustomDomain=usePrettyUrls&&!proxyManagedExternally,displayedDomain=usePrettyUrls?domain:null,dashboardDomain=displayedDomain?`dashboard.${displayedDomain}`:null,frontendBaseUrl=displayedDomain?`https://${displayedDomain}`:`http://localhost:${frontendPort}`,frontendUrl=developmentUrl(frontendBaseUrl,entryPath),apiUrl=displayedDomain?`https://${displayedDomain}/api`:`http://localhost:${apiPort}`,docsUrl=displayedDomain?`https://${displayedDomain}/docs`:`http://localhost:${docsPort}`,dashboardUrl=dashboardDomain?`https://${dashboardDomain}`:`http://localhost:${dashboardPort}`;process.env.STACKS_PROXY_MANAGED="1";process.env.STACKS_DEV_QUIET="1";console.log();if(prettySetupRequired){console.log(` ${yellow("\u26A0")} ${yellow("Pretty URL setup required")} ${dim("- using localhost for this session")}`);console.log(` ${dim(" ")}${dim("Run `./buddy setup:ssl` once, then restart `./buddy dev`.")}`);console.log()}if(portShifts.length>0){console.log(` ${yellow("\u26A0")} ${yellow("Ports in use by another process")} ${dim(`- ${portShifts.join(", ")}`)}`);console.log()}console.log(` ${bold(cyan("stacks"))} ${dim(`v${version}`)} ${dim("starting\u2026")}`);console.log();const rpxTlsPreflight=hasCustomDomain&&domain?prepareRpxTlsForDev({domain,includeDashboard,options}):Promise.resolve();if(localhostOnly&&domain&&!proxyManagedExternally)removeStalePublicDomainOverrides(domain,includeDashboard,options.verbose??!1).catch(()=>{});rpxTlsPreflight.catch(()=>{});let isExiting=!1,closeNativeApp;const SHUTDOWN_GRACE_MS=1500,cleanup=(signal)=>{if(isExiting)return;isExiting=!0;console.log();console.log(` ${bold(cyan("stacks"))} ${dim(`received ${signal} - shutting down the dev server`)}`);if(signal==="SIGHUP")console.log(` ${dim(" ")}${dim("The launching shell went away (a closed terminal, or a CI step that ended). nohup does not prevent this.")}`);closeNativeApp?.();const rpxTeardown=(async()=>{try{await unregisterRpxProxies(activeRpxRegistryIds);activeRpxRegistryIds.length=0;if(hasCustomDomain&&domain)await removeStalePublicDomainOverrides(domain,includeDashboard,options.verbose??!1)}catch{}})(),teardownDeadline=new Promise((resolve)=>setTimeout(resolve,SHUTDOWN_GRACE_MS));Promise.race([rpxTeardown,teardownDeadline]).finally(()=>{try{process.kill(-process.pid,"SIGTERM")}catch{try{process.kill(0,"SIGTERM")}catch{}}setTimeout(()=>{try{process.kill(0,"SIGKILL")}catch{process.exit(1)}},SHUTDOWN_GRACE_MS).unref()})};process.on("SIGINT",cleanup);process.on("SIGTERM",cleanup);process.on("SIGHUP",cleanup);const quietOpts={...options,quiet:!0},a=await actions(),ports=[{name:"API",port:apiPort}],readinessTimeoutMs=30000;let readyAnnounced=!1;const nativeUrl=developmentUrl(`http://localhost:${frontendPort}`,entryPath),nativeWindowReady=nativeMode?waitForPort(frontendPort,readinessTimeoutMs).then(async(ready)=>{if(!ready){log.warn(`Native window skipped because the frontend did not answer within ${readinessTimeoutMs/1000}s`);return}closeNativeApp=await launchNativeAppWindow(nativeUrl,options)}):Promise.resolve();Promise.all(ports.map((p)=>waitForPort(p.port,readinessTimeoutMs))).then(async(results)=>{if(readyAnnounced)return;readyAnnounced=!0;const failed=results.map((ok,i)=>ok?null:ports[i]?.name??null).filter((x)=>x!==null);let proxyReachable=!1;if(hasCustomDomain&&domain){(async()=>{try{await rpxTlsPreflight;await registerRpxProxiesForDomain({domain,frontendPort,apiPort,docsPort,dashboardPort,includeDashboard,options})}catch{}})();proxyReachable=await waitForHttpsProxy(443,16000);if(!proxyReachable){console.log(` ${yellow("\u26A0")} ${yellow("HTTPS proxy not reachable on :443")} - serving ${cyan(`http://localhost:${frontendPort}`)} instead`);console.log(` ${dim(" ")}${dim("rpx needs a valid SUDO_PASSWORD in .env to bind :443; trust the local CA, then restart `./buddy dev`.")}`);if(options.verbose)console.log(` ${dim(" ")}${dim(`Trust CA: sh ${join(RPX_SSL_DIR,"trust-rpx-cert.sh")}`)}`)}}eraseDevBootStartingLines();printDevReadyBanner({options,nativeMode,hasCustomDomain:!!appLooksCustom&&!localhostOnly,proxyReachable:proxyManagedExternally?!0:proxyReachable,frontendUrl,frontendBaseUrl,entryPath,apiUrl,docsUrl,dashboardUrl,frontendPort,apiPort,docsPort,includeDashboard,domain,dashboardPort,dashboardDomain});if(!nativeMode){const browserUrl=hasCustomDomain&&(proxyManagedExternally||proxyReachable)?frontendUrl:developmentUrl(`http://localhost:${frontendPort}`,entryPath);openDevelopmentBrowser(browserUrl)}if(startedAt){const elapsedMs=(Bun.nanoseconds()-startedAt)/1e6,summary=failed.length?`ready in ${(elapsedMs/1000).toFixed(1)}s - ${failed.join(", ")} did not bind within ${readinessTimeoutMs/1000}s`:`ready in ${(elapsedMs/1000).toFixed(1)}s`;console.log(` ${dim(summary)}`);console.log();printDevEngineNotes();console.log()}if(process.env.STACKS_PRINT_ROUTES==="1")await printRegisteredRoutes(apiPort).catch(()=>{})}).catch(()=>{});await Promise.all([a.runFrontendDevServer(quietOpts).catch((error)=>{if(options.verbose)log.error(`Frontend: ${error}`)}),a.runApiDevServer(quietOpts).catch((error)=>{if(options.verbose)log.error(`API: ${error}`)}),a.runDocsDevServer(quietOpts).catch((error)=>{if(options.verbose)log.error(`Docs: ${error}`)}),includeDashboard?a.runDashboardDevServer(quietOpts).catch((error)=>{if(options.verbose)log.error(`Dashboard: ${error}`)}):Promise.resolve(),nativeWindowReady.catch((error)=>{if(options.verbose)log.warn(`Native window: ${error}`)})])}function isComingSoonMode(){if(existsSync(projectPath("storage/framework/coming-soon")))return!0;try{const idx=projectPath("resources/views/index.stx");return existsSync(idx)&&/siteMode\s*=\s*['"]coming-soon['"]/.test(readFileSync(idx,"utf8"))}catch{return!1}}function blogIsConfigured(){try{const dir=projectPath("content/blog");return existsSync(dir)&&readdirSync(dir).some((f)=>f.endsWith(".md"))}catch{return!1}}function printDevReadyBanner(input){const{options,nativeMode,hasCustomDomain,proxyReachable,frontendUrl,frontendBaseUrl,entryPath,apiUrl,docsUrl,dashboardUrl,frontendPort,apiPort,docsPort,includeDashboard,domain,dashboardPort,dashboardDomain}=input,verbose=options.verbose??!1,useProxy=hasCustomDomain&&proxyReachable,feUrl=useProxy?frontendUrl:developmentUrl(`http://localhost:${frontendPort}`,entryPath),feBaseUrl=useProxy?frontendBaseUrl:`http://localhost:${frontendPort}`,apUrl=useProxy?apiUrl:`http://localhost:${apiPort}`,dcUrl=useProxy?docsUrl:`http://localhost:${docsPort}`,dbUrl=useProxy?dashboardUrl:`http://localhost:${dashboardPort}`,blogUrl=`${feBaseUrl}/blog`;console.log();const frontendLabel=entryPath==="/"?"Site":"App";console.log(` ${green("\u279C")} ${bold(frontendLabel.padEnd(8))}: ${cyan(feUrl)}`);if(entryPath!=="/")console.log(` ${dim("\u279C")} ${dim("Site")}: ${dim(feBaseUrl)}`);if(nativeMode)console.log(` ${green("\u279C")} ${bold("Native")}: ${cyan(`Craft \u2192 ${feUrl}`)}`);console.log(` ${green("\u279C")} ${bold("API")}: ${cyan(apUrl)}`);console.log(` ${green("\u279C")} ${bold("Docs")}: ${cyan(dcUrl)}`);if(blogIsConfigured())console.log(` ${green("\u279C")} ${bold("Blog")}: ${cyan(blogUrl)}`);if(includeDashboard)console.log(` ${green("\u279C")} ${bold("Dashboard")}: ${cyan(dbUrl)}`);if(useProxy){console.log(` ${dim("\u279C")} ${dim("Direct")}: ${dim(`${developmentUrl(`http://localhost:${frontendPort}`,entryPath)} (bypasses the proxy)`)}`);if(includeDashboard&&dashboardDomain)console.log(` ${dim("\u279C")} ${dim("Direct")}: ${dim(`http://localhost:${dashboardPort} (dashboard, bypasses the proxy)`)}`)}if(isComingSoonMode()){console.log();console.log(` ${yellow("\u25CF")} ${bold(yellow("Coming soon mode"))} ${dim("- visitors see the holding page; bypass with the coming-soon secret.")}`)}if(verbose&&domain){console.log(` ${dim("\u279C")} ${dim("Proxy")}: ${dim(`localhost:${frontendPort} \u2192 ${domain}`)}`);console.log(` ${dim("\u279C")} ${dim("Proxy")}: ${dim(`${frontendBaseUrl}/api \u2192 localhost:${apiPort}`)}`);console.log(` ${dim("\u279C")} ${dim("Proxy")}: ${dim(`${frontendBaseUrl}/docs \u2192 localhost:${docsPort}`)}`);if(includeDashboard)console.log(` ${dim("\u279C")} ${dim("Proxy")}: ${dim(`localhost:${dashboardPort} \u2192 ${dashboardDomain}`)}`)}console.log()}function printDevEngineNotes(){const routesFile=stxPath("routes.ts"),routesLabel=relative(projectPath(),routesFile);if(["config/css.ts","css.config.ts","config/crosswind.ts","crosswind.config.ts"].some((config)=>existsSync(join(projectPath(),config))))console.log(` ${green("[ts-css]")} ${dim("CSS engine loaded")}`);if(existsSync(routesFile))try{const routeCount=(readFileSync(routesFile,"utf8").match(/pattern:/g)??[]).length;if(routeCount>0)console.log(` ${green("[stx]")} ${dim(`Generated ${routeCount} routes \u2192 ${routesLabel}`)}`)}catch{console.log(` ${green("[stx]")} ${dim(`Routes manifest \u2192 ${routesLabel}`)}`)}}async function launchNativeAppWindow(url,options){let createApp;try{createApp=(await importCraftSdk()).createApp}catch(error){if(options.verbose)log.warn(`Native window unavailable: ${error}`);console.log(` ${dim("Native window unavailable. Install craft-native or set CRAFT_BIN to a Craft binary.")}
|
|
1
|
+
import{execSync}from"node:child_process";import{existsSync,readdirSync,readFileSync}from"node:fs";import readline from"node:readline";import process from"node:process";import{bold,cyan,dim,green,intro,log,onUnknownSubcommand,outro,prompts,runCommand,yellow}from"@stacksjs/cli";import{homedir}from"node:os";import{dirname,join,relative}from"node:path";import{fileURLToPath}from"node:url";import{Action}from"@stacksjs/enums";import{inspectDefaultsProvenance,libsPath,projectPath,stxPath}from"@stacksjs/path";import{ExitCode}from"@stacksjs/types";import{version}from"../../package.json";import{resultFailed}from"../result";const DEV_BOOT_STARTING_LINE_COUNT=3;function eraseDevBootStartingLines(progressLines=0){if(!process.stdout.isTTY)return;for(let i=0;i<DEV_BOOT_STARTING_LINE_COUNT+progressLines;i++){readline.moveCursor(process.stdout,0,-1);readline.clearLine(process.stdout,0)}}export const DEV_BOOT_PROGRESS_EVERY_SECONDS=10;const DEV_BOOT_VERBOSE_HINT_SECONDS=30;export function devBootProgressLine(elapsedSeconds,waitingOn,verbose=!1){if(!waitingOn.length||elapsedSeconds<DEV_BOOT_PROGRESS_EVERY_SECONDS)return null;const servers=waitingOn.map((server)=>`the ${server.name} on :${server.port}`).join(", "),line=`still starting, ${Math.floor(elapsedSeconds)}s so far, waiting for ${servers}`;return!verbose&&elapsedSeconds>=DEV_BOOT_VERBOSE_HINT_SECONDS?`${line} (\`./buddy dev --verbose\` shows its output)`:line}let developmentRpx;function resolveRpxEntryPath(){return[projectPath("node_modules/@stacksjs/rpx/dist/index.js"),join(homedir(),"Code/Tools/rpx/packages/rpx/dist/index.js"),projectPath("pantry/@stacksjs/rpx/dist/src/index.js")].find((entry)=>existsSync(entry))??null}async function importDevelopmentRpx(){if(developmentRpx)return developmentRpx;const entry=resolveRpxEntryPath();developmentRpx=entry?await import(entry):await import("@stacksjs/rpx");return developmentRpx}const activeRpxRegistryIds=[];let _actions;async function actions(){if(!_actions)_actions=await import("@stacksjs/actions");return _actions}async function checkFrameworkDefaults(){let skew;try{skew=inspectDefaultsProvenance(projectPath())}catch{return}if(skew.status==="not-applicable"||skew.status==="current")return;if(skew.status==="stale")throw Error(`Framework defaults version mismatch: storage/framework/defaults is from ${skew.vendored}, but @stacksjs/defaults ${skew.installed} is installed. Run \`buddy upgrade\` to synchronize them before starting development servers.`);try{const{measureDefaultsDrift}=await actions(),drift=measureDefaultsDrift(projectPath(),{shallow:!0});if(!drift||drift.length===0)return;log.warn(`storage/framework/defaults does not match the installed @stacksjs/defaults ${skew.installed}.`);log.warn("The app runs the vendored copy. Run `buddy upgrade` to sync it, or `buddy doctor` for the file counts.")}catch{}}export const interactiveDevChoices=[{value:"all",title:"All"},{value:"frontend",title:"Frontend"},{value:"api",title:"Backend"},{value:"dashboard",title:"Dashboard"},{value:"desktop",title:"Desktop"},{value:"native",title:"Native App"},{value:"components",title:"Components"},{value:"docs",title:"Documentation"}];export async function dispatchInteractiveDevSelection(selection,runners){if(!Object.hasOwn(runners,selection))return!1;await runners[selection]();return!0}export function resolvePrettyDevDomain(appUrl,nativeMode=!1){if(!appUrl||nativeMode)return null;try{const hostname=new URL(/^https?:\/\//i.test(appUrl)?appUrl:`https://${appUrl}`).hostname.toLowerCase();if(hostname==="localhost"||hostname==="127.0.0.1"||hostname==="::1"||hostname==="0.0.0.0")return null;return hostname}catch{return null}}export function shouldUsePrettyDevUrls(input){return input.domain!==null&&!input.localhostOnly&&(input.proxyManagedExternally||input.systemAuthorized)}const APPLICATION_VIEW_CANDIDATES=["app/index","app","dashboard/index","dashboard","home/index","home","composer","workspace/index","workspace","feed","portal","admin","account/index","account","profile/index","profile"],SITE_LAYOUT_NAMES=new Set(["default","guest","marketing","site"]);export function normalizeDevelopmentEntryPath(path){const value=path?.trim();if(!value||value==="/")return"/";try{const parsed=new URL(value,"http://stacks.localhost");return`${parsed.pathname}${parsed.search}${parsed.hash}`}catch{return"/"}}function configuredApplicationPath(root){if(process.env.APP_PATH)return process.env.APP_PATH;try{return readFileSync(join(root,"config/app.ts"),"utf8").match(/^[ \t]*appPath\s*:\s*(['"`])([^'"`]+)\1/m)?.[2]}catch{return}}function configuredDevelopmentLaunch(root){const environmentValue=process.env.STACKS_DEV_LAUNCH?.trim().toLowerCase();if(environmentValue==="browser"||environmentValue==="native")return environmentValue;try{const value=readFileSync(join(root,"config/app.ts"),"utf8").match(/\bdevLaunch\s*:\s*(['"`])(browser|native)\1/)?.[2];return value==="browser"||value==="native"?value:void 0}catch{return}}export function resolveDevelopmentLaunch(input={}){if(input.browser||input.site)return"browser";if(input.native)return"native";const root=input.root??projectPath();return input.configuredLaunch??configuredDevelopmentLaunch(root)??"browser"}function viewSource(root,route){const base=join(root,"resources/views",route);for(const extension of["stx","vue","html"]){const path=`${base}.${extension}`;if(existsSync(path))try{return readFileSync(path,"utf8")}catch{return""}}return}function usesApplicationLayout(source){const layout=source.match(/@extends\(\s*['"]layouts\/([^'"]+)['"]\s*\)/)?.[1]??source.match(/@layout\(\s*['"]layouts\/([^'"]+)['"]\s*\)/)?.[1];if(!layout)return!1;return!SITE_LAYOUT_NAMES.has(layout.split("/").at(-1)??layout)}export function resolveDevelopmentEntryPath(input={}){if(input.site)return"/";const root=input.root??projectPath(),configuredPath=input.configuredPath??configuredApplicationPath(root);if(configuredPath)return normalizeDevelopmentEntryPath(configuredPath);const loginExists=viewSource(root,"login")!==void 0||viewSource(root,"auth/login")!==void 0,candidates=APPLICATION_VIEW_CANDIDATES.map((route)=>({route,source:viewSource(root,route)})).filter((candidate)=>candidate.source!==void 0),appCandidate=candidates.find((candidate)=>usesApplicationLayout(candidate.source))??(loginExists?candidates[0]:void 0);if(appCandidate)return normalizeDevelopmentEntryPath(`/${appCandidate.route.replace(/\/index$/,"")}`);return loginExists?"/login":"/"}export function developmentUrl(baseUrl,entryPath){const normalized=normalizeDevelopmentEntryPath(entryPath);if(normalized==="/")return baseUrl.replace(/\/$/,"");return new URL(normalized,`${baseUrl.replace(/\/$/,"")}/`).toString()}export function developmentBrowserCommand(url,platform=process.platform){if(platform==="darwin")return["open",url];if(platform==="win32")return["cmd","/c","start","",url];return["xdg-open",url]}function openDevelopmentBrowser(url){if(!process.stdout.isTTY||process.env.CI||process.env.STACKS_DEV_NO_OPEN==="1")return;try{Bun.spawn(developmentBrowserCommand(url),{stdin:"ignore",stdout:"ignore",stderr:"ignore"}).unref()}catch{}}async function canStartPrettyDevProxy(){if(await waitForHttpsProxy(443,150))return!0;try{const{authorizeSystemAccess}=await importDevelopmentRpx();return authorizeSystemAccess({interactive:!1})}catch{return!1}}export function dev(buddy){const descriptions={dev:"Start development server",frontend:"Start the frontend development server",components:"Start the Components development server",desktop:"Start the Desktop App development server",native:"Start the app in a native Craft window",dashboard:"Start the Dashboard development server",api:"Start the local API development server",docs:"Start the Documentation development server",systemTray:"Start the System Tray development server",interactive:"Get asked which development server to start",select:"Which development server are you trying to start?",withLocalhost:"Include the localhost URL in the output",browser:"Open the application in a browser instead of its configured native window",site:"Open the marketing site instead of the application",project:"Target a specific project",verbose:"Enable verbose output"};buddy.command("dev [server]",descriptions.dev).option("-f, --frontend",descriptions.frontend).option("-a, --api",descriptions.api).option("-c, --components",descriptions.components).option("-d, --dashboard",descriptions.dashboard).option("-k, --desktop",descriptions.desktop).option("-n, --native",descriptions.native).option("-o, --docs",descriptions.docs).option("-s, --system-tray",descriptions.systemTray).option("-i, --interactive",descriptions.interactive,{default:!1}).option("-l, --with-localhost",descriptions.withLocalhost,{default:!1}).option("--browser",descriptions.browser,{default:!1}).option("--site",descriptions.site,{default:!1}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(server,options)=>{const perf=Bun.nanoseconds();process.env.STACKS_DEV_ENTRY_PATH=resolveDevelopmentEntryPath({site:options.site});await checkFrameworkDefaults();const target=server||(options.frontend?"frontend":void 0)||(options.api?"api":void 0)||(options.components?"components":void 0)||(options.dashboard?"dashboard":void 0)||(options.desktop?"desktop":void 0)||(options.native?"native":void 0)||(options.systemTray||options["system-tray"]?"system-tray":void 0)||(options.docs?"docs":void 0);if(target){const serverOptions={...options},a=await actions();switch(target){case"native":await startDevelopmentServer({...serverOptions,native:!0},perf);break;case"frontend":await a.runFrontendDevServer(serverOptions);break;case"api":await a.runApiDevServer(serverOptions);break;case"components":await a.runComponentsDevServer(serverOptions);break;case"dashboard":await a.runDashboardDevServer(serverOptions);break;case"desktop":await startDevelopmentServer({...serverOptions,native:!0},perf);break;case"system-tray":await a.runSystemTrayDevServer(serverOptions);break;case"docs":await a.runDocsDevServer(serverOptions);break;default:await log.error(`Unknown server: ${target}`);process.exit(ExitCode.InvalidArgument)}}else if(wantsInteractive(options)){const selectedValue=await prompts.select({message:descriptions.select,choices:interactiveDevChoices.map((choice)=>({value:choice.value,label:choice.title}))}),a=await actions();if(!await dispatchInteractiveDevSelection(selectedValue,{all:()=>startDevelopmentServer(options,perf),frontend:()=>a.runFrontendDevServer(options),api:()=>a.runApiDevServer(options),dashboard:()=>a.runDashboardDevServer(options),desktop:()=>startDevelopmentServer({...options,native:!0},perf),native:()=>startDevelopmentServer({...options,native:!0},perf),components:()=>a.runComponentsDevServer(options),docs:()=>a.runDocsDevServer(options)})){await log.error("Invalid option during interactive mode");process.exit(ExitCode.InvalidArgument)}}else await startDevelopmentServer(options,perf);outro("Exited",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("dev:components",descriptions.components).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{await checkFrameworkDefaults();const perf=await intro("buddy dev:components"),result=await runCommand("bun run dev",{cwd:libsPath("components/stx")});if(options.verbose)log.info("buddy dev:components result",result);if(resultFailed(result)){await outro("While running the dev:components command, there was an issue",{startTime:perf,useSeconds:!0},result.error);process.exit(ExitCode.FatalError)}console.log("");await outro("Exited",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("dev:docs",descriptions.docs).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{await checkFrameworkDefaults();const perf=await intro("buddy dev:docs"),result=await(await actions()).runAction(Action.DevDocs,options);if(resultFailed(result)){await outro("While running the dev:docs command, there was an issue",{startTime:perf,useSeconds:!0},result.error);process.exit(ExitCode.FatalError)}console.log("");await outro("Exited",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("dev:native",descriptions.native).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{await checkFrameworkDefaults();const perf=await intro("buddy dev:native");await startDevelopmentServer({...options,native:!0},perf)});buddy.command("dev:desktop",descriptions.desktop).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{await checkFrameworkDefaults();const perf=await intro("buddy dev:desktop");await startDevelopmentServer({...options,native:!0},perf)});buddy.command("dev:api",descriptions.api).option("-p, --project [project]",descriptions.project,{default:!1}).option("--no-watch-types","Skip the model/config type-regeneration watcher").option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{await checkFrameworkDefaults();const a=await actions();if(options.watchTypes!==!1)a.watchTypes(options).catch((err)=>{log.warn("[dev:api] type watcher exited:",err)});await a.runApiDevServer(options)});buddy.command("dev:frontend",descriptions.frontend).alias("dev:pages").alias("dev:views").option("-p, --project [project]",descriptions.project,{default:!1}).option("--site",descriptions.site,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{await checkFrameworkDefaults();process.env.STACKS_DEV_ENTRY_PATH=resolveDevelopmentEntryPath({site:options.site});await(await actions()).runFrontendDevServer(options)});buddy.command("dev:dashboard",descriptions.dashboard).alias("dev:admin").option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{await checkFrameworkDefaults();await(await actions()).runDashboardDevServer(options)});buddy.command("dev:system-tray",descriptions.systemTray).alias("dev:tray").option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{await checkFrameworkDefaults();await(await actions()).runSystemTrayDevServer(options)});onUnknownSubcommand(buddy,"dev")}export async function startDevelopmentServer(_options,_startTime){const options=_options,startedAt=_startTime,appUrl=process.env.APP_URL??"stacks.localhost",nativeMode=resolveDevelopmentLaunch({browser:options.browser,native:options.native,site:options.site})==="native",entryPath=resolveDevelopmentEntryPath({site:options.site});process.env.STACKS_DEV_ENTRY_PATH=entryPath;const proxyManagedExternally=process.env.STACKS_PROXY_MANAGED==="1",preferredFrontendPort=Number(process.env.PORT)||3000,preferredApiPort=Number(process.env.PORT_API)||3008,preferredDocsPort=Number(process.env.PORT_DOCS)||3006,preferredDashboardPort=Number(process.env.PORT_ADMIN)||3002;if(process.env.STACKS_DEV_NO_KILL!=="1")await cleanupStaleDevProcesses([preferredFrontendPort,preferredApiPort,preferredDocsPort,preferredDashboardPort]);const portShifts=[],claimPort=async(label,preferred)=>{if(proxyManagedExternally)return preferred;const port=await findAvailablePort(preferred);if(port!==preferred)portShifts.push(`${label} :${preferred} \u2192 :${port}`);return port},frontendPort=await claimPort("Frontend",preferredFrontendPort),apiPort=await claimPort("API",preferredApiPort),docsPort=await claimPort("Docs",preferredDocsPort),dashboardPort=await claimPort("Dashboard",preferredDashboardPort);process.env.PORT=String(frontendPort);process.env.PORT_API=String(apiPort);process.env.PORT_DOCS=String(docsPort);process.env.PORT_ADMIN=String(dashboardPort);const includeDashboard=process.env.STACKS_DEV_DASHBOARD==="1",domain=resolvePrettyDevDomain(appUrl,nativeMode),appLooksCustom=domain!==null,localhostOnly=process.env.STACKS_DEV_LOCALHOST==="1",prettyUrlsRequested=appLooksCustom&&!localhostOnly,systemAuthorized=!prettyUrlsRequested||proxyManagedExternally?!0:await canStartPrettyDevProxy(),usePrettyUrls=shouldUsePrettyDevUrls({domain,localhostOnly,proxyManagedExternally,systemAuthorized}),prettySetupRequired=prettyUrlsRequested&&!usePrettyUrls,hasCustomDomain=usePrettyUrls&&!proxyManagedExternally,displayedDomain=usePrettyUrls?domain:null,dashboardDomain=displayedDomain?`dashboard.${displayedDomain}`:null,frontendBaseUrl=displayedDomain?`https://${displayedDomain}`:`http://localhost:${frontendPort}`,frontendUrl=developmentUrl(frontendBaseUrl,entryPath),apiUrl=displayedDomain?`https://${displayedDomain}/api`:`http://localhost:${apiPort}`,docsUrl=displayedDomain?`https://${displayedDomain}/docs`:`http://localhost:${docsPort}`,dashboardUrl=dashboardDomain?`https://${dashboardDomain}`:`http://localhost:${dashboardPort}`;process.env.STACKS_PROXY_MANAGED="1";process.env.STACKS_DEV_QUIET="1";console.log();if(prettySetupRequired){console.log(` ${yellow("\u26A0")} ${yellow("Pretty URL setup required")} ${dim("- using localhost for this session")}`);console.log(` ${dim(" ")}${dim("Run `./buddy setup:ssl` once, then restart `./buddy dev`.")}`);console.log()}if(portShifts.length>0){console.log(` ${yellow("\u26A0")} ${yellow("Ports in use by another process")} ${dim(`- ${portShifts.join(", ")}`)}`);console.log()}console.log(` ${bold(cyan("stacks"))} ${dim(`v${version}`)} ${dim("starting\u2026")}`);console.log();const rpxTlsPreflight=hasCustomDomain&&domain?prepareRpxTlsForDev({domain,includeDashboard,options}):Promise.resolve();if(localhostOnly&&domain&&!proxyManagedExternally)removeStalePublicDomainOverrides(domain,includeDashboard,options.verbose??!1).catch(()=>{});rpxTlsPreflight.catch(()=>{});let isExiting=!1,closeNativeApp;const SHUTDOWN_GRACE_MS=1500,cleanup=(signal)=>{if(isExiting)return;isExiting=!0;console.log();console.log(` ${bold(cyan("stacks"))} ${dim(`received ${signal} - shutting down the dev server`)}`);if(signal==="SIGHUP")console.log(` ${dim(" ")}${dim("The launching shell went away (a closed terminal, or a CI step that ended). nohup does not prevent this.")}`);closeNativeApp?.();const rpxTeardown=(async()=>{try{await unregisterRpxProxies(activeRpxRegistryIds);activeRpxRegistryIds.length=0;if(hasCustomDomain&&domain)await removeStalePublicDomainOverrides(domain,includeDashboard,options.verbose??!1)}catch{}})(),teardownDeadline=new Promise((resolve)=>setTimeout(resolve,SHUTDOWN_GRACE_MS));Promise.race([rpxTeardown,teardownDeadline]).finally(()=>{try{process.kill(-process.pid,"SIGTERM")}catch{try{process.kill(0,"SIGTERM")}catch{}}setTimeout(()=>{try{process.kill(0,"SIGKILL")}catch{process.exit(1)}},SHUTDOWN_GRACE_MS).unref()})};process.on("SIGINT",cleanup);process.on("SIGTERM",cleanup);process.on("SIGHUP",cleanup);const quietOpts={...options,quiet:!0},a=await actions(),ports=[{name:"API",port:apiPort}],readinessTimeoutMs=30000;let readyAnnounced=!1;const nativeUrl=developmentUrl(`http://localhost:${frontendPort}`,entryPath),nativeWindowReady=nativeMode?waitForPort(frontendPort,readinessTimeoutMs).then(async(ready)=>{if(!ready){log.warn(`Native window skipped because the frontend did not answer within ${readinessTimeoutMs/1000}s`);return}closeNativeApp=await launchNativeAppWindow(nativeUrl,options)}):Promise.resolve(),waitingOn=new Set(ports),bootStartedMs=Date.now();let progressLines=0,nextProgressAt=DEV_BOOT_PROGRESS_EVERY_SECONDS;const progress=setInterval(()=>{const elapsed=(Date.now()-bootStartedMs)/1000;if(elapsed<nextProgressAt)return;nextProgressAt+=DEV_BOOT_PROGRESS_EVERY_SECONDS;const line=devBootProgressLine(elapsed,[...waitingOn],options.verbose);if(!line)return;console.log(` ${dim(line)}`);progressLines++},1000);progress.unref?.();Promise.all(ports.map((p)=>waitForPort(p.port,Number.POSITIVE_INFINITY).then(()=>waitingOn.delete(p)))).then(async()=>{clearInterval(progress);if(readyAnnounced)return;readyAnnounced=!0;let proxyReachable=!1;if(hasCustomDomain&&domain){(async()=>{try{await rpxTlsPreflight;await registerRpxProxiesForDomain({domain,frontendPort,apiPort,docsPort,dashboardPort,includeDashboard,options})}catch{}})();proxyReachable=await waitForHttpsProxy(443,16000);if(!proxyReachable){console.log(` ${yellow("\u26A0")} ${yellow("HTTPS proxy not reachable on :443")} - serving ${cyan(`http://localhost:${frontendPort}`)} instead`);console.log(` ${dim(" ")}${dim("rpx needs a valid SUDO_PASSWORD in .env to bind :443; trust the local CA, then restart `./buddy dev`.")}`);if(options.verbose)console.log(` ${dim(" ")}${dim(`Trust CA: sh ${join(RPX_SSL_DIR,"trust-rpx-cert.sh")}`)}`)}}eraseDevBootStartingLines(progressLines);printDevReadyBanner({options,nativeMode,hasCustomDomain:!!appLooksCustom&&!localhostOnly,proxyReachable:proxyManagedExternally?!0:proxyReachable,frontendUrl,frontendBaseUrl,entryPath,apiUrl,docsUrl,dashboardUrl,frontendPort,apiPort,docsPort,includeDashboard,domain,dashboardPort,dashboardDomain});if(!nativeMode){const browserUrl=hasCustomDomain&&(proxyManagedExternally||proxyReachable)?frontendUrl:developmentUrl(`http://localhost:${frontendPort}`,entryPath);openDevelopmentBrowser(browserUrl)}if(startedAt){const elapsedMs=(Bun.nanoseconds()-startedAt)/1e6;console.log(` ${dim(`ready in ${(elapsedMs/1000).toFixed(1)}s`)}`);console.log();printDevEngineNotes();console.log()}if(process.env.STACKS_PRINT_ROUTES==="1")await printRegisteredRoutes(apiPort).catch(()=>{})}).catch(()=>{});await Promise.all([a.runFrontendDevServer(quietOpts).catch((error)=>{if(options.verbose)log.error(`Frontend: ${error}`)}),a.runApiDevServer(quietOpts).catch((error)=>{if(options.verbose)log.error(`API: ${error}`)}),a.runDocsDevServer(quietOpts).catch((error)=>{if(options.verbose)log.error(`Docs: ${error}`)}),includeDashboard?a.runDashboardDevServer(quietOpts).catch((error)=>{if(options.verbose)log.error(`Dashboard: ${error}`)}):Promise.resolve(),nativeWindowReady.catch((error)=>{if(options.verbose)log.warn(`Native window: ${error}`)})])}function isComingSoonMode(){if(existsSync(projectPath("storage/framework/coming-soon")))return!0;try{const idx=projectPath("resources/views/index.stx");return existsSync(idx)&&/siteMode\s*=\s*['"]coming-soon['"]/.test(readFileSync(idx,"utf8"))}catch{return!1}}function blogIsConfigured(){try{const dir=projectPath("content/blog");return existsSync(dir)&&readdirSync(dir).some((f)=>f.endsWith(".md"))}catch{return!1}}function printDevReadyBanner(input){const{options,nativeMode,hasCustomDomain,proxyReachable,frontendUrl,frontendBaseUrl,entryPath,apiUrl,docsUrl,dashboardUrl,frontendPort,apiPort,docsPort,includeDashboard,domain,dashboardPort,dashboardDomain}=input,verbose=options.verbose??!1,useProxy=hasCustomDomain&&proxyReachable,feUrl=useProxy?frontendUrl:developmentUrl(`http://localhost:${frontendPort}`,entryPath),feBaseUrl=useProxy?frontendBaseUrl:`http://localhost:${frontendPort}`,apUrl=useProxy?apiUrl:`http://localhost:${apiPort}`,dcUrl=useProxy?docsUrl:`http://localhost:${docsPort}`,dbUrl=useProxy?dashboardUrl:`http://localhost:${dashboardPort}`,blogUrl=`${feBaseUrl}/blog`;console.log();const frontendLabel=entryPath==="/"?"Site":"App";console.log(` ${green("\u279C")} ${bold(frontendLabel.padEnd(8))}: ${cyan(feUrl)}`);if(entryPath!=="/")console.log(` ${dim("\u279C")} ${dim("Site")}: ${dim(feBaseUrl)}`);if(nativeMode)console.log(` ${green("\u279C")} ${bold("Native")}: ${cyan(`Craft \u2192 ${feUrl}`)}`);console.log(` ${green("\u279C")} ${bold("API")}: ${cyan(apUrl)}`);console.log(` ${green("\u279C")} ${bold("Docs")}: ${cyan(dcUrl)}`);if(blogIsConfigured())console.log(` ${green("\u279C")} ${bold("Blog")}: ${cyan(blogUrl)}`);if(includeDashboard)console.log(` ${green("\u279C")} ${bold("Dashboard")}: ${cyan(dbUrl)}`);if(useProxy){console.log(` ${dim("\u279C")} ${dim("Direct")}: ${dim(`${developmentUrl(`http://localhost:${frontendPort}`,entryPath)} (bypasses the proxy)`)}`);if(includeDashboard&&dashboardDomain)console.log(` ${dim("\u279C")} ${dim("Direct")}: ${dim(`http://localhost:${dashboardPort} (dashboard, bypasses the proxy)`)}`)}if(isComingSoonMode()){console.log();console.log(` ${yellow("\u25CF")} ${bold(yellow("Coming soon mode"))} ${dim("- visitors see the holding page; bypass with the coming-soon secret.")}`)}if(verbose&&domain){console.log(` ${dim("\u279C")} ${dim("Proxy")}: ${dim(`localhost:${frontendPort} \u2192 ${domain}`)}`);console.log(` ${dim("\u279C")} ${dim("Proxy")}: ${dim(`${frontendBaseUrl}/api \u2192 localhost:${apiPort}`)}`);console.log(` ${dim("\u279C")} ${dim("Proxy")}: ${dim(`${frontendBaseUrl}/docs \u2192 localhost:${docsPort}`)}`);if(includeDashboard)console.log(` ${dim("\u279C")} ${dim("Proxy")}: ${dim(`localhost:${dashboardPort} \u2192 ${dashboardDomain}`)}`)}console.log()}function printDevEngineNotes(){const routesFile=stxPath("routes.ts"),routesLabel=relative(projectPath(),routesFile);if(["config/css.ts","css.config.ts","config/crosswind.ts","crosswind.config.ts"].some((config)=>existsSync(join(projectPath(),config))))console.log(` ${green("[ts-css]")} ${dim("CSS engine loaded")}`);if(existsSync(routesFile))try{const routeCount=(readFileSync(routesFile,"utf8").match(/pattern:/g)??[]).length;if(routeCount>0)console.log(` ${green("[stx]")} ${dim(`Generated ${routeCount} routes \u2192 ${routesLabel}`)}`)}catch{console.log(` ${green("[stx]")} ${dim(`Routes manifest \u2192 ${routesLabel}`)}`)}}async function launchNativeAppWindow(url,options){let createApp;try{createApp=(await importCraftSdk()).createApp}catch(error){if(options.verbose)log.warn(`Native window unavailable: ${error}`);console.log(` ${dim("Native window unavailable. Install craft-native or set CRAFT_BIN to a Craft binary.")}
|
|
2
2
|
`);return}if(!createApp){console.log(` ${dim("Native window unavailable. The Craft SDK did not export createApp.")}
|
|
3
3
|
`);return}const craftBinaryPath=resolveCraftBinaryPath();if(!craftBinaryPath){console.log(` ${dim("Native window unavailable. Set CRAFT_BIN to a Craft binary, or install Craft in ~/Code/Tools/craft.")}
|
|
4
4
|
`);return}const appIconPath=resolveNativeAppIconPath(),app=createApp({url,quiet:!options.verbose,craftPath:craftBinaryPath,window:{title:await resolveNativeAppTitle(),width:1280,height:860,titlebarHidden:!0,webSidebarMaterial:!0,webSidebarWidth:286,webSidebarMaterialOpacity:0.78,...appIconPath&&{icon:appIconPath}}});app.show().catch((error)=>{if(options.verbose)log.warn(`Native window exited: ${error}`)});return()=>app.close()}async function importCraftSdk(){const localCraftSdk=process.env.HOME?`${process.env.HOME}/Code/Tools/craft/packages/typescript/src/index.ts`:void 0;if(localCraftSdk&&existsSync(localCraftSdk))return await import(localCraftSdk);const packageNames=["craft-native","@craft-native/craft","@stacksjs/ts-craft"];let primaryError;for(const packageName of packageNames)try{return await import(packageName)}catch(error){primaryError??=error}throw primaryError}async function cleanupStaleDevProcesses(ports){const projectRoot=projectPath(),actionDevPath=projectPath("storage/framework/core/actions/src/dev/"),pids=new Set;for(const port of ports)for(const pid of await listenerPids(port)){if(pid===process.pid)continue;const command=await commandForPid(pid);if(!command.includes(projectRoot))continue;if(command.includes("storage/framework/core/buddy/src/cli.ts dev")||command.includes("storage/framework/core/actions/src/dev/")||command.includes(actionDevPath))pids.add(pid)}if(pids.size===0)return;for(const pid of pids)try{process.kill(pid,"SIGTERM")}catch{}await new Promise((r)=>setTimeout(r,250));for(const pid of pids)try{process.kill(pid,"SIGKILL")}catch{}await new Promise((r)=>setTimeout(r,150))}async function listenerPids(port){try{const proc=Bun.spawn(["lsof","-nP",`-iTCP:${port}`,"-sTCP:LISTEN","-t"],{stdout:"pipe",stderr:"ignore"}),output=await new Response(proc.stdout).text();await proc.exited;return output.split(/\s+/).map((value)=>Number(value)).filter((pid)=>Number.isInteger(pid)&&pid>0)}catch{return[]}}async function commandForPid(pid){try{const proc=Bun.spawn(["ps","-p",String(pid),"-o","command="],{stdout:"pipe",stderr:"ignore"}),output=await new Response(proc.stdout).text();await proc.exited;return output.trim()}catch{return""}}function resolveCraftBinaryPath(){const home=process.env.HOME;return[process.env.CRAFT_BIN,home?`${home}/Code/Tools/craft/craft`:void 0,home?`${home}/Code/Tools/craft/bin/craft`:void 0,home?`${home}/Code/Tools/craft/packages/zig/zig-out/bin/craft`:void 0,home?`${home}/Documents/Projects/craft/packages/zig/zig-out/bin/craft`:void 0].filter((candidate)=>Boolean(candidate)).find((candidate)=>existsSync(candidate))}function resolveNativeAppIconPath(){return[projectPath("resources/assets/images/app-icon.png"),projectPath("resources/assets/images/icon.png"),projectPath("public/icon.png")].find((candidate)=>existsSync(candidate))}async function resolveNativeAppTitle(){try{const pkg=await Bun.file(projectPath("package.json")).json(),name=typeof pkg.productName==="string"?pkg.productName:pkg.name;if(typeof name==="string"&&name.length>0)return name.split(/[-_\s]+/).filter(Boolean).map((part)=>part.charAt(0).toUpperCase()+part.slice(1)).join(" ")}catch{}return"Stacks App"}const METHOD_COLORS={GET:cyan,POST:green,PUT:cyan,PATCH:cyan,DELETE:cyan,OPTIONS:dim};async function printRegisteredRoutes(apiPort){try{const ac=new AbortController,t=setTimeout(()=>ac.abort(),1500),res=await fetch(`http://127.0.0.1:${apiPort}/__routes`,{signal:ac.signal}).catch(()=>null);clearTimeout(t);if(!res||!res.ok)return;const routes=await res.json();if(!Array.isArray(routes)||routes.length===0)return;const sorted=[...routes].sort((a,b)=>a.path.localeCompare(b.path)||a.method.localeCompare(b.method)),longestPath=Math.max(...sorted.map((r)=>r.path.length));console.log(` ${dim("Registered routes")}
|
package/dist/commands/env.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import process from"node:process";import{log,onUnknownSubcommand}from"@stacksjs/cli";import{decryptEnv,encryptEnv,envKeyNames,getEnv,getKeypair,isValidPublicKey,publicKeyMatchesPrivateKey,resolveEnvFile,resolveEnvPrivateKey,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",rotateStdout:"Print the rotated file and its new keypair without writing either",rotateDryRun:"Report what would change without writing anything",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("-t, --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("--stdin","Read the exact value from piped standard input instead of an argument",{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").example("buddy env:set SECRET --stdin --file .env.production").action(async(key,value,options)=>{if(!key){console.error("A key is required. Pass an empty value to clear it: `buddy env:set KEY ''`");process.exit(ExitCode.FatalError)}if(options.stdin&&value!==void 0){console.error("Choose a value argument or --stdin, not both.");process.exit(ExitCode.FatalError)}let inputValue=value;if(options.stdin){if(process.stdin.isTTY){console.error("--stdin needs a pipe or file redirection; no value was written.");process.exit(ExitCode.FatalError)}try{inputValue=await Bun.stdin.text()}catch{console.error("Could not read the value from standard input; no value was written.");process.exit(ExitCode.FatalError)}}if(inputValue===void 0){console.error(`No value given for ${key}. Pass one, or an empty string to clear it: \`buddy env:set ${key} ''\``);process.exit(ExitCode.FatalError)}log.debug("Running `buddy env:set` ...",{file:options.file,stdin:options.stdin,plain:options.plain});const result=setEnv(key,inputValue,{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.rotateStdout,{default:!1}).option("--dry-run",descriptions.rotateDryRun,{default:!1}).option("-ek, --exclude-key [excludeKey]",descriptions.excludeKey,{default:""}).example("buddy env:rotate").example("buddy env:rotate --file .env.production").example("buddy env:rotate --file .env.production --dry-run").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,dryRun:options.dryRun});if(result.success){console.log(result.output);if(result.notice)console.error(result.notice);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=resolveEnvFile(options.file,options.env)||".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),varCount=Object.keys(values).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{publicKeyName,privateKeyName}=envKeyNames(envFile),privateKeyNames=[privateKeyName,"DOTENV_PRIVATE_KEY"],publicKey=values[publicKeyName]||values.DOTENV_PUBLIC_KEY,hasPublicKey=Boolean(publicKey),privateKey=resolveEnvPrivateKey({file:envFile})||privateKeyNames.map((name)=>values[name]).find(Boolean),hasPrivateKey=Boolean(privateKey);if(options.strict&&privateKey&&isValidPublicKey(publicKey)&&!publicKeyMatchesPrivateKey(publicKey,privateKey))checks.push({name:"Encryption metadata",status:"fail",message:`The public key in ${envFile} does not match the configured private key. Restore matching metadata or run \`buddy env:rotate\` before writing encrypted values.`});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) - ${envFile} is local-only and never deployed`});else{checks.push({name:"Tenant isolation",status:"warn",message:`${total} key(s) belong to another tenant - 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 findings=plaintextSecrets(values,{placeholders,strict:options.strict}),metadata=findings.filter((finding)=>finding.reason==="invalid-public-key"||finding.reason==="private-key-material"),leaked=findings.filter((finding)=>finding.reason!=="invalid-public-key"&&finding.reason!=="private-key-material");if(metadata.length)checks.push({name:"Encryption metadata",status:"fail",message:`Unsafe encryption metadata in committed ${envFile}: ${metadata.map((finding)=>finding.key).join(", ")}. Keep private keys outside Git and restore valid public keys. For legacy keys, run \`buddy env:rotate\`.`});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 - 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,app}=config,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}
|
|
1
|
+
import process from"node:process";import{log,onUnknownSubcommand}from"@stacksjs/cli";import{decryptEnv,encryptEnv,envKeyNames,getEnv,getKeypair,isValidPublicKey,publicKeyMatchesPrivateKey,resolveEnvFile,resolveEnvPrivateKey,rotateKeypair,setEnv}from"@stacksjs/env";import{ExitCode}from"@stacksjs/types";import{resolvePrivateTenants}from"../cloud-tenants";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",rotateStdout:"Print the rotated file and its new keypair without writing either",rotateDryRun:"Report what would change without writing anything",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("-t, --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("--stdin","Read the exact value from piped standard input instead of an argument",{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").example("buddy env:set SECRET --stdin --file .env.production").action(async(key,value,options)=>{if(!key){console.error("A key is required. Pass an empty value to clear it: `buddy env:set KEY ''`");process.exit(ExitCode.FatalError)}if(options.stdin&&value!==void 0){console.error("Choose a value argument or --stdin, not both.");process.exit(ExitCode.FatalError)}let inputValue=value;if(options.stdin){if(process.stdin.isTTY){console.error("--stdin needs a pipe or file redirection; no value was written.");process.exit(ExitCode.FatalError)}try{inputValue=await Bun.stdin.text()}catch{console.error("Could not read the value from standard input; no value was written.");process.exit(ExitCode.FatalError)}}if(inputValue===void 0){console.error(`No value given for ${key}. Pass one, or an empty string to clear it: \`buddy env:set ${key} ''\``);process.exit(ExitCode.FatalError)}log.debug("Running `buddy env:set` ...",{file:options.file,stdin:options.stdin,plain:options.plain});const result=setEnv(key,inputValue,{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.rotateStdout,{default:!1}).option("--dry-run",descriptions.rotateDryRun,{default:!1}).option("-ek, --exclude-key [excludeKey]",descriptions.excludeKey,{default:""}).example("buddy env:rotate").example("buddy env:rotate --file .env.production").example("buddy env:rotate --file .env.production --dry-run").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,dryRun:options.dryRun});if(result.success){console.log(result.output);if(result.notice)console.error(result.notice);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=resolveEnvFile(options.file,options.env)||".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),varCount=Object.keys(values).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{publicKeyName,privateKeyName}=envKeyNames(envFile),privateKeyNames=[privateKeyName,"DOTENV_PRIVATE_KEY"],publicKey=values[publicKeyName]||values.DOTENV_PUBLIC_KEY,hasPublicKey=Boolean(publicKey),privateKey=resolveEnvPrivateKey({file:envFile})||privateKeyNames.map((name)=>values[name]).find(Boolean),hasPrivateKey=Boolean(privateKey);if(options.strict&&privateKey&&isValidPublicKey(publicKey)&&!publicKeyMatchesPrivateKey(publicKey,privateKey))checks.push({name:"Encryption metadata",status:"fail",message:`The public key in ${envFile} does not match the configured private key. Restore matching metadata or run \`buddy env:rotate\` before writing encrypted values.`});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) - ${envFile} is local-only and never deployed`});else{checks.push({name:"Tenant isolation",status:"warn",message:`${total} key(s) belong to another tenant - 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 findings=plaintextSecrets(values,{placeholders,strict:options.strict}),metadata=findings.filter((finding)=>finding.reason==="invalid-public-key"||finding.reason==="private-key-material"),leaked=findings.filter((finding)=>finding.reason!=="invalid-public-key"&&finding.reason!=="private-key-material");if(metadata.length)checks.push({name:"Encryption metadata",status:"fail",message:`Unsafe encryption metadata in committed ${envFile}: ${metadata.map((finding)=>finding.key).join(", ")}. Keep private keys outside Git and restore valid public keys. For legacy keys, run \`buddy env:rotate\`.`});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 - 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;return{self:config.app?.name,tenants:resolvePrivateTenants({declared:cloud?.tenants})}}catch{return{tenants:resolvePrivateTenants()}}}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}
|
|
@@ -16,10 +16,19 @@ export declare function resolveBackupTarget(databaseConfig: unknown): BackupTarg
|
|
|
16
16
|
* stat of every file.
|
|
17
17
|
*/
|
|
18
18
|
export declare function backupFileName(target: BackupTarget, at: Date): string;
|
|
19
|
-
/**
|
|
19
|
+
/**
|
|
20
|
+
* Does this name look like something {@link backupFileName} produced?
|
|
21
|
+
*
|
|
22
|
+
* Uncompressed `.sql` is still recognised: dumps taken before #3079 have to
|
|
23
|
+
* keep counting toward retention and stay restorable.
|
|
24
|
+
*/
|
|
20
25
|
export declare function isBackupFileName(name: string): boolean;
|
|
26
|
+
/** Is this dump gzipped, so it has to be inflated on its way back in? */
|
|
27
|
+
export declare function isCompressedBackup(name: string): boolean;
|
|
21
28
|
/**
|
|
22
|
-
* The external command that writes a dump of `target` to
|
|
29
|
+
* The external command that writes a dump of `target` to its stdout, for
|
|
30
|
+
* {@link writeDump} to compress on the way to disk. Neither tool can gzip
|
|
31
|
+
* a plain dump itself on every version we meet, and mysqldump not at all.
|
|
23
32
|
*
|
|
24
33
|
* `null` for SQLite, which is copied in-process with `VACUUM INTO` rather than
|
|
25
34
|
* shelled out to a `sqlite3` binary that may not be installed.
|
|
@@ -29,14 +38,30 @@ export declare function isBackupFileName(name: string): boolean;
|
|
|
29
38
|
* the production database password to the process table on every release would
|
|
30
39
|
* be a worse bug than the one this file is fixing.
|
|
31
40
|
*/
|
|
32
|
-
export declare function dumpCommand(target: BackupTarget
|
|
41
|
+
export declare function dumpCommand(target: BackupTarget): DumpCommand | null;
|
|
33
42
|
/**
|
|
34
|
-
* The command that reads a dump back in
|
|
43
|
+
* The command that reads a dump back in, from its stdin, which
|
|
44
|
+
* {@link readDump} feeds (inflating a gzipped dump on the way).
|
|
35
45
|
*
|
|
36
46
|
* Restoring SQLite is a file copy, so this returns `null` for it, exactly as
|
|
37
47
|
* {@link dumpCommand} does.
|
|
38
48
|
*/
|
|
39
|
-
export declare function restoreCommand(target: BackupTarget
|
|
49
|
+
export declare function restoreCommand(target: BackupTarget): DumpCommand | null;
|
|
50
|
+
/**
|
|
51
|
+
* Run `command` and gzip what it prints into `destination`.
|
|
52
|
+
*
|
|
53
|
+
* Written beside the destination under a `.partial` name and renamed only once
|
|
54
|
+
* the tool has exited cleanly, so a dump that fails halfway never takes the
|
|
55
|
+
* place of the newest good one, which is the one a hurried restore picks.
|
|
56
|
+
* Streamed, because a dump is the whole database and the box is sized for the
|
|
57
|
+
* web process.
|
|
58
|
+
*/
|
|
59
|
+
export declare function writeDump(command: DumpCommand, destination: string, password?: string): Promise<void>;
|
|
60
|
+
/**
|
|
61
|
+
* Feed the dump at `source` to `command`'s stdin, inflating it first when it
|
|
62
|
+
* is gzipped, so dumps taken before #3079 restore exactly as they did.
|
|
63
|
+
*/
|
|
64
|
+
export declare function readDump(command: DumpCommand, source: string, password?: string): Promise<void>;
|
|
40
65
|
/**
|
|
41
66
|
* Which dumps to delete to keep the `retain` newest.
|
|
42
67
|
*
|
|
@@ -96,6 +121,17 @@ export declare function toolFailureDetail(stderr: string | Buffer, bin?: string)
|
|
|
96
121
|
export declare function parseBackupDestination(uri: string): BackupDestination;
|
|
97
122
|
/** The key a dump is stored under at its destination. */
|
|
98
123
|
export declare function backupObjectKey(destination: BackupDestination, fileName: string): string;
|
|
124
|
+
/**
|
|
125
|
+
* How many dumps to keep: the `--retain` flag, then `DB_BACKUP_RETAIN`, then
|
|
126
|
+
* `config/database.ts`'s `backups.retain`, then {@link DEFAULT_RETAIN}.
|
|
127
|
+
*
|
|
128
|
+
* The same precedence {@link resolveBackupDestination} uses, so the deploy's
|
|
129
|
+
* pre-migration backup, which passes no flag, can be tuned per app (#3079).
|
|
130
|
+
* A value that is not a whole number of at least one throws, as an unreadable
|
|
131
|
+
* destination does: a deploy that silently kept the default after being told
|
|
132
|
+
* otherwise would be the disk filling up again.
|
|
133
|
+
*/
|
|
134
|
+
export declare function resolveBackupRetain(databaseConfig: unknown, env?: Record<string, string | undefined>, flag?: string): number;
|
|
99
135
|
/**
|
|
100
136
|
* The configured offsite destination, or null.
|
|
101
137
|
*
|
|
@@ -104,6 +140,8 @@ export declare function backupObjectKey(destination: BackupDestination, fileName
|
|
|
104
140
|
* case and is not an error — it is what the deploy warning is about.
|
|
105
141
|
*/
|
|
106
142
|
export declare function resolveBackupDestination(databaseConfig: unknown, env?: Record<string, string | undefined>): BackupDestination | null;
|
|
143
|
+
/** How many dumps are kept when nothing says otherwise. */
|
|
144
|
+
export declare const DEFAULT_RETAIN: 7;
|
|
107
145
|
/** Everything needed to dump one database. */
|
|
108
146
|
export declare interface BackupTarget {
|
|
109
147
|
engine: BackupEngine
|
package/dist/database-backup.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
const ENGINES={sqlite:"sqlite",postgres:"postgres",postgresql:"postgres",mysql:"mysql",mariadb:"mysql"};export function resolveBackupTarget(databaseConfig){const cfg=databaseConfig,dialect=String(cfg?.default??"").trim().toLowerCase(),engine=ENGINES[dialect];if(!engine)return null;const connection=cfg?.connections?.[dialect];if(!connection)return null;if(engine==="sqlite"){const database=String(connection.database??"").trim();return database?{engine,database}:null}const database=String(connection.name??connection.database??"").trim();if(!database)return null;return{engine,database,host:String(connection.host??"127.0.0.1"),port:Number(connection.port)||(engine==="postgres"?5432:3306),username:String(connection.username??""),password:String(connection.password??"")}}export function backupFileName(target,at){const stamp=at.toISOString().replace(/[:.]/g,"-").replace("Z",""),extension=target.engine==="sqlite"?"sqlite":"sql";return`${stamp}.${target.engine}.${extension}`}export function isBackupFileName(name){return/^\d{4}-\d{2}-\d{2}T[\d-]+\.(?:sqlite|postgres|mysql)\.(?:sqlite|sql)$/.test(name)}export function dumpCommand(target
|
|
2
|
-
`).map((l)=>l.trim()).filter(Boolean).map((l)=>bin&&l.startsWith(`${bin}: `)?l.slice(bin.length+2):l);if(!lines.length)return"";const errorAt=lines.findIndex((l)=>/^error\b|\berror:/i.test(l));if(errorAt===-1)return lines[lines.length-1]??"";const kept=[lines[errorAt]];for(const line of lines.slice(errorAt+1))if(/^(?:detail|hint):/i.test(line))kept.push(line);return kept.join(" ")}export function parseBackupDestination(uri){const match=uri.trim().match(/^(s3|disk):\/\/([^/]+)(?:\/(.*))?$/);if(!match)throw Error(`Backup destination must be \`s3://bucket/prefix\` or \`disk://name/prefix\`, got ${JSON.stringify(uri)}. `+"A local path is not accepted here \u2014 `--out` already writes locally, and a second copy on the same disk "+"is not a backup of the box.");const[,kind,target,rawPrefix=""]=match,prefix=rawPrefix.replace(/^\/+|\/+$/g,"");if(!target)throw Error(`Backup destination ${JSON.stringify(uri)} names no ${kind==="s3"?"bucket":"disk"}.`);return{kind,target,prefix}}export function backupObjectKey(destination,fileName){return destination.prefix?`${destination.prefix}/${fileName}`:fileName}export function resolveBackupDestination(databaseConfig,env={}){const configured=env.DB_BACKUP_DESTINATION??databaseConfig?.backups?.destination;if(typeof configured!=="string"||!configured.trim())return null;return parseBackupDestination(configured)}
|
|
1
|
+
import process from"node:process";const ENGINES={sqlite:"sqlite",postgres:"postgres",postgresql:"postgres",mysql:"mysql",mariadb:"mysql"};export function resolveBackupTarget(databaseConfig){const cfg=databaseConfig,dialect=String(cfg?.default??"").trim().toLowerCase(),engine=ENGINES[dialect];if(!engine)return null;const connection=cfg?.connections?.[dialect];if(!connection)return null;if(engine==="sqlite"){const database=String(connection.database??"").trim();return database?{engine,database}:null}const database=String(connection.name??connection.database??"").trim();if(!database)return null;return{engine,database,host:String(connection.host??"127.0.0.1"),port:Number(connection.port)||(engine==="postgres"?5432:3306),username:String(connection.username??""),password:String(connection.password??"")}}export function backupFileName(target,at){const stamp=at.toISOString().replace(/[:.]/g,"-").replace("Z",""),extension=target.engine==="sqlite"?"sqlite":"sql.gz";return`${stamp}.${target.engine}.${extension}`}export function isBackupFileName(name){return/^\d{4}-\d{2}-\d{2}T[\d-]+\.(?:sqlite|postgres|mysql)\.(?:sqlite|sql|sql\.gz)$/.test(name)}export function isCompressedBackup(name){return name.endsWith(".gz")}export function dumpCommand(target){if(target.engine==="sqlite")return null;if(target.engine==="postgres")return{bin:"pg_dump",args:["--host",String(target.host),"--port",String(target.port),"--username",String(target.username),"--no-owner","--no-acl",target.database],env:target.password?{PGPASSWORD:target.password}:{}};return{bin:"mysqldump",args:[`--host=${target.host}`,`--port=${target.port}`,`--user=${target.username}`,"--single-transaction",target.database],env:target.password?{MYSQL_PWD:target.password}:{}}}export function restoreCommand(target){if(target.engine==="sqlite")return null;if(target.engine==="postgres")return{bin:"psql",args:["--host",String(target.host),"--port",String(target.port),"--username",String(target.username),"--set","ON_ERROR_STOP=1",target.database],env:target.password?{PGPASSWORD:target.password}:{}};return{bin:"mysql",args:[`--host=${target.host}`,`--port=${target.port}`,`--user=${target.username}`,`--database=${target.database}`],env:target.password?{MYSQL_PWD:target.password}:{}}}export async function writeDump(command,destination,password){const{renameSync,rmSync}=await import("node:fs"),partial=`${destination}.partial`,proc=Bun.spawn([command.bin,...command.args],{env:{...process.env,...command.env},stdout:"pipe",stderr:"pipe"});try{const[,code,stderr]=await Promise.all([Bun.write(partial,new Response(proc.stdout.pipeThrough(new CompressionStream("gzip")))),proc.exited,new Response(proc.stderr).text()]);if(code!==0)throw Error(withoutPassword(`${command.bin}: ${toolFailureDetail(stderr,command.bin)||`exit code ${code}`}`,password));renameSync(partial,destination)}finally{rmSync(partial,{force:!0})}}export async function readDump(command,source,password){const dump=Bun.file(source).stream(),proc=Bun.spawn([command.bin,...command.args],{env:{...process.env,...command.env},stdin:isCompressedBackup(source)?dump.pipeThrough(new DecompressionStream("gzip")):dump,stdout:"pipe",stderr:"pipe"}),[code,stderr]=await Promise.all([proc.exited,new Response(proc.stderr).text()]);if(code!==0)throw Error(withoutPassword(`${command.bin}: ${toolFailureDetail(stderr,command.bin)||`exit code ${code}`}`,password))}export function prunableBackups(existing,retain){if(!Number.isFinite(retain)||retain<1)return[];const backups=existing.filter(isBackupFileName).sort(),excess=backups.length-retain;return excess>0?backups.slice(0,excess):[]}export async function dumpSqlite(source,destination){const{Database}=await import("bun:sqlite"),database=new Database(source,{readonly:!0});try{database.exec(`VACUUM INTO '${destination.replace(/'/g,"''")}'`)}finally{database.close()}}export async function restoreSqlite(source,live,stamp){const{existsSync,renameSync}=await import("node:fs");let displaced=null;if(existsSync(live)){displaced=`${live}.replaced-${stamp}`;renameSync(live,displaced)}await Bun.write(live,Bun.file(source));return displaced}export function withoutPassword(text,password){return password?text.split(password).join("***"):text}export function describeCommand(command){return[command.bin,...command.args].join(" ")}export function toolFailureDetail(stderr,bin){const lines=String(stderr).split(`
|
|
2
|
+
`).map((l)=>l.trim()).filter(Boolean).map((l)=>bin&&l.startsWith(`${bin}: `)?l.slice(bin.length+2):l);if(!lines.length)return"";const errorAt=lines.findIndex((l)=>/^error\b|\berror:/i.test(l));if(errorAt===-1)return lines[lines.length-1]??"";const kept=[lines[errorAt]];for(const line of lines.slice(errorAt+1))if(/^(?:detail|hint):/i.test(line))kept.push(line);return kept.join(" ")}export function parseBackupDestination(uri){const match=uri.trim().match(/^(s3|disk):\/\/([^/]+)(?:\/(.*))?$/);if(!match)throw Error(`Backup destination must be \`s3://bucket/prefix\` or \`disk://name/prefix\`, got ${JSON.stringify(uri)}. `+"A local path is not accepted here \u2014 `--out` already writes locally, and a second copy on the same disk "+"is not a backup of the box.");const[,kind,target,rawPrefix=""]=match,prefix=rawPrefix.replace(/^\/+|\/+$/g,"");if(!target)throw Error(`Backup destination ${JSON.stringify(uri)} names no ${kind==="s3"?"bucket":"disk"}.`);return{kind,target,prefix}}export function backupObjectKey(destination,fileName){return destination.prefix?`${destination.prefix}/${fileName}`:fileName}export const DEFAULT_RETAIN=7;export function resolveBackupRetain(databaseConfig,env={},flag){const configured=databaseConfig?.backups?.retain,sources=[["--retain",flag],["DB_BACKUP_RETAIN",env.DB_BACKUP_RETAIN],["backups.retain",configured]];for(const[name,value]of sources){if(value===void 0||value===null||String(value).trim()==="")continue;const count=Number(String(value).trim());if(!Number.isInteger(count)||count<1)throw Error(`${name} must be a whole number of dumps to keep, at least 1; got ${JSON.stringify(value)}.`);return count}return DEFAULT_RETAIN}export function resolveBackupDestination(databaseConfig,env={}){const configured=env.DB_BACKUP_DESTINATION??databaseConfig?.backups?.destination;if(typeof configured!=="string"||!configured.trim())return null;return parseBackupDestination(configured)}
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@stacksjs/buddy",
|
|
3
3
|
"type": "module",
|
|
4
4
|
"sideEffects": false,
|
|
5
|
-
"version": "0.75.
|
|
5
|
+
"version": "0.75.387",
|
|
6
6
|
"description": "Meet Buddy. The Stacks runtime.",
|
|
7
7
|
"author": "Chris Breuer",
|
|
8
8
|
"contributors": [
|
|
@@ -95,69 +95,69 @@
|
|
|
95
95
|
"prepublishOnly": "bun run build"
|
|
96
96
|
},
|
|
97
97
|
"dependencies": {
|
|
98
|
-
"@stacksjs/actions": "0.75.
|
|
99
|
-
"@stacksjs/ai": "0.75.
|
|
100
|
-
"@stacksjs/alias": "0.75.
|
|
101
|
-
"@stacksjs/analytics": "0.75.
|
|
102
|
-
"@stacksjs/api": "0.75.
|
|
103
|
-
"@stacksjs/arrays": "0.75.
|
|
104
|
-
"@stacksjs/auth": "0.75.
|
|
105
|
-
"@stacksjs/browser-extension": "0.75.
|
|
106
|
-
"@stacksjs/build": "0.75.
|
|
98
|
+
"@stacksjs/actions": "0.75.387",
|
|
99
|
+
"@stacksjs/ai": "0.75.387",
|
|
100
|
+
"@stacksjs/alias": "0.75.387",
|
|
101
|
+
"@stacksjs/analytics": "0.75.387",
|
|
102
|
+
"@stacksjs/api": "0.75.387",
|
|
103
|
+
"@stacksjs/arrays": "0.75.387",
|
|
104
|
+
"@stacksjs/auth": "0.75.387",
|
|
105
|
+
"@stacksjs/browser-extension": "0.75.387",
|
|
106
|
+
"@stacksjs/build": "0.75.387",
|
|
107
107
|
"@stacksjs/bumpx": "^0.2.26",
|
|
108
|
-
"@stacksjs/cache": "0.75.
|
|
109
|
-
"@stacksjs/chat": "0.75.
|
|
108
|
+
"@stacksjs/cache": "0.75.387",
|
|
109
|
+
"@stacksjs/chat": "0.75.387",
|
|
110
110
|
"@stacksjs/clapp": "^0.2.16",
|
|
111
|
-
"@stacksjs/cli": "0.75.
|
|
112
|
-
"@stacksjs/cloud": "0.75.
|
|
113
|
-
"@stacksjs/cms": "0.75.
|
|
114
|
-
"@stacksjs/collections": "0.75.
|
|
115
|
-
"@stacksjs/commerce": "0.75.
|
|
116
|
-
"@stacksjs/config": "0.75.
|
|
117
|
-
"@stacksjs/database": "0.75.
|
|
118
|
-
"@stacksjs/desktop-build": "0.75.
|
|
119
|
-
"@stacksjs/dns": "0.75.
|
|
111
|
+
"@stacksjs/cli": "0.75.387",
|
|
112
|
+
"@stacksjs/cloud": "0.75.387",
|
|
113
|
+
"@stacksjs/cms": "0.75.387",
|
|
114
|
+
"@stacksjs/collections": "0.75.387",
|
|
115
|
+
"@stacksjs/commerce": "0.75.387",
|
|
116
|
+
"@stacksjs/config": "0.75.387",
|
|
117
|
+
"@stacksjs/database": "0.75.387",
|
|
118
|
+
"@stacksjs/desktop-build": "0.75.387",
|
|
119
|
+
"@stacksjs/dns": "0.75.387",
|
|
120
120
|
"@stacksjs/dnsx": "^0.2.3",
|
|
121
|
-
"@stacksjs/email": "0.75.
|
|
122
|
-
"@stacksjs/enums": "0.75.
|
|
123
|
-
"@stacksjs/env": "0.75.
|
|
124
|
-
"@stacksjs/error-handling": "0.75.
|
|
125
|
-
"@stacksjs/events": "0.75.
|
|
126
|
-
"@stacksjs/features": "0.75.
|
|
127
|
-
"@stacksjs/git": "0.75.
|
|
121
|
+
"@stacksjs/email": "0.75.387",
|
|
122
|
+
"@stacksjs/enums": "0.75.387",
|
|
123
|
+
"@stacksjs/env": "0.75.387",
|
|
124
|
+
"@stacksjs/error-handling": "0.75.387",
|
|
125
|
+
"@stacksjs/events": "0.75.387",
|
|
126
|
+
"@stacksjs/features": "0.75.387",
|
|
127
|
+
"@stacksjs/git": "0.75.387",
|
|
128
128
|
"@stacksjs/gitit": "^0.2.5",
|
|
129
|
-
"@stacksjs/health": "0.75.
|
|
129
|
+
"@stacksjs/health": "0.75.387",
|
|
130
130
|
"@stacksjs/httx": "^0.1.10",
|
|
131
|
-
"@stacksjs/image": "0.75.
|
|
132
|
-
"@stacksjs/lint": "0.75.
|
|
133
|
-
"@stacksjs/logging": "0.75.
|
|
134
|
-
"@stacksjs/notifications": "0.75.
|
|
135
|
-
"@stacksjs/objects": "0.75.
|
|
136
|
-
"@stacksjs/orm": "0.75.
|
|
137
|
-
"@stacksjs/path": "0.75.
|
|
138
|
-
"@stacksjs/payments": "0.75.
|
|
139
|
-
"@stacksjs/realtime": "0.75.
|
|
140
|
-
"@stacksjs/router": "0.75.
|
|
131
|
+
"@stacksjs/image": "0.75.387",
|
|
132
|
+
"@stacksjs/lint": "0.75.387",
|
|
133
|
+
"@stacksjs/logging": "0.75.387",
|
|
134
|
+
"@stacksjs/notifications": "0.75.387",
|
|
135
|
+
"@stacksjs/objects": "0.75.387",
|
|
136
|
+
"@stacksjs/orm": "0.75.387",
|
|
137
|
+
"@stacksjs/path": "0.75.387",
|
|
138
|
+
"@stacksjs/payments": "0.75.387",
|
|
139
|
+
"@stacksjs/realtime": "0.75.387",
|
|
140
|
+
"@stacksjs/router": "0.75.387",
|
|
141
141
|
"@stacksjs/rpx": "^0.11.54",
|
|
142
|
-
"@stacksjs/scheduler": "0.75.
|
|
143
|
-
"@stacksjs/search-engine": "0.75.
|
|
144
|
-
"@stacksjs/security": "0.75.
|
|
145
|
-
"@stacksjs/server": "0.75.
|
|
146
|
-
"@stacksjs/sites": "0.75.
|
|
147
|
-
"@stacksjs/skills": "0.75.
|
|
148
|
-
"@stacksjs/socials": "0.75.
|
|
149
|
-
"@stacksjs/storage": "0.75.
|
|
150
|
-
"@stacksjs/strings": "0.75.
|
|
142
|
+
"@stacksjs/scheduler": "0.75.387",
|
|
143
|
+
"@stacksjs/search-engine": "0.75.387",
|
|
144
|
+
"@stacksjs/security": "0.75.387",
|
|
145
|
+
"@stacksjs/server": "0.75.387",
|
|
146
|
+
"@stacksjs/sites": "0.75.387",
|
|
147
|
+
"@stacksjs/skills": "0.75.387",
|
|
148
|
+
"@stacksjs/socials": "0.75.387",
|
|
149
|
+
"@stacksjs/storage": "0.75.387",
|
|
150
|
+
"@stacksjs/strings": "0.75.387",
|
|
151
151
|
"@stacksjs/stx": "0.2.752",
|
|
152
|
-
"@stacksjs/testing": "0.75.
|
|
153
|
-
"@stacksjs/tinker": "0.75.
|
|
152
|
+
"@stacksjs/testing": "0.75.387",
|
|
153
|
+
"@stacksjs/tinker": "0.75.387",
|
|
154
154
|
"@stacksjs/tlsx": "^0.13.19",
|
|
155
155
|
"@stacksjs/ts-cloud": "^0.16.89",
|
|
156
|
-
"@stacksjs/tunnel": "0.75.
|
|
157
|
-
"@stacksjs/types": "0.75.
|
|
158
|
-
"@stacksjs/ui": "0.75.
|
|
159
|
-
"@stacksjs/utils": "0.75.
|
|
160
|
-
"@stacksjs/validation": "0.75.
|
|
156
|
+
"@stacksjs/tunnel": "0.75.387",
|
|
157
|
+
"@stacksjs/types": "0.75.387",
|
|
158
|
+
"@stacksjs/ui": "0.75.387",
|
|
159
|
+
"@stacksjs/utils": "0.75.387",
|
|
160
|
+
"@stacksjs/validation": "0.75.387",
|
|
161
161
|
"ajv": "^8.20.0",
|
|
162
162
|
"ajv-formats": "^3.0.1",
|
|
163
163
|
"bun-plugin-stx": "0.2.752",
|