create-nextblock 0.10.7 → 0.10.9

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.
@@ -747,6 +747,10 @@ async function runSetupWizard(projectDir, projectName) {
747
747
  'CRON_SECRET=': `CRON_SECRET=${cronSecret}`,
748
748
  'DRAFT_MODE_SECRET=': `DRAFT_MODE_SECRET=${draftSecret}`,
749
749
  'REVALIDATE_SECRET_TOKEN=': `REVALIDATE_SECRET_TOKEN=${revalidateSecret}`,
750
+ // Build-time migration hook gate for standalone installs (Milestone 4): on a
751
+ // production build with POSTGRES_URL set, pending migrations are applied before
752
+ // `next build`. Skips gracefully when the DB is unreachable.
753
+ 'NEXTBLOCK_BUILD_MIGRATE=': 'NEXTBLOCK_BUILD_MIGRATE=1',
750
754
  // The R2 public URL is consumed under two names (next/image remotePatterns + CSP, and
751
755
  // media URL resolution) — write the same value to both, matching setup.mjs.
752
756
  'NEXT_PUBLIC_R2_PUBLIC_URL=': `NEXT_PUBLIC_R2_PUBLIC_URL=${r2.publicBaseUrl}`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-nextblock",
3
- "version": "0.10.7",
3
+ "version": "0.10.9",
4
4
  "description": "",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -0,0 +1,44 @@
1
+ import { NextResponse } from 'next/server';
2
+ import { createClient } from '@nextblock-cms/db/server';
3
+ import { refreshUpstreamStatus } from '../../../../lib/updates/check-upstream';
4
+
5
+ // Node runtime: the update checker reads the filesystem (.git / workflow detection) and
6
+ // uses the service-role client. force-dynamic so it never gets statically cached.
7
+ export const runtime = 'nodejs';
8
+ export const dynamic = 'force-dynamic';
9
+
10
+ /** Authenticated ADMIN gate, mirroring lib/full-backup/server.ts. */
11
+ async function requireAdmin(): Promise<string | null> {
12
+ const supabase = createClient();
13
+ const {
14
+ data: { user },
15
+ } = await supabase.auth.getUser();
16
+ if (!user) return 'Not authenticated.';
17
+
18
+ const { data: profile, error } = await supabase
19
+ .from('profiles')
20
+ .select('role')
21
+ .eq('id', user.id)
22
+ .single();
23
+ if (error || profile?.role !== 'ADMIN') return 'Administrator role required.';
24
+ return null;
25
+ }
26
+
27
+ /**
28
+ * Admin-triggered upstream update check (Track B). Polls the GitHub Releases API,
29
+ * compares versions, and — on a standalone install — records a runtime_update_available
30
+ * alert. Returns the comparison either way so an admin "Check for updates" control can
31
+ * show the result inline.
32
+ */
33
+ export async function POST() {
34
+ const denied = await requireAdmin();
35
+ if (denied) {
36
+ return NextResponse.json({ ok: false, error: denied }, { status: 401 });
37
+ }
38
+
39
+ const { update, conflicts, snapshot } = await refreshUpstreamStatus();
40
+ // The check itself succeeded as an operation even if a network call failed; surface any
41
+ // soft error in the body rather than 500ing the admin action.
42
+ const ok = update.ok && conflicts.ok;
43
+ return NextResponse.json({ ok, update, conflicts, snapshot }, { status: ok ? 200 : 502 });
44
+ }
@@ -8180,6 +8180,81 @@ $$;
8180
8180
  ALTER FUNCTION public.duplicate_block_definition(uuid) SECURITY INVOKER;
8181
8181
 
8182
8182
 
8183
+ -- >>> FROM: 00000000000036_setup_system_alerts.sql <<<
8184
+ -- System notification layer for the automated upstream-update architecture.
8185
+ --
8186
+ -- \`system_alerts\` is the single sink that every update track writes into:
8187
+ -- * Track A (the .github/workflows/nextblock-sync.yml GitHub Action) inserts a
8188
+ -- 'merge_conflict' row via the Supabase REST API when an upstream merge can't be
8189
+ -- auto-resolved, so the CMS dashboard can point an operator at GitHub to sort it.
8190
+ -- * Track B (the runtime update engine, app/api/cms/check-updates) inserts a
8191
+ -- 'runtime_update_available' row for non-git installs (npm create / local / Docker)
8192
+ -- with a download link to the latest verified release tarball.
8193
+ -- The dashboard banner (cms/layout.tsx -> SystemAlertsBanner) renders unresolved rows.
8194
+ --
8195
+ -- Writers always use the service-role key (REST API / getServiceRoleSupabaseClient),
8196
+ -- which bypasses RLS; RLS below only governs who can READ/RESOLVE from the dashboard.
8197
+
8198
+ create table if not exists public.system_alerts (
8199
+ id uuid primary key default gen_random_uuid(),
8200
+ -- The notification kind. Constrained to the two tracks this system emits; widen the
8201
+ -- CHECK in a later migration if new alert kinds are added.
8202
+ alert_type text not null check (alert_type in ('merge_conflict', 'runtime_update_available')),
8203
+ title text not null,
8204
+ message text not null,
8205
+ -- Structured context for deep-linking the banner CTA, e.g.
8206
+ -- merge_conflict -> { "repo": "owner/name", "branch": "...", "action_url": "https://github.com/owner/name/branches" }
8207
+ -- runtime_update_available -> { "latest_version": "0.11.0", "download_url": "https://github.com/.../v0.11.0.tar.gz" }
8208
+ metadata jsonb not null default '{}'::jsonb,
8209
+ is_resolved boolean not null default false,
8210
+ resolved_at timestamptz,
8211
+ created_at timestamptz not null default now(),
8212
+ updated_at timestamptz not null default now()
8213
+ );
8214
+
8215
+ comment on table public.system_alerts is 'System notifications for the automated upstream-update architecture (merge conflicts, runtime updates available). Written by service-role; read by ADMINs.';
8216
+ comment on column public.system_alerts.alert_type is 'One of: merge_conflict, runtime_update_available.';
8217
+ comment on column public.system_alerts.metadata is 'Structured deep-link context for the dashboard banner CTA (repo/branch/action_url or latest_version/download_url).';
8218
+ comment on column public.system_alerts.is_resolved is 'When true the alert is hidden from the dashboard banner.';
8219
+
8220
+ -- Banner query: unresolved alerts, newest first.
8221
+ create index if not exists idx_system_alerts_unresolved
8222
+ on public.system_alerts (is_resolved, created_at desc);
8223
+
8224
+ -- Keep updated_at fresh on every mutation (same helper used across the schema).
8225
+ drop trigger if exists trg_system_alerts_updated_at on public.system_alerts;
8226
+ create trigger trg_system_alerts_updated_at
8227
+ before update on public.system_alerts
8228
+ for each row
8229
+ execute function public.set_current_timestamp_updated_at();
8230
+
8231
+ alter table public.system_alerts enable row level security;
8232
+
8233
+ -- Only authenticated ADMINs may view alerts in the dashboard. WRITERs and the public
8234
+ -- anon role get zero rows (RLS default-deny: no policy applies to them).
8235
+ drop policy if exists system_alerts_select_admin on public.system_alerts;
8236
+ create policy system_alerts_select_admin
8237
+ on public.system_alerts
8238
+ for select
8239
+ to authenticated
8240
+ using ((select public.get_current_user_role()) = 'ADMIN');
8241
+
8242
+ -- ADMINs may resolve (dismiss) alerts from the dashboard. Inserts are service-role only
8243
+ -- (RLS-bypassing), so there is intentionally no INSERT policy.
8244
+ drop policy if exists system_alerts_update_admin on public.system_alerts;
8245
+ create policy system_alerts_update_admin
8246
+ on public.system_alerts
8247
+ for update
8248
+ to authenticated
8249
+ using ((select public.get_current_user_role()) = 'ADMIN')
8250
+ with check ((select public.get_current_user_role()) = 'ADMIN');
8251
+
8252
+ -- Base table privileges (RLS still filters rows on top of these). Mirrors the grant
8253
+ -- model the rest of the schema uses; service_role keeps full access for the writers.
8254
+ grant select, update on public.system_alerts to authenticated;
8255
+ grant all on public.system_alerts to service_role;
8256
+
8257
+
8183
8258
  -- Step D: Anchor preserved profiles
8184
8259
  INSERT INTO public.profiles (id, updated_at, full_name, avatar_url, website, role)
8185
8260
  SELECT preserved_user.id, NULL, NULL, NULL, NULL, 'ADMIN'
@@ -12,6 +12,7 @@ import {
12
12
  ShieldCheck, Cookie, LineChart, Mail, UserPlus, SlidersHorizontal,
13
13
  } from "lucide-react"
14
14
  import TwoFactorReminderBanner from "./components/TwoFactorReminderBanner"
15
+ import SystemAlertsBanner, { type SystemAlertItem } from "./components/SystemAlertsBanner"
15
16
  import { Button } from "@nextblock-cms/ui"
16
17
  import { Avatar, AvatarFallback, AvatarImage } from "@nextblock-cms/ui"
17
18
  import { cn } from "@nextblock-cms/utils"
@@ -117,11 +118,13 @@ export default function CmsClientLayout({
117
118
  isCortexAiActive = false,
118
119
  isEcommerceActive = false,
119
120
  showTwoFactorReminder = false,
121
+ systemAlerts = [],
120
122
  }: {
121
123
  children: ReactNode,
122
124
  isCortexAiActive?: boolean,
123
125
  isEcommerceActive?: boolean,
124
126
  showTwoFactorReminder?: boolean,
127
+ systemAlerts?: SystemAlertItem[],
125
128
  }) {
126
129
  const isSandbox = process.env.NEXT_PUBLIC_IS_SANDBOX === 'true';
127
130
  const { user, profile, role, isLoading, isAdmin, isWriter } = useAuth();
@@ -498,6 +501,7 @@ export default function CmsClientLayout({
498
501
  </header>
499
502
  <main className="flex-1 min-h-0 w-full overflow-y-auto overscroll-contain px-6 pt-6 pb-20 scroll-pb-24 md:pb-24">
500
503
  {showTwoFactorReminder && <TwoFactorReminderBanner />}
504
+ <SystemAlertsBanner alerts={systemAlerts} />
501
505
  {children}
502
506
  </main>
503
507
  </div>
@@ -0,0 +1,112 @@
1
+ 'use client';
2
+
3
+ import { useState, useTransition } from 'react';
4
+ import { AlertTriangle, ArrowUpCircle, ExternalLink, X } from 'lucide-react';
5
+ import { Button } from '@nextblock-cms/ui';
6
+ import { resolveSystemAlert } from './system-alerts-actions';
7
+
8
+ export interface SystemAlertItem {
9
+ id: string;
10
+ alert_type: string;
11
+ title: string;
12
+ message: string;
13
+ metadata: Record<string, unknown> | null;
14
+ }
15
+
16
+ function readString(metadata: Record<string, unknown> | null, key: string): string | undefined {
17
+ const value = metadata?.[key];
18
+ return typeof value === 'string' && value.length > 0 ? value : undefined;
19
+ }
20
+
21
+ /**
22
+ * Dashboard banner for unresolved system_alerts. High-visibility, developer-facing:
23
+ * - merge_conflict -> amber, links to GitHub to resolve the sync (Track A).
24
+ * - runtime_update_available -> indigo, links to the release download (Track B).
25
+ * Dismissing persists is_resolved=true (ADMIN-only via RLS) and hides the alert.
26
+ */
27
+ export default function SystemAlertsBanner({ alerts }: { alerts: SystemAlertItem[] }) {
28
+ const [dismissed, setDismissed] = useState<Set<string>>(new Set());
29
+ const [isPending, startTransition] = useTransition();
30
+
31
+ const visible = alerts.filter((a) => !dismissed.has(a.id));
32
+ if (visible.length === 0) return null;
33
+
34
+ const dismiss = (id: string) => {
35
+ setDismissed((prev) => new Set(prev).add(id));
36
+ startTransition(() => {
37
+ void resolveSystemAlert(id);
38
+ });
39
+ };
40
+
41
+ return (
42
+ <div className="mb-6 flex flex-col gap-3">
43
+ {visible.map((alert) => {
44
+ const isConflict = alert.alert_type === 'merge_conflict';
45
+ const Icon = isConflict ? AlertTriangle : ArrowUpCircle;
46
+
47
+ const tone = isConflict
48
+ ? 'border-amber-300 bg-amber-50 text-amber-900 dark:border-amber-700/60 dark:bg-amber-900/20 dark:text-amber-200'
49
+ : 'border-indigo-300 bg-indigo-50 text-indigo-900 dark:border-indigo-700/60 dark:bg-indigo-900/20 dark:text-indigo-200';
50
+ const ctaBorder = isConflict
51
+ ? 'border-amber-400 dark:border-amber-600'
52
+ : 'border-indigo-400 dark:border-indigo-600';
53
+
54
+ // Track A (conflict) deep-links to GitHub; Track B (update) to the download.
55
+ const primaryHref = isConflict
56
+ ? readString(alert.metadata, 'action_url')
57
+ : readString(alert.metadata, 'download_url');
58
+ const primaryLabel = isConflict ? 'Resolve on GitHub' : 'Download latest';
59
+ const secondaryHref = isConflict
60
+ ? readString(alert.metadata, 'run_url')
61
+ : readString(alert.metadata, 'html_url');
62
+ const secondaryLabel = isConflict ? 'View workflow run' : 'Release notes';
63
+
64
+ return (
65
+ <div
66
+ key={alert.id}
67
+ className={`flex items-start gap-3 rounded-lg border px-4 py-3 ${tone}`}
68
+ role="alert"
69
+ >
70
+ <Icon className="mt-0.5 h-5 w-5 shrink-0" />
71
+ <div className="min-w-0 flex-1">
72
+ <p className="text-sm font-medium">{alert.title}</p>
73
+ <p className="text-xs opacity-90">{alert.message}</p>
74
+ {(primaryHref || secondaryHref) && (
75
+ <div className="mt-2 flex flex-wrap items-center gap-2">
76
+ {primaryHref && (
77
+ <Button asChild size="sm" variant="outline" className={ctaBorder}>
78
+ <a href={primaryHref} target="_blank" rel="noopener noreferrer">
79
+ {primaryLabel}
80
+ <ExternalLink className="ml-1.5 h-3.5 w-3.5" />
81
+ </a>
82
+ </Button>
83
+ )}
84
+ {secondaryHref && (
85
+ <a
86
+ href={secondaryHref}
87
+ target="_blank"
88
+ rel="noopener noreferrer"
89
+ className="text-xs font-medium underline underline-offset-2 opacity-80 hover:opacity-100"
90
+ >
91
+ {secondaryLabel}
92
+ </a>
93
+ )}
94
+ </div>
95
+ )}
96
+ </div>
97
+ <Button
98
+ variant="ghost"
99
+ size="icon"
100
+ onClick={() => dismiss(alert.id)}
101
+ disabled={isPending}
102
+ aria-label="Dismiss alert"
103
+ className="h-8 w-8 shrink-0"
104
+ >
105
+ <X className="h-4 w-4" />
106
+ </Button>
107
+ </div>
108
+ );
109
+ })}
110
+ </div>
111
+ );
112
+ }
@@ -0,0 +1,31 @@
1
+ 'use server';
2
+
3
+ import { revalidatePath } from 'next/cache';
4
+ import { createClient } from '@nextblock-cms/db/server';
5
+
6
+ /**
7
+ * Mark a system alert resolved (dismiss it from the dashboard banner). Runs as the
8
+ * signed-in user; the system_alerts UPDATE RLS policy restricts this to ADMINs, so a
9
+ * non-admin caller simply updates zero rows. Inserts are service-role only.
10
+ */
11
+ export async function resolveSystemAlert(
12
+ id: string,
13
+ ): Promise<{ ok: boolean; error?: string }> {
14
+ if (!id) return { ok: false, error: 'Missing alert id.' };
15
+
16
+ const supabase = createClient();
17
+ const {
18
+ data: { user },
19
+ } = await supabase.auth.getUser();
20
+ if (!user) return { ok: false, error: 'Not authenticated.' };
21
+
22
+ const { error } = await supabase
23
+ .from('system_alerts')
24
+ .update({ is_resolved: true, resolved_at: new Date().toISOString() })
25
+ .eq('id', id);
26
+
27
+ if (error) return { ok: false, error: error.message };
28
+
29
+ revalidatePath('/cms', 'layout');
30
+ return { ok: true };
31
+ }
@@ -103,10 +103,17 @@ export default function DashboardOnboarding({
103
103
  </div>
104
104
  {!step.done && step.key !== 'admin' && (
105
105
  <Button asChild variant="outline" size="sm" className="shrink-0">
106
- <Link href={step.href}>
107
- Set up
108
- <ArrowRight className="ml-1 h-3.5 w-3.5" />
109
- </Link>
106
+ {step.isExternal ? (
107
+ <a href={step.href} target="_blank" rel="noopener noreferrer">
108
+ Set up
109
+ <ArrowRight className="ml-1 h-3.5 w-3.5" />
110
+ </a>
111
+ ) : (
112
+ <Link href={step.href}>
113
+ Set up
114
+ <ArrowRight className="ml-1 h-3.5 w-3.5" />
115
+ </Link>
116
+ )}
110
117
  </Button>
111
118
  )}
112
119
  </li>
@@ -1,8 +1,39 @@
1
1
  import 'katex/dist/katex.min.css';
2
2
  import { redirect } from 'next/navigation';
3
+ import { after } from 'next/server';
3
4
  import CmsClientLayout from "./CmsClientLayout";
4
- import { verifyPackageOnline } from '@nextblock-cms/db/server';
5
+ import { verifyPackageOnline, createClient } from '@nextblock-cms/db/server';
5
6
  import { evaluateTwoFactor, getStaffTwoFactorReminder } from '../../lib/auth/twoFactor';
7
+ import { maybeRefreshUpstreamStatus } from '../../lib/updates/check-upstream';
8
+ import type { SystemAlertItem } from './components/SystemAlertsBanner';
9
+
10
+ /**
11
+ * Unresolved system alerts for the dashboard banner. Runs as the signed-in user, so the
12
+ * system_alerts SELECT RLS policy returns rows only for ADMINs (WRITERs get an empty
13
+ * list). Best-effort: any failure (e.g. the table not yet migrated) yields no banner.
14
+ */
15
+ async function getUnresolvedSystemAlerts(): Promise<SystemAlertItem[]> {
16
+ try {
17
+ const supabase = createClient();
18
+ const { data, error } = await supabase
19
+ .from('system_alerts')
20
+ .select('id, alert_type, title, message, metadata')
21
+ .eq('is_resolved', false)
22
+ .in('alert_type', ['merge_conflict', 'runtime_update_available'])
23
+ .order('created_at', { ascending: false })
24
+ .limit(20);
25
+ if (error || !data) return [];
26
+ return data.map((a) => ({
27
+ id: a.id,
28
+ alert_type: a.alert_type,
29
+ title: a.title,
30
+ message: a.message,
31
+ metadata: (a.metadata ?? null) as Record<string, unknown> | null,
32
+ }));
33
+ } catch {
34
+ return [];
35
+ }
36
+ }
6
37
 
7
38
  export default async function CmsLayout({
8
39
  children,
@@ -16,17 +47,25 @@ export default async function CmsLayout({
16
47
  redirect('/two-factor?redirect_to=/cms/dashboard');
17
48
  }
18
49
 
19
- const [isEcommerceActive, isCortexAiActive, showTwoFactorReminder] = await Promise.all([
20
- verifyPackageOnline('ecommerce'),
21
- verifyPackageOnline('cortex-ai'),
22
- getStaffTwoFactorReminder(),
23
- ]);
50
+ const [isEcommerceActive, isCortexAiActive, showTwoFactorReminder, systemAlerts] =
51
+ await Promise.all([
52
+ verifyPackageOnline('ecommerce'),
53
+ verifyPackageOnline('cortex-ai'),
54
+ getStaffTwoFactorReminder(),
55
+ getUnresolvedSystemAlerts(),
56
+ ]);
57
+
58
+ // After the response, refresh upstream update/conflict status in the background
59
+ // (throttled to ~6h, see maybeRefreshUpstreamStatus). This keeps the banner current
60
+ // without a cron — so it works on Vercel Hobby (limited crons) and self-hosted alike.
61
+ after(() => maybeRefreshUpstreamStatus());
24
62
 
25
63
  return (
26
64
  <CmsClientLayout
27
65
  isCortexAiActive={isCortexAiActive}
28
66
  isEcommerceActive={isEcommerceActive}
29
67
  showTwoFactorReminder={showTwoFactorReminder}
68
+ systemAlerts={systemAlerts}
30
69
  >
31
70
  {children}
32
71
  </CmsClientLayout>
@@ -158,3 +158,12 @@ see it scheduled.
158
158
 
159
159
  Visit the deployment URL — it redirects to `/setup` until the first admin exists.
160
160
  Complete the wizard, then sign in at `/cms/dashboard`.
161
+
162
+ **Turn on automatic updates.** The 1-click deploy forks NextBlock into your Git provider
163
+ and ships a daily upstream-sync GitHub Action — but GitHub **disables Actions on a fresh
164
+ fork** until you enable them once (your repo → **Actions** tab → *"I understand my
165
+ workflows, go ahead and enable them."*). The dashboard onboarding checklist reminds you and
166
+ links straight there. We recommend a **public** fork (fully zero-config); a **private**
167
+ fork additionally needs a `NEXTBLOCK_GITHUB_TOKEN` env var for the in-CMS conflict banner.
168
+ Full details — both tracks, conflict handling, and build-time migrations — are in
169
+ [docs/13](./13-STAYING-UP-TO-DATE.md).
@@ -0,0 +1,115 @@
1
+ # 13 · Staying Up to Date (Automated Upstream Updates)
2
+
3
+ NextBlock keeps your instance in sync with the upstream project
4
+ (`nextblock-cms/nextblock`) with **as little manual work as possible**. How updates
5
+ arrive depends on how you deployed, and the system auto-detects which path applies:
6
+
7
+ | Install type | Track | How updates arrive |
8
+ | :--- | :--- | :--- |
9
+ | Vercel 1-click / GitHub fork (git-backed) | **A** | A daily GitHub Action merges upstream and pushes to your deploy branch (→ Vercel CD). |
10
+ | `npm create nextblock` / local clone / Docker image (standalone) | **B** | The CMS checks GitHub Releases and shows a "download the new version" banner. |
11
+
12
+ Both tracks surface their status in the CMS through a dashboard banner backed by the
13
+ `system_alerts` table (migration `00000000000036`). Reads are ADMIN-only (RLS).
14
+
15
+ ---
16
+
17
+ ## Track A — Git-backed installs (Vercel 1-click, GitHub forks)
18
+
19
+ The workflow lives at [`.github/workflows/nextblock-sync.yml`](../.github/workflows/nextblock-sync.yml).
20
+ It runs **daily at 00:00 UTC** and on demand (**Actions → NextBlock Upstream Sync → Run
21
+ workflow**). Each run:
22
+
23
+ 1. Merges the upstream release branch into your deploy branch.
24
+ 2. **Clean merge** → commits and pushes to your branch, which triggers a normal Vercel
25
+ deployment. Any open conflict issue is auto-closed.
26
+ 3. **Conflict** → aborts the merge and opens (or updates) a GitHub Issue labeled
27
+ `nextblock-sync-conflict`. The CMS mirrors that issue into an **amber banner** on the
28
+ dashboard with a link to resolve it. Once you resolve and **close the issue**, the
29
+ banner clears automatically.
30
+
31
+ ### One-time step: enable GitHub Actions
32
+
33
+ GitHub **disables Actions on a freshly-forked repo** until you turn them on once:
34
+
35
+ > Your repo → **Actions** tab → **"I understand my workflows, go ahead and enable them."**
36
+
37
+ The dashboard onboarding checklist reminds you of this ("Enable automatic updates
38
+ (GitHub Actions)") and links straight to your repo's Actions tab. The step marks itself
39
+ done once the workflow has run at least once.
40
+
41
+ ### No GitHub secrets required (public forks)
42
+
43
+ The conflict signal uses the **`GITHUB_TOKEN` that GitHub provides to every workflow
44
+ automatically** — you do **not** add any Supabase secret to GitHub. The app writes the
45
+ dashboard alert itself using the Supabase key it already has, and reads your repo's
46
+ conflict issues over the public GitHub API.
47
+
48
+ > **We recommend forking to a _public_ repository** — it's fully zero-config.
49
+
50
+ ### Private forks
51
+
52
+ If your fork is **private**, the public GitHub API can't read its issues, so add **one**
53
+ environment variable to your deployment (Vercel project → Settings → Environment
54
+ Variables, or your `.env`):
55
+
56
+ | Variable | Value |
57
+ | :--- | :--- |
58
+ | `NEXTBLOCK_GITHUB_TOKEN` | A GitHub token with **read access to issues** on your fork (a fine-grained PAT scoped to the repo, or a classic token with `repo`). |
59
+
60
+ With that set, the dashboard conflict banner works on private forks too. (The workflow
61
+ itself still needs no extra secret — `GITHUB_TOKEN` covers it either way.)
62
+
63
+ ### How the dashboard stays current (no cron)
64
+
65
+ The CMS refreshes update/conflict status **in the background after a dashboard page
66
+ loads** (throttled to ~6 hours), so it works on Vercel's Hobby plan without consuming a
67
+ cron slot. Admins can also force a check immediately:
68
+
69
+ ```
70
+ POST /api/cms/check-updates # admin-only; returns the version + conflict status
71
+ ```
72
+
73
+ ---
74
+
75
+ ## Track B — Standalone installs (npm create / local / Docker)
76
+
77
+ These installs aren't wired to a GitHub Action, so NextBlock checks the **GitHub Releases
78
+ API** and, when a newer release exists, records a `runtime_update_available` alert — an
79
+ **indigo banner** on the dashboard with a direct **download link** to the release tarball.
80
+ Updating is manual by design: download the archive, replace your files, and update
81
+ dependencies (`npm install`). The same admin check endpoint above triggers a check on
82
+ demand.
83
+
84
+ ---
85
+
86
+ ## Schema stays in step with deploys (build-time migrations)
87
+
88
+ So a new version's code never runs against an old schema, a build-time hook
89
+ ([`apps/nextblock/tools/build-migrate.mjs`](../apps/nextblock/tools/build-migrate.mjs))
90
+ applies pending, forward-only migrations **before** `next build`:
91
+
92
+ - **Vercel:** runs automatically when `VERCEL_ENV=production`; **preview/development
93
+ builds are skipped** so they never touch live data.
94
+ - **Standalone / local / Docker:** gated on `NEXTBLOCK_BUILD_MIGRATE=1`, which the
95
+ `/setup` wizard and the create/Docker setup scripts write into your env automatically.
96
+
97
+ It is **non-destructive and never breaks the build** — if the database is unreachable it
98
+ logs a warning and continues. Migrations are tracked in `supabase_migrations.schema_migrations`,
99
+ identically to the Supabase CLI.
100
+
101
+ > **Edge case:** if your project's migration history is empty/inconsistent, the hook skips
102
+ > rather than risk misapplying. Run `npm run db:migrate:repair-history` then
103
+ > `npm run db:migrate` once to reconcile (see [docs/04](./04-DATABASE-AND-AUTH.md)).
104
+
105
+ ---
106
+
107
+ ## Quick reference
108
+
109
+ | You want… | Do this |
110
+ | :--- | :--- |
111
+ | Fully hands-off updates | Fork **public**, deploy on Vercel, **enable Actions** once. |
112
+ | Conflict banners on a **private** fork | Also set `NEXTBLOCK_GITHUB_TOKEN`. |
113
+ | To update a **standalone** install | Watch for the dashboard banner → download → replace → `npm install`. |
114
+ | To force an update check now | Dashboard (admin) → it polls in the background; or `POST /api/cms/check-updates`. |
115
+ | To resolve a sync conflict | Open the linked GitHub issue, merge upstream locally, fix, push, close the issue. |
@@ -5,6 +5,9 @@ import { createClient } from '@nextblock-cms/db/server';
5
5
  import { getStoreConfigStatus } from '@nextblock-cms/ecommerce/server';
6
6
  import { getEmailPublicSettings } from '../config/email-settings';
7
7
  import { getPrivacySettings } from '../privacy/settings';
8
+ import { detectChannel } from '../setup/env-status';
9
+ import { getSystemConfiguration } from '../setup/system-config';
10
+ import { selfActionsUrl } from '../updates/repo-identity';
8
11
 
9
12
  export type OnboardingStep = {
10
13
  key: string;
@@ -13,6 +16,8 @@ export type OnboardingStep = {
13
16
  href: string;
14
17
  done: boolean;
15
18
  optional: boolean;
19
+ /** When true, render the CTA as an external link (new tab) instead of an in-app route. */
20
+ isExternal?: boolean;
16
21
  };
17
22
 
18
23
  export type OnboardingStatus = {
@@ -161,6 +166,35 @@ export async function getOnboardingStatus(opts: {
161
166
  });
162
167
  }
163
168
 
169
+ // Git-backed (Vercel 1-click / fork) installs: remind the operator to enable GitHub
170
+ // Actions so the upstream sync workflow can run. "done" flips once the background poll
171
+ // (maybeRefreshUpstreamStatus) has seen the workflow run at least once.
172
+ if (detectChannel() === 'vercel') {
173
+ let actionsActive = false;
174
+ try {
175
+ const config = await getSystemConfiguration();
176
+ const upstream = config.settings?.['upstream_status'] as
177
+ | { actions_active?: boolean }
178
+ | undefined;
179
+ actionsActive = upstream?.actions_active === true;
180
+ } catch {
181
+ actionsActive = false;
182
+ }
183
+ steps.push({
184
+ key: 'github-actions',
185
+ title: 'Enable automatic updates (GitHub Actions)',
186
+ description: actionsActive
187
+ ? 'Automated upstream sync is active for your repository.'
188
+ : 'Turn on GitHub Actions in your forked repo so NextBlock can merge upstream updates for you.',
189
+ href:
190
+ selfActionsUrl() ??
191
+ 'https://docs.github.com/actions/managing-workflow-runs-and-deployments/managing-workflow-runs/disabling-and-enabling-a-workflow',
192
+ done: actionsActive,
193
+ optional: true,
194
+ isExternal: true,
195
+ });
196
+ }
197
+
164
198
  const completed = steps.filter((s) => s.done).length;
165
199
 
166
200
  return {
@@ -182,6 +182,10 @@ export async function saveSupabaseConnection(input: ConnectionInput): Promise<Ac
182
182
  NEXT_PUBLIC_SUPABASE_URL: supabaseUrl,
183
183
  NEXT_PUBLIC_SUPABASE_ANON_KEY: anonKey,
184
184
  SUPABASE_SERVICE_ROLE_KEY: serviceRoleKey,
185
+ // Enable the build-time migration hook for this install (Milestone 4). On Vercel
186
+ // VERCEL_ENV gates it instead; off-Vercel (local / Docker / standalone) production
187
+ // builds read this flag. Only written to a local-writable .env (no-op on Vercel).
188
+ NEXTBLOCK_BUILD_MIGRATE: '1',
185
189
  };
186
190
  if (input.siteUrl?.trim()) values.NEXT_PUBLIC_URL = input.siteUrl.trim();
187
191
  if (input.postgresUrl?.trim()) values.POSTGRES_URL = input.postgresUrl.trim();