@stacksjs/buddy 0.70.201 → 0.70.203

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.
@@ -5,6 +5,8 @@ import { Action } from "@stacksjs/enums";
5
5
  import { hasTTY, isCI } from "@stacksjs/env";
6
6
  import { appPath, frameworkPath, frameworkRuntimePath, projectPath } from "@stacksjs/path";
7
7
  import { ExitCode } from "@stacksjs/types";
8
+ import { preflightDatabase } from "../database-preflight";
9
+ import { DIALECT_OVERRIDE_ENV, auditMigrationCorpus, formatMigrationDialectError, stripSqlNoise } from "@stacksjs/database";
8
10
  let _runAction;
9
11
  async function runAction(...args) {
10
12
  if (!_runAction)
@@ -35,6 +37,15 @@ function acquireMigrationLock() {
35
37
  };
36
38
  }
37
39
  }
40
+ async function ensureDatabaseOrExit() {
41
+ try {
42
+ const { ensureDatabaseReady } = await import("@stacksjs/database");
43
+ await ensureDatabaseReady();
44
+ } catch (error) {
45
+ log.syncError(error instanceof Error ? error.message : String(error));
46
+ process.exit(ExitCode.FatalError);
47
+ }
48
+ }
38
49
  function readMigrateMarker() {
39
50
  const file = frameworkRuntimePath("last-migrate-result.json");
40
51
  if (!existsSync(file))
@@ -73,6 +84,17 @@ function validateModelsExist() {
73
84
  };
74
85
  return { valid: !0 };
75
86
  }
87
+ function validateMigrationDialect() {
88
+ if (process.env[DIALECT_OVERRIDE_ENV] === "1")
89
+ return { valid: !0 };
90
+ const driver = String(process.env.DB_CONNECTION || "sqlite").toLowerCase(), target = driver === "singlestore" ? "mysql" : driver;
91
+ if (target !== "sqlite" && target !== "postgres" && target !== "mysql")
92
+ return { valid: !0 };
93
+ const dir = projectPath("database/migrations"), audit = auditMigrationCorpus({ dir, target });
94
+ if (audit.empty || audit.incompatible.length === 0)
95
+ return { valid: !0 };
96
+ return { valid: !1, error: formatMigrationDialectError(audit, target, "database/migrations") };
97
+ }
76
98
  async function reportMissingForeignKeys() {
77
99
  try {
78
100
  const { auditForeignKeys } = await import("@stacksjs/database"), result = await auditForeignKeys();
@@ -189,12 +211,19 @@ export function migrate(buddy) {
189
211
  fromDb: "Diff against the live database schema instead of the snapshot (self-heal drift)",
190
212
  noRename: "Treat renamed columns as drop + add instead of a data-preserving rename"
191
213
  };
192
- buddy.command("migrate", descriptions.migrate).alias("db:migrate").option("-d, --diff", "Show the SQL that would be run", { default: !1 }).option("-p, --project [project]", descriptions.project, { default: !1 }).option("-a, --auth", descriptions.auth, { default: !0 }).option("--no-auth", "Skip auth/oauth table migrations").option("-f, --force", descriptions.force, { default: !1 }).option("--from-db", descriptions.fromDb, { default: !1 }).option("--no-rename", descriptions.noRename).option("--verbose", descriptions.verbose, { default: !1 }).action(async (options) => {
214
+ buddy.command("migrate", descriptions.migrate).alias("db:migrate").option("-d, --diff", "Show the SQL that would be run", { default: !1 }).option("-p, --project [project]", descriptions.project, { default: !1 }).option("-a, --auth", descriptions.auth, { default: !0 }).option("--no-auth", "Skip auth/oauth table migrations").option("-f, --force", descriptions.force, { default: !1 }).option("--create-database", "Create the database if it does not exist, without asking", { default: !1 }).option("--from-db", descriptions.fromDb, { default: !1 }).option("--no-rename", descriptions.noRename).option("--verbose", descriptions.verbose, { default: !1 }).action(async (options) => {
193
215
  log.debug("Running `buddy migrate` ...", options);
194
216
  const perf = await intro("buddy migrate"), validation = validateModelsExist();
195
217
  if (!validation.valid) {
196
218
  console.error(`
197
219
  \u274C Error: ${validation.error}
220
+ `);
221
+ process.exit(ExitCode.FatalError);
222
+ }
223
+ const dialectCheck = validateMigrationDialect();
224
+ if (!dialectCheck.valid) {
225
+ console.error(`
226
+ \u274C Error: ${dialectCheck.error}
198
227
  `);
199
228
  process.exit(ExitCode.FatalError);
200
229
  }
@@ -219,6 +248,7 @@ export function migrate(buddy) {
219
248
  await outro("Diff complete \u2014 no changes applied.", { startTime: perf, useSeconds: !0 });
220
249
  process.exit(ExitCode.Success);
221
250
  }
251
+ await preflightDatabase({ createDatabase: options.createDatabase, command: "migrate" });
222
252
  if ((await resolveMigrationGuards()).confirmMigrate && !options.force)
223
253
  if (isCI || !hasTTY)
224
254
  log.debug("[migrate] confirmMigrate guard skipped \u2014 non-interactive environment.");
@@ -233,6 +263,7 @@ export function migrate(buddy) {
233
263
  process.exit(ExitCode.Success);
234
264
  }
235
265
  }
266
+ await ensureDatabaseOrExit();
236
267
  const lock = acquireMigrationLock();
237
268
  if (!lock.acquired) {
238
269
  log.syncError("Another migration is already running (storage/framework/runtime/migrations.lock exists). Wait for it to finish, or remove the lockfile if it is stale.");
@@ -293,12 +324,19 @@ export function migrate(buddy) {
293
324
  });
294
325
  process.exit(ExitCode.Success);
295
326
  });
296
- buddy.command("migrate:fresh", descriptions.fresh).alias("db:fresh").option("-d, --diff", "Show the SQL that would be run", { default: !1 }).option("-p, --project [project]", descriptions.project, { default: !1 }).option("-s, --seed", "Run database seeders after migration", { default: !1 }).option("-a, --auth", descriptions.auth, { default: !0 }).option("--no-auth", "Skip auth/oauth table migrations").option("-f, --force", 'Skip the drop-database confirmation (only honored when the migrateFresh guard is "allow")', { default: !1 }).option("--verbose", descriptions.verbose, { default: !1 }).action(async (options) => {
327
+ buddy.command("migrate:fresh", descriptions.fresh).alias("db:fresh").option("-d, --diff", "Show the SQL that would be run", { default: !1 }).option("-p, --project [project]", descriptions.project, { default: !1 }).option("-s, --seed", "Run database seeders after migration", { default: !1 }).option("-a, --auth", descriptions.auth, { default: !0 }).option("--no-auth", "Skip auth/oauth table migrations").option("--create-database", "Create the database if it does not exist, without asking", { default: !1 }).option("-f, --force", 'Skip the drop-database confirmation (only honored when the migrateFresh guard is "allow")', { default: !1 }).option("--verbose", descriptions.verbose, { default: !1 }).action(async (options) => {
297
328
  log.debug("Running `buddy migrate:fresh` ...", options);
298
329
  const perf = await intro("buddy migrate:fresh"), validation = validateModelsExist();
299
330
  if (!validation.valid) {
300
331
  console.error(`
301
332
  \u274C Error: ${validation.error}
333
+ `);
334
+ process.exit(ExitCode.FatalError);
335
+ }
336
+ const dialectCheck = validateMigrationDialect();
337
+ if (!dialectCheck.valid) {
338
+ console.error(`
339
+ \u274C Error: ${dialectCheck.error}
302
340
  `);
303
341
  process.exit(ExitCode.FatalError);
304
342
  }
@@ -325,6 +363,8 @@ export function migrate(buddy) {
325
363
  process.exit(ExitCode.Success);
326
364
  }
327
365
  }
366
+ await preflightDatabase({ createDatabase: options.createDatabase, command: "migrate:fresh" });
367
+ await ensureDatabaseOrExit();
328
368
  const result = await runAction(Action.MigrateFresh, options);
329
369
  if (result.isErr)
330
370
  log.error("Model migrations failed \u2014 applying auth/notification/RBAC table guarantees before exiting.");
@@ -425,7 +465,7 @@ export function migrate(buddy) {
425
465
  if (existsSync(migrationsDir)) {
426
466
  const files = readdirSync(migrationsDir).filter((f) => f.endsWith(".sql"));
427
467
  for (const f of files) {
428
- const content = readFileSync(`${migrationsDir}/${f}`, "utf-8").toLowerCase();
468
+ const content = stripSqlNoise(readFileSync(`${migrationsDir}/${f}`, "utf-8")).toLowerCase();
429
469
  if (/alter\s+table[\s\S]*add\s+constraint/.test(content))
430
470
  alterCount++;
431
471
  if (/^\s*create\s+unique\s+index/m.test(content))
@@ -433,7 +473,9 @@ export function migrate(buddy) {
433
473
  }
434
474
  }
435
475
  } catch {}
436
- const sqliteFkNote = current === "sqlite" && alterCount > 0 ? `
476
+ const switchAudit = auditMigrationCorpus({ dir: projectPath("database/migrations"), target }), switchBlocked = switchAudit.incompatible.length > 0, blockedFiles = new Set(switchAudit.incompatible.map((m) => m.file)).size, dialectNote = switchBlocked ? `
477
+ \u26D4 ${blockedFiles} of ${switchAudit.total} migration files are ${switchAudit.inferred ?? "another dialect"}-flavoured and will NOT run on ${target}.
478
+ Regenerate them against ${target} before migrating, or this switch will fail on the first file.` : "", sqliteFkNote = current === "sqlite" && alterCount > 0 ? `
437
479
  (These were skipped on SQLite per stacksjs/stacks#1916 and survive on disk for replay.)` : "", boolNote = current === "sqlite" && (target === "postgres" || target === "mysql") ? `
438
480
  \u2022 Booleans land as 0/1 on SQLite; ${target} stores them as ${target === "postgres" ? "true/false" : "0/1 (compatible)"}.` : "", tzNote = target === "postgres" ? `
439
481
  \u2022 PostgreSQL uses timestamptz (with TZ) where SQLite/MySQL store plain TIMESTAMP. Existing rows do NOT auto-upgrade \u2014 they ride the column's stored type.` : "", envExtras = target !== "sqlite" ? ", plus DB_HOST / DB_PORT / DB_USERNAME / DB_PASSWORD / DB_DATABASE" : "", missingNote = missingEnv.length > 0 ? `
@@ -444,13 +486,13 @@ export function migrate(buddy) {
444
486
  \u2022 ${alterCount} ALTER TABLE ADD CONSTRAINT migration(s) will be applied against ${target}.${sqliteFkNote}
445
487
  \u2022 ${uniqueIdxCount} CREATE UNIQUE INDEX migration(s) will be applied against ${target}.
446
488
  \u2022 Auth tables (oauth_clients, oauth_access_tokens, oauth_refresh_tokens, password_resets) will be CREATE TABLE IF NOT EXISTS \u2014 they re-create cleanly under the new dialect.${boolNote}${tzNote}
447
- \u2022 Existing row data does NOT auto-migrate. Use \`mysqldump\` / \`pg_dump\` (or your own export) to move it.${missingNote}
489
+ \u2022 Existing row data does NOT auto-migrate. Use \`mysqldump\` / \`pg_dump\` (or your own export) to move it.${missingNote}${dialectNote}
448
490
  \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
449
491
 
450
492
  Next steps:
451
493
  1. Update .env: DB_CONNECTION=${target}${envExtras}
452
494
  2. (Optional) Export data from the current ${current} database.
453
- 3. Run \`./buddy migrate\` (or \`migrate:fresh\` to start clean).
495
+ 3. ${switchBlocked ? `Regenerate the migration files for ${target} FIRST \u2014 \`./buddy migrate\` will refuse until then.` : "Run `./buddy migrate` (or `migrate:fresh` to start clean)."}
454
496
  4. The post-migrate FK audit will report any constraints that didn't replay.
455
497
  `);
456
498
  await outro("Plan rendered. Re-run after updating .env to actually switch.", {
@@ -32,3 +32,17 @@ export declare function serve(buddy: CLI): void;
32
32
  * command — `./buddy serve:api` resolves the framework wherever it lives.
33
33
  */
34
34
  export declare function serveApi(buddy: CLI): void;
35
+ /**
36
+ * `buddy preview` — serve a finished static build locally.
37
+ *
38
+ * The twin of `buddy serve`, for the other kind of output: where that renders
39
+ * stx pages and proxies an API, this hands over pre-built files exactly as a
40
+ * static host would. It exists so checking a build before shipping it does not
41
+ * require a `preview.ts` in every project's root, hand-rolled per app and
42
+ * quietly drifting from how the host actually behaves.
43
+ *
44
+ * Extensionless paths resolve to `.html`, matching the "pretty URLs" behaviour
45
+ * of Netlify, Vercel and Cloudflare Pages, so a link that works here works
46
+ * there.
47
+ */
48
+ export declare function preview(buddy: CLI): void;
@@ -1,3 +1,4 @@
1
+ import { ExitCode } from "@stacksjs/types";
1
2
  import { existsSync } from "node:fs";
2
3
  import { homedir } from "node:os";
3
4
  import { dirname, join } from "node:path";
@@ -256,3 +257,30 @@ async function loadStxSiteConfig() {
256
257
  } catch {}
257
258
  return {};
258
259
  }
260
+ export function preview(buddy) {
261
+ buddy.command("preview [dir]", "Serve a static build locally, the way a static host would").option("-p, --port <port>", "Port to listen on", { default: "3001" }).example("buddy preview").example("buddy preview dist --port 4000").action(async (dir, options) => {
262
+ const root = dir || "dist", port = Number(options?.port) || 3001;
263
+ if (!existsSync(root)) {
264
+ log.error(`No \`${root}\` directory to preview. Run \`buddy build\` first, or pass the directory to serve.`);
265
+ process.exit(ExitCode.FatalError);
266
+ }
267
+ Bun.serve({
268
+ port,
269
+ async fetch(req) {
270
+ let pathname = new URL(req.url).pathname;
271
+ if (pathname === "/" || pathname === "")
272
+ pathname = "/index.html";
273
+ else if (!pathname.includes("."))
274
+ pathname = `${pathname.replace(/\/$/, "")}.html`;
275
+ const file = Bun.file(join(root, pathname));
276
+ if (await file.exists())
277
+ return new Response(file);
278
+ const notFound = Bun.file(join(root, "404.html"));
279
+ if (await notFound.exists())
280
+ return new Response(notFound, { status: 404 });
281
+ return new Response("Not Found", { status: 404 });
282
+ }
283
+ });
284
+ log.success(`Previewing ./${root} at http://localhost:${port}`);
285
+ });
286
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Whether we may raise a blocking question right now.
3
+ *
4
+ * Computed per call rather than read from `@stacksjs/env`'s `isCI` / `hasTTY`,
5
+ * which are module-level constants frozen at first import: anything that sets
6
+ * `CI` after the env package loads (a wrapper script, a test harness, a config
7
+ * file evaluated during CLI boot) would otherwise get no protection at all.
8
+ *
9
+ * Both stdin and stdout are required. stdout alone is not enough: `buddy
10
+ * migrate < /dev/null` keeps a terminal on stdout while stdin is at EOF.
11
+ */
12
+ export declare function canPromptInteractively(): boolean;
13
+ /**
14
+ * Resolve the standing policy, if the user set one.
15
+ * `always` and `never` skip the question entirely in both directions.
16
+ */
17
+ export declare function resolveCreatePolicy(): 'always' | 'never' | 'prompt';
18
+ /**
19
+ * Offer to create the target database when it is missing.
20
+ *
21
+ * Returns normally when the command may proceed. Exits the process when the
22
+ * user declines or when there is no safe way to continue. Never creates
23
+ * anything itself: it only records the decision, and the bootstrap in
24
+ * `@stacksjs/database` performs the create at the usual point.
25
+ */
26
+ export declare function preflightDatabase(options?: DatabasePreflightOptions): Promise<void>;
27
+ export declare interface DatabasePreflightOptions {
28
+ createDatabase?: boolean
29
+ command?: string
30
+ }
@@ -0,0 +1,88 @@
1
+ import process from "node:process";
2
+ import { confirmOrNull, log } from "@stacksjs/cli";
3
+ import { ExitCode } from "@stacksjs/types";
4
+ export function canPromptInteractively() {
5
+ if (process.env.CI || process.env.CONTINUOUS_INTEGRATION || process.env.BUILD_NUMBER || process.env.RUN_ID)
6
+ return !1;
7
+ return Boolean(process.stdin.isTTY && process.stdout.isTTY);
8
+ }
9
+ export function resolveCreatePolicy() {
10
+ const value = String(process.env.DB_CREATE_DATABASE || "").toLowerCase();
11
+ if (value === "always" || value === "true" || value === "1")
12
+ return "always";
13
+ if (value === "never" || value === "false" || value === "0")
14
+ return "never";
15
+ return "prompt";
16
+ }
17
+ function decide(create) {
18
+ process.env.STACKS_CREATE_DATABASE = create ? "1" : "0";
19
+ }
20
+ const UNANSWERED = Symbol("unanswered");
21
+ async function confirmWithTimeout(message, timeoutMs) {
22
+ let timer;
23
+ try {
24
+ return await Promise.race([
25
+ confirmOrNull({ message, initial: !0 }).then((v) => v === null ? UNANSWERED : v),
26
+ new Promise((resolve) => {
27
+ timer = setTimeout(() => resolve(UNANSWERED), timeoutMs);
28
+ })
29
+ ]);
30
+ } finally {
31
+ if (timer)
32
+ clearTimeout(timer);
33
+ }
34
+ }
35
+ export async function preflightDatabase(options = {}) {
36
+ const commandName = options.command || "migrate", {
37
+ canCreateDatabases,
38
+ describeTarget,
39
+ manualCreateHint,
40
+ probeTargetDatabase,
41
+ resolveConnectionTarget
42
+ } = await import("@stacksjs/database"), target = resolveConnectionTarget();
43
+ if (!target)
44
+ return;
45
+ const probe = await probeTargetDatabase(target);
46
+ if (probe.ok || probe.kind !== "missing-database")
47
+ return;
48
+ const where = describeTarget(target), standing = resolveCreatePolicy();
49
+ if (standing === "never") {
50
+ decide(!1);
51
+ return;
52
+ }
53
+ if (options.createDatabase || standing === "always") {
54
+ decide(!0);
55
+ return;
56
+ }
57
+ if (!canPromptInteractively()) {
58
+ log.syncError(`The database "${target.database}" does not exist on ${where}.`);
59
+ log.syncError("Refusing to create it in a non-interactive shell.");
60
+ log.syncError(" Create it ahead of time, re-run with --create-database, or set DB_CREATE_DATABASE=always.");
61
+ log.syncError(` ${manualCreateHint(target)}`);
62
+ process.exit(ExitCode.FatalError);
63
+ }
64
+ if (await canCreateDatabases(target) === !1) {
65
+ log.syncError(`The database "${target.database}" does not exist on ${where}.`);
66
+ log.syncError(`The user "${target.username}" is not allowed to create databases, so I cannot create it for you.`);
67
+ log.syncError(` Grant it with: ALTER ROLE "${target.username}" CREATEDB;`);
68
+ log.syncError(` Or create the database yourself with: ${manualCreateHint(target)}`);
69
+ process.exit(ExitCode.FatalError);
70
+ }
71
+ log.warn(`The database "${target.database}" does not exist on ${where}.`);
72
+ await log.flush();
73
+ const timeoutMs = Number(process.env.DB_CREATE_DATABASE_TIMEOUT_MS || 120000), answer = await confirmWithTimeout("Would you like to create it?", timeoutMs);
74
+ if (answer === UNANSWERED) {
75
+ log.syncError(`No answer after ${Math.round(timeoutMs / 1000)}s, so nothing was created.`);
76
+ log.syncError(" Re-run with --create-database, or set DB_CREATE_DATABASE=always, to skip this question.");
77
+ log.syncError(` ${manualCreateHint(target)}`);
78
+ process.exit(ExitCode.FatalError);
79
+ }
80
+ if (!answer) {
81
+ decide(!1);
82
+ log.info("Nothing was changed. Create it yourself with:");
83
+ log.info(` ${manualCreateHint(target)}`);
84
+ log.info(`Then re-run \`buddy ${commandName}\`, or re-run with --create-database to skip this question.`);
85
+ process.exit(ExitCode.Success);
86
+ }
87
+ decide(!0);
88
+ }
@@ -101,6 +101,7 @@ const commandRegistry = {
101
101
  seed: { path: "./commands/seed.js", exportName: "seed" },
102
102
  "seed:roles": { path: "./commands/seed.js", exportName: "seed" },
103
103
  "roles:seed": { path: "./commands/seed.js", exportName: "seed" },
104
+ preview: { path: "./commands/serve.js", exportName: "preview" },
104
105
  serve: { path: "./commands/serve.js", exportName: "serve" },
105
106
  "serve:api": { path: "./commands/serve.js", exportName: "serveApi" },
106
107
  setup: { path: "./commands/setup.js", exportName: "setup" },
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.201",
5
+ "version": "0.70.203",
6
6
  "description": "Meet Buddy. The Stacks runtime.",
7
7
  "author": "Chris Breuer",
8
8
  "contributors": [