create-nextblock 0.10.9 → 0.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-nextblock",
3
- "version": "0.10.9",
3
+ "version": "0.11.1",
4
4
  "description": "",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -0,0 +1,117 @@
1
+ 'use client';
2
+
3
+ import { useCallback, useEffect, useRef, useState } from 'react';
4
+ import { Check, ExternalLink, Github, Loader2 } from 'lucide-react';
5
+ import { Button } from '@nextblock-cms/ui';
6
+ import { startGithubConnect, pollGithubConnect } from './github-connect-actions';
7
+
8
+ type Phase = 'idle' | 'starting' | 'awaiting' | 'installed' | 'error';
9
+
10
+ /**
11
+ * One-click "Connect GitHub" via the OAuth device flow. On authorization, the server
12
+ * installs the upstream-sync workflow into the repo (the file Vercel's clone strips).
13
+ * No PAT, no env config — the public client id is baked into the app.
14
+ */
15
+ export default function ConnectGitHubButton() {
16
+ const [phase, setPhase] = useState<Phase>('idle');
17
+ const [userCode, setUserCode] = useState('');
18
+ const [verificationUri, setVerificationUri] = useState('https://github.com/login/device');
19
+ const [error, setError] = useState('');
20
+
21
+ // Cancel any in-flight polling when the component unmounts.
22
+ const activeRef = useRef(false);
23
+ useEffect(() => {
24
+ activeRef.current = true;
25
+ return () => {
26
+ activeRef.current = false;
27
+ };
28
+ }, []);
29
+
30
+ const poll = useCallback(async (intervalMs: number) => {
31
+ if (!activeRef.current) return;
32
+ const result = await pollGithubConnect();
33
+ if (!activeRef.current) return;
34
+
35
+ if (result.status === 'installed') {
36
+ setPhase('installed');
37
+ return;
38
+ }
39
+ if (result.status === 'error') {
40
+ setError(result.error);
41
+ setPhase('error');
42
+ return;
43
+ }
44
+ // pending — back off a little on slow_down, then poll again.
45
+ const next = result.slowDown ? intervalMs + 5000 : intervalMs;
46
+ setTimeout(() => void poll(next), next);
47
+ }, []);
48
+
49
+ const connect = useCallback(async () => {
50
+ setError('');
51
+ setPhase('starting');
52
+ const res = await startGithubConnect();
53
+ if (!activeRef.current) return;
54
+ if (!res.ok || !res.userCode) {
55
+ setError(res.error ?? 'Could not start GitHub connect.');
56
+ setPhase('error');
57
+ return;
58
+ }
59
+ setUserCode(res.userCode);
60
+ if (res.verificationUri) setVerificationUri(res.verificationUri);
61
+ setPhase('awaiting');
62
+ const intervalMs = Math.max((res.interval ?? 5) + 1, 5) * 1000;
63
+ setTimeout(() => void poll(intervalMs), intervalMs);
64
+ }, [poll]);
65
+
66
+ if (phase === 'installed') {
67
+ return (
68
+ <div className="flex items-center gap-1.5 text-sm font-medium text-emerald-600 dark:text-emerald-400">
69
+ <Check className="h-4 w-4" />
70
+ Connected — installing…
71
+ </div>
72
+ );
73
+ }
74
+
75
+ if (phase === 'awaiting') {
76
+ return (
77
+ <div className="flex flex-col items-end gap-1.5 text-right">
78
+ <Button asChild size="sm" variant="outline">
79
+ <a href={verificationUri} target="_blank" rel="noopener noreferrer">
80
+ Authorize on GitHub
81
+ <ExternalLink className="ml-1 h-3.5 w-3.5" />
82
+ </a>
83
+ </Button>
84
+ <p className="text-xs text-muted-foreground">
85
+ Enter code{' '}
86
+ <span className="font-mono font-semibold tracking-wider text-foreground">{userCode}</span>
87
+ </p>
88
+ <p className="flex items-center gap-1 text-xs text-muted-foreground">
89
+ <Loader2 className="h-3 w-3 animate-spin" />
90
+ Waiting for authorization…
91
+ </p>
92
+ </div>
93
+ );
94
+ }
95
+
96
+ return (
97
+ <div className="flex flex-col items-end gap-1">
98
+ <Button
99
+ size="sm"
100
+ variant="outline"
101
+ onClick={() => void connect()}
102
+ disabled={phase === 'starting'}
103
+ className="shrink-0"
104
+ >
105
+ {phase === 'starting' ? (
106
+ <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
107
+ ) : (
108
+ <Github className="mr-1 h-3.5 w-3.5" />
109
+ )}
110
+ {phase === 'starting' ? 'Starting…' : 'Connect GitHub'}
111
+ </Button>
112
+ {phase === 'error' && (
113
+ <p className="max-w-[16rem] text-right text-xs text-red-600 dark:text-red-400">{error}</p>
114
+ )}
115
+ </div>
116
+ );
117
+ }
@@ -0,0 +1,98 @@
1
+ 'use server';
2
+
3
+ import { cookies } from 'next/headers';
4
+ import { revalidatePath } from 'next/cache';
5
+ import { createClient } from '@nextblock-cms/db/server';
6
+ import {
7
+ startDeviceFlow,
8
+ pollDeviceFlowOnce,
9
+ installSyncWorkflow,
10
+ } from '../../../lib/updates/github-device';
11
+
12
+ const DEVICE_COOKIE = 'nb_gh_device';
13
+
14
+ /** ADMIN gate, mirroring the rest of the CMS server entry points. */
15
+ async function isAdmin(): Promise<boolean> {
16
+ const supabase = createClient();
17
+ const {
18
+ data: { user },
19
+ } = await supabase.auth.getUser();
20
+ if (!user) return false;
21
+ const { data: profile, error } = await supabase
22
+ .from('profiles')
23
+ .select('role')
24
+ .eq('id', user.id)
25
+ .single();
26
+ return !error && profile?.role === 'ADMIN';
27
+ }
28
+
29
+ export interface StartConnectResult {
30
+ ok: boolean;
31
+ userCode?: string;
32
+ verificationUri?: string;
33
+ interval?: number;
34
+ expiresIn?: number;
35
+ error?: string;
36
+ }
37
+
38
+ /** Begin the device flow; stash the device code in an httpOnly cookie for polling. */
39
+ export async function startGithubConnect(): Promise<StartConnectResult> {
40
+ if (!(await isAdmin())) return { ok: false, error: 'Administrator role required.' };
41
+
42
+ try {
43
+ const flow = await startDeviceFlow();
44
+ const store = await cookies();
45
+ store.set(DEVICE_COOKIE, flow.deviceCode, {
46
+ httpOnly: true,
47
+ secure: process.env.NODE_ENV === 'production',
48
+ sameSite: 'lax',
49
+ path: '/cms',
50
+ maxAge: flow.expiresIn,
51
+ });
52
+ return {
53
+ ok: true,
54
+ userCode: flow.userCode,
55
+ verificationUri: flow.verificationUri,
56
+ interval: flow.interval,
57
+ expiresIn: flow.expiresIn,
58
+ };
59
+ } catch (caught) {
60
+ return { ok: false, error: caught instanceof Error ? caught.message : 'Could not start GitHub connect.' };
61
+ }
62
+ }
63
+
64
+ export type PollConnectResult =
65
+ | { status: 'installed'; htmlUrl?: string }
66
+ | { status: 'pending'; slowDown?: boolean }
67
+ | { status: 'error'; error: string };
68
+
69
+ /** Poll once; on authorization, install the workflow and clear the device cookie. */
70
+ export async function pollGithubConnect(): Promise<PollConnectResult> {
71
+ if (!(await isAdmin())) return { status: 'error', error: 'Administrator role required.' };
72
+
73
+ const store = await cookies();
74
+ const deviceCode = store.get(DEVICE_COOKIE)?.value;
75
+ if (!deviceCode) {
76
+ return { status: 'error', error: 'Connect session expired — start again.' };
77
+ }
78
+
79
+ const poll = await pollDeviceFlowOnce(deviceCode);
80
+
81
+ if (poll.status === 'pending') return { status: 'pending', slowDown: poll.slowDown };
82
+
83
+ if (poll.status === 'authorized') {
84
+ const install = await installSyncWorkflow(poll.token);
85
+ store.delete({ name: DEVICE_COOKIE, path: '/cms' });
86
+ if (install.ok) {
87
+ revalidatePath('/cms', 'layout');
88
+ return { status: 'installed', htmlUrl: install.htmlUrl };
89
+ }
90
+ return { status: 'error', error: install.error ?? 'Could not install the workflow.' };
91
+ }
92
+
93
+ // expired / denied / error — clear the session.
94
+ store.delete({ name: DEVICE_COOKIE, path: '/cms' });
95
+ if (poll.status === 'expired') return { status: 'error', error: 'Authorization timed out — start again.' };
96
+ if (poll.status === 'denied') return { status: 'error', error: 'Authorization was declined.' };
97
+ return { status: 'error', error: poll.error };
98
+ }
@@ -12,6 +12,7 @@ import {
12
12
  } from '@nextblock-cms/ui';
13
13
  import { ArrowRight, CheckCircle2, Circle, X } from 'lucide-react';
14
14
  import type { OnboardingStatus } from '../../../../lib/onboarding/status';
15
+ import ConnectGitHubButton from '../../components/ConnectGitHubButton';
15
16
 
16
17
  export default function DashboardOnboarding({
17
18
  status,
@@ -102,19 +103,23 @@ export default function DashboardOnboarding({
102
103
  <p className="text-xs text-muted-foreground">{step.description}</p>
103
104
  </div>
104
105
  {!step.done && step.key !== 'admin' && (
105
- <Button asChild variant="outline" size="sm" className="shrink-0">
106
- {step.isExternal ? (
107
- <a href={step.href} target="_blank" rel="noopener noreferrer">
108
- Set up
109
- <ArrowRight className="ml-1 h-3.5 w-3.5" />
110
- </a>
111
- ) : (
112
- <Link href={step.href}>
113
- Set up
114
- <ArrowRight className="ml-1 h-3.5 w-3.5" />
115
- </Link>
116
- )}
117
- </Button>
106
+ step.connectGithub ? (
107
+ <ConnectGitHubButton />
108
+ ) : (
109
+ <Button asChild variant="outline" size="sm" className="shrink-0">
110
+ {step.isExternal ? (
111
+ <a href={step.href} target="_blank" rel="noopener noreferrer">
112
+ Set up
113
+ <ArrowRight className="ml-1 h-3.5 w-3.5" />
114
+ </a>
115
+ ) : (
116
+ <Link href={step.href}>
117
+ Set up
118
+ <ArrowRight className="ml-1 h-3.5 w-3.5" />
119
+ </Link>
120
+ )}
121
+ </Button>
122
+ )
118
123
  )}
119
124
  </li>
120
125
  ))}
@@ -159,11 +159,12 @@ see it scheduled.
159
159
  Visit the deployment URL — it redirects to `/setup` until the first admin exists.
160
160
  Complete the wizard, then sign in at `/cms/dashboard`.
161
161
 
162
- **Turn on automatic updates.** The 1-click deploy forks NextBlock into your Git provider
163
- and ships a daily upstream-sync GitHub Action but GitHub **disables Actions on a fresh
164
- fork** until you enable them once (your repo **Actions** tab *"I understand my
165
- workflows, go ahead and enable them."*). The dashboard onboarding checklist reminds you and
166
- links straight there. We recommend a **public** fork (fully zero-config); a **private**
167
- fork additionally needs a `NEXTBLOCK_GITHUB_TOKEN` env var for the in-CMS conflict banner.
168
- Full details both tracks, conflict handling, and build-time migrations are in
169
- [docs/13](./13-STAYING-UP-TO-DATE.md).
162
+ **Automatic updates.** The 1-click deploy creates a **new repository you own** (a copy —
163
+ *not* a GitHub fork) and ships a daily upstream-sync GitHub Action. Because it's your own
164
+ repo, **Actions are enabled by default there's nothing to turn on**; the workflow runs
165
+ once it's on your **default branch**, and the dashboard onboarding step completes
166
+ automatically when GitHub registers it. We recommend keeping the repo **public** (fully
167
+ zero-config); a **private** repo additionally needs a `NEXTBLOCK_GITHUB_TOKEN` env var for
168
+ the in-CMS conflict banner. (Only a *manually-created GitHub fork* has Actions disabled
169
+ until you enable them on the Actions tab.) Full details — both tracks, conflict handling,
170
+ and build-time migrations — are in [docs/13](./13-STAYING-UP-TO-DATE.md).
@@ -28,15 +28,44 @@ workflow**). Each run:
28
28
  dashboard with a link to resolve it. Once you resolve and **close the issue**, the
29
29
  banner clears automatically.
30
30
 
31
- ### One-time step: enable GitHub Actions
32
-
33
- GitHub **disables Actions on a freshly-forked repo** until you turn them on once:
34
-
35
- > Your repo **Actions** tab **"I understand my workflows, go ahead and enable them."**
36
-
37
- The dashboard onboarding checklist reminds you of this ("Enable automatic updates
38
- (GitHub Actions)") and links straight to your repo's Actions tab. The step marks itself
39
- done once the workflow has run at least once.
31
+ ### One-click install (Connect GitHub)
32
+
33
+ Vercel's 1-click deploy creates your repo through an integration whose token lacks the
34
+ GitHub **`workflow`** scope, so GitHub **strips `.github/workflows/`** from the copy — your
35
+ new repo won't have the sync workflow even though the template ships it. To fix that with no
36
+ token to create, the dashboard onboarding step shows a **Connect GitHub** button:
37
+
38
+ 1. Click **Connect GitHub** a short code appears.
39
+ 2. Click **Authorize on GitHub**, enter the code, approve.
40
+ 3. NextBlock installs `.github/workflows/nextblock-sync.yml` into your repo for you, and the
41
+ step turns green.
42
+
43
+ This uses GitHub's **device flow** — no token to create, no per-site callback, nothing to
44
+ configure (the public client id ships with NextBlock). The authorization requests the
45
+ `repo` + `workflow` scopes because GitHub requires them to write a workflow file; NextBlock
46
+ uses the grant once to install the file and does **not** store it. Revoke it anytime at
47
+ GitHub → **Settings → Applications**.
48
+
49
+ ### Do you need to enable GitHub Actions?
50
+
51
+ It depends on how the repository was created:
52
+
53
+ - **Vercel 1-click deploy** creates a **new repository you own** (a copy, *not* a GitHub
54
+ fork). GitHub **enables Actions by default** on repos you own — **there's nothing to turn
55
+ on**. The sync workflow runs automatically once it lands on your repo's **default branch**.
56
+ - **A manual GitHub _fork_** (the "Fork" button) has Actions **disabled** by default. Enable
57
+ them once: your repo → **Actions** tab → **"I understand my workflows, go ahead and enable
58
+ them."**
59
+
60
+ > **Seeing GitHub's "Get started with Actions / choose a workflow" page?** That only means
61
+ > your Actions tab is **empty** — `.github/workflows/nextblock-sync.yml` isn't on your
62
+ > **default branch** yet (scheduled workflows only run from the default branch). Once it is,
63
+ > the tab shows **NextBlock Upstream Sync** with a **Run workflow** button. There is no
64
+ > separate "enable" button on an owned repo because Actions are already on.
65
+
66
+ The dashboard onboarding step ("Automatic updates (GitHub Actions)") links to **Settings →
67
+ Actions** — where you can confirm Actions are allowed — and completes itself once GitHub
68
+ reports the sync workflow as active.
40
69
 
41
70
  ### No GitHub secrets required (public forks)
42
71
 
@@ -7,7 +7,8 @@ import { getEmailPublicSettings } from '../config/email-settings';
7
7
  import { getPrivacySettings } from '../privacy/settings';
8
8
  import { detectChannel } from '../setup/env-status';
9
9
  import { getSystemConfiguration } from '../setup/system-config';
10
- import { selfActionsUrl } from '../updates/repo-identity';
10
+ import { selfActionsSettingsUrl } from '../updates/repo-identity';
11
+ import { isGithubConnectAvailable } from '../updates/github-device';
11
12
 
12
13
  export type OnboardingStep = {
13
14
  key: string;
@@ -18,6 +19,8 @@ export type OnboardingStep = {
18
19
  optional: boolean;
19
20
  /** When true, render the CTA as an external link (new tab) instead of an in-app route. */
20
21
  isExternal?: boolean;
22
+ /** When true, render the device-flow "Connect GitHub" control instead of a link. */
23
+ connectGithub?: boolean;
21
24
  };
22
25
 
23
26
  export type OnboardingStatus = {
@@ -180,18 +183,22 @@ export async function getOnboardingStatus(opts: {
180
183
  } catch {
181
184
  actionsActive = false;
182
185
  }
186
+ const canConnect = !actionsActive && isGithubConnectAvailable();
183
187
  steps.push({
184
188
  key: 'github-actions',
185
- title: 'Enable automatic updates (GitHub Actions)',
189
+ title: 'Automatic updates (GitHub Actions)',
186
190
  description: actionsActive
187
- ? 'Automated upstream sync is active for your repository.'
188
- : 'Turn on GitHub Actions in your forked repo so NextBlock can merge upstream updates for you.',
191
+ ? 'The daily upstream-sync workflow is active for your repository.'
192
+ : canConnect
193
+ ? 'Connect GitHub to install the upstream-sync workflow into your repo — Vercel’s 1-click deploy can’t copy it automatically.'
194
+ : 'Vercel deploys have GitHub Actions on by default — this completes once GitHub registers the sync workflow on your default branch. A manually forked repo needs Actions enabled under Settings → Actions.',
189
195
  href:
190
- selfActionsUrl() ??
191
- 'https://docs.github.com/actions/managing-workflow-runs-and-deployments/managing-workflow-runs/disabling-and-enabling-a-workflow',
196
+ selfActionsSettingsUrl() ??
197
+ 'https://github.com/nextblock-cms/nextblock/blob/HEAD/docs/13-STAYING-UP-TO-DATE.md',
192
198
  done: actionsActive,
193
199
  optional: true,
194
200
  isExternal: true,
201
+ connectGithub: canConnect,
195
202
  });
196
203
  }
197
204
 
@@ -264,10 +264,13 @@ export async function checkForSyncConflicts(): Promise<SyncConflictResult> {
264
264
  fetchError = caught instanceof Error ? caught.message : 'Could not reach the GitHub issues API.';
265
265
  }
266
266
 
267
- // 2) Has the sync workflow ever run? (drives the onboarding "Actions enabled" state)
267
+ // 2) Is the sync workflow present AND enabled? (drives the onboarding "Actions" step)
268
+ // We check the workflow's `state` rather than whether it has *run*: a healthy Vercel
269
+ // deploy has Actions enabled by default, so the workflow is 'active' immediately — no
270
+ // need to wait up to 24h for the first daily cron before the step completes.
268
271
  let actionsActive = false;
269
272
  try {
270
- const wfUrl = `https://api.github.com/repos/${self.owner}/${self.repo}/actions/workflows/nextblock-sync.yml/runs?per_page=1`;
273
+ const wfUrl = `https://api.github.com/repos/${self.owner}/${self.repo}/actions/workflows/nextblock-sync.yml`;
271
274
  const res = await fetch(wfUrl, {
272
275
  headers: githubHeaders(),
273
276
  signal: AbortSignal.timeout(15_000),
@@ -275,7 +278,7 @@ export async function checkForSyncConflicts(): Promise<SyncConflictResult> {
275
278
  });
276
279
  if (res.ok) {
277
280
  const data = await res.json();
278
- actionsActive = (data?.total_count ?? 0) > 0;
281
+ actionsActive = data?.state === 'active';
279
282
  }
280
283
  } catch {
281
284
  /* best-effort */
@@ -0,0 +1,206 @@
1
+ import 'server-only';
2
+ // GitHub OAuth Device Flow — the one auth model that works across every NextBlock install
3
+ // domain without a per-site callback URL or a user-created PAT. Powers the onboarding
4
+ // "Connect GitHub" button, which installs the upstream-sync workflow into the user's repo
5
+ // (Vercel's 1-click clone strips .github/workflows because its token lacks the `workflow`
6
+ // scope, so the file must be added with a token that has it — which Connect obtains).
7
+ //
8
+ // The Client ID is PUBLIC (device flow uses no client secret), so it's baked in as the
9
+ // default for every install; NEXTBLOCK_GITHUB_CLIENT_ID overrides it for self-run forks.
10
+ import { resolveSelfRepo } from './repo-identity';
11
+
12
+ // Shared NextBlock OAuth App (org: nextblock-cms), Device Flow enabled. Public value.
13
+ const DEFAULT_GITHUB_CLIENT_ID = 'Ov23liVYp5Tpmq7CUnGf';
14
+ const UPSTREAM_REPO = 'nextblock-cms/nextblock';
15
+ const WORKFLOW_PATH = '.github/workflows/nextblock-sync.yml';
16
+ // Writing a workflow file requires `workflow`; `repo` covers contents on private repos.
17
+ const SCOPES = 'repo workflow';
18
+
19
+ const DEVICE_CODE_URL = 'https://github.com/login/device/code';
20
+ const TOKEN_URL = 'https://github.com/login/oauth/access_token';
21
+
22
+ export function getGithubClientId(): string {
23
+ return process.env.NEXTBLOCK_GITHUB_CLIENT_ID?.trim() || DEFAULT_GITHUB_CLIENT_ID;
24
+ }
25
+
26
+ /** Connect is offered only when we know the repo and have a client id to authorize with. */
27
+ export function isGithubConnectAvailable(): boolean {
28
+ return Boolean(getGithubClientId()) && resolveSelfRepo() !== null;
29
+ }
30
+
31
+ function apiHeaders(token?: string): Record<string, string> {
32
+ const headers: Record<string, string> = {
33
+ Accept: 'application/vnd.github+json',
34
+ 'X-GitHub-Api-Version': '2022-11-28',
35
+ 'User-Agent': 'nextblock-update-checker',
36
+ };
37
+ if (token) headers.Authorization = `Bearer ${token}`;
38
+ return headers;
39
+ }
40
+
41
+ export interface DeviceFlowStart {
42
+ deviceCode: string;
43
+ userCode: string;
44
+ verificationUri: string;
45
+ interval: number;
46
+ expiresIn: number;
47
+ }
48
+
49
+ /** Begin the device flow. Throws on a hard failure (the caller surfaces the message). */
50
+ export async function startDeviceFlow(): Promise<DeviceFlowStart> {
51
+ const res = await fetch(DEVICE_CODE_URL, {
52
+ method: 'POST',
53
+ headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
54
+ body: JSON.stringify({ client_id: getGithubClientId(), scope: SCOPES }),
55
+ signal: AbortSignal.timeout(15_000),
56
+ cache: 'no-store',
57
+ });
58
+ if (!res.ok) {
59
+ throw new Error(`GitHub device-code request failed (HTTP ${res.status}).`);
60
+ }
61
+ const data = await res.json();
62
+ if (data.error || !data.device_code) {
63
+ throw new Error(
64
+ data.error_description || data.error || 'GitHub did not return a device code.',
65
+ );
66
+ }
67
+ return {
68
+ deviceCode: data.device_code,
69
+ userCode: data.user_code,
70
+ verificationUri: data.verification_uri,
71
+ interval: typeof data.interval === 'number' ? data.interval : 5,
72
+ expiresIn: typeof data.expires_in === 'number' ? data.expires_in : 900,
73
+ };
74
+ }
75
+
76
+ export type DevicePollResult =
77
+ | { status: 'authorized'; token: string }
78
+ | { status: 'pending'; slowDown?: boolean }
79
+ | { status: 'expired' }
80
+ | { status: 'denied' }
81
+ | { status: 'error'; error: string };
82
+
83
+ /** Poll once for the user's authorization. Never throws. */
84
+ export async function pollDeviceFlowOnce(deviceCode: string): Promise<DevicePollResult> {
85
+ try {
86
+ const res = await fetch(TOKEN_URL, {
87
+ method: 'POST',
88
+ headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
89
+ body: JSON.stringify({
90
+ client_id: getGithubClientId(),
91
+ device_code: deviceCode,
92
+ grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
93
+ }),
94
+ signal: AbortSignal.timeout(15_000),
95
+ cache: 'no-store',
96
+ });
97
+ const data = await res.json();
98
+ if (data.access_token) return { status: 'authorized', token: data.access_token };
99
+ switch (data.error) {
100
+ case 'authorization_pending':
101
+ return { status: 'pending' };
102
+ case 'slow_down':
103
+ return { status: 'pending', slowDown: true };
104
+ case 'expired_token':
105
+ return { status: 'expired' };
106
+ case 'access_denied':
107
+ return { status: 'denied' };
108
+ default:
109
+ return { status: 'error', error: data.error_description || data.error || 'Unknown error.' };
110
+ }
111
+ } catch (caught) {
112
+ return {
113
+ status: 'error',
114
+ error: caught instanceof Error ? caught.message : 'Could not reach GitHub.',
115
+ };
116
+ }
117
+ }
118
+
119
+ export interface InstallResult {
120
+ ok: boolean;
121
+ htmlUrl?: string;
122
+ error?: string;
123
+ }
124
+
125
+ /**
126
+ * Install (or update) the upstream-sync workflow in the connected repo, using the
127
+ * canonical file from the upstream repo so it never drifts. Never throws.
128
+ */
129
+ export async function installSyncWorkflow(token: string): Promise<InstallResult> {
130
+ const self = resolveSelfRepo();
131
+ if (!self) return { ok: false, error: 'Could not determine this deployment’s GitHub repository.' };
132
+
133
+ try {
134
+ // 1) Default branch of the target repo.
135
+ const repoRes = await fetch(`https://api.github.com/repos/${self.owner}/${self.repo}`, {
136
+ headers: apiHeaders(token),
137
+ signal: AbortSignal.timeout(15_000),
138
+ cache: 'no-store',
139
+ });
140
+ if (!repoRes.ok) {
141
+ return { ok: false, error: `Could not read the repository (HTTP ${repoRes.status}).` };
142
+ }
143
+ const repo = await repoRes.json();
144
+ const branch: string = repo.default_branch || 'main';
145
+
146
+ // 2) Canonical workflow content from upstream (base64), passed straight through.
147
+ const upstreamRes = await fetch(
148
+ `https://api.github.com/repos/${UPSTREAM_REPO}/contents/${WORKFLOW_PATH}`,
149
+ { headers: apiHeaders(token), signal: AbortSignal.timeout(15_000), cache: 'no-store' },
150
+ );
151
+ if (!upstreamRes.ok) {
152
+ return { ok: false, error: `Could not read the upstream workflow (HTTP ${upstreamRes.status}).` };
153
+ }
154
+ const upstream = await upstreamRes.json();
155
+ const contentBase64 = typeof upstream.content === 'string' ? upstream.content.replace(/\s/g, '') : '';
156
+ if (!contentBase64) return { ok: false, error: 'Upstream workflow content was empty.' };
157
+
158
+ // 3) Existing file sha (if the path already exists on the target branch).
159
+ let sha: string | undefined;
160
+ const existingRes = await fetch(
161
+ `https://api.github.com/repos/${self.owner}/${self.repo}/contents/${WORKFLOW_PATH}?ref=${encodeURIComponent(branch)}`,
162
+ { headers: apiHeaders(token), signal: AbortSignal.timeout(15_000), cache: 'no-store' },
163
+ );
164
+ if (existingRes.ok) {
165
+ const existing = await existingRes.json();
166
+ if (existing && typeof existing.sha === 'string') sha = existing.sha;
167
+ }
168
+
169
+ // 4) Create/update the workflow file.
170
+ const putRes = await fetch(
171
+ `https://api.github.com/repos/${self.owner}/${self.repo}/contents/${WORKFLOW_PATH}`,
172
+ {
173
+ method: 'PUT',
174
+ headers: { ...apiHeaders(token), 'Content-Type': 'application/json' },
175
+ body: JSON.stringify({
176
+ message: 'ci: add NextBlock upstream-sync workflow',
177
+ content: contentBase64,
178
+ branch,
179
+ ...(sha ? { sha } : {}),
180
+ }),
181
+ signal: AbortSignal.timeout(20_000),
182
+ cache: 'no-store',
183
+ },
184
+ );
185
+ if (!putRes.ok) {
186
+ let detail = `HTTP ${putRes.status}`;
187
+ try {
188
+ const body = await putRes.json();
189
+ if (body?.message) detail = body.message;
190
+ } catch {
191
+ /* ignore */
192
+ }
193
+ return { ok: false, error: `Could not install the workflow: ${detail}.` };
194
+ }
195
+
196
+ return {
197
+ ok: true,
198
+ htmlUrl: `https://github.com/${self.owner}/${self.repo}/actions`,
199
+ };
200
+ } catch (caught) {
201
+ return {
202
+ ok: false,
203
+ error: caught instanceof Error ? caught.message : 'Could not install the workflow.',
204
+ };
205
+ }
206
+ }
@@ -39,8 +39,18 @@ export function resolveSelfRepo(): SelfRepo | null {
39
39
  return null;
40
40
  }
41
41
 
42
- /** The fork's GitHub Actions tab, for the onboarding "enable Actions" reminder. */
42
+ /** The repo's GitHub Actions tab (shows the sync workflow + a "Run workflow" button). */
43
43
  export function selfActionsUrl(): string | null {
44
44
  const self = resolveSelfRepo();
45
45
  return self ? `https://github.com/${self.owner}/${self.repo}/actions` : null;
46
46
  }
47
+
48
+ /**
49
+ * Settings -> Actions -> General. The authoritative enable/permissions page — always
50
+ * renders a clear UI (unlike /actions, which redirects to the confusing "choose a
51
+ * workflow" chooser when no workflow has run yet). Used by the onboarding reminder.
52
+ */
53
+ export function selfActionsSettingsUrl(): string | null {
54
+ const self = resolveSelfRepo();
55
+ return self ? `https://github.com/${self.owner}/${self.repo}/settings/actions` : null;
56
+ }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nextblock-cms/template",
3
- "version": "0.10.9",
3
+ "version": "0.11.1",
4
4
  "private": true,
5
5
  "scripts": {
6
6
  "dev": "next dev",