create-nextblock 0.10.7 → 0.10.8
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.
- package/bin/create-nextblock.js +4 -0
- package/package.json +1 -1
- package/templates/nextblock-template/app/api/cms/check-updates/route.ts +44 -0
- package/templates/nextblock-template/app/api/cron/reset-sandbox/sandboxResetSql.ts +75 -0
- package/templates/nextblock-template/app/cms/CmsClientLayout.tsx +4 -0
- package/templates/nextblock-template/app/cms/components/SystemAlertsBanner.tsx +112 -0
- package/templates/nextblock-template/app/cms/components/system-alerts-actions.ts +31 -0
- package/templates/nextblock-template/app/cms/dashboard/components/DashboardOnboarding.tsx +11 -4
- package/templates/nextblock-template/app/cms/layout.tsx +45 -6
- package/templates/nextblock-template/docs/12-VERCEL-DEPLOYMENT.md +9 -0
- package/templates/nextblock-template/docs/13-STAYING-UP-TO-DATE.md +115 -0
- package/templates/nextblock-template/lib/onboarding/status.ts +34 -0
- package/templates/nextblock-template/lib/setup/actions.ts +4 -0
- package/templates/nextblock-template/lib/setup/migrations-bundle.ts +20 -0
- package/templates/nextblock-template/lib/updates/check-upstream.ts +404 -0
- package/templates/nextblock-template/lib/updates/repo-identity.ts +46 -0
- package/templates/nextblock-template/package.json +2 -1
- package/templates/nextblock-template/tools/build-migrate.mjs +209 -0
- package/templates/nextblock-template/tsconfig.tsbuildinfo +1 -1
package/bin/create-nextblock.js
CHANGED
|
@@ -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
|
@@ -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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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] =
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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();
|