@stacksjs/buddy 0.70.193 → 0.70.195

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.
@@ -53,6 +53,13 @@ export declare function mergeSiteDeployEnv(sites: Record<string, any>, resolvedD
53
53
  * bare `user@host` (e.g. mail identities) is left alone. Production is untouched.
54
54
  */
55
55
  export declare function applyEnvironmentToSites(sites: Record<string, any>, environment: string, config: any): Record<string, any>;
56
+ /**
57
+ * Load the ts-cloud driver used by Buddy. Published installs use the declared
58
+ * package dependency. Framework and ts-cloud development can point at a built
59
+ * local module with TS_CLOUD_MODULE, avoiding stale nested dependency copies
60
+ * while still exercising the exact Buddy deploy path.
61
+ */
62
+ export declare function loadTsCloudDeployApi(): Promise<typeof import('@stacksjs/ts-cloud')>;
56
63
  /**
57
64
  * Loopback-bound server-app sites (e.g. the `api` site: env.HOST=127.0.0.1,
58
65
  * reached only through `buddy serve`'s same-origin /api proxy on :3000 —
@@ -1,7 +1,8 @@
1
1
  import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
- import { join, relative } from "node:path";
3
+ import { isAbsolute, join, relative, resolve } from "node:path";
4
4
  import process from "node:process";
5
+ import { pathToFileURL } from "node:url";
5
6
  import { runAction } from "@stacksjs/actions";
6
7
  import { italic, onUnknownSubcommand, outro, prompts, runCommand } from "@stacksjs/cli";
7
8
  import { app, dns as dnsConfig, email as emailConfig, cloud as cloudConfig } from "@stacksjs/config";
@@ -545,15 +546,24 @@ async function deployToHetzner(tsCloudConfig, deployEnv, options) {
545
546
  log.info("Generate one with: ssh-keygen -t ed25519");
546
547
  process.exit(ExitCode.FatalError);
547
548
  }
548
- const { createCloudDriver, deployAllComputeSites, resolveSiteKind } = await import("@stacksjs/ts-cloud");
549
+ const { createCloudDriver, deployAllComputeSites, ensureManagementDashboard, resolveSiteKind } = await loadTsCloudDeployApi();
549
550
  try {
550
- await runHetznerDeploy({ tsCloudConfig, environment, verbose, docker: options.docker === !0, createCloudDriver, deployAllComputeSites, resolveSiteKind, onlySite: options.site || void 0 });
551
+ await runHetznerDeploy({ tsCloudConfig, environment, verbose, docker: options.docker === !0, createCloudDriver, deployAllComputeSites, ensureManagementDashboard, resolveSiteKind, onlySite: options.site || void 0 });
551
552
  } catch (err) {
552
553
  log.error("Hetzner deploy failed:");
553
554
  console.error(err instanceof Error ? err.stack || err.message : err);
554
555
  process.exit(ExitCode.FatalError);
555
556
  }
556
557
  }
558
+ export async function loadTsCloudDeployApi() {
559
+ const requested = process.env.TS_CLOUD_MODULE?.trim();
560
+ if (!requested)
561
+ return import("@stacksjs/ts-cloud");
562
+ const modulePath = isAbsolute(requested) ? requested : resolve(process.cwd(), requested);
563
+ if (!existsSync(modulePath))
564
+ throw Error(`TS_CLOUD_MODULE points to a missing module: ${modulePath}`);
565
+ return import(pathToFileURL(modulePath).href);
566
+ }
557
567
  export function scrubLoopbackSitePortsForFirewall(tsCloudConfig) {
558
568
  const sites = tsCloudConfig?.sites;
559
569
  if (!sites)
@@ -652,7 +662,7 @@ async function startGithubDeployments(args) {
652
662
  return records;
653
663
  }
654
664
  async function runHetznerDeploy(args) {
655
- const { tsCloudConfig, environment, verbose, docker, createCloudDriver, deployAllComputeSites, resolveSiteKind, onlySite } = args, startTime = performance.now();
665
+ const { tsCloudConfig, environment, verbose, docker, createCloudDriver, deployAllComputeSites, ensureManagementDashboard, resolveSiteKind, onlySite } = args, startTime = performance.now();
656
666
  console.log("");
657
667
  console.log("\uD83D\uDE80 Deploy \u2192 Hetzner Cloud");
658
668
  console.log("");
@@ -661,7 +671,6 @@ async function runHetznerDeploy(args) {
661
671
  log.info(`Location: ${tsCloudConfig.hetzner?.location || process.env.HCLOUD_LOCATION || "fsn1"}`);
662
672
  log.info(`Size: ${tsCloudConfig.infrastructure?.compute?.size || "small"}`);
663
673
  try {
664
- const { ensureManagementDashboard } = await import("@stacksjs/ts-cloud");
665
674
  if (typeof ensureManagementDashboard === "function")
666
675
  ensureManagementDashboard(tsCloudConfig, {
667
676
  cwd: process.cwd(),
@@ -780,6 +789,7 @@ async function runHetznerDeploy(args) {
780
789
  log.info(onlySite ? `Shipping site '${onlySite}' to the server...` : "Shipping release to the server...");
781
790
  const deployConfig = onlySite ? { ...tsCloudConfig, sites: { [onlySite]: sitesWithResolvedEnv[onlySite] } } : { ...tsCloudConfig, sites: sitesWithResolvedEnv }, ok = await deployAllComputeSites({
782
791
  config: deployConfig,
792
+ managementDashboard: !1,
783
793
  rpxConfig: { ...tsCloudConfig, sites: sitesWithResolvedEnv },
784
794
  environment,
785
795
  driver,
@@ -900,8 +910,8 @@ ENV_CHANGED=0
900
910
  # The 'user:local' CLI is direct-DB and does NOT read /etc/mail/mail.env, so
901
911
  # without SMTP_DB_PATH it writes to ./smtp.db (the SSH cwd) \u2014 a phantom DB the
902
912
  # running server never reads, and auth silently 535s. Point it at the real DB.
903
- export SMTP_DB_PATH="$(grep -E '^SMTP_DB_PATH=' "$ENVF" 2>/dev/null | head -1 | cut -d= -f2- || true)"
904
- [ -z "$SMTP_DB_PATH" ] && export SMTP_DB_PATH=/opt/mail/smtp.db
913
+ MAIL_DB_PATH="$(grep -E '^SMTP_DB_PATH=' "$ENVF" 2>/dev/null | head -1 | cut -d= -f2- || true)"
914
+ [ -z "$MAIL_DB_PATH" ] && MAIL_DB_PATH=/opt/mail/smtp.db
905
915
  echo "MAILHOST:$(grep -E '^SMTP_HOSTNAME=' "$ENVF" 2>/dev/null | head -1 | cut -d= -f2- || true)"
906
916
  # 1) Register the local delivery domain (merge into SMTP_LOCAL_DOMAINS).
907
917
  if [ -n "$DOMAIN" ] && [ -f "$ENVF" ]; then
@@ -944,8 +954,48 @@ if [ -n "$BOXES_B64" ] && [ -x "$MS" ]; then
944
954
  [ -z "$addr" ] && continue
945
955
  # NOTE: 'user:local info' exits 0 even when the user is absent (it only
946
956
  # prints "not found"), so existence is decided from the OUTPUT, not $?.
947
- if "$MS" user:local info "$addr" 2>&1 | grep -qi 'not found'; then
948
- if "$MS" user:local create "$addr" "$pw" "$addr" >/dev/null 2>&1; then echo "MADE:$addr"; else echo "FAIL:$addr"; fi
957
+ if SMTP_DB_PATH="$MAIL_DB_PATH" "$MS" user:local info "$addr" 2>&1 | grep -qi 'not found'; then
958
+ legacy="\${addr%%@*}"
959
+ # Older single-domain installs used a bare username whose email is this
960
+ # full address. Creating the isolated address then hits users.email's
961
+ # UNIQUE constraint while the CLI still exits zero. Perform mail's
962
+ # documented username migration transactionally, including every
963
+ # username-keyed table, before falling back to a true create.
964
+ migrated=$(MAIL_DB_PATH="$MAIL_DB_PATH" OLD_USERNAME="$legacy" NEW_USERNAME="$addr" /usr/local/bin/bun --bun -e '
965
+ const { Database } = require("bun:sqlite")
966
+ const db = new Database(process.env.MAIL_DB_PATH)
967
+ const oldName = process.env.OLD_USERNAME
968
+ const newName = process.env.NEW_USERNAME
969
+ const oldUser = db.query("SELECT 1 AS found FROM users WHERE username=? AND email=?").get(oldName, newName)
970
+ const newUser = db.query("SELECT 1 AS found FROM users WHERE username=?").get(newName)
971
+ if (oldUser && !newUser) {
972
+ db.transaction(() => {
973
+ for (const table of ["users", "imap_mailboxes", "imap_uids", "webmail_sessions"])
974
+ db.query("UPDATE " + table + " SET username=? WHERE username=?").run(newName, oldName)
975
+ })()
976
+ process.stdout.write("yes")
977
+ }
978
+ db.close()
979
+ ' 2>/dev/null || true)
980
+ if [ "$migrated" = yes ]; then
981
+ if [ -d "/opt/mail/mail/$legacy" ] && [ ! -e "/opt/mail/mail/$addr" ]; then
982
+ mv -- "/opt/mail/mail/$legacy" "/opt/mail/mail/$addr"
983
+ chown -R mail-server:mail-server "/opt/mail/mail/$addr"
984
+ fi
985
+ # The declared config password becomes authoritative after migration.
986
+ SMTP_DB_PATH="$MAIL_DB_PATH" "$MS" user:local change-password "$addr" "$pw" >/dev/null 2>&1 || true
987
+ else
988
+ # The CLI action historically returned zero even when an internal
989
+ # create failed. Pin the live DB for both calls and verify the row.
990
+ SMTP_DB_PATH="$MAIL_DB_PATH" "$MS" user:local create "$addr" "$pw" "$addr" >/dev/null 2>&1 || true
991
+ fi
992
+ if SMTP_DB_PATH="$MAIL_DB_PATH" "$MS" user:local info "$addr" 2>&1 | grep -qi 'not found'; then
993
+ echo "FAIL:$addr"
994
+ elif [ "$migrated" = yes ]; then
995
+ echo "MIGRATED:$addr"
996
+ else
997
+ echo "MADE:$addr"
998
+ fi
949
999
  else
950
1000
  echo "EXISTS:$addr"
951
1001
  fi
@@ -1076,7 +1126,7 @@ if [ "$ENV_CHANGED" = 1 ]; then systemctl restart mail 2>/dev/null || true; echo
1076
1126
  input: script,
1077
1127
  encoding: "utf8",
1078
1128
  stdio: ["pipe", "pipe", "pipe"]
1079
- }), line = (out.match(/MAILTENANT:[^\n]*/) || [])[0] || "MAILTENANT:done", mailHostFromOut = (out.match(/MAILHOST:([^\n]*)/) || [])[1]?.trim(), mailHost = mailHostFromOut || `mail.${domain}`, dkimPubB64 = (out.match(/DKIMPUB:([^\n]*)/) || [])[1]?.trim() || void 0, madeAddrs = new Set([...out.matchAll(/MADE:([^\n]+)/g)].flatMap((m) => m[1] ? [m[1].trim()] : [])), created = boxes.filter((b) => madeAddrs.has(b.address)).map((b) => ({ address: b.address, password: b.password }));
1129
+ }), line = (out.match(/MAILTENANT:[^\n]*/) || [])[0] || "MAILTENANT:done", mailHostFromOut = (out.match(/MAILHOST:([^\n]*)/) || [])[1]?.trim(), mailHost = mailHostFromOut || `mail.${domain}`, dkimPubB64 = (out.match(/DKIMPUB:([^\n]*)/) || [])[1]?.trim() || void 0, madeAddrs = new Set([...out.matchAll(/MADE:([^\n]+)/g)].flatMap((m) => m[1] ? [m[1].trim()] : [])), migratedAddrs = new Set([...out.matchAll(/MIGRATED:([^\n]+)/g)].flatMap((m) => m[1] ? [m[1].trim()] : [])), created = boxes.filter((b) => madeAddrs.has(b.address)).map((b) => ({ address: b.address, password: b.password }));
1080
1130
  logger.success(`Mail routing reconciled (${line.replace("MAILTENANT:", "")})`);
1081
1131
  const failed = [...out.matchAll(/FAIL:([^\n]+)/g)].flatMap((m) => m[1] ? [m[1].trim()] : []);
1082
1132
  if (failed.length)
@@ -1090,7 +1140,7 @@ if [ "$ENV_CHANGED" = 1 ]; then systemctl restart mail 2>/dev/null || true; echo
1090
1140
  const certError = (out.match(/CERTFAIL:([^\n]*)/) || [])[1]?.trim();
1091
1141
  if (certError)
1092
1142
  logger.warn(`Mail: could not issue a certificate for mail.${domain} (clients should use ${mailHostFromOut || "the shared mail host"}): ${certError}`);
1093
- const seen = new Set([...madeAddrs, ...[...out.matchAll(/EXISTS:([^\n]+)/g)].flatMap((m) => m[1] ? [m[1].trim()] : [])]), unaccounted = boxes.filter((b) => !seen.has(b.address)).map((b) => b.address);
1143
+ const seen = new Set([...madeAddrs, ...migratedAddrs, ...[...out.matchAll(/EXISTS:([^\n]+)/g)].flatMap((m) => m[1] ? [m[1].trim()] : [])]), unaccounted = boxes.filter((b) => !seen.has(b.address)).map((b) => b.address);
1094
1144
  if (unaccounted.length)
1095
1145
  logger.warn(`Mail: ${unaccounted.length} declared mailbox(es) were not reconciled: ${unaccounted.join(", ")}`);
1096
1146
  if (created.length) {
@@ -1098,6 +1148,8 @@ if [ "$ENV_CHANGED" = 1 ]; then systemctl restart mail 2>/dev/null || true; echo
1098
1148
  for (const b of created)
1099
1149
  logger.info(` ${b.address} ${b.password}`);
1100
1150
  }
1151
+ if (migratedAddrs.size)
1152
+ logger.success(`Mail: migrated ${migratedAddrs.size} legacy mailbox username(s) to isolated full addresses`);
1101
1153
  return domain ? { domain, mailHost, dkimPubB64, created } : null;
1102
1154
  } catch (err) {
1103
1155
  logger.warn(`Mail routing reconcile skipped: ${getErrorMessage(err)}`);
@@ -8,6 +8,11 @@ function markdown(value) {
8
8
  function code(value) {
9
9
  return `\`${String(value).replaceAll("`", "\\`")}\``;
10
10
  }
11
+ function stablePath(value) {
12
+ if (typeof value !== "string" || !value.includes(root))
13
+ return value;
14
+ return value.replaceAll(root, "<project root>");
15
+ }
11
16
  function optionFlags(option) {
12
17
  return option.flags.map((flag) => code(`${flag.length === 1 ? "-" : "--"}${flag}`)).join(", ");
13
18
  }
@@ -34,7 +39,7 @@ function commandSection(command) {
34
39
  `- Arguments: ${command.arguments.length ? command.arguments.map(argumentSyntax).join(", ") : "none"}`
35
40
  ];
36
41
  if (command.options.length)
37
- lines.push("", "| Option | Description | Contract | Default |", "| --- | --- | --- | --- |", ...command.options.map((option) => `| ${optionFlags(option)} | ${markdown(option.description || "\u2014")} | ${optionKind(option)} | ${option.default === void 0 ? "\u2014" : code(JSON.stringify(option.default))} |`));
42
+ lines.push("", "| Option | Description | Contract | Default |", "| --- | --- | --- | --- |", ...command.options.map((option) => `| ${optionFlags(option)} | ${markdown(option.description || "\u2014")} | ${optionKind(option)} | ${option.default === void 0 ? "\u2014" : code(JSON.stringify(stablePath(option.default)))} |`));
38
43
  if (command.examples.length)
39
44
  lines.push("", "Examples:", "", "```bash", ...command.examples, "```");
40
45
  return lines.join(`
@@ -107,8 +112,20 @@ export async function run() {
107
112
  } else {
108
113
  if (!existsSync(outputPath))
109
114
  throw Error("Buddy command reference is missing; run bun run docs:buddy");
110
- if (readFileSync(outputPath, "utf8") !== expected)
111
- throw Error("Buddy command reference is stale; run bun run docs:buddy");
115
+ const current = readFileSync(outputPath, "utf8");
116
+ if (current !== expected) {
117
+ const currentLines = current.split(`
118
+ `), expectedLines = expected.split(`
119
+ `), differences = [];
120
+ for (let i = 0;i < Math.max(currentLines.length, expectedLines.length) && differences.length < 6; i++)
121
+ if (currentLines[i] !== expectedLines[i])
122
+ differences.push(` line ${i + 1}:
123
+ committed: ${JSON.stringify(currentLines[i] ?? "<end of file>")}
124
+ generated: ${JSON.stringify(expectedLines[i] ?? "<end of file>")}`);
125
+ throw Error(`Buddy command reference is stale; run bun run docs:buddy
126
+ ${differences.join(`
127
+ `)}`);
128
+ }
112
129
  console.log("Buddy command reference matches the runtime registry");
113
130
  }
114
131
  }
@@ -1,3 +1,5 @@
1
+ import { lstatSync, readlinkSync } from "node:fs";
2
+ import { resolve } from "node:path";
1
3
  import process from "node:process";
2
4
  import { bold, dim, green, intro, log, onUnknownSubcommand, red, yellow } from "@stacksjs/cli";
3
5
  import { feature } from "@stacksjs/config";
@@ -30,7 +32,25 @@ export function doctor(buddy) {
30
32
  buddy.command("doctor", "Run health checks on your Stacks installation").option("--no-fail", "Print results but always exit 0 (CI ramp-up)").action(async (options) => {
31
33
  log.debug("Running `buddy doctor` ...");
32
34
  await intro("buddy doctor");
33
- const checks = [], bunVersion = process.versions.bun;
35
+ const checks = [], modulesPath = resolve(process.cwd(), "node_modules");
36
+ try {
37
+ if (lstatSync(modulesPath).isSymbolicLink()) {
38
+ const target = resolve(process.cwd(), readlinkSync(modulesPath)), insideProject = target.startsWith(`${resolve(process.cwd())}/`);
39
+ checks.push({
40
+ name: "Dependency tree",
41
+ status: insideProject ? "warn" : "fail",
42
+ message: insideProject ? `node_modules is a symlink to ${target}` : `node_modules is a symlink to ${target}, which belongs to another project \u2014 installs here mutate it, and what runs locally is not what the lockfile resolves. Remove the link and run \`bun install\`.`
43
+ });
44
+ } else
45
+ checks.push({ name: "Dependency tree", status: "pass", message: "node_modules is this project's own" });
46
+ } catch {
47
+ checks.push({
48
+ name: "Dependency tree",
49
+ status: "warn",
50
+ message: "node_modules is missing \u2014 run `bun install`"
51
+ });
52
+ }
53
+ const bunVersion = process.versions.bun;
34
54
  if (bunVersion && isSupportedBunVersion(bunVersion))
35
55
  checks.push({
36
56
  name: "Bun Runtime",
@@ -424,8 +444,10 @@ export function doctor(buddy) {
424
444
  log.info("");
425
445
  if (hasFailures) {
426
446
  log.error("Some critical checks failed. Please address the issues above.");
427
- if (options?.fail !== !1)
447
+ if (options?.fail !== !1) {
448
+ await log.flush();
428
449
  process.exit(1);
450
+ }
429
451
  } else if (hasWarnings)
430
452
  log.info(yellow("Some checks have warnings. Your system should work but may have issues."));
431
453
  else
@@ -169,9 +169,15 @@ export function serveApi(buddy) {
169
169
  if (options?.port)
170
170
  process.env.PORT = String(options.port);
171
171
  process.env.APP_ENV = process.env.APP_ENV || "production";
172
- await import("@stacksjs/actions/serve/api");
172
+ await import(resolveApiEntry());
173
173
  });
174
174
  }
175
+ function resolveApiEntry() {
176
+ const vendored = join(process.cwd(), "storage/framework/core/actions/src/serve/api.ts");
177
+ if (existsSync(vendored))
178
+ return vendored;
179
+ return "@stacksjs/actions/serve/api";
180
+ }
175
181
  function resolveDefaultsResources() {
176
182
  const vendored = "storage/framework/defaults/resources";
177
183
  if (existsSync(vendored))
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@stacksjs/buddy",
3
3
  "type": "module",
4
4
  "sideEffects": false,
5
- "version": "0.70.193",
5
+ "version": "0.70.195",
6
6
  "description": "Meet Buddy. The Stacks runtime.",
7
7
  "author": "Chris Breuer",
8
8
  "contributors": [
@@ -137,7 +137,7 @@
137
137
  "@stacksjs/ui": "^0.70.180",
138
138
  "@stacksjs/utils": "^0.70.180",
139
139
  "@stacksjs/validation": "^0.70.180",
140
- "@stacksjs/ts-cloud": "^0.7.60",
140
+ "@stacksjs/ts-cloud": "^0.7.78",
141
141
  "ajv": "^8.20.0",
142
142
  "ajv-formats": "^3.0.1"
143
143
  },