@stacksjs/buddy 0.70.251 → 0.70.253

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.
@@ -1535,6 +1535,12 @@ export function deploy(buddy) {
1535
1535
  };
1536
1536
  buddy.command("deploy [env]", descriptions.deploy).option("--domain", descriptions.domain, { default: void 0 }).option("-p, --project [project]", descriptions.project, { default: !1 }).option("--prod", descriptions.production, { default: !0 }).option("--dev", descriptions.development, { default: !1 }).option("--yes", descriptions.yes, { default: !1 }).option("--site <name>", "Deploy only this one site to the existing server (multi-tenant surgical add)", { default: void 0 }).option("--staging", descriptions.staging, { default: !1 }).option("--docker", "Also build an OCI image with pantry (native, no Docker daemon) and push it to the pantry registry", { default: !1 }).option("--verbose", descriptions.verbose, { default: !1 }).action(async (envArg, options) => {
1537
1537
  log.debug("Running `buddy deploy` ...", options);
1538
+ if (process.argv.includes("--dry-run") || options.dryRun === !0) {
1539
+ log.error("`buddy deploy --dry-run` is not supported.");
1540
+ log.info("Deploy has no preview mode: provisioning, release shipping, and DNS all mutate real infrastructure.");
1541
+ log.info("To see what would ship without touching the server, inspect `config/cloud.ts` sites, or run `buddy deploy --site <name>` to narrow a real deploy to one site.");
1542
+ process.exit(ExitCode.FatalError);
1543
+ }
1538
1544
  await ensureDeployPrerequisites(options.verbose === !0);
1539
1545
  const deployEnv = envArg || (options.staging ? "staging" : options.dev ? "development" : "production"), deployEnvName = deployEnv === "prod" ? "production" : deployEnv === "dev" ? "development" : deployEnv;
1540
1546
  process.env.APP_ENV = deployEnvName;
@@ -7,6 +7,21 @@ export declare function shouldUsePrettyDevUrls(input: {
7
7
  proxyManagedExternally: boolean
8
8
  systemAuthorized: boolean
9
9
  }): boolean;
10
+ export declare function normalizeDevelopmentEntryPath(path: string | undefined): string;
11
+ export declare function resolveDevelopmentLaunch(input?: {
12
+ root?: string
13
+ browser?: boolean
14
+ native?: boolean
15
+ site?: boolean
16
+ configuredLaunch?: 'browser' | 'native'
17
+ }): 'browser' | 'native';
18
+ export declare function resolveDevelopmentEntryPath(input?: {
19
+ root?: string
20
+ site?: boolean
21
+ configuredPath?: string
22
+ }): string;
23
+ export declare function developmentUrl(baseUrl: string, entryPath: string): string;
24
+ export declare function developmentBrowserCommand(url: string, platform?: unknown): string[];
10
25
  export declare function dev(buddy: CLI): void;
11
26
  export declare function startDevelopmentServer(_options: DevOptions, _startTime?: number): Promise<void>;
12
27
  export declare function setupPrettyDevEnvironment(input: {
@@ -72,6 +72,117 @@ export function resolvePrettyDevDomain(appUrl, nativeMode = !1) {
72
72
  export function shouldUsePrettyDevUrls(input) {
73
73
  return input.domain !== null && !input.localhostOnly && (input.proxyManagedExternally || input.systemAuthorized);
74
74
  }
75
+ const APPLICATION_VIEW_CANDIDATES = [
76
+ "app/index",
77
+ "app",
78
+ "dashboard/index",
79
+ "dashboard",
80
+ "home/index",
81
+ "home",
82
+ "composer",
83
+ "workspace/index",
84
+ "workspace",
85
+ "feed",
86
+ "portal",
87
+ "admin",
88
+ "account/index",
89
+ "account",
90
+ "profile/index",
91
+ "profile"
92
+ ], SITE_LAYOUT_NAMES = new Set(["default", "guest", "marketing", "site"]);
93
+ export function normalizeDevelopmentEntryPath(path) {
94
+ const value = path?.trim();
95
+ if (!value || value === "/")
96
+ return "/";
97
+ try {
98
+ const parsed = new URL(value, "http://stacks.localhost");
99
+ return `${parsed.pathname}${parsed.search}${parsed.hash}`;
100
+ } catch {
101
+ return "/";
102
+ }
103
+ }
104
+ function configuredApplicationPath(root) {
105
+ if (process.env.APP_PATH)
106
+ return process.env.APP_PATH;
107
+ try {
108
+ return readFileSync(join(root, "config/app.ts"), "utf8").match(/^[ \t]*appPath\s*:\s*(['"`])([^'"`]+)\1/m)?.[2];
109
+ } catch {
110
+ return;
111
+ }
112
+ }
113
+ function configuredDevelopmentLaunch(root) {
114
+ const environmentValue = process.env.STACKS_DEV_LAUNCH?.trim().toLowerCase();
115
+ if (environmentValue === "browser" || environmentValue === "native")
116
+ return environmentValue;
117
+ try {
118
+ const value = readFileSync(join(root, "config/app.ts"), "utf8").match(/\bdevLaunch\s*:\s*(['"`])(browser|native)\1/)?.[2];
119
+ return value === "browser" || value === "native" ? value : void 0;
120
+ } catch {
121
+ return;
122
+ }
123
+ }
124
+ export function resolveDevelopmentLaunch(input = {}) {
125
+ if (input.browser || input.site)
126
+ return "browser";
127
+ if (input.native)
128
+ return "native";
129
+ const root = input.root ?? projectPath();
130
+ return input.configuredLaunch ?? configuredDevelopmentLaunch(root) ?? "browser";
131
+ }
132
+ function viewSource(root, route) {
133
+ const base = join(root, "resources/views", route);
134
+ for (const extension of ["stx", "vue", "html"]) {
135
+ const path = `${base}.${extension}`;
136
+ if (existsSync(path))
137
+ try {
138
+ return readFileSync(path, "utf8");
139
+ } catch {
140
+ return "";
141
+ }
142
+ }
143
+ return;
144
+ }
145
+ function usesApplicationLayout(source) {
146
+ const layout = source.match(/@extends\(\s*['"]layouts\/([^'"]+)['"]\s*\)/)?.[1] ?? source.match(/@layout\(\s*['"]layouts\/([^'"]+)['"]\s*\)/)?.[1];
147
+ if (!layout)
148
+ return !1;
149
+ return !SITE_LAYOUT_NAMES.has(layout.split("/").at(-1) ?? layout);
150
+ }
151
+ export function resolveDevelopmentEntryPath(input = {}) {
152
+ if (input.site)
153
+ return "/";
154
+ const root = input.root ?? projectPath(), configuredPath = input.configuredPath ?? configuredApplicationPath(root);
155
+ if (configuredPath)
156
+ return normalizeDevelopmentEntryPath(configuredPath);
157
+ const loginExists = viewSource(root, "login") !== void 0 || viewSource(root, "auth/login") !== void 0, candidates = APPLICATION_VIEW_CANDIDATES.map((route) => ({ route, source: viewSource(root, route) })).filter((candidate) => candidate.source !== void 0), appCandidate = candidates.find((candidate) => usesApplicationLayout(candidate.source)) ?? (loginExists ? candidates[0] : void 0);
158
+ if (appCandidate)
159
+ return normalizeDevelopmentEntryPath(`/${appCandidate.route.replace(/\/index$/, "")}`);
160
+ return loginExists ? "/login" : "/";
161
+ }
162
+ export function developmentUrl(baseUrl, entryPath) {
163
+ const normalized = normalizeDevelopmentEntryPath(entryPath);
164
+ if (normalized === "/")
165
+ return baseUrl.replace(/\/$/, "");
166
+ return new URL(normalized, `${baseUrl.replace(/\/$/, "")}/`).toString();
167
+ }
168
+ export function developmentBrowserCommand(url, platform = process.platform) {
169
+ if (platform === "darwin")
170
+ return ["open", url];
171
+ if (platform === "win32")
172
+ return ["cmd", "/c", "start", "", url];
173
+ return ["xdg-open", url];
174
+ }
175
+ function openDevelopmentBrowser(url) {
176
+ if (!process.stdout.isTTY || process.env.CI || process.env.STACKS_DEV_NO_OPEN === "1")
177
+ return;
178
+ try {
179
+ Bun.spawn(developmentBrowserCommand(url), {
180
+ stdin: "ignore",
181
+ stdout: "ignore",
182
+ stderr: "ignore"
183
+ }).unref();
184
+ } catch {}
185
+ }
75
186
  async function canStartPrettyDevProxy() {
76
187
  if (await waitForHttpsProxy(443, 150))
77
188
  return !0;
@@ -96,11 +207,15 @@ export function dev(buddy) {
96
207
  interactive: "Get asked which development server to start",
97
208
  select: "Which development server are you trying to start?",
98
209
  withLocalhost: "Include the localhost URL in the output",
210
+ browser: "Open the application in a browser instead of its configured native window",
211
+ site: "Open the marketing site instead of the application",
99
212
  project: "Target a specific project",
100
213
  verbose: "Enable verbose output"
101
214
  };
102
- buddy.command("dev [server]", descriptions.dev).option("-f, --frontend", descriptions.frontend).option("-a, --api", descriptions.api).option("-c, --components", descriptions.components).option("-d, --dashboard", descriptions.dashboard).option("-k, --desktop", descriptions.desktop).option("-n, --native", descriptions.native).option("-o, --docs", descriptions.docs).option("-s, --system-tray", descriptions.systemTray).option("-i, --interactive", descriptions.interactive, { default: !1 }).option("-l, --with-localhost", descriptions.withLocalhost, { default: !1 }).option("-p, --project [project]", descriptions.project, { default: !1 }).option("--verbose", descriptions.verbose, { default: !1 }).action(async (server, options) => {
103
- const perf = Bun.nanoseconds(), target = server || (options.frontend ? "frontend" : void 0) || (options.api ? "api" : void 0) || (options.components ? "components" : void 0) || (options.dashboard ? "dashboard" : void 0) || (options.desktop ? "desktop" : void 0) || (options.native ? "native" : void 0) || (options.systemTray || options["system-tray"] ? "system-tray" : void 0) || (options.docs ? "docs" : void 0);
215
+ buddy.command("dev [server]", descriptions.dev).option("-f, --frontend", descriptions.frontend).option("-a, --api", descriptions.api).option("-c, --components", descriptions.components).option("-d, --dashboard", descriptions.dashboard).option("-k, --desktop", descriptions.desktop).option("-n, --native", descriptions.native).option("-o, --docs", descriptions.docs).option("-s, --system-tray", descriptions.systemTray).option("-i, --interactive", descriptions.interactive, { default: !1 }).option("-l, --with-localhost", descriptions.withLocalhost, { default: !1 }).option("--browser", descriptions.browser, { default: !1 }).option("--site", descriptions.site, { default: !1 }).option("-p, --project [project]", descriptions.project, { default: !1 }).option("--verbose", descriptions.verbose, { default: !1 }).action(async (server, options) => {
216
+ const perf = Bun.nanoseconds();
217
+ process.env.STACKS_DEV_ENTRY_PATH = resolveDevelopmentEntryPath({ site: options.site });
218
+ const target = server || (options.frontend ? "frontend" : void 0) || (options.api ? "api" : void 0) || (options.components ? "components" : void 0) || (options.dashboard ? "dashboard" : void 0) || (options.desktop ? "desktop" : void 0) || (options.native ? "native" : void 0) || (options.systemTray || options["system-tray"] ? "system-tray" : void 0) || (options.docs ? "docs" : void 0);
104
219
  if (target) {
105
220
  const serverOptions = { ...options }, a = await actions();
106
221
  switch (target) {
@@ -120,7 +235,7 @@ export function dev(buddy) {
120
235
  await a.runDashboardDevServer(serverOptions);
121
236
  break;
122
237
  case "desktop":
123
- await a.runDesktopDevServer(serverOptions);
238
+ await startDevelopmentServer({ ...serverOptions, native: !0 }, perf);
124
239
  break;
125
240
  case "system-tray":
126
241
  await a.runSystemTrayDevServer(serverOptions);
@@ -144,7 +259,7 @@ export function dev(buddy) {
144
259
  frontend: () => a.runFrontendDevServer(options),
145
260
  api: () => a.runApiDevServer(options),
146
261
  dashboard: () => a.runDashboardDevServer(options),
147
- desktop: () => a.runDesktopDevServer(options),
262
+ desktop: () => startDevelopmentServer({ ...options, native: !0 }, perf),
148
263
  native: () => startDevelopmentServer({ ...options, native: !0 }, perf),
149
264
  components: () => a.runComponentsDevServer(options),
150
265
  docs: () => a.runDocsDevServer(options)
@@ -186,14 +301,8 @@ export function dev(buddy) {
186
301
  await startDevelopmentServer({ ...options, native: !0 }, perf);
187
302
  });
188
303
  buddy.command("dev:desktop", descriptions.desktop).option("-p, --project [project]", descriptions.project, { default: !1 }).option("--verbose", descriptions.verbose, { default: !1 }).action(async (options) => {
189
- const perf = await intro("buddy dev:desktop"), result = await (await actions()).runAction(Action.DevDesktop, options);
190
- if (result.isErr) {
191
- await outro("While running the dev:desktop command, there was an issue", { startTime: perf, useSeconds: !0 }, result.error);
192
- process.exit(ExitCode.FatalError);
193
- }
194
- console.log("");
195
- await outro("Exited", { startTime: perf, useSeconds: !0 });
196
- process.exit(ExitCode.Success);
304
+ const perf = await intro("buddy dev:desktop");
305
+ await startDevelopmentServer({ ...options, native: !0 }, perf);
197
306
  });
198
307
  buddy.command("dev:api", descriptions.api).option("-p, --project [project]", descriptions.project, { default: !1 }).option("--no-watch-types", "Skip the model/config type-regeneration watcher", { default: !1 }).option("--verbose", descriptions.verbose, { default: !1 }).action(async (options) => {
199
308
  const a = await actions();
@@ -203,7 +312,8 @@ export function dev(buddy) {
203
312
  });
204
313
  await a.runApiDevServer(options);
205
314
  });
206
- buddy.command("dev:frontend", descriptions.frontend).alias("dev:pages").alias("dev:views").option("-p, --project [project]", descriptions.project, { default: !1 }).option("--verbose", descriptions.verbose, { default: !1 }).action(async (options) => {
315
+ buddy.command("dev:frontend", descriptions.frontend).alias("dev:pages").alias("dev:views").option("-p, --project [project]", descriptions.project, { default: !1 }).option("--site", descriptions.site, { default: !1 }).option("--verbose", descriptions.verbose, { default: !1 }).action(async (options) => {
316
+ process.env.STACKS_DEV_ENTRY_PATH = resolveDevelopmentEntryPath({ site: options.site });
207
317
  await (await actions()).runFrontendDevServer(options);
208
318
  });
209
319
  buddy.command("dev:dashboard", descriptions.dashboard).alias("dev:admin").option("-p, --project [project]", descriptions.project, { default: !1 }).option("--verbose", descriptions.verbose, { default: !1 }).action(async (options) => {
@@ -215,7 +325,13 @@ export function dev(buddy) {
215
325
  onUnknownSubcommand(buddy, "dev");
216
326
  }
217
327
  export async function startDevelopmentServer(_options, _startTime) {
218
- const options = _options, startedAt = _startTime, appUrl = process.env.APP_URL ?? "stacks.localhost", nativeMode = options.native === !0, proxyManagedExternally = process.env.STACKS_PROXY_MANAGED === "1", preferredFrontendPort = Number(process.env.PORT) || 3000, preferredApiPort = Number(process.env.PORT_API) || 3008, preferredDocsPort = Number(process.env.PORT_DOCS) || 3006, preferredDashboardPort = Number(process.env.PORT_ADMIN) || 3002;
328
+ const options = _options, startedAt = _startTime, appUrl = process.env.APP_URL ?? "stacks.localhost", nativeMode = resolveDevelopmentLaunch({
329
+ browser: options.browser,
330
+ native: options.native,
331
+ site: options.site
332
+ }) === "native", entryPath = resolveDevelopmentEntryPath({ site: options.site });
333
+ process.env.STACKS_DEV_ENTRY_PATH = entryPath;
334
+ const proxyManagedExternally = process.env.STACKS_PROXY_MANAGED === "1", preferredFrontendPort = Number(process.env.PORT) || 3000, preferredApiPort = Number(process.env.PORT_API) || 3008, preferredDocsPort = Number(process.env.PORT_DOCS) || 3006, preferredDashboardPort = Number(process.env.PORT_ADMIN) || 3002;
219
335
  if (process.env.STACKS_DEV_NO_KILL !== "1")
220
336
  await cleanupStaleDevProcesses([
221
337
  preferredFrontendPort,
@@ -240,7 +356,7 @@ export async function startDevelopmentServer(_options, _startTime) {
240
356
  localhostOnly,
241
357
  proxyManagedExternally,
242
358
  systemAuthorized
243
- }), prettySetupRequired = prettyUrlsRequested && !usePrettyUrls, hasCustomDomain = usePrettyUrls && !proxyManagedExternally, displayedDomain = usePrettyUrls ? domain : null, dashboardDomain = displayedDomain ? `dashboard.${displayedDomain}` : null, frontendUrl = displayedDomain ? `https://${displayedDomain}` : `http://localhost:${frontendPort}`, apiUrl = displayedDomain ? `https://${displayedDomain}/api` : `http://localhost:${apiPort}`, docsUrl = displayedDomain ? `https://${displayedDomain}/docs` : `http://localhost:${docsPort}`, dashboardUrl = dashboardDomain ? `https://${dashboardDomain}` : `http://localhost:${dashboardPort}`;
359
+ }), prettySetupRequired = prettyUrlsRequested && !usePrettyUrls, hasCustomDomain = usePrettyUrls && !proxyManagedExternally, displayedDomain = usePrettyUrls ? domain : null, dashboardDomain = displayedDomain ? `dashboard.${displayedDomain}` : null, frontendBaseUrl = displayedDomain ? `https://${displayedDomain}` : `http://localhost:${frontendPort}`, frontendUrl = developmentUrl(frontendBaseUrl, entryPath), apiUrl = displayedDomain ? `https://${displayedDomain}/api` : `http://localhost:${apiPort}`, docsUrl = displayedDomain ? `https://${displayedDomain}/docs` : `http://localhost:${docsPort}`, dashboardUrl = dashboardDomain ? `https://${dashboardDomain}` : `http://localhost:${dashboardPort}`;
244
360
  process.env.STACKS_PROXY_MANAGED = "1";
245
361
  process.env.STACKS_DEV_QUIET = "1";
246
362
  console.log();
@@ -297,7 +413,7 @@ export async function startDevelopmentServer(_options, _startTime) {
297
413
  { name: "API", port: apiPort }
298
414
  ], readinessTimeoutMs = 30000;
299
415
  let readyAnnounced = !1;
300
- const nativeUrl = `http://localhost:${frontendPort}`, nativeWindowReady = nativeMode ? waitForPort(frontendPort, readinessTimeoutMs).then(async (ready) => {
416
+ const nativeUrl = developmentUrl(`http://localhost:${frontendPort}`, entryPath), nativeWindowReady = nativeMode ? waitForPort(frontendPort, readinessTimeoutMs).then(async (ready) => {
301
417
  if (!ready) {
302
418
  log.warn(`Native window skipped because the frontend did not answer within ${readinessTimeoutMs / 1000}s`);
303
419
  return;
@@ -340,6 +456,8 @@ export async function startDevelopmentServer(_options, _startTime) {
340
456
  hasCustomDomain: !!appLooksCustom && !localhostOnly,
341
457
  proxyReachable: proxyManagedExternally ? !0 : proxyReachable,
342
458
  frontendUrl,
459
+ frontendBaseUrl,
460
+ entryPath,
343
461
  apiUrl,
344
462
  docsUrl,
345
463
  dashboardUrl,
@@ -351,6 +469,10 @@ export async function startDevelopmentServer(_options, _startTime) {
351
469
  dashboardPort,
352
470
  dashboardDomain
353
471
  });
472
+ if (!nativeMode) {
473
+ const browserUrl = hasCustomDomain && (proxyManagedExternally || proxyReachable) ? frontendUrl : developmentUrl(`http://localhost:${frontendPort}`, entryPath);
474
+ openDevelopmentBrowser(browserUrl);
475
+ }
354
476
  if (startedAt) {
355
477
  const elapsedMs = (Bun.nanoseconds() - startedAt) / 1e6, summary = failed.length ? `ready in ${(elapsedMs / 1000).toFixed(1)}s \u2014 ${failed.join(", ")} did not bind within ${readinessTimeoutMs / 1000}s` : `ready in ${(elapsedMs / 1000).toFixed(1)}s`;
356
478
  console.log(` ${dim(summary)}`);
@@ -409,6 +531,8 @@ function printDevReadyBanner(input) {
409
531
  hasCustomDomain,
410
532
  proxyReachable,
411
533
  frontendUrl,
534
+ frontendBaseUrl,
535
+ entryPath,
412
536
  apiUrl,
413
537
  docsUrl,
414
538
  dashboardUrl,
@@ -419,9 +543,12 @@ function printDevReadyBanner(input) {
419
543
  domain,
420
544
  dashboardPort,
421
545
  dashboardDomain
422
- } = 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`;
546
+ } = input, verbose = options.verbose ?? !1, useProxy = hasCustomDomain && proxyReachable, feUrl = useProxy ? frontendUrl : developmentUrl(`http://localhost:${frontendPort}`, entryPath), feBaseUrl = useProxy ? frontendBaseUrl : `http://localhost:${frontendPort}`, apUrl = useProxy ? apiUrl : `http://localhost:${apiPort}`, dcUrl = useProxy ? docsUrl : `http://localhost:${docsPort}`, dbUrl = useProxy ? dashboardUrl : `http://localhost:${dashboardPort}`, blogUrl = `${feBaseUrl}/blog`;
423
547
  console.log();
424
- console.log(` ${green("\u279C")} ${bold("Frontend")}: ${cyan(feUrl)}`);
548
+ const frontendLabel = entryPath === "/" ? "Site" : "App";
549
+ console.log(` ${green("\u279C")} ${bold(frontendLabel.padEnd(8))}: ${cyan(feUrl)}`);
550
+ if (entryPath !== "/")
551
+ console.log(` ${dim("\u279C")} ${dim("Site")}: ${dim(feBaseUrl)}`);
425
552
  if (nativeMode)
426
553
  console.log(` ${green("\u279C")} ${bold("Native")}: ${cyan(`Craft \u2192 ${feUrl}`)}`);
427
554
  console.log(` ${green("\u279C")} ${bold("API")}: ${cyan(apUrl)}`);
@@ -431,7 +558,7 @@ function printDevReadyBanner(input) {
431
558
  if (includeDashboard)
432
559
  console.log(` ${green("\u279C")} ${bold("Dashboard")}: ${cyan(dbUrl)}`);
433
560
  if (useProxy) {
434
- console.log(` ${dim("\u279C")} ${dim("Direct")}: ${dim(`http://localhost:${frontendPort} (bypasses the proxy)`)}`);
561
+ console.log(` ${dim("\u279C")} ${dim("Direct")}: ${dim(`${developmentUrl(`http://localhost:${frontendPort}`, entryPath)} (bypasses the proxy)`)}`);
435
562
  if (includeDashboard && dashboardDomain)
436
563
  console.log(` ${dim("\u279C")} ${dim("Direct")}: ${dim(`http://localhost:${dashboardPort} (dashboard, bypasses the proxy)`)}`);
437
564
  }
@@ -441,8 +568,8 @@ function printDevReadyBanner(input) {
441
568
  }
442
569
  if (verbose && domain) {
443
570
  console.log(` ${dim("\u279C")} ${dim("Proxy")}: ${dim(`localhost:${frontendPort} \u2192 ${domain}`)}`);
444
- console.log(` ${dim("\u279C")} ${dim("Proxy")}: ${dim(`${frontendUrl}/api \u2192 localhost:${apiPort}`)}`);
445
- console.log(` ${dim("\u279C")} ${dim("Proxy")}: ${dim(`${frontendUrl}/docs \u2192 localhost:${docsPort}`)}`);
571
+ console.log(` ${dim("\u279C")} ${dim("Proxy")}: ${dim(`${frontendBaseUrl}/api \u2192 localhost:${apiPort}`)}`);
572
+ console.log(` ${dim("\u279C")} ${dim("Proxy")}: ${dim(`${frontendBaseUrl}/docs \u2192 localhost:${docsPort}`)}`);
446
573
  if (includeDashboard)
447
574
  console.log(` ${dim("\u279C")} ${dim("Proxy")}: ${dim(`localhost:${dashboardPort} \u2192 ${dashboardDomain}`)}`);
448
575
  }
@@ -177,6 +177,26 @@ export function doctor(buddy) {
177
177
  const example = `CREATE UNIQUE INDEX IF NOT EXISTS "${first.table}_${first.columns.join("_")}_unique" ON "${first.table}" ("${first.columns.join('", "')}")`;
178
178
  throw Error(`${result.missing.length}/${result.declared.length} declared unique constraints have no UNIQUE index: ${sample}${more}. Run \`buddy migrate\` (re-queues missing unique-index migrations, #1952) \u2014 dedupe duplicate rows first or migrate hard-fails; if no migration file exists run \`buddy generate:migrations\`, or create manually: ${example}`);
179
179
  }, 1e4);
180
+ await probe(checks, "Migration ledger", async () => {
181
+ const { auditMigrationLedger } = await import("@stacksjs/database"), result = await auditMigrationLedger();
182
+ if (!result.supported)
183
+ return "Dialect not audited (skipped)";
184
+ if (result.entries.length === 0)
185
+ return "No migration files";
186
+ const { counts, orphans } = result;
187
+ if (!result.drift)
188
+ return `${result.entries.length} migrations, ledger consistent with the schema`;
189
+ const parts = [];
190
+ if (counts.stranded > 0)
191
+ parts.push(`${counts.stranded} applied but unrecorded (would re-run)`);
192
+ if (counts.partial > 0)
193
+ parts.push(`${counts.partial} half-applied`);
194
+ if (counts.reverted > 0)
195
+ parts.push(`${counts.reverted} recorded but missing from the schema`);
196
+ if (orphans.length > 0)
197
+ parts.push(`${orphans.length} ledger row(s) with no file on disk`);
198
+ throw Error(`Migration ledger has drifted: ${parts.join(", ")}. Inspect with \`buddy migrate:status\`, repair with \`buddy migrate:status --reconcile\`.`);
199
+ }, 1e4);
180
200
  await probe(checks, "FK orphans", async () => {
181
201
  const { findFkOrphans } = await import("@stacksjs/database"), result = await findFkOrphans();
182
202
  if (!result.supported)
@@ -553,9 +553,90 @@ export function migrate(buddy) {
553
553
  process.exit(ExitCode.FatalError);
554
554
  }
555
555
  log.success(`Wrote ${result.value.files.length} ${target} migration file(s) to database/migrations.`);
556
+ if (applied > 0) {
557
+ const { reconcileMigrationLedger } = await import("@stacksjs/database"), fixed = await reconcileMigrationLedger();
558
+ if (fixed.remapped.length > 0)
559
+ log.info(`Repointed ${fixed.remapped.length} ledger row(s) at their renumbered file.`);
560
+ if (fixed.recorded.length > 0)
561
+ log.info(`Recorded ${fixed.recorded.length} migration(s) already present in the schema.`);
562
+ if (fixed.skipped.length > 0)
563
+ log.warn(`${fixed.skipped.length} ledger entr(ies) need a look \u2014 run \`./buddy migrate:status\`.`);
564
+ }
556
565
  log.info("Review the change with `git diff`, then run `./buddy migrate`.");
557
566
  await outro("Regenerated.", { startTime: perf, useSeconds: !0 });
558
567
  process.exit(ExitCode.Success);
559
568
  });
569
+ buddy.command("migrate:status", "Compare database/migrations, the migrations ledger, and the live schema").option("--reconcile", "Repair the ledger where the schema proves what happened", { default: !1 }).option("--include-partial", "With --reconcile, also record half-applied migrations", { default: !1 }).option("--json", "Emit the audit as JSON", { default: !1 }).action(async (options) => {
570
+ const perf = options.json ? void 0 : await intro("buddy migrate:status"), { auditMigrationLedger, reconcileMigrationLedger } = await import("@stacksjs/database"), audit = await auditMigrationLedger();
571
+ if (options.json) {
572
+ console.log(JSON.stringify(audit, (_k, v) => v instanceof Set ? [...v] : v, 2));
573
+ process.exit(audit.drift ? ExitCode.FatalError : ExitCode.Success);
574
+ }
575
+ if (!audit.supported) {
576
+ log.info(`Dialect "${audit.dialect}" is not audited. Nothing to compare.`);
577
+ await outro("Skipped.", { startTime: perf, useSeconds: !0 });
578
+ process.exit(ExitCode.Success);
579
+ }
580
+ const { counts, entries, orphans } = audit, list = (status) => entries.filter((e) => e.status === status).map((e) => e.file), report = [], section = (heading, files) => {
581
+ if (files.length === 0)
582
+ return;
583
+ if (heading)
584
+ report.push(` ${heading}`);
585
+ for (const file of files.slice(0, 8))
586
+ report.push(` ${file}`);
587
+ if (files.length > 8)
588
+ report.push(` \u2026 +${files.length - 8} more`);
589
+ report.push("");
590
+ };
591
+ report.push("");
592
+ report.push(` Migration status: ${audit.dialect}`);
593
+ report.push(" \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");
594
+ report.push(` ${entries.length} file(s) on disk \xB7 ${audit.recordedCount} recorded in the ledger`);
595
+ report.push("");
596
+ if (counts.applied > 0)
597
+ report.push(` ${counts.applied} applied - recorded, and present in the schema.`, "");
598
+ if (counts.unverifiable > 0)
599
+ report.push(` ${counts.unverifiable} unverifiable - data migrations with no schema trace to check.`, "");
600
+ section(`${counts.pending} pending - not applied yet, will run on the next \`buddy migrate\`:`, list("pending"));
601
+ if (counts.stranded > 0) {
602
+ report.push(` ${counts.stranded} STRANDED - already applied to the schema, but missing from the ledger.`);
603
+ report.push(" These re-run on the next `buddy migrate`, which is unsafe for anything not idempotent.");
604
+ section("", list("stranded"));
605
+ }
606
+ section(`${counts.partial} PARTIAL - some effects present, some missing. Needs a human:`, list("partial"));
607
+ section(`${counts.reverted} REVERTED - recorded as applied, but the effects are gone from the schema:`, list("reverted"));
608
+ if (orphans.length > 0)
609
+ section(`${orphans.length} orphaned ledger row(s) - recorded, but no such file on disk:`, orphans.map((o) => `${o.migration}${o.renamedTo ? ` -> renumbered to ${o.renamedTo}` : " (no counterpart; migration deleted?)"}`));
610
+ console.log(report.join(`
611
+ `));
612
+ if (!audit.drift) {
613
+ await outro("Ledger matches the corpus and the schema.", { startTime: perf, useSeconds: !0 });
614
+ process.exit(ExitCode.Success);
615
+ }
616
+ if (!options.reconcile) {
617
+ console.log(`
618
+ Repair with: ./buddy migrate:status --reconcile
619
+ That repoints renumbered ledger rows and records migrations the schema
620
+ already proves. It never runs SQL from a migration file.
621
+ `);
622
+ await outro("Drift detected.", { startTime: perf, useSeconds: !0 });
623
+ process.exit(ExitCode.FatalError);
624
+ }
625
+ const fixed = await reconcileMigrationLedger({ includePartial: options.includePartial });
626
+ if (fixed.remapped.length > 0)
627
+ log.success(`Repointed ${fixed.remapped.length} ledger row(s) at their renumbered file.`);
628
+ if (fixed.recorded.length > 0)
629
+ log.success(`Recorded ${fixed.recorded.length} migration(s) the schema already reflects.`);
630
+ if (fixed.skipped.length > 0) {
631
+ log.warn(`Left ${fixed.skipped.length} entr(ies) alone:`);
632
+ console.log(fixed.skipped.slice(0, 8).map((s) => ` ${s.file} \u2014 ${s.reason}`).join(`
633
+ `) + (fixed.skipped.length > 8 ? `
634
+ \u2026 +${fixed.skipped.length - 8} more` : ""));
635
+ }
636
+ if (fixed.remapped.length === 0 && fixed.recorded.length === 0)
637
+ log.info("Nothing could be repaired automatically.");
638
+ await outro("Reconciled.", { startTime: perf, useSeconds: !0 });
639
+ process.exit(ExitCode.Success);
640
+ });
560
641
  onUnknownSubcommand(buddy, "migrate");
561
642
  }
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.251",
5
+ "version": "0.70.253",
6
6
  "description": "Meet Buddy. The Stacks runtime.",
7
7
  "author": "Chris Breuer",
8
8
  "contributors": [
@@ -88,53 +88,53 @@
88
88
  "prepublishOnly": "bun run build"
89
89
  },
90
90
  "dependencies": {
91
- "@stacksjs/actions": "^0.70.251",
92
- "@stacksjs/ai": "^0.70.251",
93
- "@stacksjs/alias": "^0.70.251",
94
- "@stacksjs/arrays": "^0.70.251",
95
- "@stacksjs/auth": "^0.70.251",
96
- "@stacksjs/build": "^0.70.251",
97
- "@stacksjs/cache": "^0.70.251",
98
- "@stacksjs/cli": "^0.70.251",
91
+ "@stacksjs/actions": "^0.70.253",
92
+ "@stacksjs/ai": "^0.70.253",
93
+ "@stacksjs/alias": "^0.70.253",
94
+ "@stacksjs/arrays": "^0.70.253",
95
+ "@stacksjs/auth": "^0.70.253",
96
+ "@stacksjs/build": "^0.70.253",
97
+ "@stacksjs/cache": "^0.70.253",
98
+ "@stacksjs/cli": "^0.70.253",
99
99
  "@stacksjs/clapp": "^0.2.12",
100
- "@stacksjs/cloud": "^0.70.251",
101
- "@stacksjs/collections": "^0.70.251",
102
- "@stacksjs/config": "^0.70.251",
103
- "@stacksjs/database": "^0.70.251",
104
- "@stacksjs/desktop-build": "^0.70.251",
105
- "@stacksjs/dns": "^0.70.251",
106
- "@stacksjs/email": "^0.70.251",
107
- "@stacksjs/enums": "^0.70.251",
108
- "@stacksjs/error-handling": "^0.70.251",
109
- "@stacksjs/events": "^0.70.251",
110
- "@stacksjs/git": "^0.70.251",
100
+ "@stacksjs/cloud": "^0.70.253",
101
+ "@stacksjs/collections": "^0.70.253",
102
+ "@stacksjs/config": "^0.70.253",
103
+ "@stacksjs/database": "^0.70.253",
104
+ "@stacksjs/desktop-build": "^0.70.253",
105
+ "@stacksjs/dns": "^0.70.253",
106
+ "@stacksjs/email": "^0.70.253",
107
+ "@stacksjs/enums": "^0.70.253",
108
+ "@stacksjs/error-handling": "^0.70.253",
109
+ "@stacksjs/events": "^0.70.253",
110
+ "@stacksjs/git": "^0.70.253",
111
111
  "@stacksjs/gitit": "^0.2.5",
112
- "@stacksjs/health": "^0.70.251",
112
+ "@stacksjs/health": "^0.70.253",
113
113
  "@stacksjs/dnsx": "^0.2.3",
114
114
  "@stacksjs/httx": "^0.1.10",
115
- "@stacksjs/image": "^0.70.251",
116
- "@stacksjs/lint": "^0.70.251",
117
- "@stacksjs/logging": "^0.70.251",
118
- "@stacksjs/notifications": "^0.70.251",
119
- "@stacksjs/objects": "^0.70.251",
120
- "@stacksjs/orm": "^0.70.251",
121
- "@stacksjs/path": "^0.70.251",
122
- "@stacksjs/skills": "^0.70.251",
123
- "@stacksjs/payments": "^0.70.251",
124
- "@stacksjs/realtime": "^0.70.251",
125
- "@stacksjs/router": "^0.70.251",
115
+ "@stacksjs/image": "^0.70.253",
116
+ "@stacksjs/lint": "^0.70.253",
117
+ "@stacksjs/logging": "^0.70.253",
118
+ "@stacksjs/notifications": "^0.70.253",
119
+ "@stacksjs/objects": "^0.70.253",
120
+ "@stacksjs/orm": "^0.70.253",
121
+ "@stacksjs/path": "^0.70.253",
122
+ "@stacksjs/skills": "^0.70.253",
123
+ "@stacksjs/payments": "^0.70.253",
124
+ "@stacksjs/realtime": "^0.70.253",
125
+ "@stacksjs/router": "^0.70.253",
126
126
  "@stacksjs/rpx": "^0.11.42",
127
- "@stacksjs/search-engine": "^0.70.251",
128
- "@stacksjs/security": "^0.70.251",
129
- "@stacksjs/server": "^0.70.251",
130
- "@stacksjs/storage": "^0.70.251",
131
- "@stacksjs/strings": "^0.70.251",
132
- "@stacksjs/testing": "^0.70.251",
133
- "@stacksjs/tunnel": "^0.70.251",
134
- "@stacksjs/types": "^0.70.251",
135
- "@stacksjs/ui": "^0.70.251",
136
- "@stacksjs/utils": "^0.70.251",
137
- "@stacksjs/validation": "^0.70.251",
127
+ "@stacksjs/search-engine": "^0.70.253",
128
+ "@stacksjs/security": "^0.70.253",
129
+ "@stacksjs/server": "^0.70.253",
130
+ "@stacksjs/storage": "^0.70.253",
131
+ "@stacksjs/strings": "^0.70.253",
132
+ "@stacksjs/testing": "^0.70.253",
133
+ "@stacksjs/tunnel": "^0.70.253",
134
+ "@stacksjs/types": "^0.70.253",
135
+ "@stacksjs/ui": "^0.70.253",
136
+ "@stacksjs/utils": "^0.70.253",
137
+ "@stacksjs/validation": "^0.70.253",
138
138
  "@stacksjs/ts-cloud": "^0.7.92",
139
139
  "ajv": "^8.20.0",
140
140
  "ajv-formats": "^3.0.1",