create-nextblock 0.9.0 → 0.9.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.
Files changed (39) hide show
  1. package/bin/create-nextblock.js +40 -60
  2. package/docker-template/.env.docker.example +5 -4
  3. package/docker-template/Dockerfile +20 -3
  4. package/docker-template/docker-compose.yml +5 -1
  5. package/docker-template/scripts/docker-setup.mjs +19 -4
  6. package/package.json +1 -1
  7. package/templates/nextblock-template/Dockerfile +20 -3
  8. package/templates/nextblock-template/app/[slug]/page.tsx +5 -0
  9. package/templates/nextblock-template/app/actions.ts +58 -8
  10. package/templates/nextblock-template/app/api/cron/reset-sandbox/sandboxResetSql.ts +83 -0
  11. package/templates/nextblock-template/app/article/[slug]/page.tsx +5 -0
  12. package/templates/nextblock-template/app/cms/settings/security/actions.ts +30 -0
  13. package/templates/nextblock-template/app/cms/settings/security/components/SecurityPanel.tsx +69 -0
  14. package/templates/nextblock-template/app/layout.tsx +57 -3
  15. package/templates/nextblock-template/app/lib/site-settings.ts +22 -7
  16. package/templates/nextblock-template/app/page.tsx +6 -0
  17. package/templates/nextblock-template/app/product/[slug]/page.tsx +5 -0
  18. package/templates/nextblock-template/app/setup/SetupWizard.tsx +771 -0
  19. package/templates/nextblock-template/app/setup/layout.tsx +13 -0
  20. package/templates/nextblock-template/app/setup/page.tsx +103 -0
  21. package/templates/nextblock-template/components/AppShell.tsx +12 -0
  22. package/templates/nextblock-template/components/header-auth.tsx +24 -62
  23. package/templates/nextblock-template/docker-compose.yml +5 -1
  24. package/templates/nextblock-template/docs/11-SELF-HOSTED-DOCKER.md +173 -0
  25. package/templates/nextblock-template/docs/12-VERCEL-DEPLOYMENT.md +67 -0
  26. package/templates/nextblock-template/docs/README.md +2 -0
  27. package/templates/nextblock-template/lib/blocks/FeaturedProductBlock.tsx +1 -1
  28. package/templates/nextblock-template/lib/blocks/ProductGridBlock.tsx +1 -1
  29. package/templates/nextblock-template/lib/setup/actions.ts +370 -0
  30. package/templates/nextblock-template/lib/setup/env-status.ts +86 -0
  31. package/templates/nextblock-template/lib/setup/env-write.ts +111 -0
  32. package/templates/nextblock-template/lib/setup/provisioning.ts +59 -0
  33. package/templates/nextblock-template/lib/setup/schema-apply.ts +379 -0
  34. package/templates/nextblock-template/lib/setup/system-config.ts +105 -0
  35. package/templates/nextblock-template/lib/setup/types.ts +18 -0
  36. package/templates/nextblock-template/package.json +1 -1
  37. package/templates/nextblock-template/proxy.ts +143 -49
  38. package/templates/nextblock-template/scripts/docker-setup.mjs +19 -4
  39. package/templates/nextblock-template/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,370 @@
1
+ 'use server';
2
+ // Server actions backing the browser /setup wizard. Every mutating action is guarded
3
+ // by assertNotProvisioned() so setup can only run once (until a first admin exists).
4
+
5
+ import { getServiceRoleSupabaseClient } from '@nextblock-cms/db/server';
6
+ import { createClient as createSupabaseJsClient } from '@supabase/supabase-js';
7
+ import { isLocalWritableEnv } from './env-status';
8
+ import { writeEnvLocal } from './env-write';
9
+ import {
10
+ assertNotProvisioned,
11
+ getProvisioningStatus,
12
+ type ProvisioningStatus,
13
+ } from './provisioning';
14
+ import { applyMigrations, resetDatabase } from './schema-apply';
15
+ import { setSystemConfigurationServiceRole } from './system-config';
16
+
17
+ export interface ActionResult {
18
+ ok: boolean;
19
+ error?: string;
20
+ message?: string;
21
+ restartRecommended?: boolean;
22
+ schemaReady?: boolean;
23
+ }
24
+
25
+ export interface ConnectionInput {
26
+ supabaseUrl: string;
27
+ anonKey: string;
28
+ serviceRoleKey: string;
29
+ postgresUrl?: string;
30
+ siteUrl?: string;
31
+ /** Supabase personal access token — needed by `npm run db:migrate` to link + push. */
32
+ accessToken?: string;
33
+ }
34
+
35
+ /**
36
+ * Step (Profile B / local only): validate the Supabase credentials, then persist them
37
+ * to `.env.local` and the live process. Probes with the service-role key so we can
38
+ * also report whether the schema has been applied yet.
39
+ */
40
+ export async function saveSupabaseConnection(input: ConnectionInput): Promise<ActionResult> {
41
+ await assertNotProvisioned();
42
+
43
+ const supabaseUrl = input.supabaseUrl?.trim();
44
+ const anonKey = input.anonKey?.trim();
45
+ const serviceRoleKey = input.serviceRoleKey?.trim();
46
+
47
+ if (!supabaseUrl || !anonKey || !serviceRoleKey) {
48
+ return {
49
+ ok: false,
50
+ error: 'Supabase URL, anon key, and service-role key are all required.',
51
+ };
52
+ }
53
+ try {
54
+ new URL(supabaseUrl);
55
+ } catch {
56
+ return { ok: false, error: 'The Supabase URL is not a valid URL.' };
57
+ }
58
+
59
+ if (!isLocalWritableEnv()) {
60
+ return {
61
+ ok: false,
62
+ error:
63
+ 'This environment is read-only. Set the Supabase variables on your hosting platform instead of here.',
64
+ };
65
+ }
66
+
67
+ // Validate the credentials with a service-role probe before writing anything.
68
+ const probe = createSupabaseJsClient(supabaseUrl, serviceRoleKey, {
69
+ auth: { persistSession: false, autoRefreshToken: false },
70
+ });
71
+
72
+ let schemaReady = false;
73
+ try {
74
+ const { error } = await probe.from('site_settings').select('key').limit(1);
75
+ if (error) {
76
+ const missing = /relation|does not exist|schema cache/i.test(error.message);
77
+ if (!missing) {
78
+ return {
79
+ ok: false,
80
+ error: `Could not reach Supabase with those credentials: ${error.message}`,
81
+ };
82
+ }
83
+ schemaReady = false; // reachable, but the schema isn't applied yet
84
+ } else {
85
+ schemaReady = true;
86
+ }
87
+ } catch (caught) {
88
+ return {
89
+ ok: false,
90
+ error: `Could not connect to Supabase: ${
91
+ caught instanceof Error ? caught.message : 'unknown error'
92
+ }`,
93
+ };
94
+ }
95
+
96
+ const values: Record<string, string> = {
97
+ NEXT_PUBLIC_SUPABASE_URL: supabaseUrl,
98
+ NEXT_PUBLIC_SUPABASE_ANON_KEY: anonKey,
99
+ SUPABASE_SERVICE_ROLE_KEY: serviceRoleKey,
100
+ };
101
+ if (input.siteUrl?.trim()) values.NEXT_PUBLIC_URL = input.siteUrl.trim();
102
+ if (input.postgresUrl?.trim()) values.POSTGRES_URL = input.postgresUrl.trim();
103
+ if (input.accessToken?.trim()) values.SUPABASE_ACCESS_TOKEN = input.accessToken.trim();
104
+
105
+ // Derive the project ref from the URL (https://<ref>.supabase.co) so the CLI schema
106
+ // step (`npm run db:migrate`) links + pushes to THIS project, not a stale one.
107
+ try {
108
+ const host = new URL(supabaseUrl).hostname;
109
+ if (host.endsWith('.supabase.co') || host.endsWith('.supabase.in')) {
110
+ values.SUPABASE_PROJECT_ID = host.split('.')[0];
111
+ }
112
+ } catch {
113
+ // already validated above; ignore
114
+ }
115
+
116
+ try {
117
+ await writeEnvLocal(values);
118
+ } catch (caught) {
119
+ return {
120
+ ok: false,
121
+ error: `Could not write .env.local: ${
122
+ caught instanceof Error ? caught.message : 'unknown error'
123
+ }`,
124
+ };
125
+ }
126
+
127
+ return {
128
+ ok: true,
129
+ schemaReady,
130
+ restartRecommended: true,
131
+ message: schemaReady
132
+ ? 'Connection saved and verified.'
133
+ : 'Connection saved. The database schema is not applied yet — run "npm run db:migrate", then re-check below.',
134
+ };
135
+ }
136
+
137
+ /** Poll provisioning status (used by the wizard to advance past connection/schema). */
138
+ export async function recheckStatus(): Promise<ProvisioningStatus & { writable: boolean }> {
139
+ const status = await getProvisioningStatus();
140
+ return { ...status, writable: isLocalWritableEnv() };
141
+ }
142
+
143
+ export interface CompleteSetupInput {
144
+ admin: { email: string; password: string; fullName: string };
145
+ autoAcceptSignups: boolean;
146
+ /** Local-only extra env (storage / SMTP) collected by the wizard. */
147
+ envValues?: Record<string, string>;
148
+ /** Bot protection — stored in the DB so it works on read-only channels too. */
149
+ turnstile?: { provider: 'none' | 'turnstile'; siteKey: string; secretKey: string };
150
+ /** "Start from a clean database" — wipe before installing (local dev only, server-gated). */
151
+ resetFirst?: boolean;
152
+ }
153
+
154
+ /**
155
+ * After a fresh migration, PostgREST may briefly not see the new tables (its schema
156
+ * cache). applyMigrations issues a reload, but it's async — poll until a known new
157
+ * table reads cleanly so the REST reads/writes below don't hit a false "table not
158
+ * found". Best-effort: gives up after ~6s and lets the caller proceed.
159
+ */
160
+ async function waitForSchemaCache(
161
+ supabase: ReturnType<typeof getServiceRoleSupabaseClient>,
162
+ ): Promise<void> {
163
+ for (let attempt = 0; attempt < 12; attempt++) {
164
+ const { error } = await supabase.from('system_configuration').select('id').limit(1);
165
+ if (!error) return;
166
+ await new Promise((resolve) => setTimeout(resolve, 500));
167
+ }
168
+ }
169
+
170
+ /**
171
+ * Final step: persist remaining settings and create the first admin. The
172
+ * handle_new_user trigger assigns the ADMIN role and flips is_admin_created, so we
173
+ * never set the role ourselves. `email_confirm: true` means no SMTP round-trip is
174
+ * required, which keeps every channel (including cloud-without-SMTP) unblocked.
175
+ */
176
+ export async function completeSetup(input: CompleteSetupInput): Promise<ActionResult> {
177
+ // "Start from a clean database" (local dev only) deliberately re-installs over an
178
+ // existing DB — it wipes everything first — so the one-shot guard is skipped in that
179
+ // case. Otherwise enforce it: a provisioned instance can't be re-setup or wiped, and
180
+ // we return a clean message rather than throwing an unhandled error.
181
+ const willReset = input.resetFirst === true && isLocalWritableEnv();
182
+ if (!willReset) {
183
+ try {
184
+ await assertNotProvisioned();
185
+ } catch {
186
+ return {
187
+ ok: false,
188
+ error:
189
+ 'Setup has already been completed. Enable "Start from a clean database" to reinstall, or sign in instead.',
190
+ };
191
+ }
192
+ }
193
+
194
+ const email = input.admin?.email?.trim().toLowerCase();
195
+ const password = input.admin?.password ?? '';
196
+ const fullName = input.admin?.fullName?.trim() ?? '';
197
+
198
+ if (!email || !password) {
199
+ return { ok: false, error: 'Administrator email and password are required.' };
200
+ }
201
+ if (password.length < 8) {
202
+ return { ok: false, error: 'Use an administrator password of at least 8 characters.' };
203
+ }
204
+ if (input.turnstile?.provider === 'turnstile' && !input.turnstile.secretKey?.trim()) {
205
+ return { ok: false, error: 'Enter a Turnstile secret key, or disable bot protection.' };
206
+ }
207
+
208
+ // 1) Persist any local env extras (storage / SMTP) for Profile B.
209
+ if (
210
+ input.envValues &&
211
+ Object.keys(input.envValues).length > 0 &&
212
+ isLocalWritableEnv()
213
+ ) {
214
+ try {
215
+ await writeEnvLocal(input.envValues);
216
+ } catch (caught) {
217
+ return {
218
+ ok: false,
219
+ error: `Could not write .env.local: ${
220
+ caught instanceof Error ? caught.message : 'unknown error'
221
+ }`,
222
+ };
223
+ }
224
+ }
225
+
226
+ // 2) Service-role access is required from here on.
227
+ let admin: ReturnType<typeof getServiceRoleSupabaseClient>;
228
+ try {
229
+ admin = getServiceRoleSupabaseClient();
230
+ } catch {
231
+ return {
232
+ ok: false,
233
+ error:
234
+ 'The service-role key is not loaded yet. Restart the dev server after saving the connection, then retry.',
235
+ };
236
+ }
237
+
238
+ // 2.5) "Start from a clean database" (the fresh-install checkbox): wipe the DB (public
239
+ // schema + migration history + auth users) before installing, so each fresh setup
240
+ // starts clean. Two reliable guards: assertNotProvisioned() above means this only
241
+ // runs when no admin exists (a live site is immune), and isLocalWritableEnv()
242
+ // restricts it to local dev (a deployed app can never be tricked into wiping
243
+ // itself, even by a crafted request).
244
+ if (willReset) {
245
+ const reset = await resetDatabase();
246
+ if (!reset.ok) {
247
+ return { ok: false, error: `Could not reset the database: ${reset.error}` };
248
+ }
249
+ // Clear auth users via the admin API (reliable — SQL on the auth schema can be
250
+ // permission-restricted), so the admin email is free to reuse on this fresh install.
251
+ try {
252
+ const { data: list } = await admin.auth.admin.listUsers();
253
+ for (const existing of list?.users ?? []) {
254
+ await admin.auth.admin.deleteUser(existing.id);
255
+ }
256
+ } catch {
257
+ // Non-fatal — the createUser "already exists" fallback below also handles leftovers.
258
+ }
259
+ }
260
+
261
+ // 3) Apply the database schema if it isn't there yet (e.g. a fresh Supabase project).
262
+ // Direct Postgres connection — no CLI, no config.toml. Idempotent (tracks applied
263
+ // migrations), so it's a no-op when the schema already exists (Docker, or a re-run).
264
+ // Then wait for PostgREST to pick up the new tables before we touch them via REST.
265
+ const schemaStatus = await getProvisioningStatus();
266
+ if (!schemaStatus.schemaReady) {
267
+ // applyMigrations prefers the Supabase Management API (HTTPS, needs only the access
268
+ // token + project ref) and falls back to a direct Postgres connection. It returns a
269
+ // clear error if neither is available.
270
+ const schema = await applyMigrations();
271
+ if (!schema.ok) {
272
+ return { ok: false, error: `Could not apply the database schema: ${schema.error}` };
273
+ }
274
+ }
275
+
276
+ // Ensure PostgREST has the schema cached before ANY REST read/write below — covers a
277
+ // fresh apply AND a cold cache over a pre-existing schema (Docker boot race / re-run).
278
+ await waitForSchemaCache(admin);
279
+
280
+ // 4) Persist DB-backed settings (service role bypasses RLS — no admin exists yet).
281
+ try {
282
+ if (input.turnstile && input.turnstile.provider !== 'none') {
283
+ await admin.from('site_settings').upsert({
284
+ key: 'bot_protection_public',
285
+ value: { provider: input.turnstile.provider, siteKey: input.turnstile.siteKey },
286
+ });
287
+ await admin.from('site_settings').upsert({
288
+ key: 'bot_protection_secret',
289
+ value: { secretKey: input.turnstile.secretKey },
290
+ });
291
+ }
292
+ await setSystemConfigurationServiceRole({
293
+ auto_accept_signups: Boolean(input.autoAcceptSignups),
294
+ });
295
+ } catch (caught) {
296
+ return {
297
+ ok: false,
298
+ error: `Failed to save settings: ${
299
+ caught instanceof Error ? caught.message : 'unknown error'
300
+ }`,
301
+ };
302
+ }
303
+
304
+ // 4) Create the first admin account (already confirmed). The handle_new_user trigger
305
+ // assigns ADMIN to the very first user and flips is_admin_created atomically.
306
+ const createPayload = {
307
+ email,
308
+ password,
309
+ email_confirm: true,
310
+ user_metadata: { full_name: fullName },
311
+ };
312
+ let { data: created, error: createError } = await admin.auth.admin.createUser(createPayload);
313
+
314
+ // If a clean-install reset left a stale auth user (admin-API cleanup above hit an edge
315
+ // case), remove it and retry once so the operator can reuse their email.
316
+ if (
317
+ createError &&
318
+ input.resetFirst === true &&
319
+ /already|registered|exists/i.test(createError.message)
320
+ ) {
321
+ try {
322
+ const { data: list } = await admin.auth.admin.listUsers();
323
+ const stale = list?.users?.find((u) => u.email?.toLowerCase() === email);
324
+ if (stale) {
325
+ await admin.auth.admin.deleteUser(stale.id);
326
+ ({ data: created, error: createError } = await admin.auth.admin.createUser(createPayload));
327
+ }
328
+ } catch {
329
+ // fall through to the error handling below
330
+ }
331
+ }
332
+
333
+ if (createError || !created?.user) {
334
+ if (createError && /already|registered|exists/i.test(createError.message)) {
335
+ return {
336
+ ok: false,
337
+ error:
338
+ 'An account with this email already exists. Enable "Start from a clean database", or use a different email.',
339
+ };
340
+ }
341
+ return {
342
+ ok: false,
343
+ error: `Could not create the administrator account: ${
344
+ createError?.message ?? 'unknown error'
345
+ }`,
346
+ };
347
+ }
348
+
349
+ // Guard against a concurrent-setup race: assertNotProvisioned() is a check-then-act,
350
+ // so two simultaneous submissions could both pass it. The trigger still grants ADMIN
351
+ // to only the first user — so if this account came back as anything other than ADMIN,
352
+ // another session won the race. Undo this spurious account and report it, rather than
353
+ // signing the operator in as a non-admin.
354
+ const { data: createdProfile } = await admin
355
+ .from('profiles')
356
+ .select('role')
357
+ .eq('id', created.user.id)
358
+ .maybeSingle();
359
+ if (createdProfile?.role !== 'ADMIN') {
360
+ await admin.auth.admin.deleteUser(created.user.id).catch(() => {});
361
+ return {
362
+ ok: false,
363
+ error: 'Setup was just completed by another session. Please sign in instead.',
364
+ };
365
+ }
366
+
367
+ // The wizard establishes the session afterwards via the canonical signInAction (a more
368
+ // reliable cookie path than signing in here), so we just report success.
369
+ return { ok: true, message: 'Setup complete.' };
370
+ }
@@ -0,0 +1,86 @@
1
+ // Dependency-free environment / provisioning detection.
2
+ //
3
+ // This module is imported from places that run in very different runtimes — the
4
+ // `proxy.ts` middleware (Edge), server components, server actions, and even
5
+ // `next.config.js` (Node, at build time). It therefore MUST stay free of
6
+ // `server-only`, of any Node-only imports (`node:fs`, etc.), and of any Supabase
7
+ // client so it is safe to evaluate anywhere.
8
+ //
9
+ // "Configured" here means the Supabase connection vars exist — there is no
10
+ // `DATABASE_URL` in NextBlock; the database is reached through
11
+ // `NEXT_PUBLIC_SUPABASE_URL` + the anon / service-role keys.
12
+
13
+ export type DeployChannel = 'docker' | 'vercel' | 'local';
14
+
15
+ /**
16
+ * True when the public Supabase connection vars are present — enough to create an
17
+ * anon client and reach the database. This is the single predicate the boot path,
18
+ * the middleware gate, and the wizard all use to decide "is this instance wired up?".
19
+ */
20
+ export function isSupabaseConfigured(): boolean {
21
+ return Boolean(
22
+ process.env.NEXT_PUBLIC_SUPABASE_URL && process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY,
23
+ );
24
+ }
25
+
26
+ /**
27
+ * True when service-role work is also possible (creating the first admin, applying
28
+ * schema). The wizard's first-admin step and schema-apply step require this.
29
+ */
30
+ export function isFullyConfigured(): boolean {
31
+ return isSupabaseConfigured() && Boolean(process.env.SUPABASE_SERVICE_ROLE_KEY);
32
+ }
33
+
34
+ /**
35
+ * Which of the four deploy channels we appear to be running in. Drives the wizard's
36
+ * channel-aware prefills (Docker -> MinIO storage, Vercel -> Supabase Storage S3,
37
+ * local -> the user's own Cloudflare R2).
38
+ */
39
+ export function detectChannel(): DeployChannel {
40
+ if (process.env.VERCEL === '1') return 'vercel';
41
+
42
+ // Docker is identified by the internal Supabase gateway URL (Kong :8000) or the MinIO
43
+ // storage marker the docker setup writes — NOT by NEXT_PUBLIC_IS_SANDBOX, which the
44
+ // managed cloud sandbox also sets.
45
+ const url = process.env.NEXT_PUBLIC_SUPABASE_URL ?? '';
46
+ if (
47
+ url.includes('localhost:8000') ||
48
+ url.includes('127.0.0.1:8000') ||
49
+ url.includes('kong:8000') ||
50
+ process.env.R2_ACCOUNT_ID === 'minio'
51
+ ) {
52
+ return 'docker';
53
+ }
54
+
55
+ return 'local';
56
+ }
57
+
58
+ /**
59
+ * True only where the wizard may safely write `.env.local` to the working directory:
60
+ * local dev. NOT on Vercel (read-only FS, env is injected by the platform) and NOT
61
+ * inside the baked standalone Docker runner (env is set by compose, FS is read-only).
62
+ */
63
+ export function isLocalWritableEnv(): boolean {
64
+ // Require NODE_ENV === 'development' explicitly (not `!== 'production'`): an UNSET
65
+ // NODE_ENV must NOT count as local. `next dev` / `nx serve` set it to 'development';
66
+ // `next build`/`next start`, Vercel, and the Docker runner set it to 'production'.
67
+ // The remaining checks are belt-and-suspenders. This is a SAFETY boundary (it gates
68
+ // destructive setup actions), so it must fail closed.
69
+ return (
70
+ process.env.NODE_ENV === 'development' &&
71
+ process.env.VERCEL !== '1' &&
72
+ process.env.DOCKER_BUILD !== 'true' &&
73
+ process.env.NEXTBLOCK_RUNTIME !== 'standalone'
74
+ );
75
+ }
76
+
77
+ /**
78
+ * Records the unconfigured state on `process.env.NEXTBLOCK_UNCONFIGURED` so other
79
+ * call sites (CSP construction, middleware) can read it without recomputing.
80
+ * Idempotent; returns the boolean it set.
81
+ */
82
+ export function markUnconfiguredFlag(): boolean {
83
+ const unconfigured = !isSupabaseConfigured();
84
+ process.env.NEXTBLOCK_UNCONFIGURED = unconfigured ? 'true' : 'false';
85
+ return unconfigured;
86
+ }
@@ -0,0 +1,111 @@
1
+ import 'server-only';
2
+ // Server-only `.env.local` writer for the local-dev (Profile B) setup path.
3
+ //
4
+ // Reimplements the three tiny helpers from tools/scripts/lib/supabase-keys.mjs
5
+ // (generateSecret / readEnvValue / upsertEnv) inside the app so the browser wizard
6
+ // writes `.env.local` identically to the terminal flow it replaces — without pulling
7
+ // a file from outside the app's build graph. Kept dependency-free (node built-ins).
8
+ import { randomBytes } from 'node:crypto';
9
+ import { existsSync } from 'node:fs';
10
+ import { readFile, writeFile } from 'node:fs/promises';
11
+ import path from 'node:path';
12
+ import { isLocalWritableEnv } from './env-status';
13
+
14
+ /**
15
+ * Where `.env.local` belongs. In this Nx monorepo the canonical location is the
16
+ * workspace root (where `npm run setup` writes and `db:migrate`/`db:types` read), but
17
+ * `nx serve` runs server actions with cwd = apps/nextblock — so we walk up to the
18
+ * nearest ancestor containing `nx.json`. A standalone create-nextblock project has no
19
+ * `nx.json`, so we fall back to cwd (its own root, where `next dev` runs).
20
+ */
21
+ function resolveEnvDir(): string {
22
+ let dir = process.cwd();
23
+ for (let i = 0; i < 8; i++) {
24
+ if (existsSync(path.join(dir, 'nx.json'))) return dir;
25
+ const parent = path.dirname(dir);
26
+ if (parent === dir) break;
27
+ dir = parent;
28
+ }
29
+ return process.cwd();
30
+ }
31
+
32
+ /** 64-char (32-byte) hex secret. Matches tools/scripts/lib/supabase-keys.mjs. */
33
+ export function generateSecret(): string {
34
+ return randomBytes(32).toString('hex');
35
+ }
36
+
37
+ /** Read a `KEY=` value from an .env body (tolerates surrounding quotes). */
38
+ function readEnvValue(envContent: string, key: string): string {
39
+ for (const line of envContent.split(/\r?\n/)) {
40
+ if (line.startsWith(`${key}=`)) {
41
+ return line
42
+ .slice(key.length + 1)
43
+ .trim()
44
+ .replace(/^"(.*)"$/, '$1');
45
+ }
46
+ }
47
+ return '';
48
+ }
49
+
50
+ /** Apply `KEY=value` replacements line-by-line, appending keys not already present. */
51
+ function upsertEnv(envContent: string, replacements: Record<string, string>): string {
52
+ const applied = new Set<string>();
53
+ const lines = envContent.split(/\r?\n/).map((line) => {
54
+ for (const [key, value] of Object.entries(replacements)) {
55
+ if (line.startsWith(`${key}=`)) {
56
+ applied.add(key);
57
+ return `${key}=${value}`;
58
+ }
59
+ }
60
+ return line;
61
+ });
62
+
63
+ for (const [key, value] of Object.entries(replacements)) {
64
+ if (!applied.has(key)) {
65
+ lines.push(`${key}=${value}`);
66
+ }
67
+ }
68
+
69
+ return lines.join('\n');
70
+ }
71
+
72
+ const ROTATING_SECRET_KEYS = ['CRON_SECRET', 'DRAFT_MODE_SECRET', 'REVALIDATE_SECRET_TOKEN'];
73
+
74
+ /**
75
+ * Write KEY=value pairs to `.env.local` at the working directory AND mirror them into
76
+ * the live `process.env` so the running dev server can use them for server-side work
77
+ * (schema probe, admin creation) without a hard restart.
78
+ *
79
+ * Caveat the caller must surface: `NEXT_PUBLIC_*` values are inlined into client
80
+ * bundles at build/compile time, so the browser-side Supabase client still needs a
81
+ * dev-server restart to pick them up. No-op (returns false) outside a local writable
82
+ * environment (Vercel/Docker runner are read-only / platform-managed).
83
+ */
84
+ export async function writeEnvLocal(values: Record<string, string>): Promise<boolean> {
85
+ if (!isLocalWritableEnv()) return false;
86
+
87
+ const envPath = path.join(resolveEnvDir(), '.env.local');
88
+ let existing = '';
89
+ try {
90
+ existing = await readFile(envPath, 'utf8');
91
+ } catch {
92
+ existing = '';
93
+ }
94
+
95
+ // Generate the three rotating secrets only if absent (idempotent), like setup.mjs.
96
+ const ensured: Record<string, string> = { ...values };
97
+ for (const key of ROTATING_SECRET_KEYS) {
98
+ if (!ensured[key] && !readEnvValue(existing, key)) {
99
+ ensured[key] = generateSecret();
100
+ }
101
+ }
102
+
103
+ const next = upsertEnv(existing, ensured);
104
+ await writeFile(envPath, next.endsWith('\n') ? next : `${next}\n`, 'utf8');
105
+
106
+ for (const [key, value] of Object.entries(ensured)) {
107
+ process.env[key] = value;
108
+ }
109
+
110
+ return true;
111
+ }
@@ -0,0 +1,59 @@
1
+ import 'server-only';
2
+ // Server-only provisioning status used by the /setup page (to redirect away once
3
+ // complete) and by the wizard's server actions (to refuse re-running setup).
4
+ import { createClient, getServiceRoleSupabaseClient } from '@nextblock-cms/db/server';
5
+ import { isSupabaseConfigured } from './env-status';
6
+
7
+ export interface ProvisioningStatus {
8
+ /** Supabase connection vars are present. */
9
+ configured: boolean;
10
+ /** Core tables exist (migrations applied). */
11
+ schemaReady: boolean;
12
+ /** The first admin has been created (site_settings.is_admin_created === true). */
13
+ hasAdmin: boolean;
14
+ }
15
+
16
+ export async function getProvisioningStatus(): Promise<ProvisioningStatus> {
17
+ if (!isSupabaseConfigured()) {
18
+ return { configured: false, schemaReady: false, hasAdmin: false };
19
+ }
20
+
21
+ // Prefer the service-role client so the read is authoritative regardless of caller
22
+ // role/session; fall back to the request client if the service key isn't set yet.
23
+ let supabase: ReturnType<typeof getServiceRoleSupabaseClient>;
24
+ try {
25
+ supabase = getServiceRoleSupabaseClient();
26
+ } catch {
27
+ supabase = createClient() as unknown as ReturnType<typeof getServiceRoleSupabaseClient>;
28
+ }
29
+
30
+ try {
31
+ const { data, error } = await supabase
32
+ .from('site_settings')
33
+ .select('value')
34
+ .eq('key', 'is_admin_created')
35
+ .maybeSingle();
36
+
37
+ if (error) {
38
+ // A missing table (PostgREST PGRST205, or a "relation does not exist" message)
39
+ // means the schema hasn't been applied yet.
40
+ const missing =
41
+ (error as { code?: string }).code === 'PGRST205' ||
42
+ /relation|does not exist|schema cache/i.test(error.message ?? '');
43
+ return { configured: true, schemaReady: !missing, hasAdmin: false };
44
+ }
45
+
46
+ const hasAdmin = data?.value === true || data?.value === 'true';
47
+ return { configured: true, schemaReady: true, hasAdmin };
48
+ } catch {
49
+ return { configured: true, schemaReady: false, hasAdmin: false };
50
+ }
51
+ }
52
+
53
+ /** Throws if setup has already completed. Guards every mutating wizard action. */
54
+ export async function assertNotProvisioned(): Promise<void> {
55
+ const { hasAdmin } = await getProvisioningStatus();
56
+ if (hasAdmin) {
57
+ throw new Error('Setup has already been completed.');
58
+ }
59
+ }