Compare commits

...
3 Commits
Author SHA1 Message Date
zeekayandClaude Opus 4.8 cf3a9cb1e9 fix(deploy): git BFF as /git/[...path] catch-all so it wins over the SPA page (8.4.74)
v8.4.73 moved the git BFF to a top-level /git path (right — /v1/* is gateway-routed
away from Next), but used STATIC route handlers (app/git/accounts, app/git/repos).
Origin verify (pod-direct) showed the (dashboard)/[...slug] catch-all PAGE shadows a
static sibling route handler → /git/accounts served the SPA HTML, not JSON.

Fix: use the PROVEN top-level-BFF shape — a [...path] CATCH-ALL route handler
(app/git/[...path]/route.ts), exactly like /cloud, /ai, /paas, /cms. A catch-all
route handler wins over the catch-all page. One handler dispatches /git/accounts +
/git/repos. Client paths unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 18:03:40 -07:00
zeekayandClaude Opus 4.8 b0f6d6dbdf fix(deploy): serve connect-git BFF at top-level /git/* (gateway shadows /v1/*) (8.4.73)
Live verify of v8.4.72 showed the deploy hub renders in dark Tamagui (hero,
Service/Static/Container target selector, App name/Project composer, Connect-a-Git
card, Start-from-a-template card — all present, no error boundary), but
/v1/git/accounts 404'd: console.hanzo.ai's ingress routes ALL /v1/* to
hanzoai/gateway (bypassing Next), so the /v1/git/* route handlers were shadowed and
hit cloud-api (no such route → 404) — the same class the v8.4.70 CMS fix documents.

Fix: move the git BFF to the top-level Next-served path /git/* (like /cloud, /ai,
/paas, /cms), and point the GitApi client at /git/{accounts,repos}. Verified
/git/accounts reaches Next (x-powered-by: Next.js). The connect-git dropdown now
resolves the IAM-linked GitHub token server-side and lists the user's real repos
(honest "Connect GitHub" when unlinked).

The 403 on /v1/platform/projects is the honest "platform access not provisioned for
this org" path — the hub degrades to new-project mode (no crash), by design.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 17:56:05 -07:00
zeekayandClaude Opus 4.8 2335e88426 feat(deploy): "Let's build something new" hub — uniform new-project/service/site deploy (8.4.72)
Bring console's deploy-a-new-project flow up to the hanzo.app/new experience: a
single "Deploy something new" hub (Tamagui, true-black dark) that leads with
repo→deploy against the REAL per-org Hanzo PaaS, plus a real connect-git dropdown
and the real template gallery.

- New "Deploy" entry (id: new) at the top of the Platform category → DeployHub.
- Composer: paste a Git repo URL (or image ref) + target selector — Service
  (git→build→run), Static site (git→static), Container (image→run). Each maps to a
  real PaasApi.createApp shape (buildType nixpacks/static/image); no invented targets.
  Deploy watches Queued→Building→Deploying→Live on the live RailwayDeploy pipeline.
- Connect-git dropdown is REAL: ports hanzo.app's /v1/git/{accounts,repos} BFF into
  console (resolves the IAM-linked GitHub token server-side via the cloud session
  cookie; token never reaches the browser). Honest "Connect GitHub" CTA when unlinked.
- Templates: real /v1/templates gallery with "Open in builder" (hanzo.app deep-link)
  and "Browse all" → in-console /templates.
- DRY: one deploy orchestration (paas/deploy.ts launchDeploy) shared by the hub and
  the Applications New-app form; pure per-target mapping in paas/logic.ts (unit-tested).
- 33 unit tests (logic targets/detectors + launchDeploy order + git relativeTime); tsc clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 17:29:35 -07:00
15 changed files with 1516 additions and 26 deletions
+68
View File
@@ -0,0 +1,68 @@
/**
* /git/* — the console's Git-import BFF (connected accounts + repositories).
*
* A `[...path]` catch-all route handler (the PROVEN top-level BFF shape used by
* `/cloud`, `/ai`, `/paas`, `/cms`): a catch-all route handler wins over the
* `(dashboard)/[...slug]` SPA page, whereas a STATIC route handler at the same depth
* is shadowed by it. Served at TOP-LEVEL `/git/*` (NOT `/v1/*`) because
* console.hanzo.ai's ingress routes `/v1/*` to hanzoai/gateway (bypassing Next), so
* a `/v1/git/*` handler would 404 at the cloud binary.
*
* Two heads, one handler:
* - GET /git/accounts → { connected, accounts }
* - GET /git/repos?account&q → { repos } (401 when not connected)
*
* The GitHub token is resolved from IAM server-side (the user's own session) and
* used only here — it never reaches the browser. Per-user data ⇒ no-store.
*/
import { type NextRequest, NextResponse } from 'next/server'
import { resolveGithubConnection, listAccounts, listRepos } from '~/lib/server/git'
export const runtime = 'nodejs'
const NO_STORE = { 'Cache-Control': 'no-store' } as const
type Ctx = { params: Promise<{ path: string[] }> }
export async function GET(req: NextRequest, ctx: Ctx) {
const head = (await ctx.params).path?.[0] ?? ''
const conn = await resolveGithubConnection(req)
// /git/accounts — the signed-in user's connected Git accounts. Not connected ⇒
// honest `{ connected: false, accounts: [] }` (drives the "Connect GitHub" CTA).
if (head === 'accounts') {
if (!conn) return NextResponse.json({ connected: false, accounts: [] }, { headers: NO_STORE })
let accounts
try {
accounts = await listAccounts(conn)
} catch {
return NextResponse.json(
{ connected: true, accounts: [], error: 'github unreachable' },
{ status: 502, headers: NO_STORE },
)
}
// A 401 from GitHub (token revoked/expired) ⇒ treat as not connected.
if (accounts === null) return NextResponse.json({ connected: false, accounts: [] }, { headers: NO_STORE })
return NextResponse.json({ connected: true, accounts }, { headers: NO_STORE })
}
// /git/repos?account=<login>&q=<search> — repositories for one account, newest-push
// first, server-side filtered. No linked token ⇒ 401 (the client falls back to the
// "Connect GitHub" CTA) — never a service-token leak.
if (head === 'repos') {
if (!conn) return NextResponse.json({ repos: [], connected: false }, { status: 401, headers: NO_STORE })
const account = req.nextUrl.searchParams.get('account')?.trim() || ''
const q = req.nextUrl.searchParams.get('q')?.trim() || ''
let repos
try {
repos = await listRepos(conn, account, q)
} catch {
return NextResponse.json({ repos: [], error: 'github unreachable' }, { status: 502, headers: NO_STORE })
}
if (repos === null) return NextResponse.json({ repos: [], connected: false }, { status: 401, headers: NO_STORE })
return NextResponse.json({ repos, connected: true }, { headers: NO_STORE })
}
return NextResponse.json({ error: 'not found' }, { status: 404, headers: NO_STORE })
}
+18
View File
@@ -336,3 +336,21 @@ html:root.t_light {
animation: none;
}
}
/* Hover lift — a subtle rise + border/background brighten on interactive cards and
rows (the deploy-hub aesthetic: true-black ground, hairline borders, hover lift).
Reduced-motion keeps the color transition but drops the transform. */
.hz-lift {
transition: transform 0.16s cubic-bezier(0.16, 1, 0.3, 1), border-color 0.16s ease, background-color 0.16s ease;
}
.hz-lift:hover {
transform: translateY(-1px);
}
@media (prefers-reduced-motion: reduce) {
.hz-lift {
transition: border-color 0.16s ease, background-color 0.16s ease;
}
.hz-lift:hover {
transform: none;
}
}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "@hanzo/console",
"version": "8.4.67",
"version": "8.4.72",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@hanzo/console",
"version": "8.4.67",
"version": "8.4.72",
"license": "BSD-3-Clause",
"dependencies": {
"@hanzo/dash": "0.3.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@hanzo/console",
"version": "8.4.71",
"version": "8.4.74",
"private": true,
"license": "BSD-3-Clause",
"author": "Hanzo AI <dev@hanzo.ai>",
+16
View File
@@ -0,0 +1,16 @@
'use client'
/**
* Deploy — the console's "Let's build something new" hub: the ONE uniform surface
* to deploy any new project/service/container/site. Thin route adapter over
* `DeployHub`, which leads with repo→deploy against the real per-org Hanzo PaaS
* (`/v1/platform/*`), a real connect-git dropdown (`/git/*`), and the real
* template gallery (`/v1/templates`).
*/
import { DeployHub } from './deploy/DeployHub'
export function DeployModule(props: { params: Record<string, string> }) {
return <DeployHub {...props} />
}
export default DeployModule
@@ -0,0 +1,680 @@
'use client'
/**
* Deploy hub — the console's "Let's build something new" surface, the uniform way
* to deploy ANY new project/service/container/site. Mirrors the hanzo.app/new hub
* (hero + composer + connect-git + templates) but native @hanzo/gui + true-black
* dark, and infra-first: it LEADS with repo→deploy against the REAL per-org Hanzo
* PaaS (`/v1/platform/*` via the `/cloud` bearer proxy), watching the deploy go
* Queued → Building → Deploying → Live on the live RailwayDeploy pipeline.
*
* Three real, uniform targets (each a shape `PaasApi.createApp` accepts — no
* invented targets): Service (git → build → run), Static site (git → static),
* Container (prebuilt image → run). The connect-git dropdown is REAL — it lists the
* signed-in user's GitHub repos via the same connected-git BFF (console-served at `/git`)
* contract hanzo.app serves (resolved from the IAM-linked token server-side), and
* degrades to an honest "Connect GitHub" CTA when GitHub isn't linked. The
* "describe an app" path links out to Hanzo Build (hanzo.app), which owns the AI
* app-builder. Templates are the real gallery (`/v1/templates`).
*
* Every state is honest — never a fabricated project/repo/template row.
*/
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useRouter } from 'next/navigation'
import { Button, Card, Input, ScrollView, Spinner, Text, XStack, YStack } from '@hanzo/gui'
import {
ArrowRight,
ArrowUpRight,
ExternalLink,
Github,
Globe,
LayoutTemplate,
Lock,
Package,
RefreshCw,
Rocket,
Search,
Server,
Sparkles,
X,
} from '@hanzogui/lucide-icons-2'
import { config } from '~/config'
import { PaasApi, type PaasProject } from '~/lib/api/paas'
import { fetchGitAccounts, fetchGitRepos, relativeTime, type GitAccount, type GitRepo } from '~/lib/api/git'
import { TemplatesApi, buildBuilderUrl, type Template } from '~/lib/api/templates'
import { classifyBackend, type BackendState } from '~/components/ui/BackendState'
import { PrimaryButton } from '~/components/ui/PrimaryButton'
import { FieldSelect } from '~/components/ui/Field'
import { RailwayDeploy } from '../paas/RailwayDeploy'
import { launchDeploy, type LaunchStep } from '../paas/deploy'
import {
classifyPaasError,
deriveAppName,
looksLikeGitUrl,
looksLikeImageRef,
targetIsGit,
type DeployTarget,
} from '../paas/logic'
const NEW_PROJECT = ' New project…'
const openExternal = (href: string) => {
if (typeof window !== 'undefined') window.open(href, '_blank', 'noopener,noreferrer')
}
/** The Hanzo Build (hanzo.app) deep-link that builds an app from a description. */
function aiBuildHref(text: string): string {
const base = config.appUrl.replace(/\/+$/, '')
const t = text.trim()
if (!t) return `${base}/new`
const url = new URL(`${base}/dev`)
url.searchParams.set('prompt', t)
return url.toString()
}
const TARGETS: { id: DeployTarget; label: string; icon: typeof Server; blurb: string }[] = [
{ id: 'service', label: 'Service', icon: Server, blurb: 'Build a Git repo and run it as a container service.' },
{ id: 'static', label: 'Static site', icon: Globe, blurb: 'Build a Git repo and serve it as a static site.' },
{ id: 'container', label: 'Container', icon: Package, blurb: 'Run a prebuilt container image as-is.' },
]
export function DeployHub(_props: { params: Record<string, string> }) {
const router = useRouter()
// Composer state (lifted so the connect-git dropdown can fill it) ─────────────
const [target, setTarget] = useState<DeployTarget>('service')
const [ref, setRef] = useState('')
const [branch, setBranch] = useState('main')
const [appName, setAppName] = useState('')
const [appNameTouched, setAppNameTouched] = useState(false)
const [projects, setProjects] = useState<PaasProject[]>([])
const [projectChoice, setProjectChoice] = useState<string>(NEW_PROJECT)
const [newProjectName, setNewProjectName] = useState('')
const [phase, setPhase] = useState<'idle' | 'working' | 'error' | 'watching'>('idle')
const [step, setStep] = useState<LaunchStep>('project')
const [errMsg, setErrMsg] = useState('')
const [watch, setWatch] = useState<{ project: string; app: string } | null>(null)
const composerRef = useRef<HTMLDivElement | null>(null)
const loadProjects = useCallback(() => {
PaasApi.listProjects()
.then((ps) => {
setProjects(ps)
setProjectChoice((prev) => (prev === NEW_PROJECT && ps.length > 0 ? (ps[0].name || ps[0].slug) : prev))
})
.catch(() => setProjects([]))
}, [])
useEffect(() => {
loadProjects()
}, [loadProjects])
// Set the deploy source; auto-derive the app name until the user edits it.
const setSource = useCallback(
(v: string) => {
setRef(v)
if (!appNameTouched) {
const d = deriveAppName(v)
if (d) setAppName(d)
}
},
[appNameTouched],
)
// Select a repo from the connect-git dropdown → fill the composer for a service.
const pickRepo = useCallback((repo: GitRepo) => {
setTarget('service')
setRef(repo.cloneUrl)
setBranch(repo.defaultBranch || 'main')
setAppName(deriveAppName(repo.name) || repo.name)
setAppNameTouched(false)
setPhase('idle')
setErrMsg('')
if (typeof window !== 'undefined' && composerRef.current) {
composerRef.current.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
}, [])
const creatingProject = projectChoice === NEW_PROJECT || projects.length === 0
const projectOptions = useMemo(() => [...projects.map((p) => p.name || p.slug), NEW_PROJECT], [projects])
const isGit = targetIsGit(target)
const refLooksRight = ref.trim() === '' || (isGit ? looksLikeGitUrl(ref) : looksLikeImageRef(ref))
// Only a description (NL) — not a URL and not an image ref — offers the AI path.
const looksLikeDescription = ref.trim() !== '' && !looksLikeGitUrl(ref) && !looksLikeImageRef(ref)
const valid =
appName.trim() !== '' &&
ref.trim() !== '' &&
(creatingProject ? newProjectName.trim() !== '' : projectChoice !== '')
const deploy = () => {
if (!valid || phase === 'working') return
setPhase('working')
setErrMsg('')
const chosen = projects.find((p) => (p.name || p.slug) === projectChoice)
launchDeploy(
{
projectSlug: creatingProject ? undefined : chosen?.slug || chosen?.id || projectChoice,
newProjectName: creatingProject ? newProjectName.trim() : undefined,
appName: appName.trim(),
target,
ref: ref.trim(),
branch: branch.trim() || 'main',
},
setStep,
)
.then(({ project, app }) => {
setWatch({ project, app })
setPhase('watching')
})
.catch((e) => {
const { kind, message } = classifyPaasError(e)
setPhase('error')
setErrMsg(
kind === 'signin'
? 'Sign in to deploy.'
: kind === 'forbidden'
? 'Deploying requires platform access for your organization.'
: message || 'Deploy failed. Check the inputs and retry.',
)
})
}
const resetComposer = () => {
setPhase('idle')
setWatch(null)
setRef('')
setAppName('')
setAppNameTouched(false)
setErrMsg('')
loadProjects()
}
const busy = phase === 'working'
const stepLabel = step === 'project' ? 'Preparing project…' : step === 'app' ? 'Creating app…' : 'Deploying…'
return (
<YStack gap="$5">
<Hero />
{/* Primary composer */}
<div ref={composerRef}>
{phase === 'watching' && watch ? (
<DeployWatchCard
project={watch.project}
app={watch.app}
onView={() => router.push('/applications')}
onAnother={resetComposer}
/>
) : (
<Card
className="hz-lift"
p="$4"
gap="$3.5"
borderWidth={1}
borderColor="$borderColor"
bg="$color1"
rounded="$6"
hoverStyle={{ borderColor: '$color7' }}
>
<TargetSelector target={target} onChange={setTarget} disabled={busy} />
{/* The source input — a Git repo URL (service/static) or an image ref. */}
<YStack gap="$2">
<XStack
items="center"
gap="$2"
px="$3"
bg="$color2"
borderWidth={1}
borderColor={ref.trim() && !refLooksRight ? '$yellow7' : '$borderColor'}
rounded="$4"
>
{isGit ? <Github size={17} color="$color10" /> : <Package size={17} color="$color10" />}
<Input
unstyled
flex={1}
py="$3"
fontSize="$4"
color="$color12"
autoCapitalize="none"
autoCorrect={false}
placeholder={isGit ? 'https://github.com/org/repo' : 'ghcr.io/org/app:tag'}
value={ref}
onChangeText={setSource}
disabled={busy}
/>
{ref ? (
<Button chromeless circular size="$1" icon={<X size={14} />} onPress={() => setSource('')} aria-label="Clear" disabled={busy} />
) : null}
</XStack>
{looksLikeDescription ? (
<XStack items="center" gap="$2" flexWrap="wrap">
<Sparkles size={14} color="$color10" />
<Text fontSize="$2" color="$color10">
That looks like a description, not a repo.
</Text>
<Button size="$1" chromeless iconAfter={<ExternalLink size={12} />} onPress={() => openExternal(aiBuildHref(ref))}>
<Text fontSize="$2" color="$color12" fontWeight="700">Build it with AI</Text>
</Button>
</XStack>
) : (
<Text fontSize="$1" color="$color10">
{TARGETS.find((t) => t.id === target)?.blurb}
</Text>
)}
</YStack>
{/* App name + branch (git) */}
<XStack gap="$3" flexWrap="wrap">
<YStack flex={1} minW={200} gap="$1.5">
<Text fontSize="$2" color="$color11" fontWeight="600">App name</Text>
<Input
size="$3"
value={appName}
onChangeText={(v) => { setAppName(v); setAppNameTouched(true) }}
placeholder="web"
autoCapitalize="none"
disabled={busy}
/>
</YStack>
{isGit ? (
<YStack width={180} minW={140} gap="$1.5">
<Text fontSize="$2" color="$color11" fontWeight="600">Branch</Text>
<Input size="$3" value={branch} onChangeText={setBranch} placeholder="main" autoCapitalize="none" disabled={busy} />
</YStack>
) : null}
</XStack>
{/* Project */}
<XStack gap="$3" flexWrap="wrap">
<YStack flex={1} minW={200} gap="$1.5">
<Text fontSize="$2" color="$color11" fontWeight="600">Project</Text>
{projects.length > 0 ? (
<FieldSelect value={projectChoice} options={projectOptions} onChange={setProjectChoice} disabled={busy} />
) : (
<Input size="$3" value={newProjectName} onChangeText={setNewProjectName} placeholder="my-project" autoCapitalize="none" disabled={busy} />
)}
</YStack>
{creatingProject && projects.length > 0 ? (
<YStack flex={1} minW={200} gap="$1.5">
<Text fontSize="$2" color="$color11" fontWeight="600">New project name</Text>
<Input size="$3" value={newProjectName} onChangeText={setNewProjectName} placeholder="my-project" autoCapitalize="none" disabled={busy} />
</YStack>
) : null}
</XStack>
{phase === 'error' ? <Text fontSize="$2" color="$red10">{errMsg}</Text> : null}
<XStack items="center" gap="$3" flexWrap="wrap">
<PrimaryButton
size="$4"
icon={busy ? <Spinner size="small" /> : <Rocket size={17} />}
onPress={deploy}
disabled={!valid || busy}
>
{busy ? stepLabel : 'Deploy'}
</PrimaryButton>
<Text fontSize="$1" color="$color10">
{target === 'container'
? 'Applies the image and goes live.'
: 'Builds from source in-cluster (BuildKit), then goes live.'}
</Text>
</XStack>
</Card>
)}
</div>
{/* Two-column: connect a repo · start from a template */}
<XStack gap="$4" flexWrap="wrap" items="flex-start">
<YStack flex={1} minW={300}>
<GitConnect onPick={pickRepo} />
</YStack>
<YStack flex={1} minW={300}>
<TemplatePicker onBrowseAll={() => router.push('/templates')} />
</YStack>
</XStack>
</YStack>
)
}
// ── Hero ─────────────────────────────────────────────────────────────────────
function Hero() {
return (
<YStack position="relative" items="center" pt="$4" pb="$2" gap="$3">
{/* Ambient radial glow behind the hero (decorative, non-interactive). */}
<div
aria-hidden
style={{
position: 'absolute',
top: -40,
left: '50%',
transform: 'translateX(-50%)',
width: 720,
maxWidth: '100%',
height: 260,
pointerEvents: 'none',
background: 'radial-gradient(60% 60% at 50% 0%, rgba(255,255,255,0.07), transparent 70%)',
}}
/>
<YStack items="center" gap="$3" maxW={720} className="hz-fade-up">
<XStack items="center" gap="$2" px="$3" py="$1.5" rounded="$10" borderWidth={1} borderColor="$borderColor" bg="$color2">
<Rocket size={13} color="$color11" />
<Text fontSize="$2" color="$color11" fontWeight="700">Deploy</Text>
</XStack>
<Text fontSize={36} lineHeight={42} fontWeight="800" text="center" letterSpacing={-0.5} $md={{ fontSize: 50, lineHeight: 56 }}>
Let&rsquo;s build something new
</Text>
<Text fontSize="$4" color="$color11" text="center" maxW={560}>
Paste a Git repository to deploy it as a service, container, or static site or start from a template.
Hanzo builds it in-cluster, ships it, and runs it, per organization.
</Text>
</YStack>
</YStack>
)
}
// ── Target selector ──────────────────────────────────────────────────────────
function TargetSelector({ target, onChange, disabled }: { target: DeployTarget; onChange: (t: DeployTarget) => void; disabled?: boolean }) {
return (
<XStack gap="$2" flexWrap="wrap">
{TARGETS.map((t) => {
const active = t.id === target
const Icon = t.icon
return (
<Button
key={t.id}
size="$3"
onPress={() => onChange(t.id)}
disabled={disabled}
bg={active ? '$color5' : 'transparent'}
borderWidth={1}
borderColor={active ? '$color8' : '$borderColor'}
icon={<Icon size={15} />}
hoverStyle={{ borderColor: '$color8' }}
aria-label={t.label}
>
{t.label}
</Button>
)
})}
</XStack>
)
}
// ── Deploy watch (the live pipeline hand-off) ────────────────────────────────
function DeployWatchCard({ project, app, onView, onAnother }: { project: string; app: string; onView: () => void; onAnother: () => void }) {
const [live, setLive] = useState(false)
return (
<Card p="$4" gap="$3.5" borderWidth={1} borderColor="$borderColor" bg="$color1" rounded="$6">
<XStack items="center" justify="space-between" gap="$3" flexWrap="wrap">
<Text fontSize="$6" fontWeight="800">{live ? 'Your app is live' : 'Deploying your app'}</Text>
<XStack gap="$2">
<Button size="$3" onPress={onAnother}>Deploy another</Button>
<PrimaryButton size="$3" onPress={onView}>{live ? 'View app' : 'View apps'}</PrimaryButton>
</XStack>
</XStack>
<RailwayDeploy projectSlug={project} appSlug={app} status="queued" onLive={() => setLive(true)} />
<Text fontSize="$1" color="$color10">
{live
? 'It appears in Applications with its status, source, and live URL.'
: 'This tracks the live deployment status — Queued → Building → Deploying → Live.'}
</Text>
</Card>
)
}
// ── Connect a Git repository (real /git/* BFF) ────────────────────────────
type GitState =
| { phase: 'loading' }
| { phase: 'disconnected' }
| { phase: 'error' }
| { phase: 'ready'; accounts: GitAccount[] }
function GitConnect({ onPick }: { onPick: (repo: GitRepo) => void }) {
const [state, setState] = useState<GitState>({ phase: 'loading' })
const [account, setAccount] = useState('')
const [q, setQ] = useState('')
const [repos, setRepos] = useState<GitRepo[] | null>(null)
const [loadingRepos, setLoadingRepos] = useState(false)
const loadAccounts = useCallback(() => {
setState({ phase: 'loading' })
fetchGitAccounts()
.then((r) => {
if (!r.connected || r.accounts.length === 0) {
setState({ phase: 'disconnected' })
return
}
setState({ phase: 'ready', accounts: r.accounts })
setAccount((prev) => prev || r.accounts[0].login)
})
.catch(() => setState({ phase: 'error' }))
}, [])
useEffect(() => {
loadAccounts()
}, [loadAccounts])
// Load repos for the selected account (debounced on the search query).
useEffect(() => {
if (state.phase !== 'ready' || !account) return
let cancelled = false
setLoadingRepos(true)
const h = setTimeout(() => {
fetchGitRepos(account, q)
.then((rs) => { if (!cancelled) setRepos(rs) })
.finally(() => { if (!cancelled) setLoadingRepos(false) })
}, q ? 250 : 0)
return () => { cancelled = true; clearTimeout(h) }
}, [state.phase, account, q])
const accountLogins = state.phase === 'ready' ? state.accounts.map((a) => a.login) : []
return (
<Card p="$4" gap="$3" borderWidth={1} borderColor="$borderColor" bg="$color1" rounded="$5" height="100%">
<XStack items="center" justify="space-between" gap="$2">
<XStack items="center" gap="$2" minW={0}>
<Github size={16} />
<Text fontSize="$4" fontWeight="700" numberOfLines={1}>Connect a Git repository</Text>
</XStack>
{state.phase === 'ready' ? (
<Button size="$1" chromeless icon={<RefreshCw size={13} />} onPress={loadAccounts} aria-label="Refresh repositories" />
) : null}
</XStack>
{state.phase === 'loading' ? (
<XStack items="center" gap="$2" py="$3"><Spinner size="small" /><Text fontSize="$2" color="$color10">Checking GitHub</Text></XStack>
) : state.phase === 'disconnected' ? (
<YStack gap="$3" py="$2">
<Text fontSize="$2" color="$color11">
Link your GitHub account to pick a repository and deploy it in one click. Your repos load here once connected.
</Text>
<Button
size="$3"
self="flex-start"
icon={<Github size={15} />}
iconAfter={<ExternalLink size={13} />}
borderWidth={1}
borderColor="$borderColor"
hoverStyle={{ borderColor: '$color8' }}
onPress={() => openExternal(`${config.iamUrl.replace(/\/+$/, '')}/account`)}
>
Connect GitHub
</Button>
<Text fontSize="$1" color="$color10">Or paste any repository URL in the composer above.</Text>
</YStack>
) : state.phase === 'error' ? (
<YStack gap="$2" py="$2">
<Text fontSize="$2" color="$color11">Couldn&rsquo;t reach GitHub. Retry, or paste a repository URL above.</Text>
<Button size="$2" self="flex-start" icon={<RefreshCw size={13} />} onPress={loadAccounts}>Retry</Button>
</YStack>
) : (
<YStack gap="$2.5">
<XStack gap="$2" flexWrap="wrap">
<YStack flex={1} minW={140}>
<FieldSelect value={account} options={accountLogins} onChange={setAccount} />
</YStack>
<XStack flex={2} minW={160} items="center" gap="$2" px="$3" bg="$color2" borderWidth={1} borderColor="$borderColor" rounded="$4">
<Search size={14} color="$color10" />
<Input unstyled flex={1} py="$2" fontSize="$3" placeholder="Search repositories…" value={q} onChangeText={setQ} autoCapitalize="none" autoCorrect={false} />
{q ? <Button chromeless circular size="$1" icon={<X size={12} />} onPress={() => setQ('')} aria-label="Clear" /> : null}
</XStack>
</XStack>
{loadingRepos && repos === null ? (
<XStack items="center" gap="$2" py="$3"><Spinner size="small" /><Text fontSize="$2" color="$color10">Loading repositories</Text></XStack>
) : repos && repos.length > 0 ? (
<ScrollView style={{ maxHeight: 320 }}>
<YStack gap="$1.5">
{repos.map((r) => (
<XStack
key={r.fullName}
className="hz-lift"
items="center"
gap="$2"
p="$2.5"
borderWidth={1}
borderColor="$borderColor"
rounded="$4"
cursor="pointer"
hoverStyle={{ borderColor: '$color8', bg: '$color2' }}
onPress={() => onPick(r)}
aria-label={`Deploy ${r.fullName}`}
>
<YStack minW={0} flex={1} gap="$0.5">
<XStack items="center" gap="$1.5" minW={0}>
<Text fontSize="$3" fontWeight="600" color="$color12" numberOfLines={1}>{r.name}</Text>
{r.private ? <Lock size={11} color="$color10" /> : null}
</XStack>
<Text fontSize="$1" color="$color10" numberOfLines={1}>
{r.fullName}{r.language ? ` · ${r.language}` : ''}{r.pushedAt ? ` · ${relativeTime(r.pushedAt)}` : ''}
</Text>
</YStack>
<ArrowRight size={15} color="$color10" />
</XStack>
))}
</YStack>
</ScrollView>
) : (
<Text fontSize="$2" color="$color10" py="$2">{q ? `No repositories match “${q}”.` : 'No repositories found for this account.'}</Text>
)}
</YStack>
)}
</Card>
)
}
// ── Start from a template (real /v1/templates gallery) ───────────────────────
type TplState =
| { phase: 'loading' }
| { phase: 'error'; error: BackendState }
| { phase: 'ready'; templates: Template[] }
function TemplatePicker({ onBrowseAll }: { onBrowseAll: () => void }) {
const [state, setState] = useState<TplState>({ phase: 'loading' })
const [q, setQ] = useState('')
const load = useCallback(() => {
setState({ phase: 'loading' })
TemplatesApi.list()
.then((templates) => setState({ phase: 'ready', templates }))
.catch((e) => setState({ phase: 'error', error: classifyBackend(e) }))
}, [])
useEffect(() => {
load()
}, [load])
const all = state.phase === 'ready' ? state.templates : []
const filtered = useMemo(() => {
const needle = q.trim().toLowerCase()
if (!needle) return all
return all.filter(
(t) =>
t.title.toLowerCase().includes(needle) ||
(t.description ?? '').toLowerCase().includes(needle) ||
(t.framework ?? '').toLowerCase().includes(needle) ||
t.category.toLowerCase().includes(needle),
)
}, [all, q])
return (
<Card p="$4" gap="$3" borderWidth={1} borderColor="$borderColor" bg="$color1" rounded="$5" height="100%">
<XStack items="center" justify="space-between" gap="$2">
<XStack items="center" gap="$2" minW={0}>
<LayoutTemplate size={16} />
<Text fontSize="$4" fontWeight="700" numberOfLines={1}>Start from a template</Text>
</XStack>
<Button size="$1" chromeless iconAfter={<ArrowUpRight size={13} />} onPress={onBrowseAll}>
<Text fontSize="$2" color="$color11" fontWeight="700">Browse all</Text>
</Button>
</XStack>
{state.phase === 'loading' ? (
<XStack items="center" gap="$2" py="$3"><Spinner size="small" /><Text fontSize="$2" color="$color10">Loading templates</Text></XStack>
) : state.phase === 'error' ? (
<YStack gap="$2" py="$2" borderWidth={1} borderColor="$yellow7" bg="$yellow2" rounded="$4" p="$3">
<Text fontSize="$2" color="$yellow11">The template gallery is unreachable right now.</Text>
<Button size="$2" self="flex-start" icon={<RefreshCw size={13} />} onPress={load}>Retry</Button>
</YStack>
) : all.length === 0 ? (
<Text fontSize="$2" color="$color10" py="$2">No templates available yet.</Text>
) : (
<YStack gap="$2.5">
<XStack items="center" gap="$2" px="$3" bg="$color2" borderWidth={1} borderColor="$borderColor" rounded="$4">
<Search size={14} color="$color10" />
<Input unstyled flex={1} py="$2" fontSize="$3" placeholder="Search templates…" value={q} onChangeText={setQ} autoCapitalize="none" autoCorrect={false} />
{q ? <Button chromeless circular size="$1" icon={<X size={12} />} onPress={() => setQ('')} aria-label="Clear" /> : null}
</XStack>
{filtered.length === 0 ? (
<Text fontSize="$2" color="$color10" py="$2">No templates match {q}.</Text>
) : (
<ScrollView style={{ maxHeight: 320 }}>
<YStack gap="$1.5">
{filtered.slice(0, 24).map((t) => (
<XStack
key={t.slug}
className="hz-lift"
items="center"
gap="$2"
p="$2.5"
borderWidth={1}
borderColor="$borderColor"
rounded="$4"
cursor="pointer"
hoverStyle={{ borderColor: '$color8', bg: '$color2' }}
onPress={() => openExternal(buildBuilderUrl(t, '', config.appUrl))}
aria-label={`Open ${t.title} in the builder`}
>
<YStack width={30} height={30} items="center" justify="center" rounded="$3" bg="$color3">
<LayoutTemplate size={15} color="$color11" />
</YStack>
<YStack minW={0} flex={1} gap="$0.5">
<Text fontSize="$3" fontWeight="600" color="$color12" numberOfLines={1}>{t.title}</Text>
<Text fontSize="$1" color="$color10" numberOfLines={1}>
{[t.framework, t.category].filter(Boolean).join(' · ') || t.category}
</Text>
</YStack>
<Sparkles size={14} color="$color10" />
</XStack>
))}
</YStack>
</ScrollView>
)}
</YStack>
)}
</Card>
)
}
@@ -26,6 +26,7 @@ import { StatusTag } from '~/components/ui/StatusTag'
import { PrimaryButton } from '~/components/ui/PrimaryButton'
import { FieldRow, FieldText, FieldSelect } from '~/components/ui/Field'
import { appUrl, appSource, orderDeployments, buildStatusOf, deploymentLabel, classifyPaasError } from './logic'
import { launchDeploy, type LaunchStep } from './deploy'
import { DomainsPanel } from './DomainsPanel'
import { RailwayDeploy } from './RailwayDeploy'
@@ -215,30 +216,29 @@ function NewAppForm({ projects, onCancel, onDeployed }: { projects: PaasProject[
const deploy = async () => {
if (!valid) return
const stepText: Record<LaunchStep, string> = {
project: 'Preparing project…',
app: 'Creating app…',
deploy: 'Deploying…',
}
try {
setState({ phase: 'working', step: 'Preparing project…' })
let projectSlug: string
if (creatingProject) {
const p = await PaasApi.createProject({ name: newProjectName.trim() })
projectSlug = p.slug || p.id
} else {
const chosen = projects.find((p) => (p.name || p.slug) === projectChoice)
projectSlug = chosen?.slug || chosen?.id || projectChoice
}
setState({ phase: 'working', step: 'Creating app…' })
const app = await PaasApi.createApp(projectSlug, {
name: appName.trim(),
source,
...(source === 'git'
? { repo: { url: repoUrl.trim(), branch: branch.trim() || 'main' } }
: { image: { repository: imageRepo.trim(), tag: imageTag.trim() || 'latest' } }),
})
setState({ phase: 'working', step: 'Deploying…' })
await PaasApi.deploy(projectSlug, app.slug || app.id, source === 'image' ? { tag: imageTag.trim() || 'latest' } : {})
// ONE deploy orchestration (shared with the Deploy hub): resolve/create the
// project → create the app for the target → kick off the deploy. A git app is
// a `service` target; an image app is a `container` target.
const chosen = projects.find((p) => (p.name || p.slug) === projectChoice)
const { project, app } = await launchDeploy(
{
projectSlug: creatingProject ? undefined : chosen?.slug || chosen?.id || projectChoice,
newProjectName: creatingProject ? newProjectName.trim() : undefined,
appName: appName.trim(),
target: source === 'git' ? 'service' : 'container',
ref: source === 'git' ? repoUrl.trim() : `${imageRepo.trim()}:${imageTag.trim() || 'latest'}`,
branch: branch.trim() || 'main',
},
(s) => setState({ phase: 'working', step: stepText[s] }),
)
// Hand off to the live pipeline — watch it go Queued → Building → Deploying → Live.
setState({ phase: 'watching', project: projectSlug, app: app.slug || app.id })
setState({ phase: 'watching', project, app })
} catch (e) {
const { kind, message } = classifyPaasError(e)
setState({
@@ -0,0 +1,62 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { PaasApi } from '~/lib/api/paas'
import { launchDeploy, type LaunchStep } from './deploy'
vi.mock('~/lib/api/paas', () => ({
PaasApi: {
createProject: vi.fn(),
createApp: vi.fn(),
deploy: vi.fn(),
},
}))
const mockApi = PaasApi as unknown as {
createProject: ReturnType<typeof vi.fn>
createApp: ReturnType<typeof vi.fn>
deploy: ReturnType<typeof vi.fn>
}
beforeEach(() => {
vi.clearAllMocks()
mockApi.createProject.mockResolvedValue({ id: 'p1', slug: 'proj', name: 'proj' })
mockApi.createApp.mockResolvedValue({ id: 'a1', slug: 'web' })
mockApi.deploy.mockResolvedValue({ id: 'd1' })
})
describe('launchDeploy', () => {
it('creates a new project, then the app, then deploys — in order — for a service', async () => {
const steps: LaunchStep[] = []
const out = await launchDeploy(
{ newProjectName: 'proj', appName: 'web', target: 'service', ref: 'https://github.com/o/r', branch: 'main' },
(s) => steps.push(s),
)
expect(steps).toEqual(['project', 'app', 'deploy'])
expect(mockApi.createProject).toHaveBeenCalledWith({ name: 'proj' })
expect(mockApi.createApp).toHaveBeenCalledWith('proj', {
name: 'web',
source: 'git',
repo: { url: 'https://github.com/o/r', branch: 'main' },
buildType: 'nixpacks',
})
expect(mockApi.deploy).toHaveBeenCalledWith('proj', 'web', {})
expect(out).toEqual({ project: 'proj', app: 'web' })
})
it('uses an existing project (no createProject) for a container image', async () => {
await launchDeploy({ projectSlug: 'existing', appName: 'api', target: 'container', ref: 'ghcr.io/o/app:2.0' })
expect(mockApi.createProject).not.toHaveBeenCalled()
expect(mockApi.createApp).toHaveBeenCalledWith('existing', {
name: 'api',
source: 'image',
image: { repository: 'ghcr.io/o/app', tag: '2.0' },
buildType: 'image',
})
expect(mockApi.deploy).toHaveBeenCalledWith('existing', 'web', { tag: '2.0' })
})
it('throws when neither an existing nor a new project is given', async () => {
await expect(launchDeploy({ appName: 'x', target: 'service', ref: 'https://github.com/o/r' })).rejects.toThrow()
expect(mockApi.createApp).not.toHaveBeenCalled()
})
})
+73
View File
@@ -0,0 +1,73 @@
/**
* Deploy orchestration — the ONE way the console launches a new app on the live
* Hanzo PaaS (`/v1/platform/*` via the `/cloud` bearer proxy). Both the "Deploy
* something new" hub and the Applications board's New-app form drive this, so the
* three real steps (resolve/create project → create app → kick off the deploy)
* live in exactly one place.
*
* Pure inputs → real I/O against `PaasApi`; the pure per-target mapping is in
* `logic.ts` (unit-tested), so this file is the thin I/O shell. It returns the
* `{ project, app }` slugs the caller hands to `RailwayDeploy` to WATCH the deploy
* go Queued → Building → Deploying → Live.
*/
import { PaasApi } from '~/lib/api/paas'
import { createAppInputFor, deployInputFor, type DeployTarget } from './logic'
/** The composer's resolved values for one launch. */
export interface LaunchInput {
/** An existing project's slug/id to deploy into (mutually exclusive with `newProjectName`). */
projectSlug?: string
/** Create a new project with this name first (when no existing project chosen). */
newProjectName?: string
/** The app name (required). */
appName: string
/** Which target — service | static | container. */
target: DeployTarget
/** The git repo URL (service/static) OR the image ref (container). */
ref: string
/** Git branch (service/static only). */
branch?: string
}
/** The slugs to watch the live deployment on. */
export interface Launched {
project: string
app: string
}
/** Report which step is running (for a live "Preparing…/Creating…/Deploying…" label). */
export type LaunchStep = 'project' | 'app' | 'deploy'
/**
* Launch a deploy: (1) resolve the project (create it when `newProjectName` is
* given), (2) create the app for the chosen target, (3) kick off the deploy.
* Returns the project + app slugs so the caller can watch the pipeline. Throws the
* underlying `ApiError` on any step (the caller classifies it honestly).
*/
export async function launchDeploy(
input: LaunchInput,
onStep?: (step: LaunchStep) => void,
): Promise<Launched> {
onStep?.('project')
let projectSlug: string
const newName = input.newProjectName?.trim()
if (newName) {
const p = await PaasApi.createProject({ name: newName })
projectSlug = p.slug || p.id
} else {
projectSlug = (input.projectSlug || '').trim()
if (!projectSlug) throw new Error('No project selected')
}
onStep?.('app')
const app = await PaasApi.createApp(
projectSlug,
createAppInputFor(input.target, { name: input.appName, ref: input.ref, branch: input.branch }),
)
const appSlug = app.slug || app.id
onStep?.('deploy')
await PaasApi.deploy(projectSlug, appSlug, deployInputFor(input.target, input.ref))
return { project: projectSlug, app: appSlug }
}
+118
View File
@@ -14,6 +14,14 @@ import {
canRemoveDomain,
domainStatusLabel,
orderDomains,
targetIsGit,
buildTypeFor,
looksLikeGitUrl,
looksLikeImageRef,
parseImageRef,
deriveAppName,
createAppInputFor,
deployInputFor,
} from './logic'
describe('appUrl', () => {
@@ -105,3 +113,113 @@ describe('classifyPaasError — a signed-in user is never told to sign in', () =
expect(classifyPaasError(new ApiError('x', 500)).kind).toBe('error')
})
})
// ── Deploy targets (the "Deploy something new" composer) ─────────────────────
describe('targetIsGit / buildTypeFor', () => {
it('service + static are git; container is image', () => {
expect(targetIsGit('service')).toBe(true)
expect(targetIsGit('static')).toBe(true)
expect(targetIsGit('container')).toBe(false)
})
it('maps each target to the platform buildType (closed set)', () => {
expect(buildTypeFor('service')).toBe('nixpacks')
expect(buildTypeFor('static')).toBe('static')
expect(buildTypeFor('container')).toBe('image')
})
})
describe('looksLikeGitUrl', () => {
it('accepts https repos on the allowed hosts and any .git URL', () => {
expect(looksLikeGitUrl('https://github.com/hanzoai/console')).toBe(true)
expect(looksLikeGitUrl('https://github.com/hanzoai/console.git')).toBe(true)
expect(looksLikeGitUrl('https://gitlab.com/group/proj')).toBe(true)
expect(looksLikeGitUrl('https://bitbucket.org/team/repo')).toBe(true)
expect(looksLikeGitUrl('https://git.example.com/x/y.git')).toBe(true)
expect(looksLikeGitUrl('git@github.com:hanzoai/console.git')).toBe(true)
})
it('rejects a description, an image ref, or an empty string', () => {
expect(looksLikeGitUrl('')).toBe(false)
expect(looksLikeGitUrl('a todo app with auth')).toBe(false)
expect(looksLikeGitUrl('ghcr.io/hanzoai/app:1.2.3')).toBe(false)
expect(looksLikeGitUrl('https://example.com/not/a/known/host')).toBe(false)
})
})
describe('looksLikeImageRef', () => {
it('accepts registry paths and bare names, with optional tag/digest', () => {
expect(looksLikeImageRef('ghcr.io/hanzoai/app:1.2.3')).toBe(true)
expect(looksLikeImageRef('ghcr.io/hanzoai/app')).toBe(true)
expect(looksLikeImageRef('nginx')).toBe(true)
expect(looksLikeImageRef('localhost:5000/app:v1')).toBe(true)
expect(looksLikeImageRef('nginx@sha256:abc123')).toBe(true)
})
it('rejects a URL, whitespace, or empty', () => {
expect(looksLikeImageRef('')).toBe(false)
expect(looksLikeImageRef('https://github.com/org/repo')).toBe(false)
expect(looksLikeImageRef('two words')).toBe(false)
})
})
describe('parseImageRef', () => {
it('splits repository + tag, defaulting to latest', () => {
expect(parseImageRef('ghcr.io/hanzoai/app:1.2.3')).toEqual({ repository: 'ghcr.io/hanzoai/app', tag: '1.2.3' })
expect(parseImageRef('ghcr.io/hanzoai/app')).toEqual({ repository: 'ghcr.io/hanzoai/app', tag: 'latest' })
expect(parseImageRef('nginx')).toEqual({ repository: 'nginx', tag: 'latest' })
})
it('does not mistake a registry host:port for a tag', () => {
expect(parseImageRef('localhost:5000/app:v1')).toEqual({ repository: 'localhost:5000/app', tag: 'v1' })
expect(parseImageRef('localhost:5000/app')).toEqual({ repository: 'localhost:5000/app', tag: 'latest' })
})
})
describe('deriveAppName', () => {
it('derives a k8s-safe name from a repo URL', () => {
expect(deriveAppName('https://github.com/hanzoai/My-Console.git')).toBe('my-console')
expect(deriveAppName('git@github.com:hanzoai/console.git')).toBe('console')
expect(deriveAppName('https://gitlab.com/group/sub/Cool_App')).toBe('cool-app')
})
it('derives from an image ref (dropping the tag/digest)', () => {
expect(deriveAppName('ghcr.io/hanzoai/app:1.2.3')).toBe('app')
expect(deriveAppName('nginx@sha256:abc')).toBe('nginx')
})
it('returns empty for empty/all-symbol input', () => {
expect(deriveAppName('')).toBe('')
expect(deriveAppName(' ')).toBe('')
})
})
describe('createAppInputFor', () => {
it('service → git source, nixpacks build, repo+branch', () => {
expect(createAppInputFor('service', { name: 'web', ref: 'https://github.com/o/r', branch: 'dev' })).toEqual({
name: 'web',
source: 'git',
repo: { url: 'https://github.com/o/r', branch: 'dev' },
buildType: 'nixpacks',
})
})
it('static → git source, static build, branch defaults to main', () => {
expect(createAppInputFor('static', { name: 'site', ref: 'https://github.com/o/r' })).toEqual({
name: 'site',
source: 'git',
repo: { url: 'https://github.com/o/r', branch: 'main' },
buildType: 'static',
})
})
it('container → image source, image build, parsed repository+tag', () => {
expect(createAppInputFor('container', { name: 'api', ref: 'ghcr.io/o/app:1.0' })).toEqual({
name: 'api',
source: 'image',
image: { repository: 'ghcr.io/o/app', tag: '1.0' },
buildType: 'image',
})
})
})
describe('deployInputFor', () => {
it('pins the tag for a container, empty body for git', () => {
expect(deployInputFor('container', 'ghcr.io/o/app:2.0')).toEqual({ tag: '2.0' })
expect(deployInputFor('service', 'https://github.com/o/r')).toEqual({})
expect(deployInputFor('static', 'https://github.com/o/r')).toEqual({})
})
})
+118 -1
View File
@@ -4,7 +4,7 @@
* unit-testable. The board (`PaasApplications.tsx`) is a thin shell over these.
*/
import { ApiError } from '~/lib/api/client'
import type { PaasApp, PaasDeployment, PaasDomain } from '~/lib/api/paas'
import type { CreateAppInput, DeployInput, PaasApp, PaasDeployment, PaasDomain } from '~/lib/api/paas'
/** The app's primary live URL — the first domain, made absolute (https). Null when none. */
export function appUrl(app: Pick<PaasApp, 'domains'>): string | null {
@@ -100,3 +100,120 @@ export function classifyPaasError(e: unknown): { kind: PaasErrorKind; message: s
if (status === 404) return { kind: 'unavailable', message }
return { kind: 'error', message }
}
// ── Deploy targets (the "Deploy something new" composer) ─────────────────────
//
// One composer, three REAL targets that map onto the platform's own build
// strategies (`clients/platform` `buildTypes` = {nixpacks, dockerfile, static,
// buildpacks, image}). A target is purely a UX framing over `createApp`:
// - service → a git repo, auto-built (nixpacks) into a running container.
// - static → a git repo, built as a static site (buildType 'static').
// - container → a prebuilt image, run as-is (source 'image').
// No invented targets — every one is a shape `PaasApi.createApp` accepts.
/** A deploy target the composer offers. */
export type DeployTarget = 'service' | 'static' | 'container'
/** Whether a target deploys from a Git repository (vs a prebuilt image). */
export const targetIsGit = (t: DeployTarget): boolean => t === 'service' || t === 'static'
/** The platform `buildType` for a target (the closed set the backend accepts). */
export function buildTypeFor(t: DeployTarget): 'nixpacks' | 'static' | 'image' {
switch (t) {
case 'static':
return 'static'
case 'container':
return 'image'
default:
return 'nixpacks'
}
}
/**
* True when a string looks like a Git repository URL (https or scp-style ssh),
* for a host the platform accepts (github/gitlab/bitbucket/gitea/codeberg) OR any
* https URL ending `.git`. Pure + permissive — the backend re-validates the host
* at the boundary, so this only drives the composer's Deploy-vs-Build affordance.
*/
export function looksLikeGitUrl(s: string): boolean {
const v = s.trim()
if (!v) return false
// scp-style: git@github.com:owner/repo(.git)
if (/^git@[\w.-]+:[\w.-]+\/[\w.-]+/.test(v)) return true
if (!/^https?:\/\//i.test(v)) return false
if (/\.git($|[?#])/i.test(v)) return true
return /^https?:\/\/(www\.)?(github\.com|gitlab\.com|bitbucket\.org|gitea\.com|codeberg\.org)\/[\w.-]+\/[\w.-]+/i.test(v)
}
/**
* True when a string looks like a container image reference (no URL scheme, a
* registry/namespace path, optional :tag) — e.g. `ghcr.io/hanzoai/app:1.2.3` or
* `nginx`. Pure; the composer uses it only to pick the right input semantics.
*/
export function looksLikeImageRef(s: string): boolean {
const v = s.trim()
if (!v || /\s/.test(v) || /^https?:\/\//i.test(v) || v.includes('://')) return false
// A bare name (nginx) or a registry/namespace path, with an optional :tag / @digest.
return /^[a-z0-9]([\w.-]*[a-z0-9])?(:\d+)?(\/[\w.-]+)*(:[\w][\w.-]*)?(@sha256:[a-f0-9]+)?$/i.test(v)
}
/** Split an image ref into `{ repository, tag }` (defaults tag to 'latest'). */
export function parseImageRef(ref: string): { repository: string; tag: string } {
const v = ref.trim()
const at = v.indexOf('@') // digest pins live after '@'
const body = at >= 0 ? v.slice(0, at) : v
const lastSlash = body.lastIndexOf('/')
const lastColon = body.lastIndexOf(':')
// A ':' after the last '/' is a tag (a ':' inside a registry host:port is not).
if (lastColon > lastSlash) {
return { repository: body.slice(0, lastColon), tag: body.slice(lastColon + 1) || 'latest' }
}
return { repository: body, tag: 'latest' }
}
/**
* Derive a stable, k8s-safe app name from a repo URL or image ref: the last path
* segment, lower-cased, non-alphanumerics collapsed to '-', trimmed. Empty input
* (or all-symbol) yields '' so the caller keeps the user's own name.
*/
export function deriveAppName(input: string): string {
const v = input.trim()
if (!v) return ''
const s = v
.replace(/^https?:\/\//i, '') // scheme
.replace(/^git@[\w.-]+:/i, '') // scp-style git@host:
.replace(/[?#].*$/, '') // query / hash
.replace(/@[^/@]+$/, '') // a trailing @digest / @ref (no '/' inside it)
.replace(/\.git$/i, '') // trailing .git
const segs = s.split('/').filter(Boolean)
const tail = (segs[segs.length - 1] ?? '').replace(/:[\w.-]+$/, '') // drop an image :tag
return tail
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 63)
}
/** The `CreateAppInput` for a target + composer values (pure — no I/O). */
export function createAppInputFor(
target: DeployTarget,
v: { name: string; ref: string; branch?: string },
): CreateAppInput {
const name = v.name.trim()
if (target === 'container') {
const { repository, tag } = parseImageRef(v.ref)
return { name, source: 'image', image: { repository, tag }, buildType: 'image' }
}
return {
name,
source: 'git',
repo: { url: v.ref.trim(), branch: (v.branch || 'main').trim() || 'main' },
buildType: buildTypeFor(target),
}
}
/** The `deploy` body for a target (image → pin the tag; git → build the ref). */
export function deployInputFor(target: DeployTarget, ref: string): DeployInput {
if (target === 'container') return { tag: parseImageRef(ref).tag }
return {}
}
+19
View File
@@ -0,0 +1,19 @@
import { describe, expect, it } from 'vitest'
import { relativeTime } from './git'
describe('relativeTime', () => {
it('renders a compact relative time for a past push', () => {
const twoHoursAgo = new Date(Date.now() - 2 * 3600 * 1000).toISOString()
expect(relativeTime(twoHoursAgo)).toBe('updated 2h ago')
const threeDaysAgo = new Date(Date.now() - 3 * 86400 * 1000).toISOString()
expect(relativeTime(threeDaysAgo)).toBe('updated 3d ago')
})
it('is honest about empty / unparseable input', () => {
expect(relativeTime('')).toBe('')
expect(relativeTime('not-a-date')).toBe('')
})
it('handles a very recent push', () => {
expect(relativeTime(new Date().toISOString())).toBe('updated just now')
})
})
+93
View File
@@ -0,0 +1,93 @@
/**
* Client for the same-origin Git import BFF (`/git/*`).
*
* These endpoints resolve the user's IAM-linked GitHub token server-side and return
* only repository/account metadata — the token stays on the server. All calls are
* same-origin so the first-party session cookie rides automatically
* (`credentials: "include"`). Every helper resolves (never throws): a not-connected /
* unauthenticated response yields empty data so the UI shows the honest "Connect
* GitHub" CTA instead of crashing.
*
* Same contract hanzo.app's `/new` serves — so the console's connect-repo dropdown
* lights up with the user's real GitHub repos the moment they link GitHub in IAM.
*/
export interface GitAccount {
login: string
avatarUrl: string
provider: 'github'
type: 'user' | 'org'
}
export interface GitRepo {
name: string
fullName: string
private: boolean
description: string
language: string
pushedAt: string
defaultBranch: string
cloneUrl: string
htmlUrl: string
}
export interface GitAccountsResult {
connected: boolean
accounts: GitAccount[]
}
/** Connected accounts for the signed-in user (empty + not-connected on any failure). */
export async function fetchGitAccounts(): Promise<GitAccountsResult> {
try {
const res = await fetch('/git/accounts', {
credentials: 'include',
headers: { Accept: 'application/json' },
})
if (!res.ok) return { connected: false, accounts: [] }
const body = (await res.json()) as Partial<GitAccountsResult>
return {
connected: Boolean(body.connected),
accounts: Array.isArray(body.accounts) ? body.accounts : [],
}
} catch {
return { connected: false, accounts: [] }
}
}
/** Repositories for one account, server-side filtered by `q`. Empty on any failure. */
export async function fetchGitRepos(account: string, q = ''): Promise<GitRepo[]> {
try {
const params = new URLSearchParams()
if (account) params.set('account', account)
if (q.trim()) params.set('q', q.trim())
const res = await fetch(`/git/repos?${params.toString()}`, {
credentials: 'include',
headers: { Accept: 'application/json' },
})
if (!res.ok) return []
const body = (await res.json()) as { repos?: GitRepo[] }
return Array.isArray(body.repos) ? body.repos : []
} catch {
return []
}
}
/** "updated 2h ago" — compact relative time for a repo's last push. */
export function relativeTime(iso: string): string {
if (!iso) return ''
const then = new Date(iso).getTime()
if (!Number.isFinite(then)) return ''
const secs = Math.max(0, Math.floor((Date.now() - then) / 1000))
const units: [number, string][] = [
[31536000, 'y'],
[2592000, 'mo'],
[604800, 'w'],
[86400, 'd'],
[3600, 'h'],
[60, 'm'],
]
for (const [size, label] of units) {
if (secs >= size) return `updated ${Math.floor(secs / size)}${label} ago`
}
return 'updated just now'
}
+18
View File
@@ -99,6 +99,7 @@ import { ProviderAdminModule } from '~/components/products/ProviderAdminModule'
import { ModelsModule } from '~/components/products/ModelsModule'
import { ApplicationsModule } from '~/components/products/ApplicationsModule'
import { PlatformAppsModule } from '~/components/products/PlatformAppsModule'
import { DeployModule } from '~/components/products/DeployModule'
import { EmbeddingsModule } from '~/components/products/EmbeddingsModule'
import { ChatModule } from '~/components/products/ChatModule'
import { BotModule } from '~/components/products/BotModule'
@@ -1409,6 +1410,23 @@ export const catalog: CatalogEntry[] = [
// ── Deploy — the PaaS control plane (platform.hanzo.ai) over the /paas
// proxy. Clusters and Kubernetes are the real, wired surfaces; the rest of
// the CI/CD pipeline ships incrementally.
{
// The "Let's build something new" hub — the ONE uniform way to deploy any new
// project/service/container/site. Leads with repo→deploy on the real per-org
// PaaS (/v1/platform), a real connect-git dropdown (/git/*), and the real
// template gallery (/v1/templates). Placed first in Platform so "Deploy" is
// the obvious front door. Mirrors hanzo.app/new, infra-first.
id: 'new',
label: 'Deploy',
icon: Rocket,
description: 'Deploy a repo, image, or template — as a service, container, or static site.',
gcp: 'Cloud Run',
category: 'Platform',
status: 'enabled',
repo: 'hanzoai/console',
kind: 'module',
routes: [{ path: '', component: DeployModule }],
},
{
id: 'projects',
label: 'Projects',
+208
View File
@@ -0,0 +1,208 @@
/**
* Server-only Git connection layer — the trust boundary for repository import.
*
* A signed-in console user authenticates via Hanzo IAM (HIP-0111 OIDC). When they
* sign in with — or link — the GitHub provider, IAM (Casdoor) stores that user's
* GitHub OAuth token in their account `properties["oauth_GitHub_accessToken"]`. IAM
* masks per-provider tokens for every caller EXCEPT the user themselves, so reading
* the user's OWN account returns the token unmasked.
*
* The console never holds a raw IAM user bearer in the browser; it resolves the
* signed-in user from the first-party cloud SESSION COOKIE exactly like
* `resolveUser` — `GET {cloud}/v1/iam/get-account` with the request cookie — which
* returns the user's own (self) account, tokens unmasked. This module reads that
* token SERVER-SIDE and uses it to call the GitHub REST API on the user's behalf.
* The GitHub token NEVER reaches the browser — the BFF routes return only
* repository/account metadata. Fail-closed everywhere: no session, or no linked
* GitHub token ⇒ `null` (the UI shows an honest "Connect GitHub" CTA); a shared
* service token is NEVER substituted.
*
* This is the SAME connected-git contract hanzo.app serves at `/v1/git`, ported (at `/git`) so
* the console's connect-repo dropdown is genuinely real (works the moment a user
* links GitHub in IAM) rather than a mock.
*
* Server-only by construction: imported ONLY by the `/git/*` route handlers
* (the console convention — like `identity.ts`/`session.ts`, no `server-only`
* package dep), so the GitHub token never reaches the client bundle.
*/
import type { NextRequest } from 'next/server'
import { fetchWithTimeout } from './fetch-timeout'
const trim = (s: string) => s.replace(/\/+$/, '')
/** Cloud `/v1` backend (hanzoai/cloud) — resolves the session cookie to the user's
* account (with unmasked self-tokens). Same default/override as `identity.ts`. */
const CLOUD_API_URL = trim(process.env.CLOUD_API_URL ?? 'http://cloud.hanzo.svc.cluster.local:8000')
/** GitHub REST API base. */
const GITHUB_API = 'https://api.github.com'
/** A resolved GitHub connection for the signed-in user. */
export interface GithubConnection {
/** The GitHub OAuth access token (SERVER-SIDE ONLY — never serialized out). */
token: string
/** The user's GitHub login, when IAM recorded it. */
login: string
}
/** Shape of the cloud get-account response we consume (best-effort). */
interface AccountData {
github?: string
properties?: Record<string, string>
User?: { github?: string; properties?: Record<string, string> }
}
/**
* Resolve the signed-in user's GitHub token from IAM via the cloud session cookie.
*
* Returns null when the user is unauthenticated OR has no GitHub linked (the honest
* "not connected" state). A masked value ("***") is treated as absent.
*/
export async function resolveGithubConnection(req: NextRequest): Promise<GithubConnection | null> {
const cookie = req.headers.get('cookie')
if (!cookie) return null
let res: Response
try {
res = await fetchWithTimeout(`${CLOUD_API_URL}/v1/iam/get-account`, {
headers: { cookie, Accept: 'application/json' },
cache: 'no-store',
})
} catch {
return null
}
if (!res.ok) return null
const json = (await res.json().catch(() => null)) as { status?: string; data?: AccountData } | null
if (!json || json.status !== 'ok' || !json.data) return null
const d = json.data
const props = d.properties ?? d.User?.properties ?? {}
const token = props['oauth_GitHub_accessToken'] || ''
if (!token || token === '***') return null
const login = d.github || d.User?.github || props['oauth_GitHub_username'] || ''
return { token, login }
}
/** A connected Git account (the user, or an org they belong to). */
export interface GitAccount {
login: string
avatarUrl: string
provider: 'github'
type: 'user' | 'org'
}
/** A repository row for the import list. */
export interface GitRepo {
name: string
fullName: string
private: boolean
description: string
language: string
pushedAt: string
defaultBranch: string
cloneUrl: string
htmlUrl: string
}
async function gh(token: string, path: string): Promise<Response> {
return fetchWithTimeout(`${GITHUB_API}${path}`, {
headers: {
Authorization: `Bearer ${token}`,
Accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
'User-Agent': 'hanzo-console',
},
cache: 'no-store',
})
}
/**
* List the connected accounts: the authenticated user plus every org they can act
* in. A 401 (token revoked/expired) surfaces as `null` so the caller reports "not
* connected" rather than a hard error.
*/
export async function listAccounts(conn: GithubConnection): Promise<GitAccount[] | null> {
const meRes = await gh(conn.token, '/user')
if (meRes.status === 401) return null
if (!meRes.ok) throw new Error(`github /user ${meRes.status}`)
const me = (await meRes.json()) as { login: string; avatar_url: string }
const accounts: GitAccount[] = [
{ login: me.login, avatarUrl: me.avatar_url || '', provider: 'github', type: 'user' },
]
// Orgs are best-effort: a token without org scope simply yields none.
try {
const orgRes = await gh(conn.token, '/user/orgs?per_page=100')
if (orgRes.ok) {
const orgs = (await orgRes.json()) as { login: string; avatar_url: string }[]
for (const o of orgs) {
accounts.push({ login: o.login, avatarUrl: o.avatar_url || '', provider: 'github', type: 'org' })
}
}
} catch {
/* orgs are optional */
}
return accounts
}
interface RawRepo {
name: string
full_name: string
private: boolean
description: string | null
language: string | null
pushed_at: string | null
updated_at: string | null
default_branch: string
clone_url: string
html_url: string
}
function normalizeRepo(r: RawRepo): GitRepo {
return {
name: r.name,
fullName: r.full_name,
private: Boolean(r.private),
description: r.description || '',
language: r.language || '',
pushedAt: r.pushed_at || r.updated_at || '',
defaultBranch: r.default_branch || 'main',
cloneUrl: r.clone_url,
htmlUrl: r.html_url,
}
}
/**
* List repositories for one account, newest-push first, filtered by `q`.
*
* `account === conn.login` ⇒ the user's own repos (`/user/repos?type=owner`);
* otherwise the org's repos (`/orgs/:account/repos`). One page of up to 100 is
* fetched from GitHub and filtered server-side, capped at `cap` rows. Private repos
* appear only when the stored token carries the `repo` scope.
*/
export async function listRepos(
conn: GithubConnection,
account: string,
q: string,
cap = 60,
): Promise<GitRepo[] | null> {
const isSelf = !account || account === conn.login
const path = isSelf
? '/user/repos?per_page=100&sort=pushed&type=owner'
: `/orgs/${encodeURIComponent(account)}/repos?per_page=100&sort=pushed&type=all`
const res = await gh(conn.token, path)
if (res.status === 401) return null
if (!res.ok) throw new Error(`github repos ${res.status}`)
const raw = (await res.json()) as RawRepo[]
const needle = q.trim().toLowerCase()
const repos = raw
.map(normalizeRepo)
.filter((r) => (needle ? (r.fullName + ' ' + r.description).toLowerCase().includes(needle) : true))
return repos.slice(0, cap)
}