@stacksjs/buddy 0.74.6 → 0.74.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +1 @@
1
- import process from"node:process";import{runAction}from"@stacksjs/actions";import{intro,log,onUnknownSubcommand,outro}from"@stacksjs/cli";import{Action}from"@stacksjs/enums";import{ExitCode}from"@stacksjs/types";import{resultFailed}from"../result";export function queue(buddy){const descriptions={project:"Target a specific project",queue:"Specify queue name",verbose:"Enable verbose output",id:"Job or batch ID",all:"Apply to all items",force:"Force the operation without confirmation",connection:"Queue connection to use",concurrency:"Number of concurrent workers"};buddy.command("queue:work","Start processing jobs on the queue").option("-p, --project [project]",descriptions.project,{default:!1}).option("-q, --queue [queue]",descriptions.queue,{default:!1}).option("-c, --connection [connection]",descriptions.connection,{default:!1}).option("--concurrency [concurrency]",descriptions.concurrency,{default:"1"}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy queue:work` ...",options);const PARENT_BACKSTOP_MS=(Number(process.env.STACKS_QUEUE_SHUTDOWN_GRACE_MS)||1e4)+5000;let shuttingDown=!1;const onSignal=(signal)=>{if(shuttingDown)return;shuttingDown=!0;log.info(`[queue] Received ${signal}; waiting for the worker to drain\u2026`);setTimeout(()=>{log.warn("[queue] Worker drain overran its window - forcing shutdown.");process.exit(ExitCode.FatalError)},PARENT_BACKSTOP_MS).unref()};process.on("SIGINT",()=>onSignal("SIGINT"));process.on("SIGTERM",()=>onSignal("SIGTERM"));const perf=await intro("buddy queue:work"),result=await runAction(Action.QueueWork,options);if(resultFailed(result)){await outro("While running the queue:work command, there was an issue",{startTime:perf,useSeconds:!0},result.error);process.exit(ExitCode.FatalError)}process.exit(ExitCode.Success)});buddy.command("queue:failed","List all failed jobs").option("-p, --project [project]",descriptions.project,{default:!1}).option("-q, --queue [queue]",descriptions.queue,{default:!1}).option("--since [duration]","Only show jobs failed since (e.g. 1h, 30m, 2d)",{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy queue:failed` ...",options);const perf=await intro("buddy queue:failed"),result=await runAction(Action.QueueFailed,options);if(resultFailed(result)){await outro("While running the queue:failed command, there was an issue",{startTime:perf,useSeconds:!0},result.error);process.exit(ExitCode.FatalError)}await outro("Listed all failed jobs",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("queue:retry [id]","Retry failed jobs").option("-p, --project [project]",descriptions.project,{default:!1}).option("-q, --queue [queue]",descriptions.queue,{default:!1}).option("--all",descriptions.all,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(id,options)=>{log.debug("Running `buddy queue:retry` ...",options);const perf=await intro("buddy queue:retry"),result=await runAction(Action.QueueRetry,{...options,id});if(resultFailed(result)){await outro("While running the queue:retry command, there was an issue",{startTime:perf,useSeconds:!0},result.error);process.exit(ExitCode.FatalError)}const message=options.all?"Retried all failed jobs":`Retried failed job ${id}`;await outro(message,{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("queue:clear","Clear all jobs from the queue").option("-p, --project [project]",descriptions.project,{default:!1}).option("-q, --queue [queue]",descriptions.queue,{default:!1}).option("--force",descriptions.force,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy queue:clear` ...",options);const perf=await intro("buddy queue:clear"),result=await runAction(Action.QueueClear,options);if(resultFailed(result)){await outro("While running the queue:clear command, there was an issue",{startTime:perf,useSeconds:!0},result.error);process.exit(ExitCode.FatalError)}const queueName=options.queue||"default";await outro(`Cleared all jobs from the "${queueName}" queue`,{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("queue:list","List queued jobs (flat row view, filterable by queue/status)").option("-p, --project [project]",descriptions.project,{default:!1}).option("-q, --queue [queue]",descriptions.queue,{default:!1}).option("--status [status]","Filter by pending | reserved | delayed",{default:!1}).option("--limit [limit]","Maximum rows to display (default 50)",{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy queue:list` ...",options);const perf=await intro("buddy queue:list"),result=await runAction(Action.QueueList,options);if(resultFailed(result)){await outro("While running the queue:list command, there was an issue",{startTime:perf,useSeconds:!0},result.error);process.exit(ExitCode.FatalError)}await outro("Listed queued jobs",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("queue:status","Display the status of queue workers").option("-p, --project [project]",descriptions.project,{default:!1}).option("-q, --queue [queue]",descriptions.queue,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy queue:status` ...",options);const perf=await intro("buddy queue:status"),result=await runAction(Action.QueueStatus,options);if(resultFailed(result)){await outro("While running the queue:status command, there was an issue",{startTime:perf,useSeconds:!0},result.error);process.exit(ExitCode.FatalError)}await outro("Queue status displayed",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("queue:flush","Delete all failed jobs").option("-p, --project [project]",descriptions.project,{default:!1}).option("-q, --queue [queue]",descriptions.queue,{default:!1}).option("--force",descriptions.force,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy queue:flush` ...",options);const perf=await intro("buddy queue:flush"),result=await runAction(Action.QueueFlush,options);if(resultFailed(result)){await outro("While running the queue:flush command, there was an issue",{startTime:perf,useSeconds:!0},result.error);process.exit(ExitCode.FatalError)}await outro("Flushed all failed jobs",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("queue:dlq","List dead-letter jobs (jobs that re-failed after retry)").option("-p, --project [project]",descriptions.project,{default:!1}).option("-q, --queue [queue]",descriptions.queue,{default:!1}).option("--reason [reason]","Filter by reason (repeat-failure | poison-detected | circuit-broken | manual)",{default:!1}).option("--since [duration]","Only show rows dead-lettered since (e.g. 1h, 30m, 2d)",{default:!1}).option("--limit [limit]","Maximum rows to display (default 100)",{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy queue:dlq` ...",options);const perf=await intro("buddy queue:dlq"),result=await runAction(Action.QueueDlq,options);if(resultFailed(result)){await outro("While running queue:dlq there was an issue",{startTime:perf,useSeconds:!0},result.error);process.exit(ExitCode.FatalError)}await outro("Listed dead-letter jobs",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("queue:dlq:retry","Re-enqueue a dead-letter job back into its queue").option("--id [id]","Dead-letter row id to retry",{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy queue:dlq:retry` ...",options);const perf=await intro("buddy queue:dlq:retry"),result=await runAction(Action.QueueDlqRetry,options);if(resultFailed(result)){await outro("While running queue:dlq:retry there was an issue",{startTime:perf,useSeconds:!0},result.error);process.exit(ExitCode.FatalError)}await outro("Re-enqueued dead-letter job",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("queue:dlq:purge","Delete dead-letter rows older than a retention window").option("--older-than-days [days]","Retention window in days (default 30)",{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy queue:dlq:purge` ...",options);const perf=await intro("buddy queue:dlq:purge"),result=await runAction(Action.QueueDlqPurge,options);if(resultFailed(result)){await outro("While running queue:dlq:purge there was an issue",{startTime:perf,useSeconds:!0},result.error);process.exit(ExitCode.FatalError)}await outro("Purged dead-letter rows",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("queue:quarantine","List quarantined jobs (or --add a manual quarantine)").option("--add [jobName]","Manually quarantine a job class",{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy queue:quarantine` ...",options);const perf=await intro("buddy queue:quarantine"),result=await runAction(Action.QueueQuarantine,options);if(resultFailed(result)){await outro("While running queue:quarantine there was an issue",{startTime:perf,useSeconds:!0},result.error);process.exit(ExitCode.FatalError)}await outro("queue:quarantine done",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("queue:unquarantine","Lift the quarantine on a job class").option("--name [name]","Job class name to unquarantine",{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy queue:unquarantine` ...",options);const perf=await intro("buddy queue:unquarantine"),result=await runAction(Action.QueueUnquarantine,options);if(resultFailed(result)){await outro("While running queue:unquarantine there was an issue",{startTime:perf,useSeconds:!0},result.error);process.exit(ExitCode.FatalError)}await outro("Unquarantined",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("queue:pause","Manually pause a queue (circuit-breaker)").option("--queue [name]","Queue name to pause",{default:!1}).option("--for [seconds]","Pause duration in seconds (default 300)",{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy queue:pause` ...",options);const perf=await intro("buddy queue:pause"),result=await runAction(Action.QueuePause,options);if(resultFailed(result)){await outro("While running queue:pause there was an issue",{startTime:perf,useSeconds:!0},result.error);process.exit(ExitCode.FatalError)}await outro("Paused queue",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("queue:resume","Resume a paused queue (circuit-breaker)").option("--queue [name]","Queue name to resume",{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy queue:resume` ...",options);const perf=await intro("buddy queue:resume"),result=await runAction(Action.QueueResume,options);if(resultFailed(result)){await outro("While running queue:resume there was an issue",{startTime:perf,useSeconds:!0},result.error);process.exit(ExitCode.FatalError)}await outro("Resumed queue",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("queue:table","Create a migration for the jobs database table").option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy queue:table` ...",options);const perf=await intro("buddy queue:table"),result=await runAction(Action.QueueTable,options);if(resultFailed(result)){await outro("While running the queue:table command, there was an issue",{startTime:perf,useSeconds:!0},result.error);process.exit(ExitCode.FatalError)}await outro("Jobs table migration created",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("queue:monitor","Monitor queue status in real-time").option("-p, --project [project]",descriptions.project,{default:!1}).option("-q, --queue [queue]",descriptions.queue,{default:!1}).option("-i, --interval [interval]","Refresh interval in milliseconds",{default:"2000"}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy queue:monitor` ...",options);const result=await runAction(Action.QueueMonitor,options);if(resultFailed(result)){log.error("While running the queue:monitor command, there was an issue",result.error);process.exit(ExitCode.FatalError)}process.exit(ExitCode.Success)});buddy.command("queue:inspect [id]","Inspect a specific job").option("-p, --project [project]",descriptions.project,{default:!1}).option("--failed","Inspect a failed job",{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(id,options)=>{log.debug("Running `buddy queue:inspect` ...",options);const perf=await intro("buddy queue:inspect"),result=await runAction(Action.QueueInspect,{...options,id});if(resultFailed(result)){await outro("While running the queue:inspect command, there was an issue",{startTime:perf,useSeconds:!0},result.error);process.exit(ExitCode.FatalError)}await outro("Job inspection complete",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("queue:schedule","Start the job scheduler for cron-based jobs").option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy queue:schedule` ...",options);const result=await runAction(Action.QueueSchedule,options);if(resultFailed(result)){log.error("While running the queue:schedule command, there was an issue",result.error);process.exit(ExitCode.FatalError)}process.exit(ExitCode.Success)});buddy.command("queue:schedule:list","List all scheduled jobs").option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy queue:schedule:list` ...",options);const perf=await intro("buddy queue:schedule:list"),result=await runAction(Action.QueueScheduleList,options);if(resultFailed(result)){await outro("While running the queue:schedule:list command, there was an issue",{startTime:perf,useSeconds:!0},result.error);process.exit(ExitCode.FatalError)}await outro("Listed all scheduled jobs",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});onUnknownSubcommand(buddy,"queue")}
1
+ import process from"node:process";import{runAction}from"@stacksjs/actions";import{intro,log,onUnknownSubcommand,outro}from"@stacksjs/cli";import{Action}from"@stacksjs/enums";import{ExitCode}from"@stacksjs/types";import{resultFailed}from"../result";export function queue(buddy){const descriptions={project:"Target a specific project",queue:"Specify queue name",verbose:"Enable verbose output",id:"Job or batch ID",all:"Apply to all items",force:"Force the operation without confirmation",connection:"Queue connection to use",concurrency:"Number of concurrent workers"};buddy.command("queue:work","Start processing jobs on the queue").option("-p, --project [project]",descriptions.project,{default:!1}).option("-q, --queue [queue]",descriptions.queue,{default:!1}).option("-c, --connection [connection]",descriptions.connection,{default:!1}).option("--concurrency [concurrency]",descriptions.concurrency,{default:"1"}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy queue:work` ...",options);const PARENT_BACKSTOP_MS=(Number(process.env.STACKS_QUEUE_SHUTDOWN_GRACE_MS)||1e4)+5000;let shuttingDown=!1;const onSignal=(signal)=>{if(shuttingDown)return;shuttingDown=!0;log.info(`[queue] Received ${signal}; waiting for the worker to drain\u2026`);setTimeout(()=>{log.warn("[queue] Worker drain overran its window - forcing shutdown.");process.exit(ExitCode.FatalError)},PARENT_BACKSTOP_MS).unref()};process.on("SIGINT",()=>onSignal("SIGINT"));process.on("SIGTERM",()=>onSignal("SIGTERM"));const perf=await intro("buddy queue:work"),result=await runAction(Action.QueueWork,options);if(resultFailed(result)){await outro("While running the queue:work command, there was an issue",{startTime:perf,useSeconds:!0},result.error);process.exit(ExitCode.FatalError)}process.exit(ExitCode.Success)});buddy.command("queue:failed","List all failed jobs").option("-p, --project [project]",descriptions.project,{default:!1}).option("-q, --queue [queue]",descriptions.queue,{default:!1}).option("--since [duration]","Only show jobs failed since (e.g. 1h, 30m, 2d)",{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy queue:failed` ...",options);const perf=await intro("buddy queue:failed"),result=await runAction(Action.QueueFailed,options);if(resultFailed(result)){await outro("While running the queue:failed command, there was an issue",{startTime:perf,useSeconds:!0},result.error);process.exit(ExitCode.FatalError)}await outro("Listed all failed jobs",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("queue:retry [id]","Retry failed jobs").option("-p, --project [project]",descriptions.project,{default:!1}).option("-q, --queue [queue]",descriptions.queue,{default:!1}).option("--all",descriptions.all,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(id,options)=>{log.debug("Running `buddy queue:retry` ...",options);const perf=await intro("buddy queue:retry"),result=await runAction(Action.QueueRetry,{...options,id});if(resultFailed(result)){await outro("While running the queue:retry command, there was an issue",{startTime:perf,useSeconds:!0},result.error);process.exit(ExitCode.FatalError)}const message=options.all?"Retried all failed jobs":`Retried failed job ${id}`;await outro(message,{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("queue:clear","Clear all jobs from the queue").option("-p, --project [project]",descriptions.project,{default:!1}).option("-q, --queue [queue]",descriptions.queue,{default:!1}).option("--force",descriptions.force,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy queue:clear` ...",options);const perf=await intro("buddy queue:clear"),result=await runAction(Action.QueueClear,options);if(resultFailed(result)){await outro("While running the queue:clear command, there was an issue",{startTime:perf,useSeconds:!0},result.error);process.exit(ExitCode.FatalError)}const queueName=options.queue||"default";await outro(`Cleared all jobs from the "${queueName}" queue`,{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("queue:list","List queued jobs (flat row view, filterable by queue/status)").option("-p, --project [project]",descriptions.project,{default:!1}).option("-q, --queue [queue]",descriptions.queue,{default:!1}).option("--status [status]","Filter by pending | reserved | delayed",{default:!1}).option("--limit [limit]","Maximum rows to display (default 50)",{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy queue:list` ...",options);const perf=await intro("buddy queue:list"),result=await runAction(Action.QueueList,options);if(resultFailed(result)){await outro("While running the queue:list command, there was an issue",{startTime:perf,useSeconds:!0},result.error);process.exit(ExitCode.FatalError)}await outro("Listed queued jobs",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("queue:status","Display the status of queue workers").option("-p, --project [project]",descriptions.project,{default:!1}).option("-q, --queue [queue]",descriptions.queue,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy queue:status` ...",options);const perf=await intro("buddy queue:status"),result=await runAction(Action.QueueStatus,options);if(resultFailed(result)){await outro("While running the queue:status command, there was an issue",{startTime:perf,useSeconds:!0},result.error);process.exit(ExitCode.FatalError)}await outro("Queue status displayed",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("queue:flush","Delete all failed jobs").option("-p, --project [project]",descriptions.project,{default:!1}).option("-q, --queue [queue]",descriptions.queue,{default:!1}).option("--force",descriptions.force,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy queue:flush` ...",options);const perf=await intro("buddy queue:flush"),result=await runAction(Action.QueueFlush,options);if(resultFailed(result)){await outro("While running the queue:flush command, there was an issue",{startTime:perf,useSeconds:!0},result.error);process.exit(ExitCode.FatalError)}await outro("Flushed all failed jobs",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("queue:dlq","List dead-letter jobs (jobs that re-failed after retry)").option("-p, --project [project]",descriptions.project,{default:!1}).option("-q, --queue [queue]",descriptions.queue,{default:!1}).option("--reason [reason]","Filter by reason (repeat-failure | poison-detected | circuit-broken | manual)",{default:!1}).option("--since [duration]","Only show rows dead-lettered since (e.g. 1h, 30m, 2d)",{default:!1}).option("--limit [limit]","Maximum rows to display (default 100)",{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy queue:dlq` ...",options);const perf=await intro("buddy queue:dlq"),result=await runAction(Action.QueueDlq,options);if(resultFailed(result)){await outro("While running queue:dlq there was an issue",{startTime:perf,useSeconds:!0},result.error);process.exit(ExitCode.FatalError)}await outro("Listed dead-letter jobs",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("queue:dlq:retry","Re-enqueue a dead-letter job back into its queue").option("--id [id]","Dead-letter row id to retry",{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy queue:dlq:retry` ...",options);const perf=await intro("buddy queue:dlq:retry"),result=await runAction(Action.QueueDlqRetry,options);if(resultFailed(result)){await outro("While running queue:dlq:retry there was an issue",{startTime:perf,useSeconds:!0},result.error);process.exit(ExitCode.FatalError)}await outro("Re-enqueued dead-letter job",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("queue:dlq:purge","Delete dead-letter rows older than a retention window").option("--older-than-days [days]","Retention window in days (default 30)",{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy queue:dlq:purge` ...",options);const perf=await intro("buddy queue:dlq:purge"),result=await runAction(Action.QueueDlqPurge,options);if(resultFailed(result)){await outro("While running queue:dlq:purge there was an issue",{startTime:perf,useSeconds:!0},result.error);process.exit(ExitCode.FatalError)}await outro("Purged dead-letter rows",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("queue:quarantine","List quarantined jobs (or --add a manual quarantine)").option("--add [jobName]","Manually quarantine a job class",{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy queue:quarantine` ...",options);const perf=await intro("buddy queue:quarantine"),result=await runAction(Action.QueueQuarantine,options);if(resultFailed(result)){await outro("While running queue:quarantine there was an issue",{startTime:perf,useSeconds:!0},result.error);process.exit(ExitCode.FatalError)}await outro("queue:quarantine done",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("queue:unquarantine","Lift the quarantine on a job class").option("--name [name]","Job class name to unquarantine",{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy queue:unquarantine` ...",options);const perf=await intro("buddy queue:unquarantine"),result=await runAction(Action.QueueUnquarantine,options);if(resultFailed(result)){await outro("While running queue:unquarantine there was an issue",{startTime:perf,useSeconds:!0},result.error);process.exit(ExitCode.FatalError)}await outro("Unquarantined",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("queue:pause","Manually pause a queue (circuit-breaker)").option("--queue [name]","Queue name to pause",{default:!1}).option("--for [seconds]","Pause duration in seconds (default 300)",{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy queue:pause` ...",options);const perf=await intro("buddy queue:pause"),result=await runAction(Action.QueuePause,options);if(resultFailed(result)){await outro("While running queue:pause there was an issue",{startTime:perf,useSeconds:!0},result.error);process.exit(ExitCode.FatalError)}await outro("Paused queue",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("queue:resume","Resume a paused queue (circuit-breaker)").option("--queue [name]","Queue name to resume",{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy queue:resume` ...",options);const perf=await intro("buddy queue:resume"),result=await runAction(Action.QueueResume,options);if(resultFailed(result)){await outro("While running queue:resume there was an issue",{startTime:perf,useSeconds:!0},result.error);process.exit(ExitCode.FatalError)}await outro("Resumed queue",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("queue:table","Create a migration for the jobs database table").option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy queue:table` ...",options);const perf=await intro("buddy queue:table"),result=await runAction(Action.QueueTable,options);if(resultFailed(result)){await outro("While running the queue:table command, there was an issue",{startTime:perf,useSeconds:!0},result.error);process.exit(ExitCode.FatalError)}await outro("Jobs table migration created",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("queue:monitor","Monitor queue status in real-time").option("-p, --project [project]",descriptions.project,{default:!1}).option("-q, --queue [queue]",descriptions.queue,{default:!1}).option("-i, --interval [interval]","Refresh interval in milliseconds",{default:"2000"}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy queue:monitor` ...",options);const result=await runAction(Action.QueueMonitor,options);if(resultFailed(result)){await log.error("While running the queue:monitor command, there was an issue",result.error);process.exit(ExitCode.FatalError)}process.exit(ExitCode.Success)});buddy.command("queue:inspect [id]","Inspect a specific job").option("-p, --project [project]",descriptions.project,{default:!1}).option("--failed","Inspect a failed job",{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(id,options)=>{log.debug("Running `buddy queue:inspect` ...",options);const perf=await intro("buddy queue:inspect"),result=await runAction(Action.QueueInspect,{...options,id});if(resultFailed(result)){await outro("While running the queue:inspect command, there was an issue",{startTime:perf,useSeconds:!0},result.error);process.exit(ExitCode.FatalError)}await outro("Job inspection complete",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("queue:schedule","Start the job scheduler for cron-based jobs").option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy queue:schedule` ...",options);const result=await runAction(Action.QueueSchedule,options);if(resultFailed(result)){await log.error("While running the queue:schedule command, there was an issue",result.error);process.exit(ExitCode.FatalError)}process.exit(ExitCode.Success)});buddy.command("queue:schedule:list","List all scheduled jobs").option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy queue:schedule:list` ...",options);const perf=await intro("buddy queue:schedule:list"),result=await runAction(Action.QueueScheduleList,options);if(resultFailed(result)){await outro("While running the queue:schedule:list command, there was an issue",{startTime:perf,useSeconds:!0},result.error);process.exit(ExitCode.FatalError)}await outro("Listed all scheduled jobs",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});onUnknownSubcommand(buddy,"queue")}
@@ -1 +1 @@
1
- import{execFileSync}from"node:child_process";import process from"node:process";import{runAction}from"@stacksjs/actions";import{intro,italic,log,onUnknownSubcommand,outro}from"@stacksjs/cli";import{Action}from"@stacksjs/enums";import{ExitCode}from"@stacksjs/types";import{resultFailed}from"../result";const descriptions={release:"Release a new version of your libraries/packages",project:"Target a specific project",dryRun:"Run the release without actually releasing",bump:"Non-interactive bump: patch | minor | major | prepatch | preminor | premajor | prerelease | x.y.z",verbose:"Enable verbose output"};export function release(buddy){buddy.command("release",descriptions.release).option("--dry-run",descriptions.dryRun,{default:!1}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--bump <type>",descriptions.bump).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy release` ...",options);if(options.dryRun)log.warn("Dry run enabled. No changes will be made or committed.");const startTime=await intro("buddy release"),result=await runAction(Action.Release,options);if(resultFailed(result)){log.error("Failed to release",result.error);process.exit(ExitCode.FatalError)}await outro(options.dryRun?"Dry run complete. Nothing was committed, tagged or pushed.":"Triggered CI/CD Release via GitHub Actions",{startTime,useSeconds:!0});if(!options.dryRun)log.info(`Follow along: ${italic(resolveGitHubActionsUrl(readOriginRemote()))}`)});onUnknownSubcommand(buddy,"release")}export function resolveGitHubActionsUrl(remoteUrl,repository=process.env.GITHUB_REPOSITORY,serverUrl=process.env.GITHUB_SERVER_URL??"https://github.com"){const repo=repository?.trim()||remoteUrl?.trim().match(/github\.com[/:]([^/\s]+\/[^/\s]+?)(?:\.git)?$/)?.[1];return repo?`${serverUrl.replace(/\/$/,"")}/${repo}/actions`:"https://github.com/stacksjs/stacks/actions"}function readOriginRemote(){try{return execFileSync("git",["config","--get","remote.origin.url"],{encoding:"utf8"}).trim()}catch{return}}
1
+ import{execFileSync}from"node:child_process";import process from"node:process";import{runAction}from"@stacksjs/actions";import{intro,italic,log,onUnknownSubcommand,outro}from"@stacksjs/cli";import{Action}from"@stacksjs/enums";import{ExitCode}from"@stacksjs/types";import{resultFailed}from"../result";const descriptions={release:"Release a new version of your libraries/packages",project:"Target a specific project",dryRun:"Run the release without actually releasing",bump:"Non-interactive bump: patch | minor | major | prepatch | preminor | premajor | prerelease | x.y.z",verbose:"Enable verbose output"};export function release(buddy){buddy.command("release",descriptions.release).option("--dry-run",descriptions.dryRun,{default:!1}).option("-p, --project [project]",descriptions.project,{default:!1}).option("--bump <type>",descriptions.bump).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy release` ...",options);if(options.dryRun)log.warn("Dry run enabled. No changes will be made or committed.");const startTime=await intro("buddy release"),result=await runAction(Action.Release,options);if(resultFailed(result)){await log.error("Failed to release",result.error);process.exit(ExitCode.FatalError)}await outro(options.dryRun?"Dry run complete. Nothing was committed, tagged or pushed.":"Triggered CI/CD Release via GitHub Actions",{startTime,useSeconds:!0});if(!options.dryRun)log.info(`Follow along: ${italic(resolveGitHubActionsUrl(readOriginRemote()))}`)});onUnknownSubcommand(buddy,"release")}export function resolveGitHubActionsUrl(remoteUrl,repository=process.env.GITHUB_REPOSITORY,serverUrl=process.env.GITHUB_SERVER_URL??"https://github.com"){const repo=repository?.trim()||remoteUrl?.trim().match(/github\.com[/:]([^/\s]+\/[^/\s]+?)(?:\.git)?$/)?.[1];return repo?`${serverUrl.replace(/\/$/,"")}/${repo}/actions`:"https://github.com/stacksjs/stacks/actions"}function readOriginRemote(){try{return execFileSync("git",["config","--get","remote.origin.url"],{encoding:"utf8"}).trim()}catch{return}}
@@ -1 +1 @@
1
- import process from"node:process";import{runAction}from"@stacksjs/actions";import{intro,log,outro}from"@stacksjs/cli";import{Action}from"@stacksjs/enums";import{ExitCode}from"@stacksjs/types";import{resultFailed}from"../result";export function schedule(buddy){const descriptions={project:"Target a specific project",schedule:"Run the scheduler",verbose:"Enable verbose output",list:"List all registered scheduled tasks with their next run time",status:"Show currently-held overlap locks (this-process only)",runOne:"Run one registered scheduled task immediately",enable:"Resume a paused scheduled task",disable:"Pause a scheduled task without editing source"};buddy.command("schedule:run",descriptions.schedule).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy schedule:run` ...",options);const perf=await intro("buddy schedule:run"),result=await runAction(Action.ScheduleRun,options);if(resultFailed(result)){await outro("While running the schedule:run command, there was an issue",{startTime:perf,useSeconds:!0},result.error);process.exit(ExitCode.FatalError)}process.exit(ExitCode.Success)});buddy.command("schedule:list",descriptions.list).option("--json","Print a machine-readable schedule registry",{default:!1}).action(async(options)=>{try{const{runScheduler,Schedule}=await import("@stacksjs/scheduler");await runScheduler();await Promise.resolve();const jobs=Schedule.listJobs();if(options.json){console.log(`STACKS_SCHEDULE_JSON=${JSON.stringify({jobs,locks:Schedule.listLocks()})}`);await Schedule.gracefulShutdown();process.exit(ExitCode.Success)}if(jobs.length===0){log.info("No scheduled tasks registered.");await Schedule.gracefulShutdown();process.exit(ExitCode.Success)}const nameWidth=Math.max(4,...jobs.map((j)=>j.name.length)),patternWidth=Math.max(7,...jobs.map((j)=>(j.pattern??"").length));log.info(`${"NAME".padEnd(nameWidth)} ${"PATTERN".padEnd(patternWidth)} STATUS TIMEZONE NEXT RUN (UTC)`);for(const j of jobs){const next=j.nextRun?j.nextRun.toISOString():"-";log.info(`${j.name.padEnd(nameWidth)} ${(j.pattern??"").padEnd(patternWidth)} ${(j.enabled?"active":"paused").padEnd(6)} ${(j.timezone??"UTC").padEnd(25)} ${next}`)}await Schedule.gracefulShutdown();process.exit(ExitCode.Success)}catch(err){log.error(`[schedule:list] failed: ${err instanceof Error?err.message:String(err)}`);process.exit(ExitCode.FatalError)}});buddy.command("schedule:status",descriptions.status).option("--json","Print machine-readable scheduler status",{default:!1}).action(async(options)=>{try{const{Schedule}=await import("@stacksjs/scheduler"),held=Schedule.listLocks();if(options.json){console.log(`STACKS_SCHEDULE_STATUS_JSON=${JSON.stringify({locks:held})}`);process.exit(ExitCode.Success)}if(held.length===0)log.info("No in-process scheduler locks held.");else{log.info(`Held locks (${held.length}):`);for(const name of held)log.info(` \u2022 ${name}`)}process.exit(ExitCode.Success)}catch(err){log.error(`[schedule:status] failed: ${err instanceof Error?err.message:String(err)}`);process.exit(ExitCode.FatalError)}});buddy.command("schedule:run-one <name>",descriptions.runOne).action(async(name)=>{try{const{runScheduler,Schedule}=await import("@stacksjs/scheduler");await runScheduler();await Promise.resolve();await Schedule.runNow(name);await Schedule.gracefulShutdown();log.success(`Scheduled task ${name} completed.`);await log.flush();process.exit(ExitCode.Success)}catch(err){log.error(`[schedule:run-one] failed: ${err instanceof Error?err.message:String(err)}`);process.exit(ExitCode.FatalError)}});for(const[command,enabled,description]of[["schedule:enable",!0,descriptions.enable],["schedule:disable",!1,descriptions.disable]])buddy.command(`${command} <name>`,description).action(async(name)=>{try{const{runScheduler,Schedule}=await import("@stacksjs/scheduler");await runScheduler();await Promise.resolve();const exists=Schedule.listJobs().some((job)=>job.name===name);await Schedule.gracefulShutdown();if(!exists)throw Error(`Scheduled task "${name}" was not found.`);Schedule.setEnabled(name,enabled);log.success(`Scheduled task ${name} ${enabled?"resumed":"paused"}.`);await log.flush();process.exit(ExitCode.Success)}catch(err){log.error(`[${command}] failed: ${err instanceof Error?err.message:String(err)}`);process.exit(ExitCode.FatalError)}})}
1
+ import process from"node:process";import{runAction}from"@stacksjs/actions";import{intro,log,outro}from"@stacksjs/cli";import{Action}from"@stacksjs/enums";import{ExitCode}from"@stacksjs/types";import{resultFailed}from"../result";export function schedule(buddy){const descriptions={project:"Target a specific project",schedule:"Run the scheduler",verbose:"Enable verbose output",list:"List all registered scheduled tasks with their next run time",status:"Show currently-held overlap locks (this-process only)",runOne:"Run one registered scheduled task immediately",enable:"Resume a paused scheduled task",disable:"Pause a scheduled task without editing source"};buddy.command("schedule:run",descriptions.schedule).option("-p, --project [project]",descriptions.project,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy schedule:run` ...",options);const perf=await intro("buddy schedule:run"),result=await runAction(Action.ScheduleRun,options);if(resultFailed(result)){await outro("While running the schedule:run command, there was an issue",{startTime:perf,useSeconds:!0},result.error);process.exit(ExitCode.FatalError)}process.exit(ExitCode.Success)});buddy.command("schedule:list",descriptions.list).option("--json","Print a machine-readable schedule registry",{default:!1}).action(async(options)=>{try{const{runScheduler,Schedule}=await import("@stacksjs/scheduler");await runScheduler();await Promise.resolve();const jobs=Schedule.listJobs();if(options.json){console.log(`STACKS_SCHEDULE_JSON=${JSON.stringify({jobs,locks:Schedule.listLocks()})}`);await Schedule.gracefulShutdown();process.exit(ExitCode.Success)}if(jobs.length===0){log.info("No scheduled tasks registered.");await Schedule.gracefulShutdown();process.exit(ExitCode.Success)}const nameWidth=Math.max(4,...jobs.map((j)=>j.name.length)),patternWidth=Math.max(7,...jobs.map((j)=>(j.pattern??"").length));log.info(`${"NAME".padEnd(nameWidth)} ${"PATTERN".padEnd(patternWidth)} STATUS TIMEZONE NEXT RUN (UTC)`);for(const j of jobs){const next=j.nextRun?j.nextRun.toISOString():"-";log.info(`${j.name.padEnd(nameWidth)} ${(j.pattern??"").padEnd(patternWidth)} ${(j.enabled?"active":"paused").padEnd(6)} ${(j.timezone??"UTC").padEnd(25)} ${next}`)}await Schedule.gracefulShutdown();process.exit(ExitCode.Success)}catch(err){await log.error(`[schedule:list] failed: ${err instanceof Error?err.message:String(err)}`);process.exit(ExitCode.FatalError)}});buddy.command("schedule:status",descriptions.status).option("--json","Print machine-readable scheduler status",{default:!1}).action(async(options)=>{try{const{Schedule}=await import("@stacksjs/scheduler"),held=Schedule.listLocks();if(options.json){console.log(`STACKS_SCHEDULE_STATUS_JSON=${JSON.stringify({locks:held})}`);process.exit(ExitCode.Success)}if(held.length===0)log.info("No in-process scheduler locks held.");else{log.info(`Held locks (${held.length}):`);for(const name of held)log.info(` \u2022 ${name}`)}process.exit(ExitCode.Success)}catch(err){await log.error(`[schedule:status] failed: ${err instanceof Error?err.message:String(err)}`);process.exit(ExitCode.FatalError)}});buddy.command("schedule:run-one <name>",descriptions.runOne).action(async(name)=>{try{const{runScheduler,Schedule}=await import("@stacksjs/scheduler");await runScheduler();await Promise.resolve();await Schedule.runNow(name);await Schedule.gracefulShutdown();log.success(`Scheduled task ${name} completed.`);await log.flush();process.exit(ExitCode.Success)}catch(err){await log.error(`[schedule:run-one] failed: ${err instanceof Error?err.message:String(err)}`);process.exit(ExitCode.FatalError)}});for(const[command,enabled,description]of[["schedule:enable",!0,descriptions.enable],["schedule:disable",!1,descriptions.disable]])buddy.command(`${command} <name>`,description).action(async(name)=>{try{const{runScheduler,Schedule}=await import("@stacksjs/scheduler");await runScheduler();await Promise.resolve();const exists=Schedule.listJobs().some((job)=>job.name===name);await Schedule.gracefulShutdown();if(!exists)throw Error(`Scheduled task "${name}" was not found.`);Schedule.setEnabled(name,enabled);log.success(`Scheduled task ${name} ${enabled?"resumed":"paused"}.`);await log.flush();process.exit(ExitCode.Success)}catch(err){await log.error(`[${command}] failed: ${err instanceof Error?err.message:String(err)}`);process.exit(ExitCode.FatalError)}})}
@@ -1 +1 @@
1
- import process from"node:process";import{intro,log,onUnknownSubcommand,outro}from"@stacksjs/cli";import{ExitCode}from"@stacksjs/types";export function seed(buddy){const descriptions={seed:"Seed your database",project:"Target a specific project",verbose:"Enable verbose output"};buddy.command("seed",descriptions.seed).alias("db:seed").option("-p, --project [project]",descriptions.project,{default:!1}).option("--only [models]","Comma-separated list of models to seed",{default:""}).option("--except [models]","Comma-separated list of models to skip",{default:""}).option("--include-defaults","Also seed the framework's built-in models",{default:!1}).option("--allow-protected","Seed auth/oauth models even on a non-fresh DB (will invalidate live tokens)",{default:!1}).option("--fresh","Truncate tables before seeding",{default:!1}).option("--append","Add rows to tables that already have some, instead of skipping them",{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy seed` ...",options);const perf=await intro("buddy seed"),{injectGlobalAutoImports}=await import("@stacksjs/server");await injectGlobalAutoImports();const{runApplicationSeeders,seed:seedDatabase}=await import("@stacksjs/database"),list=(value)=>value?value.split(",").map((entry)=>entry.trim()).filter(Boolean):void 0,summary=await seedDatabase({verbose:options.verbose,fresh:options.fresh,append:options.append,only:list(options.only),except:list(options.except),includeDefaults:options.includeDefaults,allowProtected:options.allowProtected}),applicationSummary=await runApplicationSeeders({verbose:options.verbose}),APP_ENV=process.env.APP_ENV||"local";if(summary.total===0&&applicationSummary.total===0){await outro("No models declare a `useSeeder` trait and no application seeders were found.",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)}const failures=summary.failed+applicationSummary.failed;await outro(`Seeded your ${APP_ENV} database. ${summary.successful}/${summary.total} model(s) and ${applicationSummary.successful}/${applicationSummary.total} application seeder(s) completed${failures>0?`, ${failures} failed`:""}.`,{startTime:perf,useSeconds:!0});process.exit(failures>0?ExitCode.FatalError:ExitCode.Success)});buddy.command("seed:roles","Seed default RBAC role packs (admin, dev, client)").alias("roles:seed").action(async()=>{const perf=await intro("buddy seed:roles");try{const{seedDefaultRoles}=await import("@stacksjs/auth"),result=await seedDefaultRoles();if(result.created.length===0&&result.skipped.length>0)await outro(`All ${result.skipped.length} default role packs already exist - nothing to do.`,{startTime:perf,useSeconds:!0});else{const createdNames=result.created.map((r)=>r.name).join(", ");await outro(`Created ${result.created.length} role pack(s): ${createdNames}. Skipped ${result.skipped.length} existing.`,{startTime:perf,useSeconds:!0})}process.exit(ExitCode.Success)}catch(err){await outro("Failed to seed default roles. Most often: migrations haven't run yet (try `./buddy migrate` first).",{startTime:perf,useSeconds:!0},err);process.exit(ExitCode.FatalError)}});onUnknownSubcommand(buddy,"seed")}
1
+ import process from"node:process";import{intro,log,onUnknownSubcommand,outro}from"@stacksjs/cli";import{ExitCode}from"@stacksjs/types";export function seed(buddy){const descriptions={seed:"Seed your database",project:"Target a specific project",verbose:"Enable verbose output"};buddy.command("seed",descriptions.seed).alias("db:seed").option("-p, --project [project]",descriptions.project,{default:!1}).option("--only [models]","Comma-separated list of models to seed",{default:""}).option("--except [models]","Comma-separated list of models to skip",{default:""}).option("--only-seeders [seeders]","Comma-separated list of application seeder classes to run",{default:""}).option("--except-seeders [seeders]","Comma-separated list of application seeder classes to skip",{default:""}).option("--skip-models","Skip model-factory seeding",{default:!1}).option("--skip-application-seeders","Skip application seeders",{default:!1}).option("--include-defaults","Also seed the framework's built-in models",{default:!1}).option("--allow-protected","Seed auth/oauth models even on a non-fresh DB (will invalidate live tokens)",{default:!1}).option("--fresh","Truncate tables before seeding",{default:!1}).option("--append","Add rows to tables that already have some, instead of skipping them",{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy seed` ...",options);const perf=await intro("buddy seed"),{injectGlobalAutoImports}=await import("@stacksjs/server");await injectGlobalAutoImports();const{runApplicationSeeders,seed:seedDatabase}=await import("@stacksjs/database"),list=(value)=>value?value.split(",").map((entry)=>entry.trim()).filter(Boolean):void 0,summary=options.skipModels?{total:0,successful:0,failed:0,results:[],duration:0}:await seedDatabase({verbose:options.verbose,fresh:options.fresh,append:options.append,only:list(options.only),except:list(options.except),includeDefaults:options.includeDefaults,allowProtected:options.allowProtected}),applicationSummary=options.skipApplicationSeeders?{total:0,successful:0,failed:0,results:[],duration:0}:await runApplicationSeeders({verbose:options.verbose,only:list(options.onlySeeders),except:list(options.exceptSeeders)}),APP_ENV=process.env.APP_ENV||"local";if(summary.total===0&&applicationSummary.total===0){await outro("No matching model or application seeders were found.",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)}const failures=summary.failed+applicationSummary.failed;await outro(`Seeded your ${APP_ENV} database. ${summary.successful}/${summary.total} model(s) and ${applicationSummary.successful}/${applicationSummary.total} application seeder(s) completed${failures>0?`, ${failures} failed`:""}.`,{startTime:perf,useSeconds:!0});process.exit(failures>0?ExitCode.FatalError:ExitCode.Success)});buddy.command("seed:roles","Seed default RBAC role packs (admin, dev, client)").alias("roles:seed").action(async()=>{const perf=await intro("buddy seed:roles");try{const{seedDefaultRoles}=await import("@stacksjs/auth"),result=await seedDefaultRoles();if(result.created.length===0&&result.skipped.length>0)await outro(`All ${result.skipped.length} default role packs already exist - nothing to do.`,{startTime:perf,useSeconds:!0});else{const createdNames=result.created.map((r)=>r.name).join(", ");await outro(`Created ${result.created.length} role pack(s): ${createdNames}. Skipped ${result.skipped.length} existing.`,{startTime:perf,useSeconds:!0})}process.exit(ExitCode.Success)}catch(err){await outro("Failed to seed default roles. Most often: migrations haven't run yet (try `./buddy migrate` first).",{startTime:perf,useSeconds:!0},err);process.exit(ExitCode.FatalError)}});onUnknownSubcommand(buddy,"seed")}
@@ -1 +1 @@
1
- import{existsSync}from"node:fs";import{join}from"node:path";import process from"node:process";import{log}from"@stacksjs/logging";import{ExitCode}from"@stacksjs/types";import{startProductionServer}from"../production-server";export{loadStxPartialsDir,resolveUserPartialsPath,startProductionServer}from"../production-server";export function serve(buddy){buddy.command("serve","Start the production HTTP server (STX views + /api proxy + coming-soon/maintenance gate)").option("-p, --port <port>","Port to listen on (defaults to PORT env or 3000)").option("--verbose","Enable verbose output",{default:!1}).action(startProductionServer)}export function serveApi(buddy){buddy.command("serve:api","Start the production API server (bun-router routes the frontend proxies /api to)").option("-p, --port <port>","Port to listen on (defaults to PORT env or 3008)").action(async(options)=>{if(options?.port)process.env.PORT=String(options.port);process.env.APP_ENV=process.env.APP_ENV||"production";await import(resolveApiEntry())})}function resolveApiEntry(){const vendored=join(process.cwd(),"storage/framework/core/actions/src/serve/api.ts");if(existsSync(vendored))return vendored;return"@stacksjs/actions/serve/api"}export function preview(buddy){buddy.command("preview [dir]","Serve a static build locally, the way a static host would").option("-p, --port <port>","Port to listen on",{default:"3001"}).example("buddy preview").example("buddy preview dist --port 4000").action(async(dir,options)=>{const root=dir||"dist",port=Number(options?.port)||3001;if(!existsSync(root)){log.error(`No \`${root}\` directory to preview. Run \`buddy build\` first, or pass the directory to serve.`);process.exit(ExitCode.FatalError)}Bun.serve({port,async fetch(req){let pathname=new URL(req.url).pathname;if(pathname==="/"||pathname==="")pathname="/index.html";else if(!pathname.includes("."))pathname=`${pathname.replace(/\/$/,"")}.html`;const file=Bun.file(join(root,pathname));if(await file.exists())return new Response(file);const notFound=Bun.file(join(root,"404.html"));if(await notFound.exists())return new Response(notFound,{status:404});return new Response("Not Found",{status:404})}});log.success(`Previewing ./${root} at http://localhost:${port}`)})}
1
+ import{existsSync}from"node:fs";import{join}from"node:path";import process from"node:process";import{log}from"@stacksjs/logging";import{ExitCode}from"@stacksjs/types";import{startProductionServer}from"../production-server";export{loadStxPartialsDir,resolveUserPartialsPath,startProductionServer}from"../production-server";export function serve(buddy){buddy.command("serve","Start the production HTTP server (STX views + /api proxy + coming-soon/maintenance gate)").option("-p, --port <port>","Port to listen on (defaults to PORT env or 3000)").option("--verbose","Enable verbose output",{default:!1}).action(startProductionServer)}export function serveApi(buddy){buddy.command("serve:api","Start the production API server (bun-router routes the frontend proxies /api to)").option("-p, --port <port>","Port to listen on (defaults to PORT env or 3008)").action(async(options)=>{if(options?.port)process.env.PORT=String(options.port);process.env.APP_ENV=process.env.APP_ENV||"production";await import(resolveApiEntry())})}function resolveApiEntry(){const vendored=join(process.cwd(),"storage/framework/core/actions/src/serve/api.ts");if(existsSync(vendored))return vendored;return"@stacksjs/actions/serve/api"}export function preview(buddy){buddy.command("preview [dir]","Serve a static build locally, the way a static host would").option("-p, --port <port>","Port to listen on",{default:"3001"}).example("buddy preview").example("buddy preview dist --port 4000").action(async(dir,options)=>{const root=dir||"dist",port=Number(options?.port)||3001;if(!existsSync(root)){await log.error(`No \`${root}\` directory to preview. Run \`buddy build\` first, or pass the directory to serve.`);process.exit(ExitCode.FatalError)}Bun.serve({port,async fetch(req){let pathname=new URL(req.url).pathname;if(pathname==="/"||pathname==="")pathname="/index.html";else if(!pathname.includes("."))pathname=`${pathname.replace(/\/$/,"")}.html`;const file=Bun.file(join(root,pathname));if(await file.exists())return new Response(file);const notFound=Bun.file(join(root,"404.html"));if(await notFound.exists())return new Response(notFound,{status:404});return new Response("Not Found",{status:404})}});log.success(`Previewing ./${root} at http://localhost:${port}`)})}
@@ -1,6 +1,6 @@
1
- import{createWriteStream,existsSync,mkdirSync,statSync}from"node:fs";import{homedir}from"node:os";import{dirname,join}from"node:path";import process from"node:process";import{intro,onUnknownSubcommand,outro,prompts}from"@stacksjs/cli";import{ExitCode}from"@stacksjs/types";import{loadTsCloudConfig,loadTsCloudDeployApi,resolveProvider}from"./deploy";import{mergeSshStatePin,resolveSshTarget,sshCliArgs,sshStatePin}from"./deploy-ssh-target";import{describeDisk,flashRefusalReason,parseDnsSdBrowse,parseOsCatalogue,resolveBootVolume,selectImage}from"./server-image";import{CA_MISSING_EXIT,caCopyPath,caReadScript,DEFAULT_LAN_CA_PATH,mobileconfigInstructions,resolveCaPath,trustSummary}from"./server-trust";const log={info:(...args)=>console.log("\u2139",...args),success:(...args)=>console.log("\u2713",...args),warn:(...args)=>console.log("\u26A0",...args),error:(...args)=>console.error("\u2717",...args)},OS_CATALOGUE_URL="https://downloads.raspberrypi.com/os_list_imagingutility_v4.json";function imageCacheDir(){return join(homedir(),".cache","stacks","images")}async function readDiskInfo(device){const proc=Bun.spawn(["diskutil","info","-plist",device],{stdout:"pipe",stderr:"pipe"}),[out,code]=await Promise.all([new Response(proc.stdout).text(),proc.exited]);if(code!==0||!out.trim())return null;const bool=(key)=>{const match=new RegExp(`<key>${key}</key>\\s*<(true|false)/>`).exec(out);return match?match[1]==="true":void 0},str=(key)=>{return new RegExp(`<key>${key}</key>\\s*<string>([^<]*)</string>`).exec(out)?.[1]},num=(key)=>{const match=new RegExp(`<key>${key}</key>\\s*<integer>(\\d+)</integer>`).exec(out);return match?Number(match[1]):void 0};return{DeviceIdentifier:str("DeviceIdentifier"),DeviceNode:str("DeviceNode"),MediaName:str("MediaName"),Size:num("Size"),WholeDisk:bool("WholeDisk"),Internal:bool("Internal"),Ejectable:bool("Ejectable"),Removable:bool("Removable"),RemovableMediaOrExternalDevice:bool("RemovableMediaOrExternalDevice"),SystemImage:bool("SystemImage"),BusProtocol:str("BusProtocol")}}async function listFlashableDisks(){const proc=Bun.spawn(["diskutil","list","-plist"],{stdout:"pipe",stderr:"pipe"}),[out]=await Promise.all([new Response(proc.stdout).text(),proc.exited]),whole=[...out.matchAll(/<string>(disk\d+)<\/string>/g)].map((match)=>match[1]),found=[];for(const id of[...new Set(whole)]){const info=await readDiskInfo(`/dev/${id}`);if(info&&flashRefusalReason(info)===null)found.push(info)}return found}async function resolveImage(os){const response=await fetch(OS_CATALOGUE_URL);if(!response.ok)throw Error(`Could not read the image catalogue (HTTP ${response.status}). Check the network and try again.`);return selectImage(parseOsCatalogue(await response.json()),os)}async function downloadImage(image){const dir=imageCacheDir();mkdirSync(dir,{recursive:!0});const target=join(dir,image.url.split("/").pop()||`${image.id}.img.xz`);if(existsSync(target)&&image.downloadSize&&statSync(target).size===image.downloadSize){log.info(`Using the cached download at ${target}`);return target}const size=image.downloadSize?` (${(image.downloadSize/1e9).toFixed(2)} GB)`:"";log.info(`Downloading ${image.name}${size}...`);const response=await fetch(image.url);if(!response.ok||!response.body)throw Error(`Download failed (HTTP ${response.status}) for ${image.url}`);const file=createWriteStream(`${target}.part`);await Bun.write(Bun.file(`${target}.part`),response);file.close();await Bun.$`mv ${`${target}.part`} ${target}`.quiet();return target}async function resolveDecompressor(){for(const candidate of["xz","unxz"])if(Bun.spawnSync(["which",candidate]).exitCode===0)return[candidate,"-dc"];throw Error("No xz decompressor found, and the images are .img.xz.\n Install one with: brew install xz\n Or flash the card with Raspberry Pi Imager, then run `buddy server:first-boot` against the mounted card.")}async function loadSshApi(){const api=await loadTsCloudDeployApi(),missing=["SshDriver","buildCloudInitFirstBoot","buildSshBootstrapScript","evaluatePreflight","formatPreflightFindings"].filter((name)=>typeof api[name]!=="function");if(missing.length>0){log.error("This @stacksjs/ts-cloud does not support deploying to a host over SSH.");log.error(`Missing: ${missing.join(", ")}.`);log.info("Upgrade with `bun update @stacksjs/ts-cloud`, or point TS_CLOUD_MODULE at a build that has it.");process.exit(ExitCode.FatalError)}return api}async function loadSshProject(environment){const config=await loadTsCloudConfig(environment);if(!config){log.error("No ts-cloud configuration found. Expected a `tsCloud` export from config/cloud.ts.");process.exit(ExitCode.FatalError)}const target=resolveSshTarget(config);if(!target){log.error("No SSH host configured.");log.info("Add one to config/cloud.ts: ssh: { hosts: [{ host: 'pi-stacks.local', user: 'pi' }] }");log.info("Or set TS_CLOUD_SSH_HOST (with TS_CLOUD_SSH_USER / TS_CLOUD_SSH_PORT / TS_CLOUD_SSH_KEY).");process.exit(ExitCode.FatalError)}if(resolveProvider(config)!=="ssh")log.warn(`config/cloud.ts sets provider '${resolveProvider(config)}'. Set it to 'ssh' before \`buddy deploy\` will use this host.`);return{config,target}}async function discoverHosts(seconds=4){if(process.platform!=="darwin")return[];const proc=Bun.spawn(["dns-sd","-B","_ssh._tcp","local."],{stdout:"pipe",stderr:"ignore"}),timer=setTimeout(()=>proc.kill(),seconds*1000);try{return parseDnsSdBrowse(await new Response(proc.stdout).text())}catch{return[]}finally{clearTimeout(timer)}}async function reportPreflight(api,target,asJson){const driver=new api.SshDriver({hosts:[{host:target.host,user:target.user,port:target.port,privateKeyPath:target.identityFile}],hostKey:target.hostKey,profile:target.profile});let facts,findings;try{({facts,findings}=await driver.preflight(target.host))}catch(err){const detail=err instanceof Error?err.message:String(err),unreachable={code:"ssh.unreachable",severity:"error",message:`Could not reach ${target.user}@${target.host} over SSH.`,remediation:"Check the board is powered on and on this network, that SSH is enabled, and that your key is authorised. A board that has just booted can take a minute to answer.",detail};if(asJson)console.log(JSON.stringify({host:target.host,facts:null,findings:[unreachable]},null,2));else{log.error(unreachable.message);log.info(unreachable.remediation);log.info(detail.split(`
2
- `).find((line)=>line.trim()&&!line.startsWith("Remote SSH"))||detail)}return!1}if(asJson)console.log(JSON.stringify({host:target.host,facts,findings},null,2));else{const text=api.formatPreflightFindings(findings);if(text.trim())console.log(text);else log.success("No problems found.")}return!(typeof api.preflightFailed==="function"?api.preflightFailed(findings):findings.some((finding)=>finding.severity==="error"))}function buildBootstrapOrExit(api,config,environment,sudoUser){const profile=config.ssh?.profile==="generic"?"generic":"raspberry-pi";try{return api.buildSshBootstrapScript({config,environment,profile,sudoUser,lan:config.ssh?.lan})}catch(err){log.error(err instanceof Error?err.message:String(err));log.info("Edit config/cloud.ts and run this again.");process.exit(ExitCode.FatalError)}}async function readRemoteCa(target,caPath){const proc=Bun.spawn(["ssh",...sshCliArgs(target,{connectTimeoutSec:20}),"sh","-s"],{stdin:new TextEncoder().encode(caReadScript(caPath)),stdout:"pipe",stderr:"pipe"}),[out,err,code]=await Promise.all([new Response(proc.stdout).text(),new Response(proc.stderr).text(),proc.exited]);if(code===CA_MISSING_EXIT)return{failure:"missing",detail:""};if(code!==0)return{failure:err.toLowerCase().includes("sudo")?"unreadable":"unreachable",detail:err.trim()};if(!out.includes("-----BEGIN CERTIFICATE-----"))return{failure:"unreadable",detail:""};return{pem:out}}function trustPlatform(){if(process.platform==="darwin")return"macos";return process.platform==="win32"?"windows":"debian"}export function server(buddy){const descriptions={flash:"Write a Linux OS image to an SD card or USB disk",os:"Which image to write: raspberry-pi-os-lite, raspberry-pi-os, ubuntu-24.04, ubuntu-26.04",device:"The whole disk to write to, for example /dev/disk4",verbose:"Enable verbose output"};buddy.command("server:flash",descriptions.flash).option("--os <name>",descriptions.os,{default:"raspberry-pi-os-lite"}).option("--device <path>",descriptions.device,{default:void 0}).option("--list","List the disks that could be written to, and exit",{default:!1}).option("--dry-run","Say what would happen without writing anything",{default:!1}).option("--yes","Do not ask for confirmation before writing",{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{const perf=await intro("buddy server:flash");if(process.platform!=="darwin"){log.error("`buddy server:flash` currently supports macOS only.");log.info("On Linux, write the image with `dd`, then run `buddy server:first-boot` against the mounted boot partition.");process.exit(ExitCode.FatalError)}const disks=await listFlashableDisks();if(options.list){if(disks.length===0)log.info("No removable disks are attached.");for(const disk of disks)log.info(` ${describeDisk(disk)}`);await outro("Exited",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)}let image;try{image=await resolveImage(options.os)}catch(err){log.error(err instanceof Error?err.message:String(err));process.exit(ExitCode.FatalError)}log.info(`Image: ${image.name} (${image.releaseDate??"unknown date"})`);if(!image.supportsPi5)log.warn("This image is not listed as supporting the Raspberry Pi 5.");let device=options.device;if(!device){if(disks.length===0){log.error("No removable disk is attached. Insert the card and try again, or pass --device.");process.exit(ExitCode.FatalError)}if(disks.length>1){log.error("Several removable disks are attached, so buddy will not pick one:");for(const disk of disks)log.error(` ${describeDisk(disk)}`);log.info("Re-run with --device /dev/diskN naming the one you mean.");process.exit(ExitCode.FatalError)}device=disks[0]?.DeviceNode}const info=device?await readDiskInfo(device):null;if(!info){log.error(`Could not read ${device}. Check the device path with \`diskutil list\`.`);process.exit(ExitCode.FatalError)}const refusal=flashRefusalReason(info);if(refusal){log.error(refusal);process.exit(ExitCode.FatalError)}let decompressor;try{decompressor=await resolveDecompressor()}catch(err){log.error(err instanceof Error?err.message:String(err));process.exit(ExitCode.FatalError)}log.info(`Target: ${describeDisk(info)}`);if(options.dryRun){log.info("Dry run: nothing was downloaded and nothing was written.");await outro("Exited",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)}if(!options.yes){if(await prompts.confirm({message:`Erase ${describeDisk(info)} and write ${image.name}?`,initial:!1})!==!0){log.info("Nothing was written.");process.exit(ExitCode.Success)}}let download;try{download=await downloadImage(image)}catch(err){log.error(err instanceof Error?err.message:String(err));process.exit(ExitCode.FatalError)}const raw=info.DeviceNode.replace("/dev/disk","/dev/rdisk");log.info(`Unmounting ${info.DeviceNode}...`);await Bun.$`diskutil unmountDisk ${info.DeviceNode}`.nothrow();log.info("Writing the image. This needs your password, and takes a few minutes.");log.info(` ${decompressor.join(" ")} ${download} | sudo dd of=${raw} bs=4m status=progress`);if(await Bun.spawn(["sh","-c",`${decompressor[0]} -dc ${JSON.stringify(download)} | sudo dd of=${JSON.stringify(raw)} bs=4m status=progress`],{stdin:"inherit",stdout:"inherit",stderr:"inherit"}).exited!==0){log.error("Writing the image failed. The card is probably unusable until it is written again.");process.exit(ExitCode.FatalError)}await Bun.$`sync`.nothrow();log.success("Image written.");const boot=resolveBootVolume(image,existsSync);if(boot)log.info(`Boot partition mounted at ${boot}`);else log.info(`Re-insert the card if it does not mount, then look for /Volumes/${image.bootVolume}`);log.info("Next: `buddy server:first-boot --hostname <name> --user <name>` to configure the first boot.");await outro("Done",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("server:first-boot","Write the first-boot configuration onto a freshly flashed card").option("--hostname <name>","The name the board answers to on the network",{default:"pi-stacks"}).option("--user <name>","The login to create, which the deploy then uses",{default:"pi"}).option("--ssh-key <path>","Public key to authorise",{default:void 0}).option("--os <name>",descriptions.os,{default:"raspberry-pi-os-lite"}).option("--out <dir>","Write the files here instead of the mounted boot partition",{default:void 0}).option("--wifi-ssid <ssid>","Join this wireless network on first boot",{default:void 0}).option("--wifi-country <code>","Two-letter regulatory domain, required with wifi",{default:void 0}).option("--timezone <tz>","IANA timezone for the board",{default:void 0}).option("--env <name>","Environment whose configuration to bootstrap",{default:"production"}).option("--force","Overwrite first-boot files already on the card",{default:!1}).action(async(options)=>{const perf=await intro("buddy server:first-boot"),api=await loadSshApi(),{config}=await loadSshProject(options.env),keyPath=options.sshKey||join(homedir(),".ssh","id_ed25519.pub");if(!existsSync(keyPath)){log.error(`No public key at ${keyPath}.`);log.info("Generate one with: ssh-keygen -t ed25519");log.info("Or point at an existing key with --ssh-key.");process.exit(ExitCode.FatalError)}let wifi;if(options.wifiSsid){if(!options.wifiCountry){log.error("--wifi-country is required with --wifi-ssid. It sets the radio regulatory domain.");process.exit(ExitCode.FatalError)}const passphrase=process.env.WIFI_PASSWORD||await prompts.password({message:`Passphrase for ${options.wifiSsid}`});if(typeof passphrase!=="string"||!passphrase){log.error("No wireless passphrase given.");process.exit(ExitCode.FatalError)}wifi={ssid:options.wifiSsid,passphrase,country:String(options.wifiCountry).toUpperCase()}}const bootstrap=buildBootstrapOrExit(api,config,options.env,options.user==="root"?void 0:options.user),os=String(options.os).startsWith("ubuntu")?"ubuntu":"raspberry-pi-os",bundle=api.buildCloudInitFirstBoot({hostname:options.hostname,user:options.user,publicKey:(await Bun.file(keyPath).text()).trim(),timezone:options.timezone,wifi},bootstrap,{os});let destination=options.out;if(!destination){const image=await resolveImage(options.os).catch(()=>null);destination=image?resolveBootVolume(image,existsSync):null;if(!destination){log.error("The card does not appear to be mounted.");log.info("Insert the freshly written card and try again, or pass --out <dir> to write the files elsewhere.");process.exit(ExitCode.FatalError)}}mkdirSync(destination,{recursive:!0});for(const name of Object.keys(bundle.files)){const path=join(destination,name);if(existsSync(path)&&!options.force){log.error(`${path} already exists. Re-run with --force to replace it.`);process.exit(ExitCode.FatalError)}}for(const[name,contents]of Object.entries(bundle.files)){await Bun.write(join(destination,name),contents);log.success(`Wrote ${join(destination,name)}`)}if(bundle.instructions)console.log(`
3
- ${bundle.instructions}`);log.info(`Next: eject the card, boot the board, then \`buddy server:doctor ${options.hostname}.local\``);await outro("Done",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("server:doctor [host]","Check that a host can run this application before deploying to it").option("--env <name>","Environment whose configuration to check against",{default:"production"}).option("--discover","Browse the local network for hosts advertising SSH",{default:!1}).option("--json","Print the findings as JSON",{default:!1}).action(async(host,options)=>{const perf=options.json?void 0:await intro("buddy server:doctor"),api=await loadSshApi(),{config,target}=await loadSshProject(options.env);let host_=host;if(!host_&&options.discover){const found=await discoverHosts();if(found.length===0)log.info("No hosts advertising SSH were found on this network.");for(const entry of found)log.info(` ${entry.hostname}`);host_=found[0]?.hostname}const checked=host_?{...target,host:host_}:target;log.info(`Checking ${checked.user}@${checked.host}${checked.port===22?"":`:${checked.port}`}...`);const ok=await reportPreflight(api,checked,options.json===!0);if(perf)await outro(ok?"Ready":"Not ready",{startTime:perf,useSeconds:!0});process.exit(ok?ExitCode.Success:ExitCode.FatalError)});buddy.command("server:setup [host]","Adopt a host: check it, then install what the deploy needs").option("--env <name>","Environment whose configuration to bootstrap",{default:"production"}).option("--discover","Browse the local network for hosts advertising SSH",{default:!1}).option("--dry-run","Run the checks and stop before changing the host",{default:!1}).action(async(host,options)=>{const perf=await intro("buddy server:setup"),api=await loadSshApi(),{config,target}=await loadSshProject(options.env);let host_=host;if(!host_&&options.discover){const found=await discoverHosts();for(const entry of found)log.info(` ${entry.hostname}`);host_=found[0]?.hostname}const adopted=host_?{...target,host:host_}:target;log.info(`Adopting ${adopted.user}@${adopted.host}${adopted.port===22?"":`:${adopted.port}`}`);if(!await reportPreflight(api,adopted,!1)){log.error("The host is not ready. Nothing was changed on it.");process.exit(ExitCode.FatalError)}if(options.dryRun){log.info("Dry run: the host passed its checks and was not modified.");await outro("Done",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)}const driver=api.createCloudDriver({config,provider:"ssh"});if(!driver.provisionComputeInfrastructure){log.error("This ts-cloud cannot bootstrap an SSH host (update @stacksjs/ts-cloud).");process.exit(ExitCode.FatalError)}buildBootstrapOrExit(api,config,options.env,adopted.user==="root"?void 0:adopted.user);log.info("Installing the runtime, gateway and service units if they are missing...");let outputs;try{outputs=await driver.provisionComputeInfrastructure({config,environment:options.env})}catch(err){log.error("Bootstrapping the host failed.");log.error(err instanceof Error?err.message:String(err));process.exit(ExitCode.FatalError)}const stackName=config.project?.stackName||`${config.project?.slug||"app"}-${options.env}`,dir=join(process.cwd(),"storage","cloud","state"),statePath=join(dir,`${stackName}.json`);let recorded=null;try{recorded=existsSync(statePath)?JSON.parse(await Bun.file(statePath).text()):null}catch{recorded=null}mkdirSync(dir,{recursive:!0});await Bun.write(statePath,`${JSON.stringify(mergeSshStatePin(recorded,sshStatePin({stackName,target:adopted,deployStoragePath:outputs?.deployStoragePath})),null,2)}
1
+ import{createWriteStream,existsSync,mkdirSync,statSync}from"node:fs";import{homedir}from"node:os";import{dirname,join}from"node:path";import process from"node:process";import{intro,onUnknownSubcommand,outro,prompts}from"@stacksjs/cli";import{ExitCode}from"@stacksjs/types";import{loadTsCloudConfig,loadTsCloudDeployApi,resolveProvider}from"./deploy";import{mergeSshStatePin,resolveSshTarget,sshCliArgs,sshStatePin}from"./deploy-ssh-target";import{describeDisk,flashRefusalReason,parseDnsSdBrowse,parseOsCatalogue,resolveBootVolume,selectImage}from"./server-image";import{CA_MISSING_EXIT,caCopyPath,caReadScript,DEFAULT_LAN_CA_PATH,mobileconfigInstructions,resolveCaPath,trustSummary}from"./server-trust";const log={info:(...args)=>console.log("\u2139",...args),success:(...args)=>console.log("\u2713",...args),warn:(...args)=>console.log("\u26A0",...args),error:(...args)=>console.error("\u2717",...args)},OS_CATALOGUE_URL="https://downloads.raspberrypi.com/os_list_imagingutility_v4.json";function imageCacheDir(){return join(homedir(),".cache","stacks","images")}async function readDiskInfo(device){const proc=Bun.spawn(["diskutil","info","-plist",device],{stdout:"pipe",stderr:"pipe"}),[out,code]=await Promise.all([new Response(proc.stdout).text(),proc.exited]);if(code!==0||!out.trim())return null;const bool=(key)=>{const match=new RegExp(`<key>${key}</key>\\s*<(true|false)/>`).exec(out);return match?match[1]==="true":void 0},str=(key)=>{return new RegExp(`<key>${key}</key>\\s*<string>([^<]*)</string>`).exec(out)?.[1]},num=(key)=>{const match=new RegExp(`<key>${key}</key>\\s*<integer>(\\d+)</integer>`).exec(out);return match?Number(match[1]):void 0};return{DeviceIdentifier:str("DeviceIdentifier"),DeviceNode:str("DeviceNode"),MediaName:str("MediaName"),Size:num("Size"),WholeDisk:bool("WholeDisk"),Internal:bool("Internal"),Ejectable:bool("Ejectable"),Removable:bool("Removable"),RemovableMediaOrExternalDevice:bool("RemovableMediaOrExternalDevice"),SystemImage:bool("SystemImage"),BusProtocol:str("BusProtocol")}}async function listFlashableDisks(){const proc=Bun.spawn(["diskutil","list","-plist"],{stdout:"pipe",stderr:"pipe"}),[out]=await Promise.all([new Response(proc.stdout).text(),proc.exited]),whole=[...out.matchAll(/<string>(disk\d+)<\/string>/g)].map((match)=>match[1]),found=[];for(const id of[...new Set(whole)]){const info=await readDiskInfo(`/dev/${id}`);if(info&&flashRefusalReason(info)===null)found.push(info)}return found}async function resolveImage(os){const response=await fetch(OS_CATALOGUE_URL);if(!response.ok)throw Error(`Could not read the image catalogue (HTTP ${response.status}). Check the network and try again.`);return selectImage(parseOsCatalogue(await response.json()),os)}async function downloadImage(image){const dir=imageCacheDir();mkdirSync(dir,{recursive:!0});const target=join(dir,image.url.split("/").pop()||`${image.id}.img.xz`);if(existsSync(target)&&image.downloadSize&&statSync(target).size===image.downloadSize){log.info(`Using the cached download at ${target}`);return target}const size=image.downloadSize?` (${(image.downloadSize/1e9).toFixed(2)} GB)`:"";log.info(`Downloading ${image.name}${size}...`);const response=await fetch(image.url);if(!response.ok||!response.body)throw Error(`Download failed (HTTP ${response.status}) for ${image.url}`);const file=createWriteStream(`${target}.part`);await Bun.write(Bun.file(`${target}.part`),response);file.close();await Bun.$`mv ${`${target}.part`} ${target}`.quiet();return target}async function resolveDecompressor(){for(const candidate of["xz","unxz"])if(Bun.spawnSync(["which",candidate]).exitCode===0)return[candidate,"-dc"];throw Error("No xz decompressor found, and the images are .img.xz.\n Install one with: brew install xz\n Or flash the card with Raspberry Pi Imager, then run `buddy server:first-boot` against the mounted card.")}async function loadSshApi(){const api=await loadTsCloudDeployApi(),missing=["SshDriver","buildCloudInitFirstBoot","buildSshBootstrapScript","evaluatePreflight","formatPreflightFindings"].filter((name)=>typeof api[name]!=="function");if(missing.length>0){await log.error("This @stacksjs/ts-cloud does not support deploying to a host over SSH.");await log.error(`Missing: ${missing.join(", ")}.`);log.info("Upgrade with `bun update @stacksjs/ts-cloud`, or point TS_CLOUD_MODULE at a build that has it.");process.exit(ExitCode.FatalError)}return api}async function loadSshProject(environment){const config=await loadTsCloudConfig(environment);if(!config){await log.error("No ts-cloud configuration found. Expected a `tsCloud` export from config/cloud.ts.");process.exit(ExitCode.FatalError)}const target=resolveSshTarget(config);if(!target){await log.error("No SSH host configured.");log.info("Add one to config/cloud.ts: ssh: { hosts: [{ host: 'pi-stacks.local', user: 'pi' }] }");log.info("Or set TS_CLOUD_SSH_HOST (with TS_CLOUD_SSH_USER / TS_CLOUD_SSH_PORT / TS_CLOUD_SSH_KEY).");process.exit(ExitCode.FatalError)}if(resolveProvider(config)!=="ssh")log.warn(`config/cloud.ts sets provider '${resolveProvider(config)}'. Set it to 'ssh' before \`buddy deploy\` will use this host.`);return{config,target}}async function discoverHosts(seconds=4){if(process.platform!=="darwin")return[];const proc=Bun.spawn(["dns-sd","-B","_ssh._tcp","local."],{stdout:"pipe",stderr:"ignore"}),timer=setTimeout(()=>proc.kill(),seconds*1000);try{return parseDnsSdBrowse(await new Response(proc.stdout).text())}catch{return[]}finally{clearTimeout(timer)}}async function reportPreflight(api,target,asJson){const driver=new api.SshDriver({hosts:[{host:target.host,user:target.user,port:target.port,privateKeyPath:target.identityFile}],hostKey:target.hostKey,profile:target.profile});let facts,findings;try{({facts,findings}=await driver.preflight(target.host))}catch(err){const detail=err instanceof Error?err.message:String(err),unreachable={code:"ssh.unreachable",severity:"error",message:`Could not reach ${target.user}@${target.host} over SSH.`,remediation:"Check the board is powered on and on this network, that SSH is enabled, and that your key is authorised. A board that has just booted can take a minute to answer.",detail};if(asJson)console.log(JSON.stringify({host:target.host,facts:null,findings:[unreachable]},null,2));else{log.error(unreachable.message);log.info(unreachable.remediation);log.info(detail.split(`
2
+ `).find((line)=>line.trim()&&!line.startsWith("Remote SSH"))||detail)}return!1}if(asJson)console.log(JSON.stringify({host:target.host,facts,findings},null,2));else{const text=api.formatPreflightFindings(findings);if(text.trim())console.log(text);else log.success("No problems found.")}return!(typeof api.preflightFailed==="function"?api.preflightFailed(findings):findings.some((finding)=>finding.severity==="error"))}function buildBootstrapOrExit(api,config,environment,sudoUser){const profile=config.ssh?.profile==="generic"?"generic":"raspberry-pi";try{return api.buildSshBootstrapScript({config,environment,profile,sudoUser,lan:config.ssh?.lan})}catch(err){console.error(err instanceof Error?err.message:String(err));log.info("Edit config/cloud.ts and run this again.");process.exit(ExitCode.FatalError)}}async function readRemoteCa(target,caPath){const proc=Bun.spawn(["ssh",...sshCliArgs(target,{connectTimeoutSec:20}),"sh","-s"],{stdin:new TextEncoder().encode(caReadScript(caPath)),stdout:"pipe",stderr:"pipe"}),[out,err,code]=await Promise.all([new Response(proc.stdout).text(),new Response(proc.stderr).text(),proc.exited]);if(code===CA_MISSING_EXIT)return{failure:"missing",detail:""};if(code!==0)return{failure:err.toLowerCase().includes("sudo")?"unreadable":"unreachable",detail:err.trim()};if(!out.includes("-----BEGIN CERTIFICATE-----"))return{failure:"unreadable",detail:""};return{pem:out}}function trustPlatform(){if(process.platform==="darwin")return"macos";return process.platform==="win32"?"windows":"debian"}export function server(buddy){const descriptions={flash:"Write a Linux OS image to an SD card or USB disk",os:"Which image to write: raspberry-pi-os-lite, raspberry-pi-os, ubuntu-24.04, ubuntu-26.04",device:"The whole disk to write to, for example /dev/disk4",verbose:"Enable verbose output"};buddy.command("server:flash",descriptions.flash).option("--os <name>",descriptions.os,{default:"raspberry-pi-os-lite"}).option("--device <path>",descriptions.device,{default:void 0}).option("--list","List the disks that could be written to, and exit",{default:!1}).option("--dry-run","Say what would happen without writing anything",{default:!1}).option("--yes","Do not ask for confirmation before writing",{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{const perf=await intro("buddy server:flash");if(process.platform!=="darwin"){await log.error("`buddy server:flash` currently supports macOS only.");log.info("On Linux, write the image with `dd`, then run `buddy server:first-boot` against the mounted boot partition.");process.exit(ExitCode.FatalError)}const disks=await listFlashableDisks();if(options.list){if(disks.length===0)log.info("No removable disks are attached.");for(const disk of disks)log.info(` ${describeDisk(disk)}`);await outro("Exited",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)}let image;try{image=await resolveImage(options.os)}catch(err){await log.error(err instanceof Error?err.message:String(err));process.exit(ExitCode.FatalError)}log.info(`Image: ${image.name} (${image.releaseDate??"unknown date"})`);if(!image.supportsPi5)log.warn("This image is not listed as supporting the Raspberry Pi 5.");let device=options.device;if(!device){if(disks.length===0){await log.error("No removable disk is attached. Insert the card and try again, or pass --device.");process.exit(ExitCode.FatalError)}if(disks.length>1){log.error("Several removable disks are attached, so buddy will not pick one:");for(const disk of disks)await log.error(` ${describeDisk(disk)}`);log.info("Re-run with --device /dev/diskN naming the one you mean.");process.exit(ExitCode.FatalError)}device=disks[0]?.DeviceNode}const info=device?await readDiskInfo(device):null;if(!info){await log.error(`Could not read ${device}. Check the device path with \`diskutil list\`.`);process.exit(ExitCode.FatalError)}const refusal=flashRefusalReason(info);if(refusal){await log.error(refusal);process.exit(ExitCode.FatalError)}let decompressor;try{decompressor=await resolveDecompressor()}catch(err){await log.error(err instanceof Error?err.message:String(err));process.exit(ExitCode.FatalError)}log.info(`Target: ${describeDisk(info)}`);if(options.dryRun){log.info("Dry run: nothing was downloaded and nothing was written.");await outro("Exited",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)}if(!options.yes){if(await prompts.confirm({message:`Erase ${describeDisk(info)} and write ${image.name}?`,initial:!1})!==!0){log.info("Nothing was written.");process.exit(ExitCode.Success)}}let download;try{download=await downloadImage(image)}catch(err){await log.error(err instanceof Error?err.message:String(err));process.exit(ExitCode.FatalError)}const raw=info.DeviceNode.replace("/dev/disk","/dev/rdisk");log.info(`Unmounting ${info.DeviceNode}...`);await Bun.$`diskutil unmountDisk ${info.DeviceNode}`.nothrow();log.info("Writing the image. This needs your password, and takes a few minutes.");log.info(` ${decompressor.join(" ")} ${download} | sudo dd of=${raw} bs=4m status=progress`);if(await Bun.spawn(["sh","-c",`${decompressor[0]} -dc ${JSON.stringify(download)} | sudo dd of=${JSON.stringify(raw)} bs=4m status=progress`],{stdin:"inherit",stdout:"inherit",stderr:"inherit"}).exited!==0){await log.error("Writing the image failed. The card is probably unusable until it is written again.");process.exit(ExitCode.FatalError)}await Bun.$`sync`.nothrow();log.success("Image written.");const boot=resolveBootVolume(image,existsSync);if(boot)log.info(`Boot partition mounted at ${boot}`);else log.info(`Re-insert the card if it does not mount, then look for /Volumes/${image.bootVolume}`);log.info("Next: `buddy server:first-boot --hostname <name> --user <name>` to configure the first boot.");await outro("Done",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("server:first-boot","Write the first-boot configuration onto a freshly flashed card").option("--hostname <name>","The name the board answers to on the network",{default:"pi-stacks"}).option("--user <name>","The login to create, which the deploy then uses",{default:"pi"}).option("--ssh-key <path>","Public key to authorise",{default:void 0}).option("--os <name>",descriptions.os,{default:"raspberry-pi-os-lite"}).option("--out <dir>","Write the files here instead of the mounted boot partition",{default:void 0}).option("--wifi-ssid <ssid>","Join this wireless network on first boot",{default:void 0}).option("--wifi-country <code>","Two-letter regulatory domain, required with wifi",{default:void 0}).option("--timezone <tz>","IANA timezone for the board",{default:void 0}).option("--env <name>","Environment whose configuration to bootstrap",{default:"production"}).option("--force","Overwrite first-boot files already on the card",{default:!1}).action(async(options)=>{const perf=await intro("buddy server:first-boot"),api=await loadSshApi(),{config}=await loadSshProject(options.env),keyPath=options.sshKey||join(homedir(),".ssh","id_ed25519.pub");if(!existsSync(keyPath)){await log.error(`No public key at ${keyPath}.`);log.info("Generate one with: ssh-keygen -t ed25519");log.info("Or point at an existing key with --ssh-key.");process.exit(ExitCode.FatalError)}let wifi;if(options.wifiSsid){if(!options.wifiCountry){await log.error("--wifi-country is required with --wifi-ssid. It sets the radio regulatory domain.");process.exit(ExitCode.FatalError)}const passphrase=process.env.WIFI_PASSWORD||await prompts.password({message:`Passphrase for ${options.wifiSsid}`});if(typeof passphrase!=="string"||!passphrase){await log.error("No wireless passphrase given.");process.exit(ExitCode.FatalError)}wifi={ssid:options.wifiSsid,passphrase,country:String(options.wifiCountry).toUpperCase()}}const bootstrap=buildBootstrapOrExit(api,config,options.env,options.user==="root"?void 0:options.user),os=String(options.os).startsWith("ubuntu")?"ubuntu":"raspberry-pi-os",bundle=api.buildCloudInitFirstBoot({hostname:options.hostname,user:options.user,publicKey:(await Bun.file(keyPath).text()).trim(),timezone:options.timezone,wifi},bootstrap,{os});let destination=options.out;if(!destination){const image=await resolveImage(options.os).catch(()=>null);destination=image?resolveBootVolume(image,existsSync):null;if(!destination){await log.error("The card does not appear to be mounted.");log.info("Insert the freshly written card and try again, or pass --out <dir> to write the files elsewhere.");process.exit(ExitCode.FatalError)}}mkdirSync(destination,{recursive:!0});for(const name of Object.keys(bundle.files)){const path=join(destination,name);if(existsSync(path)&&!options.force){await log.error(`${path} already exists. Re-run with --force to replace it.`);process.exit(ExitCode.FatalError)}}for(const[name,contents]of Object.entries(bundle.files)){await Bun.write(join(destination,name),contents);log.success(`Wrote ${join(destination,name)}`)}if(bundle.instructions)console.log(`
3
+ ${bundle.instructions}`);log.info(`Next: eject the card, boot the board, then \`buddy server:doctor ${options.hostname}.local\``);await outro("Done",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("server:doctor [host]","Check that a host can run this application before deploying to it").option("--env <name>","Environment whose configuration to check against",{default:"production"}).option("--discover","Browse the local network for hosts advertising SSH",{default:!1}).option("--json","Print the findings as JSON",{default:!1}).action(async(host,options)=>{const perf=options.json?void 0:await intro("buddy server:doctor"),api=await loadSshApi(),{config,target}=await loadSshProject(options.env);let host_=host;if(!host_&&options.discover){const found=await discoverHosts();if(found.length===0)log.info("No hosts advertising SSH were found on this network.");for(const entry of found)log.info(` ${entry.hostname}`);host_=found[0]?.hostname}const checked=host_?{...target,host:host_}:target;log.info(`Checking ${checked.user}@${checked.host}${checked.port===22?"":`:${checked.port}`}...`);const ok=await reportPreflight(api,checked,options.json===!0);if(perf)await outro(ok?"Ready":"Not ready",{startTime:perf,useSeconds:!0});process.exit(ok?ExitCode.Success:ExitCode.FatalError)});buddy.command("server:setup [host]","Adopt a host: check it, then install what the deploy needs").option("--env <name>","Environment whose configuration to bootstrap",{default:"production"}).option("--discover","Browse the local network for hosts advertising SSH",{default:!1}).option("--dry-run","Run the checks and stop before changing the host",{default:!1}).action(async(host,options)=>{const perf=await intro("buddy server:setup"),api=await loadSshApi(),{config,target}=await loadSshProject(options.env);let host_=host;if(!host_&&options.discover){const found=await discoverHosts();for(const entry of found)log.info(` ${entry.hostname}`);host_=found[0]?.hostname}const adopted=host_?{...target,host:host_}:target;log.info(`Adopting ${adopted.user}@${adopted.host}${adopted.port===22?"":`:${adopted.port}`}`);if(!await reportPreflight(api,adopted,!1)){await log.error("The host is not ready. Nothing was changed on it.");process.exit(ExitCode.FatalError)}if(options.dryRun){log.info("Dry run: the host passed its checks and was not modified.");await outro("Done",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)}const driver=api.createCloudDriver({config,provider:"ssh"});if(!driver.provisionComputeInfrastructure){await log.error("This ts-cloud cannot bootstrap an SSH host (update @stacksjs/ts-cloud).");process.exit(ExitCode.FatalError)}buildBootstrapOrExit(api,config,options.env,adopted.user==="root"?void 0:adopted.user);log.info("Installing the runtime, gateway and service units if they are missing...");let outputs;try{outputs=await driver.provisionComputeInfrastructure({config,environment:options.env})}catch(err){await log.error("Bootstrapping the host failed.");await log.error(err instanceof Error?err.message:String(err));process.exit(ExitCode.FatalError)}const stackName=config.project?.stackName||`${config.project?.slug||"app"}-${options.env}`,dir=join(process.cwd(),"storage","cloud","state"),statePath=join(dir,`${stackName}.json`);let recorded=null;try{recorded=existsSync(statePath)?JSON.parse(await Bun.file(statePath).text()):null}catch{recorded=null}mkdirSync(dir,{recursive:!0});await Bun.write(statePath,`${JSON.stringify(mergeSshStatePin(recorded,sshStatePin({stackName,target:adopted,deployStoragePath:outputs?.deployStoragePath})),null,2)}
4
4
  `);log.success(`Host adopted. Recorded at storage/cloud/state/${stackName}.json`);log.info("Next: `buddy deploy --prod`");await outro("Done",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("server:trust [host]","Trust the host's own certificate authority on this machine").option("--env <name>","Environment whose configuration names the host",{default:"production"}).option("--discover","Browse the local network for hosts advertising SSH",{default:!1}).option("--ca-path <path>","Where the authority lives on the host",{default:DEFAULT_LAN_CA_PATH}).option("--mobileconfig <path>","Also write an Apple configuration profile for an iPhone or iPad",{default:void 0}).option("--export-only","Save the certificate without changing this machine, and say how to trust it by hand",{default:!1}).option("--json","Print the result as JSON",{default:!1}).action(async(host,options)=>{const asJson=options.json===!0,perf=asJson?void 0:await intro("buddy server:trust"),{target}=await loadSshProject(options.env);let host_=host;if(!host_&&options.discover){const found=await discoverHosts();if(found.length===0&&!asJson)log.info("No hosts advertising SSH were found on this network.");for(const entry of found)if(!asJson)log.info(` ${entry.hostname}`);host_=found[0]?.hostname}const checked=host_?{...target,host:host_}:target,caPath=resolveCaPath(options.caPath),fail=(reason,message,remediation,detail)=>{if(asJson)console.log(JSON.stringify({host:checked.host,caPath,error:reason,message,remediation,...detail?{detail}:{}},null,2));else{log.error(message);log.info(remediation);if(detail)log.info(detail)}process.exit(ExitCode.FatalError)};if(!asJson)log.info(`Reading ${caPath} from ${checked.user}@${checked.host}${checked.port===22?"":`:${checked.port}`}...`);const read=await readRemoteCa(checked,caPath);if("failure"in read){if(read.failure==="missing")return fail("ca.absent",`There is no certificate authority at ${caPath} on ${checked.host}.`,"That host is not serving LAN HTTPS from its own authority. Set `ssh: { lan: { tls: 'local-ca' } }` in config/cloud.ts and run `buddy deploy --prod`, which is what creates it. Nothing was created on the host. If the authority lives elsewhere, name it with --ca-path.");if(read.failure==="unreadable")return fail("ca.unreadable",`${caPath} on ${checked.host} exists but could not be read as a certificate.`,`Check it by hand with \`ssh ${checked.user}@${checked.host} sudo cat ${caPath}\`. Reading it falls back to \`sudo -n\`, so a host whose sudo asks for a password cannot serve it to this command.`,read.detail||void 0);return fail("ssh.unreachable",`Could not reach ${checked.user}@${checked.host} over SSH.`,"Check the host is powered on and on this network, that SSH is enabled, and that your key is authorised. `buddy server:doctor` reports on all three.",read.detail.split(`
5
5
  `).find((line)=>line.trim())||void 0)}const pem=read.pem,{exportCA,getCertSha256Fingerprint,isCertTrusted,trustInstructions}=await import("@stacksjs/tlsx");let fingerprint;try{fingerprint=getCertSha256Fingerprint(pem)}catch(err){return fail("ca.unparseable",`The file at ${caPath} on ${checked.host} is not a certificate this can read.`,"Point --ca-path at the root certificate rpx writes, which is a PEM.",err instanceof Error?err.message:String(err))}const savedPath=caCopyPath(checked.host);mkdirSync(dirname(savedPath),{recursive:!0});await Bun.write(savedPath,pem.endsWith(`
6
6
  `)?pem:`${pem}
@@ -1,5 +1,5 @@
1
- import{cpSync,existsSync,readFileSync,writeFileSync}from"node:fs";import{homedir}from"node:os";import{join}from"node:path";import process from"node:process";import{runAction}from"@stacksjs/actions";import{log,onUnknownSubcommand,runCommand}from"@stacksjs/cli";import{Action}from"@stacksjs/enums";import{handleError}from"@stacksjs/error-handling";import{path as p}from"@stacksjs/path";import{copyFile,storage}from"@stacksjs/storage";import{ExitCode}from"@stacksjs/types";import{setupPrettyDevEnvironment}from"./dev";import{resultFailed}from"../result";function getTimeoutMs(envVar,fallbackMs){const value=Number(process.env[envVar]);if(Number.isFinite(value)&&value>0)return value;return fallbackMs}const PANTRY_CHECK_TIMEOUT_MS=getTimeoutMs("PANTRY_CHECK_TIMEOUT_MS",15000),PANTRY_INSTALL_TIMEOUT_MS=getTimeoutMs("PANTRY_INSTALL_TIMEOUT_MS",600000),PANTRY_DEPENDENCIES_TIMEOUT_MS=getTimeoutMs("PANTRY_DEPENDENCIES_TIMEOUT_MS",1200000),KEYGEN_TIMEOUT_MS=getTimeoutMs("KEYGEN_TIMEOUT_MS",120000),AWS_CONFIG_TIMEOUT_MS=getTimeoutMs("AWS_CONFIG_TIMEOUT_MS",900000);export function setup(buddy){const descriptions={setup:"This command ensures your project is setup correctly",ssl:"Setup SSL certificates and hosts file for HTTPS development",ai:"Set the project up for an AI coding agent (Claude Code, Codex, Cursor, Copilot, Gemini)",copy:"Copy the agent files instead of symlinking them, so they can be edited per project",force:"Overwrite files that already exist",ohMyZsh:"Enable Oh My Zsh",aws:"Ensures AWS is connected to the project",project:"Target a specific project",verbose:"Enable verbose output",domain:"Custom domain to setup (defaults to APP_URL)",skipHosts:"Skip adding domain to hosts file",skipTrust:"Skip trusting the certificate",skipAws:"Skip AWS configuration during setup",skipKeygen:"Skip generating an application key during setup"};buddy.command("setup",descriptions.setup).alias("ensure").option("-p, --project [project]",descriptions.project,{default:!1}).option("--skip-aws",descriptions.skipAws,{default:!1}).option("--skip-keygen",descriptions.skipKeygen,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy setup` ...",options);await ensurePantryInstalled();await optimizePantryDeps();await initializeProject(options)});buddy.command("setup:ssl",descriptions.ssl).alias("ssl:setup").option("-d, --domain [domain]",descriptions.domain).option("--skip-hosts",descriptions.skipHosts,{default:!1}).option("--skip-trust",descriptions.skipTrust,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy setup:ssl` ...",options);if(!await setupPrettyDevEnvironment({domain:options.domain,skipHosts:options.skipHosts,skipTrust:options.skipTrust,verbose:options.verbose})){log.warn("SSL setup completed with warnings");log.info("You may need to manually trust certificates or update hosts file")}});buddy.command("setup:ai [provider]",descriptions.ai).alias("ai:setup").option("--copy",descriptions.copy,{default:!1}).option("--force",descriptions.force,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(provider,options)=>{log.debug("Running `buddy setup:ai` ...",options);const{AI_PROVIDERS,isAiProvider,reportAiSetup,setupAiProvider}=await import("./setup-ai");let id=provider;if(!id){const{select}=await import("@stacksjs/cli");id=await select({message:"Which AI coding agent do you use?",choices:AI_PROVIDERS.map((entry)=>({value:entry.id,label:entry.label})),initial:0})}if(!id||!isAiProvider(id)){log.error(`Unknown AI provider: ${id}. Expected one of: ${AI_PROVIDERS.map((entry)=>entry.id).join(", ")}`);process.exit(ExitCode.InvalidArgument)}const definition=AI_PROVIDERS.find((entry)=>entry.id===id);reportAiSetup(definition,setupAiProvider(id,{copy:options.copy,force:options.force}))});buddy.command("setup:oh-my-zsh",descriptions.ohMyZsh).alias("upgrade:oh-my-zsh").option("--verbose",descriptions.verbose,{default:!1}).action(async(_options)=>{log.debug("Running `buddy setup:oh-my-zsh` ...",_options);const result=await runAction(Action.UpgradeShell);if(resultFailed(result)){log.error(result.error);process.exit(ExitCode.FatalError)}});onUnknownSubcommand(buddy,"setup")}async function isPantryInstalled(){try{return(await runCommand("pantry --version",{silent:!0,timeoutMs:PANTRY_CHECK_TIMEOUT_MS})).isOk}catch{return!1}}async function installPantry(){const bundledInstaller=p.frameworkPath("scripts/pantry-install"),command=existsSync(bundledInstaller)?[bundledInstaller]:["sh","-c","curl -fsSL https://pantry.dev | bash"],result=await runCommand(command,{timeoutMs:PANTRY_INSTALL_TIMEOUT_MS}),localBin=join(homedir(),".local","bin");if(!process.env.PATH?.split(":").includes(localBin))process.env.PATH=`${localBin}:${process.env.PATH||""}`;if(result.isOk&&await isPantryInstalled())return;if(resultFailed(result))handleError(result.error);else log.error("Pantry installed but is not available on PATH. Open a new shell and run `buddy setup` again.");process.exit(ExitCode.FatalError)}export async function ensurePantryInstalled(){if(await isPantryInstalled())return;log.info("Pantry is required. Installing it from https://pantry.dev...");await installPantry()}export async function ensurePantryDependencies(cwd){await ensurePantryInstalled();log.info("Installing project dependencies with Pantry...");const result=await runCommand("pantry install",{cwd,timeoutMs:PANTRY_DEPENDENCIES_TIMEOUT_MS});if(resultFailed(result)){handleError(result.error);process.exit(ExitCode.FatalError)}if(existsSync(join(cwd,"package.json"))&&!existsSync(join(cwd,"node_modules"))){log.error("Pantry completed without installing the project JavaScript dependencies.");process.exit(ExitCode.FatalError)}log.success("Installed project dependencies with Pantry")}function hasAppKey(cwd){const envPath=join(cwd,".env");if(!existsSync(envPath))return!1;return/^APP_KEY=.+$/m.test(readFileSync(envPath,"utf-8"))}export async function ensureAppKey(cwd){if(hasAppKey(cwd)||process.env.APP_KEY&&process.env.APP_KEY.length>0){log.success("APP_KEY existed");return}const keyResult=await runCommand("./buddy key:generate",{cwd,timeoutMs:KEYGEN_TIMEOUT_MS});if(resultFailed(keyResult)){handleError(keyResult.error);process.exit(ExitCode.FatalError)}log.success("Generated application key")}async function runInitialMigration(cwd){const appEnv=(process.env.APP_ENV||process.env.NODE_ENV||"local").toLowerCase();if(!["local","development","dev","test"].includes(appEnv)){log.info(`Skipping initial migration in the ${appEnv} environment`);return}log.info("Running initial database migration...");try{const result=await runAction(Action.Migrate,{cwd});if(resultFailed(result)){log.warn("Initial migration did not complete - you can run it later via ./buddy migrate");log.debug(result.error);return}log.success("Database is migrated")}catch(error){log.warn("Initial migration did not complete - you can run it later via ./buddy migrate");log.debug(error)}}async function initializeProject(options){const cwd=options.cwd||p.projectPath();await ensurePantryDependencies(cwd);await ensureEnvIsSet(options);if(!options.skipKeygen)await ensureAppKey(cwd);await runInitialMigration(cwd);ensureIdeSettings(cwd);if(!options.skipAws){log.info("Ensuring AWS is connected...");try{const awsResult=await runCommand("./buddy configure:aws",{cwd,timeoutMs:AWS_CONFIG_TIMEOUT_MS});if(resultFailed(awsResult)){log.warn("AWS not configured - you can do this later via ./buddy configure:aws");log.debug(awsResult.error)}else log.success("Configured AWS")}catch(error){log.warn("AWS not configured - you can do this later via ./buddy configure:aws");log.debug(error)}}log.success("Project is setup");log.info("Run `./buddy doctor` anytime to check your setup. Happy coding! \uD83D\uDC99")}export function ensureIdeSettings(cwd){const source=p.frameworkPath("defaults/ide/vscode/.vscode"),destination=join(cwd,".vscode");if(existsSync(destination)){log.debug(".vscode already exists; keeping the project settings");return}if(!existsSync(source)){log.debug("No bundled VS Code settings found; skipping IDE setup");return}cpSync(source,destination,{recursive:!0});log.success("Installed project VS Code settings")}const DB_CONNECTION_PACKAGES={postgres:{name:"postgresql.org",version:"^17.10",service:"postgres"},mysql:{name:"mysql.com",version:"^9.2",service:"mysql"},sqlite:{name:"sqlite.org",version:"^3.47.2"}};function databaseAliases(connection,pkg){return[connection,pkg.name]}export function pantryDatabasePackage(connection){return DB_CONNECTION_PACKAGES[connection]}function detectDbPackage(cwd){const envPath=join(cwd,".env"),envExamplePath=join(cwd,".env.example"),filePath=existsSync(envPath)?envPath:existsSync(envExamplePath)?envExamplePath:void 0;if(!filePath)return;const match=readFileSync(filePath,"utf-8").match(/^DB_CONNECTION=(.+)$/m);if(!match)return;const value=match[1].trim().replace(/['"]/g,"");return pantryDatabasePackage(value)}export async function optimizePantryDeps(){const cwd=p.projectPath(),depsConfigPath=join(cwd,"config","deps.ts");if(!existsSync(depsConfigPath)){log.debug("No config/deps.ts found, skipping dependency optimization");return}let configDeps={},configServices=[],configDefined={};try{const mod=await import(depsConfigPath),config=mod.config||mod.default;if(config?.dependencies)configDeps={...config.dependencies};if(Array.isArray(config?.services?.autoStart))configServices=config.services.autoStart.filter((name)=>typeof name==="string");if(config?.services?.define&&typeof config.services.define==="object")configDefined=config.services.define}catch(err){log.debug("Could not load config/deps.ts, skipping dependency optimization");return}const dbPackage=detectDbPackage(cwd),autoStart=[...configServices];if(dbPackage){const selected=new Set(Object.entries(DB_CONNECTION_PACKAGES).filter(([,pkg])=>pkg.name===dbPackage.name).flatMap(([connection,pkg])=>databaseAliases(connection,pkg))),unused=new Set(Object.entries(DB_CONNECTION_PACKAGES).flatMap(([connection,pkg])=>databaseAliases(connection,pkg)).filter((alias)=>!selected.has(alias)));for(const pkg of Object.keys(configDeps)){const domain=pkg.split("/")[0];if(unused.has(domain)){log.info(`DB_CONNECTION selects ${dbPackage.name}, dropping unused ${pkg}`);delete configDeps[pkg]}}if(!Object.keys(configDeps).some((key)=>{const domain=key.split("/")[0];return selected.has(domain)})){log.info(`Detected DB_CONNECTION requires ${dbPackage.name}, adding to dependencies`);configDeps[dbPackage.name]=dbPackage.version}if(dbPackage.service&&!autoStart.includes(dbPackage.service))autoStart.push(dbPackage.service)}const lines=["# Auto-generated from config/deps.ts and .env sniffing.","# This file is regenerated on each `buddy setup` run.","#","# To learn more, please visit:","# https://stacksjs.com/docs/dependency-management","","dependencies:"];for(const[pkg,version]of Object.entries(configDeps))lines.push(` ${pkg}: ${version}`);const defined=Object.entries(configDefined);if(autoStart.length>0||defined.length>0){lines.push("","services:"," enabled: true");if(autoStart.length>0){lines.push(" autoStart:");for(const service of autoStart)lines.push(` - ${service}`)}if(defined.length>0){lines.push(" define:");for(const[name,definition]of defined){if(!definition||typeof definition!=="object")continue;lines.push(` ${name}:`);for(const[key,value]of Object.entries(definition)){if(value===void 0||value===null)continue;lines.push(` ${key}: ${String(value)}`)}}}}const depsYamlPath=join(cwd,"deps.yaml");writeFileSync(depsYamlPath,`${lines.join(`
1
+ import{cpSync,existsSync,readFileSync,writeFileSync}from"node:fs";import{homedir}from"node:os";import{join}from"node:path";import process from"node:process";import{runAction}from"@stacksjs/actions";import{log,onUnknownSubcommand,runCommand}from"@stacksjs/cli";import{Action}from"@stacksjs/enums";import{handleError}from"@stacksjs/error-handling";import{path as p}from"@stacksjs/path";import{copyFile,storage}from"@stacksjs/storage";import{ExitCode}from"@stacksjs/types";import{setupPrettyDevEnvironment}from"./dev";import{resultFailed}from"../result";function getTimeoutMs(envVar,fallbackMs){const value=Number(process.env[envVar]);if(Number.isFinite(value)&&value>0)return value;return fallbackMs}const PANTRY_CHECK_TIMEOUT_MS=getTimeoutMs("PANTRY_CHECK_TIMEOUT_MS",15000),PANTRY_INSTALL_TIMEOUT_MS=getTimeoutMs("PANTRY_INSTALL_TIMEOUT_MS",600000),PANTRY_DEPENDENCIES_TIMEOUT_MS=getTimeoutMs("PANTRY_DEPENDENCIES_TIMEOUT_MS",1200000),KEYGEN_TIMEOUT_MS=getTimeoutMs("KEYGEN_TIMEOUT_MS",120000),AWS_CONFIG_TIMEOUT_MS=getTimeoutMs("AWS_CONFIG_TIMEOUT_MS",900000);export function setup(buddy){const descriptions={setup:"This command ensures your project is setup correctly",ssl:"Setup SSL certificates and hosts file for HTTPS development",ai:"Set the project up for an AI coding agent (Claude Code, Codex, Cursor, Copilot, Gemini)",copy:"Copy the agent files instead of symlinking them, so they can be edited per project",force:"Overwrite files that already exist",ohMyZsh:"Enable Oh My Zsh",aws:"Ensures AWS is connected to the project",project:"Target a specific project",verbose:"Enable verbose output",domain:"Custom domain to setup (defaults to APP_URL)",skipHosts:"Skip adding domain to hosts file",skipTrust:"Skip trusting the certificate",skipAws:"Skip AWS configuration during setup",skipKeygen:"Skip generating an application key during setup"};buddy.command("setup",descriptions.setup).alias("ensure").option("-p, --project [project]",descriptions.project,{default:!1}).option("--skip-aws",descriptions.skipAws,{default:!1}).option("--skip-keygen",descriptions.skipKeygen,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy setup` ...",options);await ensurePantryInstalled();await optimizePantryDeps();await initializeProject(options)});buddy.command("setup:ssl",descriptions.ssl).alias("ssl:setup").option("-d, --domain [domain]",descriptions.domain).option("--skip-hosts",descriptions.skipHosts,{default:!1}).option("--skip-trust",descriptions.skipTrust,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(options)=>{log.debug("Running `buddy setup:ssl` ...",options);if(!await setupPrettyDevEnvironment({domain:options.domain,skipHosts:options.skipHosts,skipTrust:options.skipTrust,verbose:options.verbose})){log.warn("SSL setup completed with warnings");log.info("You may need to manually trust certificates or update hosts file")}});buddy.command("setup:ai [provider]",descriptions.ai).alias("ai:setup").option("--copy",descriptions.copy,{default:!1}).option("--force",descriptions.force,{default:!1}).option("--verbose",descriptions.verbose,{default:!1}).action(async(provider,options)=>{log.debug("Running `buddy setup:ai` ...",options);const{AI_PROVIDERS,isAiProvider,reportAiSetup,setupAiProvider}=await import("./setup-ai");let id=provider;if(!id){if(!process.stdin.isTTY){await log.error(`\`setup:ai\` needs a provider when stdin is not a terminal. Pass one: ${AI_PROVIDERS.map((entry)=>`buddy setup:ai ${entry.id}`).join(", ")}`);process.exit(ExitCode.InvalidArgument)}const{select}=await import("@stacksjs/cli");id=await select({message:"Which AI coding agent do you use?",choices:AI_PROVIDERS.map((entry)=>({value:entry.id,label:entry.label})),initial:0})}if(!id||!isAiProvider(id)){await log.error(`Unknown AI provider: ${id}. Expected one of: ${AI_PROVIDERS.map((entry)=>entry.id).join(", ")}`);process.exit(ExitCode.InvalidArgument)}const definition=AI_PROVIDERS.find((entry)=>entry.id===id);reportAiSetup(definition,setupAiProvider(id,{copy:options.copy,force:options.force}))});buddy.command("setup:oh-my-zsh",descriptions.ohMyZsh).alias("upgrade:oh-my-zsh").option("--verbose",descriptions.verbose,{default:!1}).action(async(_options)=>{log.debug("Running `buddy setup:oh-my-zsh` ...",_options);const result=await runAction(Action.UpgradeShell);if(resultFailed(result)){await log.error(result.error);process.exit(ExitCode.FatalError)}});onUnknownSubcommand(buddy,"setup")}async function isPantryInstalled(){try{return(await runCommand("pantry --version",{silent:!0,timeoutMs:PANTRY_CHECK_TIMEOUT_MS})).isOk}catch{return!1}}async function installPantry(){const bundledInstaller=p.frameworkPath("scripts/pantry-install"),command=existsSync(bundledInstaller)?[bundledInstaller]:["sh","-c","curl -fsSL https://pantry.dev | bash"],result=await runCommand(command,{timeoutMs:PANTRY_INSTALL_TIMEOUT_MS}),localBin=join(homedir(),".local","bin");if(!process.env.PATH?.split(":").includes(localBin))process.env.PATH=`${localBin}:${process.env.PATH||""}`;if(result.isOk&&await isPantryInstalled())return;if(resultFailed(result))handleError(result.error);else await log.error("Pantry installed but is not available on PATH. Open a new shell and run `buddy setup` again.");process.exit(ExitCode.FatalError)}export async function ensurePantryInstalled(){if(await isPantryInstalled())return;log.info("Pantry is required. Installing it from https://pantry.dev...");await installPantry()}export async function ensurePantryDependencies(cwd){await ensurePantryInstalled();log.info("Installing project dependencies with Pantry...");const result=await runCommand("pantry install",{cwd,timeoutMs:PANTRY_DEPENDENCIES_TIMEOUT_MS});if(resultFailed(result)){handleError(result.error);process.exit(ExitCode.FatalError)}if(existsSync(join(cwd,"package.json"))&&!existsSync(join(cwd,"node_modules"))){await log.error("Pantry completed without installing the project JavaScript dependencies.");process.exit(ExitCode.FatalError)}log.success("Installed project dependencies with Pantry")}function hasAppKey(cwd){const envPath=join(cwd,".env");if(!existsSync(envPath))return!1;return/^APP_KEY=.+$/m.test(readFileSync(envPath,"utf-8"))}export async function ensureAppKey(cwd){if(hasAppKey(cwd)||process.env.APP_KEY&&process.env.APP_KEY.length>0){log.success("APP_KEY existed");return}const keyResult=await runCommand("./buddy key:generate",{cwd,timeoutMs:KEYGEN_TIMEOUT_MS});if(resultFailed(keyResult)){handleError(keyResult.error);process.exit(ExitCode.FatalError)}log.success("Generated application key")}async function runInitialMigration(cwd){const appEnv=(process.env.APP_ENV||process.env.NODE_ENV||"local").toLowerCase();if(!["local","development","dev","test"].includes(appEnv)){log.info(`Skipping initial migration in the ${appEnv} environment`);return}log.info("Running initial database migration...");try{const result=await runAction(Action.Migrate,{cwd});if(resultFailed(result)){log.warn("Initial migration did not complete - you can run it later via ./buddy migrate");log.debug(result.error);return}log.success("Database is migrated")}catch(error){log.warn("Initial migration did not complete - you can run it later via ./buddy migrate");log.debug(error)}}async function initializeProject(options){const cwd=options.cwd||p.projectPath();await ensurePantryDependencies(cwd);await ensureEnvIsSet(options);if(!options.skipKeygen)await ensureAppKey(cwd);await runInitialMigration(cwd);ensureIdeSettings(cwd);if(!options.skipAws){log.info("Ensuring AWS is connected...");try{const awsResult=await runCommand("./buddy configure:aws",{cwd,timeoutMs:AWS_CONFIG_TIMEOUT_MS});if(resultFailed(awsResult)){log.warn("AWS not configured - you can do this later via ./buddy configure:aws");log.debug(awsResult.error)}else log.success("Configured AWS")}catch(error){log.warn("AWS not configured - you can do this later via ./buddy configure:aws");log.debug(error)}}log.success("Project is setup");log.info("Run `./buddy doctor` anytime to check your setup. Happy coding! \uD83D\uDC99")}export function ensureIdeSettings(cwd){const source=p.frameworkPath("defaults/ide/vscode/.vscode"),destination=join(cwd,".vscode");if(existsSync(destination)){log.debug(".vscode already exists; keeping the project settings");return}if(!existsSync(source)){log.debug("No bundled VS Code settings found; skipping IDE setup");return}cpSync(source,destination,{recursive:!0});log.success("Installed project VS Code settings")}const DB_CONNECTION_PACKAGES={postgres:{name:"postgresql.org",version:"^17.10",service:"postgres"},mysql:{name:"mysql.com",version:"^9.2",service:"mysql"},sqlite:{name:"sqlite.org",version:"^3.47.2"}};function databaseAliases(connection,pkg){return[connection,pkg.name]}export function pantryDatabasePackage(connection){return DB_CONNECTION_PACKAGES[connection]}function detectDbPackage(cwd){const envPath=join(cwd,".env"),envExamplePath=join(cwd,".env.example"),filePath=existsSync(envPath)?envPath:existsSync(envExamplePath)?envExamplePath:void 0;if(!filePath)return;const match=readFileSync(filePath,"utf-8").match(/^DB_CONNECTION=(.+)$/m);if(!match)return;const value=match[1].trim().replace(/['"]/g,"");return pantryDatabasePackage(value)}export async function optimizePantryDeps(){const cwd=p.projectPath(),depsConfigPath=join(cwd,"config","deps.ts");if(!existsSync(depsConfigPath)){log.debug("No config/deps.ts found, skipping dependency optimization");return}let configDeps={},configServices=[],configDefined={};try{const mod=await import(depsConfigPath),config=mod.config||mod.default;if(config?.dependencies)configDeps={...config.dependencies};if(Array.isArray(config?.services?.autoStart))configServices=config.services.autoStart.filter((name)=>typeof name==="string");if(config?.services?.define&&typeof config.services.define==="object")configDefined=config.services.define}catch(err){log.debug("Could not load config/deps.ts, skipping dependency optimization");return}const dbPackage=detectDbPackage(cwd),autoStart=[...configServices];if(dbPackage){const selected=new Set(Object.entries(DB_CONNECTION_PACKAGES).filter(([,pkg])=>pkg.name===dbPackage.name).flatMap(([connection,pkg])=>databaseAliases(connection,pkg))),unused=new Set(Object.entries(DB_CONNECTION_PACKAGES).flatMap(([connection,pkg])=>databaseAliases(connection,pkg)).filter((alias)=>!selected.has(alias)));for(const pkg of Object.keys(configDeps)){const domain=pkg.split("/")[0];if(unused.has(domain)){log.info(`DB_CONNECTION selects ${dbPackage.name}, dropping unused ${pkg}`);delete configDeps[pkg]}}if(!Object.keys(configDeps).some((key)=>{const domain=key.split("/")[0];return selected.has(domain)})){log.info(`Detected DB_CONNECTION requires ${dbPackage.name}, adding to dependencies`);configDeps[dbPackage.name]=dbPackage.version}if(dbPackage.service&&!autoStart.includes(dbPackage.service))autoStart.push(dbPackage.service)}const lines=["# Auto-generated from config/deps.ts and .env sniffing.","# This file is regenerated on each `buddy setup` run.","#","# To learn more, please visit:","# https://stacksjs.com/docs/dependency-management","","dependencies:"];for(const[pkg,version]of Object.entries(configDeps))lines.push(` ${pkg}: ${version}`);const defined=Object.entries(configDefined);if(autoStart.length>0||defined.length>0){lines.push("","services:"," enabled: true");if(autoStart.length>0){lines.push(" autoStart:");for(const service of autoStart)lines.push(` - ${service}`)}if(defined.length>0){lines.push(" define:");for(const[name,definition]of defined){if(!definition||typeof definition!=="object")continue;lines.push(` ${name}:`);for(const[key,value]of Object.entries(definition)){if(value===void 0||value===null)continue;lines.push(` ${key}: ${String(value)}`)}}}}const depsYamlPath=join(cwd,"deps.yaml");writeFileSync(depsYamlPath,`${lines.join(`
2
2
  `)}
3
3
  `);log.success("Generated deps.yaml from config/deps.ts")}export async function ensureEnvIsSet(options){log.info("Ensuring .env exists...");const cwd=options.cwd||p.projectPath(),envPath=`${cwd}/.env`,envExamplePath=`${cwd}/.env.example`;if(storage.doesNotExist(envPath)){try{copyFile(envExamplePath,envPath)}catch(error){handleError(error);process.exit(ExitCode.FatalError)}log.success(".env created")}else log.success(".env existed")}function envValues(contents){return contents.split(`
4
4
  `).map((line)=>line.trim()).filter((line)=>line.length>0&&!line.startsWith("#")&&!line.startsWith("DOTENV_PUBLIC_KEY")).map((line)=>line.slice(line.indexOf("=")+1).trim()).map((value)=>value.replace(/^['"]/,""))}function isCiphertext(value){return value.startsWith("encrypted:")||value.startsWith("enc:")}export function isEnvFileEncrypted(contents){return envValues(contents).every(isCiphertext)}function deployEnvTemplate(environment){return[`# Secrets for the ${environment} environment.`,"#","# Values here are encrypted with the public key below and decrypted at","# deploy time with the matching private key in .env.keys, which is NOT","# committed. This file is - that is the point: the ciphertext is reviewable","# and diffable, and losing a laptop does not leak production.","#","# Add one with:",`# buddy env:set STRIPE_SECRET_KEY sk_live_\u2026 --env ${environment}`,"#","# Left empty on purpose. Nothing was copied out of your .env: that file","# describes a laptop, and a server is not one.",""].join(`
5
- `)}export async function ensureDeployEnvIsSet(cwd,environment){if(["development","dev","local","test"].includes(environment))return;const fileName=`.env.${environment}`,filePath=join(cwd,fileName),created=!existsSync(filePath);if(created)writeFileSync(filePath,deployEnvTemplate(environment));const contents=readFileSync(filePath,"utf-8");if(!created&&isEnvFileEncrypted(contents)){log.success(`${fileName} existed`);return}const{encryptEnv}=await import("@stacksjs/env"),result=encryptEnv({file:fileName,cwd});if(!result.success){log.error(`Could not encrypt ${fileName}: ${result.error??"unknown error"}`);process.exit(ExitCode.FatalError)}if(created){log.success(`${fileName} created (empty, encrypted - add secrets with \`buddy env:set KEY value --env ${environment}\`)`);return}const encrypted=envValues(contents).filter((value)=>!isCiphertext(value)).length;log.success(`${fileName} encrypted (${encrypted} value${encrypted===1?"":"s"})`)}
5
+ `)}export async function ensureDeployEnvIsSet(cwd,environment){if(["development","dev","local","test"].includes(environment))return;const fileName=`.env.${environment}`,filePath=join(cwd,fileName),created=!existsSync(filePath);if(created)writeFileSync(filePath,deployEnvTemplate(environment));const contents=readFileSync(filePath,"utf-8");if(!created&&isEnvFileEncrypted(contents)){log.success(`${fileName} existed`);return}const{encryptEnv}=await import("@stacksjs/env"),result=encryptEnv({file:fileName,cwd});if(!result.success){await log.error(`Could not encrypt ${fileName}: ${result.error??"unknown error"}`);process.exit(ExitCode.FatalError)}if(created){log.success(`${fileName} created (empty, encrypted - add secrets with \`buddy env:set KEY value --env ${environment}\`)`);return}const encrypted=envValues(contents).filter((value)=>!isCiphertext(value)).length;log.success(`${fileName} encrypted (${encrypted} value${encrypted===1?"":"s"})`)}
@@ -1 +1 @@
1
- import{getErrorMessage}from"@stacksjs/utils";import process from"node:process";import{runApiDevServer,runDashboardDevServer,runDesktopDevServer,runDocsDevServer,runFrontendDevServer}from"@stacksjs/actions";import{bold,cyan,dim,green,intro,log,outro,spinner}from"@stacksjs/cli";import{ports as configPorts}from"@stacksjs/config";import{ExitCode}from"@stacksjs/types";const originalStdoutWrite=process.stdout.write.bind(process.stdout),originalStderrWrite=process.stderr.write.bind(process.stderr),originalConsoleLog=console.log,originalConsoleError=console.error,originalConsoleWarn=console.warn,originalConsoleInfo=console.info;let _muted=!1,_verboseBuffer=[];function muteOutput(){_muted=!0;_verboseBuffer=[];const filter=(fn)=>{return function(chunk,...args){if(_muted){_verboseBuffer.push(String(chunk));return!0}return fn(chunk,...args)}};process.stdout.write=filter(originalStdoutWrite);process.stderr.write=filter(originalStderrWrite);console.log=(...args)=>{if(!_muted)originalConsoleLog(...args)};console.error=(...args)=>{if(!_muted)originalConsoleError(...args)};console.warn=(...args)=>{if(!_muted)originalConsoleWarn(...args)};console.info=(...args)=>{if(!_muted)originalConsoleInfo(...args)}}function unmuteOutput(){_muted=!1;process.stdout.write=originalStdoutWrite;process.stderr.write=originalStderrWrite;console.log=originalConsoleLog;console.error=originalConsoleError;console.warn=originalConsoleWarn;console.info=originalConsoleInfo}async function waitForPort(port,host="localhost",timeoutMs=30000){const start=Date.now();while(Date.now()-start<timeoutMs)try{await(await fetch(`http://${host}:${port}`,{signal:AbortSignal.timeout(1000)})).arrayBuffer();return}catch{await Bun.sleep(300)}throw Error(`Timed out waiting for server on port ${port}`)}const devServerRunners={frontend:runFrontendDevServer,api:runApiDevServer,backend:runApiDevServer,admin:runDashboardDevServer,dashboard:runDashboardDevServer,desktop:runDesktopDevServer,docs:runDocsDevServer},companionServices={frontend:[{port:configPorts?.api||3008,suffix:"api",label:"API",runner:runApiDevServer},{port:configPorts?.docs||3006,suffix:"docs",label:"Docs",runner:runDocsDevServer}]};function capitalize(s){return s.charAt(0).toUpperCase()+s.slice(1)}export function share(buddy){buddy.command("share [type]","Share your local development server via a public tunnel").option("-p, --port <port>","Local port to share").option("--server <url>","Tunnel server URL",{default:"api.localtunnel.dev"}).option("--subdomain <name>","Request a specific subdomain").option("--verbose","Enable verbose output",{default:!1}).action(async(type,options)=>{const perf=await intro("buddy share"),serviceType=type||"frontend",defaultPorts={frontend:configPorts?.frontend||3000,api:configPorts?.api||3008,backend:configPorts?.backend||3001,admin:configPorts?.admin||3002,dashboard:configPorts?.admin||3002,library:configPorts?.library||3003,desktop:configPorts?.desktop||3004,email:configPorts?.email||3005,docs:configPorts?.docs||3006,inspect:configPorts?.inspect||3007},port=options.port?Number.parseInt(options.port,10):defaultPorts[serviceType]||3000;if(Number.isNaN(port)||port<1||port>65535){log.error(`Invalid port: ${options.port}`);process.exit(ExitCode.InvalidArgument)}const server=options.server||"api.localtunnel.dev",tunnels=[],companions=companionServices[serviceType]||[],s=spinner();try{const{localTunnel}=await import("@stacksjs/tunnel");console.log();const runner=devServerRunners[serviceType];if(runner){s.start(`Starting ${serviceType} dev server...`);muteOutput();runner({verbose:options.verbose??!1}).catch(()=>{});await waitForPort(port);unmuteOutput();s.succeed(`${bold(capitalize(serviceType))} ready ${dim(`on :${port}`)}`)}const startedCompanions=[];if(companions.length>0){s.start("Starting companion services...");muteOutput();for(const companion of companions)companion.runner({verbose:options.verbose??!1}).catch(()=>{});const results=await Promise.allSettled(companions.map((c)=>waitForPort(c.port,"localhost",60000)));unmuteOutput();for(let i=0;i<companions.length;i++){const result=results[i],companion=companions[i];if(!result||!companion)continue;if(result.status==="fulfilled"){startedCompanions.push(companion);s.succeed(`${bold(companion.label)} ready ${dim(`on :${companion.port}`)}`)}else s.fail(`${companion.label} failed to start ${dim(`on :${companion.port}`)}`)}}console.log();s.start("Creating tunnel...");const primaryTunnel=await localTunnel({port,server,subdomain:options.subdomain,verbose:options.verbose,onRequest:(req)=>{if(options.verbose)console.log(` ${dim(`${req.method} ${req.url}`)}`)},onReconnecting:(info)=>{s.start(`Reconnecting... (attempt ${info.attempt})`)}});tunnels.push(primaryTunnel);const baseSubdomain=primaryTunnel.subdomain;for(const companion of startedCompanions)try{const companionTunnel=await localTunnel({port:companion.port,server,subdomain:`${baseSubdomain}-${companion.suffix}`,verbose:options.verbose,onReconnecting:(info)=>{s.start(`${companion.label}: reconnecting... (attempt ${info.attempt})`)}});tunnels.push(companionTunnel)}catch{}s.succeed(`Connected ${dim(`to ${server}`)}`);const entries=[{label:capitalize(serviceType),url:primaryTunnel.url,local:`localhost:${port}`}];for(const companion of startedCompanions){const tunnel=tunnels.find((t)=>t.subdomain===`${baseSubdomain}-${companion.suffix}`);if(tunnel)entries.push({label:companion.label,url:tunnel.url,local:`localhost:${companion.port}`})}const maxLabel=Math.max(...entries.map((e)=>e.label.length));console.log();for(const entry of entries){const padding=" ".repeat(maxLabel-entry.label.length+1);console.log(` ${green("\u279C")} ${bold(entry.label)}${padding} ${cyan(entry.url)}`)}console.log();for(const entry of entries){const padding=" ".repeat(maxLabel-entry.label.length+1);console.log(` ${dim(entry.label)}${dim(padding)}${dim(entry.url)} ${dim("\u2192")} ${dim(entry.local)}`)}console.log();console.log(` ${dim("press Ctrl+C to stop")}`);console.log();const cleanup=async()=>{console.log();s.start("Closing tunnels...");await Promise.all(tunnels.map((t)=>t.close()));s.succeed("Tunnels closed");outro("Stopped sharing",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)};process.on("SIGINT",()=>cleanup());process.on("SIGTERM",()=>cleanup());await new Promise(()=>{})}catch(error){unmuteOutput();s.fail(getErrorMessage(error));const caught=error instanceof Error?error:Error(String(error));if(caught.message.includes("timeout")||caught.message.includes("ECONNREFUSED")){log.error(`Could not reach tunnel server at ${server}`);log.info(`Verify with: curl -sk https://${server}/status`)}else log.error(`Failed to create tunnel: ${caught.message}`);if(options.verbose)log.error(caught.stack);for(const t of tunnels)t.close();await outro("Share failed",{startTime:perf,useSeconds:!0});process.exit(ExitCode.FatalError)}})}
1
+ import{getErrorMessage}from"@stacksjs/utils";import process from"node:process";import{runApiDevServer,runDashboardDevServer,runDesktopDevServer,runDocsDevServer,runFrontendDevServer}from"@stacksjs/actions";import{bold,cyan,dim,green,intro,log,outro,spinner}from"@stacksjs/cli";import{ports as configPorts}from"@stacksjs/config";import{ExitCode}from"@stacksjs/types";const originalStdoutWrite=process.stdout.write.bind(process.stdout),originalStderrWrite=process.stderr.write.bind(process.stderr),originalConsoleLog=console.log,originalConsoleError=console.error,originalConsoleWarn=console.warn,originalConsoleInfo=console.info;let _muted=!1,_verboseBuffer=[];function muteOutput(){_muted=!0;_verboseBuffer=[];const filter=(fn)=>{return function(chunk,...args){if(_muted){_verboseBuffer.push(String(chunk));return!0}return fn(chunk,...args)}};process.stdout.write=filter(originalStdoutWrite);process.stderr.write=filter(originalStderrWrite);console.log=(...args)=>{if(!_muted)originalConsoleLog(...args)};console.error=(...args)=>{if(!_muted)originalConsoleError(...args)};console.warn=(...args)=>{if(!_muted)originalConsoleWarn(...args)};console.info=(...args)=>{if(!_muted)originalConsoleInfo(...args)}}function unmuteOutput(){_muted=!1;process.stdout.write=originalStdoutWrite;process.stderr.write=originalStderrWrite;console.log=originalConsoleLog;console.error=originalConsoleError;console.warn=originalConsoleWarn;console.info=originalConsoleInfo}async function waitForPort(port,host="localhost",timeoutMs=30000){const start=Date.now();while(Date.now()-start<timeoutMs)try{await(await fetch(`http://${host}:${port}`,{signal:AbortSignal.timeout(1000)})).arrayBuffer();return}catch{await Bun.sleep(300)}throw Error(`Timed out waiting for server on port ${port}`)}const devServerRunners={frontend:runFrontendDevServer,api:runApiDevServer,backend:runApiDevServer,admin:runDashboardDevServer,dashboard:runDashboardDevServer,desktop:runDesktopDevServer,docs:runDocsDevServer},companionServices={frontend:[{port:configPorts?.api||3008,suffix:"api",label:"API",runner:runApiDevServer},{port:configPorts?.docs||3006,suffix:"docs",label:"Docs",runner:runDocsDevServer}]};function capitalize(s){return s.charAt(0).toUpperCase()+s.slice(1)}export function share(buddy){buddy.command("share [type]","Share your local development server via a public tunnel").option("-p, --port <port>","Local port to share").option("--server <url>","Tunnel server URL",{default:"api.localtunnel.dev"}).option("--subdomain <name>","Request a specific subdomain").option("--verbose","Enable verbose output",{default:!1}).action(async(type,options)=>{const perf=await intro("buddy share"),serviceType=type||"frontend",defaultPorts={frontend:configPorts?.frontend||3000,api:configPorts?.api||3008,backend:configPorts?.backend||3001,admin:configPorts?.admin||3002,dashboard:configPorts?.admin||3002,library:configPorts?.library||3003,desktop:configPorts?.desktop||3004,email:configPorts?.email||3005,docs:configPorts?.docs||3006,inspect:configPorts?.inspect||3007},port=options.port?Number.parseInt(options.port,10):defaultPorts[serviceType]||3000;if(Number.isNaN(port)||port<1||port>65535){await log.error(`Invalid port: ${options.port}`);process.exit(ExitCode.InvalidArgument)}const server=options.server||"api.localtunnel.dev",tunnels=[],companions=companionServices[serviceType]||[],s=spinner();try{const{localTunnel}=await import("@stacksjs/tunnel");console.log();const runner=devServerRunners[serviceType];if(runner){s.start(`Starting ${serviceType} dev server...`);muteOutput();runner({verbose:options.verbose??!1}).catch(()=>{});await waitForPort(port);unmuteOutput();s.succeed(`${bold(capitalize(serviceType))} ready ${dim(`on :${port}`)}`)}const startedCompanions=[];if(companions.length>0){s.start("Starting companion services...");muteOutput();for(const companion of companions)companion.runner({verbose:options.verbose??!1}).catch(()=>{});const results=await Promise.allSettled(companions.map((c)=>waitForPort(c.port,"localhost",60000)));unmuteOutput();for(let i=0;i<companions.length;i++){const result=results[i],companion=companions[i];if(!result||!companion)continue;if(result.status==="fulfilled"){startedCompanions.push(companion);s.succeed(`${bold(companion.label)} ready ${dim(`on :${companion.port}`)}`)}else s.fail(`${companion.label} failed to start ${dim(`on :${companion.port}`)}`)}}console.log();s.start("Creating tunnel...");const primaryTunnel=await localTunnel({port,server,subdomain:options.subdomain,verbose:options.verbose,onRequest:(req)=>{if(options.verbose)console.log(` ${dim(`${req.method} ${req.url}`)}`)},onReconnecting:(info)=>{s.start(`Reconnecting... (attempt ${info.attempt})`)}});tunnels.push(primaryTunnel);const baseSubdomain=primaryTunnel.subdomain;for(const companion of startedCompanions)try{const companionTunnel=await localTunnel({port:companion.port,server,subdomain:`${baseSubdomain}-${companion.suffix}`,verbose:options.verbose,onReconnecting:(info)=>{s.start(`${companion.label}: reconnecting... (attempt ${info.attempt})`)}});tunnels.push(companionTunnel)}catch{}s.succeed(`Connected ${dim(`to ${server}`)}`);const entries=[{label:capitalize(serviceType),url:primaryTunnel.url,local:`localhost:${port}`}];for(const companion of startedCompanions){const tunnel=tunnels.find((t)=>t.subdomain===`${baseSubdomain}-${companion.suffix}`);if(tunnel)entries.push({label:companion.label,url:tunnel.url,local:`localhost:${companion.port}`})}const maxLabel=Math.max(...entries.map((e)=>e.label.length));console.log();for(const entry of entries){const padding=" ".repeat(maxLabel-entry.label.length+1);console.log(` ${green("\u279C")} ${bold(entry.label)}${padding} ${cyan(entry.url)}`)}console.log();for(const entry of entries){const padding=" ".repeat(maxLabel-entry.label.length+1);console.log(` ${dim(entry.label)}${dim(padding)}${dim(entry.url)} ${dim("\u2192")} ${dim(entry.local)}`)}console.log();console.log(` ${dim("press Ctrl+C to stop")}`);console.log();const cleanup=async()=>{console.log();s.start("Closing tunnels...");await Promise.all(tunnels.map((t)=>t.close()));s.succeed("Tunnels closed");outro("Stopped sharing",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)};process.on("SIGINT",()=>cleanup());process.on("SIGTERM",()=>cleanup());await new Promise(()=>{})}catch(error){unmuteOutput();s.fail(getErrorMessage(error));const caught=error instanceof Error?error:Error(String(error));if(caught.message.includes("timeout")||caught.message.includes("ECONNREFUSED")){log.error(`Could not reach tunnel server at ${server}`);log.info(`Verify with: curl -sk https://${server}/status`)}else log.error(`Failed to create tunnel: ${caught.message}`);if(options.verbose)log.error(caught.stack);for(const t of tunnels)t.close();await outro("Share failed",{startTime:perf,useSeconds:!0});process.exit(ExitCode.FatalError)}})}
@@ -1 +1 @@
1
- import process from"node:process";import{installStack,listStacks,uninstallStack}from"@stacksjs/actions";import{intro,italic,onUnknownSubcommand,outro}from"@stacksjs/cli";import{log}from"@stacksjs/logging";import{ExitCode}from"@stacksjs/types";export function stacks(buddy){const descriptions={install:"Install a stack into your project",uninstall:"Uninstall a stack from your project",list:"List available and installed stacks",force:"Force overwrite existing files",dryRun:"Show what would be installed without making changes",conflict:"Conflict resolution strategy: skip, overwrite, or backup",verbose:"Enable verbose output",project:"Target a specific Stacks project"};buddy.command("stack:install <name>",descriptions.install).option("--force",descriptions.force,{default:!1}).option("--dry-run",descriptions.dryRun,{default:!1}).option("--conflict <strategy>",descriptions.conflict,{default:"skip"}).option("-p, --project <path>",descriptions.project).option("--verbose",descriptions.verbose,{default:!1}).example("buddy add calendar").example("buddy add table --force").example("buddy add calendar --conflict backup --verbose").example("buddy add table --dry-run").action(async(name,options)=>{const perf=await intro("buddy stack:install");if(!name){log.error("You need to specify a stack name.");log.info("Example: buddy add calendar");process.exit(ExitCode.FatalError)}if(!await installStack({name,force:options.force,dryRun:options.dryRun,conflict:options.conflict||"skip",project:options.project,verbose:options.verbose})&&!options.dryRun){await outro("Failed to install stack",{startTime:perf,useSeconds:!0});process.exit(ExitCode.FatalError)}await outro(`Stack ${italic(name)} installed.`,{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("stack:uninstall <name>",descriptions.uninstall).option("--force",descriptions.force,{default:!1}).option("-p, --project <path>",descriptions.project).option("--verbose",descriptions.verbose,{default:!1}).example("buddy stack:uninstall blog").example("buddy stack:uninstall blog --force").action(async(name,options)=>{const perf=await intro("buddy stack:uninstall");if(!name){log.error("You need to specify a stack name.");process.exit(ExitCode.FatalError)}if(!await uninstallStack({name,force:options.force,project:options.project,verbose:options.verbose})){await outro("Failed to uninstall stack",{startTime:perf,useSeconds:!0});process.exit(ExitCode.FatalError)}await outro(`Stack ${italic(name)} uninstalled.`,{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("stack:list",descriptions.list).alias("stack:ls").option("-p, --project <path>",descriptions.project).example("buddy stack:list").action(async(options)=>{const perf=await intro("buddy stack:list"),entries=await listStacks(options.project);if(entries.length===0)log.info("No stacks found. Install one with: buddy add <name>");else{log.info(`Found ${entries.length} stack(s):`);log.info("");for(const entry of entries){const status=entry.installed?"[installed]":"[available]",desc=entry.description?` - ${entry.description}`:"",files=entry.fileCount?` (${entry.fileCount} files)`:"";log.info(` ${entry.name} ${italic(`v${entry.version}`)} ${status}${files}${desc}`)}}await outro("",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});onUnknownSubcommand(buddy,"stack")}
1
+ import process from"node:process";import{installStack,listStacks,uninstallStack}from"@stacksjs/actions";import{intro,italic,onUnknownSubcommand,outro}from"@stacksjs/cli";import{log}from"@stacksjs/logging";import{ExitCode}from"@stacksjs/types";export function stacks(buddy){const descriptions={install:"Install a stack into your project",uninstall:"Uninstall a stack from your project",list:"List available and installed stacks",force:"Force overwrite existing files",dryRun:"Show what would be installed without making changes",conflict:"Conflict resolution strategy: skip, overwrite, or backup",verbose:"Enable verbose output",project:"Target a specific Stacks project"};buddy.command("stack:install <name>",descriptions.install).option("--force",descriptions.force,{default:!1}).option("--dry-run",descriptions.dryRun,{default:!1}).option("--conflict <strategy>",descriptions.conflict,{default:"skip"}).option("-p, --project <path>",descriptions.project).option("--verbose",descriptions.verbose,{default:!1}).example("buddy add calendar").example("buddy add table --force").example("buddy add calendar --conflict backup --verbose").example("buddy add table --dry-run").action(async(name,options)=>{const perf=await intro("buddy stack:install");if(!name){await log.error("You need to specify a stack name.");log.info("Example: buddy add calendar");process.exit(ExitCode.FatalError)}if(!await installStack({name,force:options.force,dryRun:options.dryRun,conflict:options.conflict||"skip",project:options.project,verbose:options.verbose})&&!options.dryRun){await outro("Failed to install stack",{startTime:perf,useSeconds:!0});process.exit(ExitCode.FatalError)}await outro(`Stack ${italic(name)} installed.`,{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("stack:uninstall <name>",descriptions.uninstall).option("--force",descriptions.force,{default:!1}).option("-p, --project <path>",descriptions.project).option("--verbose",descriptions.verbose,{default:!1}).example("buddy stack:uninstall blog").example("buddy stack:uninstall blog --force").action(async(name,options)=>{const perf=await intro("buddy stack:uninstall");if(!name){await log.error("You need to specify a stack name.");process.exit(ExitCode.FatalError)}if(!await uninstallStack({name,force:options.force,project:options.project,verbose:options.verbose})){await outro("Failed to uninstall stack",{startTime:perf,useSeconds:!0});process.exit(ExitCode.FatalError)}await outro(`Stack ${italic(name)} uninstalled.`,{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});buddy.command("stack:list",descriptions.list).alias("stack:ls").option("-p, --project <path>",descriptions.project).example("buddy stack:list").action(async(options)=>{const perf=await intro("buddy stack:list"),entries=await listStacks(options.project);if(entries.length===0)log.info("No stacks found. Install one with: buddy add <name>");else{log.info(`Found ${entries.length} stack(s):`);log.info("");for(const entry of entries){const status=entry.installed?"[installed]":"[available]",desc=entry.description?` - ${entry.description}`:"",files=entry.fileCount?` (${entry.fileCount} files)`:"";log.info(` ${entry.name} ${italic(`v${entry.version}`)} ${status}${files}${desc}`)}}await outro("",{startTime:perf,useSeconds:!0});process.exit(ExitCode.Success)});onUnknownSubcommand(buddy,"stack")}
@@ -1 +1 @@
1
- import process from"node:process";import{bold,dim,green,intro,log,onUnknownSubcommand}from"@stacksjs/cli";const telemetry={async enable(){},async disable(){},async status(){return{enabled:!1,doNotTrack:!!process.env.DO_NOT_TRACK,eventsQueued:0,lastSent:null}}};export function telemetryCommand(buddy){buddy.command("telemetry","Manage telemetry settings").option("--enable","Enable telemetry").option("--disable","Disable telemetry").option("--status","Show telemetry status").example("buddy telemetry --status").example("buddy telemetry --enable").example("buddy telemetry --disable").action(async(options)=>{log.debug("Running `buddy telemetry` ...",options);await intro("buddy telemetry");try{if(options.enable){await telemetry.enable();log.success("Telemetry enabled");log.info("");log.info(dim("Anonymous usage statistics will be collected"));log.info(dim("to help improve Buddy CLI."));log.info("");log.info(dim("You can disable telemetry anytime with:"));log.info(dim(" buddy telemetry --disable"));log.info("");return}if(options.disable){await telemetry.disable();log.success("Telemetry disabled");log.info("");log.info(dim("No usage statistics will be collected."));log.info("");return}const status=await telemetry.status();log.info("");log.info(green(bold("Telemetry Status")));log.info(dim("\u2500".repeat(50)));log.info("");log.info(bold("Configuration:"));log.info(` Enabled: ${dim(status.enabled?"Yes":"No")}`);log.info(` DO_NOT_TRACK: ${dim(status.doNotTrack?"Yes (respected)":"No")}`);log.info(` Events queued: ${dim(status.eventsQueued)}`);if(status.lastSent){const lastSent=new Date(status.lastSent);log.info(` Last sent: ${dim(lastSent.toLocaleString())}`)}else log.info(` Last sent: ${dim("Never")}`);log.info("");log.info(bold("Privacy:"));log.info(` ${dim("\u2022 Opt-in only (disabled by default)")}`);log.info(` ${dim("\u2022 No personal information collected")}`);log.info(` ${dim("\u2022 Anonymous user IDs only")}`);log.info(` ${dim("\u2022 Respects DO_NOT_TRACK environment variable")}`);log.info("");if(!status.enabled){log.info(dim("To enable telemetry: buddy telemetry --enable"));log.info("")}else{log.info(dim("To disable telemetry: buddy telemetry --disable"));log.info("")}}catch(error){log.error("Failed to manage telemetry:",error);process.exit(1)}});onUnknownSubcommand(buddy,"telemetry")}
1
+ import process from"node:process";import{bold,dim,green,intro,log,onUnknownSubcommand}from"@stacksjs/cli";const telemetry={async enable(){},async disable(){},async status(){return{enabled:!1,doNotTrack:!!process.env.DO_NOT_TRACK,eventsQueued:0,lastSent:null}}};export function telemetryCommand(buddy){buddy.command("telemetry","Manage telemetry settings").option("--enable","Enable telemetry").option("--disable","Disable telemetry").option("--status","Show telemetry status").example("buddy telemetry --status").example("buddy telemetry --enable").example("buddy telemetry --disable").action(async(options)=>{log.debug("Running `buddy telemetry` ...",options);await intro("buddy telemetry");try{if(options.enable){await telemetry.enable();log.success("Telemetry enabled");log.info("");log.info(dim("Anonymous usage statistics will be collected"));log.info(dim("to help improve Buddy CLI."));log.info("");log.info(dim("You can disable telemetry anytime with:"));log.info(dim(" buddy telemetry --disable"));log.info("");return}if(options.disable){await telemetry.disable();log.success("Telemetry disabled");log.info("");log.info(dim("No usage statistics will be collected."));log.info("");return}const status=await telemetry.status();log.info("");log.info(green(bold("Telemetry Status")));log.info(dim("\u2500".repeat(50)));log.info("");log.info(bold("Configuration:"));log.info(` Enabled: ${dim(status.enabled?"Yes":"No")}`);log.info(` DO_NOT_TRACK: ${dim(status.doNotTrack?"Yes (respected)":"No")}`);log.info(` Events queued: ${dim(status.eventsQueued)}`);if(status.lastSent){const lastSent=new Date(status.lastSent);log.info(` Last sent: ${dim(lastSent.toLocaleString())}`)}else log.info(` Last sent: ${dim("Never")}`);log.info("");log.info(bold("Privacy:"));log.info(` ${dim("\u2022 Opt-in only (disabled by default)")}`);log.info(` ${dim("\u2022 No personal information collected")}`);log.info(` ${dim("\u2022 Anonymous user IDs only")}`);log.info(` ${dim("\u2022 Respects DO_NOT_TRACK environment variable")}`);log.info("");if(!status.enabled){log.info(dim("To enable telemetry: buddy telemetry --enable"));log.info("")}else{log.info(dim("To disable telemetry: buddy telemetry --disable"));log.info("")}}catch(error){await log.error("Failed to manage telemetry:",error);process.exit(1)}});onUnknownSubcommand(buddy,"telemetry")}
@@ -1,4 +1,4 @@
1
- import{randomBytes}from"node:crypto";import process from"node:process";import{log}from"@stacksjs/cli";import{ExitCode}from"@stacksjs/types";function generatePassword(){return randomBytes(18).toString("base64url")}async function loadUserModel(){const{existsSync}=await import("node:fs"),{join}=await import("node:path"),candidates=[join(process.cwd(),"app/Models/User.ts"),join(process.cwd(),"storage/framework/defaults/app/Models/User.ts"),join(process.cwd(),"node_modules/@stacksjs/defaults/app/Models/User.ts")];for(const candidate of candidates){if(!existsSync(candidate))continue;const loaded=(await import(candidate)).default;if(loaded?.where)return loaded}const{User}=await import("@stacksjs/orm");if(User)return User;log.error("Could not find a User model. Looked in app/Models, the framework defaults and @stacksjs/orm.");await log.flush();process.exit(ExitCode.FatalError)}export function user(buddy){buddy.command("user:add <email>","Create a user account (optionally an admin)").option("--name <name>","Display name. Defaults to the local part of the email.").option("--password <password>","Password. Generated and printed once if omitted.").option("--role <role>","Role to assign, e.g. admin. Seeds the default roles if the table is empty.").option("--update","If the account already exists, reset its password and ensure the role",{default:!1}).example("buddy user:add chris@example.com --role admin").example("buddy user:add support@example.com --name Support --password s3cret").action(async(email,options)=>{const address=String(email||"").trim().toLowerCase();if(!address.includes("@")){log.error(`\`${email}\` is not an email address.`);await log.flush();process.exit(ExitCode.FatalError)}const password=options.password||generatePassword(),generated=!options.password,name=options.name||address.split("@")[0],User=await loadUserModel();let account=await User.where("email",address).first();if(account&&!options.update){log.error(`${address} already exists. Pass --update to reset its password and role.`);await log.flush();process.exit(ExitCode.FatalError)}if(account){await User.where("email",address).update({password});log.success(`Updated ${address}`)}else{await User.create({email:address,name,password});account=await User.where("email",address).first();log.success(`Created ${address}`)}if(options.role){const{createBqbRbacStore,Rbac,seedDefaultRoles}=await import("@stacksjs/auth");try{Rbac.setStore(createBqbRbacStore());await seedDefaultRoles();await Rbac.assignRole(account,options.role);log.success(`Assigned the ${options.role} role`)}catch(error){log.error(`Could not assign the ${options.role} role: ${error instanceof Error?error.message:String(error)}`);log.info("The account exists; assign the role once the roles tables are migrated.")}}if(generated){process.stdout.write(`
1
+ import{randomBytes}from"node:crypto";import process from"node:process";import{log}from"@stacksjs/cli";import{ExitCode}from"@stacksjs/types";function generatePassword(){return randomBytes(18).toString("base64url")}async function loadUserModel(){const{existsSync}=await import("node:fs"),{join}=await import("node:path"),candidates=[join(process.cwd(),"app/Models/User.ts"),join(process.cwd(),"storage/framework/defaults/app/Models/User.ts"),join(process.cwd(),"node_modules/@stacksjs/defaults/app/Models/User.ts")];for(const candidate of candidates){if(!existsSync(candidate))continue;const loaded=(await import(candidate)).default;if(loaded?.where)return loaded}const{User}=await import("@stacksjs/orm");if(User)return User;await log.error("Could not find a User model. Looked in app/Models, the framework defaults and @stacksjs/orm.");await log.flush();process.exit(ExitCode.FatalError)}export function user(buddy){buddy.command("user:add <email>","Create a user account (optionally an admin)").option("--name <name>","Display name. Defaults to the local part of the email.").option("--password <password>","Password. Generated and printed once if omitted.").option("--role <role>","Role to assign, e.g. admin. Seeds the default roles if the table is empty.").option("--update","If the account already exists, reset its password and ensure the role",{default:!1}).example("buddy user:add chris@example.com --role admin").example("buddy user:add support@example.com --name Support --password s3cret").action(async(email,options)=>{const address=String(email||"").trim().toLowerCase();if(!address.includes("@")){await log.error(`\`${email}\` is not an email address.`);await log.flush();process.exit(ExitCode.FatalError)}const password=options.password||generatePassword(),generated=!options.password,name=options.name||address.split("@")[0],User=await loadUserModel();let account=await User.where("email",address).first();if(account&&!options.update){await log.error(`${address} already exists. Pass --update to reset its password and role.`);await log.flush();process.exit(ExitCode.FatalError)}if(account){await User.where("email",address).update({password});log.success(`Updated ${address}`)}else{await User.create({email:address,name,password});account=await User.where("email",address).first();log.success(`Created ${address}`)}if(options.role){const{createBqbRbacStore,Rbac,seedDefaultRoles}=await import("@stacksjs/auth");try{Rbac.setStore(createBqbRbacStore());await seedDefaultRoles();await Rbac.assignRole(account,options.role);log.success(`Assigned the ${options.role} role`)}catch(error){log.error(`Could not assign the ${options.role} role: ${error instanceof Error?error.message:String(error)}`);log.info("The account exists; assign the role once the roles tables are migrated.")}}if(generated){process.stdout.write(`
2
2
  ${address}
3
3
  ${password}
4
4
 
package/dist/result.d.ts CHANGED
@@ -20,7 +20,7 @@ export declare function resultError(result: unknown, fallback?: string): string;
20
20
  * Print a failure and stop.
21
21
  *
22
22
  * `console.error` rather than the logger, and that is not a style preference.
23
- * The logger writes asynchronously, so `log.error(message)` immediately
23
+ * The logger writes asynchronously, so `await log.error(message)` immediately
24
24
  * followed by `process.exit()` loses the message entirely: the process is gone
25
25
  * before the write lands. A command that refuses then produces an exit code and
26
26
  * *nothing else*, which is exactly as useless as the bug this module was
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@stacksjs/buddy",
3
3
  "type": "module",
4
4
  "sideEffects": false,
5
- "version": "0.74.6",
5
+ "version": "0.74.8",
6
6
  "description": "Meet Buddy. The Stacks runtime.",
7
7
  "author": "Chris Breuer",
8
8
  "contributors": [
@@ -95,66 +95,66 @@
95
95
  "prepublishOnly": "bun run build"
96
96
  },
97
97
  "dependencies": {
98
- "@stacksjs/actions": "^0.74.6",
99
- "@stacksjs/ai": "^0.74.6",
100
- "@stacksjs/alias": "^0.74.6",
101
- "@stacksjs/analytics": "^0.74.6",
102
- "@stacksjs/api": "^0.74.6",
103
- "@stacksjs/arrays": "^0.74.6",
104
- "@stacksjs/auth": "^0.74.6",
105
- "@stacksjs/browser-extension": "^0.74.6",
106
- "@stacksjs/build": "^0.74.6",
107
- "@stacksjs/cache": "^0.74.6",
108
- "@stacksjs/chat": "^0.74.6",
98
+ "@stacksjs/actions": "^0.74.8",
99
+ "@stacksjs/ai": "^0.74.8",
100
+ "@stacksjs/alias": "^0.74.8",
101
+ "@stacksjs/analytics": "^0.74.8",
102
+ "@stacksjs/api": "^0.74.8",
103
+ "@stacksjs/arrays": "^0.74.8",
104
+ "@stacksjs/auth": "^0.74.8",
105
+ "@stacksjs/browser-extension": "^0.74.8",
106
+ "@stacksjs/build": "^0.74.8",
107
+ "@stacksjs/cache": "^0.74.8",
108
+ "@stacksjs/chat": "^0.74.8",
109
109
  "@stacksjs/clapp": "^0.2.12",
110
- "@stacksjs/cli": "^0.74.6",
111
- "@stacksjs/cloud": "^0.74.6",
112
- "@stacksjs/cms": "^0.74.6",
113
- "@stacksjs/collections": "^0.74.6",
114
- "@stacksjs/config": "^0.74.6",
115
- "@stacksjs/database": "^0.74.6",
116
- "@stacksjs/desktop-build": "^0.74.6",
117
- "@stacksjs/dns": "^0.74.6",
110
+ "@stacksjs/cli": "^0.74.8",
111
+ "@stacksjs/cloud": "^0.74.8",
112
+ "@stacksjs/cms": "^0.74.8",
113
+ "@stacksjs/collections": "^0.74.8",
114
+ "@stacksjs/config": "^0.74.8",
115
+ "@stacksjs/database": "^0.74.8",
116
+ "@stacksjs/desktop-build": "^0.74.8",
117
+ "@stacksjs/dns": "^0.74.8",
118
118
  "@stacksjs/dnsx": "^0.2.3",
119
- "@stacksjs/email": "^0.74.6",
120
- "@stacksjs/enums": "^0.74.6",
121
- "@stacksjs/env": "^0.74.6",
122
- "@stacksjs/error-handling": "^0.74.6",
123
- "@stacksjs/events": "^0.74.6",
124
- "@stacksjs/features": "^0.74.6",
125
- "@stacksjs/git": "^0.74.6",
119
+ "@stacksjs/email": "^0.74.8",
120
+ "@stacksjs/enums": "^0.74.8",
121
+ "@stacksjs/env": "^0.74.8",
122
+ "@stacksjs/error-handling": "^0.74.8",
123
+ "@stacksjs/events": "^0.74.8",
124
+ "@stacksjs/features": "^0.74.8",
125
+ "@stacksjs/git": "^0.74.8",
126
126
  "@stacksjs/gitit": "^0.2.5",
127
- "@stacksjs/health": "^0.74.6",
127
+ "@stacksjs/health": "^0.74.8",
128
128
  "@stacksjs/httx": "^0.1.10",
129
- "@stacksjs/image": "^0.74.6",
130
- "@stacksjs/lint": "^0.74.6",
131
- "@stacksjs/logging": "^0.74.6",
132
- "@stacksjs/notifications": "^0.74.6",
133
- "@stacksjs/objects": "^0.74.6",
134
- "@stacksjs/orm": "^0.74.6",
135
- "@stacksjs/path": "^0.74.6",
136
- "@stacksjs/payments": "^0.74.6",
137
- "@stacksjs/realtime": "^0.74.6",
138
- "@stacksjs/router": "^0.74.6",
129
+ "@stacksjs/image": "^0.74.8",
130
+ "@stacksjs/lint": "^0.74.8",
131
+ "@stacksjs/logging": "^0.74.8",
132
+ "@stacksjs/notifications": "^0.74.8",
133
+ "@stacksjs/objects": "^0.74.8",
134
+ "@stacksjs/orm": "^0.74.8",
135
+ "@stacksjs/path": "^0.74.8",
136
+ "@stacksjs/payments": "^0.74.8",
137
+ "@stacksjs/realtime": "^0.74.8",
138
+ "@stacksjs/router": "^0.74.8",
139
139
  "@stacksjs/rpx": "^0.11.42",
140
- "@stacksjs/scheduler": "^0.74.6",
141
- "@stacksjs/search-engine": "^0.74.6",
142
- "@stacksjs/security": "^0.74.6",
143
- "@stacksjs/server": "^0.74.6",
144
- "@stacksjs/sites": "^0.74.6",
145
- "@stacksjs/skills": "^0.74.6",
146
- "@stacksjs/storage": "^0.74.6",
147
- "@stacksjs/strings": "^0.74.6",
140
+ "@stacksjs/scheduler": "^0.74.8",
141
+ "@stacksjs/search-engine": "^0.74.8",
142
+ "@stacksjs/security": "^0.74.8",
143
+ "@stacksjs/server": "^0.74.8",
144
+ "@stacksjs/sites": "^0.74.8",
145
+ "@stacksjs/skills": "^0.74.8",
146
+ "@stacksjs/storage": "^0.74.8",
147
+ "@stacksjs/strings": "^0.74.8",
148
148
  "@stacksjs/stx": "^0.2.253",
149
- "@stacksjs/testing": "^0.74.6",
150
- "@stacksjs/tinker": "^0.74.6",
149
+ "@stacksjs/testing": "^0.74.8",
150
+ "@stacksjs/tinker": "^0.74.8",
151
151
  "@stacksjs/tlsx": "^0.13.19",
152
152
  "@stacksjs/ts-cloud": "^0.12.10",
153
- "@stacksjs/tunnel": "^0.74.6",
154
- "@stacksjs/types": "^0.74.6",
155
- "@stacksjs/ui": "^0.74.6",
156
- "@stacksjs/utils": "^0.74.6",
157
- "@stacksjs/validation": "^0.74.6",
153
+ "@stacksjs/tunnel": "^0.74.8",
154
+ "@stacksjs/types": "^0.74.8",
155
+ "@stacksjs/ui": "^0.74.8",
156
+ "@stacksjs/utils": "^0.74.8",
157
+ "@stacksjs/validation": "^0.74.8",
158
158
  "ajv": "^8.20.0",
159
159
  "ajv-formats": "^3.0.1",
160
160
  "bun-plugin-stx": "^0.2.246",