@stacksjs/buddy 0.70.112 → 0.70.114

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 = () => {
@@ -208,25 +209,34 @@ export async function startDevelopmentServer(_options, _startTime) {
208
209
  return;
209
210
  isExiting = !0;
210
211
  closeNativeApp?.();
211
- unregisterRpxProxies(activeRpxRegistryIds);
212
- activeRpxRegistryIds.length = 0;
213
- try {
214
- process.kill(-process.pid, "SIGTERM");
215
- } catch {
212
+ const rpxTeardown = (async () => {
216
213
  try {
217
- process.kill(0, "SIGTERM");
214
+ await unregisterRpxProxies(activeRpxRegistryIds);
215
+ activeRpxRegistryIds.length = 0;
216
+ if (hasCustomDomain && domain)
217
+ await removeStalePublicDomainOverrides(domain, includeDashboard, options.verbose ?? !1);
218
218
  } catch {}
219
- }
220
- setTimeout(() => {
219
+ })(), teardownDeadline = new Promise((resolve) => setTimeout(resolve, SHUTDOWN_GRACE_MS));
220
+ Promise.race([rpxTeardown, teardownDeadline]).finally(() => {
221
221
  try {
222
- process.kill(0, "SIGKILL");
222
+ process.kill(-process.pid, "SIGTERM");
223
223
  } catch {
224
- process.exit(1);
224
+ try {
225
+ process.kill(0, "SIGTERM");
226
+ } catch {}
225
227
  }
226
- }, SHUTDOWN_GRACE_MS).unref();
228
+ setTimeout(() => {
229
+ try {
230
+ process.kill(0, "SIGKILL");
231
+ } catch {
232
+ process.exit(1);
233
+ }
234
+ }, SHUTDOWN_GRACE_MS).unref();
235
+ });
227
236
  };
228
237
  process.on("SIGINT", cleanup);
229
238
  process.on("SIGTERM", cleanup);
239
+ process.on("SIGHUP", cleanup);
230
240
  const quietOpts = { ...options, quiet: !0 }, a = await actions(), ports = [
231
241
  { name: "Frontend", port: frontendPort },
232
242
  { name: "API", port: apiPort }
@@ -272,7 +282,7 @@ export async function startDevelopmentServer(_options, _startTime) {
272
282
  printDevReadyBanner({
273
283
  options,
274
284
  nativeMode,
275
- hasCustomDomain: !!appLooksCustom,
285
+ hasCustomDomain: !!appLooksCustom && !localhostOnly,
276
286
  proxyReachable: proxyManagedExternally ? !0 : proxyReachable,
277
287
  frontendUrl,
278
288
  apiUrl,
@@ -354,21 +364,22 @@ function printDevReadyBanner(input) {
354
364
  domain,
355
365
  dashboardPort,
356
366
  dashboardDomain
357
- } = 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`;
358
368
  console.log();
359
369
  console.log(` ${green("\u279C")} ${bold("Frontend")}: ${cyan(feUrl)}`);
360
370
  if (nativeMode)
361
- console.log(` ${green("\u279C")} ${bold("Native")}: ${cyan(`Craft \u2192 http://localhost:${frontendPort}`)}`);
371
+ console.log(` ${green("\u279C")} ${bold("Native")}: ${cyan(`Craft \u2192 ${feUrl}`)}`);
362
372
  console.log(` ${green("\u279C")} ${bold("API")}: ${cyan(apUrl)}`);
363
373
  console.log(` ${green("\u279C")} ${bold("Docs")}: ${cyan(dcUrl)}`);
364
374
  if (blogIsConfigured())
365
375
  console.log(` ${green("\u279C")} ${bold("Blog")}: ${cyan(blogUrl)}`);
366
376
  if (includeDashboard)
367
377
  console.log(` ${green("\u279C")} ${bold("Dashboard")}: ${cyan(dbUrl)}`);
368
- if (useProxy && domain)
369
- console.log(` ${dim("\u279C")} ${dim("Proxy")}: ${dim(`${frontendUrl} (local HTTPS via rpx)`)}`);
370
- if (useProxy && includeDashboard && dashboardDomain)
371
- 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
+ }
372
383
  if (isComingSoonMode()) {
373
384
  console.log();
374
385
  console.log(` ${yellow("\u25CF")} ${bold(yellow("Coming soon mode"))} ${dim("\u2014 visitors see the holding page; bypass with the coming-soon secret.")}`);
@@ -840,15 +851,7 @@ async function removeStalePublicDomainOverrides(domain, includeDashboard, verbos
840
851
  }
841
852
  }
842
853
  async function prepareRpxTlsForDev(input) {
843
- const { domain, includeDashboard, options } = input, verbose = options.verbose ?? !1;
844
- if (process.env.STACKS_DEV_ALLOW_PUBLIC_DOMAIN !== "1") {
845
- if ((await dns.resolve4(domain).catch(() => [])).some((address) => !address.startsWith("127."))) {
846
- log.warn(`Skipping local DNS override for public domain ${domain}; use a .localhost/.test domain for development`);
847
- await removeStalePublicDomainOverrides(domain, includeDashboard, verbose);
848
- return;
849
- }
850
- }
851
- const hosts = [
854
+ const { domain, includeDashboard, options } = input, verbose = options.verbose ?? !1, hosts = [
852
855
  domain,
853
856
  ...includeDashboard ? [`dashboard.${domain}`] : []
854
857
  ], hostsNeedingFile = hosts.filter((host) => {
@@ -868,8 +871,11 @@ async function prepareRpxTlsForDev(input) {
868
871
  }
869
872
  async function startRpxDaemonIfNeeded(input) {
870
873
  const { ensureDaemonRunning, isDaemonRunning: isRpxUp } = await importDevelopmentRpx();
871
- if (await isRpxUp())
872
- return;
874
+ if (await isRpxUp()) {
875
+ if (await waitForHttpsProxy(443, 1500))
876
+ return;
877
+ await input.stopRpx({ timeoutMs: 5000 }).catch(() => {});
878
+ }
873
879
  const spawnOpts = {
874
880
  spawnCommand: input.spawnCommand,
875
881
  spawnCwd: resolveRpxDaemonSpawnCwd(),
@@ -922,6 +928,7 @@ async function registerRpxProxiesForDomain(input) {
922
928
  to: proxy.to,
923
929
  cwd: process.cwd(),
924
930
  createdAt,
931
+ pid: process.pid,
925
932
  pathRewrites: proxy.pathRewrites
926
933
  }, void 0, verbose);
927
934
  if (!activeRpxRegistryIds.includes(proxy.id))
@@ -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
+ }