Compare commits
3
Commits
main
...
feat/deploy-hub
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf3a9cb1e9 | ||
|
|
b0f6d6dbdf | ||
|
|
2335e88426 |
@@ -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 })
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+2
-2
@@ -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
@@ -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>",
|
||||
|
||||
@@ -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’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’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()
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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({})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 {}
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user