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.
- 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
|
@@ -178,5 +178,25 @@ export const MIGRATIONS_BUNDLE: BundledMigration[] = [
|
|
|
178
178
|
"version": "00000000000032",
|
|
179
179
|
"name": "00000000000032_neutralize_seeded_contact_emails.sql",
|
|
180
180
|
"sql": "-- 00000000000032_neutralize_seeded_contact_emails.sql\n-- Remove the original authors' contact addresses from seeded content so a\n-- downloaded / self-hosted copy of NextBlock never routes mail to us.\n--\n-- Earlier migrations baked real addresses into block content:\n-- * 00000000000027 seeded `privacy@nextblock.dev` across the Privacy Policy and\n-- Terms pages (EN + FR), as visible text and `mailto:` links.\n-- * 00000000000010 seeded a `mailto:info@nextblock.dev` CTA on the French home\n-- page (the English page correctly links to /contact), and `foo@bar.com` as\n-- the contact form recipient.\n--\n-- Migrations are append-only, so this is a forward-only data fix rather than an\n-- edit of those files. Each statement is idempotent (a no-op once applied).\n--\n-- The `{{privacy_email}}` token is resolved at render time by the app\n-- (apps/nextblock/lib/privacy/contact-emails.ts): admin \"Support email\" setting\n-- -> SANDBOX_PRIVACY_EMAIL env (sandbox only) -> privacy@example.com fallback.\n\n-- 1. Privacy / Terms legal pages: swap the hard-coded address for a merge tag.\nUPDATE public.blocks\nSET content = replace(content::text, 'privacy@nextblock.dev', '{{privacy_email}}')::jsonb\nWHERE content::text LIKE '%privacy@nextblock.dev%';\n\n-- 2. French home \"Nous contacter\" CTA: point at the contact form like the English\n-- page instead of a mailto to our inbox.\nUPDATE public.blocks\nSET content = replace(content::text, 'mailto:info@nextblock.dev', '/contact')::jsonb\nWHERE content::text LIKE '%mailto:info@nextblock.dev%';\n\n-- 3. Contact form default recipient: use a neutral placeholder. In sandbox the\n-- app overrides this with SANDBOX_CONTACT_EMAIL at submit time.\nUPDATE public.blocks\nSET content = replace(content::text, 'foo@bar.com', 'contact@example.com')::jsonb\nWHERE content::text LIKE '%foo@bar.com%';\n"
|
|
181
|
+
},
|
|
182
|
+
{
|
|
183
|
+
"version": "00000000000033",
|
|
184
|
+
"name": "00000000000033_setup_config_settings.sql",
|
|
185
|
+
"sql": "-- 00000000000033_setup_config_settings.sql\n-- DB-backed CMS configuration: move SMTP and payment-provider credentials out of\n-- environment variables and into `site_settings`. Secret rows (email_secret,\n-- payment_secret) hold AES-256-GCM envelopes and are restricted to ADMIN / service_role,\n-- extending the sensitive-key masking established in migration 018. Public rows hold\n-- non-secret config (SMTP host/from, publishable keys, provider flags) and the dashboard\n-- onboarding state.\n--\n-- This re-issues ALL FOUR site_settings policies because the masked-key list is embedded\n-- in each policy body and Postgres has no incremental \"add a key\" operation.\n\nCOMMENT ON TABLE public.site_settings IS 'Key-value store for global site settings. Sensitive keys (Cortex AI BYOK, Bot Protection Secret, Email secret, Payment secret) hold encrypted envelopes and are restricted to ADMIN via row-level policies.';\n\nDROP POLICY IF EXISTS site_settings_read_policy ON public.site_settings;\nDROP POLICY IF EXISTS site_settings_insert_policy ON public.site_settings;\nDROP POLICY IF EXISTS site_settings_update_policy ON public.site_settings;\nDROP POLICY IF EXISTS site_settings_delete_policy ON public.site_settings;\n\nCREATE POLICY site_settings_read_policy\n ON public.site_settings\n FOR SELECT\n TO public\n USING (\n key NOT IN ('cortex_ai_openrouter_api_key', 'bot_protection_secret', 'email_secret', 'payment_secret')\n OR (\n key IN ('cortex_ai_openrouter_api_key', 'bot_protection_secret', 'email_secret', 'payment_secret')\n AND (SELECT auth.role()) = 'authenticated'\n AND (SELECT public.get_current_user_role()) = 'ADMIN'\n )\n );\n\nCREATE POLICY site_settings_insert_policy\n ON public.site_settings\n FOR INSERT\n TO authenticated\n WITH CHECK (\n (\n key NOT IN ('cortex_ai_openrouter_api_key', 'bot_protection_secret', 'email_secret', 'payment_secret')\n AND (SELECT public.get_current_user_role()) IN ('ADMIN', 'WRITER')\n )\n OR (\n key IN ('cortex_ai_openrouter_api_key', 'bot_protection_secret', 'email_secret', 'payment_secret')\n AND (SELECT public.get_current_user_role()) = 'ADMIN'\n )\n );\n\nCREATE POLICY site_settings_update_policy\n ON public.site_settings\n FOR UPDATE\n TO authenticated\n USING (\n (\n key NOT IN ('cortex_ai_openrouter_api_key', 'bot_protection_secret', 'email_secret', 'payment_secret')\n AND (SELECT public.get_current_user_role()) IN ('ADMIN', 'WRITER')\n )\n OR (\n key IN ('cortex_ai_openrouter_api_key', 'bot_protection_secret', 'email_secret', 'payment_secret')\n AND (SELECT public.get_current_user_role()) = 'ADMIN'\n )\n )\n WITH CHECK (\n (\n key NOT IN ('cortex_ai_openrouter_api_key', 'bot_protection_secret', 'email_secret', 'payment_secret')\n AND (SELECT public.get_current_user_role()) IN ('ADMIN', 'WRITER')\n )\n OR (\n key IN ('cortex_ai_openrouter_api_key', 'bot_protection_secret', 'email_secret', 'payment_secret')\n AND (SELECT public.get_current_user_role()) = 'ADMIN'\n )\n );\n\nCREATE POLICY site_settings_delete_policy\n ON public.site_settings\n FOR DELETE\n TO authenticated\n USING (\n (\n key NOT IN ('cortex_ai_openrouter_api_key', 'bot_protection_secret', 'email_secret', 'payment_secret')\n AND (SELECT public.get_current_user_role()) IN ('ADMIN', 'WRITER')\n )\n OR (\n key IN ('cortex_ai_openrouter_api_key', 'bot_protection_secret', 'email_secret', 'payment_secret')\n AND (SELECT public.get_current_user_role()) = 'ADMIN'\n )\n );\n\n-- Seed the new configuration rows (idempotent). Secret rows are created on first save\n-- from the CMS, so they are intentionally not seeded here.\nINSERT INTO public.site_settings (key, value)\nVALUES ('email_public', '{\"host\": \"\", \"port\": \"\", \"fromEmail\": \"\", \"fromName\": \"\", \"secure\": true}'::jsonb)\nON CONFLICT (key) DO NOTHING;\n\nINSERT INTO public.site_settings (key, value)\nVALUES ('payment_public', '{\"stripe\": {\"publishableKey\": \"\"}, \"freemius\": {\"developerId\": \"\", \"publicKey\": \"\", \"productId\": \"\", \"sandboxEnabled\": false}}'::jsonb)\nON CONFLICT (key) DO NOTHING;\n\nINSERT INTO public.site_settings (key, value)\nVALUES ('onboarding_state', '{\"dismissed\": false, \"skipped\": []}'::jsonb)\nON CONFLICT (key) DO NOTHING;\n"
|
|
186
|
+
},
|
|
187
|
+
{
|
|
188
|
+
"version": "00000000000034",
|
|
189
|
+
"name": "00000000000034_enable_staff_2fa_reminder_default.sql",
|
|
190
|
+
"sql": "-- 00000000000034_enable_staff_2fa_reminder_default.sql\n-- Turn the \"Encourage staff to enable 2FA\" policy ON by default. The reminder banner is\n-- now implemented (shown to ADMIN/WRITER accounts without a second factor); migration 027\n-- seeded this flag to false, so flip the existing security_settings row to true. Admins can\n-- still turn it off afterward. The sandbox never enforces it (handled at runtime), so the\n-- stored value here is harmless after a sandbox reset.\n\nUPDATE public.site_settings\nSET value = jsonb_set(coalesce(value, '{}'::jsonb), '{enforce_staff_2fa}', 'true'::jsonb)\nWHERE key = 'security_settings';\n\n-- Cover the unlikely case where the row is missing (it is seeded in migration 027).\nINSERT INTO public.site_settings (key, value)\nVALUES ('security_settings', '{\"trusted_device_days\": 30, \"enforce_staff_2fa\": true}'::jsonb)\nON CONFLICT (key) DO NOTHING;\n"
|
|
191
|
+
},
|
|
192
|
+
{
|
|
193
|
+
"version": "00000000000035",
|
|
194
|
+
"name": "00000000000035_reassert_advisor_fixes.sql",
|
|
195
|
+
"sql": "-- 00000000000035_reassert_advisor_fixes.sql\n-- Re-assert two Supabase Advisor (database linter) fixes that were first applied in\n-- migration 00000000000028 but can be lost when a database is restored/reset to a\n-- pre-028 state while its migration history still records 028 as applied (so the forward\n-- tooling never re-runs it). These two advisors reappeared, so we re-apply the fixes in a\n-- forward-only, idempotent way. No application behaviour changes.\n--\n-- 1. 0011 function_search_path_mutable\n-- public.handle_ucp_cart_sessions_update() needs a pinned search_path.\n-- 2. 0029 authenticated_security_definer_function_executable\n-- public.duplicate_block_definition(uuid) must run as SECURITY INVOKER (it already\n-- keeps its own ADMIN/WRITER role check and is gated by custom_block_definitions RLS).\n\n-- 1. Pin the search_path. Re-create the function with SET search_path baked into its\n-- definition (not just an ALTER) so a future CREATE OR REPLACE can't silently drop it.\n-- The body only calls now() (pg_catalog, always implicitly searched), so an empty\n-- search_path is safe. CREATE OR REPLACE keeps the function OID, so the existing\n-- trg_handle_ucp_cart_sessions_update trigger and the service_role grant are preserved.\nCREATE OR REPLACE FUNCTION public.handle_ucp_cart_sessions_update()\nRETURNS trigger\nLANGUAGE plpgsql\nSET search_path = ''\nAS $$\nBEGIN\n NEW.updated_at = now();\n RETURN NEW;\nEND;\n$$;\n\n-- 2. Ensure the duplicate helper runs with the caller's privileges. The function body\n-- (unchanged) still raises 42501 for non-ADMIN/WRITER callers, and its SELECT/INSERT\n-- are gated by custom_block_definitions RLS, so it does not need definer privileges.\nALTER FUNCTION public.duplicate_block_definition(uuid) SECURITY INVOKER;\n"
|
|
196
|
+
},
|
|
197
|
+
{
|
|
198
|
+
"version": "00000000000036",
|
|
199
|
+
"name": "00000000000036_setup_system_alerts.sql",
|
|
200
|
+
"sql": "-- System notification layer for the automated upstream-update architecture.\n--\n-- `system_alerts` is the single sink that every update track writes into:\n-- * Track A (the .github/workflows/nextblock-sync.yml GitHub Action) inserts a\n-- 'merge_conflict' row via the Supabase REST API when an upstream merge can't be\n-- auto-resolved, so the CMS dashboard can point an operator at GitHub to sort it.\n-- * Track B (the runtime update engine, app/api/cms/check-updates) inserts a\n-- 'runtime_update_available' row for non-git installs (npm create / local / Docker)\n-- with a download link to the latest verified release tarball.\n-- The dashboard banner (cms/layout.tsx -> SystemAlertsBanner) renders unresolved rows.\n--\n-- Writers always use the service-role key (REST API / getServiceRoleSupabaseClient),\n-- which bypasses RLS; RLS below only governs who can READ/RESOLVE from the dashboard.\n\ncreate table if not exists public.system_alerts (\n id uuid primary key default gen_random_uuid(),\n -- The notification kind. Constrained to the two tracks this system emits; widen the\n -- CHECK in a later migration if new alert kinds are added.\n alert_type text not null check (alert_type in ('merge_conflict', 'runtime_update_available')),\n title text not null,\n message text not null,\n -- Structured context for deep-linking the banner CTA, e.g.\n -- merge_conflict -> { \"repo\": \"owner/name\", \"branch\": \"...\", \"action_url\": \"https://github.com/owner/name/branches\" }\n -- runtime_update_available -> { \"latest_version\": \"0.11.0\", \"download_url\": \"https://github.com/.../v0.11.0.tar.gz\" }\n metadata jsonb not null default '{}'::jsonb,\n is_resolved boolean not null default false,\n resolved_at timestamptz,\n created_at timestamptz not null default now(),\n updated_at timestamptz not null default now()\n);\n\ncomment 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.';\ncomment on column public.system_alerts.alert_type is 'One of: merge_conflict, runtime_update_available.';\ncomment 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).';\ncomment on column public.system_alerts.is_resolved is 'When true the alert is hidden from the dashboard banner.';\n\n-- Banner query: unresolved alerts, newest first.\ncreate index if not exists idx_system_alerts_unresolved\n on public.system_alerts (is_resolved, created_at desc);\n\n-- Keep updated_at fresh on every mutation (same helper used across the schema).\ndrop trigger if exists trg_system_alerts_updated_at on public.system_alerts;\ncreate trigger trg_system_alerts_updated_at\n before update on public.system_alerts\n for each row\n execute function public.set_current_timestamp_updated_at();\n\nalter table public.system_alerts enable row level security;\n\n-- Only authenticated ADMINs may view alerts in the dashboard. WRITERs and the public\n-- anon role get zero rows (RLS default-deny: no policy applies to them).\ndrop policy if exists system_alerts_select_admin on public.system_alerts;\ncreate policy system_alerts_select_admin\n on public.system_alerts\n for select\n to authenticated\n using ((select public.get_current_user_role()) = 'ADMIN');\n\n-- ADMINs may resolve (dismiss) alerts from the dashboard. Inserts are service-role only\n-- (RLS-bypassing), so there is intentionally no INSERT policy.\ndrop policy if exists system_alerts_update_admin on public.system_alerts;\ncreate policy system_alerts_update_admin\n on public.system_alerts\n for update\n to authenticated\n using ((select public.get_current_user_role()) = 'ADMIN')\n with check ((select public.get_current_user_role()) = 'ADMIN');\n\n-- Base table privileges (RLS still filters rows on top of these). Mirrors the grant\n-- model the rest of the schema uses; service_role keeps full access for the writers.\ngrant select, update on public.system_alerts to authenticated;\ngrant all on public.system_alerts to service_role;\n"
|
|
181
201
|
}
|
|
182
202
|
];
|
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
import 'server-only';
|
|
2
|
+
// Track B — Decoupled application runtime update engine + Track-A conflict mirror.
|
|
3
|
+
//
|
|
4
|
+
// Two responsibilities, both writing into system_alerts via the service-role client
|
|
5
|
+
// (the key this install already has — no GitHub secrets required):
|
|
6
|
+
// 1. checkForUpstreamUpdate(): poll the public GitHub Releases API, compare the latest
|
|
7
|
+
// release against this install's package.json version, and on a NON-git (standalone)
|
|
8
|
+
// install record a 'runtime_update_available' alert with a tarball download link.
|
|
9
|
+
// 2. checkForSyncConflicts(): for git-backed installs, poll THIS fork's issues for the
|
|
10
|
+
// 'nextblock-sync-conflict' label the sync workflow opens, and mirror open ones into
|
|
11
|
+
// 'merge_conflict' alerts (clearing them when the issue closes). This is the "pull"
|
|
12
|
+
// half of Track A: the GitHub Action signals with the auto-provided GITHUB_TOKEN, and
|
|
13
|
+
// the app — which holds the Supabase key — owns the alert write. Zero GitHub secrets.
|
|
14
|
+
//
|
|
15
|
+
// Private forks: set NEXTBLOCK_GITHUB_TOKEN so the issue/run polling is authenticated.
|
|
16
|
+
// Public forks need nothing (the unauthenticated GitHub API is sufficient).
|
|
17
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
import { getServiceRoleSupabaseClient } from '@nextblock-cms/db/server';
|
|
20
|
+
import { getSystemConfiguration, setSystemConfigurationServiceRole } from '../setup/system-config';
|
|
21
|
+
import { resolveSelfRepo } from './repo-identity';
|
|
22
|
+
import pkg from '../../package.json';
|
|
23
|
+
|
|
24
|
+
const UPSTREAM_REPO = 'nextblock-cms/nextblock';
|
|
25
|
+
const RELEASES_API = `https://api.github.com/repos/${UPSTREAM_REPO}/releases/latest`;
|
|
26
|
+
const CONFLICT_LABEL = 'nextblock-sync-conflict';
|
|
27
|
+
const REFRESH_INTERVAL_MS = 6 * 60 * 60 * 1000; // throttle the background poll to every 6h
|
|
28
|
+
|
|
29
|
+
export type UpdateTrack = 'git' | 'standalone';
|
|
30
|
+
|
|
31
|
+
export interface UpstreamUpdateResult {
|
|
32
|
+
ok: boolean;
|
|
33
|
+
currentVersion: string;
|
|
34
|
+
latestVersion: string | null;
|
|
35
|
+
updateAvailable: boolean;
|
|
36
|
+
track: UpdateTrack;
|
|
37
|
+
htmlUrl?: string;
|
|
38
|
+
tarballUrl?: string;
|
|
39
|
+
zipballUrl?: string;
|
|
40
|
+
publishedAt?: string;
|
|
41
|
+
alertRecorded: boolean;
|
|
42
|
+
error?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface SyncConflictResult {
|
|
46
|
+
ok: boolean;
|
|
47
|
+
repo: string | null;
|
|
48
|
+
openConflicts: number;
|
|
49
|
+
actionsActive: boolean;
|
|
50
|
+
alertsWritten: number;
|
|
51
|
+
alertsResolved: number;
|
|
52
|
+
error?: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** GitHub API headers, authenticated when a token is configured (needed for private forks). */
|
|
56
|
+
function githubHeaders(): Record<string, string> {
|
|
57
|
+
const token =
|
|
58
|
+
process.env.NEXTBLOCK_GITHUB_TOKEN?.trim() ||
|
|
59
|
+
process.env.GITHUB_TOKEN?.trim() ||
|
|
60
|
+
process.env.GH_TOKEN?.trim();
|
|
61
|
+
const headers: Record<string, string> = {
|
|
62
|
+
Accept: 'application/vnd.github+json',
|
|
63
|
+
'X-GitHub-Api-Version': '2022-11-28',
|
|
64
|
+
'User-Agent': 'nextblock-update-checker',
|
|
65
|
+
};
|
|
66
|
+
if (token) headers.Authorization = `Bearer ${token}`;
|
|
67
|
+
return headers;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Compare two semver-ish strings. >0 if a>b, <0 if a<b, 0 if equal. */
|
|
71
|
+
function compareSemver(a: string, b: string): number {
|
|
72
|
+
const parse = (v: string) =>
|
|
73
|
+
v
|
|
74
|
+
.trim()
|
|
75
|
+
.replace(/^v/i, '')
|
|
76
|
+
.split('-')[0]
|
|
77
|
+
.split('.')
|
|
78
|
+
.map((n) => Number.parseInt(n, 10) || 0);
|
|
79
|
+
const pa = parse(a);
|
|
80
|
+
const pb = parse(b);
|
|
81
|
+
for (let i = 0; i < 3; i++) {
|
|
82
|
+
const diff = (pa[i] ?? 0) - (pb[i] ?? 0);
|
|
83
|
+
if (diff !== 0) return diff > 0 ? 1 : -1;
|
|
84
|
+
}
|
|
85
|
+
return 0;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Classify how this install receives updates. Explicit NEXTBLOCK_UPDATE_TRACK wins;
|
|
90
|
+
* otherwise Vercel / repos carrying the sync workflow or an upstream remote are 'git'
|
|
91
|
+
* (Track A), everything else is 'standalone' (Track B).
|
|
92
|
+
*/
|
|
93
|
+
function detectTrack(): UpdateTrack {
|
|
94
|
+
const override = process.env.NEXTBLOCK_UPDATE_TRACK?.trim().toLowerCase();
|
|
95
|
+
if (override === 'git' || override === 'standalone') return override;
|
|
96
|
+
|
|
97
|
+
if (process.env.VERCEL === '1') return 'git';
|
|
98
|
+
if (resolveSelfRepo()) {
|
|
99
|
+
// A resolvable GitHub repo identity means git-backed; double-check it's a NextBlock fork.
|
|
100
|
+
const cwd = process.cwd();
|
|
101
|
+
try {
|
|
102
|
+
if (existsSync(path.join(cwd, '.github', 'workflows', 'nextblock-sync.yml'))) return 'git';
|
|
103
|
+
} catch {
|
|
104
|
+
/* ignore */
|
|
105
|
+
}
|
|
106
|
+
try {
|
|
107
|
+
const gitConfig = path.join(cwd, '.git', 'config');
|
|
108
|
+
if (existsSync(gitConfig) && /nextblock/i.test(readFileSync(gitConfig, 'utf8'))) return 'git';
|
|
109
|
+
} catch {
|
|
110
|
+
/* ignore */
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return 'standalone';
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Poll GitHub Releases and, on a standalone install with a newer release, record a
|
|
118
|
+
* runtime_update_available alert (deduped by latest version). Never throws.
|
|
119
|
+
*/
|
|
120
|
+
export async function checkForUpstreamUpdate(): Promise<UpstreamUpdateResult> {
|
|
121
|
+
const currentVersion = pkg.version;
|
|
122
|
+
const track = detectTrack();
|
|
123
|
+
const base: UpstreamUpdateResult = {
|
|
124
|
+
ok: false,
|
|
125
|
+
currentVersion,
|
|
126
|
+
latestVersion: null,
|
|
127
|
+
updateAvailable: false,
|
|
128
|
+
track,
|
|
129
|
+
alertRecorded: false,
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
let release: {
|
|
133
|
+
tag_name?: string;
|
|
134
|
+
html_url?: string;
|
|
135
|
+
tarball_url?: string;
|
|
136
|
+
zipball_url?: string;
|
|
137
|
+
published_at?: string;
|
|
138
|
+
};
|
|
139
|
+
try {
|
|
140
|
+
const res = await fetch(RELEASES_API, {
|
|
141
|
+
headers: githubHeaders(),
|
|
142
|
+
signal: AbortSignal.timeout(15_000),
|
|
143
|
+
next: { revalidate: 3600 },
|
|
144
|
+
});
|
|
145
|
+
if (res.status === 404) return { ...base, ok: true }; // no releases yet
|
|
146
|
+
if (!res.ok) return { ...base, error: `GitHub Releases API returned HTTP ${res.status}.` };
|
|
147
|
+
release = await res.json();
|
|
148
|
+
} catch (caught) {
|
|
149
|
+
return {
|
|
150
|
+
...base,
|
|
151
|
+
error:
|
|
152
|
+
caught instanceof Error
|
|
153
|
+
? `Could not reach the GitHub Releases API: ${caught.message}`
|
|
154
|
+
: 'Could not reach the GitHub Releases API.',
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const tag = release.tag_name?.trim();
|
|
159
|
+
if (!tag) return { ...base, ok: true };
|
|
160
|
+
|
|
161
|
+
const latestVersion = tag.replace(/^v/i, '');
|
|
162
|
+
const tarballUrl =
|
|
163
|
+
release.tarball_url || `https://github.com/${UPSTREAM_REPO}/archive/refs/tags/${tag}.tar.gz`;
|
|
164
|
+
const zipballUrl =
|
|
165
|
+
release.zipball_url || `https://github.com/${UPSTREAM_REPO}/archive/refs/tags/${tag}.zip`;
|
|
166
|
+
const updateAvailable = compareSemver(latestVersion, currentVersion) > 0;
|
|
167
|
+
|
|
168
|
+
const result: UpstreamUpdateResult = {
|
|
169
|
+
...base,
|
|
170
|
+
ok: true,
|
|
171
|
+
latestVersion,
|
|
172
|
+
updateAvailable,
|
|
173
|
+
htmlUrl: release.html_url,
|
|
174
|
+
tarballUrl,
|
|
175
|
+
zipballUrl,
|
|
176
|
+
publishedAt: release.published_at,
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
// Git-backed installs auto-merge via Track A — no runtime update alert for them.
|
|
180
|
+
if (!updateAvailable || track !== 'standalone') return result;
|
|
181
|
+
|
|
182
|
+
try {
|
|
183
|
+
const supabase = getServiceRoleSupabaseClient();
|
|
184
|
+
const { data: existing } = await supabase
|
|
185
|
+
.from('system_alerts')
|
|
186
|
+
.select('id, metadata')
|
|
187
|
+
.eq('alert_type', 'runtime_update_available')
|
|
188
|
+
.eq('is_resolved', false)
|
|
189
|
+
.limit(50);
|
|
190
|
+
|
|
191
|
+
const alreadyAlerted = (existing ?? []).some(
|
|
192
|
+
(row) =>
|
|
193
|
+
(row.metadata as { latest_version?: string } | null)?.latest_version === latestVersion,
|
|
194
|
+
);
|
|
195
|
+
if (alreadyAlerted) return result;
|
|
196
|
+
|
|
197
|
+
const { error } = await supabase.from('system_alerts').insert({
|
|
198
|
+
alert_type: 'runtime_update_available',
|
|
199
|
+
title: `NextBlock ${latestVersion} is available`,
|
|
200
|
+
message: `A newer NextBlock release (${latestVersion}) is available — you are on ${currentVersion}. Download the release archive, replace your files, and update dependencies to upgrade.`,
|
|
201
|
+
metadata: {
|
|
202
|
+
latest_version: latestVersion,
|
|
203
|
+
current_version: currentVersion,
|
|
204
|
+
download_url: tarballUrl,
|
|
205
|
+
zipball_url: zipballUrl,
|
|
206
|
+
html_url: release.html_url ?? null,
|
|
207
|
+
},
|
|
208
|
+
});
|
|
209
|
+
if (error) return { ...result, error: `Could not record the update alert: ${error.message}` };
|
|
210
|
+
return { ...result, alertRecorded: true };
|
|
211
|
+
} catch (caught) {
|
|
212
|
+
return {
|
|
213
|
+
...result,
|
|
214
|
+
error:
|
|
215
|
+
caught instanceof Error
|
|
216
|
+
? `Could not record the update alert: ${caught.message}`
|
|
217
|
+
: 'Could not record the update alert.',
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Mirror Track-A sync conflicts (open GitHub issues labeled nextblock-sync-conflict in
|
|
224
|
+
* THIS fork) into merge_conflict alerts, and resolve alerts whose issue has closed.
|
|
225
|
+
* Returns a soft result (never throws) so the caller can surface errors without 500ing.
|
|
226
|
+
*/
|
|
227
|
+
export async function checkForSyncConflicts(): Promise<SyncConflictResult> {
|
|
228
|
+
const self = resolveSelfRepo();
|
|
229
|
+
const base: SyncConflictResult = {
|
|
230
|
+
ok: false,
|
|
231
|
+
repo: self ? `${self.owner}/${self.repo}` : null,
|
|
232
|
+
openConflicts: 0,
|
|
233
|
+
actionsActive: false,
|
|
234
|
+
alertsWritten: 0,
|
|
235
|
+
alertsResolved: 0,
|
|
236
|
+
};
|
|
237
|
+
if (!self) return { ...base, ok: true }; // not git-backed / repo unknown — nothing to mirror
|
|
238
|
+
|
|
239
|
+
// 1) Open conflict issues. The /issues endpoint also returns PRs — filter them out.
|
|
240
|
+
// Only an AUTHORITATIVE res.ok read may drive the resolve loop below: a 404 (private
|
|
241
|
+
// fork without NEXTBLOCK_GITHUB_TOKEN, or a transient error) must NOT be read as "zero
|
|
242
|
+
// open conflicts", or live conflict alerts would be wrongly auto-resolved and hidden.
|
|
243
|
+
let issues: Array<{ number: number; html_url: string; pull_request?: unknown }> = [];
|
|
244
|
+
let issuesFetched = false;
|
|
245
|
+
let fetchError: string | undefined;
|
|
246
|
+
try {
|
|
247
|
+
const url = `https://api.github.com/repos/${self.owner}/${self.repo}/issues?labels=${CONFLICT_LABEL}&state=open&per_page=20`;
|
|
248
|
+
const res = await fetch(url, {
|
|
249
|
+
headers: githubHeaders(),
|
|
250
|
+
signal: AbortSignal.timeout(15_000),
|
|
251
|
+
next: { revalidate: 600 },
|
|
252
|
+
});
|
|
253
|
+
if (res.ok) {
|
|
254
|
+
issuesFetched = true;
|
|
255
|
+
const data = await res.json();
|
|
256
|
+
issues = (Array.isArray(data) ? data : []).filter((i) => !i.pull_request);
|
|
257
|
+
} else if (res.status === 404) {
|
|
258
|
+
fetchError =
|
|
259
|
+
'Could not read repository issues (HTTP 404). For a private fork, set NEXTBLOCK_GITHUB_TOKEN.';
|
|
260
|
+
} else {
|
|
261
|
+
fetchError = `GitHub issues API returned HTTP ${res.status}.`;
|
|
262
|
+
}
|
|
263
|
+
} catch (caught) {
|
|
264
|
+
fetchError = caught instanceof Error ? caught.message : 'Could not reach the GitHub issues API.';
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// 2) Has the sync workflow ever run? (drives the onboarding "Actions enabled" state)
|
|
268
|
+
let actionsActive = false;
|
|
269
|
+
try {
|
|
270
|
+
const wfUrl = `https://api.github.com/repos/${self.owner}/${self.repo}/actions/workflows/nextblock-sync.yml/runs?per_page=1`;
|
|
271
|
+
const res = await fetch(wfUrl, {
|
|
272
|
+
headers: githubHeaders(),
|
|
273
|
+
signal: AbortSignal.timeout(15_000),
|
|
274
|
+
next: { revalidate: 600 },
|
|
275
|
+
});
|
|
276
|
+
if (res.ok) {
|
|
277
|
+
const data = await res.json();
|
|
278
|
+
actionsActive = (data?.total_count ?? 0) > 0;
|
|
279
|
+
}
|
|
280
|
+
} catch {
|
|
281
|
+
/* best-effort */
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// 3) Mirror issues <-> system_alerts.
|
|
285
|
+
let alertsWritten = 0;
|
|
286
|
+
let alertsResolved = 0;
|
|
287
|
+
try {
|
|
288
|
+
const supabase = getServiceRoleSupabaseClient();
|
|
289
|
+
const { data: existing } = await supabase
|
|
290
|
+
.from('system_alerts')
|
|
291
|
+
.select('id, metadata')
|
|
292
|
+
.eq('alert_type', 'merge_conflict')
|
|
293
|
+
.eq('is_resolved', false)
|
|
294
|
+
.limit(50);
|
|
295
|
+
|
|
296
|
+
const alertByIssue = new Map<number, string>();
|
|
297
|
+
for (const row of existing ?? []) {
|
|
298
|
+
const issueNumber = (row.metadata as { issue_number?: number } | null)?.issue_number;
|
|
299
|
+
if (typeof issueNumber === 'number') alertByIssue.set(issueNumber, row.id);
|
|
300
|
+
}
|
|
301
|
+
const openIssueNumbers = new Set(issues.map((i) => i.number));
|
|
302
|
+
|
|
303
|
+
for (const issue of issues) {
|
|
304
|
+
if (alertByIssue.has(issue.number)) continue;
|
|
305
|
+
const { error } = await supabase.from('system_alerts').insert({
|
|
306
|
+
alert_type: 'merge_conflict',
|
|
307
|
+
title: 'Upstream sync needs manual resolution',
|
|
308
|
+
message: `An automated upstream sync hit conflicts and could not merge. Resolve it on GitHub (issue #${issue.number}), then close the issue to clear this alert.`,
|
|
309
|
+
metadata: {
|
|
310
|
+
issue_number: issue.number,
|
|
311
|
+
action_url: issue.html_url,
|
|
312
|
+
repo: `${self.owner}/${self.repo}`,
|
|
313
|
+
},
|
|
314
|
+
});
|
|
315
|
+
if (!error) alertsWritten += 1;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Only resolve when the open-issue list is authoritative (res.ok). On a 404/error we
|
|
319
|
+
// can't tell which issues are still open, so we leave existing alerts untouched.
|
|
320
|
+
if (issuesFetched) {
|
|
321
|
+
for (const [issueNumber, alertId] of alertByIssue) {
|
|
322
|
+
if (openIssueNumbers.has(issueNumber)) continue;
|
|
323
|
+
const { error } = await supabase
|
|
324
|
+
.from('system_alerts')
|
|
325
|
+
.update({ is_resolved: true, resolved_at: new Date().toISOString() })
|
|
326
|
+
.eq('id', alertId);
|
|
327
|
+
if (!error) alertsResolved += 1;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
} catch (caught) {
|
|
331
|
+
return {
|
|
332
|
+
...base,
|
|
333
|
+
actionsActive,
|
|
334
|
+
openConflicts: issues.length,
|
|
335
|
+
error: caught instanceof Error ? caught.message : 'Could not mirror conflict alerts.',
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
return {
|
|
340
|
+
ok: !fetchError,
|
|
341
|
+
repo: `${self.owner}/${self.repo}`,
|
|
342
|
+
openConflicts: issues.length,
|
|
343
|
+
actionsActive,
|
|
344
|
+
alertsWritten,
|
|
345
|
+
alertsResolved,
|
|
346
|
+
error: fetchError,
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
export interface UpstreamStatusSnapshot {
|
|
351
|
+
checked_at: string;
|
|
352
|
+
current_version: string;
|
|
353
|
+
latest_version: string | null;
|
|
354
|
+
update_available: boolean;
|
|
355
|
+
track: UpdateTrack;
|
|
356
|
+
repo: string | null;
|
|
357
|
+
open_conflicts: number;
|
|
358
|
+
actions_active: boolean;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** Run both checks and persist a status snapshot (read by the onboarding checklist). */
|
|
362
|
+
export async function refreshUpstreamStatus(): Promise<{
|
|
363
|
+
update: UpstreamUpdateResult;
|
|
364
|
+
conflicts: SyncConflictResult;
|
|
365
|
+
snapshot: UpstreamStatusSnapshot | null;
|
|
366
|
+
}> {
|
|
367
|
+
const update = await checkForUpstreamUpdate();
|
|
368
|
+
const conflicts = await checkForSyncConflicts();
|
|
369
|
+
|
|
370
|
+
let snapshot: UpstreamStatusSnapshot | null = null;
|
|
371
|
+
try {
|
|
372
|
+
const config = await getSystemConfiguration();
|
|
373
|
+
snapshot = {
|
|
374
|
+
checked_at: new Date().toISOString(),
|
|
375
|
+
current_version: update.currentVersion,
|
|
376
|
+
latest_version: update.latestVersion,
|
|
377
|
+
update_available: update.updateAvailable,
|
|
378
|
+
track: update.track,
|
|
379
|
+
repo: conflicts.repo,
|
|
380
|
+
open_conflicts: conflicts.openConflicts,
|
|
381
|
+
actions_active: conflicts.actionsActive,
|
|
382
|
+
};
|
|
383
|
+
await setSystemConfigurationServiceRole({
|
|
384
|
+
settings: { ...config.settings, upstream_status: snapshot },
|
|
385
|
+
});
|
|
386
|
+
} catch {
|
|
387
|
+
/* best-effort — the alerts themselves are already written */
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
return { update, conflicts, snapshot };
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** Throttled background refresh for the CMS layout's after() hook. Never throws. */
|
|
394
|
+
export async function maybeRefreshUpstreamStatus(): Promise<void> {
|
|
395
|
+
try {
|
|
396
|
+
const config = await getSystemConfiguration();
|
|
397
|
+
const status = config.settings?.['upstream_status'] as { checked_at?: string } | undefined;
|
|
398
|
+
const last = status?.checked_at ? Date.parse(status.checked_at) : 0;
|
|
399
|
+
if (Number.isFinite(last) && Date.now() - last < REFRESH_INTERVAL_MS) return;
|
|
400
|
+
await refreshUpstreamStatus();
|
|
401
|
+
} catch {
|
|
402
|
+
/* best-effort */
|
|
403
|
+
}
|
|
404
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import 'server-only';
|
|
2
|
+
// Resolve THIS install's own GitHub repo (the fork), so the runtime engine can poll the
|
|
3
|
+
// fork's issues/workflow runs and the onboarding checklist can deep-link its Actions tab.
|
|
4
|
+
// Order: Vercel's injected git vars -> explicit override -> parsed .git/config origin.
|
|
5
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
|
|
8
|
+
export interface SelfRepo {
|
|
9
|
+
owner: string;
|
|
10
|
+
repo: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function resolveSelfRepo(): SelfRepo | null {
|
|
14
|
+
// Vercel auto-injects these for git-connected projects (incl. 1-click forks).
|
|
15
|
+
const vercelOwner = process.env.VERCEL_GIT_REPO_OWNER?.trim();
|
|
16
|
+
const vercelSlug = process.env.VERCEL_GIT_REPO_SLUG?.trim();
|
|
17
|
+
if (vercelOwner && vercelSlug) return { owner: vercelOwner, repo: vercelSlug };
|
|
18
|
+
|
|
19
|
+
// Explicit override for self-hosted git installs: NEXTBLOCK_REPO=owner/repo.
|
|
20
|
+
const explicit = process.env.NEXTBLOCK_REPO?.trim();
|
|
21
|
+
if (explicit && explicit.includes('/')) {
|
|
22
|
+
const [owner, repo] = explicit.split('/');
|
|
23
|
+
if (owner && repo) return { owner, repo: repo.replace(/\.git$/i, '') };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Local / self-hosted clone: parse the origin remote from .git/config.
|
|
27
|
+
try {
|
|
28
|
+
const cfg = path.join(process.cwd(), '.git', 'config');
|
|
29
|
+
if (existsSync(cfg)) {
|
|
30
|
+
const match = readFileSync(cfg, 'utf8').match(
|
|
31
|
+
/github\.com[:/]([^/\s]+)\/([^/\s]+?)(?:\.git)?(?:\s|$)/i,
|
|
32
|
+
);
|
|
33
|
+
if (match) return { owner: match[1], repo: match[2] };
|
|
34
|
+
}
|
|
35
|
+
} catch {
|
|
36
|
+
/* ignore */
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The fork's GitHub Actions tab, for the onboarding "enable Actions" reminder. */
|
|
43
|
+
export function selfActionsUrl(): string | null {
|
|
44
|
+
const self = resolveSelfRepo();
|
|
45
|
+
return self ? `https://github.com/${self.owner}/${self.repo}/actions` : null;
|
|
46
|
+
}
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nextblock-cms/template",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.9",
|
|
4
4
|
"private": true,
|
|
5
5
|
"scripts": {
|
|
6
6
|
"dev": "next dev",
|
|
7
|
+
"prebuild": "node tools/build-migrate.mjs",
|
|
7
8
|
"build": "next build",
|
|
8
9
|
"start": "next start",
|
|
9
10
|
"lint": "next lint",
|