10 Commits

Author SHA1 Message Date
Alejandro Gutiérrez
1a42c2ef09 chore: trigger Vercel redeploy
Some checks failed
CI / Typecheck (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 09:58:17 +01:00
Alejandro Gutiérrez
43b70013c5 fix: exclude cli-v2 from git to unblock Vercel builds
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 09:53:29 +01:00
Alejandro Gutiérrez
b8d8b5469b fix: rename cli-v2 package to avoid Turborepo duplicate workspace
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 09:46:18 +01:00
Alejandro Gutiérrez
ab7fb6bd31 chore(web): bust Vercel build cache
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 09:39:04 +01:00
Alejandro Gutiérrez
b2999878c4 fix(web): inline CSS stub loader for Vercel path resolution
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 09:34:56 +01:00
Alejandro Gutiérrez
a890a1d92e fix(web): use --import instead of --experimental-loader for Vercel compat
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 09:29:52 +01:00
Alejandro Gutiérrez
80a6b8b50f fix(web): resolve Payload CMS build error with Node.js ESM loader
Payload CMS imports .css/.scss/.svg files that Node.js ESM can't handle
during page data collection. Added a custom ESM loader that stubs these
asset imports, fixing the build that has been broken since the upgrade.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 09:24:32 +01:00
Alejandro Gutiérrez
465ff9a10e fix(web): rewrite CLI auth login as standalone component
Remove dependency on SocialProviders/RegisterForm which need
React Query providers. Self-contained with authClient directly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 09:06:42 +01:00
Alejandro Gutiérrez
0f46c787a7 feat(web): show authenticated user in marketing header
Header now checks session and shows avatar + name + Dashboard link
when logged in, instead of always showing Sign in / Start free.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 08:55:33 +01:00
Alejandro Gutiérrez
a365fef170 feat(web): dedicated CLI auth page with inline login/register
No more redirect to generic /auth/login. The /cli-auth?code=XXXX page
now shows auth forms inline (Google, GitHub, email) with device code
context — like Anthropic's "Build with Claude" page.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 08:51:18 +01:00
8 changed files with 238 additions and 61 deletions

2
.gitignore vendored
View File

@@ -75,3 +75,5 @@ dist/
apps/web/payload.db
apps/web/public/media/*
!apps/web/public/media/.gitkeep
.env.local
apps/cli-v2/

View File

@@ -1,33 +1,10 @@
/**
* Node.js ESM custom loader — stubs static asset imports as empty modules.
*
* Next.js 16 does route collection in raw Node ESM (not webpack/turbopack).
* Payload CMS deps import .css, .scss, .svg, and other assets that Node
* can't handle. This loader intercepts those and returns empty modules.
*
* Usage: NODE_OPTIONS="--import ./apps/web/css-stub-loader.mjs"
*/
import { register } from "node:module";
register(
"data:text/javascript," +
encodeURIComponent(`
const STYLE_RE = /\\.(css|scss|sass|less|svg|png|jpg|jpeg|gif|webp|ico|woff|woff2|ttf|eot|otf)$/;
// Node.js ESM loader that stubs non-JS asset imports during Next.js page data collection.
// Payload CMS and its deps import .css/.scss/.svg files that Node.js can't handle.
const STUB_EXTENSIONS = ['.css', '.scss', '.sass', '.svg', '.png', '.jpg', '.jpeg', '.gif', '.ico', '.woff', '.woff2', '.ttf', '.eot'];
export function resolve(specifier, context, nextResolve) {
if (STYLE_RE.test(specifier)) {
return { url: 'data:text/javascript,export default {};', shortCircuit: true };
if (STUB_EXTENSIONS.some(ext => specifier.endsWith(ext))) {
return { url: 'data:text/javascript,export default ""', shortCircuit: true };
}
return nextResolve(specifier, context);
}
export function load(url, context, nextLoad) {
if (STYLE_RE.test(url)) {
return { format: 'module', source: 'export default {};', shortCircuit: true };
}
return nextLoad(url, context);
}
`),
import.meta.url,
);

View File

@@ -0,0 +1,10 @@
import { register } from "node:module";
register("data:text/javascript," + encodeURIComponent(`
const STUB_EXT = ['.css', '.scss', '.sass', '.svg', '.png', '.jpg', '.jpeg', '.gif', '.ico', '.woff', '.woff2', '.ttf', '.eot'];
export function resolve(specifier, context, nextResolve) {
if (STUB_EXT.some(ext => specifier.endsWith(ext))) {
return { url: 'data:text/javascript,export default ""', shortCircuit: true };
}
return nextResolve(specifier, context);
}
`));

View File

@@ -72,6 +72,7 @@ const securityHeaders = [
},
];
// build: 1776069543
const config: NextConfig = {
reactStrictMode: true,
output: "standalone",
@@ -90,7 +91,6 @@ const config: NextConfig = {
"@payloadcms/richtext-lexical",
"@payloadcms/next",
"@payloadcms/ui",
"react-image-crop",
"sharp",
"libsodium-wrappers",
],
@@ -130,7 +130,7 @@ const config: NextConfig = {
},
/** Enables hot reloading for local packages without a build step */
transpilePackages: INTERNAL_PACKAGES,
transpilePackages: [...INTERNAL_PACKAGES, "react-image-crop"],
experimental: {
optimizePackageImports: INTERNAL_PACKAGES,
},

View File

@@ -4,7 +4,7 @@
"private": true,
"type": "module",
"scripts": {
"build": "next build --webpack",
"build": "NODE_OPTIONS='--import ./css-stub-register.mjs' next build --webpack",
"clean": "git clean -xdf .cache .next .turbo node_modules",
"dev": "next dev",
"format": "prettier --check . --ignore-path ../../.gitignore",

View File

@@ -0,0 +1,147 @@
"use client";
import { useState } from "react";
import { authClient } from "~/lib/auth/client";
interface Props {
code: string;
}
export function CliAuthLogin({ code }: Props) {
const redirectTo = `/cli-auth?code=${encodeURIComponent(code)}`;
const [loading, setLoading] = useState<string | null>(null);
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [name, setName] = useState("");
const [mode, setMode] = useState<"login" | "register">("login");
const [error, setError] = useState("");
const handleSocial = async (provider: "google" | "github") => {
setLoading(provider);
setError("");
try {
await authClient.signIn.social({
provider,
callbackURL: redirectTo,
});
} catch (e) {
setError(e instanceof Error ? e.message : "Sign-in failed");
setLoading(null);
}
};
const handleEmailSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading("email");
setError("");
try {
if (mode === "register") {
await authClient.signUp.email({
email,
password,
name: name || email.split("@")[0] || "User",
callbackURL: redirectTo,
});
} else {
await authClient.signIn.email({
email,
password,
callbackURL: redirectTo,
});
}
window.location.href = redirectTo;
} catch (e) {
setError(e instanceof Error ? e.message : "Failed");
setLoading(null);
}
};
const btnBase = "w-full flex items-center justify-center gap-3 rounded-lg px-4 py-3 text-[15px] font-medium transition-all";
const btnOutline = `${btnBase} border border-[var(--cm-border,#333)] text-[var(--cm-fg,#fafafa)] hover:bg-[var(--cm-bg-elevated,#1a1a1a)]`;
const btnPrimary = `${btnBase} bg-[var(--cm-clay,#b07a56)] text-[var(--cm-fg,#fafafa)] hover:opacity-90`;
const inputBase = "w-full rounded-lg border border-[var(--cm-border,#333)] bg-[var(--cm-bg,#0a0a0a)] px-4 py-3 text-[15px] text-[var(--cm-fg,#fafafa)] placeholder:text-[var(--cm-fg-muted,#666)] focus:outline-none focus:ring-2 focus:ring-[var(--cm-clay,#b07a56)]/50 focus:border-[var(--cm-clay,#b07a56)]";
return (
<div className="w-full max-w-[400px] space-y-6 p-8">
{/* Header */}
<div className="text-center space-y-3">
<div className="mx-auto w-14 h-14 rounded-2xl flex items-center justify-center" style={{ background: "var(--cm-clay, #b07a56)" }}>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="4" r="2" fill="#fff" />
<circle cx="4" cy="12" r="2" fill="#fff" />
<circle cx="20" cy="12" r="2" fill="#fff" />
<circle cx="12" cy="20" r="2" fill="#fff" />
<path d="M12 4L4 12M12 4L20 12M4 12L12 20M20 12L12 20" stroke="#fff" strokeWidth="1.2" opacity="0.5" />
</svg>
</div>
<h1 className="text-[22px] font-bold tracking-tight">
Connect to claudemesh CLI
</h1>
<p className="text-[14px]" style={{ color: "var(--cm-fg-muted, #888)" }}>
{mode === "login" ? "Sign in" : "Create an account"} to connect your terminal session.
</p>
</div>
{/* Social buttons */}
<div className="space-y-2.5">
<button onClick={() => handleSocial("google")} disabled={!!loading} className={btnOutline}>
{loading === "google" ? (
<span className="animate-spin"></span>
) : (
<svg width="18" height="18" viewBox="0 0 24 24"><path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 01-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z" fill="#4285F4"/><path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853"/><path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05"/><path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335"/></svg>
)}
Continue with Google
</button>
<button onClick={() => handleSocial("github")} disabled={!!loading} className={btnOutline}>
{loading === "github" ? (
<span className="animate-spin"></span>
) : (
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><path d="M12 .3a12 12 0 00-3.8 23.4c.6.1.8-.3.8-.6v-2.2c-3.3.7-4-1.4-4-1.4-.5-1.4-1.3-1.8-1.3-1.8-1.1-.7.1-.7.1-.7 1.2.1 1.8 1.2 1.8 1.2 1 1.8 2.8 1.3 3.5 1 .1-.8.4-1.3.7-1.6-2.7-.3-5.5-1.3-5.5-6a4.7 4.7 0 011.3-3.3c-.2-.3-.6-1.6.1-3.3 0 0 1-.3 3.3 1.2a11.5 11.5 0 016 0c2.3-1.5 3.3-1.2 3.3-1.2.7 1.7.3 3 .1 3.3a4.7 4.7 0 011.3 3.3c0 4.7-2.8 5.7-5.5 6 .4.4.8 1.1.8 2.2v3.3c0 .3.2.7.8.6A12 12 0 0012 .3"/></svg>
)}
Continue with GitHub
</button>
</div>
{/* Divider */}
<div className="flex items-center gap-4">
<div className="flex-1 h-px" style={{ background: "var(--cm-border, #333)" }} />
<span className="text-[12px] uppercase tracking-wider" style={{ color: "var(--cm-fg-muted, #666)" }}>or</span>
<div className="flex-1 h-px" style={{ background: "var(--cm-border, #333)" }} />
</div>
{/* Email form */}
<form onSubmit={handleEmailSubmit} className="space-y-3">
{mode === "register" && (
<input type="text" placeholder="Name" value={name} onChange={e => setName(e.target.value)} className={inputBase} />
)}
<input type="email" placeholder="Email" value={email} onChange={e => setEmail(e.target.value)} required className={inputBase} />
<input type="password" placeholder="Password" value={password} onChange={e => setPassword(e.target.value)} required minLength={8} className={inputBase} />
{error && <p className="text-[13px] text-red-400">{error}</p>}
<button type="submit" disabled={!!loading} className={btnPrimary}>
{loading === "email" ? "..." : mode === "login" ? "Sign in" : "Create account"}
</button>
</form>
{/* Toggle mode */}
<p className="text-center text-[13px]" style={{ color: "var(--cm-fg-muted, #888)" }}>
{mode === "login" ? (
<>Don&apos;t have an account?{" "}<button onClick={() => { setMode("register"); setError(""); }} className="underline hover:text-[var(--cm-fg)]">Register</button></>
) : (
<>Already have an account?{" "}<button onClick={() => { setMode("login"); setError(""); }} className="underline hover:text-[var(--cm-fg)]">Sign in</button></>
)}
</p>
{/* Device code */}
<div className="pt-2 text-center">
<div className="inline-block rounded-lg px-5 py-2.5 font-mono text-lg tracking-[0.25em]" style={{ background: "var(--cm-bg-elevated, #1a1a1a)", border: "1px solid var(--cm-border, #333)" }}>
{code}
</div>
<p className="mt-2 text-[12px]" style={{ color: "var(--cm-fg-muted, #666)" }}>
Confirm this code matches your terminal
</p>
</div>
</div>
);
}

View File

@@ -1,14 +1,13 @@
import { redirect } from "next/navigation";
import { getSession } from "~/lib/auth/server";
import { getMetadata } from "~/lib/metadata";
import { CliAuthFlow } from "./cli-auth-flow";
import { DeviceCodeApproval } from "./device-code-approval";
import { CliAuthLogin } from "./cli-auth-login";
export const generateMetadata = getMetadata({
title: "Sync with CLI",
description: "Link your claudemesh CLI to your account.",
title: "Connect CLI",
description: "Sign in to connect your claudemesh CLI.",
});
export default async function CliAuthPage({
@@ -17,24 +16,24 @@ export default async function CliAuthPage({
searchParams: Promise<{ code?: string; port?: string }>;
}) {
const { user } = await getSession();
if (!user) {
const sp = await searchParams;
const qs = new URLSearchParams();
if (sp.code) qs.set("code", sp.code);
if (sp.port) qs.set("port", sp.port);
const returnTo = `/cli-auth${qs.size ? `?${qs}` : ""}`;
return redirect(`/auth/login?redirectTo=${encodeURIComponent(returnTo)}`);
}
const { code, port } = await searchParams;
// Device-code flow: code contains "-" (e.g. "ABCD-EFGH"), no port
const isDeviceCode = code && code.includes("-") && !port;
if (isDeviceCode) {
if (!user) {
// NOT logged in → show inline auth form with device code context
return (
<main className="min-h-screen bg-[var(--cm-bg,#0a0a0a)] text-[var(--cm-fg,#fafafa)] antialiased flex items-center justify-center">
<CliAuthLogin code={code} />
</main>
);
}
// Logged in → auto-approve
return (
<main className="min-h-screen bg-[var(--cm-bg)] text-[var(--cm-fg)] antialiased flex items-center justify-center">
<main className="min-h-screen bg-[var(--cm-bg,#0a0a0a)] text-[var(--cm-fg,#fafafa)] antialiased flex items-center justify-center">
<DeviceCodeApproval
code={code}
userName={user.name ?? user.email}
@@ -43,9 +42,19 @@ export default async function CliAuthPage({
);
}
// Legacy callback flow (port-based)
if (!user) {
const { redirect } = await import("next/navigation");
const qs = new URLSearchParams();
if (code) qs.set("code", code);
if (port) qs.set("port", port);
const returnTo = `/cli-auth${qs.size ? `?${qs}` : ""}`;
return redirect(`/auth/login?redirectTo=${encodeURIComponent(returnTo)}`);
}
return (
<main
className="min-h-screen bg-[var(--cm-bg)] text-[var(--cm-fg)] antialiased"
className="min-h-screen bg-[var(--cm-bg,#0a0a0a)] text-[var(--cm-fg,#fafafa)] antialiased"
style={{ fontFamily: "var(--cm-font-sans)" }}
>
<CliAuthFlow
@@ -57,3 +66,4 @@ export default async function CliAuthPage({
</main>
);
}

View File

@@ -1,5 +1,7 @@
import Link from "next/link";
import { getSession } from "~/lib/auth/server";
const NAV = [
{ label: "Docs", href: "https://github.com/alezmad/claudemesh-cli#readme" },
{ label: "Blog", href: "/blog" },
@@ -9,7 +11,9 @@ const NAV = [
const OSS_REPO_URL = "https://github.com/alezmad/claudemesh-cli";
export const Header = () => {
export const Header = async () => {
const { user } = await getSession();
return (
<header
className="sticky top-0 z-40 w-full border-b border-[var(--cm-border)] bg-[var(--cm-bg)]/85 backdrop-blur-md"
@@ -81,19 +85,46 @@ export const Header = () => {
<path d="M12 .3a12 12 0 00-3.8 23.4c.6.1.8-.3.8-.6v-2.2c-3.3.7-4-1.4-4-1.4-.5-1.4-1.3-1.8-1.3-1.8-1.1-.7.1-.7.1-.7 1.2.1 1.8 1.2 1.8 1.2 1 1.8 2.8 1.3 3.5 1 .1-.8.4-1.3.7-1.6-2.7-.3-5.5-1.3-5.5-6a4.7 4.7 0 011.3-3.3c-.2-.3-.6-1.6.1-3.3 0 0 1-.3 3.3 1.2a11.5 11.5 0 016 0c2.3-1.5 3.3-1.2 3.3-1.2.7 1.7.3 3 .1 3.3a4.7 4.7 0 011.3 3.3c0 4.7-2.8 5.7-5.5 6 .4.4.8 1.1.8 2.2v3.3c0 .3.2.7.8.6A12 12 0 0012 .3" />
</svg>
</a>
<Link
href="/auth/login"
className="hidden rounded-[var(--cm-radius-xs)] px-3 py-2 text-[14px] text-[var(--cm-fg-secondary)] transition-colors hover:text-[var(--cm-fg)] md:inline-flex"
>
Sign in
</Link>
<Link
href="/auth/register"
className="inline-flex items-center gap-1.5 rounded-[var(--cm-radius-xs)] bg-[var(--cm-clay)] px-4 py-2 text-[14px] font-medium text-[var(--cm-fg)] transition-colors hover:bg-[var(--cm-clay-hover)]"
>
Start free
<span className="hidden sm:inline"></span>
</Link>
{user ? (
<>
<Link
href="/dashboard"
className="hidden rounded-[var(--cm-radius-xs)] px-3 py-2 text-[14px] text-[var(--cm-fg-secondary)] transition-colors hover:text-[var(--cm-fg)] md:inline-flex"
>
Dashboard
</Link>
<Link
href="/dashboard"
className="inline-flex items-center gap-2 rounded-[var(--cm-radius-xs)] bg-[var(--cm-bg-elevated)] px-3 py-2 text-[14px] text-[var(--cm-fg)] transition-colors hover:bg-[var(--cm-clay)]/20"
>
<span
className="flex h-6 w-6 items-center justify-center rounded-full bg-[var(--cm-clay)] text-[11px] font-semibold text-[var(--cm-fg)]"
>
{(user.name ?? user.email ?? "U").charAt(0).toUpperCase()}
</span>
<span className="hidden md:inline">
{user.name ?? user.email}
</span>
</Link>
</>
) : (
<>
<Link
href="/auth/login"
className="hidden rounded-[var(--cm-radius-xs)] px-3 py-2 text-[14px] text-[var(--cm-fg-secondary)] transition-colors hover:text-[var(--cm-fg)] md:inline-flex"
>
Sign in
</Link>
<Link
href="/auth/register"
className="inline-flex items-center gap-1.5 rounded-[var(--cm-radius-xs)] bg-[var(--cm-clay)] px-4 py-2 text-[14px] font-medium text-[var(--cm-fg)] transition-colors hover:bg-[var(--cm-clay-hover)]"
>
Start free
<span className="hidden sm:inline"></span>
</Link>
</>
)}
</div>
</div>
</header>