create-nextblock 0.15.0 → 0.15.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (23) hide show
  1. package/bin/create-nextblock.js +23 -2
  2. package/docker-template/.dockerignore +2 -1
  3. package/package.json +1 -1
  4. package/scripts/sync-template.js +97 -0
  5. package/templates/nextblock-template/.dockerignore +2 -1
  6. package/templates/nextblock-template/README.md +57 -34
  7. package/templates/nextblock-template/app/api/cron/reset-sandbox/sandboxResetSql.ts +525 -1
  8. package/templates/nextblock-template/app/cms/settings/site-scripts/components/SiteScriptManager.tsx +13 -5
  9. package/templates/nextblock-template/docs/04-DATABASE-AND-AUTH.md +2 -1
  10. package/templates/nextblock-template/docs/08-NEXTBLOCK-CORTEX-AI-ARCHITECTURE.md +26 -11
  11. package/templates/nextblock-template/docs/11-SELF-HOSTED-DOCKER.md +9 -0
  12. package/templates/nextblock-template/docs/12-VERCEL-DEPLOYMENT.md +8 -0
  13. package/templates/nextblock-template/docs/13-STAYING-UP-TO-DATE.md +372 -151
  14. package/templates/nextblock-template/docs/README.md +2 -0
  15. package/templates/nextblock-template/gitignore +3 -0
  16. package/templates/nextblock-template/lib/onboarding/status.ts +11 -5
  17. package/templates/nextblock-template/lib/setup/migrations-bundle.ts +35 -0
  18. package/templates/nextblock-template/lib/updates/check-upstream.ts +167 -46
  19. package/templates/nextblock-template/package.json +6 -1
  20. package/templates/nextblock-template/tools/build-migrate.mjs +102 -209
  21. package/templates/nextblock-template/tools/lib/migrate-core.mjs +569 -0
  22. package/templates/nextblock-template/tools/update.mjs +1285 -0
  23. package/templates/nextblock-template/tsconfig.tsbuildinfo +0 -1
@@ -103,5 +103,40 @@ export const MIGRATIONS_BUNDLE: BundledMigration[] = [
103
103
  "version": "00000000000017",
104
104
  "name": "00000000000017_cortex_ai_mcp_server.sql",
105
105
  "sql": "-- Cortex AI MCP (Model Context Protocol) server access.\n--\n-- Adds the bearer-token store that gates /api/mcp, the endpoint that exposes the\n-- Cortex AI tool registry to external MCP clients (Claude Code, Claude Desktop,\n-- Cursor, VS Code). Two pieces:\n--\n-- 1. public.mcp_access_tokens — one row per issued token. We store ONLY the\n-- SHA-256 hash of the token, never the token itself: the plaintext is shown\n-- to the admin exactly once at mint time and is unrecoverable afterwards, so\n-- a database leak cannot be replayed against the MCP endpoint. `token_prefix`\n-- is the non-secret leading fragment kept purely so the UI can tell two tokens\n-- apart in a list.\n--\n-- 2. cortex_ai_mcp_settings — a non-secret JSON site_settings row holding the\n-- server on/off switch and the localhost-trust flag. It is added to all four\n-- site_settings policies so only authenticated ADMINs can read or write it;\n-- the MCP route itself reads it through the service-role client, which\n-- bypasses RLS.\n--\n-- Forward-only. Recreates the four site_settings policies idempotently, preserving\n-- every key already in each policy's sensitive array (note that\n-- language_detection_settings stays anon-READABLE and so is absent from the SELECT\n-- policy, exactly as migration 00000000000012 left it).\n\nCREATE TABLE IF NOT EXISTS public.mcp_access_tokens (\n id uuid PRIMARY KEY DEFAULT gen_random_uuid(),\n name text NOT NULL,\n -- Lowercase hex SHA-256 of the plaintext token. Unique so a lookup is a single\n -- indexed equality probe and duplicate mints are impossible.\n token_hash text NOT NULL UNIQUE,\n -- Non-secret display fragment, e.g. \"nbmcp_a1b2c3d4\". Never enough to authenticate.\n token_prefix text NOT NULL,\n -- 'read' grants the read-only tools; 'write' additionally grants the mutating ones.\n scopes text[] NOT NULL DEFAULT ARRAY['read', 'write']::text[],\n created_by uuid REFERENCES auth.users (id) ON DELETE SET NULL,\n created_at timestamptz NOT NULL DEFAULT now(),\n last_used_at timestamptz,\n expires_at timestamptz,\n revoked_at timestamptz\n);\n\nCOMMENT ON TABLE public.mcp_access_tokens IS\n 'Bearer tokens for the Cortex AI MCP server at /api/mcp. Stores SHA-256 hashes only; plaintext is displayed once at mint time.';\n\nCREATE INDEX IF NOT EXISTS mcp_access_tokens_token_hash_idx\n ON public.mcp_access_tokens (token_hash);\n\n-- Orders the admin token list newest-first without a sort.\nCREATE INDEX IF NOT EXISTS mcp_access_tokens_created_at_idx\n ON public.mcp_access_tokens (created_at DESC);\n\nALTER TABLE public.mcp_access_tokens ENABLE ROW LEVEL SECURITY;\n\n-- Tokens are credentials: admin-only, with no anon or WRITER access at all. The\n-- MCP route verifies them with the service-role client, which bypasses RLS.\nDROP POLICY IF EXISTS mcp_access_tokens_admin_select ON public.mcp_access_tokens;\nCREATE POLICY mcp_access_tokens_admin_select ON public.mcp_access_tokens\n FOR SELECT TO authenticated\n USING ((SELECT public.get_current_user_role()) = 'ADMIN'::public.user_role);\n\nDROP POLICY IF EXISTS mcp_access_tokens_admin_insert ON public.mcp_access_tokens;\nCREATE POLICY mcp_access_tokens_admin_insert ON public.mcp_access_tokens\n FOR INSERT TO authenticated\n WITH CHECK ((SELECT public.get_current_user_role()) = 'ADMIN'::public.user_role);\n\nDROP POLICY IF EXISTS mcp_access_tokens_admin_update ON public.mcp_access_tokens;\nCREATE POLICY mcp_access_tokens_admin_update ON public.mcp_access_tokens\n FOR UPDATE TO authenticated\n USING ((SELECT public.get_current_user_role()) = 'ADMIN'::public.user_role)\n WITH CHECK ((SELECT public.get_current_user_role()) = 'ADMIN'::public.user_role);\n\nDROP POLICY IF EXISTS mcp_access_tokens_admin_delete ON public.mcp_access_tokens;\nCREATE POLICY mcp_access_tokens_admin_delete ON public.mcp_access_tokens\n FOR DELETE TO authenticated\n USING ((SELECT public.get_current_user_role()) = 'ADMIN'::public.user_role);\n\nGRANT SELECT, INSERT, UPDATE, DELETE ON public.mcp_access_tokens TO authenticated;\nGRANT ALL ON public.mcp_access_tokens TO service_role;\n\n-- Add cortex_ai_mcp_settings to the admin-only site_settings group (all four policies).\nDROP POLICY IF EXISTS site_settings_read_policy ON public.site_settings;\nCREATE POLICY site_settings_read_policy ON public.site_settings FOR SELECT USING (((key <> ALL (ARRAY['cortex_ai_openrouter_api_key'::text, 'bot_protection_secret'::text, 'email_secret'::text, 'payment_secret'::text, 'cortex_ai_pexels_api_key'::text, 'cortex_ai_unsplash_access_key'::text, 'cortex_ai_mcp_settings'::text])) OR ((key = ANY (ARRAY['cortex_ai_openrouter_api_key'::text, 'bot_protection_secret'::text, 'email_secret'::text, 'payment_secret'::text, 'cortex_ai_pexels_api_key'::text, 'cortex_ai_unsplash_access_key'::text, 'cortex_ai_mcp_settings'::text])) AND (( SELECT auth.role() AS role) = 'authenticated'::text) AND (( SELECT public.get_current_user_role() AS get_current_user_role) = 'ADMIN'::public.user_role))));\n\nDROP POLICY IF EXISTS site_settings_insert_policy ON public.site_settings;\nCREATE POLICY site_settings_insert_policy ON public.site_settings FOR INSERT TO authenticated WITH CHECK ((((key <> ALL (ARRAY['cortex_ai_openrouter_api_key'::text, 'bot_protection_secret'::text, 'email_secret'::text, 'payment_secret'::text, 'language_detection_settings'::text, 'cortex_ai_pexels_api_key'::text, 'cortex_ai_unsplash_access_key'::text, 'cortex_ai_mcp_settings'::text])) AND (( SELECT public.get_current_user_role() AS get_current_user_role) = ANY (ARRAY['ADMIN'::public.user_role, 'WRITER'::public.user_role]))) OR ((key = ANY (ARRAY['cortex_ai_openrouter_api_key'::text, 'bot_protection_secret'::text, 'email_secret'::text, 'payment_secret'::text, 'language_detection_settings'::text, 'cortex_ai_pexels_api_key'::text, 'cortex_ai_unsplash_access_key'::text, 'cortex_ai_mcp_settings'::text])) AND (( SELECT public.get_current_user_role() AS get_current_user_role) = 'ADMIN'::public.user_role))));\n\nDROP POLICY IF EXISTS site_settings_update_policy ON public.site_settings;\nCREATE POLICY site_settings_update_policy ON public.site_settings FOR UPDATE TO authenticated USING ((((key <> ALL (ARRAY['cortex_ai_openrouter_api_key'::text, 'bot_protection_secret'::text, 'email_secret'::text, 'payment_secret'::text, 'language_detection_settings'::text, 'cortex_ai_pexels_api_key'::text, 'cortex_ai_unsplash_access_key'::text, 'cortex_ai_mcp_settings'::text])) AND (( SELECT public.get_current_user_role() AS get_current_user_role) = ANY (ARRAY['ADMIN'::public.user_role, 'WRITER'::public.user_role]))) OR ((key = ANY (ARRAY['cortex_ai_openrouter_api_key'::text, 'bot_protection_secret'::text, 'email_secret'::text, 'payment_secret'::text, 'language_detection_settings'::text, 'cortex_ai_pexels_api_key'::text, 'cortex_ai_unsplash_access_key'::text, 'cortex_ai_mcp_settings'::text])) AND (( SELECT public.get_current_user_role() AS get_current_user_role) = 'ADMIN'::public.user_role)))) WITH CHECK ((((key <> ALL (ARRAY['cortex_ai_openrouter_api_key'::text, 'bot_protection_secret'::text, 'email_secret'::text, 'payment_secret'::text, 'language_detection_settings'::text, 'cortex_ai_pexels_api_key'::text, 'cortex_ai_unsplash_access_key'::text, 'cortex_ai_mcp_settings'::text])) AND (( SELECT public.get_current_user_role() AS get_current_user_role) = ANY (ARRAY['ADMIN'::public.user_role, 'WRITER'::public.user_role]))) OR ((key = ANY (ARRAY['cortex_ai_openrouter_api_key'::text, 'bot_protection_secret'::text, 'email_secret'::text, 'payment_secret'::text, 'language_detection_settings'::text, 'cortex_ai_pexels_api_key'::text, 'cortex_ai_unsplash_access_key'::text, 'cortex_ai_mcp_settings'::text])) AND (( SELECT public.get_current_user_role() AS get_current_user_role) = 'ADMIN'::public.user_role))));\n\nDROP POLICY IF EXISTS site_settings_delete_policy ON public.site_settings;\nCREATE POLICY site_settings_delete_policy ON public.site_settings FOR DELETE TO authenticated USING ((((key <> ALL (ARRAY['cortex_ai_openrouter_api_key'::text, 'bot_protection_secret'::text, 'email_secret'::text, 'payment_secret'::text, 'language_detection_settings'::text, 'cortex_ai_pexels_api_key'::text, 'cortex_ai_unsplash_access_key'::text, 'cortex_ai_mcp_settings'::text])) AND (( SELECT public.get_current_user_role() AS get_current_user_role) = ANY (ARRAY['ADMIN'::public.user_role, 'WRITER'::public.user_role]))) OR ((key = ANY (ARRAY['cortex_ai_openrouter_api_key'::text, 'bot_protection_secret'::text, 'email_secret'::text, 'payment_secret'::text, 'language_detection_settings'::text, 'cortex_ai_pexels_api_key'::text, 'cortex_ai_unsplash_access_key'::text, 'cortex_ai_mcp_settings'::text])) AND (( SELECT public.get_current_user_role() AS get_current_user_role) = 'ADMIN'::public.user_role))));\n"
106
+ },
107
+ {
108
+ "version": "00000000000018",
109
+ "name": "00000000000018_site_scripts.sql",
110
+ "sql": "-- Site scripts: admin-authored JavaScript injected into every page of the public site.\n--\n-- Rich-text blocks can already carry an inline <script>, but that script belongs to\n-- one block on one page. This table is for behaviour that spans the site: chat\n-- widgets, third-party embeds, and the scroll/animation helpers that page classes\n-- rely on. Each row gets a name, an on/off switch, and a defined injection point.\n--\n-- NOT the same thing as `site_settings.privacy_settings -> custom_scripts`, which is\n-- a single consent-gated blob for marketing tags and only fires once a visitor\n-- accepts cookies. Rows here are functional site code and run unconditionally, so\n-- anything requiring consent belongs in that setting instead, not this table.\n--\n-- Scripts are emitted with the request's CSP nonce by the root layout, so they run\n-- under the site's existing Content-Security-Policy rather than forcing it open.\n--\n-- Security posture: this is arbitrary JavaScript on every page, so writes are\n-- ADMIN-only (WRITER is deliberately excluded, unlike most content tables) and the\n-- public may read only rows that are switched on, so a half-written draft is never\n-- served to a visitor.\n\nCREATE TABLE IF NOT EXISTS public.site_scripts (\n id uuid DEFAULT gen_random_uuid() NOT NULL,\n name text NOT NULL,\n description text,\n -- Raw JavaScript, stored WITHOUT the surrounding <script> tag. The layout adds\n -- the tag so the nonce and attributes are always applied by us, never by the\n -- author. Ignored when `src` is set.\n code text DEFAULT ''::text NOT NULL,\n -- When set, an external script is loaded from this URL and `code` is ignored.\n src text,\n -- Where the tag is emitted. 'head' runs before first paint (blocking, use\n -- sparingly); 'body_end' runs once the markup exists and is the right default\n -- for anything that queries the DOM.\n placement text DEFAULT 'body_end'::text NOT NULL,\n -- Applies to external `src` scripts; inline code ignores it.\n load_strategy text DEFAULT 'default'::text NOT NULL,\n is_active boolean DEFAULT false NOT NULL,\n sort_order integer DEFAULT 0 NOT NULL,\n created_at timestamp with time zone DEFAULT now() NOT NULL,\n updated_at timestamp with time zone DEFAULT now() NOT NULL,\n CONSTRAINT site_scripts_pkey PRIMARY KEY (id),\n CONSTRAINT site_scripts_placement_check\n CHECK ((placement = ANY (ARRAY['head'::text, 'body_start'::text, 'body_end'::text]))),\n CONSTRAINT site_scripts_load_strategy_check\n CHECK ((load_strategy = ANY (ARRAY['default'::text, 'defer'::text, 'async'::text]))),\n -- An external script must be https so it cannot be downgraded in transit.\n CONSTRAINT site_scripts_src_scheme_check\n CHECK ((src IS NULL OR src ~ '^https://')),\n -- A row has to actually do something: inline code or an external src.\n CONSTRAINT site_scripts_has_payload_check\n CHECK ((src IS NOT NULL OR length(btrim(code)) > 0))\n);\n\nCOMMENT ON TABLE public.site_scripts IS\n 'Admin-authored JavaScript injected into the public site by the root layout, with the request CSP nonce applied. Only is_active rows are publicly readable; only ADMIN may write. Distinct from privacy_settings.custom_scripts, which is consent-gated marketing tags.';\n\nCREATE INDEX IF NOT EXISTS site_scripts_active_placement_sort_idx\n ON public.site_scripts USING btree (is_active, placement, sort_order);\n\nDROP TRIGGER IF EXISTS set_site_scripts_updated_at ON public.site_scripts;\nCREATE TRIGGER set_site_scripts_updated_at\n BEFORE UPDATE ON public.site_scripts\n FOR EACH ROW EXECUTE FUNCTION public.set_current_timestamp_updated_at();\n\nALTER TABLE public.site_scripts ENABLE ROW LEVEL SECURITY;\n\nGRANT ALL ON TABLE public.site_scripts TO anon;\nGRANT ALL ON TABLE public.site_scripts TO authenticated;\nGRANT ALL ON TABLE public.site_scripts TO service_role;\n\n-- Anonymous visitors need the active scripts to render the page. Inactive rows stay\n-- private so a half-written script is never exposed before it is switched on.\nDROP POLICY IF EXISTS \"Public read active site scripts\" ON public.site_scripts;\nCREATE POLICY \"Public read active site scripts\" ON public.site_scripts\n FOR SELECT TO authenticated, anon USING (is_active);\n\nDROP POLICY IF EXISTS \"Admins read all site scripts\" ON public.site_scripts;\nCREATE POLICY \"Admins read all site scripts\" ON public.site_scripts\n FOR SELECT TO authenticated\n USING (((SELECT public.get_current_user_role()) = 'ADMIN'::public.user_role));\n\nDROP POLICY IF EXISTS \"Admins insert site scripts\" ON public.site_scripts;\nCREATE POLICY \"Admins insert site scripts\" ON public.site_scripts\n FOR INSERT TO authenticated\n WITH CHECK (((SELECT public.get_current_user_role()) = 'ADMIN'::public.user_role));\n\nDROP POLICY IF EXISTS \"Admins update site scripts\" ON public.site_scripts;\nCREATE POLICY \"Admins update site scripts\" ON public.site_scripts\n FOR UPDATE TO authenticated\n USING (((SELECT public.get_current_user_role()) = 'ADMIN'::public.user_role))\n WITH CHECK (((SELECT public.get_current_user_role()) = 'ADMIN'::public.user_role));\n\nDROP POLICY IF EXISTS \"Admins delete site scripts\" ON public.site_scripts;\nCREATE POLICY \"Admins delete site scripts\" ON public.site_scripts\n FOR DELETE TO authenticated\n USING (((SELECT public.get_current_user_role()) = 'ADMIN'::public.user_role));\n"
111
+ },
112
+ {
113
+ "version": "00000000000019",
114
+ "name": "00000000000019_site_script_revisions.sql",
115
+ "sql": "-- Audit trail and undo for site scripts.\n--\n-- `site_scripts` ships arbitrary JavaScript to every visitor, which makes it the\n-- highest-privilege write in the CMS: a bad or malicious snippet can read cookies,\n-- watch checkout forms, or phone home. Content has Revision History for exactly this\n-- reason; code needs it more, not less. Every create/update/delete writes one row\n-- here, and every row is a complete, restorable snapshot — so this table is both the\n-- log (\"who shipped what, when, from where\") and the undo.\n--\n-- APPEND-ONLY BY CONSTRUCTION. There are no UPDATE or DELETE policies, and the\n-- trigger below rejects both even for the service role, which otherwise bypasses\n-- RLS. An audit trail that the compromised credential can rewrite is not an audit\n-- trail. Reverting therefore writes a NEW 'revert' row rather than removing history.\n--\n-- `script_id` and `actor_user_id` are deliberately PLAIN uuids with no foreign keys:\n-- an FK with ON DELETE SET NULL would have to UPDATE this table when a script or a\n-- profile is deleted, which the append-only trigger forbids. `script_name` is\n-- denormalised so a deleted script is still identifiable in the log.\n\nCREATE TABLE IF NOT EXISTS public.site_script_revisions (\n id uuid DEFAULT gen_random_uuid() NOT NULL,\n script_id uuid,\n script_name text NOT NULL,\n revision_type text NOT NULL,\n -- Null when the actor could not be resolved (e.g. a localhost dev connection).\n actor_user_id uuid,\n -- Which surface made the change, so an unexpected edit can be traced back to\n -- the dashboard or to an MCP token.\n source text DEFAULT 'cms'::text NOT NULL,\n summary text,\n -- Full restorable state of the script at this revision. For 'delete' it is the\n -- state immediately BEFORE removal, so restoring it brings the script back.\n snapshot jsonb NOT NULL,\n created_at timestamp with time zone DEFAULT now() NOT NULL,\n CONSTRAINT site_script_revisions_pkey PRIMARY KEY (id),\n CONSTRAINT site_script_revisions_type_check\n CHECK ((revision_type = ANY (ARRAY['create'::text, 'update'::text, 'delete'::text, 'revert'::text]))),\n CONSTRAINT site_script_revisions_source_check\n CHECK ((source = ANY (ARRAY['cms'::text, 'mcp'::text]))),\n CONSTRAINT site_script_revisions_snapshot_is_object_check\n CHECK ((jsonb_typeof(snapshot) = 'object'))\n);\n\nCOMMENT ON TABLE public.site_script_revisions IS\n 'Append-only audit log and undo history for site_scripts. Each row is a restorable snapshot. UPDATE and DELETE are blocked by trigger, including for the service role.';\n\nCREATE INDEX IF NOT EXISTS site_script_revisions_script_created_idx\n ON public.site_script_revisions USING btree (script_id, created_at DESC);\n\nCREATE INDEX IF NOT EXISTS site_script_revisions_created_idx\n ON public.site_script_revisions USING btree (created_at DESC);\n\n-- Enforced in the database rather than the application so it holds for every\n-- caller, including the service-role client the MCP server uses.\nCREATE OR REPLACE FUNCTION public.prevent_site_script_revision_rewrite() RETURNS trigger\n LANGUAGE plpgsql\n SET search_path = ''\n AS $$\nBEGIN\n RAISE EXCEPTION 'site_script_revisions is append-only; % is not permitted', TG_OP\n USING ERRCODE = 'restrict_violation';\nEND;\n$$;\n\nDROP TRIGGER IF EXISTS trg_site_script_revisions_append_only ON public.site_script_revisions;\nCREATE TRIGGER trg_site_script_revisions_append_only\n BEFORE UPDATE OR DELETE ON public.site_script_revisions\n FOR EACH ROW EXECUTE FUNCTION public.prevent_site_script_revision_rewrite();\n\nALTER TABLE public.site_script_revisions ENABLE ROW LEVEL SECURITY;\n\nGRANT SELECT, INSERT ON TABLE public.site_script_revisions TO authenticated;\nGRANT ALL ON TABLE public.site_script_revisions TO service_role;\n\n-- Read is ADMIN-only: snapshots contain the full source of scripts that may not be\n-- active yet, and the log itself reveals operational history.\nDROP POLICY IF EXISTS \"Admins read site script revisions\" ON public.site_script_revisions;\nCREATE POLICY \"Admins read site script revisions\" ON public.site_script_revisions\n FOR SELECT TO authenticated\n USING (((SELECT public.get_current_user_role()) = 'ADMIN'::public.user_role));\n\nDROP POLICY IF EXISTS \"Admins insert site script revisions\" ON public.site_script_revisions;\nCREATE POLICY \"Admins insert site script revisions\" ON public.site_script_revisions\n FOR INSERT TO authenticated\n WITH CHECK (((SELECT public.get_current_user_role()) = 'ADMIN'::public.user_role));\n"
116
+ },
117
+ {
118
+ "version": "00000000000020",
119
+ "name": "00000000000020_updating_article.sql",
120
+ "sql": "-- 00000000000020_updating_article.sql\n-- Seeds the \"How Updating NextBlock Works\" guide as an EN/FR twin pair, the companion\n-- to the install guide seeded in the baseline (00000000000003, slugs 'how-to-setup-nextblock'\n-- / 'comment-configurer-nextblock'). It documents the single `npm run update` command\n-- across all four install paths — one-click Vercel, npm create → Docker, npm create →\n-- managed cloud, and the cloned monorepo.\n--\n-- Forward-only and idempotent by construction:\n-- * posts carries UNIQUE (language_id, slug), so the inserts use ON CONFLICT DO NOTHING;\n-- * blocks has no natural unique key, so the body insert is guarded on NOT EXISTS;\n-- * ids are never hardcoded — posts.id and blocks.id are identity columns and a live\n-- database has real editor-created rows occupying the low ids the baseline used.\n-- * the feature image is looked up rather than asserted, so a site that deleted the\n-- seeded media row still gets the article (with no cover) instead of a failed migration.\n--\n-- Because the sandbox reset payload and the /setup wizard's embedded bundle are both\n-- generated FROM this directory, the article reaches a fresh install and every hourly\n-- sandbox reset with no extra wiring — regenerate them with `npm run generate:sandbox`\n-- and `npm run generate:migrations-bundle`.\n--\n-- blocks.content is JSONB written with PostgreSQL dollar-quoting (same style as 006/008)\n-- so the HTML can use ordinary single-quoted class attributes without quote doubling.\n\nDO $body$\nDECLARE\n v_group uuid := 'c0d3f1a2-8b47-4e19-9a52-7f6b1d4e8c30';\n v_image uuid;\n v_en_post integer;\n v_fr_post integer;\nBEGIN\n SELECT id INTO v_image\n FROM public.media\n WHERE id = '641ddf75-5c90-41df-8b83-e7c298f30a6a'::uuid;\n\n ---------------------------------------------------------------------------\n -- English\n ---------------------------------------------------------------------------\n INSERT INTO public.posts (\n language_id, author_id, title, slug, label, excerpt, subtitle, status,\n published_at, meta_title, meta_description, feature_image_id, version,\n translation_group_id\n )\n SELECT\n 1, NULL,\n 'How Updating NextBlock Works: One Command for Every Install',\n 'how-updating-works',\n 'Maintenance',\n 'However you installed NextBlock — one-click Vercel, the CLI, Docker or a git clone — a single npm run update brings the code, the dependencies and the database schema forward together.',\n 'Automatic upstream syncing on Vercel, and one command everywhere else: what npm run update does, what it never touches, and how to roll it back.',\n 'published', now(),\n 'How to Update NextBlock — One Command for Every Install',\n 'Update NextBlock in one step. npm run update pulls new code, installs dependencies and applies pending database migrations on Vercel, Docker, CLI and git-clone installs alike.',\n v_image, 1, v_group\n WHERE NOT EXISTS (\n SELECT 1 FROM public.posts WHERE language_id = 1 AND slug = 'how-updating-works'\n );\n\n SELECT id INTO v_en_post\n FROM public.posts\n WHERE language_id = 1 AND slug = 'how-updating-works'\n ORDER BY id\n LIMIT 1;\n\n IF v_en_post IS NOT NULL AND NOT EXISTS (\n SELECT 1 FROM public.blocks WHERE post_id = v_en_post\n ) THEN\n INSERT INTO public.blocks (post_id, language_id, block_type, content, \"order\")\n VALUES (v_en_post, 1, 'text', $nben$\n{\"html_content\":\"<p class='text-lg leading-8 text-slate-700 dark:text-slate-300'>NextBlock ships improvements continuously — new blocks, editor fixes, security patches, and occasionally a database change that the new code depends on. Keeping up with all of that used to mean knowing which of the four install paths you were on. It no longer does. Every NextBlock project, however it was created, understands one command:</p>\\n\\n<div class='my-10 overflow-hidden rounded-[2rem] border border-slate-800 bg-slate-950 shadow-2xl'>\\n <div class='flex items-center gap-2 border-b border-white/10 px-5 py-3'>\\n <span class='h-3 w-3 rounded-full bg-red-400/70'></span>\\n <span class='h-3 w-3 rounded-full bg-yellow-400/70'></span>\\n <span class='h-3 w-3 rounded-full bg-green-400/70'></span>\\n <span class='ml-3 text-xs font-mono text-slate-400'>your project</span>\\n </div>\\n <div class='px-6 py-8 text-center'>\\n <p class='mt-0 mb-2 font-mono text-2xl sm:text-3xl font-semibold text-emerald-300'>npm run update</p>\\n <p class='mb-0 text-sm text-slate-400'>Code &middot; dependencies &middot; database schema &mdash; in that order, in one step.</p>\\n </div>\\n</div>\\n\\n<p>It figures out which kind of install it is running inside, picks the right source for new code, installs the matching dependencies, and then applies any database migrations the new version needs. If you would rather look before you leap, <code>npm run update -- --check</code> reports exactly what would change and touches nothing.</p>\\n\\n<h2 id='the-four-paths'>The four install paths, and how each one gets updates</h2>\\n<p class='text-slate-600 dark:text-slate-400'>These map one-to-one onto the four options in <a href='/article/how-to-setup-nextblock'>the install guide</a>. The command is the same everywhere; what differs is where the new code comes from.</p>\\n\\n<div class='grid gap-5 md:grid-cols-2 my-8'>\\n <a href='#vercel' class='block rounded-[1.75rem] border border-blue-200 bg-blue-50/70 p-6 no-underline transition-shadow hover:shadow-lg dark:border-blue-500/20 dark:bg-blue-500/10'>\\n <span class='flex h-9 w-9 items-center justify-center rounded-full bg-blue-600 text-sm font-bold text-white'>1</span>\\n <p class='mt-4 mb-0 text-xs font-semibold uppercase tracking-[0.22em] text-blue-700 dark:text-blue-200'>Fully automatic</p>\\n <h3 class='mt-2 mb-2 text-xl font-semibold text-slate-900 dark:text-white'>One-click Vercel &amp; GitHub forks</h3>\\n <p class='mb-0 text-sm leading-6 text-slate-600 dark:text-slate-300'>A daily workflow merges upstream into your repository and Vercel redeploys. You do nothing.</p>\\n </a>\\n <a href='#docker' class='block rounded-[1.75rem] border border-amber-200 bg-amber-50/70 p-6 no-underline transition-shadow hover:shadow-lg dark:border-amber-500/20 dark:bg-amber-500/10'>\\n <span class='flex h-9 w-9 items-center justify-center rounded-full bg-amber-500 text-sm font-bold text-white'>2</span>\\n <p class='mt-4 mb-0 text-xs font-semibold uppercase tracking-[0.22em] text-amber-700 dark:text-amber-200'>One command</p>\\n <h3 class='mt-2 mb-2 text-xl font-semibold text-slate-900 dark:text-white'>npm create nextblock &rarr; Docker</h3>\\n <p class='mb-0 text-sm leading-6 text-slate-600 dark:text-slate-300'>Update, then rebuild the local stack. Your Postgres and media volumes survive untouched.</p>\\n </a>\\n <a href='#cloud' class='block rounded-[1.75rem] border border-violet-200 bg-violet-50/70 p-6 no-underline transition-shadow hover:shadow-lg dark:border-violet-500/20 dark:bg-violet-500/10'>\\n <span class='flex h-9 w-9 items-center justify-center rounded-full bg-violet-600 text-sm font-bold text-white'>3</span>\\n <p class='mt-4 mb-0 text-xs font-semibold uppercase tracking-[0.22em] text-violet-700 dark:text-violet-200'>One command</p>\\n <h3 class='mt-2 mb-2 text-xl font-semibold text-slate-900 dark:text-white'>npm create nextblock &rarr; Supabase</h3>\\n <p class='mb-0 text-sm leading-6 text-slate-600 dark:text-slate-300'>New framework files come from npm; your own pages, routes and content are left alone.</p>\\n </a>\\n <a href='#clone' class='block rounded-[1.75rem] border border-emerald-200 bg-emerald-50/70 p-6 no-underline transition-shadow hover:shadow-lg dark:border-emerald-500/20 dark:bg-emerald-500/10'>\\n <span class='flex h-9 w-9 items-center justify-center rounded-full bg-emerald-600 text-sm font-bold text-white'>4</span>\\n <p class='mt-4 mb-0 text-xs font-semibold uppercase tracking-[0.22em] text-emerald-700 dark:text-emerald-200'>One command</p>\\n <h3 class='mt-2 mb-2 text-xl font-semibold text-slate-900 dark:text-white'>git clone the monorepo</h3>\\n <p class='mb-0 text-sm leading-6 text-slate-600 dark:text-slate-300'>A guarded git pull or upstream merge, then dependencies and migrations. No manual steps.</p>\\n </a>\\n</div>\\n\\n<h2 id='vercel'>1. One-click Vercel and GitHub forks &mdash; hands-off</h2>\\n<p>This path updates itself. When you deployed, NextBlock created a repository you own; the dashboard&rsquo;s <strong>Connect GitHub</strong> onboarding step installs a workflow into it that runs <strong>every day at midnight UTC</strong> and can also be triggered by hand from your repository&rsquo;s <strong>Actions</strong> tab.</p>\\n<ol class='space-y-2'>\\n <li>The workflow merges the latest upstream NextBlock into your deploy branch.</li>\\n <li>A clean merge is pushed to your branch, which triggers an ordinary Vercel deployment.</li>\\n <li>During that production build, NextBlock applies any pending database migrations <em>before</em> the app is built &mdash; so new code never runs against an old schema.</li>\\n <li>If the merge conflicts, nothing is pushed. The workflow opens a GitHub issue instead, and your CMS dashboard shows an amber banner linking straight to it. Resolve it, close the issue, and the banner clears itself.</li>\\n</ol>\\n<div class='rounded-3xl border border-emerald-200 bg-emerald-50/80 p-6 my-8 dark:border-emerald-500/20 dark:bg-emerald-500/10'>\\n <p class='mt-0 text-xs font-semibold uppercase tracking-[0.22em] text-emerald-700 dark:text-emerald-200'>Make the repository public</p>\\n <p class='mt-3 mb-0 text-sm text-slate-700 dark:text-slate-200'>A public repository is completely zero-config. On a private one, add a <code>NEXTBLOCK_GITHUB_TOKEN</code> environment variable with read access to issues so the conflict banner still works &mdash; and note that Vercel&rsquo;s free Hobby plan refuses to auto-deploy automated commits on private repositories, so the merge would land without deploying.</p>\\n</div>\\n<p>Working on a local clone of that fork? <code>npm run update</code> does the same merge on your machine, adding an <code>upstream</code> remote if it is missing, then installs dependencies and applies migrations.</p>\\n\\n<h2 id='docker'>2. npm create nextblock &rarr; Docker &mdash; update, then rebuild</h2>\\n<p>From your project directory:</p>\\n<pre><code>npm run update\\nnpm run docker:up</code></pre>\\n<p>The first command refreshes the application, its dependencies and the schema; the second rebuilds and restarts the containers. Your database and media live in Docker volumes and are never touched by either step &mdash; <code>docker:up</code> rebuilds images, not data.</p>\\n\\n<h2 id='cloud'>3. npm create nextblock &rarr; managed Supabase &mdash; one command</h2>\\n<pre><code>npm run update\\nnpm run build\\nnpm start</code></pre>\\n<p>Your project is a standalone Next.js app, so new framework code is fetched from the published <code>create-nextblock</code> package on npm &mdash; the exact artifact your project was scaffolded from, versioned in lockstep with the release. NextBlock refreshes the files it owns, merges the new dependency versions into your <code>package.json</code>, runs <code>npm install</code>, and then applies migrations.</p>\\n<div class='rounded-3xl border border-violet-200 bg-violet-50/80 p-6 my-8 dark:border-violet-500/20 dark:bg-violet-500/10'>\\n <p class='mt-0 text-xs font-semibold uppercase tracking-[0.22em] text-violet-700 dark:text-violet-200'>Deploying to Vercel from this project</p>\\n <p class='mt-3 mb-0 text-sm text-slate-700 dark:text-slate-200'>Run <code>npm run update</code> locally, commit the result, and push. Your production build applies any pending migrations on the way up, exactly as it does for one-click installs.</p>\\n</div>\\n\\n<h2 id='clone'>4. The cloned monorepo &mdash; one command</h2>\\n<pre><code>npm run update</code></pre>\\n<p>In a clone of the NextBlock repository this fast-forwards your checkout, reinstalls workspace dependencies and applies pending migrations. It refuses to run over uncommitted changes and tells you how to stash them first, so an update can never silently eat work in progress. If you have local commits, it stops and points you at <code>git pull --rebase</code> rather than guessing.</p>\\n\\n<h2 id='what-it-does'>What <code>npm run update</code> actually does</h2>\\n<ol class='space-y-2'>\\n <li><strong>Identifies the install.</strong> Monorepo or standalone app; git-backed or npm-backed; Docker or not.</li>\\n <li><strong>Updates the code</strong> from the right source &mdash; an upstream git merge, a fast-forward pull, or the published <code>create-nextblock</code> package.</li>\\n <li><strong>Installs dependencies</strong> with <code>npm install</code>, so the code and the packages it imports move together.</li>\\n <li><strong>Refreshes the migration files</strong> shipped inside <code>@nextblock-cms/db</code>, so the newest schema changes are on disk before anything is applied.</li>\\n <li><strong>Applies pending migrations</strong>, listing them first and asking before it writes.</li>\\n <li><strong>Clears the dashboard&rsquo;s update banner</strong> once the new version is really in place.</li>\\n</ol>\\n\\n<h3>Options</h3>\\n<div class='overflow-x-auto my-6'>\\n<table class='w-full text-left text-sm'>\\n <thead><tr class='border-b border-slate-200 dark:border-white/10'><th class='py-3 pr-4 font-semibold'>Command</th><th class='py-3 font-semibold'>What it does</th></tr></thead>\\n <tbody class='align-top'>\\n <tr class='border-b border-slate-100 dark:border-white/5'><td class='py-3 pr-4'><code>npm run update</code></td><td class='py-3'>Code, dependencies and schema.</td></tr>\\n <tr class='border-b border-slate-100 dark:border-white/5'><td class='py-3 pr-4'><code>npm run update -- --check</code></td><td class='py-3'>Report what would change. Writes nothing.</td></tr>\\n <tr class='border-b border-slate-100 dark:border-white/5'><td class='py-3 pr-4'><code>npm run update -- --yes</code></td><td class='py-3'>Skip the confirmation prompts. Useful in CI.</td></tr>\\n <tr class='border-b border-slate-100 dark:border-white/5'><td class='py-3 pr-4'><code>npm run update -- --db-only</code></td><td class='py-3'>Apply pending migrations and nothing else.</td></tr>\\n <tr class='border-b border-slate-100 dark:border-white/5'><td class='py-3 pr-4'><code>npm run update -- --skip-db</code></td><td class='py-3'>Update code and dependencies, leave the database alone.</td></tr>\\n <tr><td class='py-3 pr-4'><code>npm run update -- --force</code></td><td class='py-3'>Run even when you are already on the latest version.</td></tr>\\n </tbody>\\n</table>\\n</div>\\n\\n<h2 id='database'>What happens to your database</h2>\\n<p>Schema changes are <strong>forward-only</strong>. NextBlock never rewrites or replays a migration that has already run: each one is applied and recorded in the same transaction, so a failure rolls back cleanly and leaves the database exactly as it was. Already-applied migrations are skipped by version, which makes re-running an update completely safe.</p>\\n<p>Migrations change <em>structure</em> &mdash; tables, columns, indexes, permissions. Your pages, posts, products, media and users are yours; the update never deletes or rewrites them.</p>\\n<div class='rounded-3xl border border-blue-200 bg-blue-50/80 p-6 my-8 dark:border-blue-500/20 dark:bg-blue-500/10'>\\n <p class='mt-0 text-xs font-semibold uppercase tracking-[0.22em] text-blue-700 dark:text-blue-200'>Belt and braces</p>\\n <p class='mt-3 mb-0 text-sm text-slate-700 dark:text-slate-200'>Before a big jump on a production site, take a database snapshot &mdash; Supabase does daily backups on paid plans, and you can trigger one on demand from the Supabase dashboard. Then run <code>npm run update -- --check</code> to see the pending list before you commit to it.</p>\\n</div>\\n\\n<h2 id='safety'>If something goes wrong</h2>\\n<ul class='space-y-2'>\\n <li><strong>Standalone projects:</strong> every framework file the update replaces is copied first into a timestamped folder under <code>.nextblock-backup/</code> in your project. Nothing is deleted, so files you added yourself are never removed.</li>\\n <li><strong>Git-backed installs:</strong> the update is an ordinary commit. <code>git log</code> shows it and <code>git revert</code> undoes it.</li>\\n <li><strong>A conflicted merge</strong> is aborted automatically &mdash; your working tree is left exactly as it was, with instructions printed for resolving it by hand.</li>\\n <li><strong>A failed migration</strong> rolls back. Fix the cause and re-run; nothing half-applied is left behind.</li>\\n</ul>\\n<p>If you have customised a file that NextBlock owns &mdash; something under <code>app/</code>, <code>components/</code> or <code>lib/</code> &mdash; the update will replace it and back up your version. Diff the backup afterwards to bring your change forward. Customisations that live in your own new files, in the CMS, or in <code>.env</code> are never affected.</p>\\n\\n<h2 id='knowing'>Knowing when there is something to update</h2>\\n<p>You do not have to poll. NextBlock checks in the background while you use the CMS and raises a dashboard banner when a newer version is published, telling you which version you are on and what is available. Administrators can also just run <code>npm run update -- --check</code> at any time.</p>\\n\\n<h2 id='faq'>Update FAQ</h2>\\n<h3>Will updating overwrite my content or settings?</h3>\\n<p>No. Content, media, users and settings live in your database; site configuration lives in your environment variables. The update touches application code, dependencies and schema structure only.</p>\\n<h3>Do I have to update every release?</h3>\\n<p>No, though staying close to the latest release keeps you on security fixes and makes each jump smaller. Updates apply in sequence, so skipping several versions still lands correctly.</p>\\n<h3>Can I run it in CI?</h3>\\n<p>Yes &mdash; <code>npm run update -- --yes</code> never prompts, and it exits non-zero if the schema step fails so a pipeline can catch it.</p>\\n<h3>What if my project has no database connection configured?</h3>\\n<p>Code and dependencies still update; the schema step is skipped with a warning telling you which environment variable to set. Re-run <code>npm run update -- --db-only</code> once it is configured.</p>\\n<h3>I am on the one-click Vercel deploy &mdash; do I need to run anything?</h3>\\n<p>No. That path is fully automatic. The command exists for when you want an update <em>now</em> rather than at midnight, or when you are working on a local clone.</p>\\n\\n<div class='rounded-[2rem] border border-slate-200/80 bg-slate-50 p-8 my-12 text-center dark:border-white/10 dark:bg-white/5'>\\n <p class='mt-0 text-2xl font-semibold text-slate-900 dark:text-white'>One command, every install.</p>\\n <p class='text-sm text-slate-600 dark:text-slate-300'>New to NextBlock? Start with the install guide &mdash; then never think about upgrades again.</p>\\n <div class='mt-5 flex flex-wrap justify-center gap-3'>\\n <a href='/article/how-to-setup-nextblock' class='inline-flex items-center rounded-full bg-slate-900 px-6 py-3 text-sm font-semibold text-white no-underline shadow-lg hover:bg-slate-700 dark:bg-white dark:text-slate-900 dark:hover:bg-slate-200'>Read the install guide</a>\\n <a href='https://github.com/nextblock-cms/nextblock' target='_blank' rel='noopener' class='inline-flex items-center rounded-full border border-slate-300 px-6 py-3 text-sm font-semibold text-slate-700 no-underline hover:border-slate-500 dark:border-white/20 dark:text-slate-200 dark:hover:border-white/50'>View on GitHub</a>\\n </div>\\n</div>\"}\n$nben$::jsonb, 0);\n END IF;\n\n ---------------------------------------------------------------------------\n -- French\n ---------------------------------------------------------------------------\n INSERT INTO public.posts (\n language_id, author_id, title, slug, label, excerpt, subtitle, status,\n published_at, meta_title, meta_description, feature_image_id, version,\n translation_group_id\n )\n SELECT\n 2, NULL,\n 'Les mises à jour de NextBlock : une seule commande, quelle que soit l''installation',\n 'comment-fonctionnent-les-mises-a-jour',\n 'Maintenance',\n 'Quelle que soit votre installation — Vercel en un clic, le CLI, Docker ou un git clone — une seule commande npm run update fait avancer ensemble le code, les dépendances et le schéma de base de données.',\n 'Synchronisation automatique sur Vercel, et une seule commande partout ailleurs : ce que fait npm run update, ce qu''il ne touche jamais, et comment revenir en arrière.',\n 'published', now(),\n 'Mettre à jour NextBlock — une commande pour toutes les installations',\n 'Mettez NextBlock à jour en une étape. npm run update récupère le nouveau code, installe les dépendances et applique les migrations en attente, sur Vercel, Docker, CLI et git clone.',\n v_image, 1, v_group\n WHERE NOT EXISTS (\n SELECT 1 FROM public.posts\n WHERE language_id = 2 AND slug = 'comment-fonctionnent-les-mises-a-jour'\n );\n\n SELECT id INTO v_fr_post\n FROM public.posts\n WHERE language_id = 2 AND slug = 'comment-fonctionnent-les-mises-a-jour'\n ORDER BY id\n LIMIT 1;\n\n IF v_fr_post IS NOT NULL AND NOT EXISTS (\n SELECT 1 FROM public.blocks WHERE post_id = v_fr_post\n ) THEN\n INSERT INTO public.blocks (post_id, language_id, block_type, content, \"order\")\n VALUES (v_fr_post, 2, 'text', $nbfr$\n{\"html_content\":\"<p class='text-lg leading-8 text-slate-700 dark:text-slate-300'>NextBlock &eacute;volue en continu &mdash; nouveaux blocs, corrections de l'&eacute;diteur, correctifs de s&eacute;curit&eacute;, et parfois une modification de la base de donn&eacute;es dont le nouveau code d&eacute;pend. Suivre tout cela supposait autrefois de savoir laquelle des quatre m&eacute;thodes d'installation vous aviez utilis&eacute;e. Ce n'est plus le cas. Tout projet NextBlock, quelle que soit sa cr&eacute;ation, comprend une seule commande :</p>\\n\\n<div class='my-10 overflow-hidden rounded-[2rem] border border-slate-800 bg-slate-950 shadow-2xl'>\\n <div class='flex items-center gap-2 border-b border-white/10 px-5 py-3'>\\n <span class='h-3 w-3 rounded-full bg-red-400/70'></span>\\n <span class='h-3 w-3 rounded-full bg-yellow-400/70'></span>\\n <span class='h-3 w-3 rounded-full bg-green-400/70'></span>\\n <span class='ml-3 text-xs font-mono text-slate-400'>votre projet</span>\\n </div>\\n <div class='px-6 py-8 text-center'>\\n <p class='mt-0 mb-2 font-mono text-2xl sm:text-3xl font-semibold text-emerald-300'>npm run update</p>\\n <p class='mb-0 text-sm text-slate-400'>Code &middot; d&eacute;pendances &middot; sch&eacute;ma de base de donn&eacute;es &mdash; dans cet ordre, en une seule &eacute;tape.</p>\\n </div>\\n</div>\\n\\n<p>La commande d&eacute;termine dans quel type d'installation elle s'ex&eacute;cute, choisit la bonne source pour le nouveau code, installe les d&eacute;pendances correspondantes, puis applique les migrations dont la nouvelle version a besoin. Si vous pr&eacute;f&eacute;rez regarder avant de sauter, <code>npm run update -- --check</code> indique exactement ce qui changerait sans rien modifier.</p>\\n\\n<h2 id='the-four-paths'>Les quatre installations et leurs mises &agrave; jour</h2>\\n<p class='text-slate-600 dark:text-slate-400'>Elles correspondent une &agrave; une aux quatre options du <a href='/article/comment-configurer-nextblock'>guide d'installation</a>. La commande est la m&ecirc;me partout ; seule la provenance du nouveau code change.</p>\\n\\n<div class='grid gap-5 md:grid-cols-2 my-8'>\\n <a href='#vercel' class='block rounded-[1.75rem] border border-blue-200 bg-blue-50/70 p-6 no-underline transition-shadow hover:shadow-lg dark:border-blue-500/20 dark:bg-blue-500/10'>\\n <span class='flex h-9 w-9 items-center justify-center rounded-full bg-blue-600 text-sm font-bold text-white'>1</span>\\n <p class='mt-4 mb-0 text-xs font-semibold uppercase tracking-[0.22em] text-blue-700 dark:text-blue-200'>Enti&egrave;rement automatique</p>\\n <h3 class='mt-2 mb-2 text-xl font-semibold text-slate-900 dark:text-white'>Vercel en un clic et forks GitHub</h3>\\n <p class='mb-0 text-sm leading-6 text-slate-600 dark:text-slate-300'>Un workflow quotidien fusionne les nouveaut&eacute;s dans votre d&eacute;p&ocirc;t et Vercel red&eacute;ploie. Vous n'avez rien &agrave; faire.</p>\\n </a>\\n <a href='#docker' class='block rounded-[1.75rem] border border-amber-200 bg-amber-50/70 p-6 no-underline transition-shadow hover:shadow-lg dark:border-amber-500/20 dark:bg-amber-500/10'>\\n <span class='flex h-9 w-9 items-center justify-center rounded-full bg-amber-500 text-sm font-bold text-white'>2</span>\\n <p class='mt-4 mb-0 text-xs font-semibold uppercase tracking-[0.22em] text-amber-700 dark:text-amber-200'>Une commande</p>\\n <h3 class='mt-2 mb-2 text-xl font-semibold text-slate-900 dark:text-white'>npm create nextblock &rarr; Docker</h3>\\n <p class='mb-0 text-sm leading-6 text-slate-600 dark:text-slate-300'>Mettez &agrave; jour, puis reconstruisez la pile locale. Vos volumes Postgres et m&eacute;dias restent intacts.</p>\\n </a>\\n <a href='#cloud' class='block rounded-[1.75rem] border border-violet-200 bg-violet-50/70 p-6 no-underline transition-shadow hover:shadow-lg dark:border-violet-500/20 dark:bg-violet-500/10'>\\n <span class='flex h-9 w-9 items-center justify-center rounded-full bg-violet-600 text-sm font-bold text-white'>3</span>\\n <p class='mt-4 mb-0 text-xs font-semibold uppercase tracking-[0.22em] text-violet-700 dark:text-violet-200'>Une commande</p>\\n <h3 class='mt-2 mb-2 text-xl font-semibold text-slate-900 dark:text-white'>npm create nextblock &rarr; Supabase</h3>\\n <p class='mb-0 text-sm leading-6 text-slate-600 dark:text-slate-300'>Les nouveaux fichiers viennent de npm ; vos pages, routes et contenus ne sont pas touch&eacute;s.</p>\\n </a>\\n <a href='#clone' class='block rounded-[1.75rem] border border-emerald-200 bg-emerald-50/70 p-6 no-underline transition-shadow hover:shadow-lg dark:border-emerald-500/20 dark:bg-emerald-500/10'>\\n <span class='flex h-9 w-9 items-center justify-center rounded-full bg-emerald-600 text-sm font-bold text-white'>4</span>\\n <p class='mt-4 mb-0 text-xs font-semibold uppercase tracking-[0.22em] text-emerald-700 dark:text-emerald-200'>Une commande</p>\\n <h3 class='mt-2 mb-2 text-xl font-semibold text-slate-900 dark:text-white'>git clone du monorepo</h3>\\n <p class='mb-0 text-sm leading-6 text-slate-600 dark:text-slate-300'>Un pull ou une fusion prot&eacute;g&eacute;e, puis les d&eacute;pendances et les migrations. Aucune &eacute;tape manuelle.</p>\\n </a>\\n</div>\\n\\n<h2 id='vercel'>1. Vercel en un clic et forks GitHub &mdash; sans intervention</h2>\\n<p>Ce chemin se met &agrave; jour tout seul. Lors du d&eacute;ploiement, NextBlock a cr&eacute;&eacute; un d&eacute;p&ocirc;t qui vous appartient ; l'&eacute;tape <strong>Connect GitHub</strong> du tableau de bord y installe un workflow qui s'ex&eacute;cute <strong>chaque jour &agrave; minuit UTC</strong> et peut aussi &ecirc;tre lanc&eacute; &agrave; la demande depuis l'onglet <strong>Actions</strong> de votre d&eacute;p&ocirc;t.</p>\\n<ol class='space-y-2'>\\n <li>Le workflow fusionne la derni&egrave;re version de NextBlock dans votre branche de d&eacute;ploiement.</li>\\n <li>Une fusion propre est pouss&eacute;e sur votre branche, ce qui d&eacute;clenche un d&eacute;ploiement Vercel normal.</li>\\n <li>Pendant ce build de production, NextBlock applique les migrations en attente <em>avant</em> de construire l'application &mdash; le nouveau code ne tourne donc jamais sur un ancien sch&eacute;ma.</li>\\n <li>En cas de conflit, rien n'est pouss&eacute;. Le workflow ouvre une issue GitHub et votre tableau de bord affiche une banni&egrave;re ambre qui pointe dessus. R&eacute;solvez, fermez l'issue, et la banni&egrave;re dispara&icirc;t d'elle-m&ecirc;me.</li>\\n</ol>\\n<div class='rounded-3xl border border-emerald-200 bg-emerald-50/80 p-6 my-8 dark:border-emerald-500/20 dark:bg-emerald-500/10'>\\n <p class='mt-0 text-xs font-semibold uppercase tracking-[0.22em] text-emerald-700 dark:text-emerald-200'>Rendez le d&eacute;p&ocirc;t public</p>\\n <p class='mt-3 mb-0 text-sm text-slate-700 dark:text-slate-200'>Un d&eacute;p&ocirc;t public ne demande aucune configuration. Sur un d&eacute;p&ocirc;t priv&eacute;, ajoutez une variable d'environnement <code>NEXTBLOCK_GITHUB_TOKEN</code> avec un acc&egrave;s en lecture aux issues pour que la banni&egrave;re de conflit fonctionne &mdash; et sachez que l'offre gratuite Hobby de Vercel refuse de d&eacute;ployer automatiquement les commits automatis&eacute;s sur un d&eacute;p&ocirc;t priv&eacute;.</p>\\n</div>\\n<p>Vous travaillez sur un clone local de ce fork ? <code>npm run update</code> effectue la m&ecirc;me fusion sur votre machine, en ajoutant le d&eacute;p&ocirc;t <code>upstream</code> s'il manque, puis installe les d&eacute;pendances et applique les migrations.</p>\\n\\n<h2 id='docker'>2. npm create nextblock &rarr; Docker &mdash; mettre &agrave; jour puis reconstruire</h2>\\n<p>Depuis le dossier de votre projet :</p>\\n<pre><code>npm run update\\nnpm run docker:up</code></pre>\\n<p>La premi&egrave;re commande met &agrave; jour l'application, ses d&eacute;pendances et le sch&eacute;ma ; la seconde reconstruit et red&eacute;marre les conteneurs. Votre base de donn&eacute;es et vos m&eacute;dias vivent dans des volumes Docker et ne sont touch&eacute;s ni par l'une ni par l'autre &mdash; <code>docker:up</code> reconstruit des images, pas des donn&eacute;es.</p>\\n\\n<h2 id='cloud'>3. npm create nextblock &rarr; Supabase g&eacute;r&eacute; &mdash; une commande</h2>\\n<pre><code>npm run update\\nnpm run build\\nnpm start</code></pre>\\n<p>Votre projet est une application Next.js autonome : le nouveau code provient donc du paquet <code>create-nextblock</code> publi&eacute; sur npm &mdash; exactement l'artefact &agrave; partir duquel votre projet a &eacute;t&eacute; g&eacute;n&eacute;r&eacute;, versionn&eacute; en phase avec la release. NextBlock rafra&icirc;chit les fichiers qui lui appartiennent, fusionne les nouvelles versions de d&eacute;pendances dans votre <code>package.json</code>, lance <code>npm install</code>, puis applique les migrations.</p>\\n<div class='rounded-3xl border border-violet-200 bg-violet-50/80 p-6 my-8 dark:border-violet-500/20 dark:bg-violet-500/10'>\\n <p class='mt-0 text-xs font-semibold uppercase tracking-[0.22em] text-violet-700 dark:text-violet-200'>Vous d&eacute;ployez ce projet sur Vercel ?</p>\\n <p class='mt-3 mb-0 text-sm text-slate-700 dark:text-slate-200'>Lancez <code>npm run update</code> en local, validez le r&eacute;sultat et poussez. Votre build de production applique les migrations en attente au passage, exactement comme pour les installations en un clic.</p>\\n</div>\\n\\n<h2 id='clone'>4. Le monorepo clon&eacute; &mdash; une commande</h2>\\n<pre><code>npm run update</code></pre>\\n<p>Dans un clone du d&eacute;p&ocirc;t NextBlock, la commande met votre copie &agrave; jour, r&eacute;installe les d&eacute;pendances du workspace et applique les migrations en attente. Elle refuse de s'ex&eacute;cuter par-dessus des modifications non valid&eacute;es et vous explique comment les mettre de c&ocirc;t&eacute; : une mise &agrave; jour ne peut donc jamais faire dispara&icirc;tre du travail en cours. Si vous avez des commits locaux, elle s'arr&ecirc;te et vous oriente vers <code>git pull --rebase</code> plut&ocirc;t que de deviner.</p>\\n\\n<h2 id='what-it-does'>Ce que fait r&eacute;ellement <code>npm run update</code></h2>\\n<ol class='space-y-2'>\\n <li><strong>Identifie l'installation.</strong> Monorepo ou application autonome ; bas&eacute;e sur git ou sur npm ; Docker ou non.</li>\\n <li><strong>Met &agrave; jour le code</strong> depuis la bonne source &mdash; fusion git, pull en avance rapide, ou le paquet <code>create-nextblock</code> publi&eacute;.</li>\\n <li><strong>Installe les d&eacute;pendances</strong> avec <code>npm install</code>, pour que le code et les paquets qu'il importe avancent ensemble.</li>\\n <li><strong>Rafra&icirc;chit les fichiers de migration</strong> livr&eacute;s dans <code>@nextblock-cms/db</code>, afin que les derni&egrave;res &eacute;volutions du sch&eacute;ma soient sur le disque avant toute application.</li>\\n <li><strong>Applique les migrations en attente</strong>, en les listant d'abord et en demandant confirmation.</li>\\n <li><strong>Efface la banni&egrave;re de mise &agrave; jour</strong> du tableau de bord une fois la nouvelle version r&eacute;ellement en place.</li>\\n</ol>\\n\\n<h3>Options</h3>\\n<div class='overflow-x-auto my-6'>\\n<table class='w-full text-left text-sm'>\\n <thead><tr class='border-b border-slate-200 dark:border-white/10'><th class='py-3 pr-4 font-semibold'>Commande</th><th class='py-3 font-semibold'>Effet</th></tr></thead>\\n <tbody class='align-top'>\\n <tr class='border-b border-slate-100 dark:border-white/5'><td class='py-3 pr-4'><code>npm run update</code></td><td class='py-3'>Code, d&eacute;pendances et sch&eacute;ma.</td></tr>\\n <tr class='border-b border-slate-100 dark:border-white/5'><td class='py-3 pr-4'><code>npm run update -- --check</code></td><td class='py-3'>Indique ce qui changerait. N'&eacute;crit rien.</td></tr>\\n <tr class='border-b border-slate-100 dark:border-white/5'><td class='py-3 pr-4'><code>npm run update -- --yes</code></td><td class='py-3'>Sans confirmation. Pratique en CI.</td></tr>\\n <tr class='border-b border-slate-100 dark:border-white/5'><td class='py-3 pr-4'><code>npm run update -- --db-only</code></td><td class='py-3'>Applique uniquement les migrations en attente.</td></tr>\\n <tr class='border-b border-slate-100 dark:border-white/5'><td class='py-3 pr-4'><code>npm run update -- --skip-db</code></td><td class='py-3'>Met &agrave; jour le code et les d&eacute;pendances, sans toucher &agrave; la base.</td></tr>\\n <tr><td class='py-3 pr-4'><code>npm run update -- --force</code></td><td class='py-3'>S'ex&eacute;cute m&ecirc;me si vous &ecirc;tes d&eacute;j&agrave; &agrave; jour.</td></tr>\\n </tbody>\\n</table>\\n</div>\\n\\n<h2 id='database'>Ce qui arrive &agrave; votre base de donn&eacute;es</h2>\\n<p>Les &eacute;volutions du sch&eacute;ma sont <strong>uniquement additives</strong>. NextBlock ne r&eacute;&eacute;crit ni ne rejoue jamais une migration d&eacute;j&agrave; appliqu&eacute;e : chacune est appliqu&eacute;e et enregistr&eacute;e dans la m&ecirc;me transaction, si bien qu'un &eacute;chec est annul&eacute; proprement et laisse la base exactement dans son &eacute;tat initial. Les migrations d&eacute;j&agrave; appliqu&eacute;es sont ignor&eacute;es par num&eacute;ro de version, ce qui rend une nouvelle ex&eacute;cution totalement s&ucirc;re.</p>\\n<p>Les migrations modifient la <em>structure</em> &mdash; tables, colonnes, index, permissions. Vos pages, articles, produits, m&eacute;dias et utilisateurs vous appartiennent : la mise &agrave; jour ne les supprime ni ne les r&eacute;&eacute;crit.</p>\\n<div class='rounded-3xl border border-blue-200 bg-blue-50/80 p-6 my-8 dark:border-blue-500/20 dark:bg-blue-500/10'>\\n <p class='mt-0 text-xs font-semibold uppercase tracking-[0.22em] text-blue-700 dark:text-blue-200'>Par pr&eacute;caution</p>\\n <p class='mt-3 mb-0 text-sm text-slate-700 dark:text-slate-200'>Avant un grand saut sur un site en production, prenez une sauvegarde de la base &mdash; Supabase en r&eacute;alise quotidiennement sur les offres payantes, et vous pouvez en d&eacute;clencher une &agrave; la demande depuis son tableau de bord. Lancez ensuite <code>npm run update -- --check</code> pour voir la liste des migrations en attente.</p>\\n</div>\\n\\n<h2 id='safety'>En cas de probl&egrave;me</h2>\\n<ul class='space-y-2'>\\n <li><strong>Projets autonomes :</strong> chaque fichier remplac&eacute; est d'abord copi&eacute; dans un dossier horodat&eacute; sous <code>.nextblock-backup/</code> dans votre projet. Rien n'est supprim&eacute; : les fichiers que vous avez ajout&eacute;s ne disparaissent jamais.</li>\\n <li><strong>Installations bas&eacute;es sur git :</strong> la mise &agrave; jour est un commit ordinaire. <code>git log</code> l'affiche et <code>git revert</code> l'annule.</li>\\n <li><strong>Une fusion en conflit</strong> est annul&eacute;e automatiquement &mdash; votre copie de travail reste intacte, avec les instructions pour r&eacute;soudre &agrave; la main.</li>\\n <li><strong>Une migration en &eacute;chec</strong> est annul&eacute;e. Corrigez la cause et relancez : rien ne reste &agrave; moiti&eacute; appliqu&eacute;.</li>\\n</ul>\\n<p>Si vous avez personnalis&eacute; un fichier appartenant &agrave; NextBlock &mdash; sous <code>app/</code>, <code>components/</code> ou <code>lib/</code> &mdash; la mise &agrave; jour le remplacera en sauvegardant votre version. Comparez ensuite la sauvegarde pour reporter votre modification. Les personnalisations qui vivent dans vos propres fichiers, dans le CMS ou dans <code>.env</code> ne sont jamais affect&eacute;es.</p>\\n\\n<h2 id='knowing'>Savoir qu'une mise &agrave; jour est disponible</h2>\\n<p>Inutile de surveiller. NextBlock v&eacute;rifie en arri&egrave;re-plan pendant que vous utilisez le CMS et affiche une banni&egrave;re sur le tableau de bord d&egrave;s qu'une version plus r&eacute;cente est publi&eacute;e, en indiquant votre version actuelle et celle disponible. Les administrateurs peuvent aussi lancer <code>npm run update -- --check</code> &agrave; tout moment.</p>\\n\\n<h2 id='faq'>FAQ des mises &agrave; jour</h2>\\n<h3>La mise &agrave; jour va-t-elle &eacute;craser mon contenu ou mes r&eacute;glages ?</h3>\\n<p>Non. Contenus, m&eacute;dias, utilisateurs et r&eacute;glages vivent dans votre base de donn&eacute;es ; la configuration du site vit dans vos variables d'environnement. La mise &agrave; jour ne touche que le code, les d&eacute;pendances et la structure du sch&eacute;ma.</p>\\n<h3>Dois-je installer chaque version ?</h3>\\n<p>Non, mais rester proche de la derni&egrave;re version vous garantit les correctifs de s&eacute;curit&eacute; et rend chaque saut plus petit. Les migrations s'appliquent dans l'ordre : sauter plusieurs versions fonctionne malgr&eacute; tout.</p>\\n<h3>Puis-je l'ex&eacute;cuter en CI ?</h3>\\n<p>Oui &mdash; <code>npm run update -- --yes</code> ne pose aucune question et renvoie un code d'erreur si l'&eacute;tape sch&eacute;ma &eacute;choue, pour qu'un pipeline puisse le d&eacute;tecter.</p>\\n<h3>Et si aucune connexion &agrave; la base n'est configur&eacute;e ?</h3>\\n<p>Le code et les d&eacute;pendances sont tout de m&ecirc;me mis &agrave; jour ; l'&eacute;tape sch&eacute;ma est ignor&eacute;e avec un avertissement indiquant la variable d'environnement &agrave; d&eacute;finir. Relancez ensuite <code>npm run update -- --db-only</code>.</p>\\n<h3>Je suis sur le d&eacute;ploiement Vercel en un clic &mdash; dois-je lancer quelque chose ?</h3>\\n<p>Non. Ce chemin est enti&egrave;rement automatique. La commande existe pour mettre &agrave; jour <em>tout de suite</em> plut&ocirc;t qu'&agrave; minuit, ou lorsque vous travaillez sur un clone local.</p>\\n\\n<div class='rounded-[2rem] border border-slate-200/80 bg-slate-50 p-8 my-12 text-center dark:border-white/10 dark:bg-white/5'>\\n <p class='mt-0 text-2xl font-semibold text-slate-900 dark:text-white'>Une commande, toutes les installations.</p>\\n <p class='text-sm text-slate-600 dark:text-slate-300'>Vous d&eacute;butez avec NextBlock ? Commencez par le guide d'installation &mdash; puis oubliez les mises &agrave; jour.</p>\\n <div class='mt-5 flex flex-wrap justify-center gap-3'>\\n <a href='/article/comment-configurer-nextblock' class='inline-flex items-center rounded-full bg-slate-900 px-6 py-3 text-sm font-semibold text-white no-underline shadow-lg hover:bg-slate-700 dark:bg-white dark:text-slate-900 dark:hover:bg-slate-200'>Lire le guide d'installation</a>\\n <a href='https://github.com/nextblock-cms/nextblock' target='_blank' rel='noopener' class='inline-flex items-center rounded-full border border-slate-300 px-6 py-3 text-sm font-semibold text-slate-700 no-underline hover:border-slate-500 dark:border-white/20 dark:text-slate-200 dark:hover:border-white/50'>Voir sur GitHub</a>\\n </div>\\n</div>\"}\n$nbfr$::jsonb, 0);\n END IF;\nEND\n$body$;\n\n-- Revision baseline for the two new posts.\n--\n-- 00000000000016 back-fills a 'snapshot' revision for every post that existed WHEN IT\n-- RAN. On a fresh install migrations run in order, so 016 executes before this file and\n-- these two posts would be the only ones in the CMS without a restore point. This is the\n-- same projection as 016 section 3b, scoped to the two slugs.\nINSERT INTO public.post_revisions (post_id, author_id, version, revision_type, content)\nSELECT\n po.id,\n NULL::uuid,\n po.version,\n 'snapshot'::public.revision_type,\n jsonb_build_object(\n 'meta', jsonb_build_object(\n 'title', po.title,\n 'slug', po.slug,\n 'language_id', po.language_id,\n 'status', po.status,\n 'meta_title', po.meta_title,\n 'meta_description', po.meta_description,\n 'custom_canonical', po.custom_canonical,\n 'published_at', to_char(po.published_at AT TIME ZONE 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"'),\n 'feature_image_id', po.feature_image_id,\n 'label', po.label,\n 'excerpt', po.excerpt,\n 'subtitle', po.subtitle\n ),\n 'blocks', COALESCE((\n SELECT jsonb_agg(\n jsonb_build_object(\n 'language_id', b.language_id,\n 'block_type', b.block_type,\n 'content', b.content,\n 'order', b.\"order\"\n ) ORDER BY b.\"order\" ASC, b.id ASC\n )\n FROM public.blocks b\n WHERE b.post_id = po.id\n ), '[]'::jsonb)\n )\n FROM public.posts po\n WHERE po.slug IN ('how-updating-works', 'comment-fonctionnent-les-mises-a-jour')\n AND NOT EXISTS (\n SELECT 1 FROM public.post_revisions r\n WHERE r.post_id = po.id\n AND r.revision_type = 'snapshot'\n AND r.version <= po.version\n )\nON CONFLICT (post_id, version) DO NOTHING;\n"
121
+ },
122
+ {
123
+ "version": "00000000000021",
124
+ "name": "00000000000021_updating_article_git_merge.sql",
125
+ "sql": "-- 00000000000021_updating_article_git_merge.sql\n-- Corrects the \"How Updating NextBlock Works\" article seeded in 00000000000020.\n--\n-- That version described the standalone update as \"replace the files and keep a backup\n-- under .nextblock-backup/\". The updater now performs a real git 3-way merge instead:\n-- both template versions are committed into the project's own object database under\n-- refs/nextblock/{base,head}, and the diff between them is applied with `git apply --3way`.\n-- A developer's edits to framework files are preserved and only genuine overlaps conflict.\n-- The copy-and-back-up path survives only as the fallback for a project with no git repo,\n-- no commits, or a dirty tree.\n--\n-- 020 is already applied everywhere and never replays, so the copy is corrected here as\n-- targeted, idempotent string replacements rather than a full re-write of the body: each\n-- replace() is a no-op once the old sentence is gone, so re-running changes nothing.\n-- Data-only; no schema change.\n\nDO $body$\nDECLARE\n v_en_post integer;\n v_fr_post integer;\nBEGIN\n SELECT id INTO v_en_post\n FROM public.posts WHERE language_id = 1 AND slug = 'how-updating-works'\n ORDER BY id LIMIT 1;\n\n SELECT id INTO v_fr_post\n FROM public.posts WHERE language_id = 2 AND slug = 'comment-fonctionnent-les-mises-a-jour'\n ORDER BY id LIMIT 1;\n\n ---------------------------------------------------------------------------\n -- English\n ---------------------------------------------------------------------------\n IF v_en_post IS NOT NULL THEN\n UPDATE public.blocks\n SET content = jsonb_set(\n content,\n '{html_content}',\n to_jsonb(\n replace(\n replace(\n replace(\n content->>'html_content',\n -- 1. the \"if something goes wrong\" bullet\n '<li><strong>Standalone projects:</strong> every framework file the update replaces is copied first into a timestamped folder under <code>.nextblock-backup/</code> in your project. Nothing is deleted, so files you added yourself are never removed.</li>',\n '<li><strong>Standalone projects:</strong> the update is applied as a <strong>git 3-way merge</strong> into your working tree &mdash; nothing is committed for you. Review it with <code>git diff</code>, and undo the whole thing with <code>git reset --hard HEAD</code>. Nothing is ever deleted, so files you added yourself are never removed.</li>'\n ),\n -- 2. the \"you customised a framework file\" paragraph\n '<p>If you have customised a file that NextBlock owns &mdash; something under <code>app/</code>, <code>components/</code> or <code>lib/</code> &mdash; the update will replace it and back up your version. Diff the backup afterwards to bring your change forward. Customisations that live in your own new files, in the CMS, or in <code>.env</code> are never affected.</p>',\n '<p>If you have customised a file that NextBlock owns &mdash; something under <code>app/</code>, <code>components/</code> or <code>lib/</code> &mdash; <strong>your edit is kept</strong>. The update merges the upstream change into your version, and only a change that genuinely overlaps yours conflicts, with ordinary <code>&lt;&lt;&lt;&lt;&lt;&lt;&lt; ours</code> / <code>&gt;&gt;&gt;&gt;&gt;&gt;&gt; theirs</code> markers. List them with <code>git diff --name-only --diff-filter=U</code> and resolve with <code>git checkout --theirs</code> or <code>--ours</code>. Customisations in your own files, in the CMS, or in <code>.env</code> are never touched at all.</p>'\n ),\n -- 3. the managed-cloud section\n '<p>Your project is a standalone Next.js app, so new framework code is fetched from the published <code>create-nextblock</code> package on npm &mdash; the exact artifact your project was scaffolded from, versioned in lockstep with the release. NextBlock refreshes the files it owns, merges the new dependency versions into your <code>package.json</code>, runs <code>npm install</code>, and then applies migrations.</p>',\n '<p>Your project is a standalone Next.js app with no upstream to pull from, so new framework code comes from the published <code>create-nextblock</code> package on npm &mdash; the exact artifact your project was scaffolded from, versioned in lockstep with the release. NextBlock fetches both your current version and the new one, and applies the difference between them as a <strong>git 3-way merge</strong>, so the update behaves exactly like a <code>git pull</code>: files you never touched update silently, files you customised keep your changes. It then merges the new dependency versions into your <code>package.json</code>, runs <code>npm install</code>, and applies migrations.</p><p>This needs a git repository with at least one commit and a clean working tree &mdash; commit your work before updating. Without that there is nothing to merge against, so the files are copied instead and anything replaced is kept under <code>.nextblock-backup/</code>.</p>'\n )\n )\n ),\n updated_at = now()\n WHERE post_id = v_en_post\n AND block_type = 'text';\n END IF;\n\n ---------------------------------------------------------------------------\n -- French\n ---------------------------------------------------------------------------\n IF v_fr_post IS NOT NULL THEN\n UPDATE public.blocks\n SET content = jsonb_set(\n content,\n '{html_content}',\n to_jsonb(\n replace(\n replace(\n replace(\n content->>'html_content',\n '<li><strong>Projets autonomes :</strong> chaque fichier remplac&eacute; est d''abord copi&eacute; dans un dossier horodat&eacute; sous <code>.nextblock-backup/</code> dans votre projet. Rien n''est supprim&eacute; : les fichiers que vous avez ajout&eacute;s ne disparaissent jamais.</li>',\n '<li><strong>Projets autonomes :</strong> la mise &agrave; jour est appliqu&eacute;e comme une <strong>fusion git &agrave; trois voies</strong> dans votre copie de travail &mdash; rien n''est valid&eacute; &agrave; votre place. Examinez-la avec <code>git diff</code>, et annulez tout avec <code>git reset --hard HEAD</code>. Rien n''est jamais supprim&eacute; : les fichiers que vous avez ajout&eacute;s ne disparaissent jamais.</li>'\n ),\n '<p>Si vous avez personnalis&eacute; un fichier appartenant &agrave; NextBlock &mdash; sous <code>app/</code>, <code>components/</code> ou <code>lib/</code> &mdash; la mise &agrave; jour le remplacera en sauvegardant votre version. Comparez ensuite la sauvegarde pour reporter votre modification. Les personnalisations qui vivent dans vos propres fichiers, dans le CMS ou dans <code>.env</code> ne sont jamais affect&eacute;es.</p>',\n '<p>Si vous avez personnalis&eacute; un fichier appartenant &agrave; NextBlock &mdash; sous <code>app/</code>, <code>components/</code> ou <code>lib/</code> &mdash; <strong>votre modification est conserv&eacute;e</strong>. La mise &agrave; jour fusionne le changement amont dans votre version, et seul un changement qui chevauche r&eacute;ellement le v&ocirc;tre entre en conflit, avec les marqueurs habituels <code>&lt;&lt;&lt;&lt;&lt;&lt;&lt; ours</code> / <code>&gt;&gt;&gt;&gt;&gt;&gt;&gt; theirs</code>. Listez-les avec <code>git diff --name-only --diff-filter=U</code> et r&eacute;solvez avec <code>git checkout --theirs</code> ou <code>--ours</code>. Les personnalisations dans vos propres fichiers, dans le CMS ou dans <code>.env</code> ne sont jamais touch&eacute;es.</p>'\n ),\n '<p>Votre projet est une application Next.js autonome : le nouveau code provient donc du paquet <code>create-nextblock</code> publi&eacute; sur npm &mdash; exactement l''artefact &agrave; partir duquel votre projet a &eacute;t&eacute; g&eacute;n&eacute;r&eacute;, versionn&eacute; en phase avec la release. NextBlock rafra&icirc;chit les fichiers qui lui appartiennent, fusionne les nouvelles versions de d&eacute;pendances dans votre <code>package.json</code>, lance <code>npm install</code>, puis applique les migrations.</p>',\n '<p>Votre projet est une application Next.js autonome sans d&eacute;p&ocirc;t amont &agrave; tirer : le nouveau code provient donc du paquet <code>create-nextblock</code> publi&eacute; sur npm &mdash; exactement l''artefact &agrave; partir duquel votre projet a &eacute;t&eacute; g&eacute;n&eacute;r&eacute;, versionn&eacute; en phase avec la release. NextBlock r&eacute;cup&egrave;re votre version actuelle et la nouvelle, puis applique la diff&eacute;rence entre les deux comme une <strong>fusion git &agrave; trois voies</strong> : la mise &agrave; jour se comporte donc exactement comme un <code>git pull</code> &mdash; les fichiers que vous n''avez jamais touch&eacute;s se mettent &agrave; jour silencieusement, ceux que vous avez personnalis&eacute;s conservent vos changements. Elle fusionne ensuite les nouvelles versions de d&eacute;pendances dans votre <code>package.json</code>, lance <code>npm install</code> et applique les migrations.</p><p>Cela n&eacute;cessite un d&eacute;p&ocirc;t git avec au moins un commit et une copie de travail propre &mdash; validez votre travail avant de mettre &agrave; jour. Sans cela il n''y a rien contre quoi fusionner : les fichiers sont alors copi&eacute;s et tout ce qui est remplac&eacute; est conserv&eacute; sous <code>.nextblock-backup/</code>.</p>'\n )\n )\n ),\n updated_at = now()\n WHERE post_id = v_fr_post\n AND block_type = 'text';\n END IF;\nEND\n$body$;\n"
126
+ },
127
+ {
128
+ "version": "00000000000022",
129
+ "name": "00000000000022_updating_article_accuracy.sql",
130
+ "sql": "-- 00000000000022_updating_article_accuracy.sql\n-- Three accuracy fixes to the \"How Updating NextBlock Works\" article\n-- (seeded in 00000000000020, first corrected in 00000000000021).\n--\n-- 0. The merge is performed with `git merge-file`, not `git apply --3way`. The latter\n-- implies --index: it stages its result (so `git diff` shows the developer nothing),\n-- requires every path to be tracked (one gitignored framework path aborted the whole\n-- update), and requires the worktree to match the index. merge-file touches no git\n-- state at all. Consequently the resolution commands 021 shipped are wrong: there are\n-- no index stages, so `git checkout --theirs/--ours` does not apply. The conflict\n-- markers are ordinary text; `git checkout -- <file>` discards one file's merge.\n--\n-- 1. \"A conflicted merge is aborted automatically\" was true of only ONE path. The\n-- monorepo/fork path does abort and restore the tree, because the merge belongs to\n-- upstream. The standalone path deliberately LEAVES the conflict in the working tree,\n-- because it is the developer's own repository and resolving it is the whole point.\n-- The bullet now states both.\n-- 2. The Docker section claimed `npm run update` refreshes the schema. It does not, by\n-- design: the self-hosted stack ships its own migration runner (the `migrate` service\n-- in docker-compose.yml, which tracks applied versions in a different table), so the\n-- updater stages the SQL and hands off to `npm run docker:up` rather than applying it\n-- twice through two different trackers.\n--\n-- Same targeted, idempotent replace() approach as 021 — a no-op once the old sentence is\n-- gone. Data-only; no schema change.\n\nDO $body$\nDECLARE\n v_en_post integer;\n v_fr_post integer;\nBEGIN\n SELECT id INTO v_en_post\n FROM public.posts WHERE language_id = 1 AND slug = 'how-updating-works'\n ORDER BY id LIMIT 1;\n\n SELECT id INTO v_fr_post\n FROM public.posts WHERE language_id = 2 AND slug = 'comment-fonctionnent-les-mises-a-jour'\n ORDER BY id LIMIT 1;\n\n IF v_en_post IS NOT NULL THEN\n UPDATE public.blocks\n SET content = jsonb_set(\n content,\n '{html_content}',\n to_jsonb(\n replace(\n replace(\n replace(\n content->>'html_content',\n -- 021 shipped index-based resolution commands; the merge no longer\n -- uses the git index, so they do not apply.\n '<strong>your edit is kept</strong>. The update merges the upstream change into your version, and only a change that genuinely overlaps yours conflicts, with ordinary <code>&lt;&lt;&lt;&lt;&lt;&lt;&lt; ours</code> / <code>&gt;&gt;&gt;&gt;&gt;&gt;&gt; theirs</code> markers. List them with <code>git diff --name-only --diff-filter=U</code> and resolve with <code>git checkout --theirs</code> or <code>--ours</code>.',\n '<strong>your edit is kept</strong>. The update merges the upstream change into your version, and only a change that genuinely overlaps yours conflicts &mdash; the updater lists those files, and each one carries ordinary <code>&lt;&lt;&lt;&lt;&lt;&lt;&lt; your version</code> / <code>&gt;&gt;&gt;&gt;&gt;&gt;&gt; NextBlock</code> markers. Edit them as you would any conflict, or run <code>git checkout -- &lt;file&gt;</code> to discard the merge for that one file.'\n ),\n '<li><strong>A conflicted merge</strong> is aborted automatically &mdash; your working tree is left exactly as it was, with instructions printed for resolving it by hand.</li>',\n '<li><strong>A conflict</strong> behaves differently by install, on purpose. On a fork or clone the upstream merge is <em>aborted</em> and your working tree is left exactly as it was. On a standalone project the conflict is <em>left in place</em> for you to resolve &mdash; it is your own repository, and that is the point &mdash; and <code>git reset --hard HEAD</code> backs the whole update out.</li>'\n ),\n '<p>The first command refreshes the application, its dependencies and the schema; the second rebuilds and restarts the containers. Your database and media live in Docker volumes and are never touched by either step &mdash; <code>docker:up</code> rebuilds images, not data.</p>',\n '<p>The first command updates the application and its dependencies and stages the new migrations; the second rebuilds the containers <em>and applies those migrations</em>. The self-hosted stack runs its own migration service, so the updater hands the schema step to it rather than applying the same SQL through two different trackers. Your database and media live in Docker volumes and are never touched by either command &mdash; <code>docker:up</code> rebuilds images, not data.</p>'\n )\n )\n ),\n updated_at = now()\n WHERE post_id = v_en_post\n AND block_type = 'text';\n END IF;\n\n IF v_fr_post IS NOT NULL THEN\n UPDATE public.blocks\n SET content = jsonb_set(\n content,\n '{html_content}',\n to_jsonb(\n replace(\n replace(\n replace(\n content->>'html_content',\n '<strong>votre modification est conserv&eacute;e</strong>. La mise &agrave; jour fusionne le changement amont dans votre version, et seul un changement qui chevauche r&eacute;ellement le v&ocirc;tre entre en conflit, avec les marqueurs habituels <code>&lt;&lt;&lt;&lt;&lt;&lt;&lt; ours</code> / <code>&gt;&gt;&gt;&gt;&gt;&gt;&gt; theirs</code>. Listez-les avec <code>git diff --name-only --diff-filter=U</code> et r&eacute;solvez avec <code>git checkout --theirs</code> ou <code>--ours</code>.',\n '<strong>votre modification est conserv&eacute;e</strong>. La mise &agrave; jour fusionne le changement amont dans votre version, et seul un changement qui chevauche r&eacute;ellement le v&ocirc;tre entre en conflit &mdash; la commande liste ces fichiers, et chacun porte les marqueurs habituels <code>&lt;&lt;&lt;&lt;&lt;&lt;&lt; your version</code> / <code>&gt;&gt;&gt;&gt;&gt;&gt;&gt; NextBlock</code>. Modifiez-les comme n''importe quel conflit, ou lancez <code>git checkout -- &lt;fichier&gt;</code> pour abandonner la fusion sur ce seul fichier.'\n ),\n '<li><strong>Une fusion en conflit</strong> est annul&eacute;e automatiquement &mdash; votre copie de travail reste intacte, avec les instructions pour r&eacute;soudre &agrave; la main.</li>',\n '<li><strong>Un conflit</strong> se comporte diff&eacute;remment selon l''installation, volontairement. Sur un fork ou un clone, la fusion amont est <em>annul&eacute;e</em> et votre copie de travail reste intacte. Sur un projet autonome, le conflit est <em>laiss&eacute; en place</em> pour que vous le r&eacute;solviez &mdash; c''est votre d&eacute;p&ocirc;t, et c''est tout l''int&eacute;r&ecirc;t &mdash; et <code>git reset --hard HEAD</code> annule toute la mise &agrave; jour.</li>'\n ),\n '<p>La premi&egrave;re commande met &agrave; jour l''application, ses d&eacute;pendances et le sch&eacute;ma ; la seconde reconstruit et red&eacute;marre les conteneurs. Votre base de donn&eacute;es et vos m&eacute;dias vivent dans des volumes Docker et ne sont touch&eacute;s ni par l''une ni par l''autre &mdash; <code>docker:up</code> reconstruit des images, pas des donn&eacute;es.</p>',\n '<p>La premi&egrave;re commande met &agrave; jour l''application et ses d&eacute;pendances et pr&eacute;pare les nouvelles migrations ; la seconde reconstruit les conteneurs <em>et applique ces migrations</em>. La pile auto-h&eacute;berg&eacute;e dispose de son propre service de migration : la mise &agrave; jour lui confie donc l''&eacute;tape sch&eacute;ma plut&ocirc;t que d''appliquer le m&ecirc;me SQL via deux suivis diff&eacute;rents. Votre base de donn&eacute;es et vos m&eacute;dias vivent dans des volumes Docker et ne sont touch&eacute;s par aucune des deux commandes &mdash; <code>docker:up</code> reconstruit des images, pas des donn&eacute;es.</p>'\n )\n )\n ),\n updated_at = now()\n WHERE post_id = v_fr_post\n AND block_type = 'text';\n END IF;\nEND\n$body$;\n"
131
+ },
132
+ {
133
+ "version": "00000000000023",
134
+ "name": "00000000000023_updating_article_layout_not_host.sql",
135
+ "sql": "-- 00000000000023_updating_article_layout_not_host.sql\n-- Corrects the most misleading claim in the \"How Updating NextBlock Works\" article\n-- (seeded 00000000000020, corrected in 021 and 022): that the automatic GitHub Action is\n-- about being deployed on Vercel.\n--\n-- It is not. The upstream-sync Action merges the NextBlock MONOREPO into the repository,\n-- so it only works where the repository IS the monorepo — a Vercel 1-click deploy, a\n-- GitHub fork, or a clone. A project scaffolded by `npm create nextblock` is the flattened\n-- standalone app (app/, components/, lib/ at the root); merging apps/ + libs/ + nx.json\n-- into it would wreck it. Pushing that project to GitHub and deploying it on Vercel does\n-- not change its layout, so it is still a `npm run update` install. Docker is orthogonal:\n-- it is how you RUN a project, not what shape the repository is.\n--\n-- Also documents that a merge conflict now holds the migration step back until the\n-- conflict is resolved, so the schema never moves ahead of undecided code.\n--\n-- Targeted, idempotent replace() as in 021/022. Data-only; no schema change.\n\nDO $body$\nDECLARE\n v_en_post integer;\n v_fr_post integer;\nBEGIN\n SELECT id INTO v_en_post\n FROM public.posts WHERE language_id = 1 AND slug = 'how-updating-works'\n ORDER BY id LIMIT 1;\n\n SELECT id INTO v_fr_post\n FROM public.posts WHERE language_id = 2 AND slug = 'comment-fonctionnent-les-mises-a-jour'\n ORDER BY id LIMIT 1;\n\n IF v_en_post IS NOT NULL THEN\n UPDATE public.blocks\n SET content = jsonb_set(\n content,\n '{html_content}',\n to_jsonb(\n replace(\n replace(\n replace(\n content->>'html_content',\n -- 1. The section intro: say what actually qualifies.\n '<p>This path updates itself. When you deployed, NextBlock created a repository you own; the dashboard&rsquo;s <strong>Connect GitHub</strong> onboarding step installs a workflow into it that runs <strong>every day at midnight UTC</strong> and can also be triggered by hand from your repository&rsquo;s <strong>Actions</strong> tab.</p>',\n '<p>This path updates itself. When you deployed, NextBlock created a repository you own; the dashboard&rsquo;s <strong>Connect GitHub</strong> onboarding step installs a workflow into it that runs <strong>every day at midnight UTC</strong> and can also be triggered by hand from your repository&rsquo;s <strong>Actions</strong> tab.</p>\\n<div class=''rounded-3xl border border-slate-200 bg-slate-50 p-6 my-8 dark:border-white/10 dark:bg-white/5''>\\n <p class=''mt-0 text-xs font-semibold uppercase tracking-[0.22em] text-slate-600 dark:text-slate-300''>What qualifies &mdash; it is the repository, not the host</p>\\n <p class=''mt-3 mb-0 text-sm text-slate-700 dark:text-slate-200''>The workflow merges the <strong>NextBlock monorepo</strong> into your repository, so it only works where your repository <em>is</em> that monorepo: a one-click deploy, a GitHub fork, or a clone. A project created with <code>npm create nextblock</code> is the flattened standalone app &mdash; <code>app/</code>, <code>components/</code> and <code>lib/</code> at the root &mdash; and merging <code>apps/</code>, <code>libs/</code> and <code>nx.json</code> into it would wreck it. Pushing that project to GitHub and deploying it on Vercel does not change its shape: it is still an <code>npm run update</code> install, and NextBlock will not offer it this workflow. Docker is a separate question entirely &mdash; that is how you <em>run</em> a project, not what shape its repository is.</p>\\n</div>'\n ),\n -- 2. The FAQ answer, which asked exactly the question this clarifies.\n '<h3>I am on the one-click Vercel deploy &mdash; do I need to run anything?</h3>\\n<p>No. That path is fully automatic. The command exists for when you want an update <em>now</em> rather than at midnight, or when you are working on a local clone.</p>',\n '<h3>I am on the one-click Vercel deploy &mdash; do I need to run anything?</h3>\\n<p>No. That path is fully automatic. The command exists for when you want an update <em>now</em> rather than at midnight, or when you are working on a local clone.</p>\\n<h3>I deployed to Vercel, but from <code>npm create nextblock</code>. Is that automatic too?</h3>\\n<p>No &mdash; and this is the distinction that catches people out. Automatic updates depend on your repository being the NextBlock <strong>monorepo</strong>, not on where the site is hosted. A project scaffolded by the CLI is the flattened standalone app whatever you deploy it to, so it updates with <code>npm run update</code>. You will not see the <strong>Connect GitHub</strong> step on that kind of install, because the workflow it installs would merge a completely different source tree into yours.</p>'\n ),\n -- 3. Conflicts hold the schema step.\n '<li><strong>A failed migration</strong> rolls back. Fix the cause and re-run; nothing half-applied is left behind.</li>',\n '<li><strong>A failed migration</strong> rolls back. Fix the cause and re-run; nothing half-applied is left behind.</li>\\n <li><strong>Unresolved conflicts hold the database back.</strong> If a merge left conflicts, the update finishes the code and dependency work but <em>stops before migrating</em> &mdash; your schema never moves ahead of code you have not finished deciding on. Resolve them and run <code>npm run update</code> again to apply the migrations, or walk away with <code>git reset --hard HEAD</code>; either way the database was never touched.</li>'\n )\n )\n ),\n updated_at = now()\n WHERE post_id = v_en_post\n AND block_type = 'text';\n END IF;\n\n IF v_fr_post IS NOT NULL THEN\n UPDATE public.blocks\n SET content = jsonb_set(\n content,\n '{html_content}',\n to_jsonb(\n replace(\n replace(\n replace(\n content->>'html_content',\n '<p>Ce chemin se met &agrave; jour tout seul. Lors du d&eacute;ploiement, NextBlock a cr&eacute;&eacute; un d&eacute;p&ocirc;t qui vous appartient ; l''&eacute;tape <strong>Connect GitHub</strong> du tableau de bord y installe un workflow qui s''ex&eacute;cute <strong>chaque jour &agrave; minuit UTC</strong> et peut aussi &ecirc;tre lanc&eacute; &agrave; la demande depuis l''onglet <strong>Actions</strong> de votre d&eacute;p&ocirc;t.</p>',\n '<p>Ce chemin se met &agrave; jour tout seul. Lors du d&eacute;ploiement, NextBlock a cr&eacute;&eacute; un d&eacute;p&ocirc;t qui vous appartient ; l''&eacute;tape <strong>Connect GitHub</strong> du tableau de bord y installe un workflow qui s''ex&eacute;cute <strong>chaque jour &agrave; minuit UTC</strong> et peut aussi &ecirc;tre lanc&eacute; &agrave; la demande depuis l''onglet <strong>Actions</strong> de votre d&eacute;p&ocirc;t.</p>\\n<div class=''rounded-3xl border border-slate-200 bg-slate-50 p-6 my-8 dark:border-white/10 dark:bg-white/5''>\\n <p class=''mt-0 text-xs font-semibold uppercase tracking-[0.22em] text-slate-600 dark:text-slate-300''>Ce qui compte : le d&eacute;p&ocirc;t, pas l''h&eacute;bergeur</p>\\n <p class=''mt-3 mb-0 text-sm text-slate-700 dark:text-slate-200''>Le workflow fusionne le <strong>monorepo NextBlock</strong> dans votre d&eacute;p&ocirc;t : il ne fonctionne donc que si votre d&eacute;p&ocirc;t <em>est</em> ce monorepo &mdash; d&eacute;ploiement en un clic, fork GitHub ou clone. Un projet cr&eacute;&eacute; avec <code>npm create nextblock</code> est l''application autonome aplatie &mdash; <code>app/</code>, <code>components/</code> et <code>lib/</code> &agrave; la racine &mdash; et y fusionner <code>apps/</code>, <code>libs/</code> et <code>nx.json</code> le casserait. Pousser ce projet sur GitHub et le d&eacute;ployer sur Vercel ne change pas sa forme : il se met toujours &agrave; jour avec <code>npm run update</code>, et NextBlock ne lui proposera pas ce workflow. Docker est une tout autre question &mdash; c''est la fa&ccedil;on d''<em>ex&eacute;cuter</em> un projet, pas la forme de son d&eacute;p&ocirc;t.</p>\\n</div>'\n ),\n '<h3>Je suis sur le d&eacute;ploiement Vercel en un clic &mdash; dois-je lancer quelque chose ?</h3>\\n<p>Non. Ce chemin est enti&egrave;rement automatique. La commande existe pour mettre &agrave; jour <em>tout de suite</em> plut&ocirc;t qu''&agrave; minuit, ou lorsque vous travaillez sur un clone local.</p>',\n '<h3>Je suis sur le d&eacute;ploiement Vercel en un clic &mdash; dois-je lancer quelque chose ?</h3>\\n<p>Non. Ce chemin est enti&egrave;rement automatique. La commande existe pour mettre &agrave; jour <em>tout de suite</em> plut&ocirc;t qu''&agrave; minuit, ou lorsque vous travaillez sur un clone local.</p>\\n<h3>J''ai d&eacute;ploy&eacute; sur Vercel, mais depuis <code>npm create nextblock</code>. Est-ce automatique aussi ?</h3>\\n<p>Non &mdash; et c''est la distinction qui pi&egrave;ge le plus. Les mises &agrave; jour automatiques d&eacute;pendent du fait que votre d&eacute;p&ocirc;t soit le <strong>monorepo</strong> NextBlock, pas de l''endroit o&ugrave; le site est h&eacute;berg&eacute;. Un projet g&eacute;n&eacute;r&eacute; par le CLI reste l''application autonome aplatie, quel que soit l''h&eacute;bergeur : il se met &agrave; jour avec <code>npm run update</code>. L''&eacute;tape <strong>Connect GitHub</strong> ne s''affiche pas sur ce type d''installation, car le workflow qu''elle installe fusionnerait une arborescence totalement diff&eacute;rente dans la v&ocirc;tre.</p>'\n ),\n '<li><strong>Une migration en &eacute;chec</strong> est annul&eacute;e. Corrigez la cause et relancez : rien ne reste &agrave; moiti&eacute; appliqu&eacute;.</li>',\n '<li><strong>Une migration en &eacute;chec</strong> est annul&eacute;e. Corrigez la cause et relancez : rien ne reste &agrave; moiti&eacute; appliqu&eacute;.</li>\\n <li><strong>Les conflits non r&eacute;solus bloquent la base.</strong> Si une fusion a laiss&eacute; des conflits, la mise &agrave; jour termine le code et les d&eacute;pendances mais <em>s''arr&ecirc;te avant les migrations</em> &mdash; votre sch&eacute;ma ne prend jamais de l''avance sur un code que vous n''avez pas fini d''arbitrer. R&eacute;solvez-les puis relancez <code>npm run update</code> pour appliquer les migrations, ou abandonnez avec <code>git reset --hard HEAD</code> : dans les deux cas la base n''a jamais &eacute;t&eacute; touch&eacute;e.</li>'\n )\n )\n ),\n updated_at = now()\n WHERE post_id = v_fr_post\n AND block_type = 'text';\n END IF;\nEND\n$body$;\n"
136
+ },
137
+ {
138
+ "version": "00000000000024",
139
+ "name": "00000000000024_updating_article_newline_fix.sql",
140
+ "sql": "-- 00000000000024_updating_article_newline_fix.sql\n-- Repairs two mistakes made by 00000000000023 in the \"How Updating NextBlock Works\"\n-- article, and lands the FAQ entry that migration failed to insert.\n--\n-- 1. RENDERING BUG. 023 wrote `\\n` inside ordinary single-quoted SQL literals. With\n-- standard_conforming_strings on (the default), that is a literal backslash followed\n-- by 'n' — not a newline — so five visible \"\\n\" sequences were stored in the article\n-- body. Replaced here with real newlines via chr(10). Idempotent: once none remain,\n-- replace() is a no-op.\n--\n-- 2. SILENT NO-MATCH. 023's FAQ replacement targeted a string spanning `</h3>\\n<p>`, and\n-- for the same reason the literal never matched the real newline in the stored HTML, so\n-- the replacement quietly did nothing. Redone here by anchoring on the single-line <h3>\n-- alone and prepending the new entry — no newline in either the search or the\n-- replacement, which is the rule this file establishes for editing the article.\n--\n-- Data-only; no schema change.\n\nDO $body$\nDECLARE\n v_en_post integer;\n v_fr_post integer;\nBEGIN\n SELECT id INTO v_en_post\n FROM public.posts WHERE language_id = 1 AND slug = 'how-updating-works'\n ORDER BY id LIMIT 1;\n\n SELECT id INTO v_fr_post\n FROM public.posts WHERE language_id = 2 AND slug = 'comment-fonctionnent-les-mises-a-jour'\n ORDER BY id LIMIT 1;\n\n IF v_en_post IS NOT NULL THEN\n UPDATE public.blocks\n SET content = jsonb_set(\n content,\n '{html_content}',\n to_jsonb(\n replace(\n -- (1) literal backslash-n -> real newline\n replace(content->>'html_content', E'\\\\n', chr(10)),\n -- (2) the FAQ entry 023 failed to insert\n '<h3>I am on the one-click Vercel deploy &mdash; do I need to run anything?</h3>',\n '<h3>I deployed to Vercel, but from <code>npm create nextblock</code>. Is that automatic too?</h3><p>No &mdash; and this is the distinction that catches people out. Automatic updates depend on your repository being the NextBlock <strong>monorepo</strong>, not on where the site is hosted. A project scaffolded by the CLI is the flattened standalone app whatever you deploy it to, so it updates with <code>npm run update</code>. You will not see the <strong>Connect GitHub</strong> step on that kind of install, because the workflow it installs would merge a completely different source tree into yours.</p><h3>I am on the one-click Vercel deploy &mdash; do I need to run anything?</h3>'\n )\n )\n ),\n updated_at = now()\n WHERE post_id = v_en_post\n AND block_type = 'text';\n END IF;\n\n IF v_fr_post IS NOT NULL THEN\n UPDATE public.blocks\n SET content = jsonb_set(\n content,\n '{html_content}',\n to_jsonb(\n replace(\n replace(content->>'html_content', E'\\\\n', chr(10)),\n '<h3>Je suis sur le d&eacute;ploiement Vercel en un clic &mdash; dois-je lancer quelque chose ?</h3>',\n '<h3>J''ai d&eacute;ploy&eacute; sur Vercel, mais depuis <code>npm create nextblock</code>. Est-ce automatique aussi ?</h3><p>Non &mdash; et c''est la distinction qui pi&egrave;ge le plus. Les mises &agrave; jour automatiques d&eacute;pendent du fait que votre d&eacute;p&ocirc;t soit le <strong>monorepo</strong> NextBlock, pas de l''endroit o&ugrave; le site est h&eacute;berg&eacute;. Un projet g&eacute;n&eacute;r&eacute; par le CLI reste l''application autonome aplatie, quel que soit l''h&eacute;bergeur : il se met &agrave; jour avec <code>npm run update</code>. L''&eacute;tape <strong>Connect GitHub</strong> ne s''affiche pas sur ce type d''installation, car le workflow qu''elle installe fusionnerait une arborescence totalement diff&eacute;rente dans la v&ocirc;tre.</p><h3>Je suis sur le d&eacute;ploiement Vercel en un clic &mdash; dois-je lancer quelque chose ?</h3>'\n )\n )\n ),\n updated_at = now()\n WHERE post_id = v_fr_post\n AND block_type = 'text';\n END IF;\nEND\n$body$;\n"
106
141
  }
107
142
  ];
@@ -23,6 +23,12 @@ import pkg from '../../package.json';
23
23
 
24
24
  const UPSTREAM_REPO = 'nextblock-cms/nextblock';
25
25
  const RELEASES_API = `https://api.github.com/repos/${UPSTREAM_REPO}/releases/latest`;
26
+ // The npm registry is the authoritative version signal for standalone installs: the
27
+ // `create-nextblock` package ships the exact standalone template they were scaffolded
28
+ // from and is published in lockstep with the app, whereas GitHub release tags are cut by
29
+ // hand and lag (they have, in practice, sat many minor versions behind the real version —
30
+ // which silently disabled this whole check). Releases stay as the fallback.
31
+ const NPM_REGISTRY_API = 'https://registry.npmjs.org/create-nextblock/latest';
26
32
  // The sync workflow tags its conflict issues with this hidden body marker. We match on it
27
33
  // (not on a label) so a label that failed to create on GitHub can't hide a real conflict.
28
34
  const CONFLICT_MARKER = '<!-- nextblock-sync-conflict -->';
@@ -87,18 +93,90 @@ function compareSemver(a: string, b: string): number {
87
93
  return 0;
88
94
  }
89
95
 
96
+ /**
97
+ * The NextBlock version this install is running.
98
+ *
99
+ * `package.json.nextblock.version` is the authoritative stamp, written by the scaffolder
100
+ * and re-written by `npm run update`. A project's own `version` field belongs to the
101
+ * user, and the moment they bump it for their own site — entirely normal — comparing a
102
+ * release against it becomes meaningless. Fall back to it only for projects created
103
+ * before the stamp existed.
104
+ */
105
+ function readInstalledVersion(): string {
106
+ const manifest = pkg as { version?: string; nextblock?: { version?: string } };
107
+ const stamped = manifest.nextblock?.version?.trim();
108
+ return stamped || manifest.version || '0.0.0';
109
+ }
110
+
111
+ /** Latest published `create-nextblock` version, or null when the registry is unreachable. */
112
+ async function fetchLatestFromNpm(): Promise<string | null> {
113
+ try {
114
+ const res = await fetch(NPM_REGISTRY_API, {
115
+ headers: { Accept: 'application/vnd.npm.install-v1+json, application/json' },
116
+ signal: AbortSignal.timeout(15_000),
117
+ next: { revalidate: 3600 },
118
+ });
119
+ if (!res.ok) return null;
120
+ const body = (await res.json()) as { version?: string };
121
+ const version = body.version?.trim();
122
+ return version && /^\d+\.\d+\.\d+/.test(version) ? version : null;
123
+ } catch {
124
+ return null;
125
+ }
126
+ }
127
+
128
+ /**
129
+ * Is this install the NextBlock MONOREPO (a Vercel 1-click deploy, a GitHub fork, or a
130
+ * clone) rather than the flattened standalone app that `npm create nextblock` produces?
131
+ *
132
+ * This — not the hosting platform — is what decides whether the upstream-sync GitHub
133
+ * Action can work, because that Action merges the monorepo (`apps/`, `libs/`, `tools/`,
134
+ * `nx.json`) into the repository. Merging it into a flat standalone project, whose tree is
135
+ * `app/`, `components/`, `lib/`, would wreck the project. A standalone app pushed to GitHub
136
+ * and deployed on Vercel is still standalone; where it is hosted is irrelevant.
137
+ *
138
+ * The marker lives in the bundled package.json (`nextblock.install`) so it is readable on a
139
+ * serverless filesystem, where neither `nx.json` nor `.github/` is traced into the function.
140
+ * Projects scaffolded before that marker existed fall back to filesystem probes and then to
141
+ * 'standalone' — which at worst shows a 1-click deploy an extra update banner, and is the
142
+ * safe default: it never offers to install the Action where it would do damage.
143
+ */
144
+ export function isMonorepoInstall(): boolean {
145
+ const manifest = pkg as { nextblock?: { install?: string } };
146
+ const declared = manifest.nextblock?.install;
147
+ if (declared === 'monorepo') return true;
148
+ if (declared === 'standalone') return false;
149
+
150
+ const cwd = process.cwd();
151
+ try {
152
+ if (existsSync(path.join(cwd, 'nx.json'))) return true;
153
+ } catch {
154
+ /* ignore */
155
+ }
156
+ try {
157
+ if (existsSync(path.join(cwd, '.github', 'workflows', 'nextblock-sync.yml'))) return true;
158
+ } catch {
159
+ /* ignore */
160
+ }
161
+ return false;
162
+ }
163
+
90
164
  /**
91
165
  * Classify how this install receives updates. Explicit NEXTBLOCK_UPDATE_TRACK wins;
92
- * otherwise Vercel / repos carrying the sync workflow or an upstream remote are 'git'
93
- * (Track A), everything else is 'standalone' (Track B).
166
+ * otherwise a monorepo-shaped install with a resolvable GitHub repo is 'git' (Track A
167
+ * the daily Action merges upstream for it), and everything else is 'standalone' (Track B
168
+ * it needs `npm run update`).
169
+ *
170
+ * Deliberately NOT keyed on `process.env.VERCEL`: a standalone project deployed to Vercel
171
+ * used to be classified 'git', which suppressed its update banner entirely while no Action
172
+ * existed to update it — leaving it silently frozen.
94
173
  */
95
174
  function detectTrack(): UpdateTrack {
96
175
  const override = process.env.NEXTBLOCK_UPDATE_TRACK?.trim().toLowerCase();
97
176
  if (override === 'git' || override === 'standalone') return override;
177
+ if (!isMonorepoInstall()) return 'standalone';
98
178
 
99
- if (process.env.VERCEL === '1') return 'git';
100
179
  if (resolveSelfRepo()) {
101
- // A resolvable GitHub repo identity means git-backed; double-check it's a NextBlock fork.
102
180
  const cwd = process.cwd();
103
181
  try {
104
182
  if (existsSync(path.join(cwd, '.github', 'workflows', 'nextblock-sync.yml'))) return 'git';
@@ -111,16 +189,77 @@ function detectTrack(): UpdateTrack {
111
189
  } catch {
112
190
  /* ignore */
113
191
  }
192
+ // Monorepo-shaped and GitHub-hosted, but the workflow isn't visible from here (normal
193
+ // on a serverless filesystem). Trust the layout marker.
194
+ return 'git';
114
195
  }
115
196
  return 'standalone';
116
197
  }
117
198
 
118
199
  /**
119
- * Poll GitHub Releases and, on a standalone install with a newer release, record a
120
- * runtime_update_available alert (deduped by latest version). Never throws.
200
+ * Determine the newest published NextBlock version. The npm registry is authoritative
201
+ * (see NPM_REGISTRY_API); GitHub Releases is the fallback and also supplies the release
202
+ * notes / archive links when a release exists.
203
+ */
204
+ async function resolveLatestRelease(): Promise<{
205
+ latestVersion: string | null;
206
+ release: {
207
+ tag_name?: string;
208
+ html_url?: string;
209
+ tarball_url?: string;
210
+ zipball_url?: string;
211
+ published_at?: string;
212
+ } | null;
213
+ error?: string;
214
+ }> {
215
+ const [npmVersion, githubResult] = await Promise.all([
216
+ fetchLatestFromNpm(),
217
+ (async () => {
218
+ try {
219
+ const res = await fetch(RELEASES_API, {
220
+ headers: githubHeaders(),
221
+ signal: AbortSignal.timeout(15_000),
222
+ next: { revalidate: 3600 },
223
+ });
224
+ if (res.status === 404) return { release: null }; // no releases cut yet
225
+ if (!res.ok) return { release: null, error: `GitHub Releases API returned HTTP ${res.status}.` };
226
+ return { release: await res.json() };
227
+ } catch (caught) {
228
+ return {
229
+ release: null,
230
+ error:
231
+ caught instanceof Error
232
+ ? `Could not reach the GitHub Releases API: ${caught.message}`
233
+ : 'Could not reach the GitHub Releases API.',
234
+ };
235
+ }
236
+ })(),
237
+ ]);
238
+
239
+ const tagVersion = githubResult.release?.tag_name?.trim().replace(/^v/i, '') || null;
240
+ // Whichever source is ahead wins: a hand-cut tag can lag npm, and npm can lag a
241
+ // release that was published before the packages went out.
242
+ const latestVersion =
243
+ npmVersion && tagVersion
244
+ ? compareSemver(npmVersion, tagVersion) >= 0
245
+ ? npmVersion
246
+ : tagVersion
247
+ : (npmVersion ?? tagVersion);
248
+
249
+ return {
250
+ latestVersion,
251
+ release: githubResult.release,
252
+ // Only surface the GitHub error when npm did not answer either.
253
+ error: latestVersion ? undefined : githubResult.error,
254
+ };
255
+ }
256
+
257
+ /**
258
+ * Resolve the newest published version and, on a standalone install that is behind,
259
+ * record a runtime_update_available alert (deduped by latest version). Never throws.
121
260
  */
122
261
  export async function checkForUpstreamUpdate(): Promise<UpstreamUpdateResult> {
123
- const currentVersion = pkg.version;
262
+ const currentVersion = readInstalledVersion();
124
263
  const track = detectTrack();
125
264
  const base: UpstreamUpdateResult = {
126
265
  ok: false,
@@ -131,40 +270,15 @@ export async function checkForUpstreamUpdate(): Promise<UpstreamUpdateResult> {
131
270
  alertRecorded: false,
132
271
  };
133
272
 
134
- let release: {
135
- tag_name?: string;
136
- html_url?: string;
137
- tarball_url?: string;
138
- zipball_url?: string;
139
- published_at?: string;
140
- };
141
- try {
142
- const res = await fetch(RELEASES_API, {
143
- headers: githubHeaders(),
144
- signal: AbortSignal.timeout(15_000),
145
- next: { revalidate: 3600 },
146
- });
147
- if (res.status === 404) return { ...base, ok: true }; // no releases yet
148
- if (!res.ok) return { ...base, error: `GitHub Releases API returned HTTP ${res.status}.` };
149
- release = await res.json();
150
- } catch (caught) {
151
- return {
152
- ...base,
153
- error:
154
- caught instanceof Error
155
- ? `Could not reach the GitHub Releases API: ${caught.message}`
156
- : 'Could not reach the GitHub Releases API.',
157
- };
158
- }
273
+ const { latestVersion, release, error } = await resolveLatestRelease();
274
+ if (error) return { ...base, error };
275
+ if (!latestVersion) return { ...base, ok: true }; // nothing published yet
159
276
 
160
- const tag = release.tag_name?.trim();
161
- if (!tag) return { ...base, ok: true };
162
-
163
- const latestVersion = tag.replace(/^v/i, '');
277
+ const tag = release?.tag_name?.trim() || `v${latestVersion}`;
164
278
  const tarballUrl =
165
- release.tarball_url || `https://github.com/${UPSTREAM_REPO}/archive/refs/tags/${tag}.tar.gz`;
279
+ release?.tarball_url || `https://github.com/${UPSTREAM_REPO}/archive/refs/tags/${tag}.tar.gz`;
166
280
  const zipballUrl =
167
- release.zipball_url || `https://github.com/${UPSTREAM_REPO}/archive/refs/tags/${tag}.zip`;
281
+ release?.zipball_url || `https://github.com/${UPSTREAM_REPO}/archive/refs/tags/${tag}.zip`;
168
282
  const updateAvailable = compareSemver(latestVersion, currentVersion) > 0;
169
283
 
170
284
  const result: UpstreamUpdateResult = {
@@ -172,10 +286,10 @@ export async function checkForUpstreamUpdate(): Promise<UpstreamUpdateResult> {
172
286
  ok: true,
173
287
  latestVersion,
174
288
  updateAvailable,
175
- htmlUrl: release.html_url,
289
+ htmlUrl: release?.html_url,
176
290
  tarballUrl,
177
291
  zipballUrl,
178
- publishedAt: release.published_at,
292
+ publishedAt: release?.published_at,
179
293
  };
180
294
 
181
295
  // Git-backed installs auto-merge via Track A — no runtime update alert for them.
@@ -183,11 +297,15 @@ export async function checkForUpstreamUpdate(): Promise<UpstreamUpdateResult> {
183
297
 
184
298
  try {
185
299
  const supabase = getServiceRoleSupabaseClient();
300
+ // Deliberately NOT filtered on is_resolved: an admin who dismisses the banner for a
301
+ // given version has answered for that version, and re-inserting it on the next
302
+ // 6-hour poll would make the banner un-dismissable. A genuinely newer version
303
+ // carries a different latest_version and still alerts.
186
304
  const { data: existing } = await supabase
187
305
  .from('system_alerts')
188
306
  .select('id, metadata')
189
307
  .eq('alert_type', 'runtime_update_available')
190
- .eq('is_resolved', false)
308
+ .order('created_at', { ascending: false })
191
309
  .limit(50);
192
310
 
193
311
  const alreadyAlerted = (existing ?? []).some(
@@ -196,19 +314,22 @@ export async function checkForUpstreamUpdate(): Promise<UpstreamUpdateResult> {
196
314
  );
197
315
  if (alreadyAlerted) return result;
198
316
 
199
- const { error } = await supabase.from('system_alerts').insert({
317
+ const { error: insertError } = await supabase.from('system_alerts').insert({
200
318
  alert_type: 'runtime_update_available',
201
319
  title: `NextBlock ${latestVersion} is available`,
202
- message: `A newer NextBlock release (${latestVersion}) is available — you are on ${currentVersion}. Download the release archive, replace your files, and update dependencies to upgrade.`,
320
+ message: `NextBlock ${latestVersion} is out — you are on ${currentVersion}. Run "npm run update" in your project to upgrade the code, dependencies and database schema in one step.`,
203
321
  metadata: {
204
322
  latest_version: latestVersion,
205
323
  current_version: currentVersion,
324
+ update_command: 'npm run update',
206
325
  download_url: tarballUrl,
207
326
  zipball_url: zipballUrl,
208
- html_url: release.html_url ?? null,
327
+ html_url: release?.html_url ?? null,
209
328
  },
210
329
  });
211
- if (error) return { ...result, error: `Could not record the update alert: ${error.message}` };
330
+ if (insertError) {
331
+ return { ...result, error: `Could not record the update alert: ${insertError.message}` };
332
+ }
212
333
  return { ...result, alertRecorded: true };
213
334
  } catch (caught) {
214
335
  return {
@@ -411,7 +532,7 @@ export async function markSyncWorkflowInstalled(): Promise<void> {
411
532
  const self = resolveSelfRepo();
412
533
  const snapshot: UpstreamStatusSnapshot = {
413
534
  checked_at: new Date().toISOString(),
414
- current_version: prev?.current_version ?? pkg.version,
535
+ current_version: prev?.current_version ?? readInstalledVersion(),
415
536
  latest_version: prev?.latest_version ?? null,
416
537
  update_available: prev?.update_available ?? false,
417
538
  track: prev?.track ?? 'git',
@@ -1,13 +1,18 @@
1
1
  {
2
2
  "name": "@nextblock-cms/template",
3
- "version": "0.15.0",
3
+ "version": "0.15.2",
4
4
  "private": true,
5
+ "nextblock": {
6
+ "install": "standalone"
7
+ },
5
8
  "scripts": {
6
9
  "dev": "next dev",
7
10
  "prebuild": "node tools/build-migrate.mjs",
8
11
  "build": "next build",
9
12
  "start": "next start",
10
13
  "lint": "next lint",
14
+ "update": "node tools/update.mjs",
15
+ "update:check": "node tools/update.mjs --check",
11
16
  "deploy:supabase": "node tools/deploy-supabase.js",
12
17
  "configure:supabase-auth": "node tools/configure-supabase-auth.js",
13
18
  "docker:setup": "node scripts/docker-setup.mjs",