create-nextblock 0.12.3 → 0.12.5

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.
@@ -15,6 +15,19 @@ CRON_SECRET=replace-me-generated
15
15
  DRAFT_MODE_SECRET=replace-me-generated
16
16
  REVALIDATE_SECRET_TOKEN=replace-me-generated
17
17
 
18
+ # --- Host port mappings ----------------------------------------------------------------------
19
+ # The published host ports for each service. docker:setup auto-picks a FREE port when a default
20
+ # is taken or reserved by the OS (common on Windows, e.g. Postgres 54322), then keeps the URLs
21
+ # below and LOOPBACK_PROXIES in lockstep. Change a port here only if you also update its URL.
22
+ APP_PORT=3000
23
+ KONG_HTTP_PORT=8000
24
+ MINIO_S3_PORT=9000
25
+ MINIO_CONSOLE_PORT=9001
26
+ POSTGRES_PORT_EXTERNAL=54322
27
+ # In-container loopback (socat): maps the localhost:<port> server code derives from the public
28
+ # URLs to the real service. Left-hand ports MUST match NEXT_PUBLIC_SUPABASE_URL / R2_S3_PUBLIC_ENDPOINT.
29
+ LOOPBACK_PROXIES=8000:kong:8000 9000:minio:9000
30
+
18
31
  # --- Supabase wiring (internal container network vs browser) ---------------------------------
19
32
  NEXT_PUBLIC_SUPABASE_URL=http://localhost:8000
20
33
  NEXT_PUBLIC_SUPABASE_ANON_KEY=${ANON_KEY}
@@ -182,8 +182,13 @@ services:
182
182
  NODE_ENV: production
183
183
  PORT: 3000
184
184
  HOSTNAME: 0.0.0.0
185
- # Browser AND server both use localhost:8000; the in-container loopback proxy (see Dockerfile)
186
- # forwards it to Kong, keeping the host-derived Supabase auth cookie key consistent for SSR.
185
+ # In-container loopback proxies (see Dockerfile entrypoint): map the localhost:<port> that
186
+ # server-side code derives from the public URLs to the real service. docker:setup rewrites
187
+ # this when a default host port is taken/reserved and it remaps Kong/MinIO to a free port,
188
+ # so the left-hand ports here always match NEXT_PUBLIC_SUPABASE_URL / R2_S3_PUBLIC_ENDPOINT.
189
+ LOOPBACK_PROXIES: ${LOOPBACK_PROXIES:-8000:kong:8000 9000:minio:9000}
190
+ # Browser AND server both use the same Supabase URL; the loopback proxy above forwards it to
191
+ # Kong, keeping the host-derived Supabase auth cookie key consistent for SSR.
187
192
  NEXT_PUBLIC_SUPABASE_URL: ${NEXT_PUBLIC_SUPABASE_URL:-http://localhost:8000}
188
193
  NEXT_PUBLIC_SUPABASE_ANON_KEY: ${ANON_KEY}
189
194
  SUPABASE_SERVICE_ROLE_KEY: ${SERVICE_ROLE_KEY}
@@ -12,6 +12,7 @@ import { readFile, writeFile, access } from 'node:fs/promises';
12
12
  import { resolve, dirname } from 'node:path';
13
13
  import { fileURLToPath } from 'node:url';
14
14
  import { spawn, spawnSync } from 'node:child_process';
15
+ import { createServer } from 'node:net';
15
16
 
16
17
  const __dirname = dirname(fileURLToPath(import.meta.url));
17
18
  const PROJECT_ROOT = resolve(__dirname, '..');
@@ -79,6 +80,41 @@ const pathExists = async (p) => {
79
80
 
80
81
  const commandWorks = (cmd, args) => spawnSync(cmd, args, { stdio: 'ignore' }).status === 0;
81
82
 
83
+ // Can the host PUBLISH (bind) this TCP port? Docker publishes on 0.0.0.0, and on Windows the
84
+ // Hyper-V/WinNAT excluded port ranges (which sit in the 49152+ ephemeral band — e.g. the default
85
+ // Postgres port 54322) or any process already holding the port make the bind fail with
86
+ // EACCES ("access permissions") / EADDRINUSE — exactly what `docker compose up` fails on. Probing
87
+ // here lets us pick a working host port BEFORE compose tries and aborts the whole stack.
88
+ function canBindPort(port) {
89
+ return new Promise((res) => {
90
+ const srv = createServer();
91
+ srv.once('error', () => res(false));
92
+ srv.once('listening', () => srv.close(() => res(true)));
93
+ try {
94
+ srv.listen(port, '0.0.0.0');
95
+ } catch {
96
+ res(false);
97
+ }
98
+ });
99
+ }
100
+
101
+ // Prefer the conventional port; if it's unavailable, fall back into the registered-port band
102
+ // (1024–49151, below the ephemeral range WinNAT reserves) rather than just incrementing — a
103
+ // reserved/used port often sits inside a contiguous block, so +1 tends to be taken too.
104
+ async function findAvailablePort(preferred, fallbackBase, taken) {
105
+ const candidates = [preferred];
106
+ for (let i = 0; i < 100; i++) candidates.push(fallbackBase + i);
107
+ for (const p of candidates) {
108
+ if (taken.has(p)) continue;
109
+ if (await canBindPort(p)) {
110
+ taken.add(p);
111
+ return p;
112
+ }
113
+ }
114
+ taken.add(preferred);
115
+ return preferred; // give up gracefully — let compose surface the real error
116
+ }
117
+
82
118
  function detectCompose() {
83
119
  if (commandWorks('docker', ['compose', 'version'])) return { cmd: 'docker', args: ['compose'] };
84
120
  if (commandWorks('docker-compose', ['version'])) return { cmd: 'docker-compose', args: [] };
@@ -138,6 +174,41 @@ async function main() {
138
174
  const minioPassword = reuse('MINIO_ROOT_PASSWORD', generateSecret);
139
175
  const bucket = readEnvValue(existing, 'STORAGE_BUCKET') || 'nextblock';
140
176
 
177
+ // Resolve host ports up front so a port that's in use or reserved by the OS (very common on
178
+ // Windows for the default Postgres port 54322) never aborts `docker compose up`. Reuse a port
179
+ // already encoded in .env — the explicit *_PORT* var, else the port parsed from a coupled URL —
180
+ // so re-runs stay stable and never disturb an already-running stack; only a FRESH install probes
181
+ // for free ports. Coupled ports (Kong/MinIO/app) also drive the URLs + loopback proxy below.
182
+ const takenPorts = new Set();
183
+ const reusedPort = (key, urlKey) => {
184
+ const direct = parseInt(readEnvValue(existing, key), 10);
185
+ if (Number.isInteger(direct) && direct > 0) return direct;
186
+ if (urlKey) {
187
+ const m = readEnvValue(existing, urlKey).match(/:(\d{2,5})(?:\/|$)/);
188
+ if (m) return parseInt(m[1], 10);
189
+ }
190
+ return null;
191
+ };
192
+ const resolvePort = async (key, preferred, fallbackBase, urlKey) => {
193
+ const prev = reusedPort(key, urlKey);
194
+ if (prev) {
195
+ takenPorts.add(prev);
196
+ return prev;
197
+ }
198
+ if (existing) {
199
+ // An existing .env with no recorded port: keep the default rather than reshuffle a setup
200
+ // the user may already be running.
201
+ takenPorts.add(preferred);
202
+ return preferred;
203
+ }
204
+ return findAvailablePort(preferred, fallbackBase, takenPorts);
205
+ };
206
+ const appPort = await resolvePort('APP_PORT', 3000, 13000, 'NEXT_PUBLIC_URL');
207
+ const kongPort = await resolvePort('KONG_HTTP_PORT', 8000, 18000, 'NEXT_PUBLIC_SUPABASE_URL');
208
+ const minioS3Port = await resolvePort('MINIO_S3_PORT', 9000, 19000, 'R2_S3_PUBLIC_ENDPOINT');
209
+ const minioConsolePort = await resolvePort('MINIO_CONSOLE_PORT', 9001, 19001, null);
210
+ const dbPort = await resolvePort('POSTGRES_PORT_EXTERNAL', 54322, 15432, null);
211
+
141
212
  const replacements = {
142
213
  POSTGRES_PASSWORD: `POSTGRES_PASSWORD=${postgresPassword}`,
143
214
  POSTGRES_DB: 'POSTGRES_DB=postgres',
@@ -145,12 +216,22 @@ async function main() {
145
216
  JWT_EXP: 'JWT_EXP=3600',
146
217
  ANON_KEY: `ANON_KEY=${anonKey}`,
147
218
  SERVICE_ROLE_KEY: `SERVICE_ROLE_KEY=${serviceRoleKey}`,
148
- NEXT_PUBLIC_SUPABASE_URL: 'NEXT_PUBLIC_SUPABASE_URL=http://localhost:8000',
219
+ // Host port mappings (compose reads these) + the coupled public URLs, kept in lockstep so a
220
+ // remapped port stays consistent everywhere it's referenced.
221
+ APP_PORT: `APP_PORT=${appPort}`,
222
+ KONG_HTTP_PORT: `KONG_HTTP_PORT=${kongPort}`,
223
+ MINIO_S3_PORT: `MINIO_S3_PORT=${minioS3Port}`,
224
+ MINIO_CONSOLE_PORT: `MINIO_CONSOLE_PORT=${minioConsolePort}`,
225
+ POSTGRES_PORT_EXTERNAL: `POSTGRES_PORT_EXTERNAL=${dbPort}`,
226
+ // In-container loopback: server code hits localhost:<kongPort> / 127.0.0.1:<minioS3Port> and
227
+ // socat forwards to the real service. Left ports MUST match the URLs below.
228
+ LOOPBACK_PROXIES: `LOOPBACK_PROXIES=${kongPort}:kong:8000 ${minioS3Port}:minio:9000`,
229
+ NEXT_PUBLIC_SUPABASE_URL: `NEXT_PUBLIC_SUPABASE_URL=http://localhost:${kongPort}`,
149
230
  NEXT_PUBLIC_SUPABASE_ANON_KEY: `NEXT_PUBLIC_SUPABASE_ANON_KEY=${anonKey}`,
150
231
  SUPABASE_SERVICE_ROLE_KEY: `SUPABASE_SERVICE_ROLE_KEY=${serviceRoleKey}`,
151
- API_EXTERNAL_URL: 'API_EXTERNAL_URL=http://localhost:8000',
152
- SITE_URL: 'SITE_URL=http://localhost:3000',
153
- NEXT_PUBLIC_URL: 'NEXT_PUBLIC_URL=http://localhost:3000',
232
+ API_EXTERNAL_URL: `API_EXTERNAL_URL=http://localhost:${kongPort}`,
233
+ SITE_URL: `SITE_URL=http://localhost:${appPort}`,
234
+ NEXT_PUBLIC_URL: `NEXT_PUBLIC_URL=http://localhost:${appPort}`,
154
235
  NEXT_PUBLIC_IS_SANDBOX: 'NEXT_PUBLIC_IS_SANDBOX=true',
155
236
  CRON_SECRET: `CRON_SECRET=${cronSecret}`,
156
237
  DRAFT_MODE_SECRET: `DRAFT_MODE_SECRET=${draftSecret}`,
@@ -165,10 +246,10 @@ async function main() {
165
246
  // port-scoped, so the browser would send the app's Supabase auth cookies to MinIO too — and
166
247
  // MinIO rejects oversized header sets (MetadataTooLarge), breaking image display once cookies
167
248
  // grow. 127.0.0.1 is a different cookie host, so the browser never sends them there.
168
- R2_S3_PUBLIC_ENDPOINT: 'R2_S3_PUBLIC_ENDPOINT=http://127.0.0.1:9000',
249
+ R2_S3_PUBLIC_ENDPOINT: `R2_S3_PUBLIC_ENDPOINT=http://127.0.0.1:${minioS3Port}`,
169
250
  R2_FORCE_PATH_STYLE: 'R2_FORCE_PATH_STYLE=true',
170
- NEXT_PUBLIC_R2_BASE_URL: `NEXT_PUBLIC_R2_BASE_URL=http://127.0.0.1:9000/${bucket}`,
171
- NEXT_PUBLIC_R2_PUBLIC_URL: `NEXT_PUBLIC_R2_PUBLIC_URL=http://127.0.0.1:9000/${bucket}`,
251
+ NEXT_PUBLIC_R2_BASE_URL: `NEXT_PUBLIC_R2_BASE_URL=http://127.0.0.1:${minioS3Port}/${bucket}`,
252
+ NEXT_PUBLIC_R2_PUBLIC_URL: `NEXT_PUBLIC_R2_PUBLIC_URL=http://127.0.0.1:${minioS3Port}/${bucket}`,
172
253
  NEXT_PUBLIC_TURNSTILE_SITE_KEY: `NEXT_PUBLIC_TURNSTILE_SITE_KEY=${turnstileSiteKey}`,
173
254
  TURNSTILE_SECRET_KEY: `TURNSTILE_SECRET_KEY=${turnstileSecretKey}`,
174
255
  GOTRUE_MAILER_AUTOCONFIRM: `GOTRUE_MAILER_AUTOCONFIRM=${mailerAutoconfirm}`,
@@ -186,6 +267,19 @@ async function main() {
186
267
  await writeFile(ENV_PATH, nextEnv, 'utf8');
187
268
  console.log('✓ Wrote .env (Postgres, JWT secret + signed anon/service keys, MinIO, app secrets).\n');
188
269
 
270
+ const remapped = [
271
+ appPort !== 3000 && `app ${appPort} (default 3000)`,
272
+ kongPort !== 8000 && `Supabase API ${kongPort} (default 8000)`,
273
+ minioS3Port !== 9000 && `MinIO S3 ${minioS3Port} (default 9000)`,
274
+ minioConsolePort !== 9001 && `MinIO console ${minioConsolePort} (default 9001)`,
275
+ dbPort !== 54322 && `Postgres ${dbPort} (default 54322)`,
276
+ ].filter(Boolean);
277
+ if (remapped.length) {
278
+ console.log(
279
+ `↔ Some default host ports were unavailable (in use, or reserved by the OS — common on Windows).\n Remapped to free ports: ${remapped.join(', ')}.\n`,
280
+ );
281
+ }
282
+
189
283
  // A brand-new .env means brand-new secrets. Postgres only runs its init scripts (which set role
190
284
  // passwords) on an EMPTY volume, so a leftover volume from a previous install would keep the old
191
285
  // credentials and GoTrue/PostgREST could not log in. Reset volumes when the config is fresh.
@@ -202,10 +296,10 @@ async function main() {
202
296
  await run(compose.cmd, [...compose.args, 'up', '-d', '--build'], { cwd: PROJECT_ROOT });
203
297
 
204
298
  console.log('\n🎉 Stack is up!');
205
- console.log(' 1. Open the app: http://localhost:3000');
206
- console.log(' 2. Finish setup: complete the browser wizard at http://localhost:3000/setup');
299
+ console.log(` 1. Open the app: http://localhost:${appPort}`);
300
+ console.log(` 2. Finish setup: complete the browser wizard at http://localhost:${appPort}/setup`);
207
301
  console.log(' (creates your first admin — auto-confirmed, no email needed).');
208
- console.log(' 3. Supabase API: http://localhost:8000 MinIO console: http://localhost:9001');
302
+ console.log(` 3. Supabase API: http://localhost:${kongPort} MinIO console: http://localhost:${minioConsolePort}`);
209
303
  const composeStr = `${compose.cmd} ${compose.args.join(' ')}`.trim();
210
304
  console.log(`\n Logs: ${composeStr} logs -f nextblock-cms | Stop: ${composeStr} down (add -v to wipe data)`);
211
305
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-nextblock",
3
- "version": "0.12.3",
3
+ "version": "0.12.5",
4
4
  "description": "",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -182,8 +182,13 @@ services:
182
182
  NODE_ENV: production
183
183
  PORT: 3000
184
184
  HOSTNAME: 0.0.0.0
185
- # Browser AND server both use localhost:8000; the in-container loopback proxy (see Dockerfile)
186
- # forwards it to Kong, keeping the host-derived Supabase auth cookie key consistent for SSR.
185
+ # In-container loopback proxies (see Dockerfile entrypoint): map the localhost:<port> that
186
+ # server-side code derives from the public URLs to the real service. docker:setup rewrites
187
+ # this when a default host port is taken/reserved and it remaps Kong/MinIO to a free port,
188
+ # so the left-hand ports here always match NEXT_PUBLIC_SUPABASE_URL / R2_S3_PUBLIC_ENDPOINT.
189
+ LOOPBACK_PROXIES: ${LOOPBACK_PROXIES:-8000:kong:8000 9000:minio:9000}
190
+ # Browser AND server both use the same Supabase URL; the loopback proxy above forwards it to
191
+ # Kong, keeping the host-derived Supabase auth cookie key consistent for SSR.
187
192
  NEXT_PUBLIC_SUPABASE_URL: ${NEXT_PUBLIC_SUPABASE_URL:-http://localhost:8000}
188
193
  NEXT_PUBLIC_SUPABASE_ANON_KEY: ${ANON_KEY}
189
194
  SUPABASE_SERVICE_ROLE_KEY: ${SERVICE_ROLE_KEY}
@@ -30,11 +30,20 @@ export type OnboardingStatus = {
30
30
  dismissed: boolean;
31
31
  };
32
32
 
33
- // Seeded defaults (libs/db migrations 008 + 010). The branding/copyright steps count as
34
- // "done" only when the value has been customized away from these — a fresh install ships
35
- // with the seeds, so a plain presence check would mark every step complete immediately.
33
+ // Seeded defaults (libs/db baseline seed 00000000000003 + migration 00000000000004). The
34
+ // branding/copyright steps count as "done" only when the value has been customized away from
35
+ // these — a fresh install ships with the seeds, so a plain presence check would mark every
36
+ // step complete immediately.
36
37
  const SEEDED_SITE_TITLE = 'NextBlock™ CMS';
37
- const SEEDED_LOGO_OBJECT_KEY = 'images/nextblock-logo-small.webp';
38
+ // Every object_key a fresh install may ship as the DEFAULT logo — the original baseline WebP
39
+ // and the migration-00000000000004 email-safe PNG (which repoints the seeded default). A logo
40
+ // counts as customized only when it is none of these; a new default swap must be added here or
41
+ // branding falsely reads as done out of the box. Mirrors BUNDLED_PUBLIC_MEDIA_KEYS in
42
+ // lib/media/resolveMediaUrl.ts.
43
+ const SEEDED_LOGO_OBJECT_KEYS = new Set<string>([
44
+ 'images/nextblock-logo-small.webp',
45
+ 'images/nextblock-logo-button-tiny.png',
46
+ ]);
38
47
  const SEEDED_COPYRIGHT: Record<string, string> = {
39
48
  en: '© {year} Nextblock CMS. All rights reserved.',
40
49
  fr: '© {year} Nextblock CMS. Tous droits réservés.',
@@ -88,7 +97,7 @@ export async function getOnboardingStatus(opts: {
88
97
  const siteTitle = typeof siteTitleRaw === 'string' ? siteTitleRaw.trim() : '';
89
98
  const siteTitleCustomized = siteTitle.length > 0 && siteTitle !== SEEDED_SITE_TITLE;
90
99
  const logoObjectKey = extractLogoObjectKey(logoRow);
91
- const logoCustomized = Boolean(logoObjectKey) && logoObjectKey !== SEEDED_LOGO_OBJECT_KEY;
100
+ const logoCustomized = Boolean(logoObjectKey) && !SEEDED_LOGO_OBJECT_KEYS.has(logoObjectKey ?? '');
92
101
 
93
102
  // Branding is "done" once the user renames the site or uploads their own logo — not merely
94
103
  // because the seeded NextBlock title/logo exist.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nextblock-cms/template",
3
- "version": "0.12.3",
3
+ "version": "0.12.5",
4
4
  "private": true,
5
5
  "scripts": {
6
6
  "dev": "next dev",
@@ -12,6 +12,7 @@ import { readFile, writeFile, access } from 'node:fs/promises';
12
12
  import { resolve, dirname } from 'node:path';
13
13
  import { fileURLToPath } from 'node:url';
14
14
  import { spawn, spawnSync } from 'node:child_process';
15
+ import { createServer } from 'node:net';
15
16
 
16
17
  const __dirname = dirname(fileURLToPath(import.meta.url));
17
18
  const PROJECT_ROOT = resolve(__dirname, '..');
@@ -79,6 +80,41 @@ const pathExists = async (p) => {
79
80
 
80
81
  const commandWorks = (cmd, args) => spawnSync(cmd, args, { stdio: 'ignore' }).status === 0;
81
82
 
83
+ // Can the host PUBLISH (bind) this TCP port? Docker publishes on 0.0.0.0, and on Windows the
84
+ // Hyper-V/WinNAT excluded port ranges (which sit in the 49152+ ephemeral band — e.g. the default
85
+ // Postgres port 54322) or any process already holding the port make the bind fail with
86
+ // EACCES ("access permissions") / EADDRINUSE — exactly what `docker compose up` fails on. Probing
87
+ // here lets us pick a working host port BEFORE compose tries and aborts the whole stack.
88
+ function canBindPort(port) {
89
+ return new Promise((res) => {
90
+ const srv = createServer();
91
+ srv.once('error', () => res(false));
92
+ srv.once('listening', () => srv.close(() => res(true)));
93
+ try {
94
+ srv.listen(port, '0.0.0.0');
95
+ } catch {
96
+ res(false);
97
+ }
98
+ });
99
+ }
100
+
101
+ // Prefer the conventional port; if it's unavailable, fall back into the registered-port band
102
+ // (1024–49151, below the ephemeral range WinNAT reserves) rather than just incrementing — a
103
+ // reserved/used port often sits inside a contiguous block, so +1 tends to be taken too.
104
+ async function findAvailablePort(preferred, fallbackBase, taken) {
105
+ const candidates = [preferred];
106
+ for (let i = 0; i < 100; i++) candidates.push(fallbackBase + i);
107
+ for (const p of candidates) {
108
+ if (taken.has(p)) continue;
109
+ if (await canBindPort(p)) {
110
+ taken.add(p);
111
+ return p;
112
+ }
113
+ }
114
+ taken.add(preferred);
115
+ return preferred; // give up gracefully — let compose surface the real error
116
+ }
117
+
82
118
  function detectCompose() {
83
119
  if (commandWorks('docker', ['compose', 'version'])) return { cmd: 'docker', args: ['compose'] };
84
120
  if (commandWorks('docker-compose', ['version'])) return { cmd: 'docker-compose', args: [] };
@@ -138,6 +174,41 @@ async function main() {
138
174
  const minioPassword = reuse('MINIO_ROOT_PASSWORD', generateSecret);
139
175
  const bucket = readEnvValue(existing, 'STORAGE_BUCKET') || 'nextblock';
140
176
 
177
+ // Resolve host ports up front so a port that's in use or reserved by the OS (very common on
178
+ // Windows for the default Postgres port 54322) never aborts `docker compose up`. Reuse a port
179
+ // already encoded in .env — the explicit *_PORT* var, else the port parsed from a coupled URL —
180
+ // so re-runs stay stable and never disturb an already-running stack; only a FRESH install probes
181
+ // for free ports. Coupled ports (Kong/MinIO/app) also drive the URLs + loopback proxy below.
182
+ const takenPorts = new Set();
183
+ const reusedPort = (key, urlKey) => {
184
+ const direct = parseInt(readEnvValue(existing, key), 10);
185
+ if (Number.isInteger(direct) && direct > 0) return direct;
186
+ if (urlKey) {
187
+ const m = readEnvValue(existing, urlKey).match(/:(\d{2,5})(?:\/|$)/);
188
+ if (m) return parseInt(m[1], 10);
189
+ }
190
+ return null;
191
+ };
192
+ const resolvePort = async (key, preferred, fallbackBase, urlKey) => {
193
+ const prev = reusedPort(key, urlKey);
194
+ if (prev) {
195
+ takenPorts.add(prev);
196
+ return prev;
197
+ }
198
+ if (existing) {
199
+ // An existing .env with no recorded port: keep the default rather than reshuffle a setup
200
+ // the user may already be running.
201
+ takenPorts.add(preferred);
202
+ return preferred;
203
+ }
204
+ return findAvailablePort(preferred, fallbackBase, takenPorts);
205
+ };
206
+ const appPort = await resolvePort('APP_PORT', 3000, 13000, 'NEXT_PUBLIC_URL');
207
+ const kongPort = await resolvePort('KONG_HTTP_PORT', 8000, 18000, 'NEXT_PUBLIC_SUPABASE_URL');
208
+ const minioS3Port = await resolvePort('MINIO_S3_PORT', 9000, 19000, 'R2_S3_PUBLIC_ENDPOINT');
209
+ const minioConsolePort = await resolvePort('MINIO_CONSOLE_PORT', 9001, 19001, null);
210
+ const dbPort = await resolvePort('POSTGRES_PORT_EXTERNAL', 54322, 15432, null);
211
+
141
212
  const replacements = {
142
213
  POSTGRES_PASSWORD: `POSTGRES_PASSWORD=${postgresPassword}`,
143
214
  POSTGRES_DB: 'POSTGRES_DB=postgres',
@@ -145,12 +216,22 @@ async function main() {
145
216
  JWT_EXP: 'JWT_EXP=3600',
146
217
  ANON_KEY: `ANON_KEY=${anonKey}`,
147
218
  SERVICE_ROLE_KEY: `SERVICE_ROLE_KEY=${serviceRoleKey}`,
148
- NEXT_PUBLIC_SUPABASE_URL: 'NEXT_PUBLIC_SUPABASE_URL=http://localhost:8000',
219
+ // Host port mappings (compose reads these) + the coupled public URLs, kept in lockstep so a
220
+ // remapped port stays consistent everywhere it's referenced.
221
+ APP_PORT: `APP_PORT=${appPort}`,
222
+ KONG_HTTP_PORT: `KONG_HTTP_PORT=${kongPort}`,
223
+ MINIO_S3_PORT: `MINIO_S3_PORT=${minioS3Port}`,
224
+ MINIO_CONSOLE_PORT: `MINIO_CONSOLE_PORT=${minioConsolePort}`,
225
+ POSTGRES_PORT_EXTERNAL: `POSTGRES_PORT_EXTERNAL=${dbPort}`,
226
+ // In-container loopback: server code hits localhost:<kongPort> / 127.0.0.1:<minioS3Port> and
227
+ // socat forwards to the real service. Left ports MUST match the URLs below.
228
+ LOOPBACK_PROXIES: `LOOPBACK_PROXIES=${kongPort}:kong:8000 ${minioS3Port}:minio:9000`,
229
+ NEXT_PUBLIC_SUPABASE_URL: `NEXT_PUBLIC_SUPABASE_URL=http://localhost:${kongPort}`,
149
230
  NEXT_PUBLIC_SUPABASE_ANON_KEY: `NEXT_PUBLIC_SUPABASE_ANON_KEY=${anonKey}`,
150
231
  SUPABASE_SERVICE_ROLE_KEY: `SUPABASE_SERVICE_ROLE_KEY=${serviceRoleKey}`,
151
- API_EXTERNAL_URL: 'API_EXTERNAL_URL=http://localhost:8000',
152
- SITE_URL: 'SITE_URL=http://localhost:3000',
153
- NEXT_PUBLIC_URL: 'NEXT_PUBLIC_URL=http://localhost:3000',
232
+ API_EXTERNAL_URL: `API_EXTERNAL_URL=http://localhost:${kongPort}`,
233
+ SITE_URL: `SITE_URL=http://localhost:${appPort}`,
234
+ NEXT_PUBLIC_URL: `NEXT_PUBLIC_URL=http://localhost:${appPort}`,
154
235
  NEXT_PUBLIC_IS_SANDBOX: 'NEXT_PUBLIC_IS_SANDBOX=true',
155
236
  CRON_SECRET: `CRON_SECRET=${cronSecret}`,
156
237
  DRAFT_MODE_SECRET: `DRAFT_MODE_SECRET=${draftSecret}`,
@@ -165,10 +246,10 @@ async function main() {
165
246
  // port-scoped, so the browser would send the app's Supabase auth cookies to MinIO too — and
166
247
  // MinIO rejects oversized header sets (MetadataTooLarge), breaking image display once cookies
167
248
  // grow. 127.0.0.1 is a different cookie host, so the browser never sends them there.
168
- R2_S3_PUBLIC_ENDPOINT: 'R2_S3_PUBLIC_ENDPOINT=http://127.0.0.1:9000',
249
+ R2_S3_PUBLIC_ENDPOINT: `R2_S3_PUBLIC_ENDPOINT=http://127.0.0.1:${minioS3Port}`,
169
250
  R2_FORCE_PATH_STYLE: 'R2_FORCE_PATH_STYLE=true',
170
- NEXT_PUBLIC_R2_BASE_URL: `NEXT_PUBLIC_R2_BASE_URL=http://127.0.0.1:9000/${bucket}`,
171
- NEXT_PUBLIC_R2_PUBLIC_URL: `NEXT_PUBLIC_R2_PUBLIC_URL=http://127.0.0.1:9000/${bucket}`,
251
+ NEXT_PUBLIC_R2_BASE_URL: `NEXT_PUBLIC_R2_BASE_URL=http://127.0.0.1:${minioS3Port}/${bucket}`,
252
+ NEXT_PUBLIC_R2_PUBLIC_URL: `NEXT_PUBLIC_R2_PUBLIC_URL=http://127.0.0.1:${minioS3Port}/${bucket}`,
172
253
  NEXT_PUBLIC_TURNSTILE_SITE_KEY: `NEXT_PUBLIC_TURNSTILE_SITE_KEY=${turnstileSiteKey}`,
173
254
  TURNSTILE_SECRET_KEY: `TURNSTILE_SECRET_KEY=${turnstileSecretKey}`,
174
255
  GOTRUE_MAILER_AUTOCONFIRM: `GOTRUE_MAILER_AUTOCONFIRM=${mailerAutoconfirm}`,
@@ -186,6 +267,19 @@ async function main() {
186
267
  await writeFile(ENV_PATH, nextEnv, 'utf8');
187
268
  console.log('✓ Wrote .env (Postgres, JWT secret + signed anon/service keys, MinIO, app secrets).\n');
188
269
 
270
+ const remapped = [
271
+ appPort !== 3000 && `app ${appPort} (default 3000)`,
272
+ kongPort !== 8000 && `Supabase API ${kongPort} (default 8000)`,
273
+ minioS3Port !== 9000 && `MinIO S3 ${minioS3Port} (default 9000)`,
274
+ minioConsolePort !== 9001 && `MinIO console ${minioConsolePort} (default 9001)`,
275
+ dbPort !== 54322 && `Postgres ${dbPort} (default 54322)`,
276
+ ].filter(Boolean);
277
+ if (remapped.length) {
278
+ console.log(
279
+ `↔ Some default host ports were unavailable (in use, or reserved by the OS — common on Windows).\n Remapped to free ports: ${remapped.join(', ')}.\n`,
280
+ );
281
+ }
282
+
189
283
  // A brand-new .env means brand-new secrets. Postgres only runs its init scripts (which set role
190
284
  // passwords) on an EMPTY volume, so a leftover volume from a previous install would keep the old
191
285
  // credentials and GoTrue/PostgREST could not log in. Reset volumes when the config is fresh.
@@ -202,10 +296,10 @@ async function main() {
202
296
  await run(compose.cmd, [...compose.args, 'up', '-d', '--build'], { cwd: PROJECT_ROOT });
203
297
 
204
298
  console.log('\n🎉 Stack is up!');
205
- console.log(' 1. Open the app: http://localhost:3000');
206
- console.log(' 2. Finish setup: complete the browser wizard at http://localhost:3000/setup');
299
+ console.log(` 1. Open the app: http://localhost:${appPort}`);
300
+ console.log(` 2. Finish setup: complete the browser wizard at http://localhost:${appPort}/setup`);
207
301
  console.log(' (creates your first admin — auto-confirmed, no email needed).');
208
- console.log(' 3. Supabase API: http://localhost:8000 MinIO console: http://localhost:9001');
302
+ console.log(` 3. Supabase API: http://localhost:${kongPort} MinIO console: http://localhost:${minioConsolePort}`);
209
303
  const composeStr = `${compose.cmd} ${compose.args.join(' ')}`.trim();
210
304
  console.log(`\n Logs: ${composeStr} logs -f nextblock-cms | Stop: ${composeStr} down (add -v to wipe data)`);
211
305
  }