@stacksjs/buddy 0.70.113 → 0.70.115

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -2,6 +2,13 @@
2
2
  import process from "node:process";
3
3
  import { cli, log } from "@stacksjs/cli";
4
4
  import { path as p } from "@stacksjs/path";
5
+ try {
6
+ const { isSupportedBunVersion, minimumBunVersion } = await import("@stacksjs/utils"), currentBunVersion = typeof Bun < "u" ? Bun.version : process.versions.bun;
7
+ if (currentBunVersion && !isSupportedBunVersion(currentBunVersion)) {
8
+ console.error(`[buddy] Bun v${minimumBunVersion} or later is required (current: v${currentBunVersion}). Run: bun upgrade`);
9
+ process.exit(1);
10
+ }
11
+ } catch {}
5
12
  const args = process.argv.slice(2), requestedCommand = args[0] || "help", isHelpFlag = args.includes("--help") || args.includes("-h"), isVersionOnly = ["--version", "-v", "version"].includes(requestedCommand), isHelpMode = requestedCommand === "help" || isHelpFlag && args.length <= 2, skipAppKeyCheck = [
6
13
  "build",
7
14
  "lint",
@@ -79,7 +86,7 @@ async function main() {
79
86
  if (args.length === 0 && process.stdin.isTTY && !buddy.isNoInteraction)
80
87
  await showInteractiveMenu(buddy);
81
88
  else
82
- buddy.parse();
89
+ await parseOrExit(buddy);
83
90
  }
84
91
  async function showInteractiveMenu(buddy) {
85
92
  const { bold, green, intro } = await import("@stacksjs/cli"), { select } = await import("@stacksjs/cli");
@@ -107,7 +114,21 @@ async function showInteractiveMenu(buddy) {
107
114
  buddy.outputHelp();
108
115
  else {
109
116
  process.argv = ["bun", "buddy", choice];
110
- buddy.parse();
117
+ await parseOrExit(buddy);
118
+ }
119
+ }
120
+ async function parseOrExit(buddy) {
121
+ try {
122
+ await buddy.parse();
123
+ } catch (error) {
124
+ if (!(error?.name === "ClappError" || error?.isUsageError === !0))
125
+ throw error;
126
+ process.stderr.write(`${error.message}
127
+ `);
128
+ if (error.usage)
129
+ process.stderr.write(`${error.usage}
130
+ `);
131
+ process.exit(typeof error.exitCode === "number" ? error.exitCode : 2);
111
132
  }
112
133
  }
113
134
  await main();
@@ -5,7 +5,7 @@ import { ExitCode } from "@stacksjs/types";
5
5
  import { onUnknownSubcommand } from "@stacksjs/cli";
6
6
  export function add(buddy) {
7
7
  const descriptions = {
8
- add: "Add a stack to your project (coming soon)",
8
+ add: "Add a stack to your project (table and calendar available, more coming soon)",
9
9
  table: "Add the Table Stack to your project",
10
10
  calendar: "Add the Calendar Stack to your project",
11
11
  all: "Add all stacks",
@@ -1,6 +1,7 @@
1
1
  import process from "node:process";
2
- import { intro, log, onUnknownSubcommand, outro } from "@stacksjs/cli";
2
+ import { intro, log, multiselect, onUnknownSubcommand, outro } from "@stacksjs/cli";
3
3
  import { Action } from "@stacksjs/enums";
4
+ import { hasTTY, isCI } from "@stacksjs/env";
4
5
  import { ExitCode } from "@stacksjs/types";
5
6
  let _runAction;
6
7
  async function runAction(...args) {
@@ -60,24 +61,62 @@ export function build(buddy) {
60
61
  default:
61
62
  break;
62
63
  }
63
- if (hasNoOptions(options))
64
- options.stacks = !0;
64
+ if (hasNoOptions(options)) {
65
+ if (!isCI && hasTTY && process.stdin.isTTY) {
66
+ const answers = await multiselect({
67
+ message: descriptions.select,
68
+ choices: [
69
+ { label: "Frontend (views)", value: "views" },
70
+ { label: "Components", value: "components" },
71
+ { label: "Web Components", value: "webComponents" },
72
+ { label: "Functions", value: "functions" },
73
+ { label: "Documentation", value: "docs" },
74
+ { label: "Stacks framework", value: "stacks" },
75
+ { label: "Buddy CLI", value: "buddy" },
76
+ { label: "Server (Docker image)", value: "server" }
77
+ ]
78
+ }), selected = new Set(answers);
79
+ if (selected.has("views"))
80
+ options.views = !0;
81
+ if (selected.has("components"))
82
+ options.components = !0;
83
+ if (selected.has("webComponents"))
84
+ options.webComponents = !0;
85
+ if (selected.has("functions"))
86
+ options.functions = !0;
87
+ if (selected.has("docs"))
88
+ options.docs = !0;
89
+ if (selected.has("stacks"))
90
+ options.stacks = !0;
91
+ if (selected.has("buddy"))
92
+ options.buddy = !0;
93
+ if (selected.has("server"))
94
+ options.server = !0;
95
+ }
96
+ if (hasNoOptions(options)) {
97
+ options.views = !0;
98
+ log.info("No build target specified, defaulting to the frontend (views). See `buddy build --help` for all targets.");
99
+ }
100
+ }
101
+ let succeeded = !0;
65
102
  if (options.docs)
66
- await runAction(Action.BuildDocs);
103
+ succeeded = await runBuildAction(Action.BuildDocs, "documentation") && succeeded;
67
104
  if (options.components)
68
- await runAction(Action.BuildComponentLibs);
105
+ succeeded = await runBuildAction(Action.BuildComponentLibs, "component libraries") && succeeded;
69
106
  if (options.webComponents)
70
- await runAction(Action.BuildWebComponentLib);
107
+ succeeded = await runBuildAction(Action.BuildWebComponentLib, "web component library") && succeeded;
71
108
  if (options.functions)
72
- await runAction(Action.BuildFunctionLib);
109
+ succeeded = await runBuildAction(Action.BuildFunctionLib, "function library") && succeeded;
73
110
  if (options.views)
74
- await runAction(Action.BuildViews);
111
+ succeeded = await runBuildAction(Action.BuildViews, "frontend") && succeeded;
75
112
  if (options.stacks)
76
- await runAction(Action.BuildStacks);
113
+ succeeded = await runBuildAction(Action.BuildStacks, "Stacks framework") && succeeded;
77
114
  if (options.buddy)
78
- await runAction(Action.BuildCli);
115
+ succeeded = await runBuildAction(Action.BuildCli, "Buddy CLI") && succeeded;
79
116
  if (options.server)
80
- await runAction(Action.BuildServer);
117
+ succeeded = await runBuildAction(Action.BuildServer, "server") && succeeded;
118
+ if (!succeeded)
119
+ process.exit(ExitCode.FatalError);
81
120
  process.exit(ExitCode.Success);
82
121
  });
83
122
  buddy.command("build:components", "Automagically build component libraries for production use & npm/CDN distribution").alias("prod:components").option("-c, --components", descriptions.components, { default: !0 }).option("-p, --project [project]", descriptions.project, { default: !1 }).option("--verbose", descriptions.verbose, { default: !1 }).action(async (options) => {
@@ -151,3 +190,11 @@ export function build(buddy) {
151
190
  function hasNoOptions(options) {
152
191
  return !options.components && !options.webComponents && !options.elements && !options.functions && !options.views && !options.docs && !options.stacks && !options.buddy && !options.server;
153
192
  }
193
+ async function runBuildAction(action, target) {
194
+ const result = await runAction(action);
195
+ if (result.isErr) {
196
+ log.error(`Failed to build ${target}.`, result.error);
197
+ return !1;
198
+ }
199
+ return !0;
200
+ }
@@ -1,17 +1,24 @@
1
1
  import process from "node:process";
2
2
  import { runAction } from "@stacksjs/actions";
3
3
  import { intro, log, onUnknownSubcommand, outro } from "@stacksjs/cli";
4
+ import { hasTTY, isCI } from "@stacksjs/env";
4
5
  import { Action } from "@stacksjs/enums";
5
6
  import { ExitCode } from "@stacksjs/types";
6
7
  export function clean(buddy) {
7
8
  const descriptions = {
8
9
  clean: "Removes all node_modules & lock files",
9
10
  project: "Target a specific project",
11
+ force: "Skip the confirmation prompt (required in CI/non-interactive shells)",
10
12
  verbose: "Enable verbose output"
11
13
  };
12
- buddy.command("clean", descriptions.clean).option("-p, --project [project]", descriptions.project, { default: !1 }).option("--verbose", descriptions.verbose, { default: !1 }).action(async (options) => {
14
+ buddy.command("clean", descriptions.clean).option("-p, --project [project]", descriptions.project, { default: !1 }).option("-f, --force", descriptions.force, { default: !1 }).option("--verbose", descriptions.verbose, { default: !1 }).action(async (options) => {
13
15
  log.debug("Running `buddy clean` ...", options);
14
- if (!buddy.isForce && !buddy.isNoInteraction) {
16
+ const skipConfirm = options.force === !0 || Boolean(buddy.isForce) || Boolean(buddy.isNoInteraction);
17
+ if (!skipConfirm && (isCI || !hasTTY || !process.stdin.isTTY)) {
18
+ log.syncError("Refusing to run `buddy clean` from a non-interactive shell without confirmation.");
19
+ log.fatal(" \u27A1\uFE0F Re-run with `--force` to proceed: `buddy clean --force`");
20
+ }
21
+ if (!skipConfirm) {
15
22
  const { confirm } = await import("@stacksjs/cli");
16
23
  if (!await confirm({
17
24
  message: "This will remove all node_modules and lock files. Continue?",
@@ -1,5 +1,5 @@
1
1
  import process from "node:process";
2
- import { intro, italic, log, onUnknownSubcommand, outro, prompts, runCommand, underline } from "@stacksjs/cli";
2
+ import { intro, italic, log, onUnknownSubcommand, outro, prompts, runCommand, text, underline } from "@stacksjs/cli";
3
3
  import {
4
4
  addJumpBox,
5
5
  deleteCdkRemnants,
@@ -14,6 +14,7 @@ import {
14
14
  getCloudFrontDistributionId,
15
15
  getJumpBoxInstanceId
16
16
  } from "@stacksjs/cloud";
17
+ import { hasTTY, isCI } from "@stacksjs/env";
17
18
  import { path as p } from "@stacksjs/path";
18
19
  import { ExitCode } from "@stacksjs/types";
19
20
  async function createTemporaryCdkRole(roleName) {
@@ -260,16 +261,25 @@ export function cloud(buddy) {
260
261
  });
261
262
  buddy.command("cloud:remove", descriptions.remove).alias("cloud:destroy").alias("cloud:rm").alias("undeploy").option("--jump-box", "Remove the jump-box", { default: !1 }).option("--force", "Force deletion of stack in bad state", { default: !1 }).option("--yes", "Skip confirmation prompts", { default: !1 }).option("-p, --project [project]", descriptions.project, { default: !1 }).option("--verbose", descriptions.verbose, { default: !1 }).action(async (options) => {
262
263
  log.debug("Running `buddy cloud:remove` ...", options);
263
- const startTime = await intro("buddy cloud:remove");
264
+ const startTime = await intro("buddy cloud:remove"), environment = process.env.APP_ENV || process.env.NODE_ENV || "production";
265
+ if (!options.yes && (isCI || !hasTTY || !process.stdin.isTTY)) {
266
+ log.syncError(`Refusing to remove the "${environment}" cloud infrastructure from a non-interactive shell without confirmation.`);
267
+ log.syncError(" \u27A1\uFE0F Re-run with `--yes` to confirm (e.g. in CI): `buddy cloud:remove --yes`");
268
+ await outro("cloud:remove cancelled.", { startTime, useSeconds: !0 });
269
+ await log.flush();
270
+ process.exit(ExitCode.FatalError);
271
+ }
264
272
  if (options.jumpBox) {
265
- const { confirm } = await prompts({
266
- name: "confirm",
267
- type: "confirm",
268
- message: "Would you like to remove your jump-box for now?"
269
- });
270
- if (!confirm) {
271
- await outro("Exited", { startTime, useSeconds: !0 });
272
- process.exit(ExitCode.Success);
273
+ if (!options.yes) {
274
+ const { confirm } = await prompts({
275
+ name: "confirm",
276
+ type: "confirm",
277
+ message: "Would you like to remove your jump-box for now?"
278
+ });
279
+ if (!confirm) {
280
+ await outro("Exited", { startTime, useSeconds: !0 });
281
+ process.exit(ExitCode.Success);
282
+ }
273
283
  }
274
284
  const result = await deleteJumpBox();
275
285
  if (isResultError(result)) {
@@ -282,11 +292,17 @@ export function cloud(buddy) {
282
292
  });
283
293
  process.exit(ExitCode.Success);
284
294
  }
295
+ if (!options.yes) {
296
+ log.warning(`This will permanently delete the "${environment}" cloud infrastructure (compute, storage, CDN, and DNS managed by Stacks). This cannot be undone.`);
297
+ if ((await text({ message: `Type the environment name "${environment}" to confirm (blank to cancel):` })).trim() !== environment) {
298
+ await outro("cloud:remove cancelled - confirmation did not match.", { startTime, useSeconds: !0 });
299
+ process.exit(ExitCode.Success);
300
+ }
301
+ }
285
302
  console.log("");
286
303
  console.log("Removing cloud infrastructure...");
287
304
  console.log(` ${italic("This typically takes 2-5 minutes.")}`);
288
305
  console.log("");
289
- const environment = process.env.APP_ENV || process.env.NODE_ENV || "production";
290
306
  if (!process.env.AWS_ACCESS_KEY_ID || !process.env.AWS_SECRET_ACCESS_KEY) {
291
307
  const { existsSync, readFileSync } = await import("node:fs"), { projectPath } = await import("@stacksjs/path"), envFiles = [
292
308
  projectPath(`.env.${environment}`),
@@ -4,7 +4,7 @@ import { log } from "@stacksjs/logging";
4
4
  import { onUnknownSubcommand } from "@stacksjs/cli";
5
5
  export function commit(buddy) {
6
6
  const descriptions = {
7
- commit: "Commit your stashed changes",
7
+ commit: "Commit your staged changes",
8
8
  project: "Target a specific project",
9
9
  verbose: "Enable verbose output"
10
10
  };
@@ -6,7 +6,6 @@ import { Action } from "@stacksjs/enums";
6
6
  import { resolve } from "@stacksjs/path";
7
7
  import { isFolder } from "@stacksjs/storage";
8
8
  import { ExitCode } from "@stacksjs/types";
9
- import { useOnline } from "@stacksjs/utils";
10
9
  import { uninstallAllFeatures } from "./features";
11
10
  import { ensurePantryDependencies, ensurePantryInstalled } from "./setup";
12
11
  export function create(buddy) {
@@ -28,13 +27,13 @@ export function create(buddy) {
28
27
  minimal: "Skip optional feature bundles (cms, commerce, dashboard, marketing, monitoring, realtime, queue) \u2014 bare-bones API/SPA starter that can re-add them later via `./buddy <feature>:install`.",
29
28
  verbose: "Enable verbose output"
30
29
  };
31
- buddy.command("new [name]", descriptions.command).alias("create [name]").option("-n, --name [name]", descriptions.name, { default: !1 }).option("-u, --ui", descriptions.ui, { default: !0 }).option("-c, --components", descriptions.components, { default: !0 }).option("-w, --web-components", descriptions.webComponents, { default: !0 }).option("-v, --vue", descriptions.vue, { default: !0 }).option("-p, --views", descriptions.views, { default: !0 }).option("-f, --functions", descriptions.functions, { default: !0 }).option("-a, --api", descriptions.api, { default: !0 }).option("-d, --database", descriptions.database, { default: !0 }).option("-ca, --cache", descriptions.cache, { default: !1 }).option("-e, --email", descriptions.email, { default: !1 }).option("-p, --project [project]", descriptions.project, { default: !1 }).option("-m, --minimal", descriptions.minimal, { default: !1 }).option("--verbose", descriptions.verbose, { default: !1 }).action(async (name, options) => {
30
+ buddy.command("new [name]", descriptions.command).alias("create [name]").option("-n, --name [name]", descriptions.name, { default: !1 }).option("-u, --ui", descriptions.ui, { default: !0 }).option("-c, --components", descriptions.components, { default: !0 }).option("-w, --web-components", descriptions.webComponents, { default: !0 }).option("-v, --vue", descriptions.vue, { default: !0 }).option("-p, --views", descriptions.views, { default: !0 }).option("-f, --functions", descriptions.functions, { default: !0 }).option("-a, --api", descriptions.api, { default: !0 }).option("-d, --database", descriptions.database, { default: !0 }).option("-ca, --cache", descriptions.cache, { default: !1 }).option("-e, --email", descriptions.email, { default: !1 }).option("-P, --project [project]", descriptions.project, { default: !1 }).option("-m, --minimal", descriptions.minimal, { default: !1 }).option("--verbose", descriptions.verbose, { default: !1 }).action(async (name, options) => {
32
31
  log.debug("Running `buddy new <name>` ...", options);
33
32
  const startTime = await intro("buddy new");
34
33
  name = name ?? options.name;
35
34
  const path = resolve(process.cwd(), name);
36
35
  isFolderCheck(path);
37
- onlineCheck();
36
+ await onlineCheck();
38
37
  const result = await download(name, path, options);
39
38
  if (result.isErr) {
40
39
  log.error(result.error);
@@ -50,6 +49,8 @@ export function create(buddy) {
50
49
  log.success(dim(`[${time.toFixed(2)}ms] Completed`));
51
50
  }
52
51
  log.info(bold("Welcome to the Stacks Framework! \u269B\uFE0F"));
52
+ log.info(`Get started: ${cyan(`cd ${name}`)} and then ${cyan("./buddy dev")}`);
53
+ log.info(`Run ${cyan("./buddy doctor")} anytime to check your setup`);
53
54
  log.info("To learn more, visit https://stacksjs.com");
54
55
  process.exit(ExitCode.Success);
55
56
  });
@@ -61,19 +62,28 @@ function isFolderCheck(path) {
61
62
  process.exit(ExitCode.FatalError);
62
63
  }
63
64
  }
64
- function onlineCheck() {
65
- if (!useOnline()) {
66
- log.info("It appears you are disconnected from the internet.");
67
- log.info("The Stacks setup requires a brief internet connection for setup.");
68
- log.info("For instance, it installs your dependencies from.");
69
- process.exit(ExitCode.FatalError);
65
+ async function onlineCheck() {
66
+ if (await isOnline())
67
+ return;
68
+ log.info("It appears you are disconnected from the internet.");
69
+ log.info("Creating a new project requires a brief internet connection to download the template and install dependencies.");
70
+ process.exit(ExitCode.FatalError);
71
+ }
72
+ async function isOnline() {
73
+ try {
74
+ return (await fetch("https://github.com", {
75
+ method: "HEAD",
76
+ signal: AbortSignal.timeout(3000)
77
+ })).ok;
78
+ } catch {
79
+ return !1;
70
80
  }
71
81
  }
72
82
  async function download(name, path, _options) {
73
83
  log.info("Setting up your stack.");
74
84
  try {
75
85
  const { downloadTemplate } = await import("@stacksjs/gitit");
76
- await downloadTemplate("stacks", { dir: name });
86
+ await downloadTemplate("gh:stacksjs/stacks", { dir: name });
77
87
  log.success(`Successfully scaffolded your project at ${cyan(path)}`);
78
88
  return { isErr: !1 };
79
89
  } catch (error) {
@@ -738,8 +738,11 @@ async function runHetznerDeploy(args) {
738
738
  "temp",
739
739
  ".DS_Store",
740
740
  "*.log",
741
+ ".env",
741
742
  ".env.local",
742
- ".env.production.bak"
743
+ ".env.keys",
744
+ ".env.production.bak",
745
+ ".env.production.plain"
743
746
  ].flatMap((p) => [`--exclude='${p}'`, `--exclude='*/${p}'`]);
744
747
  if (onlySite && !sites[onlySite]) {
745
748
  log.error(`--site '${onlySite}' is not a configured site. Available: ${Object.keys(sites).join(", ")}`);
@@ -861,7 +864,10 @@ async function resolveAttachTargetBox(owner, environment) {
861
864
  return { serverId: chosen.id, serverName: chosen.name, publicIp: chosen.public_net?.ipv4?.ip };
862
865
  }
863
866
  export async function provisionMailTenant(ip, logger) {
864
- const cfg = emailConfig || {}, domain = cfg.domain || (typeof cfg.from?.address === "string" && cfg.from.address.includes("@") ? cfg.from.address.split("@")[1] : void 0), forwards = cfg.forwards && typeof cfg.forwards === "object" ? cfg.forwards : {}, hasForwards = Object.keys(forwards).length > 0, boxes = domain ? resolveMailboxes(cfg.mailboxes, domain) : [];
867
+ const cfg = emailConfig || {};
868
+ if (cfg.server?.enabled === !1)
869
+ return null;
870
+ const domain = cfg.domain || (typeof cfg.from?.address === "string" && cfg.from.address.includes("@") ? cfg.from.address.split("@")[1] : void 0), forwards = cfg.forwards && typeof cfg.forwards === "object" ? cfg.forwards : {}, hasForwards = Object.keys(forwards).length > 0, boxes = domain ? resolveMailboxes(cfg.mailboxes, domain) : [];
865
871
  if (!domain && !hasForwards)
866
872
  return null;
867
873
  const { execSync } = await import("node:child_process"), sshArgs = ["-o", "StrictHostKeyChecking=accept-new", "-o", "BatchMode=yes", "-o", "ConnectTimeout=20", `root@${ip}`], forwardsB64 = hasForwards ? Buffer.from(JSON.stringify(forwards)).toString("base64") : "", readme = "Auto-forwarding rules, re-read on every message (edits take effect immediately, no restart). KEY = the delivered mailbox: the FULL address for per-domain isolated mailboxes (e.g. no-reply@app.com), or a bare local-part for legacy role mailboxes. VALUE = list of destination addresses; targets on a local domain are written straight to that mailbox Maildir, external targets are relayed. Managed by buddy deploy " + "from config/email.ts (merge-based \u2014 hand edits to other keys are preserved).", readmeB64 = Buffer.from(readme).toString("base64"), boxesB64 = boxes.length ? Buffer.from(boxes.map((b) => `${b.address} ${b.password}`).join(`
@@ -1083,6 +1089,21 @@ async function reconcileConfigDns(sites, logger) {
1083
1089
  logger.warn(`DNS (config/dns.ts) reconcile for ${domain} failed: ${err instanceof Error ? err.message : String(err)}`);
1084
1090
  }
1085
1091
  }
1092
+ async function verifyDnsRecord(provider, domain, fqdn, ip) {
1093
+ try {
1094
+ if (typeof provider?.listRecords !== "function")
1095
+ return !0;
1096
+ const res = await provider.listRecords(domain);
1097
+ if (!res?.success || !Array.isArray(res.records))
1098
+ return !0;
1099
+ return res.records.some((r) => {
1100
+ const name = typeof r?.name === "string" ? r.name.replace(/\.$/, "") : "";
1101
+ return r?.type === "A" && name === fqdn && r?.content === ip;
1102
+ });
1103
+ } catch {
1104
+ return !0;
1105
+ }
1106
+ }
1086
1107
  async function reconcileHetznerDns(sites, ip, logger) {
1087
1108
  const domains = new Set;
1088
1109
  for (const site of Object.values(sites))
@@ -1129,11 +1150,15 @@ async function reconcileHetznerDns(sites, ip, logger) {
1129
1150
  continue;
1130
1151
  }
1131
1152
  for (const sub of ["", "www"]) {
1132
- const fqdn = sub ? `${sub}.${domain}` : domain, res = await provider.upsertRecord(domain, { name: sub, type: "A", content: ip, ttl: 600 });
1133
- if (res?.success === !1)
1134
- logger.warn(` DNS: ${fqdn} \u2192 ${ip} failed: ${res.error || "unknown error"}`);
1135
- else
1153
+ const fqdn = sub ? `${sub}.${domain}` : domain, res = await provider.upsertRecord(domain, { name: fqdn, type: "A", content: ip, ttl: 600 });
1154
+ if (res?.success === !1) {
1155
+ logger.warn(` DNS: ${fqdn} \u2192 ${ip} failed: ${res.error || res.message || "unknown error"}`);
1156
+ continue;
1157
+ }
1158
+ if (await verifyDnsRecord(provider, domain, fqdn, ip))
1136
1159
  logger.success(` DNS: ${fqdn} \u2192 ${ip} (${provider.name})`);
1160
+ else
1161
+ logger.warn(` DNS: ${fqdn} \u2192 ${ip} reported success at ${provider.name} but no matching A record exists \u2014 create it manually: A ${fqdn} \u2192 ${ip}`);
1137
1162
  }
1138
1163
  } catch (err) {
1139
1164
  logger.warn(` DNS: ${domain} reconciliation failed: ${err?.message || err}`);
@@ -6,7 +6,6 @@ import { bold, cyan, dim, green, intro, log, onUnknownSubcommand, outro, prompts
6
6
  import { homedir } from "node:os";
7
7
  import { dirname, join } from "node:path";
8
8
  import { fileURLToPath } from "node:url";
9
- import { promises as dns } from "node:dns";
10
9
  import { Action } from "@stacksjs/enums";
11
10
  import { libsPath, projectPath } from "@stacksjs/path";
12
11
  import { ExitCode } from "@stacksjs/types";
@@ -187,7 +186,7 @@ export function dev(buddy) {
187
186
  onUnknownSubcommand(buddy, "dev");
188
187
  }
189
188
  export async function startDevelopmentServer(_options, _startTime) {
190
- const options = _options, startedAt = _startTime, appUrl = process.env.APP_URL, nativeMode = options.native === !0, proxyManagedExternally = process.env.STACKS_PROXY_MANAGED === "1", frontendPort = Number(process.env.PORT) || 3000, apiPort = Number(process.env.PORT_API) || 3008, docsPort = Number(process.env.PORT_DOCS) || 3006, dashboardPort = Number(process.env.PORT_ADMIN) || 3002, includeDashboard = process.env.STACKS_DEV_DASHBOARD === "1", appLooksCustom = !nativeMode && appUrl && appUrl !== "localhost" && !appUrl.includes("localhost:"), domain = appLooksCustom ? appUrl.replace(/^https?:\/\//, "") : null, isLocalDevTld = (host) => host === "localhost" || host.endsWith(".localhost") || host.endsWith(".test") || host.endsWith(".invalid"), domainIsPublic = Boolean(domain) && process.env.STACKS_DEV_ALLOW_PUBLIC_DOMAIN !== "1" && !isLocalDevTld(domain) && (await dns.resolve4(domain).catch(() => [])).some((address) => !address.startsWith("127.")), hasCustomDomain = appLooksCustom && !proxyManagedExternally && !domainIsPublic, dashboardDomain = domain ? `dashboard.${domain}` : null, frontendUrl = domain ? `https://${domain}` : `http://localhost:${frontendPort}`, apiUrl = domain ? `https://${domain}/api` : `http://localhost:${apiPort}`, docsUrl = domain ? `https://${domain}/docs` : `http://localhost:${docsPort}`, dashboardUrl = dashboardDomain ? `https://${dashboardDomain}` : `http://localhost:${dashboardPort}`, managedPorts = [
189
+ const options = _options, startedAt = _startTime, appUrl = process.env.APP_URL, nativeMode = options.native === !0, proxyManagedExternally = process.env.STACKS_PROXY_MANAGED === "1", frontendPort = Number(process.env.PORT) || 3000, apiPort = Number(process.env.PORT_API) || 3008, docsPort = Number(process.env.PORT_DOCS) || 3006, dashboardPort = Number(process.env.PORT_ADMIN) || 3002, includeDashboard = process.env.STACKS_DEV_DASHBOARD === "1", appLooksCustom = !nativeMode && appUrl && appUrl !== "localhost" && !appUrl.includes("localhost:"), domain = appLooksCustom ? appUrl.replace(/^https?:\/\//, "") : null, localhostOnly = process.env.STACKS_DEV_LOCALHOST === "1", hasCustomDomain = appLooksCustom && !proxyManagedExternally && !localhostOnly, dashboardDomain = domain ? `dashboard.${domain}` : null, frontendUrl = domain ? `https://${domain}` : `http://localhost:${frontendPort}`, apiUrl = domain ? `https://${domain}/api` : `http://localhost:${apiPort}`, docsUrl = domain ? `https://${domain}/docs` : `http://localhost:${docsPort}`, dashboardUrl = dashboardDomain ? `https://${dashboardDomain}` : `http://localhost:${dashboardPort}`, managedPorts = [
191
190
  frontendPort,
192
191
  apiPort,
193
192
  docsPort,
@@ -201,6 +200,8 @@ export async function startDevelopmentServer(_options, _startTime) {
201
200
  if (process.env.STACKS_DEV_NO_KILL !== "1")
202
201
  await cleanupStaleDevProcesses(managedPorts);
203
202
  const rpxTlsPreflight = hasCustomDomain && domain ? prepareRpxTlsForDev({ domain, includeDashboard, options }) : Promise.resolve();
203
+ if (localhostOnly && domain && !proxyManagedExternally)
204
+ removeStalePublicDomainOverrides(domain, includeDashboard, options.verbose ?? !1).catch(() => {});
204
205
  rpxTlsPreflight.catch(() => {});
205
206
  let isExiting = !1, closeNativeApp;
206
207
  const SHUTDOWN_GRACE_MS = 1500, cleanup = () => {
@@ -281,7 +282,7 @@ export async function startDevelopmentServer(_options, _startTime) {
281
282
  printDevReadyBanner({
282
283
  options,
283
284
  nativeMode,
284
- hasCustomDomain: !!appLooksCustom,
285
+ hasCustomDomain: !!appLooksCustom && !localhostOnly,
285
286
  proxyReachable: proxyManagedExternally ? !0 : proxyReachable,
286
287
  frontendUrl,
287
288
  apiUrl,
@@ -363,21 +364,22 @@ function printDevReadyBanner(input) {
363
364
  domain,
364
365
  dashboardPort,
365
366
  dashboardDomain
366
- } = input, verbose = options.verbose ?? !1, useProxy = hasCustomDomain && proxyReachable, feUrl = `http://localhost:${frontendPort}`, apUrl = `http://localhost:${apiPort}`, dcUrl = `http://localhost:${docsPort}`, dbUrl = `http://localhost:${dashboardPort}`, blogUrl = `${feUrl}/blog`;
367
+ } = input, verbose = options.verbose ?? !1, useProxy = hasCustomDomain && proxyReachable, feUrl = useProxy ? frontendUrl : `http://localhost:${frontendPort}`, apUrl = useProxy ? apiUrl : `http://localhost:${apiPort}`, dcUrl = useProxy ? docsUrl : `http://localhost:${docsPort}`, dbUrl = useProxy ? dashboardUrl : `http://localhost:${dashboardPort}`, blogUrl = `${feUrl}/blog`;
367
368
  console.log();
368
369
  console.log(` ${green("\u279C")} ${bold("Frontend")}: ${cyan(feUrl)}`);
369
370
  if (nativeMode)
370
- console.log(` ${green("\u279C")} ${bold("Native")}: ${cyan(`Craft \u2192 http://localhost:${frontendPort}`)}`);
371
+ console.log(` ${green("\u279C")} ${bold("Native")}: ${cyan(`Craft \u2192 ${feUrl}`)}`);
371
372
  console.log(` ${green("\u279C")} ${bold("API")}: ${cyan(apUrl)}`);
372
373
  console.log(` ${green("\u279C")} ${bold("Docs")}: ${cyan(dcUrl)}`);
373
374
  if (blogIsConfigured())
374
375
  console.log(` ${green("\u279C")} ${bold("Blog")}: ${cyan(blogUrl)}`);
375
376
  if (includeDashboard)
376
377
  console.log(` ${green("\u279C")} ${bold("Dashboard")}: ${cyan(dbUrl)}`);
377
- if (useProxy && domain)
378
- console.log(` ${dim("\u279C")} ${dim("Proxy")}: ${dim(`${frontendUrl} (local HTTPS via rpx)`)}`);
379
- if (useProxy && includeDashboard && dashboardDomain)
380
- console.log(` ${dim("\u279C")} ${dim("Proxy")}: ${dim(`${dashboardUrl} (local HTTPS via rpx)`)}`);
378
+ if (useProxy) {
379
+ console.log(` ${dim("\u279C")} ${dim("Direct")}: ${dim(`http://localhost:${frontendPort} (bypasses the proxy)`)}`);
380
+ if (includeDashboard && dashboardDomain)
381
+ console.log(` ${dim("\u279C")} ${dim("Direct")}: ${dim(`http://localhost:${dashboardPort} (dashboard, bypasses the proxy)`)}`);
382
+ }
381
383
  if (isComingSoonMode()) {
382
384
  console.log();
383
385
  console.log(` ${yellow("\u25CF")} ${bold(yellow("Coming soon mode"))} ${dim("\u2014 visitors see the holding page; bypass with the coming-soon secret.")}`);
@@ -849,15 +851,7 @@ async function removeStalePublicDomainOverrides(domain, includeDashboard, verbos
849
851
  }
850
852
  }
851
853
  async function prepareRpxTlsForDev(input) {
852
- const { domain, includeDashboard, options } = input, verbose = options.verbose ?? !1;
853
- if (process.env.STACKS_DEV_ALLOW_PUBLIC_DOMAIN !== "1") {
854
- if ((await dns.resolve4(domain).catch(() => [])).some((address) => !address.startsWith("127."))) {
855
- log.warn(`Skipping local DNS override for public domain ${domain}; use a .localhost/.test domain for development`);
856
- await removeStalePublicDomainOverrides(domain, includeDashboard, verbose);
857
- return;
858
- }
859
- }
860
- const hosts = [
854
+ const { domain, includeDashboard, options } = input, verbose = options.verbose ?? !1, hosts = [
861
855
  domain,
862
856
  ...includeDashboard ? [`dashboard.${domain}`] : []
863
857
  ], hostsNeedingFile = hosts.filter((host) => {
@@ -877,8 +871,11 @@ async function prepareRpxTlsForDev(input) {
877
871
  }
878
872
  async function startRpxDaemonIfNeeded(input) {
879
873
  const { ensureDaemonRunning, isDaemonRunning: isRpxUp } = await importDevelopmentRpx();
880
- if (await isRpxUp())
881
- return;
874
+ if (await isRpxUp()) {
875
+ if (await waitForHttpsProxy(443, 1500))
876
+ return;
877
+ await input.stopRpx({ timeoutMs: 5000 }).catch(() => {});
878
+ }
882
879
  const spawnOpts = {
883
880
  spawnCommand: input.spawnCommand,
884
881
  spawnCwd: resolveRpxDaemonSpawnCwd(),
@@ -1,2 +1,11 @@
1
1
  import type { CLI } from '@stacksjs/types';
2
2
  export declare function doctor(buddy: CLI): void;
3
+ /**
4
+ * Throw inside a probe fn to record a warning instead of a failure.
5
+ * Used when a check cannot run to completion (e.g. the driver exposes
6
+ * no raw query method, so there is nothing to probe with) or when the
7
+ * finding is advisory rather than fatal (e.g. a busy dev port).
8
+ */
9
+ declare class ProbeWarning extends Error {
10
+
11
+ }
@@ -2,22 +2,28 @@ import process from "node:process";
2
2
  import { bold, dim, green, intro, log, onUnknownSubcommand, red, yellow } from "@stacksjs/cli";
3
3
  import { feature } from "@stacksjs/config";
4
4
  import { storage } from "@stacksjs/storage";
5
+ import { isSupportedBunVersion, isVersionGreaterThanOrEqual, minimumBunVersion } from "@stacksjs/utils";
5
6
  import { FEATURE_NAMES, featurePathsPresent } from "./features";
7
+ const minimumSqliteVersion = "3.47.2";
8
+
9
+ class ProbeWarning extends Error {
10
+ }
6
11
  async function probe(checks, name, fn, timeoutMs = 2000) {
7
- const start = Date.now();
12
+ const start = Date.now(), ac = new AbortController, timer = setTimeout(() => ac.abort(), timeoutMs);
8
13
  try {
9
- const ac = new AbortController, timer = setTimeout(() => ac.abort(), timeoutMs), message = await Promise.race([
14
+ const message = await Promise.race([
10
15
  fn(),
11
16
  new Promise((_, rej) => ac.signal.addEventListener("abort", () => rej(Error(`timed out (>${Math.round(timeoutMs / 1000)}s)`))))
12
17
  ]);
13
- clearTimeout(timer);
14
18
  checks.push({ name, status: "pass", message: `${message} (${Date.now() - start}ms)` });
15
19
  } catch (err) {
16
20
  checks.push({
17
21
  name,
18
- status: "fail",
22
+ status: err instanceof ProbeWarning ? "warn" : "fail",
19
23
  message: err instanceof Error ? err.message : String(err)
20
24
  });
25
+ } finally {
26
+ clearTimeout(timer);
21
27
  }
22
28
  }
23
29
  export function doctor(buddy) {
@@ -25,19 +31,18 @@ export function doctor(buddy) {
25
31
  log.debug("Running `buddy doctor` ...");
26
32
  await intro("buddy doctor");
27
33
  const checks = [], bunVersion = process.versions.bun;
28
- if (bunVersion)
29
- if (Number.parseInt(bunVersion.split(".")[0] || "0", 10) >= 1)
30
- checks.push({
31
- name: "Bun Runtime",
32
- status: "pass",
33
- message: `v${bunVersion}`
34
- });
35
- else
36
- checks.push({
37
- name: "Bun Runtime",
38
- status: "warn",
39
- message: `v${bunVersion} (v1.0+ recommended)`
40
- });
34
+ if (bunVersion && isSupportedBunVersion(bunVersion))
35
+ checks.push({
36
+ name: "Bun Runtime",
37
+ status: "pass",
38
+ message: `v${bunVersion}`
39
+ });
40
+ else if (bunVersion)
41
+ checks.push({
42
+ name: "Bun Runtime",
43
+ status: "fail",
44
+ message: `v${bunVersion} (requires v${minimumBunVersion} or later, run: bun upgrade)`
45
+ });
41
46
  else
42
47
  checks.push({
43
48
  name: "Bun Runtime",
@@ -100,9 +105,34 @@ export function doctor(buddy) {
100
105
  status: "fail",
101
106
  message: "Not set \u2014 features that depend on it (encrypted columns, signed URLs, env decryption) will refuse to run. Run `buddy key:generate`."
102
107
  });
108
+ await probe(checks, "SQLite Engine", async () => {
109
+ const { Database } = await import("bun:sqlite"), memory = new Database(":memory:");
110
+ let version;
111
+ try {
112
+ const row = memory.query("SELECT sqlite_version() AS version").get();
113
+ if (typeof row?.version === "string")
114
+ version = row.version;
115
+ } finally {
116
+ memory.close();
117
+ }
118
+ if (!version)
119
+ throw new ProbeWarning("could not read sqlite_version() (skipped)");
120
+ if (!isVersionGreaterThanOrEqual(version, minimumSqliteVersion))
121
+ throw Error(`v${version} is below the required v${minimumSqliteVersion} (system.sqlite in package.json); upgrade Bun for a newer bundled SQLite`);
122
+ return `v${version}`;
123
+ });
103
124
  await probe(checks, "Database", async () => {
104
- const { db } = await import("@stacksjs/database");
105
- await db.unsafe?.("SELECT 1");
125
+ const { db } = await import("@stacksjs/database"), unsafe = db.unsafe;
126
+ if (typeof unsafe !== "function")
127
+ throw new ProbeWarning("driver exposes no raw query method (skipped)");
128
+ const statement = unsafe("SELECT 1");
129
+ if (!statement || typeof statement.then !== "function" && typeof statement.execute !== "function")
130
+ throw new ProbeWarning("driver returned a non-executable statement (skipped)");
131
+ const result = typeof statement.execute === "function" ? await statement.execute() : await statement;
132
+ if (result === void 0 || result === null)
133
+ throw Error("probe query returned no result");
134
+ if (Array.isArray(result) && result.length === 0)
135
+ throw Error("probe query returned no rows");
106
136
  return "Reachable";
107
137
  });
108
138
  await probe(checks, "Database FKs", async () => {
@@ -303,6 +333,34 @@ export function doctor(buddy) {
303
333
  message: `Could not audit dev-domain overrides: ${err instanceof Error ? err.message : String(err)}`
304
334
  });
305
335
  }
336
+ await probe(checks, "Dev ports", async () => {
337
+ const net = await import("node:net"), { config } = await import("@stacksjs/config"), configured = config.ports ?? {}, targets = [
338
+ { name: "frontend", key: "frontend", envVar: "PORT", fallback: 3000 },
339
+ { name: "api", key: "api", envVar: "PORT_API", fallback: 3008 },
340
+ { name: "docs", key: "docs", envVar: "PORT_DOCS", fallback: 3006 },
341
+ { name: "dashboard", key: "admin", envVar: "PORT_ADMIN", fallback: 3002 }
342
+ ].map((t) => ({ ...t, port: Number(configured[t.key]) || t.fallback })), canConnect = (port, host) => new Promise((resolve) => {
343
+ const socket = net.createConnection({ port, host });
344
+ socket.setTimeout(400);
345
+ const done = (occupied) => {
346
+ socket.destroy();
347
+ resolve(occupied);
348
+ };
349
+ socket.once("connect", () => done(!0));
350
+ socket.once("timeout", () => done(!1));
351
+ socket.once("error", () => done(!1));
352
+ }), occupied = new Set;
353
+ await Promise.all([...new Set(targets.map((t) => t.port))].map(async (port) => {
354
+ if (await canConnect(port, "127.0.0.1") || await canConnect(port, "::1"))
355
+ occupied.add(port);
356
+ }));
357
+ const busy = targets.filter((t) => occupied.has(t.port));
358
+ if (busy.length > 0) {
359
+ const list = busy.map((t) => `${t.name} :${t.port} (${t.envVar})`).join(", ");
360
+ throw new ProbeWarning(`in use: ${list}. buddy dev will fail to bind; stop the process holding the port or set the override env var`);
361
+ }
362
+ return `All free: ${targets.map((t) => `${t.name} :${t.port}`).join(", ")}`;
363
+ });
306
364
  try {
307
365
  const orphans = [];
308
366
  for (const name of FEATURE_NAMES) {