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 +1 -1
- package/templates/nextblock-template/app/cms/components/ConnectGitHubButton.tsx +117 -0
- package/templates/nextblock-template/app/cms/components/github-connect-actions.ts +98 -0
- package/templates/nextblock-template/app/cms/dashboard/components/DashboardOnboarding.tsx +18 -13
- package/templates/nextblock-template/docs/12-VERCEL-DEPLOYMENT.md +9 -8
- package/templates/nextblock-template/docs/13-STAYING-UP-TO-DATE.md +38 -9
- package/templates/nextblock-template/lib/onboarding/status.ts +13 -6
- package/templates/nextblock-template/lib/updates/check-upstream.ts +6 -3
- package/templates/nextblock-template/lib/updates/github-device.ts +206 -0
- package/templates/nextblock-template/lib/updates/repo-identity.ts +11 -1
- package/templates/nextblock-template/package.json +1 -1
- package/templates/nextblock-template/tsconfig.tsbuildinfo +1 -1
package/package.json
CHANGED
|
@@ -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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
**
|
|
163
|
-
and ships a daily upstream-sync GitHub Action
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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 {
|
|
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: '
|
|
189
|
+
title: 'Automatic updates (GitHub Actions)',
|
|
186
190
|
description: actionsActive
|
|
187
|
-
? '
|
|
188
|
-
:
|
|
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
|
-
|
|
191
|
-
'https://
|
|
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)
|
|
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
|
|
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 =
|
|
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
|
|
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
|
+
}
|