@stacksjs/buddy 0.70.222 → 0.70.224

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.
@@ -76,6 +76,12 @@ export declare function loadTsCloudDeployApi(): Promise<typeof import('@stacksjs
76
76
  * port declaration is left alone.
77
77
  */
78
78
  export declare function shouldInjectManagementDashboard(tsCloudConfig: any): boolean;
79
+ /**
80
+ * Read a tenant's existing shared-server pin without requiring provider API
81
+ * credentials. The pin contains only compute metadata and is sufficient for
82
+ * an SSH release to a box that the owner project already provisioned.
83
+ */
84
+ export declare function resolvePersistedAttachTargetBox(tsCloudConfig: any, environment: string, cwd?: unknown): AttachedComputeBox | null;
79
85
  export declare function scrubLoopbackSitePortsForFirewall(tsCloudConfig: any): any;
80
86
  /**
81
87
  * Mail tenancy is an explicit deployment capability. The merged Stacks config
@@ -141,6 +147,12 @@ declare const log: {
141
147
  error: (...args: any[]) => unknown;
142
148
  debug: (...args: any[]) => void
143
149
  };
150
+ declare interface AttachedComputeBox {
151
+ serverId: number
152
+ serverName: string
153
+ publicIp: string
154
+ publicIpv6?: string
155
+ }
144
156
  /** What a mail-tenant reconcile resolved + provisioned, for the DNS step. */
145
157
  export declare interface MailTenantResult {
146
158
  domain: string
@@ -534,8 +534,8 @@ export function applyEnvironmentToSites(sites, environment, config) {
534
534
  return out;
535
535
  }
536
536
  async function deployToHetzner(tsCloudConfig, deployEnv, options) {
537
- const verbose = options.verbose === !0, environment = deployEnv === "prod" ? "production" : deployEnv;
538
- if (!(tsCloudConfig.hetzner?.apiToken || process.env.HCLOUD_TOKEN || process.env.HETZNER_API_TOKEN)) {
537
+ const verbose = options.verbose === !0, environment = deployEnv === "prod" ? "production" : deployEnv, apiToken = tsCloudConfig.hetzner?.apiToken || process.env.HCLOUD_TOKEN || process.env.HETZNER_API_TOKEN, persistedAttachBox = resolvePersistedAttachTargetBox(tsCloudConfig, environment);
538
+ if (!apiToken && !persistedAttachBox) {
539
539
  log.error("No Hetzner API token found. Set HCLOUD_TOKEN in your .env (or hetzner.apiToken in config/cloud.ts).");
540
540
  process.exit(ExitCode.FatalError);
541
541
  }
@@ -548,7 +548,7 @@ async function deployToHetzner(tsCloudConfig, deployEnv, options) {
548
548
  }
549
549
  const { createCloudDriver, deployAllComputeSites, ensureManagementDashboard, resolveSiteKind } = await loadTsCloudDeployApi();
550
550
  try {
551
- await runHetznerDeploy({ tsCloudConfig, environment, verbose, docker: options.docker === !0, createCloudDriver, deployAllComputeSites, ensureManagementDashboard, 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, persistedAttachBox });
552
552
  } catch (err) {
553
553
  log.error("Hetzner deploy failed:");
554
554
  console.error(err instanceof Error ? err.stack || err.message : err);
@@ -567,6 +567,27 @@ export async function loadTsCloudDeployApi() {
567
567
  export function shouldInjectManagementDashboard(tsCloudConfig) {
568
568
  return !tsCloudConfig.cloud?.attachTo;
569
569
  }
570
+ export function resolvePersistedAttachTargetBox(tsCloudConfig, environment, cwd = process.cwd()) {
571
+ const owner = tsCloudConfig.cloud?.attachTo;
572
+ if (!owner)
573
+ return null;
574
+ const stackName = tsCloudConfig.project?.stackName || `${tsCloudConfig.project?.slug || "app"}-${environment}`, statePath = join(cwd, "storage", "cloud", "state", `${stackName}.json`);
575
+ if (!existsSync(statePath))
576
+ return null;
577
+ try {
578
+ const state = JSON.parse(readFileSync(statePath, "utf8"));
579
+ if (state.stackName !== stackName || typeof state.serverId !== "number" || typeof state.publicIp !== "string" || !state.publicIp.trim())
580
+ return null;
581
+ return {
582
+ serverId: state.serverId,
583
+ serverName: typeof state.serverName === "string" && state.serverName ? state.serverName : `${owner}-${environment}-app`,
584
+ publicIp: state.publicIp,
585
+ publicIpv6: typeof state.publicIpv6 === "string" ? state.publicIpv6 : void 0
586
+ };
587
+ } catch {
588
+ return null;
589
+ }
590
+ }
570
591
  export function scrubLoopbackSitePortsForFirewall(tsCloudConfig) {
571
592
  const sites = tsCloudConfig?.sites;
572
593
  if (!sites)
@@ -665,7 +686,7 @@ async function startGithubDeployments(args) {
665
686
  return records;
666
687
  }
667
688
  async function runHetznerDeploy(args) {
668
- const { tsCloudConfig, environment, verbose, docker, createCloudDriver, deployAllComputeSites, ensureManagementDashboard, resolveSiteKind, onlySite } = args, startTime = performance.now();
689
+ const { tsCloudConfig, environment, verbose, docker, createCloudDriver, deployAllComputeSites, ensureManagementDashboard, resolveSiteKind, onlySite, persistedAttachBox } = args, startTime = performance.now();
669
690
  console.log("");
670
691
  console.log("\uD83D\uDE80 Deploy \u2192 Hetzner Cloud");
671
692
  console.log("");
@@ -692,7 +713,7 @@ async function runHetznerDeploy(args) {
692
713
  const attachTo = tsCloudConfig.cloud?.attachTo;
693
714
  let ip, ipv6;
694
715
  if (attachTo) {
695
- const box = await resolveAttachTargetBox(attachTo, environment);
716
+ const box = persistedAttachBox ?? await resolveAttachTargetBox(attachTo, environment);
696
717
  if (!box?.publicIp) {
697
718
  log.error(`Attach target '${attachTo}' has no reachable box for '${environment}'. Is '${attachTo}-${environment}-app' provisioned (by its owner)?`);
698
719
  process.exit(ExitCode.FatalError);
@@ -19,6 +19,7 @@ export declare function resolveUserPartialsPath(cwd?: unknown, configuredDir?: s
19
19
  * it {@link resolveUserPartialsPath} falls back to the conventions.
20
20
  */
21
21
  export declare function loadStxPartialsDir(cwd?: unknown): Promise<string | undefined>;
22
+ export declare function startProductionServer(options?: { port?: string | number, verbose?: boolean }): Promise<void>;
22
23
  export declare function serve(buddy: CLI): void;
23
24
  /**
24
25
  * `buddy serve:api` — boot the production API server (bun-router routes).
@@ -90,80 +90,81 @@ function resolveCsrfMiddlewarePath() {
90
90
  return vendored;
91
91
  }
92
92
  }
93
- export function serve(buddy) {
94
- buddy.command("serve", "Start the production HTTP server (STX views + /api proxy + coming-soon/maintenance gate)").option("-p, --port <port>", "Port to listen on (defaults to PORT env or 3000)").option("--verbose", "Enable verbose output", { default: !1 }).action(async (options) => {
95
- if (options?.port)
96
- process.env.PORT = String(options.port);
97
- process.env.APP_ENV = process.env.APP_ENV || "production";
98
- const port = Number(process.env.PORT) || 3000, { config, overridesReady } = await import("@stacksjs/config");
99
- await overridesReady;
100
- const { injectGlobalAutoImports } = await import("@stacksjs/server");
101
- await injectGlobalAutoImports();
102
- let stxServe;
103
- const serveCandidates = [
104
- join(homedir(), "Code/Tools/stx/packages/bun-plugin/dist/serve.js"),
105
- join(process.cwd(), "pantry/bun-plugin-stx/dist/serve.js")
106
- ];
107
- for (const entry of serveCandidates)
108
- try {
109
- if (existsSync(entry)) {
110
- ({ serve: stxServe } = await import(entry));
111
- break;
112
- }
113
- } catch {}
114
- if (!stxServe)
115
- ({ serve: stxServe } = await import("bun-plugin-stx/serve"));
116
- const stxModule = await resolveVendoredStxModule(), { site: siteConfig, i18n: i18nConfig } = await loadStxSiteConfig(), userViewsPath = "resources/views", defaultsResources = resolveDefaultsResources(), defaultViewsPath = join(defaultsResources, "views"), userLayoutsPath = existsSync("resources/views/layouts") ? "resources/views/layouts" : "resources/layouts", userPartialsPath = resolveUserPartialsPath(process.cwd(), await loadStxPartialsDir()), apiBase = process.env.API_URL || `http://127.0.0.1:${Number(process.env.PORT_API) || config.ports?.api || 3008}`;
117
- log.info(`Starting production server on port ${port}...`);
118
- await stxServe({
119
- patterns: [userViewsPath, defaultViewsPath],
120
- port,
121
- autoIncrementPort: !1,
122
- reusePort: ["production", "staging", "development"].includes((process.env.APP_ENV || "").toLowerCase()),
123
- componentsDir: join(defaultsResources, "components"),
124
- layoutsDir: userLayoutsPath,
125
- ...userPartialsPath && { partialsDir: userPartialsPath },
126
- fallbackLayoutsDir: join(defaultsResources, "layouts"),
127
- fallbackPartialsDir: defaultViewsPath,
128
- quiet: options?.verbose !== !0,
129
- ...stxModule && { stxModule },
130
- ...i18nConfig && { i18n: i18nConfig },
131
- ...siteConfig?.url && { site: siteConfig },
132
- onRequest: async (req) => {
133
- const { maintenanceGate, isApiBoundRequest, proxyToBackend } = await import("@stacksjs/server"), gated = await maintenanceGate(req);
134
- if (gated)
135
- return gated;
136
- const url = new URL(req.url);
137
- if (isApiBoundRequest(req, url.pathname))
138
- try {
139
- return await proxyToBackend(req, apiBase);
140
- } catch (error) {
141
- log.error(`API proxy to ${apiBase} failed: ${error.message}`);
142
- return new Response("Bad Gateway", { status: 502 });
143
- }
144
- if (existsSync(join(process.cwd(), "resources/views/blog.stx"))) {
145
- const { renderBlogFeed } = await import("@stacksjs/actions/blog"), feed = await renderBlogFeed(req);
146
- if (feed)
147
- return feed;
148
- }
149
- globalThis.__stxServeSearch = url.search;
150
- globalThis.__stxServeCookies = parseCookies(req);
151
- return;
152
- },
153
- onResponse: async (req, response) => {
154
- const method = req.method.toUpperCase();
155
- if (method !== "GET" && method !== "HEAD")
156
- return;
93
+ export async function startProductionServer(options) {
94
+ if (options?.port)
95
+ process.env.PORT = String(options.port);
96
+ process.env.APP_ENV = process.env.APP_ENV || "production";
97
+ const port = Number(process.env.PORT) || 3000, { config, overridesReady } = await import("@stacksjs/config");
98
+ await overridesReady;
99
+ const { injectGlobalAutoImports } = await import("@stacksjs/server");
100
+ await injectGlobalAutoImports();
101
+ let stxServe;
102
+ const serveCandidates = [
103
+ join(homedir(), "Code/Tools/stx/packages/bun-plugin/dist/serve.js"),
104
+ join(process.cwd(), "pantry/bun-plugin-stx/dist/serve.js")
105
+ ];
106
+ for (const entry of serveCandidates)
107
+ try {
108
+ if (existsSync(entry)) {
109
+ ({ serve: stxServe } = await import(entry));
110
+ break;
111
+ }
112
+ } catch {}
113
+ if (!stxServe)
114
+ ({ serve: stxServe } = await import("bun-plugin-stx/serve"));
115
+ const stxModule = await resolveVendoredStxModule(), { site: siteConfig, i18n: i18nConfig } = await loadStxSiteConfig(), userViewsPath = "resources/views", defaultsResources = resolveDefaultsResources(), defaultViewsPath = join(defaultsResources, "views"), userLayoutsPath = existsSync("resources/views/layouts") ? "resources/views/layouts" : "resources/layouts", userPartialsPath = resolveUserPartialsPath(process.cwd(), await loadStxPartialsDir()), apiBase = process.env.API_URL || `http://127.0.0.1:${Number(process.env.PORT_API) || config.ports?.api || 3008}`;
116
+ log.info(`Starting production server on port ${port}...`);
117
+ await stxServe({
118
+ patterns: [userViewsPath, defaultViewsPath],
119
+ port,
120
+ autoIncrementPort: !1,
121
+ reusePort: ["production", "staging", "development"].includes((process.env.APP_ENV || "").toLowerCase()),
122
+ componentsDir: join(defaultsResources, "components"),
123
+ layoutsDir: userLayoutsPath,
124
+ ...userPartialsPath && { partialsDir: userPartialsPath },
125
+ fallbackLayoutsDir: join(defaultsResources, "layouts"),
126
+ fallbackPartialsDir: defaultViewsPath,
127
+ quiet: options?.verbose !== !0,
128
+ ...stxModule && { stxModule },
129
+ ...i18nConfig && { i18n: i18nConfig },
130
+ ...siteConfig?.url && { site: siteConfig },
131
+ onRequest: async (req) => {
132
+ const { maintenanceGate, isApiBoundRequest, proxyToBackend } = await import("@stacksjs/server"), gated = await maintenanceGate(req);
133
+ if (gated)
134
+ return gated;
135
+ const url = new URL(req.url);
136
+ if (isApiBoundRequest(req, url.pathname))
157
137
  try {
158
- const { seedCsrfCookieIfMissing } = await import(resolveCsrfMiddlewarePath());
159
- return seedCsrfCookieIfMissing(req, response);
138
+ return await proxyToBackend(req, apiBase);
160
139
  } catch (error) {
161
- log.debug(`CSRF cookie seeding skipped: ${error.message}`);
140
+ log.error(`API proxy to ${apiBase} failed: ${error.message}`);
141
+ return new Response("Bad Gateway", { status: 502 });
162
142
  }
143
+ if (existsSync(join(process.cwd(), "resources/views/blog.stx"))) {
144
+ const { renderBlogFeed } = await import("@stacksjs/actions/blog"), feed = await renderBlogFeed(req);
145
+ if (feed)
146
+ return feed;
163
147
  }
164
- });
165
- log.success(`Production server listening on http://0.0.0.0:${port}`);
148
+ globalThis.__stxServeSearch = url.search;
149
+ globalThis.__stxServeCookies = parseCookies(req);
150
+ return;
151
+ },
152
+ onResponse: async (req, response) => {
153
+ const method = req.method.toUpperCase();
154
+ if (method !== "GET" && method !== "HEAD")
155
+ return;
156
+ try {
157
+ const { seedCsrfCookieIfMissing } = await import(resolveCsrfMiddlewarePath());
158
+ return seedCsrfCookieIfMissing(req, response);
159
+ } catch (error) {
160
+ log.debug(`CSRF cookie seeding skipped: ${error.message}`);
161
+ }
162
+ }
166
163
  });
164
+ log.success(`Production server listening on http://0.0.0.0:${port}`);
165
+ }
166
+ export function serve(buddy) {
167
+ buddy.command("serve", "Start the production HTTP server (STX views + /api proxy + coming-soon/maintenance gate)").option("-p, --port <port>", "Port to listen on (defaults to PORT env or 3000)").option("--verbose", "Enable verbose output", { default: !1 }).action(startProductionServer);
167
168
  }
168
169
  export function serveApi(buddy) {
169
170
  buddy.command("serve:api", "Start the production API server (bun-router routes the frontend proxies /api to)").option("-p, --port <port>", "Port to listen on (defaults to PORT env or 3008)").action(async (options) => {
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.222",
5
+ "version": "0.70.224",
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.90",
140
+ "@stacksjs/ts-cloud": "^0.7.91",
141
141
  "ajv": "^8.20.0",
142
142
  "ajv-formats": "^3.0.1",
143
143
  "ts-pantry": "^0.11.0"