@stacksjs/buddy 0.70.204 → 0.70.206

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.
@@ -690,7 +690,7 @@ async function runHetznerDeploy(args) {
690
690
  process.exit(ExitCode.FatalError);
691
691
  }
692
692
  const attachTo = tsCloudConfig.cloud?.attachTo;
693
- let ip;
693
+ let ip, ipv6;
694
694
  if (attachTo) {
695
695
  const box = await resolveAttachTargetBox(attachTo, environment);
696
696
  if (!box?.publicIp) {
@@ -698,6 +698,7 @@ async function runHetznerDeploy(args) {
698
698
  process.exit(ExitCode.FatalError);
699
699
  }
700
700
  ip = box.publicIp;
701
+ ipv6 = box.publicIpv6;
701
702
  log.info(`Attaching to '${attachTo}' box '${box.serverName}' (${ip}) \u2014 skipping provisioning`);
702
703
  const compute = (tsCloudConfig.infrastructure ??= {}).compute ??= {};
703
704
  compute.webServer = "rpx";
@@ -717,6 +718,7 @@ async function runHetznerDeploy(args) {
717
718
  log.info("Provisioning Hetzner compute infrastructure...");
718
719
  const outputs = await driver.provisionComputeInfrastructure({ config: scrubLoopbackSitePortsForFirewall(tsCloudConfig), environment });
719
720
  ip = outputs.appPublicIp;
721
+ ipv6 = outputs.appPublicIpv6;
720
722
  log.success("Hetzner compute infrastructure ready");
721
723
  if (outputs.appInstanceId)
722
724
  log.info(`Server ID: ${outputs.appInstanceId}`);
@@ -815,7 +817,7 @@ async function runHetznerDeploy(args) {
815
817
  }
816
818
  });
817
819
  if (ok)
818
- await reconcileHetznerDns(onlySite ? { [onlySite]: sites[onlySite] } : sites, ip, log);
820
+ await reconcileHetznerDns(onlySite ? { [onlySite]: sites[onlySite] } : sites, ip, log, ipv6);
819
821
  if (ok)
820
822
  await reconcileConfigDns(onlySite ? { [onlySite]: sites[onlySite] } : sites, log);
821
823
  if (ok) {
@@ -921,7 +923,13 @@ async function resolveAttachTargetBox(owner, environment) {
921
923
  }, byLabel = await req(`label_selector=${encodeURIComponent(`ts-cloud/project=${owner},ts-cloud/environment=${environment},ts-cloud/role=app`)}`), chosen = pick(byLabel) || pick(await req(`name=${encodeURIComponent(`${owner}-${environment}-app`)}`));
922
924
  if (!chosen)
923
925
  return null;
924
- return { serverId: chosen.id, serverName: chosen.name, publicIp: chosen.public_net?.ipv4?.ip };
926
+ const { hetznerBoxIpv6 } = await import("@stacksjs/ts-cloud");
927
+ return {
928
+ serverId: chosen.id,
929
+ serverName: chosen.name,
930
+ publicIp: chosen.public_net?.ipv4?.ip,
931
+ publicIpv6: hetznerBoxIpv6?.(chosen.public_net?.ipv6?.ip)
932
+ };
925
933
  }
926
934
  export async function provisionMailTenant(ip, logger) {
927
935
  if (!hasExplicitEmailConfig())
@@ -1291,7 +1299,7 @@ async function reconcileConfigDns(sites, logger) {
1291
1299
  logger.warn(`DNS (config/dns.ts) reconcile for ${domain} failed: ${err instanceof Error ? err.message : String(err)}`);
1292
1300
  }
1293
1301
  }
1294
- async function verifyDnsRecord(provider, domain, fqdn, ip) {
1302
+ async function verifyDnsRecord(provider, domain, fqdn, ip, recordType = "A") {
1295
1303
  try {
1296
1304
  if (typeof provider?.listRecords !== "function")
1297
1305
  return !0;
@@ -1300,13 +1308,17 @@ async function verifyDnsRecord(provider, domain, fqdn, ip) {
1300
1308
  return !0;
1301
1309
  return res.records.some((r) => {
1302
1310
  const name = typeof r?.name === "string" ? r.name.replace(/\.$/, "") : "";
1303
- return r?.type === "A" && name === fqdn && r?.content === ip;
1311
+ return r?.type === recordType && name === fqdn && r?.content === ip;
1304
1312
  });
1305
1313
  } catch {
1306
1314
  return !0;
1307
1315
  }
1308
1316
  }
1309
- async function reconcileHetznerDns(sites, ip, logger) {
1317
+ const IPV6_EXCLUDED_HOSTS = new Set(["mail", "smtp", "imap", "mx"]);
1318
+ function skipIpv6ForHost(fqdn) {
1319
+ return IPV6_EXCLUDED_HOSTS.has(fqdn.split(".")[0] ?? "");
1320
+ }
1321
+ async function reconcileHetznerDns(sites, ip, logger, ipv6) {
1310
1322
  const domains = new Set;
1311
1323
  for (const site of Object.values(sites))
1312
1324
  if (site?.domain && typeof site.domain === "string")
@@ -1382,6 +1394,20 @@ async function reconcileHetznerDns(sites, ip, logger) {
1382
1394
  logger.success(` DNS: ${fqdn} \u2192 ${ip} (${provider.name})`);
1383
1395
  else
1384
1396
  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}`);
1397
+ if (!ipv6 || skipIpv6ForHost(fqdn))
1398
+ continue;
1399
+ const v6res = await provider.upsertRecord(domain, { name: fqdn, type: "AAAA", content: ipv6, ttl: 600 });
1400
+ if (v6res?.success === !1) {
1401
+ logger.warn(` DNS: ${fqdn} \u2192 ${ipv6} failed: ${v6res.error || v6res.message || "unknown error"}`);
1402
+ continue;
1403
+ }
1404
+ const v6Cleanup = await removeStaleServerAddressRecords(provider, domain, fqdn, ipv6, "AAAA");
1405
+ for (const warning of v6Cleanup)
1406
+ logger.warn(` DNS: ${fqdn} cleanup: ${warning}`);
1407
+ if (await verifyDnsRecord(provider, domain, fqdn, ipv6, "AAAA"))
1408
+ logger.success(` DNS: ${fqdn} \u2192 ${ipv6} (${provider.name})`);
1409
+ else
1410
+ logger.warn(` DNS: ${fqdn} \u2192 ${ipv6} reported success at ${provider.name} but no matching AAAA record exists \u2014 create it manually: AAAA ${fqdn} \u2192 ${ipv6}`);
1385
1411
  }
1386
1412
  } catch (err) {
1387
1413
  logger.warn(` DNS: ${domain} reconciliation failed: ${err?.message || err}`);
@@ -501,5 +501,61 @@ export function migrate(buddy) {
501
501
  });
502
502
  process.exit(ExitCode.Success);
503
503
  });
504
+ buddy.command("migrate:regenerate [dialect]", "Rebuild database/migrations from your models for a given dialect").option("--dry-run", "Show what would change without writing anything", { default: !1 }).option("-f, --force", "Regenerate even though the database already has migrations recorded", { default: !1 }).action(async (dialect, options) => {
505
+ const perf = await intro("buddy migrate:regenerate"), target = (dialect || process.env.DB_CONNECTION || "sqlite").toLowerCase();
506
+ if (!new Set(["sqlite", "mysql", "postgres", "singlestore"]).has(target)) {
507
+ log.syncError(`Unknown dialect "${target}". Allowed: sqlite, mysql, postgres, singlestore.`);
508
+ process.exit(ExitCode.FatalError);
509
+ }
510
+ const { countAppliedMigrations, regenerateMigrationCorpus } = await import("@stacksjs/database");
511
+ let applied = 0;
512
+ try {
513
+ applied = await countAppliedMigrations();
514
+ } catch {
515
+ applied = 0;
516
+ }
517
+ if (applied > 0 && !options.force && !options.dryRun) {
518
+ log.syncError(`This database already has ${applied} migration(s) recorded.`);
519
+ log.syncError("Regenerating renumbers every file, and the migrations table keys on the filename,");
520
+ log.syncError("so already-applied migrations would look pending and run a second time.");
521
+ log.syncError(" Point at an empty database, or re-run with --force if you know it is safe.");
522
+ process.exit(ExitCode.FatalError);
523
+ }
524
+ const plan = await regenerateMigrationCorpus({ dialect: target, dryRun: !0 });
525
+ if (plan.isErr) {
526
+ log.syncError(plan.error.message);
527
+ process.exit(ExitCode.FatalError);
528
+ }
529
+ const { files, removed, models } = plan.value;
530
+ console.log(`
531
+ Regenerate plan: ${target}
532
+ \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
533
+ \u2022 ${models} model(s) read from app/Models and the framework defaults
534
+ \u2022 ${files.length} migration file(s) will be written
535
+ \u2022 ${removed.length} existing file(s) will be removed
536
+ \u2022 These files are tracked in git, so review with \`git diff\` afterwards
537
+ \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
538
+ `);
539
+ if (options.dryRun) {
540
+ await outro("Dry run. Nothing was written.", { startTime: perf, useSeconds: !0 });
541
+ process.exit(ExitCode.Success);
542
+ }
543
+ if (!isCI && hasTTY && process.stdin.isTTY) {
544
+ await log.flush();
545
+ if (!await confirm({ message: `Replace ${removed.length} migration file(s) with ${files.length} generated for ${target}?`, initial: !1 })) {
546
+ await outro("Cancelled. Nothing was written.", { startTime: perf, useSeconds: !0 });
547
+ process.exit(ExitCode.Success);
548
+ }
549
+ }
550
+ const result = await regenerateMigrationCorpus({ dialect: target });
551
+ if (result.isErr) {
552
+ log.syncError(result.error.message);
553
+ process.exit(ExitCode.FatalError);
554
+ }
555
+ log.success(`Wrote ${result.value.files.length} ${target} migration file(s) to database/migrations.`);
556
+ log.info("Review the change with `git diff`, then run `./buddy migrate`.");
557
+ await outro("Regenerated.", { startTime: perf, useSeconds: !0 });
558
+ process.exit(ExitCode.Success);
559
+ });
504
560
  onUnknownSubcommand(buddy, "migrate");
505
561
  }
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.204",
5
+ "version": "0.70.206",
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.84",
140
+ "@stacksjs/ts-cloud": "^0.7.85",
141
141
  "ajv": "^8.20.0",
142
142
  "ajv-formats": "^3.0.1"
143
143
  },