From 57e883f0fecd4e37f24bfc924820d219787e8255 Mon Sep 17 00:00:00 2001
From: Che <30403707+Che-Zhu@users.noreply.github.com>
Date: Mon, 13 Jul 2026 15:27:09 +0800
Subject: [PATCH 1/2] feat: reset app on Better Auth v3 foundation
---
.env.template | 36 +-
.github/workflows/build-runtime.yml | 216 --
.github/workflows/ci.yml | 30 +-
.gitignore | 11 +-
AGENTS.md | 33 +-
CONTRIBUTING.md | 45 +-
Dockerfile | 14 +-
README.md | 201 +-
app/(auth)/auth-error/page.tsx | 133 --
.../login/_components/github-login-button.tsx | 36 +
app/(auth)/login/page.tsx | 158 +-
.../_components/dashboard-header.tsx | 51 +
.../_components/kubeconfig-status.test.ts | 25 +
.../_components/kubeconfig-status.ts | 34 +
app/(dashboard)/layout.tsx | 14 +
.../_components/create-project-card.tsx | 48 -
.../(list)/_components/home-page-content.tsx | 111 --
.../_components/page-header-with-filter.tsx | 57 -
.../_components/project-actions-menu.tsx | 206 --
.../(list)/_components/project-card.tsx | 139 --
.../(list)/_components/project-list.tsx | 41 -
.../(list)/_components/status-config.ts | 71 -
app/(dashboard)/projects/(list)/layout.tsx | 19 -
app/(dashboard)/projects/(list)/page.tsx | 29 -
.../[id]/_components/settings-layout.tsx | 47 -
app/(dashboard)/projects/[id]/auth/page.tsx | 171 --
.../_components/add-database-card.tsx | 56 -
.../_components/connection-string.tsx | 95 -
.../database/_components/feature-cards.tsx | 58 -
.../database/_components/read-only-field.tsx | 39 -
.../projects/[id]/database/page.tsx | 79 -
.../projects/[id]/environment/page.tsx | 72 -
.../projects/[id]/exec-test/client.tsx | 12 -
.../projects/[id]/exec-test/page.tsx | 85 -
app/(dashboard)/projects/[id]/github/page.tsx | 192 --
app/(dashboard)/projects/[id]/layout.tsx | 62 -
app/(dashboard)/projects/[id]/page.tsx | 8 -
.../projects/[id]/payment/page.tsx | 174 --
.../projects/[id]/secrets/page.tsx | 72 -
.../projects/[id]/terminal/page.tsx | 11 -
.../_components/create-project-dialog.tsx | 125 --
.../_components/import-github-dialog.tsx | 380 ----
.../projects/_components/search-bar.tsx | 67 -
.../_components/github-status-card.tsx | 160 --
.../settings/_components/settings-sidebar.tsx | 44 -
.../settings/integrations/page.tsx | 23 -
.../_components/kubeconfig-form.tsx | 225 +++
app/(dashboard)/settings/kubeconfig/page.tsx | 5 +
app/(dashboard)/settings/layout.tsx | 21 -
app/(dashboard)/settings/page.tsx | 18 -
.../skills/_components/skills-library.tsx | 154 --
app/(dashboard)/skills/layout.tsx | 14 -
app/(dashboard)/skills/page.tsx | 29 -
.../_components/workspace-overview.tsx | 54 +
app/(dashboard)/workspace/page.tsx | 8 +
app/(landing)/_components/landing-client.tsx | 175 +-
app/(landing)/page.tsx | 11 +-
app/api/auth/[...all]/route.ts | 5 +
app/api/auth/[...nextauth]/route.ts | 3 -
app/api/github/app/callback/route.ts | 217 --
app/api/github/app/webhook/route.ts | 80 -
app/api/kubeconfig/route.test.ts | 110 ++
app/api/kubeconfig/route.ts | 115 ++
app/api/projects/[id]/delete/route.ts | 59 -
.../[id]/environment/[envId]/route.ts | 229 ---
app/api/projects/[id]/environment/route.ts | 219 --
app/api/projects/[id]/route.ts | 58 -
app/api/projects/[id]/start/route.ts | 57 -
app/api/projects/[id]/stop/route.ts | 57 -
app/api/sandbox/[id]/app-status/route.ts | 69 -
app/api/sandbox/[id]/cwd/route.ts | 85 -
app/api/sandbox/[id]/exec/route.ts | 87 -
app/api/user/config/anthropic/route.ts | 226 ---
app/api/user/config/kc/route.ts | 117 --
app/api/user/config/route.ts | 156 --
app/api/user/config/system-prompt/route.ts | 101 -
app/error.tsx | 104 +-
app/github/app/callback/page.tsx | 122 --
app/globals.css | 441 +----
app/layout.tsx | 26 +-
app/not-found.tsx | 81 +-
components/config/env-var-section.tsx | 373 ----
components/dialog/settings-dialog.tsx | 753 -------
components/home-page.tsx | 110 --
components/icons/index.tsx | 44 -
.../layout/project-content-wrapper.module.css | 39 -
components/layout/project-content-wrapper.tsx | 90 -
components/layout/repo-status-indicator.tsx | 136 --
components/layout/status-bar.tsx | 51 -
components/sidebar.tsx | 122 --
components/sidebars/primary-sidebar.tsx | 82 -
components/sidebars/project-sidebar.tsx | 157 --
components/terminal/hooks/use-file-drop.ts | 251 ---
components/terminal/hooks/use-file-upload.tsx | 237 ---
components/terminal/terminal-container.tsx | 167 --
components/terminal/terminal-display.tsx | 201 --
.../terminal/toolbar/app-runner-dialog.tsx | 72 -
components/terminal/toolbar/app-runner.tsx | 78 -
.../terminal/toolbar/directory-selector.tsx | 123 --
.../terminal/toolbar/network-dialog.tsx | 157 --
components/terminal/toolbar/terminal-tabs.tsx | 85 -
components/terminal/toolbar/toolbar.tsx | 103 -
components/terminal/ttyd-exec-test.tsx | 185 --
components/terminal/xterm-terminal.tsx | 782 --------
components/ui/badge.tsx | 46 -
components/ui/card.tsx | 92 -
components/ui/dialog.tsx | 143 --
components/ui/dropdown-menu.tsx | 257 ---
components/ui/fullscreen-dialog.tsx | 181 --
components/ui/input.tsx | 21 -
components/ui/kbd.tsx | 28 -
components/ui/scroll-area.tsx | 58 -
components/ui/select.tsx | 187 --
components/ui/separator.tsx | 28 -
components/ui/skeleton.tsx | 13 -
components/ui/sonner.tsx | 40 -
components/ui/spinner.tsx | 16 -
components/ui/tabs.tsx | 66 -
components/ui/toggle-group.tsx | 83 -
components/ui/toggle.tsx | 47 -
components/ui/tooltip.tsx | 68 -
docs/architecture.md | 14 +-
docs/github-oauth-verification.md | 29 +
docs/v2-resource-inventory.md | 29 +
e2e/global-setup.ts | 76 +
e2e/global-teardown.ts | 16 +
e2e/minimal-flow.spec.ts | 101 +
hooks/use-app-runner.ts | 67 -
hooks/use-environment-variables.ts | 135 --
hooks/use-project-operations.ts | 78 -
hooks/use-project.ts | 48 -
instrumentation.ts | 35 -
.../desktop-sdk/detect-sealos-iframe.test.ts | 123 --
.../desktop-sdk/detect-sealos-iframe.ts | 42 -
.../desktop-sdk/get-sealos-session.test.ts | 104 -
.../sealos/desktop-sdk/get-sealos-session.ts | 41 -
integrations/sealos/desktop-sdk/index.ts | 3 -
integrations/sealos/desktop-sdk/types.ts | 15 -
landing-candidate-2.png | Bin 1665810 -> 0 bytes
lib/actions/database.ts | 57 -
lib/actions/github.ts | 97 -
lib/actions/project.ts | 63 -
lib/actions/sandbox.ts | 69 -
lib/actions/sealos-auth.ts | 36 -
lib/actions/skill.ts | 34 -
lib/actions/types.ts | 20 -
lib/api-auth.ts | 301 ---
lib/auth-client.ts | 5 +
lib/auth.ts | 610 ------
lib/auth/config.ts | 26 +
lib/auth/index.ts | 8 +
lib/auth/session.test.ts | 119 ++
lib/auth/session.ts | 61 +
lib/const.ts | 8 -
lib/data/database.ts | 19 -
lib/data/project.ts | 89 -
lib/data/user-skill.ts | 10 -
lib/db.ts | 21 +-
lib/env.ts | 74 +-
lib/events/database/bus.ts | 35 -
lib/events/database/databaseListener.ts | 301 ---
lib/events/database/index.ts | 3 -
lib/events/sandbox/bus.ts | 37 -
lib/events/sandbox/index.ts | 3 -
lib/events/sandbox/sandboxListener.ts | 372 ----
lib/fetch-client.ts | 309 ---
lib/jobs/database/databaseReconcile.ts | 102 -
lib/jobs/database/index.ts | 5 -
.../executors/clone-repository.ts | 95 -
lib/jobs/project-task/executors/index.ts | 28 -
.../project-task/executors/install-skill.ts | 118 --
.../project-task/executors/uninstall-skill.ts | 121 --
lib/jobs/project-task/index.ts | 2 -
lib/jobs/project-task/projectTaskReconcile.ts | 277 ---
lib/jobs/sandbox/index.ts | 1 -
lib/jobs/sandbox/sandboxReconcile.ts | 108 -
lib/jwt.ts | 118 --
lib/k8s/database-manager.ts | 825 --------
lib/k8s/index.ts | 3 +
lib/k8s/k8s-error-utils.ts | 6 -
lib/k8s/k8s-service-helper.ts | 148 --
lib/k8s/kubernetes-service-factory.ts | 343 ----
lib/k8s/kubernetes-utils.ts | 183 --
lib/k8s/kubernetes.test.ts | 68 +
lib/k8s/kubernetes.ts | 338 +---
lib/k8s/sandbox-manager.ts | 1753 -----------------
lib/k8s/validate-kubeconfig.test.ts | 113 ++
lib/k8s/validate-kubeconfig.ts | 199 ++
lib/k8s/versions.ts | 45 -
lib/kubeconfig/errors.ts | 31 +
lib/kubeconfig/get-content.ts | 10 +
lib/kubeconfig/get-status.ts | 15 +
lib/kubeconfig/index.ts | 6 +
lib/kubeconfig/remove.ts | 5 +
lib/kubeconfig/save.ts | 12 +
lib/logger.ts | 15 +-
.../commands/database/create-database.test.ts | 183 --
.../commands/database/create-database.ts | 76 -
.../commands/database/delete-database.test.ts | 86 -
.../commands/database/delete-database.ts | 40 -
.../control/commands/database/index.ts | 2 -
.../control/commands/database/readme.md | 6 -
.../create-project-from-github.test.ts | 249 ---
.../project/create-project-from-github.ts | 124 --
.../commands/project/create-project.test.ts | 178 --
.../commands/project/create-project.ts | 67 -
.../control/commands/project/index.ts | 5 -
.../control/commands/project/readme.md | 7 -
.../control/commands/project/shared.ts | 27 -
lib/platform/control/commands/readme.md | 12 -
.../skill/enable-global-skill.test.ts | 169 --
.../commands/skill/enable-global-skill.ts | 129 --
lib/platform/control/commands/skill/index.ts | 2 -
.../skill/uninstall-global-skill.test.ts | 213 --
.../commands/skill/uninstall-global-skill.ts | 178 --
lib/platform/control/readme.md | 5 -
lib/platform/control/types.ts | 6 -
lib/platform/executors/README.md | 5 -
lib/platform/executors/deploy/README.md | 3 -
lib/platform/executors/k8s/README.md | 3 -
lib/platform/executors/sandbox/README.md | 3 -
lib/platform/integrations/README.md | 3 -
lib/platform/integrations/aiproxy/README.md | 3 -
lib/platform/integrations/github/README.md | 3 -
.../find-installation-repository.test.ts | 71 -
.../github/find-installation-repository.ts | 27 -
lib/platform/integrations/k8s/README.md | 3 -
.../k8s/get-user-default-namespace.test.ts | 35 -
.../k8s/get-user-default-namespace.ts | 19 -
lib/platform/integrations/ttyd/README.md | 3 -
lib/platform/orchestrators/README.md | 5 -
.../orchestrators/resources/README.md | 3 -
lib/platform/orchestrators/tasks/README.md | 3 -
lib/platform/persistence/README.md | 5 -
.../find-github-installation-by-id.test.ts | 34 -
.../github/find-github-installation-by-id.ts | 21 -
.../create-clone-repository-task.test.ts | 51 -
.../create-clone-repository-task.ts | 42 -
.../create-install-skill-task.test.ts | 45 -
.../project-task/create-install-skill-task.ts | 47 -
.../create-uninstall-skill-task.test.ts | 46 -
.../create-uninstall-skill-task.ts | 48 -
.../create-project-with-sandbox.test.ts | 232 ---
.../project/create-project-with-sandbox.ts | 156 --
lib/platform/readme.md | 14 -
lib/repo/database.ts | 237 ---
lib/repo/environment.ts | 37 -
lib/repo/github.ts | 106 -
lib/repo/project-task.ts | 271 ---
lib/repo/project.ts | 186 --
lib/repo/sandbox.ts | 231 ---
lib/repo/user-skill.ts | 33 -
lib/services/aiproxy.ts | 128 --
lib/services/github-app.ts | 274 ---
lib/services/github-token-refresh.ts | 72 -
lib/services/project-task-dispatcher.ts | 59 -
lib/services/repoService.ts | 266 ---
lib/skills/catalog.ts | 39 -
lib/startup/index.ts | 155 --
lib/util/action.ts | 308 ---
lib/util/common-web.ts | 58 -
lib/util/common.ts | 9 -
lib/util/filebrowser.ts | 322 ---
lib/util/format-time.ts | 16 -
lib/util/project-display-status.ts | 78 -
lib/util/project-import-status.ts | 64 -
lib/util/projectStatus.ts | 78 -
lib/util/status-colors.ts | 158 --
lib/util/ttyd-context.ts | 87 -
lib/util/ttyd-exec.ts | 606 ------
lib/util/type-guards.ts | 37 -
next.config.ts | 1 +
package.json | 59 +-
playwright.config.ts | 52 +
pnpm-lock.yaml | 1335 ++++---------
.../migration.sql | 53 -
.../migration.sql | 31 -
.../migration.sql | 94 +
prisma/schema.prisma | 394 +---
runtime/.bashrc | 62 -
runtime/Dockerfile | 321 ---
runtime/README.md | 441 -----
runtime/entrypoint.sh | 95 -
runtime/ttyd-startup.sh | 73 -
vitest.config.ts | 2 +-
285 files changed, 2599 insertions(+), 28962 deletions(-)
delete mode 100644 .github/workflows/build-runtime.yml
delete mode 100644 app/(auth)/auth-error/page.tsx
create mode 100644 app/(auth)/login/_components/github-login-button.tsx
create mode 100644 app/(dashboard)/_components/dashboard-header.tsx
create mode 100644 app/(dashboard)/_components/kubeconfig-status.test.ts
create mode 100644 app/(dashboard)/_components/kubeconfig-status.ts
create mode 100644 app/(dashboard)/layout.tsx
delete mode 100644 app/(dashboard)/projects/(list)/_components/create-project-card.tsx
delete mode 100644 app/(dashboard)/projects/(list)/_components/home-page-content.tsx
delete mode 100644 app/(dashboard)/projects/(list)/_components/page-header-with-filter.tsx
delete mode 100644 app/(dashboard)/projects/(list)/_components/project-actions-menu.tsx
delete mode 100644 app/(dashboard)/projects/(list)/_components/project-card.tsx
delete mode 100644 app/(dashboard)/projects/(list)/_components/project-list.tsx
delete mode 100644 app/(dashboard)/projects/(list)/_components/status-config.ts
delete mode 100644 app/(dashboard)/projects/(list)/layout.tsx
delete mode 100644 app/(dashboard)/projects/(list)/page.tsx
delete mode 100644 app/(dashboard)/projects/[id]/_components/settings-layout.tsx
delete mode 100644 app/(dashboard)/projects/[id]/auth/page.tsx
delete mode 100644 app/(dashboard)/projects/[id]/database/_components/add-database-card.tsx
delete mode 100644 app/(dashboard)/projects/[id]/database/_components/connection-string.tsx
delete mode 100644 app/(dashboard)/projects/[id]/database/_components/feature-cards.tsx
delete mode 100644 app/(dashboard)/projects/[id]/database/_components/read-only-field.tsx
delete mode 100644 app/(dashboard)/projects/[id]/database/page.tsx
delete mode 100644 app/(dashboard)/projects/[id]/environment/page.tsx
delete mode 100644 app/(dashboard)/projects/[id]/exec-test/client.tsx
delete mode 100644 app/(dashboard)/projects/[id]/exec-test/page.tsx
delete mode 100644 app/(dashboard)/projects/[id]/github/page.tsx
delete mode 100644 app/(dashboard)/projects/[id]/layout.tsx
delete mode 100644 app/(dashboard)/projects/[id]/page.tsx
delete mode 100644 app/(dashboard)/projects/[id]/payment/page.tsx
delete mode 100644 app/(dashboard)/projects/[id]/secrets/page.tsx
delete mode 100644 app/(dashboard)/projects/[id]/terminal/page.tsx
delete mode 100644 app/(dashboard)/projects/_components/create-project-dialog.tsx
delete mode 100644 app/(dashboard)/projects/_components/import-github-dialog.tsx
delete mode 100644 app/(dashboard)/projects/_components/search-bar.tsx
delete mode 100644 app/(dashboard)/settings/_components/github-status-card.tsx
delete mode 100644 app/(dashboard)/settings/_components/settings-sidebar.tsx
delete mode 100644 app/(dashboard)/settings/integrations/page.tsx
create mode 100644 app/(dashboard)/settings/kubeconfig/_components/kubeconfig-form.tsx
create mode 100644 app/(dashboard)/settings/kubeconfig/page.tsx
delete mode 100644 app/(dashboard)/settings/layout.tsx
delete mode 100644 app/(dashboard)/settings/page.tsx
delete mode 100644 app/(dashboard)/skills/_components/skills-library.tsx
delete mode 100644 app/(dashboard)/skills/layout.tsx
delete mode 100644 app/(dashboard)/skills/page.tsx
create mode 100644 app/(dashboard)/workspace/_components/workspace-overview.tsx
create mode 100644 app/(dashboard)/workspace/page.tsx
create mode 100644 app/api/auth/[...all]/route.ts
delete mode 100644 app/api/auth/[...nextauth]/route.ts
delete mode 100644 app/api/github/app/callback/route.ts
delete mode 100644 app/api/github/app/webhook/route.ts
create mode 100644 app/api/kubeconfig/route.test.ts
create mode 100644 app/api/kubeconfig/route.ts
delete mode 100644 app/api/projects/[id]/delete/route.ts
delete mode 100644 app/api/projects/[id]/environment/[envId]/route.ts
delete mode 100644 app/api/projects/[id]/environment/route.ts
delete mode 100644 app/api/projects/[id]/route.ts
delete mode 100644 app/api/projects/[id]/start/route.ts
delete mode 100644 app/api/projects/[id]/stop/route.ts
delete mode 100644 app/api/sandbox/[id]/app-status/route.ts
delete mode 100644 app/api/sandbox/[id]/cwd/route.ts
delete mode 100644 app/api/sandbox/[id]/exec/route.ts
delete mode 100644 app/api/user/config/anthropic/route.ts
delete mode 100644 app/api/user/config/kc/route.ts
delete mode 100644 app/api/user/config/route.ts
delete mode 100644 app/api/user/config/system-prompt/route.ts
delete mode 100644 app/github/app/callback/page.tsx
delete mode 100644 components/config/env-var-section.tsx
delete mode 100644 components/dialog/settings-dialog.tsx
delete mode 100644 components/home-page.tsx
delete mode 100644 components/icons/index.tsx
delete mode 100644 components/layout/project-content-wrapper.module.css
delete mode 100644 components/layout/project-content-wrapper.tsx
delete mode 100644 components/layout/repo-status-indicator.tsx
delete mode 100644 components/layout/status-bar.tsx
delete mode 100644 components/sidebar.tsx
delete mode 100644 components/sidebars/primary-sidebar.tsx
delete mode 100644 components/sidebars/project-sidebar.tsx
delete mode 100644 components/terminal/hooks/use-file-drop.ts
delete mode 100644 components/terminal/hooks/use-file-upload.tsx
delete mode 100644 components/terminal/terminal-container.tsx
delete mode 100644 components/terminal/terminal-display.tsx
delete mode 100644 components/terminal/toolbar/app-runner-dialog.tsx
delete mode 100644 components/terminal/toolbar/app-runner.tsx
delete mode 100644 components/terminal/toolbar/directory-selector.tsx
delete mode 100644 components/terminal/toolbar/network-dialog.tsx
delete mode 100644 components/terminal/toolbar/terminal-tabs.tsx
delete mode 100644 components/terminal/toolbar/toolbar.tsx
delete mode 100644 components/terminal/ttyd-exec-test.tsx
delete mode 100644 components/terminal/xterm-terminal.tsx
delete mode 100644 components/ui/badge.tsx
delete mode 100644 components/ui/card.tsx
delete mode 100644 components/ui/dialog.tsx
delete mode 100644 components/ui/dropdown-menu.tsx
delete mode 100644 components/ui/fullscreen-dialog.tsx
delete mode 100644 components/ui/input.tsx
delete mode 100644 components/ui/kbd.tsx
delete mode 100644 components/ui/scroll-area.tsx
delete mode 100644 components/ui/select.tsx
delete mode 100644 components/ui/separator.tsx
delete mode 100644 components/ui/skeleton.tsx
delete mode 100644 components/ui/sonner.tsx
delete mode 100644 components/ui/spinner.tsx
delete mode 100644 components/ui/tabs.tsx
delete mode 100644 components/ui/toggle-group.tsx
delete mode 100644 components/ui/toggle.tsx
delete mode 100644 components/ui/tooltip.tsx
create mode 100644 docs/github-oauth-verification.md
create mode 100644 docs/v2-resource-inventory.md
create mode 100644 e2e/global-setup.ts
create mode 100644 e2e/global-teardown.ts
create mode 100644 e2e/minimal-flow.spec.ts
delete mode 100644 hooks/use-app-runner.ts
delete mode 100644 hooks/use-environment-variables.ts
delete mode 100644 hooks/use-project-operations.ts
delete mode 100644 hooks/use-project.ts
delete mode 100644 instrumentation.ts
delete mode 100644 integrations/sealos/desktop-sdk/detect-sealos-iframe.test.ts
delete mode 100644 integrations/sealos/desktop-sdk/detect-sealos-iframe.ts
delete mode 100644 integrations/sealos/desktop-sdk/get-sealos-session.test.ts
delete mode 100644 integrations/sealos/desktop-sdk/get-sealos-session.ts
delete mode 100644 integrations/sealos/desktop-sdk/index.ts
delete mode 100644 integrations/sealos/desktop-sdk/types.ts
delete mode 100644 landing-candidate-2.png
delete mode 100644 lib/actions/database.ts
delete mode 100644 lib/actions/github.ts
delete mode 100644 lib/actions/project.ts
delete mode 100644 lib/actions/sandbox.ts
delete mode 100644 lib/actions/sealos-auth.ts
delete mode 100644 lib/actions/skill.ts
delete mode 100644 lib/actions/types.ts
delete mode 100644 lib/api-auth.ts
create mode 100644 lib/auth-client.ts
delete mode 100644 lib/auth.ts
create mode 100644 lib/auth/config.ts
create mode 100644 lib/auth/index.ts
create mode 100644 lib/auth/session.test.ts
create mode 100644 lib/auth/session.ts
delete mode 100644 lib/const.ts
delete mode 100644 lib/data/database.ts
delete mode 100644 lib/data/project.ts
delete mode 100644 lib/data/user-skill.ts
delete mode 100644 lib/events/database/bus.ts
delete mode 100644 lib/events/database/databaseListener.ts
delete mode 100644 lib/events/database/index.ts
delete mode 100644 lib/events/sandbox/bus.ts
delete mode 100644 lib/events/sandbox/index.ts
delete mode 100644 lib/events/sandbox/sandboxListener.ts
delete mode 100644 lib/fetch-client.ts
delete mode 100644 lib/jobs/database/databaseReconcile.ts
delete mode 100644 lib/jobs/database/index.ts
delete mode 100644 lib/jobs/project-task/executors/clone-repository.ts
delete mode 100644 lib/jobs/project-task/executors/index.ts
delete mode 100644 lib/jobs/project-task/executors/install-skill.ts
delete mode 100644 lib/jobs/project-task/executors/uninstall-skill.ts
delete mode 100644 lib/jobs/project-task/index.ts
delete mode 100644 lib/jobs/project-task/projectTaskReconcile.ts
delete mode 100644 lib/jobs/sandbox/index.ts
delete mode 100644 lib/jobs/sandbox/sandboxReconcile.ts
delete mode 100644 lib/jwt.ts
delete mode 100644 lib/k8s/database-manager.ts
create mode 100644 lib/k8s/index.ts
delete mode 100644 lib/k8s/k8s-error-utils.ts
delete mode 100644 lib/k8s/k8s-service-helper.ts
delete mode 100644 lib/k8s/kubernetes-service-factory.ts
delete mode 100644 lib/k8s/kubernetes-utils.ts
create mode 100644 lib/k8s/kubernetes.test.ts
delete mode 100644 lib/k8s/sandbox-manager.ts
create mode 100644 lib/k8s/validate-kubeconfig.test.ts
create mode 100644 lib/k8s/validate-kubeconfig.ts
delete mode 100644 lib/k8s/versions.ts
create mode 100644 lib/kubeconfig/errors.ts
create mode 100644 lib/kubeconfig/get-content.ts
create mode 100644 lib/kubeconfig/get-status.ts
create mode 100644 lib/kubeconfig/index.ts
create mode 100644 lib/kubeconfig/remove.ts
create mode 100644 lib/kubeconfig/save.ts
delete mode 100644 lib/platform/control/commands/database/create-database.test.ts
delete mode 100644 lib/platform/control/commands/database/create-database.ts
delete mode 100644 lib/platform/control/commands/database/delete-database.test.ts
delete mode 100644 lib/platform/control/commands/database/delete-database.ts
delete mode 100644 lib/platform/control/commands/database/index.ts
delete mode 100644 lib/platform/control/commands/database/readme.md
delete mode 100644 lib/platform/control/commands/project/create-project-from-github.test.ts
delete mode 100644 lib/platform/control/commands/project/create-project-from-github.ts
delete mode 100644 lib/platform/control/commands/project/create-project.test.ts
delete mode 100644 lib/platform/control/commands/project/create-project.ts
delete mode 100644 lib/platform/control/commands/project/index.ts
delete mode 100644 lib/platform/control/commands/project/readme.md
delete mode 100644 lib/platform/control/commands/project/shared.ts
delete mode 100644 lib/platform/control/commands/readme.md
delete mode 100644 lib/platform/control/commands/skill/enable-global-skill.test.ts
delete mode 100644 lib/platform/control/commands/skill/enable-global-skill.ts
delete mode 100644 lib/platform/control/commands/skill/index.ts
delete mode 100644 lib/platform/control/commands/skill/uninstall-global-skill.test.ts
delete mode 100644 lib/platform/control/commands/skill/uninstall-global-skill.ts
delete mode 100644 lib/platform/control/readme.md
delete mode 100644 lib/platform/control/types.ts
delete mode 100644 lib/platform/executors/README.md
delete mode 100644 lib/platform/executors/deploy/README.md
delete mode 100644 lib/platform/executors/k8s/README.md
delete mode 100644 lib/platform/executors/sandbox/README.md
delete mode 100644 lib/platform/integrations/README.md
delete mode 100644 lib/platform/integrations/aiproxy/README.md
delete mode 100644 lib/platform/integrations/github/README.md
delete mode 100644 lib/platform/integrations/github/find-installation-repository.test.ts
delete mode 100644 lib/platform/integrations/github/find-installation-repository.ts
delete mode 100644 lib/platform/integrations/k8s/README.md
delete mode 100644 lib/platform/integrations/k8s/get-user-default-namespace.test.ts
delete mode 100644 lib/platform/integrations/k8s/get-user-default-namespace.ts
delete mode 100644 lib/platform/integrations/ttyd/README.md
delete mode 100644 lib/platform/orchestrators/README.md
delete mode 100644 lib/platform/orchestrators/resources/README.md
delete mode 100644 lib/platform/orchestrators/tasks/README.md
delete mode 100644 lib/platform/persistence/README.md
delete mode 100644 lib/platform/persistence/github/find-github-installation-by-id.test.ts
delete mode 100644 lib/platform/persistence/github/find-github-installation-by-id.ts
delete mode 100644 lib/platform/persistence/project-task/create-clone-repository-task.test.ts
delete mode 100644 lib/platform/persistence/project-task/create-clone-repository-task.ts
delete mode 100644 lib/platform/persistence/project-task/create-install-skill-task.test.ts
delete mode 100644 lib/platform/persistence/project-task/create-install-skill-task.ts
delete mode 100644 lib/platform/persistence/project-task/create-uninstall-skill-task.test.ts
delete mode 100644 lib/platform/persistence/project-task/create-uninstall-skill-task.ts
delete mode 100644 lib/platform/persistence/project/create-project-with-sandbox.test.ts
delete mode 100644 lib/platform/persistence/project/create-project-with-sandbox.ts
delete mode 100644 lib/platform/readme.md
delete mode 100644 lib/repo/database.ts
delete mode 100644 lib/repo/environment.ts
delete mode 100644 lib/repo/github.ts
delete mode 100644 lib/repo/project-task.ts
delete mode 100644 lib/repo/project.ts
delete mode 100644 lib/repo/sandbox.ts
delete mode 100644 lib/repo/user-skill.ts
delete mode 100644 lib/services/aiproxy.ts
delete mode 100644 lib/services/github-app.ts
delete mode 100644 lib/services/github-token-refresh.ts
delete mode 100644 lib/services/project-task-dispatcher.ts
delete mode 100644 lib/services/repoService.ts
delete mode 100644 lib/skills/catalog.ts
delete mode 100644 lib/startup/index.ts
delete mode 100644 lib/util/action.ts
delete mode 100644 lib/util/common-web.ts
delete mode 100644 lib/util/common.ts
delete mode 100644 lib/util/filebrowser.ts
delete mode 100644 lib/util/format-time.ts
delete mode 100644 lib/util/project-display-status.ts
delete mode 100644 lib/util/project-import-status.ts
delete mode 100644 lib/util/projectStatus.ts
delete mode 100644 lib/util/status-colors.ts
delete mode 100644 lib/util/ttyd-context.ts
delete mode 100644 lib/util/ttyd-exec.ts
delete mode 100644 lib/util/type-guards.ts
create mode 100644 playwright.config.ts
delete mode 100644 prisma/migrations/20260311171810_project_task_platform/migration.sql
delete mode 100644 prisma/migrations/20260317120000_user_skill_global_enablement/migration.sql
create mode 100644 prisma/migrations/20260713000000_better_auth_v3_baseline/migration.sql
delete mode 100644 runtime/.bashrc
delete mode 100644 runtime/Dockerfile
delete mode 100644 runtime/README.md
delete mode 100755 runtime/entrypoint.sh
delete mode 100644 runtime/ttyd-startup.sh
diff --git a/.env.template b/.env.template
index 7190077..20143a8 100644
--- a/.env.template
+++ b/.env.template
@@ -2,38 +2,10 @@
DATABASE_URL=""
# Authentication
-NEXTAUTH_URL=""
-NEXTAUTH_SECRET=""
-AUTH_TRUST_HOST="true"
-
-# GitHub App (required for GitHub login and repository import)
-GITHUB_APP_ID=""
-GITHUB_APP_PRIVATE_KEY=""
-GITHUB_APP_WEBHOOK_SECRET=""
-GITHUB_APP_CLIENT_ID=""
-GITHUB_APP_CLIENT_SECRET=""
-NEXT_PUBLIC_GITHUB_APP_NAME=""
-
-# Sealos OAuth (optional)
-SEALOS_JWT_SECRET=""
-
-# Feature Flags
-ENABLE_PASSWORD_AUTH=""
-ENABLE_GITHUB_AUTH="true"
-ENABLE_SEALOS_AUTH=""
-
-# Reconciliation
-DATABASE_LOCK_DURATION_SECONDS=""
-MAX_DATABASES_PER_RECONCILE=""
-SANDBOX_LOCK_DURATION_SECONDS=""
-MAX_SANDBOXES_PER_RECONCILE=""
-
-# Kubernetes
-RUNTIME_IMAGE=""
-
-# AI Proxy
-AIPROXY_ENDPOINT=""
-ANTHROPIC_BASE_URL=""
+BETTER_AUTH_URL="http://localhost:3000"
+BETTER_AUTH_SECRET=""
+GITHUB_CLIENT_ID=""
+GITHUB_CLIENT_SECRET=""
# Logging
LOG_LEVEL="info"
diff --git a/.github/workflows/build-runtime.yml b/.github/workflows/build-runtime.yml
deleted file mode 100644
index 997c39f..0000000
--- a/.github/workflows/build-runtime.yml
+++ /dev/null
@@ -1,216 +0,0 @@
-name: Build Runtime Image
-
-on:
- workflow_dispatch:
-
-permissions:
- pull-requests: write
- packages: write
- contents: read
-
-concurrency:
- group: ${{ github.workflow }}-${{ github.ref }}
- cancel-in-progress: true
-
-env:
- DOCKERHUB_USERNAME: ${{ vars.DOCKERHUB_USERNAME }}
-
-jobs:
- build-runtime-images:
- name: Build Runtime Docker Images
- permissions:
- packages: write
- runs-on: ubuntu-24.04
-
- steps:
- - name: Checkout code
- uses: actions/checkout@v4
- with:
- fetch-depth: 0
-
- - name: Convert repository owner to lowercase
- id: repo-owner
- run: |
- echo "lowercase=${GITHUB_REPOSITORY_OWNER@L}" >> $GITHUB_OUTPUT
-
- - name: Set up Docker Buildx
- uses: docker/setup-buildx-action@v3
-
- - name: Login to Docker Hub
- if: ${{ github.event_name != 'pull_request' && github.actor != 'dependabot[bot]' && env.DOCKERHUB_USERNAME != '' }}
- uses: docker/login-action@v3
- with:
- username: ${{ vars.DOCKERHUB_USERNAME }}
- password: ${{ secrets.DOCKERHUB_TOKEN }}
-
- - name: Login to GitHub Container Registry
- if: ${{ github.event_name != 'pull_request' && github.actor != 'dependabot[bot]' }}
- uses: docker/login-action@v3
- with:
- registry: ghcr.io
- username: ${{ steps.repo-owner.outputs.lowercase }}
- password: ${{ secrets.GITHUB_TOKEN }}
-
- - name: Extract metadata (tags, labels) for Docker
- id: meta
- uses: docker/metadata-action@v5
- with:
- images: |
- ghcr.io/${{ steps.repo-owner.outputs.lowercase }}/fullstack-web-runtime
- ${{ env.DOCKERHUB_USERNAME && format('docker.io/{0}/fullstack-web-runtime', env.DOCKERHUB_USERNAME) || '' }}
- tags: |
- type=ref,event=branch
- type=ref,event=tag
- type=semver,pattern={{version}}
- type=semver,pattern={{major}}.{{minor}}
- type=semver,pattern={{major}}
- type=sha,prefix=sha-
- type=raw,value=latest,enable=${{ github.ref == format('refs/heads/{0}', 'main') || github.ref == format('refs/heads/{0}', 'master') }}
- labels: |
- org.opencontainers.image.title=FullStack Web Runtime
- org.opencontainers.image.description=Full-stack web development runtime with Next.js, shadcn/ui, Claude Code CLI, and container tools
- org.opencontainers.image.vendor=${{ steps.repo-owner.outputs.lowercase }}
-
- - name: Build and Push Docker Image
- id: docker-build
- uses: docker/build-push-action@v6
- with:
- context: ./runtime
- file: ./runtime/Dockerfile
- labels: ${{ steps.meta.outputs.labels }}
- platforms: linux/amd64
- tags: ${{ steps.meta.outputs.tags }}
- # PR builds: load locally for validation, Push builds: push to registry
- push: ${{ github.event_name != 'pull_request' && github.actor != 'dependabot[bot]' }}
- load: ${{ github.event_name == 'pull_request' }}
- cache-from: type=gha,scope=runtime-amd64
- cache-to: type=gha,mode=max,scope=runtime-amd64
-
- - name: Comment on PR
- if: github.event_name == 'pull_request' && always()
- uses: actions/github-script@v7
- continue-on-error: true
- with:
- script: |
- const buildSuccess = '${{ steps.docker-build.outcome }}' === 'success';
- const emoji = buildSuccess ? '✅' : '❌';
- const status = buildSuccess ? 'Success' : 'Failed';
-
- let body = `## ${emoji} FullStack Web Runtime Build ${status}\n\n`;
- body += `### Build Details\n\n`;
- body += `| Item | Value |\n`;
- body += `|------|-------|\n`;
- body += `| Build Status | ${buildSuccess ? '✅ Passed' : '❌ Failed'} |\n`;
- body += `| Platforms | linux/amd64 (PR validation) |\n`;
- body += `| Push to Registry | ⚠️ No (PR build only) |\n`;
- body += `| Base Image | ubuntu:24.04 |\n`;
- body += `| Node.js | 22.x LTS |\n`;
- body += `| Components | Claude Code CLI, ttyd, Next.js, Prisma, PostgreSQL client, Buildah |\n\n`;
-
- if (buildSuccess) {
- body += `### 📦 Runtime image will be published after merge\n\n`;
- body += `**Note**: PR builds only verify the Docker build process. `;
- body += `Images are pushed to registries only when merged to main.\n\n`;
- body += `**Registries**:\n`;
- body += `- GitHub Container Registry: \`ghcr.io/${{ steps.repo-owner.outputs.lowercase }}/fullstack-web-runtime\`\n`;
- if ('${{ env.DOCKERHUB_USERNAME }}') {
- body += `- Docker Hub: \`docker.io/${{ env.DOCKERHUB_USERNAME }}/fullstack-web-runtime\`\n`;
- }
- body += `\n**Included Tools**:\n`;
- body += `- Node.js 22.x + npm, pnpm, yarn\n`;
- body += `- Claude Code CLI (@anthropic-ai/claude-code)\n`;
- body += `- Next.js with shadcn/ui components\n`;
- body += `- PostgreSQL 16 client\n`;
- body += `- Container tools (Buildah, Podman, Skopeo)\n`;
- body += `- Development tools (Git, GitHub CLI, ripgrep, jq, etc.)\n`;
- body += `- ttyd web terminal\n`;
- } else {
- body += `### ❌ Build Failed\n\n`;
- body += `Please check the workflow logs for detailed error information.\n`;
- }
-
- body += `\n---\n`;
- body += `**Commit**: \`${{ github.sha }}\`\n`;
- body += `**Triggered by**: @${{ github.actor }}\n`;
-
- try {
- const { data: comments } = await github.rest.issues.listComments({
- owner: context.repo.owner,
- repo: context.repo.repo,
- issue_number: context.issue.number,
- });
-
- const botComment = comments.find(comment =>
- comment.user.type === 'Bot' &&
- comment.body.includes('FullStack Web Runtime Build')
- );
-
- if (botComment) {
- await github.rest.issues.updateComment({
- owner: context.repo.owner,
- repo: context.repo.repo,
- comment_id: botComment.id,
- body: body
- });
- } else {
- await github.rest.issues.createComment({
- owner: context.repo.owner,
- repo: context.repo.repo,
- issue_number: context.issue.number,
- body: body
- });
- }
- } catch (error) {
- console.log('Failed to post comment:', error.message);
- console.log('This might be expected for PRs from forks');
- }
-
- - name: Generate build summary
- if: ${{ github.event_name != 'pull_request' && github.actor != 'dependabot[bot]' && always() }}
- run: |
- echo "## 🚀 Runtime Image Build & Push Report" >> $GITHUB_STEP_SUMMARY
- echo "" >> $GITHUB_STEP_SUMMARY
- echo "### Build Status" >> $GITHUB_STEP_SUMMARY
- if [ "${{ steps.docker-build.outcome }}" = "success" ]; then
- echo "- ✅ Runtime build successful" >> $GITHUB_STEP_SUMMARY
- echo "- ✅ Platform: \`linux/amd64\`" >> $GITHUB_STEP_SUMMARY
- echo "- ✅ Pushed to GitHub Container Registry: \`ghcr.io/${{ steps.repo-owner.outputs.lowercase }}/fullstack-web-runtime\`" >> $GITHUB_STEP_SUMMARY
- if [ -n "${{ env.DOCKERHUB_USERNAME }}" ]; then
- echo "- ✅ Pushed to Docker Hub: \`docker.io/${{ env.DOCKERHUB_USERNAME }}/fullstack-web-runtime\`" >> $GITHUB_STEP_SUMMARY
- fi
- else
- echo "- ❌ Build or push failed" >> $GITHUB_STEP_SUMMARY
- fi
- echo "" >> $GITHUB_STEP_SUMMARY
- echo "### Runtime Image Details" >> $GITHUB_STEP_SUMMARY
- echo "- **Base**: Ubuntu 24.04" >> $GITHUB_STEP_SUMMARY
- echo "- **Node.js**: 22.x LTS" >> $GITHUB_STEP_SUMMARY
- echo "- **PostgreSQL Client**: 16" >> $GITHUB_STEP_SUMMARY
- echo "- **Claude Code CLI**: @anthropic-ai/claude-code" >> $GITHUB_STEP_SUMMARY
- echo "- **Next.js**: Latest with shadcn/ui components" >> $GITHUB_STEP_SUMMARY
- echo "- **Container Tools**: Buildah, Podman, Skopeo" >> $GITHUB_STEP_SUMMARY
- echo "- **Terminal**: ttyd web-based terminal" >> $GITHUB_STEP_SUMMARY
- echo "" >> $GITHUB_STEP_SUMMARY
- echo "### Build Information" >> $GITHUB_STEP_SUMMARY
- echo "- **Commit SHA**: \`${{ github.sha }}\`" >> $GITHUB_STEP_SUMMARY
- echo "- **Branch**: \`${{ github.ref_name }}\`" >> $GITHUB_STEP_SUMMARY
- echo "- **Triggered by**: @${{ github.actor }}" >> $GITHUB_STEP_SUMMARY
- echo "- **Event**: \`${{ github.event_name }}\`" >> $GITHUB_STEP_SUMMARY
- echo "- **Build time**: $(date '+%Y-%m-%d %H:%M:%S UTC')" >> $GITHUB_STEP_SUMMARY
- echo "" >> $GITHUB_STEP_SUMMARY
- echo "### Image Tags" >> $GITHUB_STEP_SUMMARY
- echo '```' >> $GITHUB_STEP_SUMMARY
- echo "${{ steps.meta.outputs.tags }}" >> $GITHUB_STEP_SUMMARY
- echo '```' >> $GITHUB_STEP_SUMMARY
- echo "" >> $GITHUB_STEP_SUMMARY
- echo "### Usage Example" >> $GITHUB_STEP_SUMMARY
- echo '```bash' >> $GITHUB_STEP_SUMMARY
- echo "# Pull the latest image" >> $GITHUB_STEP_SUMMARY
- echo "docker pull ghcr.io/${{ steps.repo-owner.outputs.lowercase }}/fullstack-web-runtime:latest" >> $GITHUB_STEP_SUMMARY
- echo "" >> $GITHUB_STEP_SUMMARY
- echo "# Run with environment variables" >> $GITHUB_STEP_SUMMARY
- echo "docker run -d -p 7681:7681 -p 3000:3000 \\" >> $GITHUB_STEP_SUMMARY
- echo " -e ANTHROPIC_AUTH_TOKEN=your_token \\" >> $GITHUB_STEP_SUMMARY
- echo " -e PROJECT_NAME=my-project \\" >> $GITHUB_STEP_SUMMARY
- echo " ghcr.io/${{ steps.repo-owner.outputs.lowercase }}/fullstack-web-runtime:latest" >> $GITHUB_STEP_SUMMARY
- echo '```' >> $GITHUB_STEP_SUMMARY
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 0c98dcd..51909d3 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -16,13 +16,32 @@ env:
NODE_VERSION: "22.12.0"
PNPM_VERSION: "10.20.0"
NEXT_TELEMETRY_DISABLED: 1
- SKIP_ENV_VALIDATION: 1
+ DATABASE_URL: postgresql://postgres:postgres@localhost:5432/fulling_test
+ BETTER_AUTH_URL: http://127.0.0.1:3000
+ BETTER_AUTH_SECRET: ci-better-auth-secret-at-least-32-characters
+ GITHUB_CLIENT_ID: test-github-client-id
+ GITHUB_CLIENT_SECRET: test-github-client-secret
jobs:
lint-test-build:
name: Lint, Test, Build
runs-on: ubuntu-latest
+ services:
+ postgres:
+ image: postgres:16-alpine
+ env:
+ POSTGRES_DB: fulling_test
+ POSTGRES_PASSWORD: postgres
+ POSTGRES_USER: postgres
+ ports:
+ - 5432:5432
+ options: >-
+ --health-cmd "pg_isready -U postgres -d fulling_test"
+ --health-interval 10s
+ --health-timeout 5s
+ --health-retries 5
+
steps:
- name: Checkout code
uses: actions/checkout@v4
@@ -41,11 +60,20 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile
+ - name: Deploy database baseline
+ run: pnpm prisma:migrate
+
- name: Run linter
run: pnpm lint
- name: Run tests
run: pnpm test
+ - name: Install Playwright Chromium
+ run: pnpm exec playwright install --with-deps chromium
+
+ - name: Run browser tests
+ run: pnpm test:e2e
+
- name: Build project
run: pnpm build
diff --git a/.gitignore b/.gitignore
index 51b965d..5517136 100644
--- a/.gitignore
+++ b/.gitignore
@@ -13,6 +13,9 @@
# testing
/coverage
+/playwright-report/
+/test-results/
+/e2e/.auth/
# next.js
/.next/
@@ -42,8 +45,12 @@ yarn-error.log*
*.pem
*.crt
*.pfx
-kubeconfig
-*kubeconfig*
+/kubeconfig
+/kubeconfig.yaml
+/kubeconfig.yml
+*.kubeconfig
+*.kubeconfig.yaml
+*.kubeconfig.yml
credentials.json
secrets.yaml
secrets.yml
diff --git a/AGENTS.md b/AGENTS.md
index fa38a85..1ecbd00 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -12,8 +12,8 @@ Read [docs/architecture.md](./docs/architecture.md) before product or architectu
- Next.js 16 (App Router) + React 19 + TypeScript
- Tailwind CSS v4 + Shadcn/UI
-- Node.js 22 + Prisma + NextAuth v5
-- Kubernetes + PostgreSQL (KubeBlocks)
+- Node.js 22 + Prisma + Better Auth
+- Kubernetes + PostgreSQL
## Code Conventions
@@ -25,11 +25,13 @@ Read [docs/architecture.md](./docs/architecture.md) before product or architectu
## Current Implementation Context
-- Existing code still contains v2 project/resource abstractions. Do not treat them as v3 product architecture.
-- **Asynchronous reconciliation** exists in current code: API → Database → Reconciliation Job → Event → K8s Operation
-- **Always use user-specific K8s service**: `const k8sService = await getK8sServiceForUser(userId)`
-- **Optimistic locking** in Repository layer
-- **Non-blocking APIs**: endpoints only update DB, return immediately
+- GitHub is the only authentication provider.
+- Better Auth stores users, provider accounts, and sessions in PostgreSQL.
+- Each user may store one plaintext kubeconfig; browser APIs never return it.
+- **Always use the user-specific K8s service**:
+ `const k8sService = await getK8sServiceForUser(userId)`.
+- The current user-level kubeconfig is a foundation boundary, not the final v3
+ Workspace Runtime ownership model.
## UI Direction
@@ -43,9 +45,9 @@ Avoid generic AI copywriting cliches such as "Elevate", "Seamless", and
## Key Files
- [docs/architecture.md](./docs/architecture.md) — v3 system architecture and product model
-- `lib/k8s/k8s-service-helper.ts` — User-specific K8s service
-- `lib/events/` + `lib/jobs/` — Reconciliation core
-- `instrumentation.ts` — Application startup
+- `lib/auth/` — Better Auth and application session boundary
+- `lib/kubeconfig/` — User credential persistence
+- `lib/k8s/` — Validation and user-specific Kubernetes service
## Image Tagging Policy
@@ -90,9 +92,10 @@ Do not use ambiguous moving tags such as `main`, `stable`, `prod`, `release`,
## Development Commands
```bash
-pnpm dev # Start dev server
-pnpm build # Build for production
-pnpm lint # Run ESLint
-npx prisma generate # Generate Prisma client
-npx prisma db push # Push schema to database
+corepack pnpm dev # Start dev server
+corepack pnpm build # Build for production
+corepack pnpm lint # Run ESLint
+corepack pnpm test # Run Vitest
+corepack pnpm test:e2e # Run Playwright
+corepack pnpm prisma:migrate # Deploy the baseline migration
```
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 8adffc5..5feba05 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -1,42 +1,31 @@
# Contributing to Fulling
-Thank you for your interest in Fulling. This document covers the essentials for contributing effectively.
-
-## Project Direction
-
-Fulling v3.0 is building **dedicated AI workspaces** — persistent, personalizable environments with skills, files, memory, scripts, and runtime. Before contributing, read [README.md](./README.md) for the product vision and [design/DESIGN.md](./design/DESIGN.md) for the UI design system.
+Fulling v3 is building dedicated AI workspaces. Read
+[docs/architecture.md](./docs/architecture.md) before product or architecture
+work.
## Development Setup
-Prerequisites: Node.js 22.12.0+, pnpm 10.20.0+, PostgreSQL, Kubernetes with KubeBlocks.
+Requirements: Node.js 22.12+, pnpm 10.20.0, PostgreSQL, and GitHub OAuth
+credentials.
```bash
-git clone https://github.com/FullAgent/fulling.git
-cd fulling
-pnpm install
+corepack pnpm install
cp .env.template .env.local
-# Edit .env.local with your settings
-npx prisma generate
-npx prisma db push
-pnpm dev
+corepack pnpm prisma:migrate
+corepack pnpm dev
```
-Generated repository documentation lives in [.qoder/repowiki](./.qoder/repowiki).
-Regenerate it when code structure changes materially.
+The baseline migration targets a new or explicitly reset database. There is no
+v2 data migration.
## Before Submitting
-- [ ] `pnpm lint` passes
-- [ ] `pnpm build` succeeds
-- [ ] UI changes follow [design/DESIGN.md](./design/DESIGN.md)
-- [ ] Commits use conventional format: `feat:`, `fix:`, `docs:`, `refactor:`, `chore:`
-
-## Reporting Issues
-
-- Search existing issues first
-- One issue per bug or feature request
-- Include: reproduction steps, expected vs actual behavior, environment info
-
-## Questions?
+- `corepack pnpm lint`
+- `corepack pnpm test`
+- `corepack pnpm test:e2e`
+- `corepack pnpm build`
+- verify that no browser response or log includes kubeconfig content
-Open a [GitHub Discussion](https://github.com/FullAgent/fulling/discussions) or ask in an existing issue.
+Commits use conventional prefixes such as `feat:`, `fix:`, `docs:`, `refactor:`,
+and `chore:`.
diff --git a/Dockerfile b/Dockerfile
index 9fa064f..544e755 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,7 +1,7 @@
# Install dependencies only when needed
-FROM node:current-alpine AS deps
+FROM node:22-alpine AS deps
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
-RUN apk add --no-cache libc6-compat openssl && npm install -g pnpm
+RUN apk add --no-cache libc6-compat openssl && corepack enable && corepack prepare pnpm@10.20.0 --activate
WORKDIR /app
# Copy package files and prisma schema
@@ -18,7 +18,7 @@ RUN \
fi
# Rebuild the source code only when needed
-FROM node:current-alpine AS builder
+FROM node:22-alpine AS builder
RUN apk add --no-cache openssl
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
@@ -29,16 +29,15 @@ COPY . .
# Learn more here: https://nextjs.org/telemetry
# Uncomment the following line in case you want to disable telemetry during the build.
ENV NEXT_TELEMETRY_DISABLED=1
-ENV NEXT_PUBLIC_MOCK_USER=''
ENV SKIP_ENV_VALIDATION=1
-# Install pnpm and generate Prisma client before build
-RUN npm install -g pnpm && \
+# Generate Prisma client before build
+RUN corepack enable && corepack prepare pnpm@10.20.0 --activate && \
pnpm prisma generate && \
pnpm run build
# Production image, copy all the files and run next
-FROM node:current-alpine AS runner
+FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
@@ -49,7 +48,6 @@ RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
# Install runtime dependencies including OpenSSL for Prisma
-RUN sed -i 's/https/http/' /etc/apk/repositories
RUN apk add --no-cache \
curl \
ca-certificates \
diff --git a/README.md b/README.md
index 675f3e4..747ae99 100644
--- a/README.md
+++ b/README.md
@@ -1,169 +1,84 @@
# Fulling
-
-
+Fulling is building dedicated AI workspaces: persistent environments that combine
+skills, files, memory, scripts, and runtime. The current v3 foundation provides
+the identity and Kubernetes credential boundary required for that product model.
-
Package AI workspaces once. Share them with people who just need the AI to work.
+## Current Foundation
-
-
-
-
-
-
+- GitHub-only sign-in through Better Auth
+- PostgreSQL-backed users, provider accounts, and sessions
+- a protected application workspace entry
+- one plaintext kubeconfig per user
+- authenticated kubeconfig validation through Kubernetes `SelfSubjectReview`
+- a user-scoped Kubernetes client boundary
-
- Overview •
- Workspace model •
- How it works •
- Project status •
- Local development
-
-
+The repository intentionally contains no compatibility layer for the previous
+product model or authentication system.
-## Overview
+Read [docs/architecture.md](./docs/architecture.md) for the target Workspace model.
+The user-level kubeconfig in this foundation is not the final Workspace Runtime
+ownership model.
-Fulling is a product for creating ready-to-use AI workspaces.
+## Requirements
-It is built around a practical problem: many professionals can benefit from AI,
-but they do not want to configure skills, prompts, memory, scripts, integrations,
-or runtime settings. Fulling gives creators a place to assemble those pieces,
-then share the finished workspace with the person who needs it.
-
-> [!NOTE]
-> Fulling v3.0 centers on workspace delivery: the setup work happens once, and
-> the recipient opens a prepared AI workspace instead of learning the AI stack.
-
-## Why Fulling?
-
-Most AI tools start with an empty chat box. That works for people who already
-know how to prompt, configure tools, and judge the outputs. It breaks down when
-the user is a domain expert who simply needs help with real work.
-
-Fulling turns that setup into a deliverable:
-
-- a building designer receives a workspace with drawing review workflows,
- project references, and report templates
-- a consultant receives a workspace with client materials, analysis scripts,
- and reusable delivery formats
-- a team receives a workspace with shared context, approved tools, and repeatable
- operating routines
-
-The recipient uses the workspace. The creator owns the configuration.
-
-## Workspace Model
-
-An AI workspace combines the context, capabilities, and runtime needed for a
-specific person or job.
-
-| Part | Purpose |
-| --- | --- |
-| Mission | The job this AI workspace exists to help with |
-| Knowledge | Files, notes, examples, references, and domain material |
-| Memory | Durable context that can evolve over time |
-| Skills | Named capabilities the AI can use |
-| Scripts | Repeatable actions for work that needs reliability |
-| Runtime | A place where tools, code, and automation can run |
-| Sharing | A way to hand the configured workspace to another user |
-
-## How It Works
-
-```text
-Create
- -> choose who the workspace is for
- -> describe the work it supports
- -> add knowledge and files
- -> configure skills, scripts, memory, and runtime
- -> test the workspace
- -> share it with the recipient
-
-Use
- -> open the prepared workspace
- -> ask for work through task-focused entry points
- -> review outputs, files, and approvals
- -> keep using the same workspace as context grows
-```
-
-## What Fulling Is Not
-
-Fulling is not a prompt marketplace, generic chatbot builder, Kubernetes UI, or
-DevOps control panel. Those pieces can exist behind the scenes, but the product
-is organized around prepared AI workspaces.
-
-## Project Status
-
-Fulling v3.0 is the active product direction. The current repository is being
-realigned around the workspace delivery model described above.
-
-The previous v2.0.0 release is available at
-[Fulling v2.0.0](https://github.com/FullAgent/fulling/releases/tag/v2.0.0).
-
-Active branches:
-
-- `v3.0` for the current product direction
-- `release/2.0` for v2 maintenance
-- `main` is not the target branch for v3 work
-
-## Tech Stack
-
-| Area | Stack |
-| --- | --- |
-| App | Next.js 16 App Router, React 19, TypeScript |
-| UI | Tailwind CSS v4, Shadcn/UI, Radix UI |
-| Data | Prisma, PostgreSQL |
-| Auth | NextAuth v5 |
-| Runtime | Kubernetes, KubeBlocks PostgreSQL, ttyd, FileBrowser |
-| Integrations | GitHub App, sandbox-side commands, AI proxy |
-| Testing | Vitest, ESLint |
+- Node.js 22.12 or later
+- pnpm 10.20.0
+- PostgreSQL
+- a GitHub OAuth App
-## Repository Map
+Configure the GitHub OAuth callback as:
```text
-app/ Next.js routes, dashboard, auth, API endpoints
-components/ Shared UI and layout components
-lib/ Application libraries and service code
-prisma/ Database schema and migrations
-runtime/ Runtime image and sandbox support files
-public/ Static icons and assets
+${BETTER_AUTH_URL}/api/auth/callback/github
```
## Local Development
-### Prerequisites
-
-- Node.js 22.12.0 or later
-- pnpm 10.20.0
-- PostgreSQL database
-- Kubernetes cluster with KubeBlocks installed
-- GitHub App and OAuth credentials for GitHub integration work
-
-### Setup
-
```bash
-git clone https://github.com/FullAgent/fulling.git
-cd fulling
-pnpm install
+corepack pnpm install
cp .env.template .env.local
-npx prisma generate
-npx prisma db push
-pnpm dev
+# Fill in the database, Better Auth, and GitHub values.
+corepack pnpm prisma:migrate
+corepack pnpm dev
```
Open [http://localhost:3000](http://localhost:3000).
-## Useful Commands
+This release uses a new baseline schema. Run it against a new database or an
+explicitly reset database; it does not migrate v2 data.
+
+## Commands
```bash
-pnpm dev # Start the development server
-pnpm build # Generate Prisma client and build for production
-pnpm lint # Run ESLint
-pnpm test # Run Vitest
-pnpm test:watch # Run Vitest in watch mode
-npx prisma generate # Generate Prisma client
-npx prisma db push # Push schema changes to the database
+corepack pnpm dev # Start the development server
+corepack pnpm build # Generate Prisma client and build
+corepack pnpm lint # Run ESLint
+corepack pnpm test # Run Vitest
+corepack pnpm test:e2e # Run Playwright
+corepack pnpm prisma:format # Format the Prisma schema
+corepack pnpm prisma:validate # Validate the Prisma schema
+corepack pnpm prisma:migrate # Deploy the baseline migration
```
-## Documentation
+## Credential Boundary
+
+Kubeconfigs are stored as plaintext in PostgreSQL. Database read access grants
+access to users' Kubernetes credentials. Browser-facing APIs never return saved
+content, and logs must not contain tokens, keys, certificates, or kubeconfig
+content.
+
+Validation rejects executable credential plugins, auth-provider plugins, local
+file credential fields, proxy configuration, non-HTTPS API servers, redirects,
+and anonymous identities. Authenticated users may still configure an HTTPS API
+server on any network address. This authenticated outbound-request/SSRF boundary
+is an explicit deployment decision.
+
+## Deployment Reset
+
+Before replacing a v2 deployment, follow
+[docs/v2-resource-inventory.md](./docs/v2-resource-inventory.md). Resetting the
+Fulling database does not delete Kubernetes resources created by v2.
-- [AGENTS.md](./AGENTS.md) - agent guidance for this repository
-- [docs/architecture.md](./docs/architecture.md) - v3 system architecture
+Use [docs/github-oauth-verification.md](./docs/github-oauth-verification.md) to
+verify a real OAuth application before release.
diff --git a/app/(auth)/auth-error/page.tsx b/app/(auth)/auth-error/page.tsx
deleted file mode 100644
index 08b90b9..0000000
--- a/app/(auth)/auth-error/page.tsx
+++ /dev/null
@@ -1,133 +0,0 @@
-'use client';
-
-import { Suspense } from 'react';
-import { MdArrowBack, MdErrorOutline, MdHome, MdRefresh } from 'react-icons/md';
-import Link from 'next/link';
-import { useSearchParams } from 'next/navigation';
-
-import { Button } from '@/components/ui/button';
-
-function ErrorContent() {
- const searchParams = useSearchParams();
- const error = searchParams.get('error');
-
- const getErrorDetails = () => {
- switch (error) {
- case 'Configuration':
- return {
- title: 'Configuration Error',
- message: 'There was a problem with the authentication configuration.',
- code: 'AUTH_CONFIG_ERROR',
- };
- case 'AccessDenied':
- return {
- title: 'Access Denied',
- message: 'You do not have permission to sign in.',
- code: 'AUTH_ACCESS_DENIED',
- };
- case 'Verification':
- return {
- title: 'Verification Failed',
- message: 'The verification token has expired or has already been used.',
- code: 'AUTH_VERIFICATION_FAILED',
- };
- default:
- return {
- title: 'Authentication Error',
- message: 'An unexpected error occurred during authentication.',
- code: 'AUTH_UNKNOWN_ERROR',
- };
- }
- };
-
- const errorDetails = getErrorDetails();
-
- return (
-
- {/* Error Header - VSCode style */}
-
-
-
-
-
-
{errorDetails.title}
-
{errorDetails.message}
-
-
-
- {/* Error Code Box - VSCode terminal style */}
-
-
- ✗
- Error Code
-
-
{errorDetails.code}
-
-
- {/* Actions - VSCode button style */}
-
-
-
-
- Try Again
-
-
-
-
-
- Go to Home
-
-
-
-
-
- Back to Projects
-
-
-
-
- {/* Help Text */}
-
-
- If this problem persists, please contact support or check your authentication
- configuration.
-
-
-
- );
-}
-
-export default function ErrorPage() {
- return (
-
- }
- >
-
-
-
- );
-}
diff --git a/app/(auth)/login/_components/github-login-button.tsx b/app/(auth)/login/_components/github-login-button.tsx
new file mode 100644
index 0000000..473f522
--- /dev/null
+++ b/app/(auth)/login/_components/github-login-button.tsx
@@ -0,0 +1,36 @@
+'use client'
+
+import { useState } from 'react'
+import { Github, LoaderCircle } from 'lucide-react'
+
+import { Button } from '@/components/ui/button'
+import { signIn } from '@/lib/auth-client'
+
+export function GitHubLoginButton() {
+ const [isPending, setIsPending] = useState(false)
+ const [error, setError] = useState(null)
+
+ async function handleSignIn() {
+ setIsPending(true)
+ setError(null)
+ const result = await signIn.social({
+ provider: 'github',
+ callbackURL: '/workspace',
+ errorCallbackURL: '/login?error=oauth',
+ })
+ if (result?.error) {
+ setError('GitHub sign-in could not be started. Please try again.')
+ setIsPending(false)
+ }
+ }
+
+ return (
+
+
+ {isPending ? : }
+ {isPending ? 'Connecting...' : 'Continue with GitHub'}
+
+ {error ?
{error}
: null}
+
+ )
+}
diff --git a/app/(auth)/login/page.tsx b/app/(auth)/login/page.tsx
index f4776c2..c865040 100644
--- a/app/(auth)/login/page.tsx
+++ b/app/(auth)/login/page.tsx
@@ -1,132 +1,44 @@
-'use client';
+import Link from 'next/link'
+import { redirect } from 'next/navigation'
-import { useState } from 'react';
-import { FaGithub } from 'react-icons/fa';
-import { MdPerson } from 'react-icons/md';
-import { useRouter } from 'next/navigation';
-import { signIn } from 'next-auth/react';
+import { getSession } from '@/lib/auth/session'
-import { Button } from '@/components/ui/button';
-import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
-import { Input } from '@/components/ui/input';
-import { Label } from '@/components/ui/label';
+import { GitHubLoginButton } from './_components/github-login-button'
-export default function LoginPage() {
- const router = useRouter();
- const [username, setUsername] = useState('');
- const [password, setPassword] = useState('');
- const [error, setError] = useState('');
- const [isLoading, setIsLoading] = useState(false);
-
- const handleCredentialsLogin = async (e: React.FormEvent) => {
- e.preventDefault();
- setIsLoading(true);
- setError('');
+type LoginPageProps = {
+ searchParams: Promise<{ error?: string }>
+}
- try {
- const result = await signIn('credentials', {
- username,
- password,
- redirect: false,
- callbackUrl: '/projects',
- });
+export default async function LoginPage({ searchParams }: LoginPageProps) {
+ if (await getSession()) redirect('/workspace')
- if (result?.error) {
- // Show generic error message for security
- setError('Invalid username or password');
- } else if (result?.ok) {
- // Login successful - redirect to projects
- router.push('/projects');
- router.refresh();
- }
- } catch (err) {
- console.error('Login error:', err);
- setError('An error occurred. Please try again.');
- } finally {
- setIsLoading(false);
- }
- };
+ const { error } = await searchParams
return (
-
-
-
- Welcome to Fulling
-
- You're one click away from creating your own full-stack app.
-
-
-
-
-
-
-
- signIn('github-app', { callbackUrl: '/projects?github_install=true' })}
- className="w-full bg-secondary text-secondary-foreground hover:bg-muted rounded-md"
- size="lg"
- variant="outline"
+
+
+
+
-
- Continue with GitHub
-
-
-
-
- );
+ Fulling
+
+
+ Sign in to your workspace
+
+ Use your GitHub account to continue.
+ {error === 'oauth' ? (
+
+ GitHub sign-in could not be completed. Please try again.
+
+ ) : null}
+
+
+
+
+
+ )
}
diff --git a/app/(dashboard)/_components/dashboard-header.tsx b/app/(dashboard)/_components/dashboard-header.tsx
new file mode 100644
index 0000000..cfc9b48
--- /dev/null
+++ b/app/(dashboard)/_components/dashboard-header.tsx
@@ -0,0 +1,51 @@
+'use client'
+
+import { useState } from 'react'
+import { LogOut, Settings2 } from 'lucide-react'
+import Link from 'next/link'
+import { useRouter } from 'next/navigation'
+
+import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
+import { Button } from '@/components/ui/button'
+import { signOut } from '@/lib/auth-client'
+
+type DashboardHeaderProps = { userName: string; userEmail: string; userImage: string | null }
+
+export function DashboardHeader({ userName, userEmail, userImage }: DashboardHeaderProps) {
+ const router = useRouter()
+ const [isSigningOut, setIsSigningOut] = useState(false)
+
+ async function handleSignOut() {
+ setIsSigningOut(true)
+ const result = await signOut()
+
+ if (result.error) {
+ setIsSigningOut(false)
+ return
+ }
+
+ router.push('/login')
+ router.refresh()
+ }
+
+ return (
+
+
+
Fulling
+
+ Workspace
+ Kubeconfig
+
+
+
{userName.slice(0, 1).toUpperCase()}
+
+
+
+
+
+ Workspace
+ Kubeconfig
+
+
+ )
+}
diff --git a/app/(dashboard)/_components/kubeconfig-status.test.ts b/app/(dashboard)/_components/kubeconfig-status.test.ts
new file mode 100644
index 0000000..8991ff1
--- /dev/null
+++ b/app/(dashboard)/_components/kubeconfig-status.test.ts
@@ -0,0 +1,25 @@
+import { describe, expect, it } from 'vitest'
+
+import { formatUpdatedAt, parseKubeconfigStatus } from './kubeconfig-status'
+
+describe('parseKubeconfigStatus', () => {
+ it('keeps the public status fields', () => {
+ expect(
+ parseKubeconfigStatus({ configured: true, updatedAt: '2026-07-13T10:30:00.000Z' }),
+ ).toEqual({ configured: true, updatedAt: '2026-07-13T10:30:00.000Z' })
+ })
+
+ it('rejects malformed responses', () => {
+ expect(() => parseKubeconfigStatus(null)).toThrow('Invalid kubeconfig status response.')
+ expect(() => parseKubeconfigStatus({ configured: 'yes', updatedAt: 123 })).toThrow(
+ 'Invalid kubeconfig status response.'
+ )
+ })
+})
+
+describe('formatUpdatedAt', () => {
+ it('does not render invalid dates', () => {
+ expect(formatUpdatedAt(null)).toBe('Not available')
+ expect(formatUpdatedAt('not-a-date')).toBe('Not available')
+ })
+})
diff --git a/app/(dashboard)/_components/kubeconfig-status.ts b/app/(dashboard)/_components/kubeconfig-status.ts
new file mode 100644
index 0000000..1f3ff34
--- /dev/null
+++ b/app/(dashboard)/_components/kubeconfig-status.ts
@@ -0,0 +1,34 @@
+export type KubeconfigStatus = {
+ configured: boolean
+ updatedAt: string | null
+}
+
+export function parseKubeconfigStatus(value: unknown): KubeconfigStatus {
+ if (!value || typeof value !== 'object') {
+ throw new Error('Invalid kubeconfig status response.')
+ }
+
+ const { configured, updatedAt } = value as Record
+ if (
+ typeof configured !== 'boolean' ||
+ (updatedAt !== null && typeof updatedAt !== 'string')
+ ) {
+ throw new Error('Invalid kubeconfig status response.')
+ }
+
+ return { configured, updatedAt }
+}
+
+export function formatUpdatedAt(updatedAt: string | null): string {
+ if (!updatedAt) {
+ return 'Not available'
+ }
+
+ const date = new Date(updatedAt)
+ return Number.isNaN(date.valueOf())
+ ? 'Not available'
+ : new Intl.DateTimeFormat(undefined, {
+ dateStyle: 'medium',
+ timeStyle: 'short',
+ }).format(date)
+}
diff --git a/app/(dashboard)/layout.tsx b/app/(dashboard)/layout.tsx
new file mode 100644
index 0000000..5ab3c22
--- /dev/null
+++ b/app/(dashboard)/layout.tsx
@@ -0,0 +1,14 @@
+import { requireSession } from '@/lib/auth/session'
+
+import { DashboardHeader } from './_components/dashboard-header'
+
+export default async function DashboardLayout({ children }: Readonly<{ children: React.ReactNode }>) {
+ const { user } = await requireSession()
+
+ return (
+
+
+ {children}
+
+ )
+}
diff --git a/app/(dashboard)/projects/(list)/_components/create-project-card.tsx b/app/(dashboard)/projects/(list)/_components/create-project-card.tsx
deleted file mode 100644
index 6c6a3f5..0000000
--- a/app/(dashboard)/projects/(list)/_components/create-project-card.tsx
+++ /dev/null
@@ -1,48 +0,0 @@
-'use client'
-
-import { useState } from 'react'
-import { MdAdd } from 'react-icons/md'
-
-import { cn } from '@/lib/utils'
-
-import CreateProjectDialog from '../../_components/create-project-dialog'
-
-export function CreateProjectCard() {
- const [isDialogOpen, setIsDialogOpen] = useState(false)
-
- return (
- <>
- setIsDialogOpen(true)}
- className={cn(
- 'group bg-card/30 border border-dashed border-border rounded-xl',
- 'flex flex-col items-center justify-center p-8',
- 'hover:bg-card/50 hover:border-primary/50',
- 'transition-all cursor-pointer h-full min-h-[280px]'
- )}
- >
-
-
-
-
- Create New Project
-
-
- Start from scratch or use one of our templates.
-
-
-
-
- >
- )
-}
diff --git a/app/(dashboard)/projects/(list)/_components/home-page-content.tsx b/app/(dashboard)/projects/(list)/_components/home-page-content.tsx
deleted file mode 100644
index 40501f6..0000000
--- a/app/(dashboard)/projects/(list)/_components/home-page-content.tsx
+++ /dev/null
@@ -1,111 +0,0 @@
-'use client'
-
-import { useEffect, useState } from 'react'
-import { useRouter, useSearchParams } from 'next/navigation'
-import { toast } from 'sonner'
-
-import { getInstallations } from '@/lib/actions/github'
-import type { ProjectWithRelations } from '@/lib/data/project'
-import { env } from '@/lib/env'
-import type { ProjectDisplayStatus } from '@/lib/util/project-display-status'
-
-import { PageHeaderWithFilter } from './page-header-with-filter'
-import { ProjectList } from './project-list'
-
-const REFRESH_INTERVAL_MS = 3000
-
-interface HomePageContentProps {
- projects: ProjectWithRelations<{ sandboxes: true; tasks: true }>[]
-}
-
-export function HomePageContent({ projects }: HomePageContentProps) {
- const router = useRouter()
- const searchParams = useSearchParams()
- const [activeFilter, setActiveFilter] = useState<'ALL' | ProjectDisplayStatus>('ALL')
- const [hasTriggeredInstall, setHasTriggeredInstall] = useState(false)
-
- useEffect(() => {
- const interval = setInterval(() => {
- router.refresh()
- }, REFRESH_INTERVAL_MS)
-
- return () => clearInterval(interval)
- }, [router])
-
- useEffect(() => {
- if (hasTriggeredInstall) return
-
- const shouldTriggerInstall = searchParams.get('github_install') === 'true'
- if (!shouldTriggerInstall) return
-
- const triggerGitHubAppInstall = async () => {
- setHasTriggeredInstall(true)
-
- try {
- const result = await getInstallations()
- if (result.success && result.data.length > 0) {
- router.replace('/projects')
- return
- }
-
- const appName = env.NEXT_PUBLIC_GITHUB_APP_NAME
- if (!appName) {
- toast.error('GitHub App is not configured')
- router.replace('/projects')
- return
- }
-
- const installUrl = `https://github.com/apps/${appName}/installations/new`
-
- const width = 800
- const height = 800
- const left = window.screen.width / 2 - width / 2
- const top = window.screen.height / 2 - height / 2
-
- const popup = window.open(
- installUrl,
- 'github-app-install',
- `width=${width},height=${height},left=${left},top=${top},resizable=yes,scrollbars=yes`
- )
-
- if (!popup) {
- toast.error('Failed to open popup window. Please allow popups for this site.')
- router.replace('/projects')
- return
- }
-
- const handleMessage = (event: MessageEvent) => {
- if (event.origin !== window.location.origin) return
- if (event.data.type !== 'github-app-installed') return
-
- window.removeEventListener('message', handleMessage)
- toast.success('GitHub App installed successfully!')
- router.replace('/projects')
- router.refresh()
- }
-
- window.addEventListener('message', handleMessage)
-
- const checkClosed = setInterval(() => {
- if (popup.closed) {
- clearInterval(checkClosed)
- window.removeEventListener('message', handleMessage)
- router.replace('/projects')
- }
- }, 500)
- } catch (error) {
- console.error('Failed to trigger GitHub App install:', error)
- router.replace('/projects')
- }
- }
-
- triggerGitHubAppInstall()
- }, [searchParams, hasTriggeredInstall, router])
-
- return (
- <>
-
-
- >
- )
-}
diff --git a/app/(dashboard)/projects/(list)/_components/page-header-with-filter.tsx b/app/(dashboard)/projects/(list)/_components/page-header-with-filter.tsx
deleted file mode 100644
index 37c781f..0000000
--- a/app/(dashboard)/projects/(list)/_components/page-header-with-filter.tsx
+++ /dev/null
@@ -1,57 +0,0 @@
-'use client'
-
-import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
-import type { ProjectDisplayStatus } from '@/lib/util/project-display-status'
-import { cn } from '@/lib/utils'
-
-type FilterStatus = 'ALL' | ProjectDisplayStatus
-
-interface PageHeaderWithFilterProps {
- activeFilter: FilterStatus
- onFilterChange: (filter: FilterStatus) => void
-}
-
-const filters: { label: string; value: FilterStatus }[] = [
- { label: 'All', value: 'ALL' },
- { label: 'Running', value: 'RUNNING' },
- { label: 'Importing', value: 'IMPORTING' },
- { label: 'Stopped', value: 'STOPPED' },
- { label: 'Needs Attention', value: 'NEEDS_ATTENTION' },
-]
-
-export function PageHeaderWithFilter({ activeFilter, onFilterChange }: PageHeaderWithFilterProps) {
- return (
-
-
-
- My Projects
-
-
- Manage and monitor your full stack project workspaces.
-
-
-
value && onFilterChange(value as FilterStatus)}
- className="flex bg-sidebar p-1 rounded-lg border border-border"
- spacing={1}
- >
- {filters.map((filter) => (
-
- {filter.label}
-
- ))}
-
-
- )
-}
diff --git a/app/(dashboard)/projects/(list)/_components/project-actions-menu.tsx b/app/(dashboard)/projects/(list)/_components/project-actions-menu.tsx
deleted file mode 100644
index 23adbec..0000000
--- a/app/(dashboard)/projects/(list)/_components/project-actions-menu.tsx
+++ /dev/null
@@ -1,206 +0,0 @@
-'use client';
-
-import { useState } from 'react';
-import { MdDeleteOutline, MdMoreHoriz, MdPause, MdPlayArrow, MdRefresh, MdSettings } from 'react-icons/md';
-import { ProjectStatus } from '@prisma/client';
-import { useRouter } from 'next/navigation';
-
-import { Button } from '@/components/ui/button';
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuSeparator,
- DropdownMenuTrigger,
-} from '@/components/ui/dropdown-menu';
-import {
- FullScreenDialog,
- FullScreenDialogAction,
- FullScreenDialogClose,
- FullScreenDialogContent,
- FullScreenDialogDescription,
- FullScreenDialogFooter,
- FullScreenDialogHeader,
- FullScreenDialogTitle,
-} from '@/components/ui/fullscreen-dialog';
-import { Input } from '@/components/ui/input';
-import { Label } from '@/components/ui/label';
-import { useProjectOperations } from '@/hooks/use-project-operations';
-
-interface ProjectActionsMenuProps {
- projectId: string;
- projectName: string;
- status: ProjectStatus;
-}
-
-export function ProjectActionsMenu({ projectId, projectName, status }: ProjectActionsMenuProps) {
- const router = useRouter();
- const [showDeleteDialog, setShowDeleteDialog] = useState(false);
- const [confirmInput, setConfirmInput] = useState('');
- const { executeOperation, loading } = useProjectOperations(projectId);
-
- // Determine available actions based on status
- const showStart = status === 'STOPPED';
- const showStop = status !== 'STOPPED';
-
- // Check if the confirmation input matches the project name
- const isConfirmValid = confirmInput === projectName;
-
- const handleDeleteClick = () => {
- setShowDeleteDialog(true);
- };
-
- const handleDeleteConfirm = () => {
- if (!isConfirmValid) return;
- setShowDeleteDialog(false);
- setConfirmInput('');
- executeOperation('DELETE');
- };
-
- const handleDialogOpenChange = (open: boolean) => {
- setShowDeleteDialog(open);
- if (!open) {
- setConfirmInput('');
- }
- };
-
- const handleSettingsClick = () => {
- router.push(`/projects/${projectId}/environment`);
- };
-
- return (
- <>
-
-
- e.stopPropagation()}
- >
-
- More options
-
-
-
- {/* Start/Stop based on status */}
- {showStart && (
- {
- e.stopPropagation();
- executeOperation('START');
- }}
- disabled={loading !== null}
- className="gap-3 px-3 py-2 text-xs font-medium text-muted-foreground hover:text-white hover:bg-white/5"
- >
- {loading === 'START' ? (
- <>
-
- Starting...
- >
- ) : (
- <>
-
- Start
- >
- )}
-
- )}
- {showStop && (
- {
- e.stopPropagation();
- executeOperation('STOP');
- }}
- disabled={loading !== null}
- className="gap-3 px-3 py-2 text-xs font-medium text-muted-foreground hover:text-white hover:bg-white/5"
- >
- {loading === 'STOP' ? (
- <>
-
- Stopping...
- >
- ) : (
- <>
-
- Stop
- >
- )}
-
- )}
-
- {/* Settings */}
- {
- e.stopPropagation();
- handleSettingsClick();
- }}
- className="gap-3 px-3 py-2 text-xs font-medium text-muted-foreground hover:text-white hover:bg-white/5"
- >
-
- Settings
-
-
-
-
- {/* Delete */}
- {
- e.stopPropagation();
- handleDeleteClick();
- }}
- disabled={loading !== null}
- className="gap-3 px-3 py-2 text-xs font-medium text-red-500 hover:text-red-400 hover:bg-red-500/10"
- >
-
- Delete
-
-
-
-
- {/* Delete Confirmation Dialog */}
-
-
-
-
- Are you sure you want to delete
- "{projectName}" ?
-
-
- This will terminate all resources (databases, sandboxes) and cannot be undone.
-
-
-
- {/* Confirmation Input */}
-
-
- Type {projectName} to confirm
-
- setConfirmInput(e.target.value)}
- placeholder={projectName}
- className="bg-background border-border rounded-xl px-4 py-3 text-white placeholder:text-muted-foreground/30 focus-visible:border-red-500 focus-visible:ring-red-500/50 font-mono text-sm shadow-inner"
- />
-
-
-
-
- Cancel
-
-
- Permanently Delete
-
-
-
-
- >
- );
-}
diff --git a/app/(dashboard)/projects/(list)/_components/project-card.tsx b/app/(dashboard)/projects/(list)/_components/project-card.tsx
deleted file mode 100644
index 4e347cc..0000000
--- a/app/(dashboard)/projects/(list)/_components/project-card.tsx
+++ /dev/null
@@ -1,139 +0,0 @@
-import { MdOpenInNew } from 'react-icons/md'
-import { ProjectStatus } from '@prisma/client'
-import Link from 'next/link'
-
-import { Button } from '@/components/ui/button'
-import { Card, CardContent, CardHeader } from '@/components/ui/card'
-import type { ProjectDisplayStatus } from '@/lib/util/project-display-status'
-import { cn } from '@/lib/utils'
-
-import { ProjectActionsMenu } from './project-actions-menu'
-import { statusConfig } from './status-config'
-
-interface ProjectCardProps {
- id: string
- name: string
- description: string
- status: ProjectStatus
- displayStatus: ProjectDisplayStatus
- updatedAt: string
- publicUrl?: string | null
-}
-
-
-export function ProjectCard({
- id,
- name,
- description,
- status,
- displayStatus,
- updatedAt,
- publicUrl,
-}: ProjectCardProps) {
- const config = statusConfig[displayStatus]
- const initial = name.charAt(0).toUpperCase()
-
- // Handle open project button - open sandbox publicUrl in new tab
- const handleOpenProject = (e: React.MouseEvent) => {
- e.preventDefault()
- e.stopPropagation()
- if (publicUrl) {
- window.open(publicUrl, '_blank', 'noopener,noreferrer')
- }
- }
-
- return (
-
-
- {/* Card Header */}
-
- {/* More dropdown */}
-
-
- {/* Initial Avatar */}
-
- {initial}
-
-
-
- {/* Card Content */}
-
-
-
- {name}
-
-
-
-
- {description}
-
-
- {/* Card Footer */}
-
-
- {/* Status indicator */}
-
- {displayStatus === 'RUNNING' && (
-
- )}
-
-
-
- {config.label}
-
-
• {updatedAt}
-
-
- {/* Open button */}
-
-
-
-
-
-
-
- )
-}
diff --git a/app/(dashboard)/projects/(list)/_components/project-list.tsx b/app/(dashboard)/projects/(list)/_components/project-list.tsx
deleted file mode 100644
index 82d95dd..0000000
--- a/app/(dashboard)/projects/(list)/_components/project-list.tsx
+++ /dev/null
@@ -1,41 +0,0 @@
-import type { ProjectWithRelations } from '@/lib/data/project'
-import { formatRelativeTime } from '@/lib/util/format-time'
-import {
- getProjectDisplayStatus,
- type ProjectDisplayStatus,
-} from '@/lib/util/project-display-status'
-
-import { CreateProjectCard } from './create-project-card'
-import { ProjectCard } from './project-card'
-
-interface ProjectListProps {
- projects: ProjectWithRelations<{ sandboxes: true; tasks: true }>[]
- activeFilter: 'ALL' | ProjectDisplayStatus
-}
-
-export function ProjectList({ projects, activeFilter }: ProjectListProps) {
- // Map to frontend format with sandbox publicUrl
- const mappedProjects = projects.map((p) => ({
- id: p.id,
- name: p.name,
- description: p.description || 'No description',
- status: p.status,
- displayStatus: getProjectDisplayStatus(p),
- updatedAt: formatRelativeTime(p.updatedAt),
- publicUrl: p.sandboxes?.[0]?.publicUrl,
- }))
-
- const filteredProjects =
- activeFilter === 'ALL'
- ? mappedProjects
- : mappedProjects.filter((p) => p.displayStatus === activeFilter)
-
- return (
-
- {filteredProjects.map((project) => (
-
- ))}
-
-
- )
-}
diff --git a/app/(dashboard)/projects/(list)/_components/status-config.ts b/app/(dashboard)/projects/(list)/_components/status-config.ts
deleted file mode 100644
index 0cfe4c4..0000000
--- a/app/(dashboard)/projects/(list)/_components/status-config.ts
+++ /dev/null
@@ -1,71 +0,0 @@
-import type { ProjectDisplayStatus } from '@/lib/util/project-display-status'
-
-export interface StatusConfigItem {
- color: string
- bg: string
- label: string
- animate?: string
-}
-
-export const statusConfig: Record = {
- // Stable states
- RUNNING: {
- color: 'text-emerald-500',
- bg: 'bg-emerald-500',
- label: 'Running',
- animate: 'animate-pulse',
- },
- STOPPED: {
- color: 'text-gray-500',
- bg: 'bg-gray-500',
- label: 'Stopped',
- },
- // Transition states
- CREATING: {
- color: 'text-yellow-500',
- bg: 'bg-yellow-500',
- label: 'Creating',
- animate: 'animate-pulse',
- },
- IMPORTING: {
- color: 'text-sky-400',
- bg: 'bg-sky-400',
- label: 'Importing',
- animate: 'animate-pulse',
- },
- UPDATING: {
- color: 'text-blue-500',
- bg: 'bg-blue-500',
- label: 'Updating',
- animate: 'animate-pulse',
- },
- STARTING: {
- color: 'text-cyan-500',
- bg: 'bg-cyan-500',
- label: 'Starting',
- animate: 'animate-pulse',
- },
- STOPPING: {
- color: 'text-orange-500',
- bg: 'bg-orange-500',
- label: 'Stopping',
- animate: 'animate-pulse',
- },
- TERMINATING: {
- color: 'text-red-400',
- bg: 'bg-red-400',
- label: 'Terminating',
- animate: 'animate-pulse',
- },
- // Special states
- ERROR: {
- color: 'text-red-500',
- bg: 'bg-red-500',
- label: 'Error',
- },
- NEEDS_ATTENTION: {
- color: 'text-amber-400',
- bg: 'bg-amber-400',
- label: 'Needs Attention',
- },
-}
diff --git a/app/(dashboard)/projects/(list)/layout.tsx b/app/(dashboard)/projects/(list)/layout.tsx
deleted file mode 100644
index 826509b..0000000
--- a/app/(dashboard)/projects/(list)/layout.tsx
+++ /dev/null
@@ -1,19 +0,0 @@
-import { Sidebar } from '@/components/sidebar'
-
-import { SearchBar } from '../_components/search-bar'
-
-export default function HomeLayout({
- children,
-}: {
- children: React.ReactNode
-}) {
- return (
-
- )
-}
diff --git a/app/(dashboard)/projects/(list)/page.tsx b/app/(dashboard)/projects/(list)/page.tsx
deleted file mode 100644
index 7764b48..0000000
--- a/app/(dashboard)/projects/(list)/page.tsx
+++ /dev/null
@@ -1,29 +0,0 @@
-import { redirect } from 'next/navigation'
-
-import { auth } from '@/lib/auth'
-import { getProjects } from '@/lib/data/project'
-
-import { HomePageContent } from './_components/home-page-content'
-
-export const metadata = {
- title: 'My Projects | Fulling',
- description: 'Manage and monitor your full stack project workspaces.',
-}
-
-export default async function HomePage() {
- const session = await auth()
-
- if (!session) {
- redirect('/login')
- }
-
- const projects = await getProjects(session.user.id, { sandboxes: true, tasks: true })
-
- return (
-
-
-
-
-
- )
-}
diff --git a/app/(dashboard)/projects/[id]/_components/settings-layout.tsx b/app/(dashboard)/projects/[id]/_components/settings-layout.tsx
deleted file mode 100644
index aca701c..0000000
--- a/app/(dashboard)/projects/[id]/_components/settings-layout.tsx
+++ /dev/null
@@ -1,47 +0,0 @@
-/**
- * Settings page layout component
- * Used for project settings pages (database, environments, etc.)
- * VSCode Dark Modern style with clean design
- */
-
-'use client';
-
-import type { ReactNode } from 'react';
-
-import { Skeleton } from '@/components/ui/skeleton';
-
-interface SettingsLayoutProps {
- title: string;
- description: string;
- children: ReactNode;
- loading?: boolean;
-}
-
-/**
- * Layout wrapper for project settings pages
- * Uses skeleton to maintain layout stability during loading
- */
-export function SettingsLayout({ title, description, children, loading }: SettingsLayoutProps) {
- return (
-
-
-
- {title}
- {description}
-
-
-
- {loading ? (
- <>
-
-
-
- >
- ) : (
- children
- )}
-
-
-
- );
-}
diff --git a/app/(dashboard)/projects/[id]/auth/page.tsx b/app/(dashboard)/projects/[id]/auth/page.tsx
deleted file mode 100644
index 69e2053..0000000
--- a/app/(dashboard)/projects/[id]/auth/page.tsx
+++ /dev/null
@@ -1,171 +0,0 @@
-/**
- * Authentication Configuration Page
- * Configure OAuth providers and NextAuth settings
- */
-
-'use client';
-
-import { FaGithub } from 'react-icons/fa';
-import { MdOpenInNew, MdVpnKey } from 'react-icons/md';
-import { useParams } from 'next/navigation';
-
-import { EnvVarSection } from '@/components/config/env-var-section';
-import {
- useBatchUpdateEnvironmentVariables,
- useEnvironmentVariables,
-} from '@/hooks/use-environment-variables';
-import { useProject } from '@/hooks/use-project';
-
-import { SettingsLayout } from '../_components/settings-layout';
-
-/**
- * Generate a secure random secret
- */
-function generateSecret(): string {
- const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
- let secret = '';
- for (let i = 0; i < 32; i++) {
- secret += chars.charAt(Math.floor(Math.random() * chars.length));
- }
- return secret;
-}
-
-function AuthPageContent() {
- const params = useParams();
- const projectId = params.id as string;
-
- const { data: envData, isLoading: envLoading } = useEnvironmentVariables(projectId);
- const { data: project, isLoading: projectLoading } = useProject(projectId);
- const batchUpdate = useBatchUpdateEnvironmentVariables(projectId);
-
- const authVars = envData?.auth || [];
-
- const handleSave = async (
- variables: Array<{ key: string; value: string; isSecret?: boolean }>
- ) => {
- await batchUpdate.mutateAsync({
- category: 'auth',
- variables,
- });
- };
-
- // GitHub OAuth templates
- const githubTemplates = [
- {
- key: 'GITHUB_CLIENT_ID',
- label: 'Client ID',
- placeholder: 'Enter your GitHub OAuth App Client ID',
- isSecret: false,
- description: 'Get this from GitHub Developer Settings → OAuth Apps',
- },
- {
- key: 'GITHUB_CLIENT_SECRET',
- label: 'Client Secret',
- placeholder: 'Enter your GitHub OAuth App Client Secret',
- isSecret: true,
- description: 'Keep this secret! Get it from your GitHub OAuth App settings',
- },
- ];
-
- // NextAuth templates
- const nextAuthTemplates = [
- {
- key: 'NEXTAUTH_URL',
- label: 'Application URL',
- placeholder: 'https://your-app.example.com',
- isSecret: false,
- description: 'The public URL of your application',
- },
- {
- key: 'NEXTAUTH_SECRET',
- label: 'NextAuth Secret',
- placeholder: 'Click Generate to create a secure secret',
- isSecret: true,
- description: 'A random string used to hash tokens and sign cookies (min 32 characters)',
- generateValue: generateSecret,
- },
- ];
-
- return (
-
-
- {/* GitHub OAuth Section */}
-
-
-
-
-
- {/* Setup Instructions */}
-
-
Setup Instructions
-
- Go to GitHub Settings → Developer settings → OAuth Apps
- Click "New OAuth App" or select an existing app
- Set the Homepage URL and Authorization callback URL
- Copy the Client ID and Client Secret to the fields above
- Save your changes
-
-
-
-
- {/* Divider */}
-
-
- {/* NextAuth Configuration Section */}
-
-
-
-
NextAuth Configuration
-
-
-
-
- {/* Important Notes */}
-
-
Important Notes
-
- The NextAuth URL must match your application URL exactly
- The secret should be at least 32 characters long
- Never commit your NEXTAUTH_SECRET to version control
- Use a strong, unique secret for production
-
-
-
-
-
- );
-}
-
-export default AuthPageContent;
diff --git a/app/(dashboard)/projects/[id]/database/_components/add-database-card.tsx b/app/(dashboard)/projects/[id]/database/_components/add-database-card.tsx
deleted file mode 100644
index e80046f..0000000
--- a/app/(dashboard)/projects/[id]/database/_components/add-database-card.tsx
+++ /dev/null
@@ -1,56 +0,0 @@
-'use client'
-
-import { useTransition } from 'react'
-import { useRouter } from 'next/navigation'
-import { toast } from 'sonner'
-
-import { Button } from '@/components/ui/button'
-import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
-import { createDatabase } from '@/lib/actions/database'
-
-interface AddDatabaseCardProps {
- projectId: string
- projectName: string
-}
-
-export function AddDatabaseCard({ projectId }: AddDatabaseCardProps) {
- const router = useRouter()
- const [isPending, startTransition] = useTransition()
-
- const handleCreateDatabase = () => {
- startTransition(async () => {
- const result = await createDatabase(projectId)
-
- if (!result.success) {
- toast.error(result.error || 'Failed to create database')
- return
- }
-
- toast.success('Database is being created...')
- router.refresh()
- })
- }
-
- return (
-
-
- No Database
-
- This project doesn't have a database yet. Add a PostgreSQL database to get started.
-
-
-
-
- {isPending ? 'Creating Database...' : 'Add PostgreSQL Database'}
-
-
- A PostgreSQL cluster will be created with 1Gi storage, 100m CPU, and 128Mi memory.
-
-
-
- )
-}
diff --git a/app/(dashboard)/projects/[id]/database/_components/connection-string.tsx b/app/(dashboard)/projects/[id]/database/_components/connection-string.tsx
deleted file mode 100644
index e37ec94..0000000
--- a/app/(dashboard)/projects/[id]/database/_components/connection-string.tsx
+++ /dev/null
@@ -1,95 +0,0 @@
-'use client';
-
-import { useMemo, useState } from 'react';
-import {
- MdCheck,
- MdContentCopy,
- MdInfo,
- MdVisibility,
- MdVisibilityOff,
-} from 'react-icons/md';
-
-import { Button } from '@/components/ui/button';
-import { cn } from '@/lib/utils';
-
-interface ConnectionStringProps {
- connectionString: string;
-}
-
-export function ConnectionString({ connectionString }: ConnectionStringProps) {
- const [isVisible, setIsVisible] = useState(false);
- const [copied, setCopied] = useState(false);
-
- // Memoize masked string to avoid recalculation on every render
- const displayValue = useMemo(() => {
- if (isVisible) return connectionString;
- return '•'.repeat(Math.min(connectionString.length, 50));
- }, [connectionString, isVisible]);
-
- const handleCopy = async () => {
- await navigator.clipboard.writeText(connectionString);
- setCopied(true);
- setTimeout(() => setCopied(false), 2000);
- };
-
- return (
-
- {/* Header with title and badge */}
-
-
- Full Connection String
-
-
- Read-only
-
-
-
- {/* Connection string display */}
-
-
- {displayValue}
-
-
- {/* Action buttons */}
-
- {/* Toggle visibility */}
- setIsVisible(!isVisible)}
- variant="secondary"
- size="icon"
- className="h-8 w-8"
- aria-label={isVisible ? 'Hide connection string' : 'Show connection string'}
- aria-pressed={isVisible}
- type="button"
- >
- {isVisible ? : }
-
-
- {/* Copy button */}
-
- {copied ? : }
-
-
-
-
- {/* Footer with info */}
-
-
- Use this connection string in your application to connect to the database.
-
-
- );
-}
diff --git a/app/(dashboard)/projects/[id]/database/_components/feature-cards.tsx b/app/(dashboard)/projects/[id]/database/_components/feature-cards.tsx
deleted file mode 100644
index 73c6599..0000000
--- a/app/(dashboard)/projects/[id]/database/_components/feature-cards.tsx
+++ /dev/null
@@ -1,58 +0,0 @@
-import { type IconType } from 'react-icons';
-import { MdAutoAwesome, MdHttps, MdStorage, MdTerminal } from 'react-icons/md';
-
-// Feature card component
-function FeatureCard({
- icon: Icon,
- title,
- description,
-}: {
- icon: IconType;
- title: string;
- description: string;
-}) {
- return (
-
-
-
-
-
{title}
-
{description}
-
- );
-}
-
-// Feature cards data
-const DATABASE_FEATURES = [
- {
- icon: MdAutoAwesome,
- title: 'Auto Provisioned',
- description: 'Database is automatically provisioned and ready to use with your sandbox environment.',
- },
- {
- icon: MdStorage,
- title: 'High Availability',
- description: 'Managed by KubeBlocks with high availability and automatic failover.',
- },
- {
- icon: MdHttps,
- title: 'SSL Encrypted',
- description: 'SSL encryption enabled by default for secure database connections.',
- },
- {
- icon: MdTerminal,
- title: 'Environment Variable',
- description: 'Connection string available via DATABASE_URL environment variable.',
- },
-] as const;
-
-// Export complete component
-export function FeatureCards() {
- return (
-
- {DATABASE_FEATURES.map(({ icon, title, description }) => (
-
- ))}
-
- );
-}
diff --git a/app/(dashboard)/projects/[id]/database/_components/read-only-field.tsx b/app/(dashboard)/projects/[id]/database/_components/read-only-field.tsx
deleted file mode 100644
index f923c8a..0000000
--- a/app/(dashboard)/projects/[id]/database/_components/read-only-field.tsx
+++ /dev/null
@@ -1,39 +0,0 @@
-/**
- * Read-only field component for displaying data
- * Mimics input styling for display-only scenarios
- */
-
-import { useId } from 'react';
-
-import { Input } from '@/components/ui/input';
-import { Label } from '@/components/ui/label';
-
-interface ReadOnlyFieldProps {
- /** Field label */
- label: string;
- /** Field value */
- value: string | number;
- /** Full width on mobile (col-span-2) */
- fullWidth?: boolean;
-}
-
-export function ReadOnlyField({ label, value, fullWidth }: ReadOnlyFieldProps) {
- const fieldId = useId();
-
- return (
-
-
- {label}
-
-
-
- );
-}
diff --git a/app/(dashboard)/projects/[id]/database/page.tsx b/app/(dashboard)/projects/[id]/database/page.tsx
deleted file mode 100644
index dad82bd..0000000
--- a/app/(dashboard)/projects/[id]/database/page.tsx
+++ /dev/null
@@ -1,79 +0,0 @@
-import { notFound, redirect } from 'next/navigation';
-
-import { auth } from '@/lib/auth';
-import { parseConnectionUrl } from '@/lib/data/database';
-import { getProject } from '@/lib/data/project';
-
-import { SettingsLayout } from '../_components/settings-layout';
-
-import { AddDatabaseCard } from './_components/add-database-card';
-import { ConnectionString } from './_components/connection-string';
-import { FeatureCards } from './_components/feature-cards';
-import { ReadOnlyField } from './_components/read-only-field';
-
-export default async function DatabasePage({ params }: { params: Promise<{ id: string }> }) {
- // Parallel: fetch session and params simultaneously
- const [session, paramsResolved] = await Promise.all([
- auth(),
- params
- ]);
-
- if (!session) redirect('/login');
-
- const { id } = paramsResolved;
-
- const project = await getProject(id, session.user.id, {
- databases: true,
- });
-
- if (!project) notFound();
-
- const database = project.databases[0];
-
- // If no database exists, show "Add Database" card
- if (!database) {
- return (
-
-
-
- );
- }
-
- const connectionString = database.connectionUrl || '';
- const connectionInfo = parseConnectionUrl(connectionString) || {
- host: '', port: '', database: '', username: '', password: ''
- };
-
- return (
-
- {connectionString ? (
- <>
-
-
PostgreSQL Connection
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- >
- ) : (
-
-
Database is being created...
-
- Connection details will appear once the database is ready
-
-
- )}
-
- );
-}
\ No newline at end of file
diff --git a/app/(dashboard)/projects/[id]/environment/page.tsx b/app/(dashboard)/projects/[id]/environment/page.tsx
deleted file mode 100644
index 2dd5515..0000000
--- a/app/(dashboard)/projects/[id]/environment/page.tsx
+++ /dev/null
@@ -1,72 +0,0 @@
-/**
- * Environment Variables Configuration Page
- * Configure custom environment variables
- */
-
-'use client';
-
-import { useParams } from 'next/navigation';
-
-import { EnvVarSection } from '@/components/config/env-var-section';
-import {
- useBatchUpdateEnvironmentVariables,
- useEnvironmentVariables,
-} from '@/hooks/use-environment-variables';
-import { useProject } from '@/hooks/use-project';
-
-import { SettingsLayout } from '../_components/settings-layout';
-
-function EnvironmentPageContent() {
- const params = useParams();
- const projectId = params.id as string;
-
- const { data: envData, isLoading: envLoading } = useEnvironmentVariables(projectId);
- const { data: project, isLoading: projectLoading } = useProject(projectId);
- const batchUpdate = useBatchUpdateEnvironmentVariables(projectId);
-
- const generalVars = envData?.general || [];
-
- const handleSave = async (
- variables: Array<{ key: string; value: string; isSecret?: boolean }>
- ) => {
- await batchUpdate.mutateAsync({
- category: 'general',
- variables,
- });
- };
-
- return (
-
-
- {/* Environment Variables Section */}
-
-
- {/* Usage Information */}
-
-
Environment Variable Usage
-
- Environment variables are available in your application via process.env
- Changes require an application restart to take effect
- For authentication providers, use the Authentication page
- For payment providers, use the Payment page
- For sensitive data like API keys, use the Secrets page
-
-
-
-
- );
-}
-
-export default EnvironmentPageContent;
diff --git a/app/(dashboard)/projects/[id]/exec-test/client.tsx b/app/(dashboard)/projects/[id]/exec-test/client.tsx
deleted file mode 100644
index def9039..0000000
--- a/app/(dashboard)/projects/[id]/exec-test/client.tsx
+++ /dev/null
@@ -1,12 +0,0 @@
-'use client'
-
-import { TtydExecTest } from '@/components/terminal/ttyd-exec-test'
-
-interface TtydExecTestClientProps {
- ttydUrl: string
- accessToken: string
-}
-
-export function TtydExecTestClient({ ttydUrl, accessToken }: TtydExecTestClientProps) {
- return
-}
\ No newline at end of file
diff --git a/app/(dashboard)/projects/[id]/exec-test/page.tsx b/app/(dashboard)/projects/[id]/exec-test/page.tsx
deleted file mode 100644
index a3c47f5..0000000
--- a/app/(dashboard)/projects/[id]/exec-test/page.tsx
+++ /dev/null
@@ -1,85 +0,0 @@
-/**
- * Terminal Exec Test Page
- *
- * Test page for verifying the ttyd-exec utility works in the browser.
- * Accessible at: /projects/[id]/exec-test
- */
-
-import { notFound,redirect } from 'next/navigation'
-
-import { auth } from '@/lib/auth'
-import { prisma } from '@/lib/db'
-
-import { TtydExecTestClient } from './client'
-
-export default async function ExecTestPage({
- params,
-}: {
- params: Promise<{ id: string }>
-}) {
- const session = await auth()
-
- if (!session) {
- redirect('/login')
- }
-
- const { id } = await params
-
- // Get project with sandbox
- const project = await prisma.project.findFirst({
- where: {
- id: id,
- userId: session.user.id,
- },
- include: {
- sandboxes: true,
- environments: true,
- },
- })
-
- if (!project) {
- notFound()
- }
-
- const sandbox = project.sandboxes[0]
-
- // Get TTYD access token from environments
- const ttydAccessToken = project.environments.find(
- (env) => env.key === 'TTYD_ACCESS_TOKEN'
- )?.value
-
- if (!sandbox?.ttydUrl || !ttydAccessToken) {
- return (
-
-
-
Configuration Missing
-
-
- ttydUrl: {sandbox?.ttydUrl || 'Not available'}
-
-
- accessToken: {ttydAccessToken ? 'Configured' : 'Not configured'}
-
-
- Sandbox Status: {sandbox?.status || 'No sandbox'}
-
-
-
- Make sure the sandbox is RUNNING and TTYD_ACCESS_TOKEN is set in environments.
-
-
-
- )
- }
-
- // Parse the ttydUrl to get base URL (without query params)
- const ttydBaseUrl = new URL(sandbox.ttydUrl)
- ttydBaseUrl.search = '' // Remove query params
- const baseUrl = ttydBaseUrl.toString().replace(/\/$/, '')
-
- return (
-
-
-
- )
-}
\ No newline at end of file
diff --git a/app/(dashboard)/projects/[id]/github/page.tsx b/app/(dashboard)/projects/[id]/github/page.tsx
deleted file mode 100644
index 6f9fe88..0000000
--- a/app/(dashboard)/projects/[id]/github/page.tsx
+++ /dev/null
@@ -1,192 +0,0 @@
-'use client';
-
-import { useState } from 'react';
-import { FaGithub } from 'react-icons/fa';
-import { MdOpenInNew, MdRefresh } from 'react-icons/md';
-import { useParams, useRouter } from 'next/navigation';
-import { toast } from 'sonner';
-
-import SettingsDialog from '@/components/dialog/settings-dialog';
-import { Button } from '@/components/ui/button';
-import { useProject } from '@/hooks/use-project';
-import { commitChanges, initializeRepo } from '@/lib/services/repoService';
-
-import { SettingsLayout } from '../_components/settings-layout';
-
-export default function GithubPage() {
- const params = useParams();
- const projectId = params.id as string;
- const router = useRouter();
-
- const { data: project, isLoading: projectLoading } = useProject(projectId);
-
- const [isInitializing, setIsInitializing] = useState(false);
- const [isCommitting, setIsCommitting] = useState(false);
- const [showSettings, setShowSettings] = useState(false);
-
- const repoFullName = project?.githubRepoFullName || project?.githubRepo;
- const hasRepo = Boolean(repoFullName);
- const repoUrl = repoFullName
- ? `https://github.com/${repoFullName}`
- : null;
-
- // Create a new repository on GitHub
- const handleInitialize = async () => {
- if (hasRepo || isInitializing) return;
-
- setIsInitializing(true);
- try {
- const result = await initializeRepo(projectId);
- if (result.success) {
- toast.success(result.message);
- router.refresh();
- } else {
- if (result.code === 'GITHUB_NOT_BOUND') {
- toast.error('Please connect your GitHub account first');
- setShowSettings(true);
- } else {
- toast.error(result.message);
- }
- }
- } catch (_error) {
- toast.error('An unexpected error occurred');
- } finally {
- setIsInitializing(false);
- }
- };
-
- // Commit changes to the repository and push to GitHub
- const handleCommit = async () => {
- if (isCommitting) return;
-
- setIsCommitting(true);
- try {
- const result = await commitChanges(projectId);
- if (result.success) {
- toast.success(result.message);
- } else {
- if (result.code === 'GITHUB_NOT_BOUND') {
- toast.error('Please connect your GitHub account first');
- setShowSettings(true);
- } else {
- toast.error(result.message);
- }
- }
- } catch (_error) {
- toast.error('Failed to commit changes');
- } finally {
- setIsCommitting(false);
- }
- };
-
- return (
-
-
- {/* Connection Status Section */}
-
- {/* Visual Header */}
-
-
-
-
-
-
- {hasRepo ? 'Connected to GitHub' : 'GitHub Repository'}
-
-
- {hasRepo
- ? 'Your project is currently active and synced with a remote GitHub repository. You can push your latest changes below.'
- : 'Initialize a new repository to start tracking changes. This will create a private repository in your GitHub account and push the initial code.'}
-
-
-
-
- {/* Actions */}
-
- {hasRepo ? (
-
-
-
-
-
- {isCommitting ? (
- <>
-
- Pushing...
- >
- ) : (
- <>
-
- Push Changes
- >
- )}
-
- window.open(repoUrl!, '_blank')}
- >
- View on GitHub
-
-
-
- ) : (
-
- {isInitializing ? (
- <>
-
- Creating Repository...
- >
- ) : (
- <>
-
- Initialize & Push to GitHub
- >
- )}
-
- )}
-
-
-
- {/* Global Settings Link */}
-
-
-
GitHub Account Settings
-
Manage your global GitHub connection and personal access tokens.
-
-
setShowSettings(true)}>
- Open Settings
-
-
-
-
-
-
- );
-}
diff --git a/app/(dashboard)/projects/[id]/layout.tsx b/app/(dashboard)/projects/[id]/layout.tsx
deleted file mode 100644
index 855bbd7..0000000
--- a/app/(dashboard)/projects/[id]/layout.tsx
+++ /dev/null
@@ -1,62 +0,0 @@
-import { redirect } from 'next/navigation';
-import { notFound } from 'next/navigation';
-
-import { ProjectContentWrapper } from '@/components/layout/project-content-wrapper';
-import PrimarySidebar from '@/components/sidebars/primary-sidebar';
-import ProjectSidebar from '@/components/sidebars/project-sidebar';
-import { auth } from '@/lib/auth';
-import { prisma } from '@/lib/db';
-
-export default async function ProjectLayout({
- children,
- params,
-}: {
- children: React.ReactNode;
- params: Promise<{ id: string }>;
-}) {
- const session = await auth();
-
- if (!session) {
- redirect('/login');
- }
-
- const { id } = await params;
-
- // Only need to check if project exists and belongs to user
- // All components fetch their own data via useProject hook
- const project = await prisma.project.findFirst({
- where: {
- id: id,
- userId: session.user.id,
- },
- select: {
- id: true,
- },
- });
-
- if (!project) {
- notFound();
- }
-
-
- return (
-
-
- {/* Primary Sidebar - VSCode style */}
-
-
- {/* Secondary Sidebar - Project Settings */}
-
-
- {/* Main Content Area */}
-
-
-
- );
-}
diff --git a/app/(dashboard)/projects/[id]/page.tsx b/app/(dashboard)/projects/[id]/page.tsx
deleted file mode 100644
index 26a535d..0000000
--- a/app/(dashboard)/projects/[id]/page.tsx
+++ /dev/null
@@ -1,8 +0,0 @@
-import { redirect } from 'next/navigation';
-
-export default async function ProjectDetailPage({ params }: { params: Promise<{ id: string }> }) {
- const { id } = await params;
-
- // Redirect to terminal page (main view)
- redirect(`/projects/${id}/terminal`);
-}
diff --git a/app/(dashboard)/projects/[id]/payment/page.tsx b/app/(dashboard)/projects/[id]/payment/page.tsx
deleted file mode 100644
index 94e91eb..0000000
--- a/app/(dashboard)/projects/[id]/payment/page.tsx
+++ /dev/null
@@ -1,174 +0,0 @@
-/**
- * Payment Configuration Page
- * Configure payment providers (Stripe, PayPal)
- */
-
-'use client';
-
-import { MdOpenInNew } from 'react-icons/md';
-import { useParams } from 'next/navigation';
-
-import { EnvVarSection } from '@/components/config/env-var-section';
-import {
- useBatchUpdateEnvironmentVariables,
- useEnvironmentVariables,
-} from '@/hooks/use-environment-variables';
-import { useProject } from '@/hooks/use-project';
-
-import { SettingsLayout } from '../_components/settings-layout';
-
-function PaymentPageContent() {
- const params = useParams();
- const projectId = params.id as string;
-
- const { data: envData, isLoading: envLoading } = useEnvironmentVariables(projectId);
- const { data: project, isLoading: projectLoading } = useProject(projectId);
- const batchUpdate = useBatchUpdateEnvironmentVariables(projectId);
-
- const paymentVars = envData?.payment || [];
-
- const handleSave = async (
- variables: Array<{ key: string; value: string; isSecret?: boolean }>
- ) => {
- await batchUpdate.mutateAsync({
- category: 'payment',
- variables,
- });
- };
-
- // Stripe templates
- const stripeTemplates = [
- {
- key: 'NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY',
- label: 'Publishable Key',
- placeholder: 'pk_test_...',
- isSecret: false,
- description: 'Public key used in client-side code (starts with pk_)',
- },
- {
- key: 'STRIPE_SECRET_KEY',
- label: 'Secret Key',
- placeholder: 'sk_test_...',
- isSecret: true,
- description: 'Secret key for server-side operations (starts with sk_)',
- },
- {
- key: 'STRIPE_WEBHOOK_SECRET',
- label: 'Webhook Secret',
- placeholder: 'whsec_...',
- isSecret: true,
- description: 'Secret for verifying webhook signatures (starts with whsec_)',
- },
- ];
-
- // PayPal templates
- const paypalTemplates = [
- {
- key: 'PAYPAL_CLIENT_ID',
- label: 'Client ID',
- placeholder: 'Enter PayPal Client ID',
- isSecret: false,
- description: 'PayPal client identifier for your application',
- },
- {
- key: 'PAYPAL_CLIENT_SECRET',
- label: 'Client Secret',
- placeholder: 'Enter PayPal Client Secret',
- isSecret: true,
- description: 'Secret key for PayPal API authentication',
- },
- ];
-
- return (
-
-
- {/* Stripe Section */}
-
-
-
-
-
- {/* Setup Instructions */}
-
-
Setup Instructions
-
- Go to Stripe Dashboard → Developers → API keys
- Copy the Publishable key and Secret key
- For webhooks: Developers → Webhooks → Add endpoint
- Configure webhook endpoint and copy the signing secret
-
-
-
-
- {/* Divider */}
-
-
- {/* PayPal Section */}
-
-
-
-
-
- {/* Setup Instructions */}
-
-
Setup Instructions
-
- Go to PayPal Developer Dashboard
- Navigate to My Apps & Credentials
- Create a new app or select an existing one
- Copy the Client ID and Secret from the app details
-
-
-
-
-
- );
-}
-
-export default PaymentPageContent;
diff --git a/app/(dashboard)/projects/[id]/secrets/page.tsx b/app/(dashboard)/projects/[id]/secrets/page.tsx
deleted file mode 100644
index e1ee3b4..0000000
--- a/app/(dashboard)/projects/[id]/secrets/page.tsx
+++ /dev/null
@@ -1,72 +0,0 @@
-/**
- * Secrets Configuration Page
- * Manage sensitive environment variables and API keys
- */
-
-'use client';
-
-import { useParams } from 'next/navigation';
-
-import { EnvVarSection } from '@/components/config/env-var-section';
-import {
- useBatchUpdateEnvironmentVariables,
- useEnvironmentVariables,
-} from '@/hooks/use-environment-variables';
-import { useProject } from '@/hooks/use-project';
-
-import { SettingsLayout } from '../_components/settings-layout';
-
-function SecretsPageContent() {
- const params = useParams();
- const projectId = params.id as string;
-
- const { data: envData, isLoading: envLoading } = useEnvironmentVariables(projectId);
- const { data: project, isLoading: projectLoading } = useProject(projectId);
- const batchUpdate = useBatchUpdateEnvironmentVariables(projectId);
-
- const secretVars = envData?.secret || [];
-
- const handleSave = async (
- variables: Array<{ key: string; value: string; isSecret?: boolean }>
- ) => {
- await batchUpdate.mutateAsync({
- category: 'secret',
- variables: variables.map((v) => ({ ...v, isSecret: true })),
- });
- };
-
- return (
-
-
- {/* Secrets Section */}
-
({ ...v, isSecret: true }))}
- sandboxes={project?.sandboxes || []}
- onSave={handleSave}
- saving={batchUpdate.isPending}
- allowCustomVariables={true}
- />
-
- {/* Security Notice */}
-
-
Security Best Practices
-
- All secret values are masked by default for security
- Never commit secrets to Git
- Rotate secrets regularly to maintain security
- {/*Use different secrets for development and production environments */}
- Limit access to secrets to only those who need them
-
-
-
-
- );
-}
-
-export default SecretsPageContent;
diff --git a/app/(dashboard)/projects/[id]/terminal/page.tsx b/app/(dashboard)/projects/[id]/terminal/page.tsx
deleted file mode 100644
index 749cb94..0000000
--- a/app/(dashboard)/projects/[id]/terminal/page.tsx
+++ /dev/null
@@ -1,11 +0,0 @@
-/**
- * Terminal Page
- *
- * This page is now a placeholder. The actual TerminalContainer is rendered
- * in the ProjectLayout (app/projects/[id]/layout.tsx) to ensure persistence
- * across page navigations.
- */
-
-export default function TerminalPage() {
- return null;
-}
\ No newline at end of file
diff --git a/app/(dashboard)/projects/_components/create-project-dialog.tsx b/app/(dashboard)/projects/_components/create-project-dialog.tsx
deleted file mode 100644
index 0ccecb8..0000000
--- a/app/(dashboard)/projects/_components/create-project-dialog.tsx
+++ /dev/null
@@ -1,125 +0,0 @@
-/**
- * CreateProjectDialog Component
- *
- * Dialog for creating new projects
- * Handles project creation with kubeconfig validation
- */
-
-'use client';
-
-import { useState, useTransition } from 'react';
-import { useRouter } from 'next/navigation';
-import { toast } from 'sonner';
-
-import {
- FullScreenDialog,
- FullScreenDialogAction,
- FullScreenDialogClose,
- FullScreenDialogContent,
- FullScreenDialogDescription,
- FullScreenDialogFooter,
- FullScreenDialogHeader,
- FullScreenDialogTitle,
-} from '@/components/ui/fullscreen-dialog';
-import { Input } from '@/components/ui/input';
-import { Label } from '@/components/ui/label';
-import { createProject } from '@/lib/actions/project';
-
-interface CreateProjectDialogProps {
- open: boolean;
- onOpenChange: (open: boolean) => void;
-}
-
-export default function CreateProjectDialog({ open, onOpenChange }: CreateProjectDialogProps) {
- const router = useRouter();
- const [isPending, startTransition] = useTransition();
- const [projectName, setProjectName] = useState('');
- const [description, setDescription] = useState('');
-
- const handleCreateProject = (e: React.FormEvent) => {
- e.preventDefault();
-
- if (!projectName.trim()) {
- toast.error('Project name is required');
- return;
- }
-
- startTransition(async () => {
- const result = await createProject(projectName, description);
-
- if (!result.success) {
- toast.error(result.error, { duration: 6000 });
- return;
- }
-
- toast.success('Creating project...');
- setProjectName('');
- setDescription('');
- onOpenChange(false);
- router.refresh();
- });
- };
-
- return (
-
-
-
- Create New Project
-
- Enter a name and optional description for your new project.
-
-
-
-
-
-
- );
-}
diff --git a/app/(dashboard)/projects/_components/import-github-dialog.tsx b/app/(dashboard)/projects/_components/import-github-dialog.tsx
deleted file mode 100644
index 2104ee0..0000000
--- a/app/(dashboard)/projects/_components/import-github-dialog.tsx
+++ /dev/null
@@ -1,380 +0,0 @@
-'use client'
-
-import { useCallback, useEffect, useState } from 'react'
-import { FaGithub } from 'react-icons/fa'
-import { MdRefresh } from 'react-icons/md'
-import type { ProjectStatus, ProjectTask, ResourceStatus } from '@prisma/client'
-import { useRouter } from 'next/navigation'
-import { toast } from 'sonner'
-
-import { Button } from '@/components/ui/button'
-import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
-import { Input } from '@/components/ui/input'
-import { ScrollArea } from '@/components/ui/scroll-area'
-import {
- getInstallationRepos,
- getInstallations,
- type GitHubRepo,
-} from '@/lib/actions/github'
-import { importProjectFromGitHub } from '@/lib/actions/project'
-import { env } from '@/lib/env'
-import { GET } from '@/lib/fetch-client'
-import { getProjectImportStatus } from '@/lib/util/project-import-status'
-
-type Step = 'loading' | 'check-github-app' | 'select-repo'
-
-interface ImportGitHubDialogProps {
- open: boolean
- onOpenChange: (open: boolean) => void
-}
-
-type ImportStatusPollResponse = {
- status: ProjectStatus
- sandboxes: Array<{ status: ResourceStatus }>
- tasks: Array>
-}
-
-export function ImportGitHubDialog({ open, onOpenChange }: ImportGitHubDialogProps) {
- const router = useRouter()
- const [step, setStep] = useState('loading')
- const [isLoading, setIsLoading] = useState(true)
- const [searchQuery, setSearchQuery] = useState('')
-
- // Step 1 state
- const [hasInstallation, setHasInstallation] = useState(false)
- const [installationId, setInstallationId] = useState(null)
-
- // Step 2 state
- const [repos, setRepos] = useState([])
- const [selectedRepo, setSelectedRepo] = useState(null)
- const [isCreating, setIsCreating] = useState(false)
- const [importProjectId, setImportProjectId] = useState(null)
-
- const resetState = useCallback(() => {
- setStep('loading')
- setIsLoading(true)
- setSearchQuery('')
- setHasInstallation(false)
- setInstallationId(null)
- setRepos([])
- setSelectedRepo(null)
- setIsCreating(false)
- setImportProjectId(null)
- }, [])
-
- const checkIdentity = useCallback(async () => {
- setStep('loading')
- setIsLoading(true)
- try {
- // Directly check for GitHub App installation
- const installResult = await getInstallations()
- if (installResult.success && installResult.data.length > 0) {
- const firstInstallationId = installResult.data[0].installationId
- setHasInstallation(true)
- setInstallationId(firstInstallationId)
- const repoResult = await getInstallationRepos(firstInstallationId.toString())
- if (repoResult.success) {
- setRepos(repoResult.data)
- setStep('select-repo')
- } else {
- setStep('check-github-app')
- }
- } else {
- setHasInstallation(false)
- setStep('check-github-app')
- }
- } catch (error) {
- console.error('Failed to check GitHub installation:', error)
- } finally {
- setIsLoading(false)
- }
- }, [])
-
- useEffect(() => {
- if (open) {
- resetState()
- checkIdentity()
- }
- }, [open, resetState, checkIdentity])
-
- useEffect(() => {
- if (!open || !importProjectId) {
- return
- }
-
- const pollImportStatus = async () => {
- try {
- const project = await GET(
- `/api/projects/${importProjectId}`
- )
-
- if (project.status === 'ERROR' || project.sandboxes.some((sandbox) => sandbox.status === 'ERROR')) {
- toast.error('Project creation failed because the sandbox could not start.')
- onOpenChange(false)
- setImportProjectId(null)
- router.refresh()
- return
- }
-
- const importStatus = getProjectImportStatus({ tasks: project.tasks })
-
- if (importStatus === 'IMPORTED') {
- toast.success('Repository imported successfully')
- onOpenChange(false)
- setImportProjectId(null)
- router.refresh()
- return
- }
-
- if (importStatus === 'IMPORT_FAILED') {
- toast.error('Repository import failed. An empty project was created instead.')
- onOpenChange(false)
- setImportProjectId(null)
- router.refresh()
- }
- } catch (error) {
- console.error('Failed to poll import status:', error)
- }
- }
-
- const timer = setInterval(pollImportStatus, 3000)
- void pollImportStatus()
- return () => clearInterval(timer)
- }, [importProjectId, onOpenChange, open, router])
-
- const handleInstallApp = () => {
- const appName = env.NEXT_PUBLIC_GITHUB_APP_NAME
- if (!appName) {
- toast.error('GitHub App is not configured')
- return
- }
-
- setIsLoading(true)
-
- const installUrl = `https://github.com/apps/${appName}/installations/new`
-
- const width = 800
- const height = 800
- const left = window.screen.width / 2 - width / 2
- const top = window.screen.height / 2 - height / 2
-
- const popup = window.open(
- installUrl,
- 'github-app-install',
- `width=${width},height=${height},left=${left},top=${top},resizable=yes,scrollbars=yes`
- )
-
- if (!popup) {
- toast.error('Failed to open popup window. Please allow popups for this site.')
- setIsLoading(false)
- return
- }
-
- const handleMessage = (event: MessageEvent) => {
- if (event.origin !== window.location.origin) return
- if (event.data.type !== 'github-app-installed') return
-
- window.removeEventListener('message', handleMessage)
- clearInterval(checkClosed)
- toast.success('GitHub App installed successfully!')
- checkIdentity()
- }
-
- window.addEventListener('message', handleMessage)
-
- const checkClosed = setInterval(() => {
- if (popup.closed) {
- clearInterval(checkClosed)
- window.removeEventListener('message', handleMessage)
- checkIdentity()
- }
- }, 500)
- }
-
- const handleSelectRepo = (repo: GitHubRepo) => {
- setSelectedRepo(repo)
- }
-
- const handleImport = async () => {
- if (!selectedRepo || !installationId) return
-
- setIsCreating(true)
- try {
- const result = await importProjectFromGitHub({
- installationId,
- repoId: selectedRepo.id,
- repoName: selectedRepo.name,
- repoFullName: selectedRepo.full_name,
- defaultBranch: selectedRepo.default_branch,
- })
-
- if (result.success) {
- toast.success(`Project "${selectedRepo.name}" is being imported...`)
- setImportProjectId(result.data.id)
- } else {
- toast.error(result.error || 'Failed to import project')
- }
- } catch (error) {
- console.error('Failed to import project:', error)
- toast.error('Failed to import project')
- } finally {
- setIsCreating(false)
- }
- }
-
- const filteredRepos = repos.filter((repo) =>
- repo.full_name.toLowerCase().includes(searchQuery.toLowerCase())
- )
-
- const renderStepContent = () => {
- switch (step) {
- case 'loading':
- return (
-
-
-
- Loading your GitHub repositories...
-
-
- )
-
- case 'check-github-app':
- if (hasInstallation) {
- return null
- }
-
- return (
-
-
-
- Install the GitHub App to grant access to your repositories.
-
-
-
-
-
- {isLoading ? 'Installing...' : 'Install GitHub App'}
-
-
- )
-
- case 'select-repo':
- return (
-
-
setSearchQuery(e.target.value)}
- className="bg-input border-border"
- />
-
-
-
- {isLoading ? (
-
-
-
- Loading repositories...
-
-
- ) : filteredRepos.length === 0 ? (
-
- No repositories found
-
- ) : (
-
- {filteredRepos.map((repo) => (
-
handleSelectRepo(repo)}
- className={`w-full text-left p-3 rounded-lg border transition-colors ${
- selectedRepo?.id === repo.id
- ? 'bg-primary/10 border-primary'
- : 'bg-card/50 border-border hover:bg-secondary/50'
- }`}
- >
-
-
- {repo.full_name}
-
- {repo.private && (
-
- Private
-
- )}
-
-
- ))}
-
- )}
-
-
-
- {selectedRepo && (
-
- setSelectedRepo(null)}>
- Cancel
-
-
- {isCreating ? (
- <>
-
- Importing...
- >
- ) : (
- 'Import'
- )}
-
-
- )}
-
- )
-
- default:
- return null
- }
- }
-
- const getStepTitle = () => {
- switch (step) {
- case 'loading':
- return 'Import from GitHub'
- case 'check-github-app':
- return 'Install GitHub App'
- case 'select-repo':
- return 'Select Repository'
- default:
- return 'Import from GitHub'
- }
- }
-
- return (
-
-
-
- {getStepTitle()}
-
-
- {renderStepContent()}
-
- {/* Step indicators */}
-
-
-
- )
-}
diff --git a/app/(dashboard)/projects/_components/search-bar.tsx b/app/(dashboard)/projects/_components/search-bar.tsx
deleted file mode 100644
index cb542ba..0000000
--- a/app/(dashboard)/projects/_components/search-bar.tsx
+++ /dev/null
@@ -1,67 +0,0 @@
-'use client'
-
-import { useState } from 'react'
-import { MdAdd, MdSearch } from 'react-icons/md'
-
-import { Button } from '@/components/ui/button'
-import { Input } from '@/components/ui/input'
-import { Kbd } from '@/components/ui/kbd'
-
-import CreateProjectDialog from './create-project-dialog'
-import { ImportGitHubDialog } from './import-github-dialog'
-
-export function SearchBar() {
- const [isDialogOpen, setIsDialogOpen] = useState(false)
- const [isImportDialogOpen, setIsImportDialogOpen] = useState(false)
-
- return (
- <>
-
-
-
-
-
- >
- )
-}
diff --git a/app/(dashboard)/settings/_components/github-status-card.tsx b/app/(dashboard)/settings/_components/github-status-card.tsx
deleted file mode 100644
index b8207f8..0000000
--- a/app/(dashboard)/settings/_components/github-status-card.tsx
+++ /dev/null
@@ -1,160 +0,0 @@
-'use client'
-
-import { useEffect, useState } from 'react'
-import { FaGithub } from 'react-icons/fa'
-import { MdCheck } from 'react-icons/md'
-import Image from 'next/image'
-import { toast } from 'sonner'
-
-import { Button } from '@/components/ui/button'
-import { getInstallations, type GitHubInstallation } from '@/lib/actions/github'
-import { env } from '@/lib/env'
-
-export function GitHubStatusCard() {
- const [isLoading, setIsLoading] = useState(true)
- const [installation, setInstallation] = useState(null)
-
- useEffect(() => {
- loadData()
- }, [])
-
- const loadData = async () => {
- setIsLoading(true)
- try {
- const installationsResult = await getInstallations()
-
- if (installationsResult.success && installationsResult.data.length > 0) {
- setInstallation(installationsResult.data[0])
- }
- } catch (error) {
- console.error('Failed to load GitHub data:', error)
- } finally {
- setIsLoading(false)
- }
- }
-
- const handleInstallApp = () => {
- const appName = env.NEXT_PUBLIC_GITHUB_APP_NAME
- if (!appName) {
- toast.error('GitHub App is not configured')
- return
- }
-
- const installUrl = `https://github.com/apps/${appName}/installations/new`
-
- const width = 800
- const height = 800
- const left = window.screen.width / 2 - width / 2
- const top = window.screen.height / 2 - height / 2
-
- const popup = window.open(
- installUrl,
- 'github-app-install',
- `width=${width},height=${height},left=${left},top=${top},resizable=yes,scrollbars=yes`
- )
-
- if (!popup) {
- toast.error('Failed to open popup window. Please allow popups for this site.')
- return
- }
-
- const checkClosed = setInterval(() => {
- if (popup.closed) {
- clearInterval(checkClosed)
- window.removeEventListener('message', handleMessage)
- }
- }, 500)
-
- const handleMessage = (event: MessageEvent) => {
- if (event.origin !== window.location.origin) return
- if (event.data.type !== 'github-app-installed') return
-
- window.removeEventListener('message', handleMessage)
- clearInterval(checkClosed)
- toast.success('GitHub App installed successfully!')
- loadData()
- }
-
- window.addEventListener('message', handleMessage)
- }
-
- if (isLoading) {
- return (
-
- )
- }
-
- return (
-
-
-
-
GitHub Integration
-
- {installation ? (
-
- ) : (
-
- )}
-
-
-
-
-
- What does the GitHub Integration do?
-
-
- Connecting to GitHub allows you to import repositories and keep project source metadata available in Fulling.
-
-
- {installation ? (
-
- {installation.accountAvatarUrl ? (
-
- ) : (
-
-
-
- )}
-
- {installation.accountLogin}
-
-
● Connected
-
- ) : (
-
-
- Connect to GitHub
-
- )}
-
-
-
- )
-}
diff --git a/app/(dashboard)/settings/_components/settings-sidebar.tsx b/app/(dashboard)/settings/_components/settings-sidebar.tsx
deleted file mode 100644
index 7015687..0000000
--- a/app/(dashboard)/settings/_components/settings-sidebar.tsx
+++ /dev/null
@@ -1,44 +0,0 @@
-'use client'
-
-import Link from 'next/link'
-import { usePathname } from 'next/navigation'
-
-import { Separator } from '@/components/ui/separator'
-import { cn } from '@/lib/utils'
-
-const menuItems = [
- { label: 'Integrations', href: '/settings/integrations' },
-]
-
-export function SettingsSidebar() {
- const pathname = usePathname()
-
- return (
-
-
-
Settings
-
-
-
- {menuItems.map((item) => {
- const isActive = pathname === item.href
-
- return (
-
-
- {item.label}
-
-
- )
- })}
-
-
- )
-}
diff --git a/app/(dashboard)/settings/integrations/page.tsx b/app/(dashboard)/settings/integrations/page.tsx
deleted file mode 100644
index a664135..0000000
--- a/app/(dashboard)/settings/integrations/page.tsx
+++ /dev/null
@@ -1,23 +0,0 @@
-import { GitHubStatusCard } from '../_components/github-status-card'
-
-export const metadata = {
- title: 'Integrations | Settings | Fulling',
- description: 'Manage your integrations and connected services.',
-}
-
-export default function IntegrationsPage() {
- return (
- <>
-
-
-
-
-
- >
- )
-}
diff --git a/app/(dashboard)/settings/kubeconfig/_components/kubeconfig-form.tsx b/app/(dashboard)/settings/kubeconfig/_components/kubeconfig-form.tsx
new file mode 100644
index 0000000..8b31d39
--- /dev/null
+++ b/app/(dashboard)/settings/kubeconfig/_components/kubeconfig-form.tsx
@@ -0,0 +1,225 @@
+'use client'
+
+import { useCallback, useEffect, useState } from 'react'
+import { CheckCircle2, LoaderCircle, Save, Trash2 } from 'lucide-react'
+
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+ AlertDialogTrigger,
+} from '@/components/ui/alert-dialog'
+import { Button } from '@/components/ui/button'
+import { Label } from '@/components/ui/label'
+import { Textarea } from '@/components/ui/textarea'
+
+import {
+ formatUpdatedAt,
+ type KubeconfigStatus,
+ parseKubeconfigStatus,
+} from '../../../_components/kubeconfig-status'
+
+type ApiError = { message?: unknown }
+
+async function readError(response: Response, fallback: string): Promise {
+ try {
+ const body = (await response.json()) as ApiError
+ return typeof body.message === 'string' ? body.message : fallback
+ } catch {
+ return fallback
+ }
+}
+
+export function KubeconfigForm() {
+ const [content, setContent] = useState('')
+ const [status, setStatus] = useState(null)
+ const [error, setError] = useState(null)
+ const [notice, setNotice] = useState(null)
+ const [isLoading, setIsLoading] = useState(true)
+ const [isSaving, setIsSaving] = useState(false)
+ const [isDeleting, setIsDeleting] = useState(false)
+
+ const loadStatus = useCallback(async () => {
+ setError(null)
+ setIsLoading(true)
+ try {
+ const response = await fetch('/api/kubeconfig', { cache: 'no-store' })
+ if (!response.ok) {
+ throw new Error(await readError(response, 'Could not load kubeconfig status.'))
+ }
+ setStatus(parseKubeconfigStatus(await response.json()))
+ } catch (loadError) {
+ setError(loadError instanceof Error ? loadError.message : 'Could not load kubeconfig status.')
+ } finally {
+ setIsLoading(false)
+ }
+ }, [])
+
+ useEffect(() => {
+ void loadStatus()
+ }, [loadStatus])
+
+ async function handleSave() {
+ setError(null)
+ setNotice(null)
+ setIsSaving(true)
+
+ try {
+ const response = await fetch('/api/kubeconfig', {
+ method: 'PUT',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ content }),
+ })
+ if (!response.ok) {
+ throw new Error(await readError(response, 'Could not save kubeconfig.'))
+ }
+
+ setStatus(parseKubeconfigStatus(await response.json()))
+ setContent('')
+ setNotice('Kubeconfig saved. The stored content remains hidden.')
+ } catch (saveError) {
+ setError(saveError instanceof Error ? saveError.message : 'Could not save kubeconfig.')
+ } finally {
+ setIsSaving(false)
+ }
+ }
+
+ async function handleDelete() {
+ setError(null)
+ setNotice(null)
+ setIsDeleting(true)
+
+ try {
+ const response = await fetch('/api/kubeconfig', { method: 'DELETE' })
+ if (!response.ok) {
+ throw new Error(await readError(response, 'Could not delete kubeconfig.'))
+ }
+
+ setStatus(parseKubeconfigStatus(await response.json()))
+ setContent('')
+ setNotice('Kubeconfig deleted.')
+ } catch (deleteError) {
+ setError(deleteError instanceof Error ? deleteError.message : 'Could not delete kubeconfig.')
+ } finally {
+ setIsDeleting(false)
+ }
+ }
+
+ return (
+
+
+
+
+
+
+
+ Saved credential
+
+
+ {isLoading
+ ? 'Loading status...'
+ : status?.configured
+ ? `Configured. Last updated ${formatUpdatedAt(status.updatedAt)}.`
+ : 'No kubeconfig is configured.'}
+
+
+ {status?.configured ? (
+
+
+ Configured
+
+ ) : null}
+
+
+
+
+
+
+ {status?.configured ? 'Replace kubeconfig' : 'Configure kubeconfig'}
+
+
+ Paste the complete replacement. Existing content is intentionally not prefilled.
+
+
+ Kubeconfig content
+
+
+
+
+ {status?.configured ? (
+
+
+
+
Delete kubeconfig
+
+ Your account and session remain active after deletion.
+
+
+
+
+
+ Delete
+
+
+
+
+ Delete the saved kubeconfig?
+
+ Kubernetes operations will remain unavailable until you configure another
+ credential.
+
+
+
+ Cancel
+ void handleDelete()}
+ >
+ Delete kubeconfig
+
+
+
+
+
+
+ ) : null}
+
+ )
+}
diff --git a/app/(dashboard)/settings/kubeconfig/page.tsx b/app/(dashboard)/settings/kubeconfig/page.tsx
new file mode 100644
index 0000000..f47dfb9
--- /dev/null
+++ b/app/(dashboard)/settings/kubeconfig/page.tsx
@@ -0,0 +1,5 @@
+import { KubeconfigForm } from './_components/kubeconfig-form'
+
+export default function KubeconfigSettingsPage() {
+ return
+}
diff --git a/app/(dashboard)/settings/layout.tsx b/app/(dashboard)/settings/layout.tsx
deleted file mode 100644
index 3a4020b..0000000
--- a/app/(dashboard)/settings/layout.tsx
+++ /dev/null
@@ -1,21 +0,0 @@
-import { Sidebar } from '@/components/sidebar'
-
-import { SettingsSidebar } from './_components/settings-sidebar'
-
-export default function SettingsLayout({
- children,
-}: {
- children: React.ReactNode
-}) {
- return (
-
-
-
-
-
- {children}
-
-
-
- )
-}
diff --git a/app/(dashboard)/settings/page.tsx b/app/(dashboard)/settings/page.tsx
deleted file mode 100644
index c1ebece..0000000
--- a/app/(dashboard)/settings/page.tsx
+++ /dev/null
@@ -1,18 +0,0 @@
-import { redirect } from 'next/navigation'
-
-import { auth } from '@/lib/auth'
-
-export const metadata = {
- title: 'Settings | Fulling',
- description: 'Manage your account settings and integrations.',
-}
-
-export default async function SettingsPage() {
- const session = await auth()
-
- if (!session) {
- redirect('/login')
- }
-
- redirect('/settings/integrations')
-}
diff --git a/app/(dashboard)/skills/_components/skills-library.tsx b/app/(dashboard)/skills/_components/skills-library.tsx
deleted file mode 100644
index 4851df2..0000000
--- a/app/(dashboard)/skills/_components/skills-library.tsx
+++ /dev/null
@@ -1,154 +0,0 @@
-'use client'
-
-import { useState, useTransition } from 'react'
-import { MdBolt, MdCheckCircle, MdOpenInNew } from 'react-icons/md'
-import { useRouter } from 'next/navigation'
-import { toast } from 'sonner'
-
-import { Badge } from '@/components/ui/badge'
-import { Button } from '@/components/ui/button'
-import {
- Card,
- CardContent,
- CardDescription,
- CardFooter,
- CardHeader,
- CardTitle,
-} from '@/components/ui/card'
-import { enableGlobalSkill, uninstallGlobalSkill } from '@/lib/actions/skill'
-import { getSkillCatalog } from '@/lib/skills/catalog'
-
-type SkillsLibraryProps = {
- enabledSkillIds: string[]
-}
-
-export function SkillsLibrary({ enabledSkillIds }: SkillsLibraryProps) {
- const router = useRouter()
- const [pendingOperation, setPendingOperation] = useState<{
- skillId: string
- type: 'enable' | 'uninstall'
- } | null>(null)
- const [isPending, startTransition] = useTransition()
- const catalog = getSkillCatalog()
- const enabledSkillSet = new Set(enabledSkillIds)
-
- const handleEnable = (skillId: string) => {
- startTransition(async () => {
- setPendingOperation({ skillId, type: 'enable' })
-
- const result = await enableGlobalSkill(skillId)
- if (!result.success) {
- toast.error(result.error)
- setPendingOperation(null)
- return
- }
-
- toast.success('Global skill enabled. Install tasks will fan out across your projects.')
- router.refresh()
- setPendingOperation(null)
- })
- }
-
- const handleUninstall = (skillId: string) => {
- startTransition(async () => {
- setPendingOperation({ skillId, type: 'uninstall' })
-
- const result = await uninstallGlobalSkill(skillId)
- if (!result.success) {
- toast.error(result.error)
- setPendingOperation(null)
- return
- }
-
- toast.success('Global skill removed. Uninstall tasks will converge existing projects.')
- router.refresh()
- setPendingOperation(null)
- })
- }
-
- return (
-
-
-
- Global Skills
-
-
-
Skills
-
- Skills here define global desired state for the user. Enabling fans out
- `INSTALL_SKILL` tasks to existing projects and future projects inherit the skill.
- Uninstalling removes the global desired state and fans out `UNINSTALL_SKILL` work
- without auto-starting stopped sandboxes.
-
-
-
-
-
- {catalog.map((skill) => {
- const isEnabled = enabledSkillSet.has(skill.skillId)
- const isLoading = isPending && pendingOperation?.skillId === skill.skillId
- const isUninstalling = isLoading && pendingOperation?.type === 'uninstall'
-
- return (
-
-
-
-
-
-
-
- {isEnabled ? 'Enabled' : 'Available'}
-
-
-
- {skill.name}
- {skill.description}
-
-
-
-
-
-
-
- Install Command
-
-
- {skill.installCommand}
-
-
-
-
-
-
-
-
- Source
-
-
-
-
- isEnabled ? handleUninstall(skill.skillId) : handleEnable(skill.skillId)
- }
- disabled={isLoading}
- >
- {isEnabled
- ? isUninstalling
- ? 'Uninstalling...'
- : 'Uninstall Skill'
- : isLoading
- ? 'Enabling...'
- : 'Enable Skill'}
-
-
-
- )
- })}
-
-
- )
-}
diff --git a/app/(dashboard)/skills/layout.tsx b/app/(dashboard)/skills/layout.tsx
deleted file mode 100644
index bb6b758..0000000
--- a/app/(dashboard)/skills/layout.tsx
+++ /dev/null
@@ -1,14 +0,0 @@
-import { Sidebar } from '@/components/sidebar'
-
-export default function SkillsLayout({
- children,
-}: {
- children: React.ReactNode
-}) {
- return (
-
-
- {children}
-
- )
-}
diff --git a/app/(dashboard)/skills/page.tsx b/app/(dashboard)/skills/page.tsx
deleted file mode 100644
index 1f34fe0..0000000
--- a/app/(dashboard)/skills/page.tsx
+++ /dev/null
@@ -1,29 +0,0 @@
-import { redirect } from 'next/navigation'
-
-import { auth } from '@/lib/auth'
-import { getUserSkills } from '@/lib/data/user-skill'
-
-import { SkillsLibrary } from './_components/skills-library'
-
-export const metadata = {
- title: 'Skills | Fulling',
- description: 'Manage global skills that install into or uninstall from your projects.',
-}
-
-export default async function SkillsPage() {
- const session = await auth()
-
- if (!session) {
- redirect('/login')
- }
-
- const userSkills = await getUserSkills(session.user.id)
-
- return (
-
- skill.skillId)}
- />
-
- )
-}
diff --git a/app/(dashboard)/workspace/_components/workspace-overview.tsx b/app/(dashboard)/workspace/_components/workspace-overview.tsx
new file mode 100644
index 0000000..2bc0de7
--- /dev/null
+++ b/app/(dashboard)/workspace/_components/workspace-overview.tsx
@@ -0,0 +1,54 @@
+'use client'
+
+import { useCallback, useEffect, useState } from 'react'
+import { ArrowRight, CheckCircle2, CircleDashed, RefreshCw } from 'lucide-react'
+import Link from 'next/link'
+
+import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
+import { Button } from '@/components/ui/button'
+
+import { formatUpdatedAt, type KubeconfigStatus,parseKubeconfigStatus } from '../../_components/kubeconfig-status'
+
+type WorkspaceOverviewProps = { user: { name: string; email: string; image: string | null } }
+
+export function WorkspaceOverview({ user }: WorkspaceOverviewProps) {
+ const [status, setStatus] = useState(null)
+ const [error, setError] = useState(null)
+
+ const loadStatus = useCallback(async () => {
+ setError(null)
+ try {
+ const response = await fetch('/api/kubeconfig', { method: 'GET', cache: 'no-store' })
+ if (!response.ok) throw new Error('request failed')
+ setStatus(parseKubeconfigStatus(await response.json()))
+ } catch {
+ setError('Kubeconfig status could not be loaded.')
+ }
+ }, [])
+
+ useEffect(() => { void loadStatus() }, [loadStatus])
+
+ return (
+
+
+
Workspace
Welcome, {user.name}
+
Open settings
+
+
+
+ Account
+
+
{user.name.slice(0, 1).toUpperCase()}
+
+
+
+
+ Kubernetes connection
+ {!status && !error ? Loading status...
: null}
+ {error ? {error}
void loadStatus()}> Retry : null}
+ {status ? {status.configured ? : }{status.configured ? 'Configured' : 'Not configured'}
Last updated
{formatUpdatedAt(status.updatedAt)}
: null}
+
+
+
+ )
+}
diff --git a/app/(dashboard)/workspace/page.tsx b/app/(dashboard)/workspace/page.tsx
new file mode 100644
index 0000000..13e7fa8
--- /dev/null
+++ b/app/(dashboard)/workspace/page.tsx
@@ -0,0 +1,8 @@
+import { requireSession } from '@/lib/auth/session'
+
+import { WorkspaceOverview } from './_components/workspace-overview'
+
+export default async function WorkspacePage() {
+ const { user } = await requireSession()
+ return
+}
diff --git a/app/(landing)/_components/landing-client.tsx b/app/(landing)/_components/landing-client.tsx
index 00a0e79..7d5fed3 100644
--- a/app/(landing)/_components/landing-client.tsx
+++ b/app/(landing)/_components/landing-client.tsx
@@ -1,154 +1,37 @@
-import {
- MdCheckCircleOutline,
- MdLockOutline,
- MdOutlineShield,
-} from 'react-icons/md';
-import Image from 'next/image';
-import Link from 'next/link';
+import { ArrowRight } from 'lucide-react'
+import Image from 'next/image'
+import Link from 'next/link'
-const navItems = ['Product', 'Templates', 'Solutions', 'Resources', 'Pricing'];
-
-const proofItems = [
- {
- icon: MdOutlineShield,
- title: 'Enterprise ready',
- detail: 'SSO, RBAC, audit logs',
- },
- {
- icon: MdLockOutline,
- title: 'Your data, your control',
- detail: 'Private by default',
- },
- {
- icon: MdCheckCircleOutline,
- title: 'Built for reliability',
- detail: 'Observability & retries',
- },
-];
-
-/**
- * Landing page shell.
- */
-export function LandingClient() {
- return (
-
-
-
-
-
-
-
-
-
-
-
- Workspace OS for teams
-
-
-
- Ship AI
-
- workspaces,
-
- not prompts
-
-
-
- Build, run, and share AI workspaces as products.
-
- Versioned. Reliable. Governed.
-
-
-
-
- Create workspace
-
-
- View templates
-
-
-
-
- {proofItems.map((item) => (
-
-
-
-
- {item.title}
-
-
- {item.detail}
-
-
-
- ))}
-
-
-
-
-
-
-
-
-
-
- );
+type LandingClientProps = {
+ ctaHref: '/login' | '/workspace'
}
-function LandingHeader() {
+export function LandingClient({ ctaHref }: LandingClientProps) {
+ const actionLabel = ctaHref === '/workspace' ? 'Open workspace' : 'Sign in'
+
return (
-
-
-
- Fulling
+
+
+ Fulling
+
+ {actionLabel}
-
-
- {navItems.map((item) => (
-
- {item}
+
+
+
+ Dedicated AI workspaces
+ Fulling
+ Connect the Kubernetes credentials that will power your workspace runtime.
+
+
+ {actionLabel}
- ))}
-
-
-
-
- Sign in
-
-
- Create workspace
-
+
+
+
+
-
-
- );
+
+
+ )
}
diff --git a/app/(landing)/page.tsx b/app/(landing)/page.tsx
index c5b9e15..0f76497 100644
--- a/app/(landing)/page.tsx
+++ b/app/(landing)/page.tsx
@@ -1,8 +1,9 @@
-import { LandingClient } from './_components/landing-client';
+import { getSession } from '@/lib/auth/session'
+
+import { LandingClient } from './_components/landing-client'
-/**
- * Landing page — Server Component.
- */
export default async function LandingPage() {
- return
;
+ const session = await getSession()
+
+ return
}
diff --git a/app/api/auth/[...all]/route.ts b/app/api/auth/[...all]/route.ts
new file mode 100644
index 0000000..7d557f4
--- /dev/null
+++ b/app/api/auth/[...all]/route.ts
@@ -0,0 +1,5 @@
+import { toNextJsHandler } from 'better-auth/next-js'
+
+import { auth } from '@/lib/auth'
+
+export const { GET, POST } = toNextJsHandler(auth)
diff --git a/app/api/auth/[...nextauth]/route.ts b/app/api/auth/[...nextauth]/route.ts
deleted file mode 100644
index 06cdcbd..0000000
--- a/app/api/auth/[...nextauth]/route.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-import { handlers } from '@/lib/auth';
-
-export const { GET, POST } = handlers;
\ No newline at end of file
diff --git a/app/api/github/app/callback/route.ts b/app/api/github/app/callback/route.ts
deleted file mode 100644
index 24c186e..0000000
--- a/app/api/github/app/callback/route.ts
+++ /dev/null
@@ -1,217 +0,0 @@
-import { NextRequest, NextResponse } from 'next/server'
-
-import { auth } from '@/lib/auth'
-import { prisma } from '@/lib/db'
-import { logger as baseLogger } from '@/lib/logger'
-import { upsertInstallation } from '@/lib/repo/github'
-import {
- exchangeCodeForUserToken,
- getGitHubUser,
- getInstallationDetails,
-} from '@/lib/services/github-app'
-
-const logger = baseLogger.child({ module: 'api/github/app/callback' })
-
-export async function GET(request: NextRequest) {
- try {
- const session = await auth()
- if (!session?.user?.id) {
- logger.warn('Unauthenticated user attempted GitHub App callback')
- return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
- }
-
- const searchParams = request.nextUrl.searchParams
- const installationIdStr = searchParams.get('installation_id')
- const setupAction = searchParams.get('setup_action')
- const code = searchParams.get('code') // NEW: OAuth code
-
- if (!installationIdStr) {
- logger.error('Missing installation_id in GitHub App callback')
- return NextResponse.json({ error: 'Missing installation_id' }, { status: 400 })
- }
-
- const installationId = parseInt(installationIdStr, 10)
- logger.info(
- `GitHub App callback: installation_id=${installationId}, setup_action=${setupAction}, has_code=${!!code}`
- )
-
- // ========================================================================
- // Handle OAuth code exchange (if present)
- // ========================================================================
- let oauthSucceeded = false
- if (code) {
- try {
- // Exchange code for user tokens
- const tokenResponse = await exchangeCodeForUserToken(code)
-
- // Get user info with access token
- const githubUser = await getGitHubUser(tokenResponse.accessToken)
-
- // Create/update UserIdentity with new metadata structure
- await prisma.userIdentity.upsert({
- where: {
- unique_provider_user: {
- provider: 'GITHUB',
- providerUserId: githubUser.id.toString(),
- },
- },
- create: {
- userId: session.user.id,
- provider: 'GITHUB',
- providerUserId: githubUser.id.toString(),
- metadata: {
- accessToken: tokenResponse.accessToken,
- refreshToken: tokenResponse.refreshToken,
- expiresAt: tokenResponse.expiresAt,
- tokenType: tokenResponse.tokenType,
- scope: tokenResponse.scope,
- login: githubUser.login,
- name: githubUser.name,
- avatarUrl: githubUser.avatar_url,
- email: githubUser.email,
- githubId: githubUser.id,
- },
- isPrimary: false,
- },
- update: {
- metadata: {
- accessToken: tokenResponse.accessToken,
- refreshToken: tokenResponse.refreshToken,
- expiresAt: tokenResponse.expiresAt,
- tokenType: tokenResponse.tokenType,
- scope: tokenResponse.scope,
- login: githubUser.login,
- name: githubUser.name,
- avatarUrl: githubUser.avatar_url,
- email: githubUser.email,
- githubId: githubUser.id,
- },
- },
- })
-
- logger.info(
- `GitHub identity created/updated for user ${session.user.id} (GitHub ID: ${githubUser.id})`
- )
- oauthSucceeded = true
- } catch (error) {
- logger.error(`OAuth token exchange failed: ${error}`)
- // Continue to installation creation even if OAuth fails (graceful degradation)
- }
- }
-
- const details = await getInstallationDetails(installationId)
-
- // Ownership validation (skip if OAuth succeeded, as it already validated the user)
- if (!oauthSucceeded) {
- if (details.account && 'type' in details.account && details.account.type === 'User') {
- const githubIdentity = await prisma.userIdentity.findFirst({
- where: { userId: session.user.id, provider: 'GITHUB' },
- })
-
- if (!githubIdentity) {
- logger.warn(`User ${session.user.id} has no GitHub identity linked`)
- return NextResponse.json({ error: 'GitHub account not linked' }, { status: 400 })
- }
-
- const userGitHubId = parseInt(githubIdentity.providerUserId, 10)
- if (details.account.id !== userGitHubId) {
- logger.warn(
- `User ${session.user.id} (GitHub ID ${userGitHubId}) attempted to claim installation for ${details.account.login} (GitHub ID ${details.account.id})`
- )
- return NextResponse.json({ error: 'Installation owner mismatch' }, { status: 403 })
- }
- } else if (details.account && 'type' in details.account) {
- logger.warn(`Organization installation not supported yet: ${details.account.login}`)
- return NextResponse.json({ error: 'Organization installation not supported' }, { status: 400 })
- }
- }
-
- await upsertInstallation({
- installationId: details.id,
- userId: session.user.id,
- accountId: details.account?.id ?? 0,
- accountLogin: ('login' in (details.account ?? {}) ? (details.account as any).login : (details.account as any)?.name) ?? 'unknown',
- accountType: ('type' in (details.account ?? {}) ? (details.account as any).type : 'Unknown'),
- accountAvatarUrl: details.account?.avatar_url ?? null,
- repositorySelection: details.repository_selection,
- permissions: details.permissions,
- events: details.events,
- })
-
- const accountName = 'login' in (details.account ?? {}) ? (details.account as any).login : (details.account as any)?.name ?? 'unknown'
- logger.info(`GitHub App installed: ${accountName}`)
-
- return createCallbackPage(true, 'GitHub App installed successfully!')
- } catch (error) {
- logger.error(`GitHub App callback error: ${error}`)
- return createCallbackPage(false, 'Failed to install GitHub App. Please try again.')
- }
-}
-
-function createCallbackPage(success: boolean, message: string): NextResponse {
- const html = `
-
-
-
-
-
GitHub App Installation
-
-
-
-
-
${success ? '✅' : '❌'}
-
${message}
-
This window will close automatically...
-
-
-
-
- `
-
- return new NextResponse(html, {
- status: 200,
- headers: {
- 'Content-Type': 'text/html',
- },
- })
-}
diff --git a/app/api/github/app/webhook/route.ts b/app/api/github/app/webhook/route.ts
deleted file mode 100644
index 08e89f0..0000000
--- a/app/api/github/app/webhook/route.ts
+++ /dev/null
@@ -1,80 +0,0 @@
-import { NextRequest, NextResponse } from 'next/server'
-
-import { logger as baseLogger } from '@/lib/logger'
-import { getInstallationByGitHubId, updateInstallationStatus } from '@/lib/repo/github'
-import { verifyWebhookSignature } from '@/lib/services/github-app'
-
-const logger = baseLogger.child({ module: 'api/github/app/webhook' })
-
-export async function POST(request: NextRequest) {
- try {
- const payload = await request.text()
- const signature = request.headers.get('x-hub-signature-256') || ''
- const event = request.headers.get('x-github-event') || ''
-
- // UPDATED: verifyWebhookSignature is now async (uses Octokit)
- if (!(await verifyWebhookSignature(payload, signature))) {
- logger.warn('Invalid webhook signature')
- return NextResponse.json({ error: 'Invalid signature' }, { status: 401 })
- }
-
- const body = JSON.parse(payload)
- const action = body.action as string
-
- logger.info(`Webhook received: ${event}.${action}`)
-
- if (event === 'installation') {
- await handleInstallationEvent(action, body)
- } else {
- logger.info(`Unhandled webhook event: ${event}`)
- }
-
- return NextResponse.json({ ok: true })
- } catch (error) {
- logger.error(`Webhook processing error: ${error}`)
- return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
- }
-}
-
-async function handleInstallationEvent(
- action: string,
- body: {
- installation: {
- id: number
- account: { id: number; login: string; type: string; avatar_url: string }
- repository_selection: string
- permissions: Record
- events: string[]
- }
- }
-) {
- const inst = body.installation
-
- switch (action) {
- case 'created': {
- const existing = await getInstallationByGitHubId(inst.id)
- if (existing) {
- logger.info(`Installation ${inst.id} already exists (created via callback)`)
- } else {
- logger.info(`Installation ${inst.id} created via webhook (no callback user — skipping)`)
- }
- break
- }
- case 'deleted':
- await updateInstallationStatus(inst.id, 'DELETED')
- // Note: Octokit handles token cache invalidation automatically
- logger.info(`Installation ${inst.id} deleted`)
- break
- case 'suspend':
- await updateInstallationStatus(inst.id, 'SUSPENDED')
- // Note: Octokit handles token cache invalidation automatically
- logger.info(`Installation ${inst.id} suspended`)
- break
- case 'unsuspend':
- await updateInstallationStatus(inst.id, 'ACTIVE', null)
- logger.info(`Installation ${inst.id} unsuspended`)
- break
- default:
- logger.info(`Unhandled installation action: ${action}`)
- }
-}
diff --git a/app/api/kubeconfig/route.test.ts b/app/api/kubeconfig/route.test.ts
new file mode 100644
index 0000000..5674c6d
--- /dev/null
+++ b/app/api/kubeconfig/route.test.ts
@@ -0,0 +1,110 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+import { InvalidKubeconfigError } from '@/lib/kubeconfig'
+
+import { DELETE, GET, PUT } from './route'
+
+const mocks = vi.hoisted(() => ({
+ getKubeconfigStatus: vi.fn(),
+ getSession: vi.fn(),
+ removeKubeconfig: vi.fn(),
+ saveKubeconfig: vi.fn(),
+ validateKubeconfig: vi.fn(),
+}))
+
+vi.mock('@/lib/auth/session', () => ({ getSession: mocks.getSession }))
+vi.mock('@/lib/kubeconfig', async (importOriginal) => {
+ const actual = await importOriginal()
+ return {
+ ...actual,
+ getKubeconfigStatus: mocks.getKubeconfigStatus,
+ removeKubeconfig: mocks.removeKubeconfig,
+ saveKubeconfig: mocks.saveKubeconfig,
+ }
+})
+vi.mock('@/lib/k8s', () => ({ validateKubeconfig: mocks.validateKubeconfig }))
+vi.mock('@/lib/logger', () => ({
+ logger: { error: vi.fn(), info: vi.fn(), warn: vi.fn() },
+}))
+
+const session = {
+ user: { id: 'user-1', name: 'Test', email: 'test@example.com', image: null },
+}
+
+describe('/api/kubeconfig', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mocks.getSession.mockResolvedValue(session)
+ })
+
+ it.each([
+ ['GET', () => GET()],
+ ['PUT', () => PUT(new Request('http://localhost/api/kubeconfig', { method: 'PUT' }))],
+ ['DELETE', () => DELETE()],
+ ])('returns 401 for anonymous %s requests', async (_method, call) => {
+ mocks.getSession.mockResolvedValue(null)
+
+ const response = await call()
+
+ expect(response.status).toBe(401)
+ await expect(response.json()).resolves.toMatchObject({ code: 'UNAUTHORIZED' })
+ })
+
+ it('returns status without kubeconfig content', async () => {
+ mocks.getKubeconfigStatus.mockResolvedValue({
+ configured: true,
+ updatedAt: new Date('2026-07-13T12:00:00.000Z'),
+ })
+
+ const response = await GET()
+ const body = await response.json()
+
+ expect(body).toEqual({ configured: true, updatedAt: '2026-07-13T12:00:00.000Z' })
+ expect(body).not.toHaveProperty('content')
+ })
+
+ it('does not save a kubeconfig that fails validation', async () => {
+ mocks.validateKubeconfig.mockRejectedValue(new InvalidKubeconfigError('Invalid config.'))
+
+ const response = await PUT(
+ new Request('http://localhost/api/kubeconfig', {
+ method: 'PUT',
+ body: JSON.stringify({ content: 'invalid' }),
+ })
+ )
+
+ expect(response.status).toBe(400)
+ expect(mocks.saveKubeconfig).not.toHaveBeenCalled()
+ })
+
+ it('saves only after successful validation', async () => {
+ mocks.validateKubeconfig.mockResolvedValue({
+ apiServerOrigin: 'https://cluster.example.com',
+ namespace: 'default',
+ username: 'test-user',
+ })
+ mocks.saveKubeconfig.mockResolvedValue(new Date('2026-07-13T12:00:00.000Z'))
+
+ const response = await PUT(
+ new Request('http://localhost/api/kubeconfig', {
+ method: 'PUT',
+ body: JSON.stringify({ content: 'valid-config' }),
+ })
+ )
+
+ expect(mocks.saveKubeconfig).toHaveBeenCalledWith('user-1', 'valid-config')
+ await expect(response.json()).resolves.toEqual({
+ configured: true,
+ updatedAt: '2026-07-13T12:00:00.000Z',
+ })
+ })
+
+ it('delegates idempotent deletion by user ID', async () => {
+ mocks.removeKubeconfig.mockResolvedValue(undefined)
+
+ const response = await DELETE()
+
+ expect(mocks.removeKubeconfig).toHaveBeenCalledWith('user-1')
+ await expect(response.json()).resolves.toEqual({ configured: false, updatedAt: null })
+ })
+})
diff --git a/app/api/kubeconfig/route.ts b/app/api/kubeconfig/route.ts
new file mode 100644
index 0000000..d3dd595
--- /dev/null
+++ b/app/api/kubeconfig/route.ts
@@ -0,0 +1,115 @@
+import { NextResponse } from 'next/server'
+
+import { getSession } from '@/lib/auth/session'
+import { validateKubeconfig } from '@/lib/k8s'
+import {
+ getKubeconfigStatus,
+ InvalidKubeconfigError,
+ KubeconfigValidationError,
+ removeKubeconfig,
+ saveKubeconfig,
+} from '@/lib/kubeconfig'
+import { logger } from '@/lib/logger'
+
+const unauthorized = () =>
+ NextResponse.json({ code: 'UNAUTHORIZED', message: 'Sign in to continue.' }, { status: 401 })
+
+export async function GET() {
+ const session = await getSession()
+ if (!session) {
+ return unauthorized()
+ }
+
+ try {
+ const status = await getKubeconfigStatus(session.user.id)
+ return NextResponse.json({
+ configured: status.configured,
+ updatedAt: status.updatedAt?.toISOString() ?? null,
+ })
+ } catch (error) {
+ logger.error({ error, userId: session.user.id }, 'Failed to read kubeconfig status')
+ return NextResponse.json(
+ { code: 'INTERNAL_ERROR', message: 'Could not read kubeconfig status.' },
+ { status: 500 }
+ )
+ }
+}
+
+export async function PUT(request: Request) {
+ const session = await getSession()
+ if (!session) {
+ return unauthorized()
+ }
+
+ let body: unknown
+ try {
+ body = await request.json()
+ } catch {
+ return NextResponse.json(
+ { code: 'INVALID_KUBECONFIG', message: 'Send a valid JSON request.' },
+ { status: 400 }
+ )
+ }
+
+ const content =
+ body && typeof body === 'object' && 'content' in body
+ ? (body as { content?: unknown }).content
+ : undefined
+
+ if (typeof content !== 'string') {
+ return NextResponse.json(
+ { code: 'INVALID_KUBECONFIG', message: 'Enter a kubeconfig to continue.' },
+ { status: 400 }
+ )
+ }
+
+ try {
+ const validation = await validateKubeconfig(content)
+ const updatedAt = await saveKubeconfig(session.user.id, content)
+
+ logger.info(
+ {
+ apiServerOrigin: validation.apiServerOrigin,
+ namespace: validation.namespace,
+ userId: session.user.id,
+ },
+ 'Saved kubeconfig'
+ )
+
+ return NextResponse.json({ configured: true, updatedAt: updatedAt.toISOString() })
+ } catch (error) {
+ if (error instanceof InvalidKubeconfigError) {
+ return NextResponse.json({ code: error.code, message: error.message }, { status: 400 })
+ }
+
+ if (error instanceof KubeconfigValidationError) {
+ logger.warn({ userId: session.user.id }, 'Kubeconfig validation failed')
+ return NextResponse.json({ code: error.code, message: error.message }, { status: 422 })
+ }
+
+ logger.error({ error, userId: session.user.id }, 'Failed to save kubeconfig')
+ return NextResponse.json(
+ { code: 'INTERNAL_ERROR', message: 'Could not save kubeconfig.' },
+ { status: 500 }
+ )
+ }
+}
+
+export async function DELETE() {
+ const session = await getSession()
+ if (!session) {
+ return unauthorized()
+ }
+
+ try {
+ await removeKubeconfig(session.user.id)
+ logger.info({ userId: session.user.id }, 'Deleted kubeconfig')
+ return NextResponse.json({ configured: false, updatedAt: null })
+ } catch (error) {
+ logger.error({ error, userId: session.user.id }, 'Failed to delete kubeconfig')
+ return NextResponse.json(
+ { code: 'INTERNAL_ERROR', message: 'Could not delete kubeconfig.' },
+ { status: 500 }
+ )
+ }
+}
diff --git a/app/api/projects/[id]/delete/route.ts b/app/api/projects/[id]/delete/route.ts
deleted file mode 100644
index edccfc7..0000000
--- a/app/api/projects/[id]/delete/route.ts
+++ /dev/null
@@ -1,59 +0,0 @@
-import type { ProjectStatus } from '@prisma/client'
-import { NextResponse } from 'next/server'
-
-import { withAuth } from '@/lib/api-auth'
-import { prisma } from '@/lib/db'
-import { updateProjectStatus } from '@/lib/repo/project'
-import { canDeleteProject } from '@/lib/util/action'
-
-type DeleteProjectResponse =
- | { error: string }
- | { message: string; project: { id: string; status: ProjectStatus; updatedAt: Date } }
-
-/**
- * POST /api/projects/[id]/delete
- *
- * Delete a project and all its resources
- * - Updates all databases and sandboxes to TERMINATING status
- * - Reconcile job will handle the actual K8s resource deletion
- * - After all resources are deleted, the project will be automatically removed
- */
-export const POST = withAuth(async (_req, context, session) => {
- const resolvedParams = await context.params
- const projectId = Array.isArray(resolvedParams.id) ? resolvedParams.id[0] : resolvedParams.id
-
- // Get project with resources to check if delete is allowed
- const project = await prisma.project.findUnique({
- where: { id: projectId, userId: session.user.id },
- include: {
- databases: { select: { status: true } },
- sandboxes: { select: { status: true } },
- },
- })
-
- if (!project) {
- return NextResponse.json({ error: 'Project not found' }, { status: 404 })
- }
-
- // Check if delete action is allowed
- const actionCheck = canDeleteProject(project)
- if (!actionCheck.allowed) {
- return NextResponse.json(
- { error: actionCheck.reason || 'Cannot delete project' },
- { status: 400 }
- )
- }
-
- // Update all resources to TERMINATING
- const updatedProject = await updateProjectStatus(projectId, 'TERMINATING')
-
- return NextResponse.json({
- message:
- 'Project deletion requested - reconcile job will handle resource cleanup and project removal',
- project: {
- id: updatedProject.id,
- status: updatedProject.status,
- updatedAt: updatedProject.updatedAt,
- },
- })
-})
diff --git a/app/api/projects/[id]/environment/[envId]/route.ts b/app/api/projects/[id]/environment/[envId]/route.ts
deleted file mode 100644
index 307a124..0000000
--- a/app/api/projects/[id]/environment/[envId]/route.ts
+++ /dev/null
@@ -1,229 +0,0 @@
-import type { Environment } from '@prisma/client'
-import { NextResponse } from 'next/server'
-
-import { verifyProjectAccess, withAuth } from '@/lib/api-auth'
-import { prisma } from '@/lib/db'
-import { logger as baseLogger } from '@/lib/logger'
-
-const logger = baseLogger.child({ module: 'api/projects/[id]/environment/[envId]' })
-
-type PutEnvironmentResponse = { error: string } | Environment
-
-export const PUT = withAuth(async (req, context, session) => {
- const params = await context.params
- const projectId = Array.isArray(params.id) ? params.id[0] : params.id
- const envId = Array.isArray(params.envId) ? params.envId[0] : params.envId
-
- try {
- // Verify project access
- await verifyProjectAccess(projectId, session.user.id)
-
- // Parse request body first (before any validation that might return early)
- const body = await req.json()
- const { value } = body
-
- if (value === undefined) {
- return NextResponse.json({ error: 'Value is required' }, { status: 400 })
- }
-
- // Verify the environment variable belongs to the project
- const envVar = await prisma.environment.findFirst({
- where: {
- id: envId,
- projectId: projectId,
- },
- })
-
- if (!envVar) {
- return NextResponse.json({ error: 'Environment variable not found' }, { status: 404 })
- }
-
- // Check if project sandboxes can be updated
- const project = await prisma.project.findUnique({
- where: { id: projectId },
- include: {
- sandboxes: {
- select: { id: true, status: true, name: true },
- },
- },
- })
-
- if (!project) {
- return NextResponse.json({ error: 'Project not found' }, { status: 404 })
- }
-
- // Check if project status is RUNNING
- if (project.status !== 'RUNNING') {
- logger.warn(
- `Cannot update environment variable for project ${projectId}: project status is ${project.status}, not RUNNING`
- )
- return NextResponse.json(
- {
- error: 'Environment variables can only be updated when the project is running.',
- },
- { status: 400 }
- )
- }
-
- // Require all sandboxes to be RUNNING
- if (project.sandboxes.length === 0) {
- logger.warn(
- `Cannot update environment variable for project ${projectId}: project has no sandboxes`
- )
- return NextResponse.json(
- {
- error: 'Environment variables can only be updated when the project is running.',
- },
- { status: 400 }
- )
- }
-
- // Check if ALL sandboxes are RUNNING
- const nonRunningSandboxes = project.sandboxes.filter((sb) => sb.status !== 'RUNNING')
-
- if (nonRunningSandboxes.length > 0) {
- logger.warn(
- `Cannot update environment variable for project ${projectId}: not all sandboxes are RUNNING`
- )
- return NextResponse.json(
- {
- error: 'Environment variables can only be updated when the project is running.',
- },
- { status: 400 }
- )
- }
-
- // Update the environment variable in database
- const updated = await prisma.environment.update({
- where: { id: envId },
- data: { value },
- })
-
- // Set all sandboxes to UPDATING status
- if (project.sandboxes.length > 0) {
- await prisma.sandbox.updateMany({
- where: {
- projectId,
- status: 'RUNNING', // Only update RUNNING sandboxes
- },
- data: {
- status: 'UPDATING',
- },
- })
- logger.info(
- `Set ${project.sandboxes.length} sandboxes to UPDATING status for project ${projectId}`
- )
- }
-
- return NextResponse.json(updated)
- } catch (error) {
- logger.error(`Error updating environment variable: ${error}`)
- return NextResponse.json({ error: 'Failed to update environment variable' }, { status: 500 })
- }
-})
-
-type DeleteEnvironmentResponse = { error: string } | { success: true }
-
-export const DELETE = withAuth(async (_req, context, session) => {
- const params = await context.params
- const projectId = Array.isArray(params.id) ? params.id[0] : params.id
- const envId = Array.isArray(params.envId) ? params.envId[0] : params.envId
-
- try {
- // Verify project access
- await verifyProjectAccess(projectId, session.user.id)
-
- // Verify the environment variable belongs to the project
- const envVar = await prisma.environment.findFirst({
- where: {
- id: envId,
- projectId: projectId,
- },
- })
-
- if (!envVar) {
- return NextResponse.json({ error: 'Environment variable not found' }, { status: 404 })
- }
-
- // Check if project sandboxes can be updated
- const project = await prisma.project.findUnique({
- where: { id: projectId },
- include: {
- sandboxes: {
- select: { id: true, status: true, name: true },
- },
- },
- })
-
- if (!project) {
- return NextResponse.json({ error: 'Project not found' }, { status: 404 })
- }
-
- // Check if project status is RUNNING
- if (project.status !== 'RUNNING') {
- logger.warn(
- `Cannot delete environment variable for project ${projectId}: project status is ${project.status}, not RUNNING`
- )
- return NextResponse.json(
- {
- error: 'Environment variables can only be updated when the project is running.',
- },
- { status: 400 }
- )
- }
-
- // Require all sandboxes to be RUNNING
- if (project.sandboxes.length === 0) {
- logger.warn(
- `Cannot delete environment variable for project ${projectId}: project has no sandboxes`
- )
- return NextResponse.json(
- {
- error: 'Environment variables can only be updated when the project is running.',
- },
- { status: 400 }
- )
- }
-
- // Check if ALL sandboxes are RUNNING
- const nonRunningSandboxes = project.sandboxes.filter((sb) => sb.status !== 'RUNNING')
-
- if (nonRunningSandboxes.length > 0) {
- logger.warn(
- `Cannot delete environment variable for project ${projectId}: not all sandboxes are RUNNING`
- )
- return NextResponse.json(
- {
- error: 'Environment variables can only be updated when the project is running.',
- },
- { status: 400 }
- )
- }
-
- // Delete the environment variable from database
- await prisma.environment.delete({
- where: { id: envId },
- })
-
- // Set all sandboxes to UPDATING status
- if (project.sandboxes.length > 0) {
- await prisma.sandbox.updateMany({
- where: {
- projectId,
- status: 'RUNNING', // Only update RUNNING sandboxes
- },
- data: {
- status: 'UPDATING',
- },
- })
- logger.info(
- `Set ${project.sandboxes.length} sandboxes to UPDATING status for project ${projectId}`
- )
- }
-
- return NextResponse.json({ success: true })
- } catch (error) {
- logger.error(`Error deleting environment variable: ${error}`)
- return NextResponse.json({ error: 'Failed to delete environment variable' }, { status: 500 })
- }
-})
diff --git a/app/api/projects/[id]/environment/route.ts b/app/api/projects/[id]/environment/route.ts
deleted file mode 100644
index 1fb8617..0000000
--- a/app/api/projects/[id]/environment/route.ts
+++ /dev/null
@@ -1,219 +0,0 @@
-import type { Environment } from '@prisma/client'
-import { NextResponse } from 'next/server'
-
-import { verifyProjectAccess, withAuth } from '@/lib/api-auth'
-import { EnvironmentCategory } from '@/lib/const'
-import { prisma } from '@/lib/db'
-import { logger as baseLogger } from '@/lib/logger'
-
-const logger = baseLogger.child({ module: 'api/projects/[id]/environment' })
-
-type GroupedEnvironments = Record
-
-type GetEnvironmentsResponse = { error: string } | GroupedEnvironments
-
-export const GET = withAuth(async (_req, context, session) => {
- const resolvedParams = await context.params
- const projectId = Array.isArray(resolvedParams.id) ? resolvedParams.id[0] : resolvedParams.id
-
- try {
- await verifyProjectAccess(projectId, session.user.id)
-
- // Fetch environment variables
- const environments = await prisma.environment.findMany({
- where: { projectId },
- orderBy: { createdAt: 'asc' },
- })
-
- // Group environment variables by category (dynamically based on EnvironmentCategory enum)
- const grouped: GroupedEnvironments = {}
-
- // Initialize all categories from enum
- Object.values(EnvironmentCategory).forEach((category) => {
- grouped[category] = environments.filter((e) => e.category === category)
- })
-
- // Add general category for 'general' and null/undefined categories
- grouped.general = environments.filter((e) => e.category === 'general' || !e.category)
-
- return NextResponse.json(grouped)
- } catch (error) {
- console.error('Error fetching environment variables:', error)
- return NextResponse.json({ error: 'Failed to fetch environment variables' }, { status: 500 })
- }
-})
-
-interface EnvironmentVariableInput {
- key: string
- value: string
- category?: string
- isSecret?: boolean
-}
-
-type PostEnvironmentResponse = { error: string } | Environment | { success: true; count: number }
-
-export const POST = withAuth(async (req, context, session) => {
- const resolvedParams = await context.params
- const projectId = Array.isArray(resolvedParams.id) ? resolvedParams.id[0] : resolvedParams.id
-
- try {
- await verifyProjectAccess(projectId, session.user.id)
-
- // Parse request body first (before any validation that might return early)
- const body = await req.json()
-
- // Check if project sandboxes can be updated
- const project = await prisma.project.findUnique({
- where: { id: projectId },
- include: {
- sandboxes: {
- select: { id: true, status: true, name: true },
- },
- },
- })
-
- if (!project) {
- return NextResponse.json({ error: 'Project not found' }, { status: 404 })
- }
-
- // Check if project status is RUNNING
- if (project.status !== 'RUNNING') {
- logger.warn(
- `Cannot update environment variables for project ${projectId}: project status is ${project.status}, not RUNNING`
- )
- return NextResponse.json(
- {
- error: 'Environment variables can only be updated when the project is running.',
- },
- { status: 400 }
- )
- }
-
- // Require all sandboxes to be RUNNING
- if (project.sandboxes.length === 0) {
- logger.warn(
- `Cannot update environment variables for project ${projectId}: project has no sandboxes`
- )
- return NextResponse.json(
- {
- error: 'Environment variables can only be updated when the project is running.',
- },
- { status: 400 }
- )
- }
-
- // Check if ALL sandboxes are RUNNING
- const nonRunningSandboxes = project.sandboxes.filter((sb) => sb.status !== 'RUNNING')
-
- if (nonRunningSandboxes.length > 0) {
- logger.warn(
- `Cannot update environment variables for project ${projectId}: not all sandboxes are RUNNING`
- )
- return NextResponse.json(
- {
- error: 'Environment variables can only be updated when the project is running.',
- },
- { status: 400 }
- )
- }
-
- // Check if this is a single variable creation or batch update
- if (body.key && body.value !== undefined) {
- // Single variable creation
- const newVar = await prisma.environment.create({
- data: {
- projectId,
- key: body.key,
- value: body.value,
- category: body.category || 'general',
- isSecret: body.isSecret || false,
- },
- })
-
- // Set all sandboxes to UPDATING status
- if (project.sandboxes.length > 0) {
- await prisma.sandbox.updateMany({
- where: {
- projectId,
- status: 'RUNNING', // Only update RUNNING sandboxes
- },
- data: {
- status: 'UPDATING',
- },
- })
- logger.info(
- `Set ${project.sandboxes.length} sandboxes to UPDATING status for project ${projectId}`
- )
- }
-
- return NextResponse.json(newVar)
- } else if (body.variables) {
- // Batch update (replace variables by category)
- const { variables } = body
-
- // Determine the primary category for this batch update
- // If all variables have the same category, only delete that category
- // Otherwise, delete all to maintain backward compatibility
- const categories = new Set(
- (variables as EnvironmentVariableInput[]).map((v) => v.category || 'general')
- )
- const deleteByCategory = categories.size === 1
-
- if (deleteByCategory) {
- // Delete only environment variables of the same category
- const targetCategory = Array.from(categories)[0]
- await prisma.environment.deleteMany({
- where: {
- projectId,
- category: targetCategory,
- },
- })
- } else {
- // Mixed categories - delete all (fallback behavior)
- await prisma.environment.deleteMany({
- where: { projectId },
- })
- }
-
- // Create new environment variables
- const envPromises = (variables as EnvironmentVariableInput[])
- .filter((v) => v.key && v.value !== undefined)
- .map((v) =>
- prisma.environment.create({
- data: {
- projectId,
- key: v.key,
- value: v.value,
- category: v.category || 'general',
- isSecret: v.isSecret || false,
- },
- })
- )
-
- const created = await Promise.all(envPromises)
-
- // Set all sandboxes to UPDATING status
- if (project.sandboxes.length > 0) {
- await prisma.sandbox.updateMany({
- where: {
- projectId,
- status: 'RUNNING', // Only update RUNNING sandboxes
- },
- data: {
- status: 'UPDATING',
- },
- })
- logger.info(
- `Set ${project.sandboxes.length} sandboxes to UPDATING status for project ${projectId}`
- )
- }
-
- return NextResponse.json({ success: true, count: created.length })
- } else {
- return NextResponse.json({ error: 'Invalid request body' }, { status: 400 })
- }
- } catch (error) {
- logger.error(`Error saving environment variables: ${error}`)
- return NextResponse.json({ error: 'Failed to save environment variables' }, { status: 500 })
- }
-})
diff --git a/app/api/projects/[id]/route.ts b/app/api/projects/[id]/route.ts
deleted file mode 100644
index 6b68ff7..0000000
--- a/app/api/projects/[id]/route.ts
+++ /dev/null
@@ -1,58 +0,0 @@
-import type { Prisma } from '@prisma/client'
-import { NextResponse } from 'next/server'
-
-import { withAuth } from '@/lib/api-auth'
-import { prisma } from '@/lib/db'
-
-type ProjectWithFullRelations = Prisma.ProjectGetPayload<{
- include: {
- sandboxes: true
- databases: true
- environments: true
- tasks: true
- }
-}>
-
-type GetProjectResponse = { error: string } | ProjectWithFullRelations
-
-/**
- * GET /api/projects/[id]
- * Get project details with sandboxes and databases
- */
-export const GET = withAuth(async (_req, context, session) => {
- const resolvedParams = await context.params
- const projectId = Array.isArray(resolvedParams.id) ? resolvedParams.id[0] : resolvedParams.id
-
- try {
- // Verify project access and get full project data
- const project = await prisma.project.findFirst({
- where: {
- id: projectId,
- userId: session.user.id,
- },
- include: {
- sandboxes: {
- orderBy: { createdAt: 'asc' },
- },
- databases: {
- orderBy: { createdAt: 'asc' },
- },
- environments: {
- orderBy: { createdAt: 'asc' },
- },
- tasks: {
- orderBy: { createdAt: 'desc' },
- },
- },
- })
-
- if (!project) {
- return NextResponse.json({ error: 'Project not found' }, { status: 404 })
- }
-
- return NextResponse.json(project)
- } catch (error) {
- console.error('Error fetching project:', error)
- return NextResponse.json({ error: 'Failed to fetch project details' }, { status: 500 })
- }
-})
diff --git a/app/api/projects/[id]/start/route.ts b/app/api/projects/[id]/start/route.ts
deleted file mode 100644
index 5314ee8..0000000
--- a/app/api/projects/[id]/start/route.ts
+++ /dev/null
@@ -1,57 +0,0 @@
-import type { ProjectStatus } from '@prisma/client'
-import { NextResponse } from 'next/server'
-
-import { withAuth } from '@/lib/api-auth'
-import { prisma } from '@/lib/db'
-import { updateProjectStatus } from '@/lib/repo/project'
-import { canStartProject } from '@/lib/util/action'
-
-type StartProjectResponse =
- | { error: string }
- | { message: string; project: { id: string; status: ProjectStatus; updatedAt: Date } }
-
-/**
- * POST /api/projects/[id]/start
- *
- * Start all resources of a project
- * - Updates all databases and sandboxes to STARTING status
- * - Reconcile job will handle the actual K8s operations
- */
-export const POST = withAuth(async (_req, context, session) => {
- const resolvedParams = await context.params
- const projectId = Array.isArray(resolvedParams.id) ? resolvedParams.id[0] : resolvedParams.id
-
- // Get project with resources to check if start is allowed
- const project = await prisma.project.findUnique({
- where: { id: projectId, userId: session.user.id },
- include: {
- databases: { select: { status: true } },
- sandboxes: { select: { status: true } },
- },
- })
-
- if (!project) {
- return NextResponse.json({ error: 'Project not found' }, { status: 404 })
- }
-
- // Check if start action is allowed
- const actionCheck = canStartProject(project)
- if (!actionCheck.allowed) {
- return NextResponse.json(
- { error: actionCheck.reason || 'Cannot start project' },
- { status: 400 }
- )
- }
-
- // Update all resources to STARTING
- const updatedProject = await updateProjectStatus(projectId, 'STARTING')
-
- return NextResponse.json({
- message: 'Project start requested - reconcile job will handle resource operations',
- project: {
- id: updatedProject.id,
- status: updatedProject.status,
- updatedAt: updatedProject.updatedAt,
- },
- })
-})
diff --git a/app/api/projects/[id]/stop/route.ts b/app/api/projects/[id]/stop/route.ts
deleted file mode 100644
index 8ffce51..0000000
--- a/app/api/projects/[id]/stop/route.ts
+++ /dev/null
@@ -1,57 +0,0 @@
-import type { ProjectStatus } from '@prisma/client'
-import { NextResponse } from 'next/server'
-
-import { withAuth } from '@/lib/api-auth'
-import { prisma } from '@/lib/db'
-import { updateProjectStatus } from '@/lib/repo/project'
-import { canStopProject } from '@/lib/util/action'
-
-type StopProjectResponse =
- | { error: string }
- | { message: string; project: { id: string; status: ProjectStatus; updatedAt: Date } }
-
-/**
- * POST /api/projects/[id]/stop
- *
- * Stop all resources of a project
- * - Updates all databases and sandboxes to STOPPING status
- * - Reconcile job will handle the actual K8s operations
- */
-export const POST = withAuth(async (_req, context, session) => {
- const resolvedParams = await context.params
- const projectId = Array.isArray(resolvedParams.id) ? resolvedParams.id[0] : resolvedParams.id
-
- // Get project with resources to check if stop is allowed
- const project = await prisma.project.findUnique({
- where: { id: projectId, userId: session.user.id },
- include: {
- databases: { select: { status: true } },
- sandboxes: { select: { status: true } },
- },
- })
-
- if (!project) {
- return NextResponse.json({ error: 'Project not found' }, { status: 404 })
- }
-
- // Check if stop action is allowed
- const actionCheck = canStopProject(project)
- if (!actionCheck.allowed) {
- return NextResponse.json(
- { error: actionCheck.reason || 'Cannot stop project' },
- { status: 400 }
- )
- }
-
- // Update all resources to STOPPING
- const updatedProject = await updateProjectStatus(projectId, 'STOPPING')
-
- return NextResponse.json({
- message: 'Project stop requested - reconcile job will handle resource operations',
- project: {
- id: updatedProject.id,
- status: updatedProject.status,
- updatedAt: updatedProject.updatedAt,
- },
- })
-})
diff --git a/app/api/sandbox/[id]/app-status/route.ts b/app/api/sandbox/[id]/app-status/route.ts
deleted file mode 100644
index cf29798..0000000
--- a/app/api/sandbox/[id]/app-status/route.ts
+++ /dev/null
@@ -1,69 +0,0 @@
-/**
- * GET /api/sandbox/[id]/app-status
- * Check if application is running (port 3000 listening)
- *
- * DELETE /api/sandbox/[id]/app-status
- * Stop the application (kill process on port 3000)
- */
-
-import { NextResponse } from 'next/server'
-
-import { verifySandboxAccess, withAuth } from '@/lib/api-auth'
-import { getK8sServiceForUser } from '@/lib/k8s/k8s-service-helper'
-import { logger as baseLogger } from '@/lib/logger'
-
-const logger = baseLogger.child({ module: 'api/sandbox/[id]/app-status' })
-const APP_PORT = 3000
-
-export const GET = withAuth<{ running: boolean }>(async (req, context, session) => {
- const resolvedParams = await context.params
- const sandboxId = Array.isArray(resolvedParams.id) ? resolvedParams.id[0] : resolvedParams.id
-
- try {
- const sandbox = await verifySandboxAccess(sandboxId, session.user.id)
- const k8sService = await getK8sServiceForUser(session.user.id)
-
- const running = await k8sService.isPortListening(
- sandbox.k8sNamespace,
- sandbox.sandboxName,
- APP_PORT
- )
-
- return NextResponse.json({ running })
- } catch (error) {
- logger.error(`Failed to check app status: ${error}`)
- return NextResponse.json({ running: false })
- }
-})
-
-export const DELETE = withAuth<{ success: boolean; error?: string }>(
- async (req, context, session) => {
- const resolvedParams = await context.params
- const sandboxId = Array.isArray(resolvedParams.id) ? resolvedParams.id[0] : resolvedParams.id
-
- try {
- const sandbox = await verifySandboxAccess(sandboxId, session.user.id)
- const k8sService = await getK8sServiceForUser(session.user.id)
-
- logger.info(`Stopping app in sandbox ${sandboxId} (${sandbox.sandboxName})`)
-
- const result = await k8sService.killProcessOnPort(
- sandbox.k8sNamespace,
- sandbox.sandboxName,
- APP_PORT
- )
-
- if (result.success) {
- logger.info(`App stopped in sandbox ${sandboxId}`)
- } else {
- logger.warn(`Failed to stop app in sandbox ${sandboxId}: ${result.error}`)
- }
-
- return NextResponse.json(result)
- } catch (error) {
- logger.error(`Failed to stop app: ${error}`)
- const errorMessage = error instanceof Error ? error.message : 'Unknown error'
- return NextResponse.json({ success: false, error: errorMessage }, { status: 500 })
- }
- }
-)
diff --git a/app/api/sandbox/[id]/cwd/route.ts b/app/api/sandbox/[id]/cwd/route.ts
deleted file mode 100644
index f3f69e3..0000000
--- a/app/api/sandbox/[id]/cwd/route.ts
+++ /dev/null
@@ -1,85 +0,0 @@
-/**
- * GET /api/sandbox/[id]/cwd
- *
- * Get current working directory of a terminal session in the sandbox.
- * Uses session ID to identify the specific terminal instance.
- *
- * Query Parameters:
- * - sessionId: Terminal session ID (from TERMINAL_SESSION_ID env var in shell)
- *
- * Returns:
- * - cwd: Absolute path of current working directory
- * - homeDir: User's home directory path
- * - isInHome: Whether cwd is within homeDir
- *
- * Security:
- * - Only allows file uploads within user's home directory
- * - Uses /proc filesystem to find shell process by session ID
- */
-
-import { NextResponse } from 'next/server'
-
-import { verifySandboxAccess, withAuth } from '@/lib/api-auth'
-import { getK8sServiceForUser } from '@/lib/k8s/k8s-service-helper'
-import { logger as baseLogger } from '@/lib/logger'
-
-const logger = baseLogger.child({ module: 'api/sandbox/[id]/cwd' })
-
-interface CwdResponse {
- cwd: string
- homeDir: string
- isInHome: boolean
-}
-
-type GetCwdResponse = { error: string } | CwdResponse
-
-export const GET = withAuth(async (req, context, session) => {
- const resolvedParams = await context.params
- const sandboxId = Array.isArray(resolvedParams.id) ? resolvedParams.id[0] : resolvedParams.id
-
- try {
- // Get sessionId from query params
- const { searchParams } = new URL(req.url)
- const sessionId = searchParams.get('sessionId')
-
- if (!sessionId) {
- logger.warn(`Missing sessionId query parameter for sandbox ${sandboxId}`)
- return NextResponse.json({ error: 'sessionId query parameter is required' }, { status: 400 })
- }
-
- // Verify user owns this sandbox
- const sandbox = await verifySandboxAccess(sandboxId, session.user.id)
-
- logger.info(
- `Getting current directory for sandbox ${sandboxId} (${sandbox.sandboxName}) with session ${sessionId}`
- )
-
- // Get K8s service for user
- const k8sService = await getK8sServiceForUser(session.user.id)
-
- // Get current working directory from sandbox
- const cwdInfo = await k8sService.getSandboxCurrentDirectory(
- sandbox.k8sNamespace,
- sandbox.sandboxName,
- sessionId
- )
-
- logger.info(
- `Current directory for sandbox ${sandboxId}: ${cwdInfo.cwd} (isInHome: ${cwdInfo.isInHome})`
- )
-
- return NextResponse.json({
- cwd: cwdInfo.cwd,
- homeDir: cwdInfo.homeDir,
- isInHome: cwdInfo.isInHome,
- })
- } catch (error) {
- logger.error(`Failed to get sandbox cwd: ${error}`)
-
- const errorMessage = error instanceof Error ? error.message : 'Unknown error'
- return NextResponse.json(
- { error: `Failed to get current directory: ${errorMessage}` },
- { status: 500 }
- )
- }
-})
\ No newline at end of file
diff --git a/app/api/sandbox/[id]/exec/route.ts b/app/api/sandbox/[id]/exec/route.ts
deleted file mode 100644
index 9131493..0000000
--- a/app/api/sandbox/[id]/exec/route.ts
+++ /dev/null
@@ -1,87 +0,0 @@
-/**
- * POST /api/sandbox/[id]/exec
- *
- * Execute a command in the sandbox background.
- * Runs command with nohup, returns PID immediately.
- *
- * Request Body:
- * - command: Command to execute (required)
- * - workdir: Working directory (optional, default: /home/fulling)
- *
- * Returns:
- * - success: Whether execution was successful
- * - pid: Process ID
- * - error: Error message if failed
- *
- * Security:
- * - Requires authentication
- * - Verifies user owns the sandbox
- */
-
-import { NextResponse } from 'next/server'
-
-import { verifySandboxAccess, withAuth } from '@/lib/api-auth'
-import { getK8sServiceForUser } from '@/lib/k8s/k8s-service-helper'
-import { logger as baseLogger } from '@/lib/logger'
-
-const logger = baseLogger.child({ module: 'api/sandbox/[id]/exec' })
-
-interface ExecRequestBody {
- command: string
- workdir?: string
-}
-
-interface ExecResponse {
- success: boolean
- pid?: number
- error?: string
-}
-
-export const POST = withAuth(async (req, context, session) => {
- const resolvedParams = await context.params
- const sandboxId = Array.isArray(resolvedParams.id) ? resolvedParams.id[0] : resolvedParams.id
-
- try {
- // Parse request body
- const body: ExecRequestBody = await req.json()
-
- if (!body.command) {
- logger.warn(`Missing command in request body for sandbox ${sandboxId}`)
- return NextResponse.json({ success: false, error: 'command is required' }, { status: 400 })
- }
-
- // Verify user owns this sandbox
- const sandbox = await verifySandboxAccess(sandboxId, session.user.id)
-
- logger.info(
- `Executing background command in sandbox ${sandboxId} (${sandbox.sandboxName}): "${body.command}"`
- )
-
- // Get K8s service for user
- const k8sService = await getK8sServiceForUser(session.user.id)
-
- // Execute command in sandbox background
- const result = await k8sService.execCommandInBackground(
- sandbox.k8sNamespace,
- sandbox.sandboxName,
- body.command,
- body.workdir
- )
-
- if (result.success) {
- logger.info(`Command started in sandbox ${sandboxId} (PID: ${result.pid})`)
- } else {
- logger.warn(`Command execution failed in sandbox ${sandboxId}: ${result.error}`)
- }
-
- return NextResponse.json(result, { status: result.success ? 200 : 500 })
- } catch (error) {
- logger.error(`Failed to execute command in sandbox: ${error}`)
-
- const errorMessage = error instanceof Error ? error.message : 'Unknown error'
- return NextResponse.json(
- { success: false, error: `Failed to execute command: ${errorMessage}` },
- { status: 500 }
- )
- }
-})
diff --git a/app/api/user/config/anthropic/route.ts b/app/api/user/config/anthropic/route.ts
deleted file mode 100644
index c9e773e..0000000
--- a/app/api/user/config/anthropic/route.ts
+++ /dev/null
@@ -1,226 +0,0 @@
-/**
- * Anthropic Configuration API
- *
- * GET /api/user/config/anthropic
- * - Get Anthropic API configuration
- * - Returns: { apiBaseUrl: string | null, apiKey: string | null }
- *
- * POST /api/user/config/anthropic
- * - Save Anthropic API configuration
- * - Body: { apiBaseUrl: string, apiKey: string }
- * - Returns: { success: true }
- */
-
-import { NextRequest, NextResponse } from 'next/server'
-
-import { type RouteContext, withAuth } from '@/lib/api-auth'
-import { prisma } from '@/lib/db'
-import { logger as baseLogger } from '@/lib/logger'
-
-const logger = baseLogger.child({ module: 'api/user/config/anthropic' })
-
-const ANTHROPIC_API_KEY = 'ANTHROPIC_API_KEY'
-const ANTHROPIC_API = 'ANTHROPIC_API'
-const ANTHROPIC_MODEL = 'ANTHROPIC_MODEL'
-const ANTHROPIC_SMALL_FAST_MODEL = 'ANTHROPIC_SMALL_FAST_MODEL'
-
-type GetAnthropicConfigResponse =
- | { error: string }
- | {
- apiKey: string | null
- apiBaseUrl: string | null
- model: string | null
- smallFastModel: string | null
- }
-
-/**
- * GET /api/user/config/anthropic
- * Get Anthropic API configuration
- */
-export const GET = withAuth(
- async (_req: NextRequest, _context: RouteContext, session) => {
- try {
- const configs = await prisma.userConfig.findMany({
- where: {
- userId: session.user.id,
- key: {
- in: [ANTHROPIC_API_KEY, ANTHROPIC_API, ANTHROPIC_MODEL, ANTHROPIC_SMALL_FAST_MODEL],
- },
- },
- })
-
- const apiKey = configs.find((c) => c.key === ANTHROPIC_API_KEY)?.value || null
- const apiBaseUrl = configs.find((c) => c.key === ANTHROPIC_API)?.value || null
- const model = configs.find((c) => c.key === ANTHROPIC_MODEL)?.value || null
- const smallFastModel =
- configs.find((c) => c.key === ANTHROPIC_SMALL_FAST_MODEL)?.value || null
-
- return NextResponse.json({
- apiKey,
- apiBaseUrl,
- model,
- smallFastModel,
- })
- } catch (error) {
- logger.error(`Failed to fetch Anthropic config: ${error}`)
- return NextResponse.json(
- { error: 'Failed to fetch Anthropic configuration' },
- { status: 500 }
- )
- }
- }
-)
-
-/**
- * POST /api/user/config/anthropic
- * Save Anthropic API configuration
- */
-interface SaveAnthropicConfigRequest {
- apiBaseUrl: string
- apiKey: string
- model?: string
- smallFastModel?: string
-}
-
-type PostAnthropicConfigResponse = { error: string } | { success: true; message: string }
-
-export const POST = withAuth(
- async (req: NextRequest, _context: RouteContext, session) => {
- try {
- const body: SaveAnthropicConfigRequest = await req.json()
-
- // Validate inputs
- if (!body.apiBaseUrl || typeof body.apiBaseUrl !== 'string') {
- return NextResponse.json({ error: 'API base URL is required' }, { status: 400 })
- }
-
- if (!body.apiKey || typeof body.apiKey !== 'string') {
- return NextResponse.json({ error: 'API key is required' }, { status: 400 })
- }
-
- // Validate URL format
- try {
- new URL(body.apiBaseUrl)
- } catch {
- return NextResponse.json({ error: 'Invalid API base URL format' }, { status: 400 })
- }
-
- // Execute all operations in a transaction
- await prisma.$transaction(async (tx) => {
- // Save API key
- await tx.userConfig.upsert({
- where: {
- userId_key: {
- userId: session.user.id,
- key: ANTHROPIC_API_KEY,
- },
- },
- create: {
- userId: session.user.id,
- key: ANTHROPIC_API_KEY,
- value: body.apiKey,
- category: 'anthropic',
- isSecret: true,
- },
- update: {
- value: body.apiKey,
- },
- })
-
- // Save API base URL
- await tx.userConfig.upsert({
- where: {
- userId_key: {
- userId: session.user.id,
- key: ANTHROPIC_API,
- },
- },
- create: {
- userId: session.user.id,
- key: ANTHROPIC_API,
- value: body.apiBaseUrl,
- category: 'anthropic',
- isSecret: false,
- },
- update: {
- value: body.apiBaseUrl,
- },
- })
-
- // Save or clear model if provided
- if (body.model !== undefined) {
- if (body.model === '' || body.model === null) {
- // Delete the config if the value is empty or null
- await tx.userConfig.deleteMany({
- where: {
- userId: session.user.id,
- key: ANTHROPIC_MODEL,
- },
- })
- } else {
- await tx.userConfig.upsert({
- where: {
- userId_key: {
- userId: session.user.id,
- key: ANTHROPIC_MODEL,
- },
- },
- create: {
- userId: session.user.id,
- key: ANTHROPIC_MODEL,
- value: body.model,
- category: 'anthropic',
- isSecret: false,
- },
- update: {
- value: body.model,
- },
- })
- }
- }
-
- // Save or clear small fast model if provided
- if (body.smallFastModel !== undefined) {
- if (body.smallFastModel === '' || body.smallFastModel === null) {
- // Delete the config if the value is empty or null
- await tx.userConfig.deleteMany({
- where: {
- userId: session.user.id,
- key: ANTHROPIC_SMALL_FAST_MODEL,
- },
- })
- } else {
- await tx.userConfig.upsert({
- where: {
- userId_key: {
- userId: session.user.id,
- key: ANTHROPIC_SMALL_FAST_MODEL,
- },
- },
- create: {
- userId: session.user.id,
- key: ANTHROPIC_SMALL_FAST_MODEL,
- value: body.smallFastModel,
- category: 'anthropic',
- isSecret: false,
- },
- update: {
- value: body.smallFastModel,
- },
- })
- }
- }
- })
-
- logger.info(`Anthropic configuration saved for user ${session.user.id}`)
-
- return NextResponse.json({
- success: true,
- message: 'Anthropic configuration saved successfully',
- })
- } catch (error) {
- logger.error(`Failed to save Anthropic config: ${error}`)
- return NextResponse.json({ error: 'Failed to save Anthropic configuration' }, { status: 500 })
- }
- }
-)
diff --git a/app/api/user/config/kc/route.ts b/app/api/user/config/kc/route.ts
deleted file mode 100644
index aea3569..0000000
--- a/app/api/user/config/kc/route.ts
+++ /dev/null
@@ -1,117 +0,0 @@
-/**
- * Kubeconfig Management API
- *
- * GET /api/user/config/kc
- * - Get current kubeconfig
- * - Returns: { kubeconfig: string, namespace?: string }
- *
- * POST /api/user/config/kc
- * - Validate and save kubeconfig
- * - Body: { kubeconfig: string }
- * - Validates before saving, returns error if invalid
- * - Returns: { success: true, namespace: string }
- */
-
-import { NextRequest, NextResponse } from 'next/server'
-
-import { type RouteContext, withAuth } from '@/lib/api-auth'
-import { prisma } from '@/lib/db'
-import { updateUserKubeconfig } from '@/lib/k8s/k8s-service-helper'
-import { KubernetesUtils } from '@/lib/k8s/kubernetes-utils'
-import { logger as baseLogger } from '@/lib/logger'
-
-const logger = baseLogger.child({ module: 'api/user/config/kc' })
-
-type GetKubeconfigResponse = { error: string } | { kubeconfig: string; namespace: string | null }
-
-/**
- * GET /api/user/config/kc
- * Get current kubeconfig
- */
-export const GET = withAuth(
- async (_req: NextRequest, _context: RouteContext, session) => {
- try {
- const config = await prisma.userConfig.findUnique({
- where: {
- userId_key: {
- userId: session.user.id,
- key: 'KUBECONFIG',
- },
- },
- })
-
- if (!config) {
- return NextResponse.json({ error: 'Kubeconfig not found' }, { status: 404 })
- }
-
- // Extract namespace (optional)
- const namespace = KubernetesUtils.extractNamespaceFromString(config.value)
-
- return NextResponse.json({
- kubeconfig: config.value,
- namespace,
- })
- } catch (error) {
- logger.error(`Failed to fetch kubeconfig: ${error}`)
- return NextResponse.json({ error: 'Failed to fetch kubeconfig' }, { status: 500 })
- }
- }
-)
-
-/**
- * POST /api/user/config/kc
- * Validate and save kubeconfig
- */
-interface SaveKubeconfigRequest {
- kubeconfig: string
-}
-
-type PostKubeconfigResponse =
- | { error: string; valid?: false }
- | { success: true; namespace: string | null; message: string }
-
-export const POST = withAuth(
- async (req: NextRequest, _context: RouteContext, session) => {
- try {
- const body: SaveKubeconfigRequest = await req.json()
-
- if (!body.kubeconfig || typeof body.kubeconfig !== 'string') {
- return NextResponse.json({ error: 'Kubeconfig is required' }, { status: 400 })
- }
-
- logger.info(`Validating kubeconfig for user ${session.user.id}`)
-
- // Step 1: Validate kubeconfig
- const validation = await KubernetesUtils.validateKubeconfig(body.kubeconfig)
-
- if (!validation.valid) {
- logger.warn(`Kubeconfig validation failed for user ${session.user.id}: ${validation.error}`)
- return NextResponse.json(
- {
- error: validation.error || 'Invalid kubeconfig',
- valid: false,
- },
- { status: 400 }
- )
- }
-
- logger.info(
- `Kubeconfig validation successful for user ${session.user.id}, namespace: ${validation.namespace}`
- )
-
- // Step 2: Save validated kubeconfig and clear factory cache
- await updateUserKubeconfig(session.user.id, body.kubeconfig)
-
- logger.info(`Kubeconfig saved successfully for user ${session.user.id}`)
-
- return NextResponse.json({
- success: true,
- namespace: validation.namespace ?? null,
- message: 'Kubeconfig validated and saved successfully',
- })
- } catch (error) {
- logger.error(`Failed to save kubeconfig: ${error}`)
- return NextResponse.json({ error: 'Failed to save kubeconfig' }, { status: 500 })
- }
- }
-)
diff --git a/app/api/user/config/route.ts b/app/api/user/config/route.ts
deleted file mode 100644
index 6b536b1..0000000
--- a/app/api/user/config/route.ts
+++ /dev/null
@@ -1,156 +0,0 @@
-/**
- * User Configuration API
- *
- * GET /api/user/config?keys=KEY1,KEY2
- * - Fetch specific user config values
- * - Query params: keys (comma-separated list of config keys)
- * - Returns: { configs: { key: value, ... } }
- *
- * POST /api/user/config
- * - Update or create user config values
- * - Body: { configs: [{ key, value, category?, isSecret? }, ...] }
- * - Returns: { success: true, configs: [...] }
- */
-
-import { NextRequest, NextResponse } from 'next/server'
-
-import { type RouteContext, withAuth } from '@/lib/api-auth'
-import { prisma } from '@/lib/db'
-import { logger as baseLogger } from '@/lib/logger'
-
-const logger = baseLogger.child({ module: 'api/user/config' })
-
-type GetConfigResponse = { error: string } | { configs: Record }
-
-/**
- * GET /api/user/config?keys=KEY1,KEY2
- * Fetch user config values
- */
-export const GET = withAuth(
- async (req: NextRequest, _context: RouteContext, session) => {
- const { searchParams } = new URL(req.url)
- const keysParam = searchParams.get('keys')
-
- if (!keysParam) {
- return NextResponse.json({ error: 'Missing keys parameter' }, { status: 400 })
- }
-
- const keys = keysParam.split(',').map((k) => k.trim())
-
- try {
- const configs = await prisma.userConfig.findMany({
- where: {
- userId: session.user.id,
- key: { in: keys },
- },
- select: {
- key: true,
- value: true,
- category: true,
- isSecret: true,
- },
- })
-
- // Build response object
- const configMap: Record = {}
- configs.forEach((config) => {
- configMap[config.key] = config.value
- })
-
- logger.debug(`Fetched ${configs.length} configs for user ${session.user.id}`)
-
- return NextResponse.json({ configs: configMap })
- } catch (error) {
- logger.error(`Failed to fetch user configs: ${error}`)
- return NextResponse.json({ error: 'Failed to fetch configurations' }, { status: 500 })
- }
- }
-)
-
-/**
- * POST /api/user/config
- * Update or create user config values
- */
-interface UpdateConfigRequest {
- configs: Array<{
- key: string
- value: string
- category?: string
- isSecret?: boolean
- }>
-}
-
-type PostConfigResponse =
- | { error: string }
- | {
- success: true
- configs: Array<{
- key: string
- value: string
- category: string | null
- isSecret: boolean
- }>
- }
-
-export const POST = withAuth(
- async (req: NextRequest, _context: RouteContext, session) => {
- try {
- const body: UpdateConfigRequest = await req.json()
-
- if (!body.configs || !Array.isArray(body.configs)) {
- return NextResponse.json({ error: 'Invalid request body' }, { status: 400 })
- }
-
- // Validate config items
- for (const config of body.configs) {
- if (!config.key || typeof config.key !== 'string') {
- return NextResponse.json({ error: 'Each config must have a key' }, { status: 400 })
- }
- if (config.value === undefined || config.value === null) {
- return NextResponse.json({ error: 'Each config must have a value' }, { status: 400 })
- }
- }
-
- // Upsert all configs
- const results = await Promise.all(
- body.configs.map((config) =>
- prisma.userConfig.upsert({
- where: {
- userId_key: {
- userId: session.user.id,
- key: config.key,
- },
- },
- create: {
- userId: session.user.id,
- key: config.key,
- value: config.value,
- category: config.category,
- isSecret: config.isSecret ?? false,
- },
- update: {
- value: config.value,
- category: config.category,
- isSecret: config.isSecret,
- },
- })
- )
- )
-
- logger.info(`Updated ${results.length} configs for user ${session.user.id}`)
-
- return NextResponse.json({
- success: true,
- configs: results.map((r) => ({
- key: r.key,
- value: r.value,
- category: r.category,
- isSecret: r.isSecret,
- })),
- })
- } catch (error) {
- logger.error(`Failed to update user configs: ${error}`)
- return NextResponse.json({ error: 'Failed to update configurations' }, { status: 500 })
- }
- }
-)
diff --git a/app/api/user/config/system-prompt/route.ts b/app/api/user/config/system-prompt/route.ts
deleted file mode 100644
index f50701a..0000000
--- a/app/api/user/config/system-prompt/route.ts
+++ /dev/null
@@ -1,101 +0,0 @@
-/**
- * System Prompt Management API
- *
- * GET /api/user/config/system-prompt
- * - Get current system prompt
- * - Returns: { systemPrompt: string }
- *
- * POST /api/user/config/system-prompt
- * - Save system prompt
- * - Body: { systemPrompt: string }
- * - Returns: { success: true }
- */
-
-import { NextRequest, NextResponse } from 'next/server'
-
-import { type RouteContext, withAuth } from '@/lib/api-auth'
-import { prisma } from '@/lib/db'
-import { logger as baseLogger } from '@/lib/logger'
-
-const logger = baseLogger.child({ module: 'api/user/config/system-prompt' })
-
-const SYSTEM_PROMPT_KEY = 'SYSTEM_PROMPT'
-
-type GetSystemPromptResponse = { error: string } | { systemPrompt: string | null }
-
-/**
- * GET /api/user/config/system-prompt
- * Get current system prompt
- */
-export const GET = withAuth(
- async (_req: NextRequest, _context: RouteContext, session) => {
- try {
- const config = await prisma.userConfig.findUnique({
- where: {
- userId_key: {
- userId: session.user.id,
- key: SYSTEM_PROMPT_KEY,
- },
- },
- })
-
- return NextResponse.json({
- systemPrompt: config?.value || null,
- })
- } catch (error) {
- logger.error(`Failed to fetch system prompt: ${error}`)
- return NextResponse.json({ error: 'Failed to fetch system prompt' }, { status: 500 })
- }
- }
-)
-
-/**
- * POST /api/user/config/system-prompt
- * Save system prompt
- */
-interface SaveSystemPromptRequest {
- systemPrompt: string
-}
-
-type PostSystemPromptResponse = { error: string } | { success: true; message: string }
-
-export const POST = withAuth(
- async (req: NextRequest, _context: RouteContext, session) => {
- try {
- const body: SaveSystemPromptRequest = await req.json()
-
- if (!body.systemPrompt || typeof body.systemPrompt !== 'string') {
- return NextResponse.json({ error: 'System prompt is required' }, { status: 400 })
- }
-
- await prisma.userConfig.upsert({
- where: {
- userId_key: {
- userId: session.user.id,
- key: SYSTEM_PROMPT_KEY,
- },
- },
- create: {
- userId: session.user.id,
- key: SYSTEM_PROMPT_KEY,
- value: body.systemPrompt,
- category: 'anthropic',
- isSecret: false,
- },
- update: {
- value: body.systemPrompt,
- },
- })
-
- logger.info(`System prompt saved for user ${session.user.id}`)
-
- return NextResponse.json({
- success: true,
- message: 'System prompt saved successfully',
- })
- } catch (error) {
- logger.error(`Failed to save system prompt: ${error}`)
- return NextResponse.json({ error: 'Failed to save system prompt' }, { status: 500 })
- }
- }
-)
diff --git a/app/error.tsx b/app/error.tsx
index 69a9318..36cdf39 100644
--- a/app/error.tsx
+++ b/app/error.tsx
@@ -1,88 +1,44 @@
-'use client';
+'use client'
-import { useEffect } from 'react';
-import { MdErrorOutline, MdHome, MdRefresh } from 'react-icons/md';
-import Link from 'next/link';
+import { useEffect } from 'react'
+import { CircleAlert, House, RefreshCw } from 'lucide-react'
+import Link from 'next/link'
-import { Button } from '@/components/ui/button';
+import { Button } from '@/components/ui/button'
-export default function Error({
+export default function ErrorPage({
error,
reset,
}: {
- error: Error & { digest?: string };
- reset: () => void;
+ error: Error & { digest?: string }
+ reset: () => void
}) {
useEffect(() => {
- // Log the error to an error reporting service
- console.error('Application error:', error);
- }, [error]);
+ console.error('Application error:', error)
+ }, [error])
return (
-
-
- {/* Error Header */}
-
-
-
-
-
-
Something went wrong
-
- An unexpected error occurred. Please try again or return to the home page.
-
-
-
-
- {/* Error Details */}
- {error.digest && (
-
-
- ✗
- Error Reference
-
-
{error.digest}
-
- )}
-
- {/* Actions */}
-
-
-
- Try Again
+
+
+
+
Something went wrong
+
+ The request could not be completed. Try again or return to Fulling.
+
+ {error.digest ? (
+
Reference: {error.digest}
+ ) : null}
+
+
+ Try again
+
+
+
+ Go home
+
-
-
-
- Go to Home
-
-
-
-
- {/* Help Text */}
-
-
- );
+
+ )
}
diff --git a/app/github/app/callback/page.tsx b/app/github/app/callback/page.tsx
deleted file mode 100644
index 7b4fe74..0000000
--- a/app/github/app/callback/page.tsx
+++ /dev/null
@@ -1,122 +0,0 @@
-'use client'
-
-import { useEffect, useState } from 'react'
-import { Loader2 } from 'lucide-react'
-
-export default function GitHubAppCallbackPage() {
- const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading')
- const [errorMessage, setErrorMessage] = useState
(null)
-
- useEffect(() => {
- const handleCallback = async () => {
- const searchParams = new URLSearchParams(window.location.search)
- const installationId = searchParams.get('installation_id')
- const setupAction = searchParams.get('setup_action')
- const error = searchParams.get('error')
- const errorDescription = searchParams.get('error_description')
-
- if (error) {
- setStatus('error')
- setErrorMessage(errorDescription || error)
- return
- }
-
- if (!installationId) {
- setStatus('error')
- setErrorMessage('Missing installation_id')
- return
- }
-
- try {
- const response = await fetch(
- `/api/github/app/callback?installation_id=${installationId}&setup_action=${setupAction || ''}`
- )
-
- if (!response.ok) {
- const data = await response.json().catch(() => ({}))
- throw new Error(data.error || 'Failed to complete installation')
- }
-
- setStatus('success')
-
- if (window.opener) {
- window.opener.postMessage(
- {
- type: 'github-app-installed',
- success: true,
- installationId,
- },
- window.location.origin
- )
- setTimeout(() => {
- window.close()
- }, 500)
- } else {
- setTimeout(() => {
- window.location.href = '/projects?github=connected'
- }, 1000)
- }
- } catch (err) {
- setStatus('error')
- setErrorMessage(err instanceof Error ? err.message : 'Unknown error')
- }
- }
-
- handleCallback()
- }, [])
-
- return (
-
-
- {status === 'loading' && (
- <>
-
-
Completing GitHub App installation...
- >
- )}
- {status === 'success' && (
- <>
-
-
GitHub App installed successfully!
-
This window will close automatically...
- >
- )}
- {status === 'error' && (
- <>
-
-
Installation failed
-
{errorMessage}
- >
- )}
-
-
- )
-}
diff --git a/app/globals.css b/app/globals.css
index a014b60..bf61c61 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -1,307 +1,26 @@
@import "tailwindcss";
@import "tw-animate-css";
-@custom-variant dark (&:is(.dark *));
-
:root {
- --background: oklch(1.00 0 0);
- --foreground: oklch(0.32 0 0);
- --card: oklch(1.00 0 0);
- --card-foreground: oklch(0.32 0 0);
- --popover: oklch(1.00 0 0);
- --popover-foreground: oklch(0.32 0 0);
- --primary: oklch(0.62 0.19 259.76);
- --primary-foreground: oklch(1.00 0 0);
- --primary-hover: #1077BB;
- --secondary: oklch(0.97 0 0);
- --secondary-foreground: oklch(0.45 0.03 257.68);
- --muted: oklch(0.98 0 0);
- --muted-foreground: oklch(0.55 0.02 264.41);
- --accent: oklch(0.95 0.03 233.56);
- --accent-foreground: oklch(0.38 0.14 265.59);
- --brand-claude: #d97757;
- --destructive: oklch(0.64 0.21 25.39);
- --border: oklch(0.93 0.01 261.82);
- --input: oklch(0.93 0.01 261.82);
- --ring: oklch(0.62 0.19 259.76);
- --chart-1: oklch(0.62 0.19 259.76);
- --chart-2: oklch(0.55 0.22 262.96);
- --chart-3: oklch(0.49 0.22 264.43);
- --chart-4: oklch(0.42 0.18 265.55);
- --chart-5: oklch(0.38 0.14 265.59);
- --sidebar: oklch(0.98 0 0);
- --sidebar-foreground: oklch(0.14 0 0);
- --sidebar-background: oklch(1.0 0 0);
- --sidebar-project-background: oklch(1.0 0 0);
- --sidebar-accent: oklch(0.97 0 0);
- --sidebar-border: oklch(0.92 0 0);
- --destructive-foreground: oklch(1.0000 0 0);
- --content-background: oklch(1.00 0 0);
- --font-serif: Source Serif 4, serif;
- --font-mono: JetBrains Mono, monospace;
- --radius: 0.375rem;
- --shadow-2xs: 0px 4px 8px -1px hsl(0 0% 0% / 0.05);
- --shadow-xs: 0px 4px 8px -1px hsl(0 0% 0% / 0.05);
- --shadow-sm: 0px 4px 8px -1px hsl(0 0% 0% / 0.10), 0px 1px 2px -2px hsl(0 0% 0% / 0.10);
- --shadow: 0px 4px 8px -1px hsl(0 0% 0% / 0.10), 0px 1px 2px -2px hsl(0 0% 0% / 0.10);
- --shadow-md: 0px 4px 8px -1px hsl(0 0% 0% / 0.10), 0px 2px 4px -2px hsl(0 0% 0% / 0.10);
- --shadow-lg: 0px 4px 8px -1px hsl(0 0% 0% / 0.10), 0px 4px 6px -2px hsl(0 0% 0% / 0.10);
- --shadow-xl: 0px 4px 8px -1px hsl(0 0% 0% / 0.10), 0px 8px 10px -2px hsl(0 0% 0% / 0.10);
- --shadow-2xl: 0px 4px 8px -1px hsl(0 0% 0% / 0.25);
- --tracking-normal: 0em;
-}
-
-.dark {
- --background: #1E1E1E;
- /*-- main background */
- --foreground: #CCCCCC;
- /*-- main text */
- --card: oklch(0.27 0 0);
- /*-- container background */
- --card-foreground: oklch(0.92 0 0);
- --popover: oklch(0.27 0 0);
- /*-- modal background */
- --popover-foreground: oklch(0.92 0 0);
- --primary: #10639D;
- /*-- main brand color */
- --primary-hover: #155a8a;
- /*-- main brand color hover for dark mode */
- --primary-foreground: oklch(1.00 0 0);
- --secondary: oklch(0.27 0 0);
- /*-- secondary brand color */
- --secondary-foreground: oklch(0.92 0 0);
- --muted: oklch(0.27 0 0);
- --muted-foreground: oklch(0.72 0 0);
- /*-- secondary text */
- --accent: #2B2D2E;
- /*-- for hover */
- --accent-foreground: oklch(0.88 0.06 254.63);
- --destructive: oklch(0.64 0.21 25.39);
- --destructive-foreground: oklch(1.0000 0 0);
- --border: #3E3E3E;
- --input: oklch(0.37 0 0);
- --ring: oklch(0.62 0.19 259.76);
- --chart-1: oklch(0.71 0.14 254.69);
- --chart-2: oklch(0.62 0.19 259.76);
- --chart-3: oklch(0.55 0.22 262.96);
- --chart-4: oklch(0.49 0.22 264.43);
- --chart-5: oklch(0.42 0.18 265.55);
- --sidebar: oklch(0.21 0.01 285.93);
- --sidebar-foreground: oklch(0.99 0 0);
- --sidebar-background: #333333;
- --sidebar-project-background: #242426;
- --sidebar-accent: #2B2D2E;
- --sidebar-border: oklch(1.00 0 0 / 10%);
- --content-background: #1E1E1E;
- --tabs-background: #252526;
- --tab-background: #292929;
- --tab-foreground: #A0A0A0;
- --tab-active-background: #1E1E1E;
- --tab-active-foreground: #FFFFFF;
- --tab-hover-background: #363637;
- /*-- subtle hover for tab buttons */
- --font-serif: Source Serif 4, serif;
- --font-mono: JetBrains Mono, monospace;
- --radius: 0.375rem;
- --shadow-2xs: 0px 4px 8px -1px hsl(0 0% 0% / 0.05);
- --shadow-xs: 0px 4px 8px -1px hsl(0 0% 0% / 0.05);
- --shadow-sm: 0px 4px 8px -1px hsl(0 0% 0% / 0.10), 0px 1px 2px -2px hsl(0 0% 0% / 0.10);
- --shadow: 0px 4px 8px -1px hsl(0 0% 0% / 0.10), 0px 1px 2px -2px hsl(0 0% 0% / 0.10);
- --shadow-md: 0px 4px 8px -1px hsl(0 0% 0% / 0.10), 0px 2px 4px -2px hsl(0 0% 0% / 0.10);
- --shadow-lg: 0px 4px 8px -1px hsl(0 0% 0% / 0.10), 0px 4px 6px -2px hsl(0 0% 0% / 0.10);
- --shadow-xl: 0px 4px 8px -1px hsl(0 0% 0% / 0.10), 0px 8px 10px -2px hsl(0 0% 0% / 0.10);
- --shadow-2xl: 0px 4px 8px -1px hsl(0 0% 0% / 0.25);
-}
-
-.stealth {
- /* === Base Colors === */
- --background: #1e1e1e;
- --foreground: #cccccc;
-
- /* === UI Components === */
- --card: #252526;
- --card-foreground: #cccccc;
- --popover: #2d2d30;
- --popover-foreground: #cccccc;
-
- /* === Brand Colors === */
- --primary: #007acc;
- --primary-foreground: #ffffff;
- --primary-hover: #0062a3;
-
- --secondary: #2d2d30;
- --secondary-foreground: #dcdcdc;
-
- --accent: #2d2d30;
- --accent-foreground: #ffffff;
-
- --muted: #2d2d30;
- --muted-foreground: #858585;
-
- --destructive: #F14C4C;
- --destructive-foreground: #F14C4C;
-
- /* === Borders & Inputs === */
- --border: #3e3e42;
- --input: #3e3e42;
- --ring: #007fd4;
-
- /* === Sidebar (VS Code Style) === */
- --sidebar: #2d2d30;
- --sidebar-foreground: #dcdcdc;
- --sidebar-background: #2d2d30;
- --sidebar-project-background: #252526;
- --sidebar-accent: #2d2d30;
- --sidebar-border: #1e1e1e;
-
- /* === Charts === */
- --chart-1: #007acc;
- --chart-2: #57a64a;
- --chart-3: #d69d85;
- --chart-4: #569cd6;
- --chart-5: #b5cea8;
-
- /* === Custom/Specific Overrides === */
- --content-background: #1e1e1e;
-
- /* Tabs */
- --tabs-background: #2d2d30;
- --tab-background: #2d2d30;
- --tab-foreground: #969696;
- --tab-active-background: #1e1e1e;
- --tab-active-foreground: #ffffff;
- --tab-hover-background: #3e3e42;
-}
-
-/* Modern Theme - Dark Mode (default) */
-.modern {
- /* === Base Colors === */
- --background: #121416;
- --foreground: #fafafa;
-
- /* === UI Components === */
- --card: #18181b;
- --card-foreground: #fafafa;
- --popover: #18181b;
- --popover-foreground: #fafafa;
-
- /* === Brand Colors === */
- --primary: #3b82f6;
- --primary-foreground: #ffffff;
- --primary-hover: #60a5fa;
-
- --secondary: #27272a;
- --secondary-foreground: #a1a1aa;
-
- --accent: #27272a;
- --accent-foreground: #fafafa;
-
- --muted: #27272a;
- --muted-foreground: #a1a1aa;
-
- --destructive: #ef4444;
- --destructive-foreground: #ffffff;
-
- /* === Borders & Inputs === */
- --border: #2d3136;
- --input: #27272a;
- --ring: #3b82f6;
-
- /* === Sidebar === */
- --sidebar: #18181b;
- --sidebar-foreground: #fafafa;
- --sidebar-background: #18181b;
- --sidebar-project-background: #09090b;
- --sidebar-accent: #27272a;
- --sidebar-border: #2d3136;
-
- /* === Charts === */
- --chart-1: #3b82f6;
- --chart-2: #94a3b8;
- --chart-3: #34d399;
- --chart-4: #fbbf24;
- --chart-5: #f87171;
-
- /* === Content === */
- --content-background: #09090b;
-
- /* === Tabs === */
- --tabs-background: #18181b;
- --tab-background: #18181b;
- --tab-foreground: #a1a1aa;
- --tab-active-background: #09090b;
- --tab-active-foreground: #ffffff;
- --tab-hover-background: #27272a;
-
- /* === Typography === */
- --font-sans: Inter, sans-serif;
- --font-mono: JetBrains Mono, monospace;
- --radius: 0.375rem;
-}
-
-/* Modern Theme - Light Mode (optional) */
-.modern-light {
- /* === Base Colors === */
- --background: #ffffff;
- --foreground: #09090b;
-
- /* === UI Components === */
- --card: #f9fafb;
- --card-foreground: #09090b;
+ --background: #f7f8f6;
+ --foreground: #171a1f;
+ --card: #ffffff;
+ --card-foreground: #171a1f;
--popover: #ffffff;
- --popover-foreground: #09090b;
-
- /* === Brand Colors === */
- --primary: #3b82f6;
+ --popover-foreground: #171a1f;
+ --primary: #171a1f;
--primary-foreground: #ffffff;
- --primary-hover: #2563eb;
-
- --secondary: #f3f4f6;
- --secondary-foreground: #64748b;
-
- --accent: #f3f4f6;
- --accent-foreground: #09090b;
-
- --muted: #f3f4f6;
- --muted-foreground: #64748b;
-
- --destructive: #ef4444;
+ --secondary: #eef1ef;
+ --secondary-foreground: #30373e;
+ --muted: #f0f2f1;
+ --muted-foreground: #5e6974;
+ --accent: #edf3ef;
+ --accent-foreground: #245f49;
+ --destructive: #c43d3d;
--destructive-foreground: #ffffff;
-
- /* === Borders & Inputs === */
- --border: #e5e7eb;
- --input: #e5e7eb;
- --ring: #3b82f6;
-
- /* === Sidebar === */
- --sidebar: #f3f4f6;
- --sidebar-foreground: #09090b;
- --sidebar-background: #f3f4f6;
- --sidebar-project-background: #ffffff;
- --sidebar-accent: #e5e7eb;
- --sidebar-border: #e5e7eb;
-
- /* === Charts === */
- --chart-1: #3b82f6;
- --chart-2: #64748b;
- --chart-3: #10b981;
- --chart-4: #f59e0b;
- --chart-5: #ef4444;
-
- /* === Content === */
- --content-background: #ffffff;
-
- /* === Tabs === */
- --tabs-background: #f3f4f6;
- --tab-background: #f3f4f6;
- --tab-foreground: #64748b;
- --tab-active-background: #ffffff;
- --tab-active-foreground: #09090b;
- --tab-hover-background: #e5e7eb;
-
- /* === Typography === */
- --font-sans: Inter, sans-serif;
- --font-mono: JetBrains Mono, monospace;
+ --border: #dce1e4;
+ --input: #d4dade;
+ --ring: #287355;
--radius: 0.375rem;
}
@@ -314,142 +33,50 @@
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
- --color-primary-hover: var(--primary-hover);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
- --color-brand-claude: var(--brand-claude);
--color-destructive: var(--destructive);
+ --color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
- --color-chart-1: var(--chart-1);
- --color-chart-2: var(--chart-2);
- --color-chart-3: var(--chart-3);
- --color-chart-4: var(--chart-4);
- --color-chart-5: var(--chart-5);
- --color-sidebar: var(--sidebar);
- --color-sidebar-foreground: var(--sidebar-foreground);
- --color-sidebar-background: var(--sidebar-background);
- --color-sidebar-project-background: var(--sidebar-project-background);
-
- --color-sidebar-accent: var(--sidebar-accent);
- --color-sidebar-border: var(--sidebar-border);
-
- --color-destructive-foreground: var(--destructive-foreground);
- --color-content-background: var(--content-background);
- --color-tabs-background: var(--tabs-background);
- --color-tab-background: var(--tab-background);
- --color-tab-foreground: var(--tab-foreground);
- --color-tab-active-background: var(--tab-active-background);
- --color-tab-active-foreground: var(--tab-active-foreground);
- --color-tab-hover-background: var(--tab-hover-background);
- --font-sans: var(--font-sans);
- --font-mono: var(--font-mono);
- --font-serif: var(--font-serif);
- --radius-sm: calc(var(--radius) - 4px);
- --radius-md: calc(var(--radius) - 2px);
- --radius-lg: var(--radius);
- --radius-xl: calc(var(--radius) + 4px);
- --shadow-2xs: var(--shadow-2xs);
- --shadow-xs: var(--shadow-xs);
- --shadow-sm: var(--shadow-sm);
- --shadow: var(--shadow);
- --shadow-md: var(--shadow-md);
- --shadow-lg: var(--shadow-lg);
- --shadow-xl: var(--shadow-xl);
- --shadow-2xl: var(--shadow-2xl);
-}
-
-#devtools-indicator,
-[data-nextjs-dev-tools-button] {
- display: none !important;
-}
-
-@layer base {
- * {
- @apply border-border;
- }
-
- body {
- @apply bg-background text-foreground;
- font-family: var(--font-body), sans-serif;
- }
-
- h1,
- h2,
- h3,
- h4,
- h5,
- h6 {
- font-family: var(--font-heading), sans-serif;
- }
-
- h1 {
- @apply scroll-m-20 text-4xl tracking-tight text-balance;
- }
-
- h2 {
- @apply scroll-m-20 text-3xl tracking-tight first:mt-0;
- }
-
- p {
- @apply leading-7 text-muted-foreground;
- }
-
- code,
- pre,
- .font-mono {
- font-family: var(--font-mono), monospace;
- }
+ --font-sans: var(--font-body);
}
-/* Terminal scroll indicator animation */
-@keyframes fade-in {
- from {
- opacity: 0;
- transform: translateY(10px);
- }
-
- to {
- opacity: 1;
- transform: translateY(0);
- }
+* {
+ border-color: var(--border);
+ letter-spacing: 0;
}
-.animate-fade-in {
- animation: fade-in 0.2s ease-out;
+html {
+ background: var(--background);
}
-/* Cursor blink animation for terminal demo */
-@keyframes blink {
-
- 0%,
- 100% {
- opacity: 1;
- }
-
- 50% {
- opacity: 0;
- }
+body {
+ min-height: 100vh;
+ background: var(--background);
+ color: var(--foreground);
+ font-family: var(--font-body), sans-serif;
}
-.cursor-blink {
- animation: blink 1s step-end infinite;
+button,
+a,
+input,
+textarea {
+ -webkit-tap-highlight-color: transparent;
}
-/* Respect user motion preferences globally */
@media (prefers-reduced-motion: reduce) {
-
*,
*::before,
*::after {
+ scroll-behavior: auto !important;
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
- scroll-behavior: auto !important;
}
}
diff --git a/app/layout.tsx b/app/layout.tsx
index 9e33e0b..f641f22 100644
--- a/app/layout.tsx
+++ b/app/layout.tsx
@@ -1,8 +1,6 @@
import type { Metadata } from 'next';
import { Noto_Sans, Space_Grotesk } from 'next/font/google';
-import { Toaster } from '@/components/ui/sonner';
-
import './globals.css';
const spaceGrotesk = Space_Grotesk({
@@ -19,8 +17,7 @@ const notoSans = Noto_Sans({
export const metadata: Metadata = {
title: 'Fulling - Dedicated AI Workspaces',
- description:
- 'Build, run, and share dedicated AI workspaces as versioned, reliable products.',
+ description: 'The identity and runtime credential foundation for dedicated AI workspaces.',
};
export default function RootLayout({
@@ -32,27 +29,6 @@ export default function RootLayout({
{children}
-
);
diff --git a/app/not-found.tsx b/app/not-found.tsx
index 28c0de1..b603e52 100644
--- a/app/not-found.tsx
+++ b/app/not-found.tsx
@@ -1,64 +1,31 @@
-import { MdArrowBack, MdHelpOutline, MdHome } from 'react-icons/md';
-import Link from 'next/link';
+import { ArrowLeft, CircleHelp, House } from 'lucide-react'
+import Link from 'next/link'
-import { Button } from '@/components/ui/button';
+import { Button } from '@/components/ui/button'
export default function NotFound() {
return (
-
-
- {/* Header */}
-
-
-
-
-
-
Page Not Found
-
- The page you're looking for doesn't exist or has been moved.
-
-
-
-
- {/* 404 Code Box */}
-
-
- ⚠
- HTTP Status
-
-
404 - NOT_FOUND
-
-
- {/* Actions */}
-
-
-
-
- Go to Home
-
-
-
-
-
- Back to Projects
-
-
-
-
- {/* Help Text */}
-
-
- Check the URL for typos, or use the navigation above to find what you need.
-
+
+
+
+
404
+
Page not found
+
+ The page does not exist or has moved.
+
+
+
+
+ Go home
+
+
+
+
+ Open workspace
+
+
-
- );
+
+ )
}
diff --git a/components/config/env-var-section.tsx b/components/config/env-var-section.tsx
deleted file mode 100644
index 6eee7e9..0000000
--- a/components/config/env-var-section.tsx
+++ /dev/null
@@ -1,373 +0,0 @@
-/**
- * Environment Variable Section Component
- * Reusable section for managing categorized environment variables
- * VSCode Dark Modern style
- */
-
-'use client';
-
-import { useState } from 'react';
-import {
- MdAdd,
- MdClose,
- MdContentCopy,
- MdSave,
- MdTag,
- MdVisibility,
- MdVisibilityOff,
-} from 'react-icons/md';
-import { toast } from 'sonner';
-
-import {
- AlertDialog,
- AlertDialogAction,
- AlertDialogCancel,
- AlertDialogContent,
- AlertDialogDescription,
- AlertDialogFooter,
- AlertDialogHeader,
- AlertDialogTitle,
-} from '@/components/ui/alert-dialog';
-import { Button } from '@/components/ui/button';
-import { Input } from '@/components/ui/input';
-import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
-
-interface EnvironmentVariable {
- id?: string;
- key: string;
- value: string;
- isSecret?: boolean;
-}
-
-interface VariableTemplate {
- key: string;
- label: string;
- placeholder?: string;
- isSecret?: boolean;
- description?: string;
- readOnly?: boolean;
- generateValue?: () => string;
-}
-
-interface EnvVarSectionProps {
- /** Section title */
- title: string;
- /** Section description */
- description?: string;
- /** Initial variables */
- variables: EnvironmentVariable[];
- /** Variable templates for this section */
- templates?: VariableTemplate[];
- /** Project sandboxes for status check */
- sandboxes: Array<{ status: string }>;
- /** Save handler */
- onSave: (variables: EnvironmentVariable[]) => Promise
;
- /** Whether data is being saved */
- saving?: boolean;
- /** Whether to allow free-form variable addition */
- allowCustomVariables?: boolean;
-}
-
-/**
- * Environment variable section with status checks and confirmation
- */
-export function EnvVarSection({
- title,
- description,
- variables: initialVariables,
- templates = [],
- sandboxes,
- onSave,
- saving,
- allowCustomVariables = false,
-}: EnvVarSectionProps) {
- const [variables, setVariables] = useState(() => {
- // Initialize from templates if provided
- if (templates.length > 0) {
- return templates.map((template) => {
- const existing = initialVariables.find((v) => v.key === template.key);
- return {
- key: template.key,
- value: existing?.value || '',
- isSecret: template.isSecret || false,
- };
- });
- }
- return initialVariables;
- });
-
- const [showConfirm, setShowConfirm] = useState(false);
- const [showSecrets, setShowSecrets] = useState>({});
-
- // Check if ALL sandboxes are RUNNING (project must be RUNNING)
- const canUpdate =
- sandboxes.length > 0 && sandboxes.every((sb) => sb.status === 'RUNNING');
-
- const hasChanges = JSON.stringify(variables) !== JSON.stringify(initialVariables);
-
- const handleAdd = () => {
- setVariables([...variables, { key: '', value: '', isSecret: false }]);
- };
-
- const handleRemove = (index: number) => {
- setVariables(variables.filter((_, i) => i !== index));
- };
-
- const handleUpdate = (
- index: number,
- field: keyof EnvironmentVariable,
- value: string | boolean
- ) => {
- const updated = [...variables];
- updated[index] = { ...updated[index], [field]: value };
- setVariables(updated);
- };
-
- const handleGenerate = (index: number, generateFn: () => string) => {
- handleUpdate(index, 'value', generateFn());
- };
-
- const handleCopyKey = async (key: string) => {
- try {
- await navigator.clipboard.writeText(key);
- toast.success(`Copied ${key} to clipboard`);
- } catch {
- toast.error('Failed to copy to clipboard');
- }
- };
-
- const handleSaveClick = () => {
- if (!canUpdate) {
- toast.error('Environment variables can only be updated when the project is running');
- return;
- }
-
- if (!hasChanges) {
- toast.info('No changes to save');
- return;
- }
-
- const invalidVars = variables.filter((v) => v.key && !v.value);
- if (invalidVars.length > 0) {
- toast.error('All variables must have a value');
- return;
- }
-
- setShowConfirm(true);
- };
-
- const handleConfirmSave = async () => {
- setShowConfirm(false);
- try {
- // Filter out empty variables
- const validVars = variables.filter((v) => v.key && v.value);
- await onSave(validVars);
- toast.success('Environment variables updated successfully');
- } catch {
- // Error already handled by mutation
- }
- };
-
- const getTemplate = (key: string): VariableTemplate | undefined => {
- return templates.find((t) => t.key === key);
- };
-
- return (
- <>
-
- {/* Title */}
- {title && (
-
-
{title}
- {description &&
{description}
}
-
- )}
-
- {/* Variable List */}
-
- {variables.map((variable, index) => {
- const template = getTemplate(variable.key);
- const isReadOnly = template?.readOnly || false;
-
- return (
-
- {/* Label */}
- {template && (
-
-
{template.label}
-
-
- handleCopyKey(variable.key)}
- className="flex items-center gap-1.5 text-xs bg-[#2d2d30] border border-[#3e3e42] px-2 py-0.5 rounded text-[#3794ff] font-mono hover:bg-[#37373d] hover:border-[#3794ff]/50 transition-colors cursor-pointer group"
- >
-
- {variable.key}
-
-
-
-
-
- Environment variable key
- • Click to copy
-
-
-
-
- )}
-
- {/* Input Row */}
-
- {/* Key Input (if no template) */}
- {!template && (
-
- handleUpdate(
- index,
- 'key',
- e.target.value.toUpperCase().replace(/[^A-Z0-9_]/g, '')
- )
- }
- disabled={!canUpdate || saving}
- className="flex-1 bg-[#3c3c3c] border-[#3e3e42] text-[#cccccc] font-mono text-sm h-9 focus:border-[#3794ff] focus:ring-1 focus:ring-[#3794ff]"
- />
- )}
-
- {/* Value Input */}
-
- handleUpdate(index, 'value', e.target.value)}
- disabled={!canUpdate || saving || isReadOnly}
- className="bg-[#3c3c3c] border-[#3e3e42] text-[#cccccc] text-sm h-9 pr-8 focus:border-[#3794ff] focus:ring-1 focus:ring-[#3794ff] font-mono"
- />
- {variable.isSecret && (
-
- setShowSecrets({ ...showSecrets, [index]: !showSecrets[index] })
- }
- className="absolute right-2 top-1/2 -translate-y-1/2 text-[#858585] hover:text-[#cccccc]"
- >
- {showSecrets[index] ? (
-
- ) : (
-
- )}
-
- )}
-
-
- {/* Generate Button */}
- {template?.generateValue && (
-
handleGenerate(index, template.generateValue!)}
- disabled={!canUpdate || saving}
- variant="ghost"
- size="sm"
- className="text-[#cccccc] hover:text-white hover:bg-[#37373d] h-9 px-3"
- >
- Generate
-
- )}
-
- {/* Delete Button (if no template or custom variables allowed) */}
- {(!template || allowCustomVariables) && (
-
handleRemove(index)}
- disabled={!canUpdate || saving}
- className="text-[#858585] hover:text-[#f48771] opacity-0 group-hover:opacity-100 transition-opacity disabled:opacity-40 disabled:cursor-not-allowed"
- >
-
-
- )}
-
-
- {/* Description */}
- {template?.description && (
-
{template.description}
- )}
-
- );
- })}
-
- {/* Empty State */}
- {variables.length === 0 && (
-
- No secrets configured yet.
-
- )}
-
-
- {/* Actions */}
-
- {allowCustomVariables && (
-
-
- Add Variable
-
- )}
-
-
-
-
-
- {saving ? 'Saving...' : 'Save Changes'}
-
-
-
- {/* Status Message */}
- {!canUpdate && (
-
- Environment variables can only be updated when the project is running
-
- )}
-
-
- {/* Confirmation Dialog */}
-
-
-
-
- Confirm Environment Variable Changes
-
-
- Updating environment variables will restart your application. All active terminal
- sessions will be lost. Do you want to continue?
-
-
-
-
- Cancel
-
-
- Save Changes
-
-
-
-
- >
- );
-}
diff --git a/components/dialog/settings-dialog.tsx b/components/dialog/settings-dialog.tsx
deleted file mode 100644
index 5b98021..0000000
--- a/components/dialog/settings-dialog.tsx
+++ /dev/null
@@ -1,753 +0,0 @@
-'use client';
-
-import { useEffect, useState } from 'react';
-import { FaGithub } from 'react-icons/fa';
-import { MdCode, MdSave, MdStorage, MdTerminal } from 'react-icons/md';
-import Image from 'next/image';
-import { toast } from 'sonner';
-
-import {
- AlertDialog,
- AlertDialogAction,
- AlertDialogCancel,
- AlertDialogContent,
- AlertDialogDescription,
- AlertDialogFooter,
- AlertDialogHeader,
- AlertDialogTitle,
-} from '@/components/ui/alert-dialog';
-import { Button } from '@/components/ui/button';
-import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
-import { Input } from '@/components/ui/input';
-import { Label } from '@/components/ui/label';
-import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
-import { Textarea } from '@/components/ui/textarea';
-import { getInstallations, type GitHubInstallation } from '@/lib/actions/github';
-import { env } from '@/lib/env';
-import * as fetchClient from '@/lib/fetch-client';
-
-interface SettingsDialogProps {
- open: boolean;
- onOpenChange: (open: boolean) => void;
- defaultTab?: 'system-prompt' | 'kubeconfig' | 'anthropic' | 'github';
-}
-
-const DEFAULT_SYSTEM_PROMPT = `You are an AI full-stack developer working in a Next.js environment.
-
-## Environment Information
-- Framework: Next.js 15 with App Router
-- Language: TypeScript
-- Database: PostgreSQL with Prisma ORM
-- UI Framework: Shadcn/UI with Tailwind CSS
-- Authentication: NextAuth v5
-
-## Available Environment Variables
-- DATABASE_URL: PostgreSQL connection string
-- ANTHROPIC_API_KEY: Claude API key
-- ANTHROPIC_BASE_URL: Claude API base URL
-
-## Instructions
-- Follow Next.js 15 App Router conventions
-- Use TypeScript for type safety
-- Implement proper error handling and loading states
-- Follow responsive design principles with Tailwind CSS
-- Focus on creating production-ready, maintainable code
-
-## Development Guidelines
-- Write clean, readable code with proper documentation
-- Implement proper error boundaries and validation
-- Use semantic HTML and accessibility best practices
-- Optimize for performance and SEO
-- Follow modern React patterns and best practices`;
-
-type TabType = 'system-prompt' | 'kubeconfig' | 'anthropic' | 'github';
-
-export default function SettingsDialog({
- open,
- onOpenChange,
- defaultTab = 'kubeconfig',
-}: SettingsDialogProps) {
- const isSealos = false;
- const [activeTab, setActiveTab] = useState(defaultTab);
-
- // System Prompt state
- const [systemPrompt, setSystemPrompt] = useState('');
- const [isSystemPromptLoading, setIsSystemPromptLoading] = useState(false);
- const [isSystemPromptInitialLoading, setIsSystemPromptInitialLoading] = useState(true);
-
- // Kubeconfig state
- const [kubeconfig, setKubeconfig] = useState('');
- const [kubeconfigNamespace, setKubeconfigNamespace] = useState(null);
- const [isKubeconfigLoading, setIsKubeconfigLoading] = useState(false);
- const [isKubeconfigInitialLoading, setIsKubeconfigInitialLoading] = useState(true);
-
- // Anthropic state
- const [anthropicApiKey, setAnthropicApiKey] = useState('');
- const [anthropicApiBaseUrl, setAnthropicApiBaseUrl] = useState('');
- const [anthropicModel, setAnthropicModel] = useState('');
- const [anthropicSmallFastModel, setAnthropicSmallFastModel] = useState('');
- const [isAnthropicLoading, setIsAnthropicLoading] = useState(false);
- const [isAnthropicInitialLoading, setIsAnthropicInitialLoading] = useState(true);
-
- // GitHub state
- const [githubInstallation, setGithubInstallation] = useState(null);
- const [isGithubLoading, setIsGithubLoading] = useState(false);
- const [isGithubInitialLoading, setIsGithubInitialLoading] = useState(true);
-
- // Confirmation dialog state
- const [showSystemPromptConfirm, setShowSystemPromptConfirm] = useState(false);
- const [showSystemPromptResetConfirm, setShowSystemPromptResetConfirm] = useState(false);
- const [showAnthropicConfirm, setShowAnthropicConfirm] = useState(false);
-
- // Load data when dialog opens
- useEffect(() => {
- if (open) {
- loadSystemPrompt();
- if (!isSealos) {
- loadKubeconfig();
- }
- loadAnthropicConfig();
- loadGithubStatus();
- }
- }, [open, isSealos]);
-
- // Set active tab when defaultTab changes
- useEffect(() => {
- if (open) {
- setActiveTab(defaultTab);
- }
- }, [open, defaultTab]);
-
- const loadSystemPrompt = async () => {
- try {
- const data = await fetchClient.GET<{ systemPrompt: string | null }>(
- '/api/user/config/system-prompt'
- );
- setSystemPrompt(data.systemPrompt || DEFAULT_SYSTEM_PROMPT);
- } catch (error) {
- console.error('Failed to load system prompt:', error);
- setSystemPrompt(DEFAULT_SYSTEM_PROMPT);
- } finally {
- setIsSystemPromptInitialLoading(false);
- }
- };
-
- const loadKubeconfig = async () => {
- try {
- const data = await fetchClient.GET<{ kubeconfig: string; namespace?: string | null }>(
- '/api/user/config/kc'
- );
- setKubeconfig(data.kubeconfig);
- setKubeconfigNamespace(data.namespace || null);
- } catch (error: unknown) {
- if (error instanceof Error && error.message.includes('404')) {
- setKubeconfig('');
- } else {
- console.error('Failed to load kubeconfig:', error);
- }
- } finally {
- setIsKubeconfigInitialLoading(false);
- }
- };
-
- const loadAnthropicConfig = async () => {
- try {
- const data = await fetchClient.GET<{
- apiKey: string | null;
- apiBaseUrl: string | null;
- model: string | null;
- smallFastModel: string | null;
- }>('/api/user/config/anthropic');
- setAnthropicApiKey(data.apiKey || '');
- setAnthropicApiBaseUrl(data.apiBaseUrl || '');
- setAnthropicModel(data.model || '');
- setAnthropicSmallFastModel(data.smallFastModel || '');
- } catch (error) {
- console.error('Failed to load Anthropic config:', error);
- } finally {
- setIsAnthropicInitialLoading(false);
- }
- };
-
- const loadGithubStatus = async () => {
- try {
- const result = await getInstallations();
- if (result.success && result.data.length > 0) {
- setGithubInstallation(result.data[0]);
- } else {
- setGithubInstallation(null);
- }
- } catch (error) {
- console.error('Failed to load GitHub status:', error);
- setGithubInstallation(null);
- } finally {
- setIsGithubInitialLoading(false);
- }
- };
-
- const handleSaveSystemPrompt = () => {
- setShowSystemPromptConfirm(true);
- };
-
- const handleConfirmSaveSystemPrompt = async () => {
- setShowSystemPromptConfirm(false);
- setIsSystemPromptLoading(true);
- try {
- await fetchClient.POST('/api/user/config/system-prompt', {
- systemPrompt,
- });
- toast.success('System prompt saved successfully');
- onOpenChange(false);
- } catch (error) {
- console.error('Failed to save system prompt:', error);
- toast.error('Failed to save system prompt');
- } finally {
- setIsSystemPromptLoading(false);
- }
- };
-
- const handleSaveKubeconfig = async () => {
- if (!kubeconfig.trim()) {
- toast.error('Kubeconfig cannot be empty');
- return;
- }
-
- setIsKubeconfigLoading(true);
- try {
- const result = await fetchClient.POST<{
- success: boolean;
- namespace?: string;
- error?: string;
- valid?: boolean;
- }>('/api/user/config/kc', {
- kubeconfig,
- });
-
- if (result.success) {
- setKubeconfigNamespace(result.namespace || null);
- toast.success(`Kubeconfig saved successfully (namespace: ${result.namespace})`);
- onOpenChange(false);
- } else {
- toast.error(result.error || 'Failed to save kubeconfig');
- }
- } catch (error: unknown) {
- console.error('Failed to save kubeconfig:', error);
- toast.error(error instanceof Error ? error.message : 'Failed to save kubeconfig');
- } finally {
- setIsKubeconfigLoading(false);
- }
- };
-
- const handleSaveAnthropicConfig = () => {
- if (!anthropicApiKey.trim() || !anthropicApiBaseUrl.trim()) {
- toast.error('Both API key and base URL are required');
- return;
- }
- setShowAnthropicConfirm(true);
- };
-
- const handleConfirmSaveAnthropicConfig = async () => {
- setShowAnthropicConfirm(false);
- setIsAnthropicLoading(true);
- try {
- await fetchClient.POST('/api/user/config/anthropic', {
- apiKey: anthropicApiKey,
- apiBaseUrl: anthropicApiBaseUrl,
- model: anthropicModel.trim() || undefined,
- smallFastModel: anthropicSmallFastModel.trim() || undefined,
- });
- toast.success('Anthropic configuration saved successfully');
- onOpenChange(false);
- } catch (error: unknown) {
- console.error('Failed to save Anthropic config:', error);
- toast.error(
- error instanceof Error ? error.message : 'Failed to save Anthropic configuration'
- );
- } finally {
- setIsAnthropicLoading(false);
- }
- };
-
- const handleResetSystemPrompt = () => {
- setShowSystemPromptResetConfirm(true);
- };
-
- const handleConfirmResetSystemPrompt = () => {
- setShowSystemPromptResetConfirm(false);
- setSystemPrompt(DEFAULT_SYSTEM_PROMPT);
- toast.success('Reset to default system prompt');
- };
-
- const handleConnectGithub = () => {
- const appName = env.NEXT_PUBLIC_GITHUB_APP_NAME;
- if (!appName) {
- toast.error('GitHub App is not configured');
- return;
- }
-
- setIsGithubLoading(true);
-
- const installUrl = `https://github.com/apps/${appName}/installations/new`;
-
- const width = 800;
- const height = 800;
- const left = window.screen.width / 2 - width / 2;
- const top = window.screen.height / 2 - height / 2;
-
- const popup = window.open(
- installUrl,
- 'github-app-install',
- `width=${width},height=${height},left=${left},top=${top},resizable=yes,scrollbars=yes`
- );
-
- if (!popup) {
- toast.error('Failed to open popup window. Please allow popups for this site.');
- setIsGithubLoading(false);
- return;
- }
-
- const handleMessage = (event: MessageEvent) => {
- if (event.origin !== window.location.origin) return;
- if (event.data.type !== 'github-app-installed') return;
-
- window.removeEventListener('message', handleMessage);
- if (event.data.success) {
- toast.success('GitHub App installed successfully!');
- loadGithubStatus();
- } else {
- toast.error(event.data.message || 'Failed to install GitHub App');
- }
- setIsGithubLoading(false);
- };
-
- window.addEventListener('message', handleMessage);
-
- const checkClosed = setInterval(() => {
- if (popup.closed) {
- clearInterval(checkClosed);
- window.removeEventListener('message', handleMessage);
- setIsGithubLoading(false);
- }
- }, 500);
- };
-
- const handleDisconnectGithub = async () => {
- toast.info(
- 'To disconnect, please go to GitHub Settings > Applications and revoke access to the app.'
- );
- };
-
- return (
-
-
-
- Settings
-
-
-
-
setActiveTab(value as TabType)}
- className="h-full flex flex-col"
- >
-
-
-
- System Prompt
-
- {!isSealos && (
-
-
- Kubeconfig
-
- )}
-
-
- Anthropic
-
-
-
- GitHub
-
-
-
-
- {/* System Prompt Tab */}
-
-
-
-
- System Prompt Template
-
-
-
-
-
- {/* Kubeconfig Tab */}
- {!isSealos && (
-
-
-
-
- Kubeconfig Content
-
- {kubeconfigNamespace && (
-
- Current namespace: {kubeconfigNamespace}
-
- )}
-
-
-
- )}
-
- {/* Anthropic Tab */}
-
-
-
-
- API Base URL
-
-
setAnthropicApiBaseUrl(e.target.value)}
- disabled={isAnthropicInitialLoading}
- className="bg-input border-border text-foreground placeholder:text-muted-foreground disabled:opacity-50 rounded-md focus:ring-2 focus:ring-ring focus:border-ring"
- placeholder="https://api.anthropic.com"
- />
-
- The base URL for Anthropic API (e.g., https://api.anthropic.com or your proxy
- URL)
-
-
-
-
-
- API Key
-
-
setAnthropicApiKey(e.target.value)}
- disabled={isAnthropicInitialLoading}
- className="bg-input border-border text-foreground placeholder:text-muted-foreground font-mono disabled:opacity-50 rounded-md focus:ring-2 focus:ring-ring focus:border-ring"
- placeholder="sk-ant-..."
- />
-
- Your Anthropic API key. This will be stored securely and injected as
- ANTHROPIC_AUTH_TOKEN in sandboxes.
-
-
-
-
-
- Default Model (Optional)
-
-
setAnthropicModel(e.target.value)}
- disabled={isAnthropicInitialLoading}
- className="bg-input border-border text-foreground placeholder:text-muted-foreground font-mono disabled:opacity-50 rounded-md focus:ring-2 focus:ring-ring focus:border-ring"
- placeholder="claude-sonnet-4-5-20250929"
- />
-
- Default model to use (e.g., claude-sonnet-4-5-20250929). This will be injected
- as ANTHROPIC_MODEL in sandboxes.
-
-
-
-
-
- Small Fast Model (Optional)
-
-
setAnthropicSmallFastModel(e.target.value)}
- disabled={isAnthropicInitialLoading}
- className="bg-input border-border text-foreground placeholder:text-muted-foreground font-mono disabled:opacity-50 rounded-md focus:ring-2 focus:ring-ring focus:border-ring"
- placeholder="claude-3-5-haiku-20241022"
- />
-
- Small fast model for quick operations (e.g., claude-3-5-haiku-20241022). This
- will be injected as ANTHROPIC_SMALL_FAST_MODEL in sandboxes.
-
-
-
-
-
-
- {isAnthropicLoading ? 'Saving...' : 'Save Configuration'}
-
-
-
-
-
- {/* GitHub Tab */}
-
-
-
-
GitHub App
-
- Connect your GitHub account to enable repository access and code management features.
-
-
-
- {isGithubInitialLoading ? (
-
- ) : githubInstallation ? (
-
-
- {githubInstallation.accountAvatarUrl && (
-
- )}
-
-
-
- {githubInstallation.accountLogin}
-
- ● Connected
-
-
- Your GitHub App is installed and ready to use.
-
-
-
-
-
- Disconnect GitHub App
-
-
- ) : (
-
-
-
- No GitHub App installed. Connect your GitHub account to access repositories and enable version control features.
-
-
-
-
-
- {isGithubLoading ? 'Connecting...' : 'Connect GitHub App'}
-
-
- )}
-
-
-
-
-
-
- {/* System Prompt Confirmation Dialog */}
-
-
-
-
- Confirm Save System Prompt
-
-
- These changes won't take effect until you manually restart the application.
- Save now?
-
-
-
-
- Cancel
-
-
- Save
-
-
-
-
-
- {/* System Prompt Reset Confirmation Dialog */}
-
-
-
-
- Reset System Prompt to Default
-
-
- This will reset the system prompt to the default template. You'll need to
- manually restart the application for this change to take effect. Continue?
-
-
-
-
- Cancel
-
-
- Reset to Default
-
-
-
-
-
- {/* Anthropic Config Confirmation Dialog */}
-
-
-
-
- Confirm Save Anthropic Configuration
-
-
- These changes won't take effect until you manually restart the application.
- Save now?
-
-
-
-
- Cancel
-
-
- Save Configuration
-
-
-
-
-
-
- );
-}
diff --git a/components/home-page.tsx b/components/home-page.tsx
deleted file mode 100644
index 2b4bdcf..0000000
--- a/components/home-page.tsx
+++ /dev/null
@@ -1,110 +0,0 @@
-'use client';
-
-import { useCallback, useState } from 'react';
-import Image from 'next/image';
-import Link from 'next/link';
-import { useRouter } from 'next/navigation';
-
-import { Button } from '@/components/ui/button';
-
-/**
- * Home page client component with unified rendering.
- *
- * Get Started Button Behavior:
- * - Non-Sealos + Authenticated: Go to /projects
- * - Non-Sealos + Unauthenticated: Go to /login
- * - Sealos + Authenticated: Go to /projects
- * - Sealos + Unauthenticated: Trigger Sealos auth → then go to /projects
- */
-export function HomePage() {
- const router = useRouter();
-
- const [authError, setAuthError] = useState(null);
-
- // Determine button action based on environment and auth status
- const handleGetStarted = async () => {
- // Clear previous errors on retry
- setAuthError(null);
- router.push('/login');
- };
-
- const getButtonText = useCallback(() => 'Get Started', []);
-
- const isButtonDisabled = false;
-
- return (
- <>
- {/* Base marketing page - always visible */}
-
-
-
- {/* Hero Section: Logo and Title */}
-
-
-
-
-
- Fulling
-
-
- Beta
-
-
-
-
- {/* Subtitle and Description */}
-
AI-Powered Full-Stack Development Platform
-
- Create, develop, and deploy production-ready web applications using natural language.
- Powered by Claude Code in isolated sandbox environments.
-
-
-
-
-
- {/* Divider with VS Code styling */}
-
-
- {/* Error message - with aria attributes for accessibility */}
- {authError && (
-
- {authError}
-
- )}
-
-
-
- {getButtonText()}
-
-
-
- Learn More
-
-
-
-
-
- >
- );
-}
diff --git a/components/icons/index.tsx b/components/icons/index.tsx
deleted file mode 100644
index eea4777..0000000
--- a/components/icons/index.tsx
+++ /dev/null
@@ -1,44 +0,0 @@
-export const EmptyStateIcon = ({
- className,
- ...props
-}: {
- className?: string;
-} & React.SVGProps) => (
-
-
-
-
-
-
-
-
-);
diff --git a/components/layout/project-content-wrapper.module.css b/components/layout/project-content-wrapper.module.css
deleted file mode 100644
index d5e749b..0000000
--- a/components/layout/project-content-wrapper.module.css
+++ /dev/null
@@ -1,39 +0,0 @@
-/**
- * ProjectContentWrapper Styles
- *
- * Controls visibility of terminal and content panels
- * Uses data attributes for semantic clarity
- */
-
-.wrapper {
- width: 100%;
- height: 100%;
- position: relative;
-}
-
-.panel {
- width: 100%;
- height: 100%;
-}
-
-/* Hide panels when not visible */
-.panel[data-visible='false'] {
- display: none;
- content-visibility: hidden; /* Modern CSS optimization */
-}
-
-/* Terminal panel - flex column layout for terminal + status bar */
-.terminalPanel[data-visible='true'] {
- display: flex;
- flex-direction: column;
- content-visibility: auto;
-}
-
-/* Content panel - flex layout */
-.contentPanel[data-visible='true'] {
- display: flex;
- flex-direction: column;
- overflow: hidden;
- min-height: 0;
- content-visibility: auto;
-}
diff --git a/components/layout/project-content-wrapper.tsx b/components/layout/project-content-wrapper.tsx
deleted file mode 100644
index 52019d6..0000000
--- a/components/layout/project-content-wrapper.tsx
+++ /dev/null
@@ -1,90 +0,0 @@
-/**
- * ProjectContentWrapper Component
- *
- * Manages visibility of terminal and content panels based on current route.
- * Uses CSS-based visibility toggling to preserve component state during navigation.
- *
- * Architecture:
- * - Terminal panel: Persisted in layout, never unmounts (preserves WebSocket state)
- * - Content panel: Regular page content (overview, settings, etc.)
- * - Visibility controlled via data attributes + CSS instead of conditional rendering
- *
- * Why this approach:
- * - Leverages Next.js 16 Layout Persistence feature
- * - Avoids component unmount/remount overhead
- * - Maintains WebSocket connections and terminal state
- * - Better performance than Parallel Routes (no redundant RSC fetches)
- */
-
-'use client';
-
-import { usePathname } from 'next/navigation';
-
-import { TerminalContainer } from '@/components/terminal/terminal-container';
-import { useProject } from '@/hooks/use-project';
-
-import { StatusBar } from './status-bar';
-
-import styles from './project-content-wrapper.module.css';
-
-// ============================================================================
-// Types
-// ============================================================================
-
-interface ProjectContentWrapperProps {
- projectId: string;
- children: React.ReactNode;
-}
-
-// ============================================================================
-// Component
-// ============================================================================
-
-export function ProjectContentWrapper({
- children,
- projectId,
-}: ProjectContentWrapperProps) {
- const { data: project } = useProject(projectId);
- const pathname = usePathname();
-
- // Determine which panel to display based on current route
- const isTerminalPage = pathname?.endsWith('/terminal') ?? false;
-
- // Get sandbox from project data
- const sandbox = project?.sandboxes?.[0];
-
- return (
-
- {/* Terminal Panel - Persisted across navigation */}
-
- {project && (
- <>
-
-
- >
- )}
-
-
- {/* Content Panel - Regular pages (overview, settings, env, etc.) */}
-
- {children}
-
-
- );
-}
diff --git a/components/layout/repo-status-indicator.tsx b/components/layout/repo-status-indicator.tsx
deleted file mode 100644
index f46e8e3..0000000
--- a/components/layout/repo-status-indicator.tsx
+++ /dev/null
@@ -1,136 +0,0 @@
-'use client'
-
-import { useState } from 'react'
-import { MdRefresh } from 'react-icons/md'
-import { Project } from '@prisma/client'
-import { useRouter } from 'next/navigation'
-import { toast } from 'sonner'
-
-import SettingsDialog from '@/components/dialog/settings-dialog'
-import { commitChanges, initializeRepo } from '@/lib/services/repoService'
-
-interface RepoStatusIndicatorProps {
- project: Pick
-}
-
-export function RepoStatusIndicator({ project }: RepoStatusIndicatorProps) {
- const router = useRouter()
- const [isInitializing, setIsInitializing] = useState(false)
- const [isCommitting, setIsCommitting] = useState(false)
- const [showSettings, setShowSettings] = useState(false)
-
- const repoFullName = project.githubRepoFullName || project.githubRepo
- const hasRepo = Boolean(repoFullName)
-
- // Create a new repository on GitHub
- const handleInitialize = async () => {
- if (hasRepo || isInitializing) return
-
- setIsInitializing(true)
- try {
- const result = await initializeRepo(project.id)
- if (result.success) {
- toast.success(result.message)
- router.refresh()
- } else {
- if (result.code === 'GITHUB_NOT_BOUND') {
- toast.error('Please connect your GitHub account first')
- setShowSettings(true)
- } else {
- toast.error(result.message)
- }
- }
- } catch (_error) {
- toast.error('An unexpected error occurred')
- } finally {
- setIsInitializing(false)
- }
- }
-
- // Commit changes to the repository and push to GitHub
- const handleCommit = async (e: React.MouseEvent) => {
- e.stopPropagation() // Prevent triggering other clicks if needed
- if (isCommitting) return
-
- setIsCommitting(true)
- try {
- const result = await commitChanges(project.id)
- if (result.success) {
- toast.success(result.message)
- } else {
- if (result.code === 'GITHUB_NOT_BOUND') {
- toast.error('Please connect your GitHub account first')
- setShowSettings(true)
- } else {
- toast.error(result.message)
- }
- }
- } catch (_error) {
- toast.error('Failed to commit changes')
- } finally {
- setIsCommitting(false)
- }
- }
-
- const isLoading = isInitializing || isCommitting
-
- const repoUrl = repoFullName
- ? `https://github.com/${repoFullName}`
- : null
-
- return (
- <>
-
-
-
- {(!hasRepo && isInitializing) ? (
-
- ) : (
-
- )}
-
-
- {hasRepo ? (
-
- {repoFullName}
-
- ) : (
-
- {isInitializing ? 'Syncing...' : 'Sync to GitHub'}
-
- )}
-
-
- {hasRepo && (
-
- {isLoading ? (
-
- ) : (
-
- )}
-
- )}
-
-
-
- >
- )
-}
diff --git a/components/layout/status-bar.tsx b/components/layout/status-bar.tsx
deleted file mode 100644
index 5bcaaf5..0000000
--- a/components/layout/status-bar.tsx
+++ /dev/null
@@ -1,51 +0,0 @@
-'use client';
-
-import React from 'react';
-
-import { RepoStatusIndicator } from '@/components/layout/repo-status-indicator';
-import { useProject } from '@/hooks/use-project';
-import { getStatusBgColor } from '@/lib/util/status-colors';
-
-interface StatusBarProps {
- projectId: string;
-}
-
-export function StatusBar({ projectId }: StatusBarProps) {
- const { data: project } = useProject(projectId);
-
- const database = project?.databases?.[0];
- const sandbox = project?.sandboxes?.[0];
- const sbStatus = sandbox?.status || 'CREATING';
-
- return (
-
-
- {project && (
-
- )}
-
-
-
-
-
-
-
Sandbox: {sbStatus}
-
-
- {database ? (
-
-
-
Database: {database.status}
-
- ) : (
-
-
-
Database: Not Configured
-
- )}
-
-
- );
-}
diff --git a/components/sidebar.tsx b/components/sidebar.tsx
deleted file mode 100644
index 18691ae..0000000
--- a/components/sidebar.tsx
+++ /dev/null
@@ -1,122 +0,0 @@
-'use client'
-
-import { FaGithub } from 'react-icons/fa6'
-import {
- MdGridView,
- MdLightbulbOutline,
- MdPsychology,
- MdSettings,
-} from 'react-icons/md'
-import Image from 'next/image'
-import Link from 'next/link'
-import { usePathname } from 'next/navigation'
-
-import { Button } from '@/components/ui/button'
-
-import packageInfo from '../package.json'
-
-const menuItems = [
- { icon: MdGridView, label: 'Projects', href: '/projects', active: false },
- { icon: MdPsychology, label: 'Skills', href: '/skills', active: false },
- { icon: MdSettings, label: 'Settings', href: '/settings', active: false },
-]
-
-export function Sidebar() {
- return (
-
- )
-}
-
-function LogoSection() {
- return (
-
-
-
-
- Fulling
-
-
- Full-Stack Agent
-
-
-
- )
-}
-
-function NavMenu() {
- const pathname = usePathname()
-
- return (
-
- {menuItems.map((item) => {
- const isActive = pathname === item.href || pathname.startsWith(`${item.href}/`)
-
- return (
-
-
-
- {item.label}
-
-
- )
- })}
-
- )
-}
-
-function Footer() {
- return (
-
-
-
-
-
-
- v{packageInfo.version}
-
-
-
-
-
-
- )
-}
diff --git a/components/sidebars/primary-sidebar.tsx b/components/sidebars/primary-sidebar.tsx
deleted file mode 100644
index 36c86d9..0000000
--- a/components/sidebars/primary-sidebar.tsx
+++ /dev/null
@@ -1,82 +0,0 @@
-'use client';
-
-import { useState } from 'react';
-import { MdOutlineGridView, MdOutlineSettings } from 'react-icons/md';
-import Image from 'next/image';
-import Link from 'next/link';
-
-import SettingsDialog from '@/components/dialog/settings-dialog';
-import { Button } from '@/components/ui/button';
-
-/**
- * PrimarySidebar Component
- *
- * This is the persistent left-most command center of the application with a fixed width.
- * It remains visible across page transitions, providing global access to core functionalities.
- *
- * Structure:
- * 1. Top Section: Contains 'Home' and 'Projects' navigation buttons.
- * 2. Bottom Section: Contains 'Settings' button which triggers the settings dialog.
- *
- * Note: Marked with 'use client' as it manages local state (useState) for the settings dialog.
- */
-export default function PrimarySidebar() {
- return (
-
- {/* Top buttons */}
-
-
- {/* Bottom buttons */}
-
-
-
-
- );
-}
-
-function HomeButton() {
- return (
-
-
-
-
-
- );
-}
-
-function ProjectsButton() {
- return (
-
-
-
-
-
- );
-}
-
-function SettingsButton() {
- const [showSettings, setShowSettings] = useState(false);
-
- return (
- <>
- setShowSettings(true)}
- className="group hover:bg-transparent"
- >
-
-
-
- >
- );
-}
diff --git a/components/sidebars/project-sidebar.tsx b/components/sidebars/project-sidebar.tsx
deleted file mode 100644
index 4c8ee34..0000000
--- a/components/sidebars/project-sidebar.tsx
+++ /dev/null
@@ -1,157 +0,0 @@
-'use client';
-
-import { useState } from 'react';
-import { IconType } from 'react-icons';
-import {
- MdChevronLeft,
- MdChevronRight,
- MdOutlineCode,
- MdOutlineCreditCard,
- MdOutlineDns,
- MdOutlineLayers,
- MdOutlineSecurity,
- MdOutlineTerminal,
- MdOutlineVpnKey,
-} from 'react-icons/md';
-import Link from 'next/link';
-import { usePathname } from 'next/navigation';
-
-import { useProject } from '@/hooks/use-project';
-import { cn } from '@/lib/utils';
-
-// Icon mapping for dynamic icon rendering
-const ICON_MAP: Record = {
- terminal: MdOutlineTerminal,
- dns: MdOutlineDns,
- layers: MdOutlineLayers,
- vpn_key: MdOutlineVpnKey,
- security: MdOutlineSecurity,
- credit_card: MdOutlineCreditCard,
- code: MdOutlineCode,
-};
-
-// Static menu configuration - hoisted outside component to avoid recreation on every render
-const WORKSPACE_SECTIONS = [
- { id: 'terminal', label: 'Web Terminal', icon: 'terminal' },
- { id: 'database', label: 'Database', icon: 'dns' },
-] as const;
-
-const CONFIG_SECTIONS = [
- { id: 'environment', label: 'Environment Variables', icon: 'layers' },
- { id: 'secrets', label: 'Secret Configuration', icon: 'vpn_key' },
- { id: 'auth', label: 'Auth Configuration', icon: 'security' },
- { id: 'payment', label: 'Payment Configuration', icon: 'credit_card' },
- { id: 'github', label: 'GitHub Integration', icon: 'code' },
-] as const;
-
-interface ProjectSidebarProps {
- projectId: string;
-}
-
-/**
- * A collapsible navigation sidebar for project detail pages.
- *
- * Features:
- * - Displays current project name in header
- * - Provides navigation links grouped into Workspace and Configuration sections
- * - Supports collapse/expand toggle with smooth transition
- * - Highlights active route based on current pathname
- *
- * Navigation Structure:
- * - Workspace: Web Terminal, Database
- * - Configuration: Environment Variables, Secrets, Auth, Payment, GitHub Integration
- */
-export default function ProjectSidebar({ projectId }: ProjectSidebarProps) {
- const { data: project } = useProject(projectId);
- const [isCollapsed, setIsCollapsed] = useState(false);
- const pathname = usePathname();
-
- // Generate href with projectId
- const getHref = (sectionId: string) => `/projects/${projectId}/${sectionId}`;
-
- return (
-
- {/* Toggle Button */}
-
setIsCollapsed(!isCollapsed)}
- className="absolute -right-2 top-1/2 -translate-y-1/2 z-20 h-10 w-4 rounded-md border border-border bg-sidebar-background flex items-center justify-center text-muted-foreground hover:text-foreground transition-colors shadow-sm"
- >
- {isCollapsed ? : }
-
-
- {/* Header */}
-
- {!isCollapsed && (
- Project {project?.name ?? 'Loading...'}
- )}
-
-
- {!isCollapsed && (
-
- {/* Top sections */}
-
-
Workspace
-
- {WORKSPACE_SECTIONS.map((section) => {
- const href = getHref(section.id);
- const isActive = pathname === href;
- const IconComponent = ICON_MAP[section.icon];
-
- return (
-
-
-
- {section.label}
-
-
- );
- })}
-
-
-
- {/* Configuration Group */}
-
-
Configuration
-
- {CONFIG_SECTIONS.map((section) => {
- const href = getHref(section.id);
- const isActive = pathname === href;
- const IconComponent = ICON_MAP[section.icon];
-
- return (
-
-
-
- {section.label}
-
-
- );
- })}
-
-
-
- )}
-
- );
-}
diff --git a/components/terminal/hooks/use-file-drop.ts b/components/terminal/hooks/use-file-drop.ts
deleted file mode 100644
index 846f8fd..0000000
--- a/components/terminal/hooks/use-file-drop.ts
+++ /dev/null
@@ -1,251 +0,0 @@
-/**
- * useFileDrop Hook
- *
- * Custom hook for handling file drag-and-drop and clipboard paste events.
- * Monitors the entire window for file drops and pastes, making it work
- * seamlessly even when xterm has focus.
- *
- * Features:
- * - Global drag and drop support
- * - Global paste event handling (works with xterm focus)
- * - Folder and file extraction
- * - Visual drag feedback state
- * - Automatic event cleanup
- *
- * @example
- * ```tsx
- * const { isDragging } = useFileDrop({
- * enabled: true,
- * onFilesDropped: (files) => console.log(files),
- * onFilesPasted: (files) => console.log(files),
- * });
- * ```
- */
-
-'use client'
-
-import { useCallback, useEffect, useRef, useState } from 'react'
-
-// ============================================================================
-// Types
-// ============================================================================
-
-export interface FileDropConfig {
- /** Enable/disable file drop and paste handling */
- enabled?: boolean
- /** Callback when files are dropped */
- onFilesDropped?: (files: File[]) => void
- /** Callback when files are pasted */
- onFilesPasted?: (files: File[]) => void
- /** Container element to attach events to (defaults to window) */
- containerRef?: React.RefObject
-}
-
-// ============================================================================
-// Hook
-// ============================================================================
-
-export function useFileDrop(config: FileDropConfig) {
- const [isDragging, setIsDragging] = useState(false)
- const dragCounterRef = useRef(0)
-
- /**
- * Extract files from clipboard data
- */
- const extractFilesFromClipboard = useCallback(
- async (clipboardData: DataTransfer): Promise => {
- const files: File[] = []
- const items = clipboardData.items
-
- if (!items) return files
-
- // Extract files from clipboard items
- for (let i = 0; i < items.length; i++) {
- const item = items[i]
- if (item.kind === 'file') {
- const file = item.getAsFile()
- if (file) {
- files.push(file)
- }
- }
- }
-
- return files
- },
- []
- )
-
- /**
- * Extract files from DataTransfer (supports folders)
- */
- const extractFilesFromDataTransfer = useCallback(
- async (dataTransfer: DataTransfer): Promise => {
- const { extractFilesFromDataTransfer: extractFiles } = await import('@/lib/util/filebrowser')
- return extractFiles(dataTransfer)
- },
- []
- )
-
- /**
- * Handle drag enter event
- */
- const handleDragEnter = useCallback(
- (evt: Event) => {
- if (!config.enabled) return
-
- const e = evt as DragEvent
- e.preventDefault()
- e.stopPropagation()
-
- dragCounterRef.current++
-
- // Check if drag contains files
- const hasFiles = Array.from(e.dataTransfer?.items || []).some((item) => item.kind === 'file')
-
- if (hasFiles && dragCounterRef.current === 1) {
- setIsDragging(true)
- }
- },
- [config.enabled]
- )
-
- /**
- * Handle drag over event
- */
- const handleDragOver = useCallback(
- (evt: Event) => {
- if (!config.enabled) return
-
- const e = evt as DragEvent
- e.preventDefault()
- e.stopPropagation()
- },
- [config.enabled]
- )
-
- /**
- * Handle drag leave event
- */
- const handleDragLeave = useCallback(
- (evt: Event) => {
- if (!config.enabled) return
-
- const e = evt as DragEvent
- e.preventDefault()
- e.stopPropagation()
-
- dragCounterRef.current--
-
- if (dragCounterRef.current === 0) {
- setIsDragging(false)
- }
- },
- [config.enabled]
- )
-
- /**
- * Handle drop event
- */
- const handleDrop = useCallback(
- async (evt: Event) => {
- if (!config.enabled) return
-
- const e = evt as DragEvent
- e.preventDefault()
- e.stopPropagation()
-
- // Reset drag state
- dragCounterRef.current = 0
- setIsDragging(false)
-
- if (!e.dataTransfer) return
-
- try {
- const files = await extractFilesFromDataTransfer(e.dataTransfer)
-
- if (files.length > 0) {
- config.onFilesDropped?.(files)
- }
- } catch (error) {
- console.error('[useFileDrop] Failed to extract files from drop:', error)
- }
- },
- [config, extractFilesFromDataTransfer]
- )
-
- /**
- * Handle paste event
- */
- const handlePaste = useCallback(
- async (evt: Event) => {
- if (!config.enabled) return
-
- const e = evt as ClipboardEvent
- const clipboardData = e.clipboardData
- if (!clipboardData) return
-
- try {
- const files = await extractFilesFromClipboard(clipboardData)
-
- if (files.length > 0) {
- // Prevent default paste behavior when files are detected
- e.preventDefault()
- e.stopPropagation()
-
- config.onFilesPasted?.(files)
- }
- } catch (error) {
- console.error('[useFileDrop] Failed to extract files from paste:', error)
- }
- },
- [config, extractFilesFromClipboard]
- )
-
- /**
- * Setup event listeners
- */
- useEffect(() => {
- if (!config.enabled) return
-
- // Use provided container or default to window
- const target = config.containerRef?.current || window
- if (!target) return
-
- // Add event listeners
- // Note: drag events use bubble phase (default)
- target.addEventListener('dragenter', handleDragEnter)
- target.addEventListener('dragover', handleDragOver)
- target.addEventListener('dragleave', handleDragLeave)
- target.addEventListener('drop', handleDrop)
-
- // CRITICAL: Use capture phase for paste to intercept before xterm!
- // xterm blocks paste event propagation, so we must listen in capture phase
- target.addEventListener('paste', handlePaste, true)
-
- // Cleanup
- return () => {
- target.removeEventListener('dragenter', handleDragEnter)
- target.removeEventListener('dragover', handleDragOver)
- target.removeEventListener('dragleave', handleDragLeave)
- target.removeEventListener('drop', handleDrop)
- // Must match the addEventListener call (with capture=true)
- target.removeEventListener('paste', handlePaste, true)
-
- // Reset state
- dragCounterRef.current = 0
- setIsDragging(false)
- }
- }, [
- config.enabled,
- config.containerRef,
- handleDragEnter,
- handleDragOver,
- handleDragLeave,
- handleDrop,
- handlePaste,
- ])
-
- return {
- isDragging,
- }
-}
diff --git a/components/terminal/hooks/use-file-upload.tsx b/components/terminal/hooks/use-file-upload.tsx
deleted file mode 100644
index 70e924f..0000000
--- a/components/terminal/hooks/use-file-upload.tsx
+++ /dev/null
@@ -1,237 +0,0 @@
-/**
- * useFileUpload Hook
- *
- * Custom hook for handling file uploads to FileBrowser service.
- * Provides seamless file upload functionality with progress tracking and toast notifications.
- *
- * Features:
- * - Batch file upload support
- * - Progress tracking with callbacks
- * - Automatic toast notifications
- * - Clipboard integration
- * - Error handling
- *
- * @example
- * ```tsx
- * const { uploadFiles, isUploading } = useFileUpload({
- * fileBrowserUrl: 'https://...',
- * fileBrowserUsername: 'admin',
- * fileBrowserPassword: 'password',
- * });
- *
- * await uploadFiles([file1, file2]);
- * ```
- */
-
-'use client';
-
-import { useCallback, useRef, useState } from 'react';
-import { toast } from 'sonner';
-
-// ============================================================================
-// Types
-// ============================================================================
-
-export interface FileUploadConfig {
- fileBrowserUrl?: string;
- fileBrowserUsername?: string;
- fileBrowserPassword?: string;
- enabled?: boolean;
-}
-
-export interface UploadOptions {
- /** Show toast notifications during upload */
- showToast?: boolean;
- /** Copy path to clipboard after successful upload */
- copyToClipboard?: boolean;
- /** Target directory path for upload (defaults to root if not specified) */
- targetPath?: string;
- /** Absolute container path for display in toast (e.g., /home/fulling/next/src) */
- absolutePath?: string;
-}
-
-// ============================================================================
-// Hook
-// ============================================================================
-
-export function useFileUpload(config: FileUploadConfig) {
- const [isUploading, setIsUploading] = useState(false);
- const [uploadProgress, setUploadProgress] = useState<{
- completed: number;
- total: number;
- currentFile: string;
- } | null>(null);
-
- // Keep refs for stable callbacks
- const uploadToastIdRef = useRef(null);
-
- // Check if upload is properly configured
- const isConfigured = Boolean(
- config.enabled !== false &&
- config.fileBrowserUrl &&
- config.fileBrowserUsername &&
- config.fileBrowserPassword
- );
-
- /**
- * Upload multiple files to FileBrowser
- */
- const uploadFiles = useCallback(
- async (
- files: File[],
- options: UploadOptions = {
- showToast: true,
- copyToClipboard: true,
- }
- ) => {
- // Validation
- if (!isConfigured) {
- if (options.showToast) {
- toast.error('File upload not configured', {
- description: 'FileBrowser credentials are missing',
- });
- }
- throw new Error('File upload not configured');
- }
-
- if (files.length === 0) {
- return;
- }
-
- // Start upload
- setIsUploading(true);
- setUploadProgress({ completed: 0, total: files.length, currentFile: '' });
-
- // Show initial toast
- if (options.showToast) {
- uploadToastIdRef.current = toast.loading(`Uploading ${files.length} file(s)...`, {
- description: 'Please wait',
- });
- }
-
- try {
- // Dynamic import for tree-shaking
- const { uploadFilesToFileBrowser, copyToClipboard, formatFileSize } = await import(
- '@/lib/util/filebrowser'
- );
-
- // Upload with progress tracking
- const result = await uploadFilesToFileBrowser(
- config.fileBrowserUrl!,
- config.fileBrowserUsername!,
- config.fileBrowserPassword!,
- files,
- (completed: number, total: number, currentFile: string) => {
- setUploadProgress({ completed, total, currentFile });
-
- // Update loading toast
- if (options.showToast && uploadToastIdRef.current) {
- toast.loading(`Uploading ${completed}/${total} files...`, {
- id: uploadToastIdRef.current,
- description: currentFile ? `Current: ${currentFile}` : 'Processing...',
- });
- }
- },
- options.targetPath // Pass target path to upload function
- );
-
- // Dismiss loading toast
- if (options.showToast && uploadToastIdRef.current) {
- toast.dismiss(uploadToastIdRef.current);
- uploadToastIdRef.current = null;
- }
-
- // Handle results
- if (result.succeeded.length > 0) {
- const totalSize = result.succeeded.reduce((sum, r) => sum + r.size, 0);
-
- // For clipboard: copy only filename (single file) or directory path (multiple files)
- const pathToCopy =
- result.succeeded.length === 1 ? result.succeeded[0].filename : result.rootPath;
-
- // For display: use absolute path if provided, otherwise use relative path
- const displayPath = options.absolutePath || result.rootPath;
-
- // Copy to clipboard
- let clipboardSuccess = false;
- if (options.copyToClipboard) {
- try {
- await copyToClipboard(pathToCopy);
- clipboardSuccess = true;
- } catch (error) {
- console.warn('[useFileUpload] Failed to copy to clipboard:', error);
- }
- }
-
- // Show success toast with clipboard feedback
- if (options.showToast) {
- if (result.failed.length === 0) {
- // All succeeded
- if (result.succeeded.length === 1) {
- const file = result.succeeded[0];
- const clipboardHint = clipboardSuccess ? ' • Filename copied!' : '';
- toast.success('File uploaded', {
- description: `${file.filename} (${formatFileSize(file.size)}) → ${displayPath}${clipboardHint}`,
- duration: 5000,
- });
- } else {
- const clipboardHint = clipboardSuccess ? ' • Directory path copied!' : '';
- toast.success(`${result.succeeded.length} files uploaded`, {
- description: `Total: ${formatFileSize(totalSize)} → ${displayPath}${clipboardHint}`,
- duration: 5000,
- });
- }
- } else {
- // Partial success
- const failedNames = result.failed.map((f) => f.filename).join(', ');
- const clipboardHint = clipboardSuccess ? ' • Directory path copied!' : '';
- toast.warning(`Uploaded ${result.succeeded.length} of ${result.total} files`, {
- description: `${formatFileSize(totalSize)} uploaded → ${displayPath} • Failed: ${failedNames}${clipboardHint}`,
- duration: 6000,
- });
- }
- }
- } else {
- // All failed
- if (options.showToast) {
- toast.error('Upload failed', {
- description:
- result.failed.length > 0
- ? `Error: ${result.failed[0].error}`
- : 'Unknown error occurred',
- duration: 5000,
- });
- }
- }
-
- return result;
- } catch (error) {
- console.error('[useFileUpload] Upload error:', error);
-
- if (options.showToast) {
- if (uploadToastIdRef.current) {
- toast.dismiss(uploadToastIdRef.current);
- uploadToastIdRef.current = null;
- }
- toast.error('Upload failed', {
- description: error instanceof Error ? error.message : 'Unknown error',
- duration: 5000,
- });
- }
-
- throw error;
- } finally {
- setIsUploading(false);
- setUploadProgress(null);
- }
- },
- [isConfigured, config.fileBrowserUrl, config.fileBrowserUsername, config.fileBrowserPassword]
- );
-
- return {
- uploadFiles,
- isUploading,
- uploadProgress,
- isConfigured,
- };
-}
diff --git a/components/terminal/terminal-container.tsx b/components/terminal/terminal-container.tsx
deleted file mode 100644
index 55e1ade..0000000
--- a/components/terminal/terminal-container.tsx
+++ /dev/null
@@ -1,167 +0,0 @@
-/**
- * TerminalContainer Component
- *
- * Root container component that manages terminal tabs and combines toolbar with display area.
- * Implements multi-tab functionality where each tab maintains an independent terminal instance.
- *
- * Architecture:
- * - Tab state management (add, close, switch)
- * - Renders all tabs but only shows the active one (maintains state)
- * - Passes project and sandbox data to child components
- * - Each tab gets unique terminal instance with independent WebSocket
- */
-
-'use client';
-
-import { useState } from 'react';
-import type { Prisma } from '@prisma/client';
-
-import { type Tab } from './toolbar/terminal-tabs';
-import { TerminalToolbar } from './toolbar/toolbar';
-import { TerminalDisplay } from './terminal-display';
-
-// ============================================================================
-// Types
-// ============================================================================
-
-type Project = Prisma.ProjectGetPayload<{
- include: {
- sandboxes: true;
- databases: true;
- environments: true;
- };
-}>;
-
-type Sandbox = Prisma.SandboxGetPayload;
-
-export interface TerminalContainerProps {
- project: Project;
- sandbox: Sandbox | undefined;
- /**
- * Controls whether the terminal is visible
- * - Used to optimize performance by avoiding unnecessary fit() calls when hidden
- * - Passed down to child components to coordinate visibility state
- * - Default: true
- */
- isVisible?: boolean;
-}
-
-// ============================================================================
-// Component
-// ============================================================================
-
-export function TerminalContainer({ project, sandbox, isVisible = true }: TerminalContainerProps) {
- // =========================================================================
- // Tab State Management
- // =========================================================================
-
- const [tabs, setTabs] = useState([{ id: '1', name: 'Terminal 1' }]);
- const [activeTabId, setActiveTabId] = useState('1');
-
- // =========================================================================
- // Extract FileBrowser Credentials
- // =========================================================================
-
- const fileBrowserCredentials = (() => {
- const username = project.environments?.find((env) => env.key === 'FILE_BROWSER_USERNAME')
- ?.value;
- const password = project.environments?.find((env) => env.key === 'FILE_BROWSER_PASSWORD')
- ?.value;
-
- if (username && password) {
- return { username, password };
- }
- return undefined;
- })();
-
- // =========================================================================
- // Tab Operations
- // =========================================================================
-
- /**
- * Create and activate a new terminal tab
- */
- const handleTabAdd = () => {
- const newId = Date.now().toString();
- const newTab: Tab = {
- id: newId,
- name: `Terminal ${tabs.length + 1}`,
- };
- setTabs([...tabs, newTab]);
- setActiveTabId(newId);
- };
-
- /**
- * Close a terminal tab
- * Maintains at least one tab and switches to first tab if closing active tab
- */
- const handleTabClose = (id: string) => {
- if (tabs.length === 1) return;
-
- const remainingTabs = tabs.filter((t) => t.id !== id);
- setTabs(remainingTabs);
-
- // Switch to first tab if we're closing the active tab
- if (activeTabId === id) {
- setActiveTabId(remainingTabs[0].id);
- }
- };
-
- /**
- * Switch to a different tab
- */
- const handleTabSelect = (id: string) => {
- setActiveTabId(id);
- };
-
- // =========================================================================
- // Render
- // =========================================================================
-
- return (
-
- {/* Toolbar with tabs and operations */}
-
-
- {/* Terminal display area with tab switching */}
-
- {tabs.map((tab) => (
-
- {/* Each tab maintains its own terminal instance */}
- {/*
- isVisible combines two conditions:
- 1. isVisible: Terminal page is visible (not hidden by routing)
- 2. tab.id === activeTabId: This specific tab is active
- Only when both are true will the terminal fit() to correct dimensions
- */}
-
-
- ))}
-
-
- );
-}
diff --git a/components/terminal/terminal-display.tsx b/components/terminal/terminal-display.tsx
deleted file mode 100644
index 76fadad..0000000
--- a/components/terminal/terminal-display.tsx
+++ /dev/null
@@ -1,201 +0,0 @@
-/**
- * TerminalDisplay Component
- *
- * Wrapper component for XtermTerminal that manages connection states and loading UI.
- * Displays appropriate status messages when terminal is not ready or sandbox is not running.
- *
- * Features:
- * - Loading overlays during initialization and connection
- * - Status-based conditional rendering
- * - Connection status indicators
- * - Automatic reconnection feedback
- */
-
-'use client';
-
-import { useCallback, useState } from 'react';
-import { MdErrorOutline, MdTerminal } from 'react-icons/md';
-
-import { Spinner } from '@/components/ui/spinner';
-import {
- getStatusIconColor,
- getStatusMessage,
- isErrorStatus,
- shouldShowSpinner,
-} from '@/lib/util/status-colors';
-import { cn } from '@/lib/utils';
-
-import { XtermTerminal } from './xterm-terminal';
-
-// ============================================================================
-// Types
-// ============================================================================
-
-export interface TerminalDisplayProps {
- sandboxId: string;
- ttydUrl?: string | null;
- status: string;
- tabId: string;
- fileBrowserUrl?: string | null;
- fileBrowserUsername?: string;
- fileBrowserPassword?: string;
- /**
- * Indicates whether this terminal instance is currently visible to the user
- * - Controls when to trigger terminal resize/fit operations
- * - Prevents incorrect dimension calculations when container is hidden (display: none)
- * - Passed down from TerminalContainer which combines route visibility and active tab state
- * - Default: true
- */
- isVisible?: boolean;
-}
-
-type ConnectionStatus = 'connecting' | 'connected' | 'error';
-
-// ============================================================================
-// Component
-// ============================================================================
-
-export function TerminalDisplay({
- sandboxId,
- ttydUrl,
- status,
- tabId,
- fileBrowserUrl,
- fileBrowserUsername,
- fileBrowserPassword,
- isVisible = true,
-}: TerminalDisplayProps) {
- // =========================================================================
- // State Management
- // =========================================================================
-
- const [terminalReady, setTerminalReady] = useState(false);
- const [connectionStatus, setConnectionStatus] = useState('connecting');
-
- // =========================================================================
- // Event Handlers
- // =========================================================================
-
- const handleReady = useCallback(() => {
- console.log('[TerminalDisplay] Terminal initialized successfully');
- setTerminalReady(true);
- }, []);
-
- const handleConnected = useCallback(() => {
- console.log('[TerminalDisplay] WebSocket connection established');
- setConnectionStatus('connected');
- }, []);
-
- const handleDisconnected = useCallback(() => {
- console.log('[TerminalDisplay] WebSocket connection closed');
- setConnectionStatus('connecting');
- }, []);
-
- // =========================================================================
- // Conditional Rendering Logic
- // =========================================================================
-
- // Only render terminal when sandbox is running and ttyd URL is available
- if (status === 'RUNNING' && ttydUrl) {
- const isLoading = connectionStatus === 'connecting' || !terminalReady;
- const showReconnectIndicator = connectionStatus === 'connecting' && terminalReady;
- const showErrorIndicator = connectionStatus === 'error' && terminalReady;
-
- return (
-
- {/* Loading Overlay */}
- {isLoading && (
-
-
-
-
- {!terminalReady ? 'Initializing terminal...' : 'Establishing connection...'}
-
-
-
- )}
-
- {/* Terminal Instance */}
-
- {/*
- Pass visibility state to XtermTerminal
- - XtermTerminal will use this to decide when to call fit()
- - Avoids fitting terminal when container dimensions are 0 (display: none)
- */}
-
-
-
- {/* Connection Status Indicators */}
- {showReconnectIndicator && (
-
-
- Reconnecting...
-
- )}
-
- {showErrorIndicator && (
-
-
- Connection failed
-
- )}
-
- );
- }
-
- // =========================================================================
- // Status Message Display
- // =========================================================================
-
- // Show appropriate status message when terminal is not available
- const StatusIcon = shouldShowSpinner(status)
- ? Spinner
- : isErrorStatus(status)
- ? MdErrorOutline
- : MdTerminal;
-
- return (
-
-
-
- {getStatusMessage(status)}
-
-
- );
-}
\ No newline at end of file
diff --git a/components/terminal/toolbar/app-runner-dialog.tsx b/components/terminal/toolbar/app-runner-dialog.tsx
deleted file mode 100644
index 3b525a7..0000000
--- a/components/terminal/toolbar/app-runner-dialog.tsx
+++ /dev/null
@@ -1,72 +0,0 @@
-import {
- AlertDialog,
- AlertDialogAction,
- AlertDialogCancel,
- AlertDialogContent,
- AlertDialogDescription,
- AlertDialogFooter,
- AlertDialogHeader,
- AlertDialogTitle,
-} from '@/components/ui/alert-dialog';
-
-interface AppRunnerDialogProps {
- open: boolean;
- onOpenChange: (open: boolean) => void;
- onConfirm: () => void;
-}
-
-export function AppRunnerDialog({
- open,
- onOpenChange,
- onConfirm,
-}: AppRunnerDialogProps) {
- return (
-
-
-
- Prepare Deployment Files?
-
-
-
- This will invoke the deployment skill in the selected directory by running:
-
-
- claude -p "/fulling-deploy"
-
-
-
-
-
-
- •
- Generate or reuse a Dockerfile for the current project
-
-
- •
- Create a GitHub Actions workflow for image build and push
-
-
- •
- Let the skill commit and push the generated files to GitHub
-
-
-
-
-
-
-
-
-
- Cancel
-
-
- Confirm & Run Skill
-
-
-
-
- );
-}
diff --git a/components/terminal/toolbar/app-runner.tsx b/components/terminal/toolbar/app-runner.tsx
deleted file mode 100644
index 69d332e..0000000
--- a/components/terminal/toolbar/app-runner.tsx
+++ /dev/null
@@ -1,78 +0,0 @@
-'use client';
-
-import { useState } from 'react';
-import { MdRefresh, MdRocketLaunch } from 'react-icons/md';
-import type { Prisma } from '@prisma/client';
-
-import { useAppRunner } from '@/hooks/use-app-runner';
-import { cn } from '@/lib/utils';
-
-import { AppRunnerDialog } from './app-runner-dialog';
-import { DirectorySelector } from './directory-selector';
-
-type Sandbox = Prisma.SandboxGetPayload;
-
-interface AppRunnerProps {
- sandbox: Sandbox | undefined;
-}
-
-export function AppRunner({ sandbox }: AppRunnerProps) {
- const [showRunConfirm, setShowRunConfirm] = useState(false);
- const [deployDirectory, setDeployDirectory] = useState('./');
- const {
- isRunningSkill,
- runDeploySkill,
- } = useAppRunner(sandbox?.id, deployDirectory);
-
- const handleRunSkill = () => {
- setShowRunConfirm(true);
- };
-
- const handleConfirmRun = () => {
- setShowRunConfirm(false);
- runDeploySkill();
- };
-
- return (
- <>
-
- {/* Directory Selector */}
-
-
-
-
- {/* Run App Button */}
-
- {isRunningSkill ? (
-
- ) : (
-
- )}
- {isRunningSkill ? 'Starting...' : 'Prepare Deploy'}
-
-
-
- {/* Separator */}
-
-
- {/* Confirmation Alert Dialog */}
-
- >
- );
-}
diff --git a/components/terminal/toolbar/directory-selector.tsx b/components/terminal/toolbar/directory-selector.tsx
deleted file mode 100644
index e41bc6c..0000000
--- a/components/terminal/toolbar/directory-selector.tsx
+++ /dev/null
@@ -1,123 +0,0 @@
-'use client';
-
-import { useEffect,useState } from 'react';
-import { MdFolder, MdKeyboardArrowDown, MdRefresh } from 'react-icons/md';
-
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuTrigger,
-} from '@/components/ui/dropdown-menu';
-import { runCommand } from '@/lib/actions/sandbox';
-
-interface DirectorySelectorProps {
- sandboxId?: string;
- value?: string;
- onChange?: (value: string) => void;
-}
-
-// Directories to exclude from the list
-const EXCLUDED_DIRS = [
- 'node_modules',
- '.git',
- '.next',
- '.cache',
- 'dist',
- 'build',
- '.turbo',
- '.vercel',
-];
-
-export function DirectorySelector({
- sandboxId,
- value: controlledValue,
- onChange,
-}: DirectorySelectorProps) {
- const [internalValue, setInternalValue] = useState('./');
- const [directories, setDirectories] = useState(['./']);
- const [isLoading, setIsLoading] = useState(false);
-
- const value = controlledValue ?? internalValue;
-
- // Fetch directories from sandbox on mount
- useEffect(() => {
- if (!sandboxId) return;
-
- const fetchDirectories = async () => {
- setIsLoading(true);
- try {
- const result = await runCommand(sandboxId, 'find . -type d -maxdepth 1');
- if (result.success && result.output) {
- const dirs = result.output
- .split('\n')
- .map((dir) => dir.trim())
- .filter((dir) => {
- if (!dir) return false;
- // Only keep lines that look like directory paths (start with . or /)
- if (!dir.startsWith('.') && !dir.startsWith('/')) return false;
- // Exclude hidden dirs and common build outputs
- return !EXCLUDED_DIRS.some((excluded) => dir.includes(excluded));
- })
- .slice(0, 20); // Limit to 20 directories
-
- setDirectories(dirs.length > 0 ? dirs : ['./']);
- }
- } catch (error) {
- console.error('Failed to fetch directories:', error);
- // Keep default on error
- } finally {
- setIsLoading(false);
- }
- };
-
- fetchDirectories();
- }, [sandboxId]);
-
- const handleSelect = (newValue: string) => {
- if (onChange) {
- onChange(newValue);
- } else {
- setInternalValue(newValue);
- }
- };
-
- return (
-
-
-
-
- {isLoading ? (
-
- ) : (
-
- )}
-
- {value}
-
-
-
-
-
-
- {directories.map((dir) => (
- handleSelect(dir)}
- className="text-xs font-mono text-[#cccccc] hover:bg-[#37373d] hover:text-white focus:bg-[#37373d] focus:text-white cursor-pointer"
- >
-
- {dir}
-
- ))}
-
-
- );
-}
diff --git a/components/terminal/toolbar/network-dialog.tsx b/components/terminal/toolbar/network-dialog.tsx
deleted file mode 100644
index 9e55f19..0000000
--- a/components/terminal/toolbar/network-dialog.tsx
+++ /dev/null
@@ -1,157 +0,0 @@
-'use client';
-
-import { useState } from 'react';
-import { MdContentCopy, MdVisibility, MdVisibilityOff } from 'react-icons/md';
-
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogHeader,
- DialogTitle,
-} from '@/components/ui/dialog';
-
-// ============================================================================
-// Types
-// ============================================================================
-
-export interface NetworkEndpoint {
- domain: string | null | undefined;
- port: number;
- protocol: string;
- label: string;
- hasCredentials?: boolean;
-}
-
-export interface NetworkDialogProps {
- open: boolean;
- onOpenChange: (open: boolean) => void;
- endpoints: NetworkEndpoint[];
- fileBrowserCredentials?: {
- username: string;
- password: string;
- };
-}
-
-// ============================================================================
-// Component
-// ============================================================================
-
-export function NetworkDialog({
- open,
- onOpenChange,
- endpoints,
- fileBrowserCredentials,
-}: NetworkDialogProps) {
- const [showPassword, setShowPassword] = useState(false);
- const [copiedField, setCopiedField] = useState(null);
-
- const copyToClipboard = async (text: string, field: string) => {
- try {
- await navigator.clipboard.writeText(text);
- setCopiedField(field);
- setTimeout(() => setCopiedField(null), 2000);
- } catch (err) {
- console.error('Failed to copy:', err);
- }
- };
-
- return (
-
-
-
- Network Endpoints
-
- All publicly accessible endpoints for this sandbox
-
-
-
- {endpoints.map((endpoint, index) => (
-
-
-
- Port {endpoint.port}
-
- {endpoint.label}
-
-
-
{endpoint.protocol}
-
-
- {endpoint.domain}
-
-
- {/* Show credentials for File Browser */}
- {endpoint.hasCredentials && fileBrowserCredentials && (
-
-
Login Credentials:
-
- {/* Username */}
-
-
-
Username
-
- {fileBrowserCredentials.username}
-
-
-
copyToClipboard(fileBrowserCredentials.username, 'username')}
- className="p-1.5 hover:bg-[#37373d] rounded transition-colors shrink-0"
- title="Copy username"
- >
- {copiedField === 'username' ? (
- ✓
- ) : (
-
- )}
-
-
-
- {/* Password */}
-
-
-
Password
-
- {showPassword ? fileBrowserCredentials.password : '••••••••••••••••'}
-
-
-
setShowPassword(!showPassword)}
- className="p-1.5 hover:bg-[#37373d] rounded transition-colors shrink-0"
- title={showPassword ? 'Hide password' : 'Show password'}
- >
- {showPassword ? (
-
- ) : (
-
- )}
-
-
copyToClipboard(fileBrowserCredentials.password, 'password')}
- className="p-1.5 hover:bg-[#37373d] rounded transition-colors shrink-0"
- title="Copy password"
- >
- {copiedField === 'password' ? (
- ✓
- ) : (
-
- )}
-
-
-
- )}
-
- ))}
-
-
-
- );
-}
diff --git a/components/terminal/toolbar/terminal-tabs.tsx b/components/terminal/toolbar/terminal-tabs.tsx
deleted file mode 100644
index 5f900de..0000000
--- a/components/terminal/toolbar/terminal-tabs.tsx
+++ /dev/null
@@ -1,85 +0,0 @@
-'use client';
-
-import { MdAdd, MdClose, MdTerminal } from 'react-icons/md';
-
-import { cn } from '@/lib/utils';
-
-// ============================================================================
-// Types
-// ============================================================================
-
-export interface Tab {
- id: string;
- name: string;
-}
-
-export interface TerminalTabsProps {
- tabs: Tab[];
- activeTabId: string;
- onTabSelect: (tabId: string) => void;
- onTabClose: (tabId: string) => void;
- onTabAdd: () => void;
-}
-
-// ============================================================================
-// Component
-// ============================================================================
-
-export function TerminalTabs({
- tabs,
- activeTabId,
- onTabSelect,
- onTabClose,
- onTabAdd,
-}: TerminalTabsProps) {
- return (
-
- {tabs.map((tab) => (
-
onTabSelect(tab.id)}
- >
- {/* Top Accent Line for Active Tab */}
- {activeTabId === tab.id && (
-
- )}
-
-
-
{tab.name}
- {tabs.length > 1 && (
-
{
- e.stopPropagation();
- onTabClose(tab.id);
- }}
- className={cn(
- 'p-0.5 rounded-sm opacity-0 group-hover:opacity-100 transition-all',
- activeTabId === tab.id ? 'hover:bg-[#37373d]' : 'hover:bg-[#454549]'
- )}
- >
-
-
- )}
-
- ))}
-
-
-
-
- );
-}
diff --git a/components/terminal/toolbar/toolbar.tsx b/components/terminal/toolbar/toolbar.tsx
deleted file mode 100644
index d5add05..0000000
--- a/components/terminal/toolbar/toolbar.tsx
+++ /dev/null
@@ -1,103 +0,0 @@
-/**
- * TerminalToolbar Component
- *
- * Toolbar for terminal with tabs, status, and operation controls
- */
-
-'use client';
-
-import { useState } from 'react';
-import { MdLan } from 'react-icons/md';
-import type { Prisma } from '@prisma/client';
-
-import { NetworkDialog } from './network-dialog';
-import { type Tab,TerminalTabs } from './terminal-tabs';
-
-type Sandbox = Prisma.SandboxGetPayload;
-
-export interface TerminalToolbarProps {
- /** Sandbox data */
- sandbox: Sandbox | undefined;
- /** Terminal tabs */
- tabs: Tab[];
- /** Active tab ID */
- activeTabId: string;
- /** Callback when tab is selected */
- onTabSelect: (tabId: string) => void;
- /** Callback when tab is closed */
- onTabClose: (tabId: string) => void;
- /** Callback when new tab is added */
- onTabAdd: () => void;
- /** FileBrowser credentials (optional) */
- fileBrowserCredentials?: {
- username: string;
- password: string;
- };
-}
-
-/**
- * Terminal toolbar with tabs and operations
- */
-export function TerminalToolbar({
- sandbox,
- tabs,
- activeTabId,
- onTabSelect,
- onTabClose,
- onTabAdd,
- fileBrowserCredentials,
-}: TerminalToolbarProps) {
- const [showNetworkDialog, setShowNetworkDialog] = useState(false);
-
- // Build network endpoints list, filtering out any without URLs
- const allEndpoints = [
- { domain: sandbox?.publicUrl, port: 3000, protocol: 'HTTPS', label: 'Application' },
- { domain: sandbox?.ttydUrl, port: 7681, protocol: 'HTTPS', label: 'Terminal' },
- {
- domain: sandbox?.fileBrowserUrl,
- port: 8080,
- protocol: 'HTTPS',
- label: 'File Browser',
- hasCredentials: true,
- },
- ];
-
- // Only show endpoints that have a valid domain URL
- const networkEndpoints = allEndpoints.filter((endpoint) => endpoint.domain);
-
- return (
- <>
-
- {/* Terminal Tabs */}
-
-
- {/* Action Buttons */}
-
- {/* Network Button */}
- setShowNetworkDialog(true)}
- className="px-2 py-1 text-xs text-foreground font-semibold hover:text-white hover:bg-zinc-800 rounded transition-colors flex items-center gap-1"
- title="View network endpoints"
- >
-
- Network
-
-
-
-
- {/* Network Dialog */}
-
- >
- );
-}
diff --git a/components/terminal/ttyd-exec-test.tsx b/components/terminal/ttyd-exec-test.tsx
deleted file mode 100644
index f7b0238..0000000
--- a/components/terminal/ttyd-exec-test.tsx
+++ /dev/null
@@ -1,185 +0,0 @@
-'use client'
-
-/**
- * TtydExecTest Component
- *
- * A simple test component to verify the ttyd-exec utility works in the browser.
- * This component provides a UI to execute commands via ttyd WebSocket and display results.
- *
- * Usage:
- * ```tsx
- *
- * ```
- */
-
-import { useCallback, useState } from 'react'
-
-import { executeTtydCommand, TtydExecError, TtydExecResult } from '@/lib/util/ttyd-exec'
-
-interface TtydExecTestProps {
- /** ttyd server URL (e.g., 'https://terminal.example.com') */
- ttydUrl: string
- /** Access token for ttyd authentication */
- accessToken: string
-}
-
-export function TtydExecTest({ ttydUrl, accessToken }: TtydExecTestProps) {
- const [command, setCommand] = useState('echo "Hello from ttyd-exec!"')
- const [result, setResult] = useState(null)
- const [error, setError] = useState(null)
- const [isLoading, setIsLoading] = useState(false)
-
- const handleExecute = useCallback(async () => {
- if (!command.trim()) return
-
- setIsLoading(true)
- setError(null)
- setResult(null)
-
- try {
- console.log('[TtydExecTest] Executing command:', command)
- console.log('[TtydExecTest] URL:', ttydUrl)
-
- const execResult = await executeTtydCommand({
- ttydUrl,
- accessToken,
- command,
- timeoutMs: 30000,
- stripAnsi: true,
- })
-
- console.log('[TtydExecTest] Result:', execResult)
- setResult(execResult)
- } catch (err) {
- console.error('[TtydExecTest] Error:', err)
- if (err instanceof TtydExecError) {
- setError(`${err.code}: ${err.message}`)
- } else {
- setError(err instanceof Error ? err.message : 'Unknown error')
- }
- } finally {
- setIsLoading(false)
- }
- }, [command, ttydUrl, accessToken])
-
- const handleKeyDown = useCallback(
- (e: React.KeyboardEvent) => {
- if (e.key === 'Enter' && !e.shiftKey) {
- e.preventDefault()
- handleExecute()
- }
- },
- [handleExecute]
- )
-
- return (
-
-
ttyd-exec Test
-
- {/* Command Input */}
-
-
Command:
-
- setCommand(e.target.value)}
- onKeyDown={handleKeyDown}
- placeholder="Enter command to execute..."
- className="flex-1 px-3 py-2 bg-gray-800 border border-gray-700 rounded text-white font-mono text-sm focus:outline-none focus:border-blue-500"
- disabled={isLoading}
- />
-
- {isLoading ? 'Executing...' : 'Execute'}
-
-
-
-
- {/* Quick Commands */}
-
-
Quick commands:
-
- {[
- 'echo "Hello World"',
- 'pwd',
- 'ls -la',
- 'whoami',
- 'date',
- 'node --version',
- 'cat /etc/os-release | head -3',
- ].map((cmd) => (
- setCommand(cmd)}
- className="px-2 py-1 bg-gray-700 hover:bg-gray-600 rounded text-xs font-mono transition-colors"
- >
- {cmd}
-
- ))}
-
-
-
- {/* Error Display */}
- {error && (
-
- )}
-
- {/* Result Display */}
- {result && (
-
- {/* Metadata */}
-
-
-
Exit Code
-
- {result.exitCode}
-
-
-
-
Duration
-
{result.durationMs}ms
-
-
-
Timed Out
-
- {result.timedOut ? 'Yes' : 'No'}
-
-
-
-
- {/* Output */}
-
-
Output:
-
- {result.output || '(empty output)'}
-
-
-
- )}
-
- {/* Connection Info */}
-
-
-
- URL: {ttydUrl}
-
-
- Token: {accessToken.slice(0, 8)}...
-
-
-
-
- )
-}
\ No newline at end of file
diff --git a/components/terminal/xterm-terminal.tsx b/components/terminal/xterm-terminal.tsx
deleted file mode 100644
index d3cd501..0000000
--- a/components/terminal/xterm-terminal.tsx
+++ /dev/null
@@ -1,782 +0,0 @@
-/**
- * XtermTerminal Component
- *
- * Production-grade terminal component built with xterm.js, supporting WebSocket connection
- * to ttyd backend with comprehensive file upload integration.
- *
- * Core Features:
- * - SSR-safe dynamic module loading for Next.js compatibility
- * - WebSocket auto-reconnection with graceful error handling
- * - Smart scroll behavior with new content indicator
- * - Multiple renderer support (WebGL → Canvas → DOM fallback)
- * - Proper cleanup and memory management on unmount
- *
- * File Upload System:
- * - Drag & drop and paste (Ctrl+V) support
- * - Smart directory detection via terminal session tracking
- * - Uploads to current working directory (not fixed location)
- * - Multi-terminal isolation via container-scoped event listeners
- * - FileBrowser integration with TUS protocol
- * - Security: Only allows uploads within home directory
- * - Toast notifications with absolute path display and filename clipboard copy
- * - Background upload without blocking terminal interaction
- *
- * Authentication & Session Tracking:
- * - ttyd uses HTTP Basic Auth via -c parameter (username:password)
- * - URL format: ?authorization=base64(user:pass)&arg=SESSION_ID
- * - AuthToken sent in WebSocket JSON message for ttyd validation
- * - Session ID passed to ttyd-auth.sh via ?arg= for file upload cwd detection
- * - ttyd-auth.sh stores shell PID in /tmp/.terminal-session-{SESSION_ID}
- * - Backend reads shell PID to detect current working directory via /proc/{PID}/cwd
- */
-
-'use client';
-
-import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
-import { MdArrowDownward } from 'react-icons/md';
-import type { ITerminalOptions, Terminal as ITerminal } from '@xterm/xterm';
-import { toast } from 'sonner';
-
-import { useFileDrop } from './hooks/use-file-drop';
-import { useFileUpload } from './hooks/use-file-upload';
-
-import '@xterm/xterm/css/xterm.css';
-
-// ============================================================================
-// Type Definitions
-// ============================================================================
-
-type FitAddon = import('@xterm/addon-fit').FitAddon;
-type WebglAddon = import('@xterm/addon-webgl').WebglAddon;
-type CanvasAddon = import('@xterm/addon-canvas').CanvasAddon;
-
-enum Command {
- OUTPUT = '0',
- SET_WINDOW_TITLE = '1',
- SET_PREFERENCES = '2',
-}
-
-enum ClientCommand {
- INPUT = '0',
- RESIZE_TERMINAL = '1',
- PAUSE = '2',
- RESUME = '3',
-}
-
-export interface XtermTerminalProps {
- wsUrl: string;
- sandboxId: string; // Sandbox ID for API calls
- theme?: {
- foreground?: string;
- background?: string;
- cursor?: string;
- black?: string;
- red?: string;
- green?: string;
- yellow?: string;
- blue?: string;
- magenta?: string;
- cyan?: string;
- white?: string;
- brightBlack?: string;
- brightRed?: string;
- brightGreen?: string;
- brightYellow?: string;
- brightBlue?: string;
- brightMagenta?: string;
- brightCyan?: string;
- brightWhite?: string;
- };
- fontSize?: number;
- fontFamily?: string;
- rendererType?: 'dom' | 'canvas' | 'webgl';
- onReady?: () => void;
- onConnected?: () => void;
- onDisconnected?: () => void;
- // FileBrowser upload support
- fileBrowserUrl?: string;
- fileBrowserUsername?: string;
- fileBrowserPassword?: string;
- enableFileUpload?: boolean;
- /**
- * Indicates whether this terminal is currently visible in the UI
- *
- * Purpose:
- * - Triggers terminal fit() when visibility changes from hidden to visible
- * - Prevents calling fit() when container has zero dimensions (display: none)
- * - Avoids the known xterm.js FitAddon issue where fitting a hidden terminal results in 1x1 dimensions
- *
- * Why this is needed:
- * - FitAddon cannot auto-detect when a container changes from display:none to display:block
- * - When hidden, container.offsetWidth and container.offsetHeight are 0
- * - Fitting with zero dimensions causes terminal to render incorrectly (1x1 or 80x24 fallback)
- * - requestAnimationFrame ensures fit() is called after browser layout completes
- *
- * Related resources:
- * - GitHub Issue #5320: "wtf why it goes width=1?"
- * - Community pattern: React + xterm.js visibility handling
- * - Default: true
- */
- isVisible?: boolean;
-}
-
-// ============================================================================
-// Component
-// ============================================================================
-
-export function XtermTerminal({
- wsUrl,
- sandboxId,
- theme,
- fontSize = 14,
- fontFamily = 'Consolas, Liberation Mono, Menlo, Courier, monospace',
- rendererType = 'webgl',
- onReady,
- onConnected,
- onDisconnected,
- fileBrowserUrl,
- fileBrowserUsername,
- fileBrowserPassword,
- enableFileUpload = true,
- isVisible = true,
-}: XtermTerminalProps) {
- // =========================================================================
- // State & Refs
- // =========================================================================
-
- const fileDropContainerRef = useRef(null); // Wrapper for file drop events
- const containerRef = useRef(null); // Xterm.js container
- const terminalRef = useRef(null);
- const fitAddonRef = useRef(null);
- const hasNewContentRef = useRef(false);
- const newLineCountRef = useRef(0);
-
- // Cleanup function refs to prevent memory leaks
- const resizeCleanupRef = useRef<(() => void) | null>(null);
- const lineFeedTimeoutRef = useRef(null);
-
- const [hasNewContent, setHasNewContent] = useState(false);
- const [newLineCount, setNewLineCount] = useState(0);
-
- // Terminal session ID for multi-terminal support
- const terminalSessionId = useRef(`terminal-${Date.now()}-${Math.random().toString(36).slice(2)}`);
-
- // =========================================================================
- // File Upload Integration
- // =========================================================================
-
- // Setup file upload hook
- const { uploadFiles, isUploading, isConfigured } = useFileUpload({
- fileBrowserUrl,
- fileBrowserUsername,
- fileBrowserPassword,
- enabled: enableFileUpload,
- });
-
- // Handle file drop and paste events
- const handleFilesReceived = useCallback(
- async (files: File[]) => {
- if (files.length === 0) return;
-
- // Get current working directory from sandbox
- let targetPath: string | undefined = undefined;
- let absolutePath: string | undefined = undefined;
-
- try {
- const response = await fetch(
- `/api/sandbox/${sandboxId}/cwd?sessionId=${terminalSessionId.current}`
- );
-
- if (response.ok) {
- const cwdInfo = await response.json();
-
- // Check if current directory is within home directory
- if (cwdInfo.isInHome && cwdInfo.cwd && cwdInfo.homeDir) {
- // Convert container absolute path to FileBrowser relative path
- // FileBrowser root (/srv) is mounted to /home/fulling
- // Example: /home/fulling/next/src -> /next/src
- const relativePath = cwdInfo.cwd.startsWith(cwdInfo.homeDir)
- ? cwdInfo.cwd.slice(cwdInfo.homeDir.length) || '/'
- : '/';
-
- targetPath = relativePath;
- absolutePath = cwdInfo.cwd; // Store absolute path for toast display
- } else if (!cwdInfo.isInHome) {
- toast.warning('Upload to home directory', {
- description: `Current directory (${cwdInfo.cwd}) is outside home. Uploading to home directory instead.`,
- duration: 4000,
- });
- // Will use default path (/) with homeDir as absolute path
- absolutePath = cwdInfo.homeDir;
- }
- } else {
- console.warn('[XtermTerminal] Failed to get cwd, using default upload path');
- }
- } catch (error) {
- console.warn('[XtermTerminal] Failed to get cwd:', error);
- // Continue with default path
- }
-
- // Upload files - if targetPath is undefined, uploadFiles will use default root path
- try {
- await uploadFiles(files, {
- showToast: true,
- copyToClipboard: true,
- targetPath: targetPath, // undefined = use default root path
- absolutePath: absolutePath, // Absolute container path for toast display
- });
- } catch (error) {
- console.error('[XtermTerminal] Upload failed:', error);
- toast.error('Upload failed', {
- description: error instanceof Error ? error.message : 'Unknown error',
- duration: 5000,
- });
- }
- },
- [uploadFiles, sandboxId]
- );
-
- // Setup drag and drop / paste event handling
- // Listen on terminal container element instead of window for proper multi-terminal isolation
- useFileDrop({
- enabled: isConfigured && !isUploading,
- onFilesDropped: handleFilesReceived,
- onFilesPasted: handleFilesReceived,
- containerRef: fileDropContainerRef, // Listen on this terminal's container only
- });
-
- // =========================================================================
- // Memoized Configuration
- // =========================================================================
-
- const terminalOptions: ITerminalOptions = useMemo(
- () => ({
- fontSize,
- fontFamily,
- theme: theme || {
- foreground: '#d2d2d2',
- background: '#1e1e1e',
- cursor: '#adadad',
- black: '#000000',
- red: '#d81e00',
- green: '#5ea702',
- yellow: '#cfae00',
- blue: '#427ab3',
- magenta: '#89658e',
- cyan: '#00a7aa',
- white: '#dbded8',
- brightBlack: '#686a66',
- brightRed: '#f54235',
- brightGreen: '#99e343',
- brightYellow: '#fdeb61',
- brightBlue: '#84b0d8',
- brightMagenta: '#bc94b7',
- brightCyan: '#37e6e8',
- brightWhite: '#f1f1f0',
- },
- cursorBlink: true,
- cursorStyle: 'block',
- allowProposedApi: true,
- scrollback: 10000,
- tabStopWidth: 8,
- }),
- [fontSize, fontFamily, theme]
- );
-
- // =========================================================================
- // Callbacks
- // =========================================================================
-
- const handleScrollToBottom = useCallback(() => {
- const terminal = terminalRef.current;
- if (terminal) {
- terminal.scrollToBottom();
- }
- setHasNewContent(false);
- setNewLineCount(0);
- }, []);
-
- // Wrap callbacks to ensure stability
- const stableOnReady = useCallback(() => {
- onReady?.();
- }, [onReady]);
-
- const stableOnConnected = useCallback(() => {
- onConnected?.();
- }, [onConnected]);
-
- const stableOnDisconnected = useCallback(() => {
- onDisconnected?.();
- }, [onDisconnected]);
-
- // =========================================================================
- // Main Effect - Terminal Lifecycle Management
- // =========================================================================
-
- useEffect(() => {
- if (!containerRef.current || !wsUrl) return;
-
- let terminal: ITerminal | null = null;
- let socket: WebSocket | null = null;
- let webglAddon: WebglAddon | null = null;
- let canvasAddon: CanvasAddon | null = null;
- let isMounted = true;
- let isAtBottom = true;
- let reconnectTimeout: NodeJS.Timeout | null = null;
-
- const textEncoder = new TextEncoder();
- const textDecoder = new TextDecoder();
-
- // -----------------------------------------------------------------------
- // Helper: Check if at bottom
- // -----------------------------------------------------------------------
-
- const isTerminalAtBottom = (): boolean => {
- if (!terminal) return true;
- try {
- const buffer = terminal.buffer.active;
- const threshold = 2;
- return buffer.viewportY >= buffer.baseY - threshold;
- } catch {
- return true;
- }
- };
-
- // -----------------------------------------------------------------------
- // Helper: Parse WebSocket URL and add session ID
- // -----------------------------------------------------------------------
-
- const parseUrl = (): { wsFullUrl: string; authorization: string } | null => {
- try {
- const url = new URL(wsUrl);
- // Get authorization parameter (base64 encoded credentials for HTTP Basic Auth)
- const authorization = url.searchParams.get('authorization') || '';
-
- if (!authorization) {
- console.error('[XtermTerminal] No authorization found in URL');
- return null;
- }
-
- // Remove credentials from URL — they are sent in the WebSocket init message instead
- url.searchParams.delete('authorization');
-
- // Add session ID as arg parameter for ttyd-auth.sh
- url.searchParams.append('arg', terminalSessionId.current);
-
- const wsProtocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
- const wsPath = url.pathname.replace(/\/$/, '') + '/ws';
- const wsFullUrl = `${wsProtocol}//${url.host}${wsPath}${url.search}`;
-
- console.log('[XtermTerminal] Connecting to:', wsFullUrl);
- return { wsFullUrl, authorization };
- } catch (error) {
- console.error('[XtermTerminal] Failed to parse URL:', error);
- return null;
- }
- };
-
- // -----------------------------------------------------------------------
- // Helper: Send data to server
- // -----------------------------------------------------------------------
-
- const sendData = (data: string | Uint8Array) => {
- if (!socket || socket.readyState !== WebSocket.OPEN) return;
-
- if (typeof data === 'string') {
- // Allocate buffer for UTF-8 encoding: each character may take up to 3 bytes in UTF-8.
- // The extra byte (+1) is for the command code at the start of the buffer.
- const payload = new Uint8Array(data.length * 3 + 1);
- payload[0] = ClientCommand.INPUT.charCodeAt(0);
- const stats = textEncoder.encodeInto(data, payload.subarray(1));
- socket.send(payload.subarray(0, (stats.written as number) + 1));
- } else {
- const payload = new Uint8Array(data.length + 1);
- payload[0] = ClientCommand.INPUT.charCodeAt(0);
- payload.set(data, 1);
- socket.send(payload);
- }
- };
-
- // -----------------------------------------------------------------------
- // Helper: Apply renderer
- // -----------------------------------------------------------------------
-
- const applyRenderer = async (type: 'dom' | 'canvas' | 'webgl') => {
- if (!terminal) return;
-
- // Cleanup existing renderers
- try {
- webglAddon?.dispose();
- webglAddon = null;
- } catch {}
- try {
- canvasAddon?.dispose();
- canvasAddon = null;
- } catch {}
-
- // Apply new renderer
- switch (type) {
- case 'webgl':
- try {
- const { WebglAddon: WebglAddonClass } = await import('@xterm/addon-webgl');
- webglAddon = new WebglAddonClass();
- terminal.loadAddon(webglAddon);
- console.log('[XtermTerminal] WebGL renderer loaded');
- } catch (e) {
- console.log('[XtermTerminal] WebGL failed, falling back to canvas', e);
- await applyRenderer('canvas');
- }
- break;
- case 'canvas':
- try {
- const { CanvasAddon: CanvasAddonClass } = await import('@xterm/addon-canvas');
- canvasAddon = new CanvasAddonClass();
- terminal.loadAddon(canvasAddon);
- console.log('[XtermTerminal] Canvas renderer loaded');
- } catch (e) {
- console.log('[XtermTerminal] Canvas failed, using DOM', e);
- }
- break;
- case 'dom':
- console.log('[XtermTerminal] DOM renderer loaded');
- break;
- }
- };
-
- // -----------------------------------------------------------------------
- // Helper: Connect WebSocket
- // -----------------------------------------------------------------------
-
- const connectWebSocket = () => {
- if (!terminal || !isMounted) return;
-
- const parsed = parseUrl();
- if (!parsed) {
- stableOnDisconnected();
- return;
- }
-
- const { wsFullUrl, authorization } = parsed;
-
- console.log('[XtermTerminal] Creating WebSocket connection...');
- socket = new WebSocket(wsFullUrl, ['tty']);
- socket.binaryType = 'arraybuffer';
-
- socket.onopen = () => {
- if (!isMounted) return;
- console.log('[XtermTerminal] WebSocket connected');
- stableOnConnected();
-
- // Send initial terminal size and AuthToken to ttyd
- // AuthToken is required when ttyd is started with -c parameter (HTTP Basic Auth)
- // The value is base64(username:password), same as the authorization URL parameter
- // ttyd expects the same base64(username:password) value that is passed
- // through the authorization URL parameter.
- const initMsg = JSON.stringify({
- AuthToken: authorization,
- columns: terminal!.cols,
- rows: terminal!.rows,
- });
- socket?.send(textEncoder.encode(initMsg));
-
- terminal!.focus();
- };
-
- socket.onmessage = (event: MessageEvent) => {
- if (!terminal || !isMounted) return;
-
- const rawData = event.data as ArrayBuffer;
- const cmd = String.fromCharCode(new Uint8Array(rawData)[0]);
- const data = rawData.slice(1);
-
- switch (cmd) {
- case Command.OUTPUT:
- const shouldAutoScroll = isAtBottom;
- terminal.write(new Uint8Array(data));
-
- if (shouldAutoScroll) {
- requestAnimationFrame(() => {
- terminal?.scrollToBottom();
- requestAnimationFrame(() => {
- if (terminal && !isTerminalAtBottom()) {
- terminal.scrollToBottom();
- }
- });
- });
- }
- break;
- case Command.SET_WINDOW_TITLE:
- document.title = textDecoder.decode(data);
- break;
- case Command.SET_PREFERENCES:
- console.log('[XtermTerminal] Preferences:', textDecoder.decode(data));
- break;
- default:
- console.warn('[XtermTerminal] Unknown command:', cmd);
- }
- };
-
- socket.onclose = (event: CloseEvent) => {
- if (!isMounted) return;
-
- console.log('[XtermTerminal] WebSocket closed:', event.code, event.reason);
- socket = null;
- stableOnDisconnected();
-
- if (event.code !== 1000) {
- terminal?.write('\r\n\x1b[33m[Connection lost. Reconnecting in 3s...]\x1b[0m\r\n');
- reconnectTimeout = setTimeout(() => {
- if (isMounted) connectWebSocket();
- }, 3000);
- } else {
- terminal?.write('\r\n\x1b[31m[Connection closed]\x1b[0m\r\n');
- }
- };
-
- socket.onerror = (error) => {
- console.error('[XtermTerminal] WebSocket error:', error);
- };
- };
-
- // -----------------------------------------------------------------------
- // Main initialization
- // -----------------------------------------------------------------------
-
- const init = async () => {
- try {
- const [xtermModule, fitAddonModule, webLinksModule] = await Promise.all([
- import('@xterm/xterm'),
- import('@xterm/addon-fit'),
- import('@xterm/addon-web-links'),
- ]);
-
- if (!isMounted || !containerRef.current) return;
-
- terminal = new xtermModule.Terminal(terminalOptions);
- terminalRef.current = terminal;
-
- fitAddonRef.current = new fitAddonModule.FitAddon();
- terminal.loadAddon(fitAddonRef.current);
- terminal.loadAddon(new webLinksModule.WebLinksAddon());
-
- terminal.open(containerRef.current);
-
- requestAnimationFrame(() => {
- if (!isMounted) return;
- fitAddonRef.current?.fit();
- });
-
- requestAnimationFrame(() => {
- if (!isMounted) return;
- applyRenderer(rendererType);
- });
-
- // Setup event handlers
- terminal.onData((data) => {
- if (!isAtBottom) {
- terminal?.scrollToBottom();
- isAtBottom = true;
- }
- if (hasNewContentRef.current) {
- hasNewContentRef.current = false;
- newLineCountRef.current = 0;
- setHasNewContent(false);
- setNewLineCount(0);
- }
- sendData(data);
- });
-
- terminal.onBinary((data) => {
- if (!isAtBottom) {
- terminal?.scrollToBottom();
- isAtBottom = true;
- }
- if (hasNewContentRef.current) {
- hasNewContentRef.current = false;
- newLineCountRef.current = 0;
- setHasNewContent(false);
- setNewLineCount(0);
- }
- sendData(Uint8Array.from(data, (v) => v.charCodeAt(0)));
- });
-
- terminal.onResize(({ cols, rows }) => {
- if (socket?.readyState === WebSocket.OPEN) {
- const msg = JSON.stringify({ columns: cols, rows });
- socket.send(textEncoder.encode(ClientCommand.RESIZE_TERMINAL + msg));
- }
- });
-
- terminal.onScroll(() => {
- const wasAtBottom = isAtBottom;
- const nowAtBottom = isTerminalAtBottom();
-
- if (wasAtBottom !== nowAtBottom) {
- isAtBottom = nowAtBottom;
- if (nowAtBottom && hasNewContentRef.current) {
- hasNewContentRef.current = false;
- newLineCountRef.current = 0;
- setHasNewContent(false);
- setNewLineCount(0);
- }
- }
- });
-
- // Track new line feed events with debounced scroll indicator
- terminal.onLineFeed(() => {
- // Clear previous timeout to debounce rapid line feeds
- if (lineFeedTimeoutRef.current) {
- clearTimeout(lineFeedTimeoutRef.current);
- }
-
- // Schedule scroll indicator update
- lineFeedTimeoutRef.current = setTimeout(() => {
- if (isAtBottom) {
- terminal?.scrollToBottom();
- } else {
- hasNewContentRef.current = true;
- newLineCountRef.current += 1;
- setHasNewContent(true);
- setNewLineCount(newLineCountRef.current);
- }
- }, 10);
- });
-
- // Window resize handler: fit terminal when browser window size changes
- const handleResize = () => fitAddonRef.current?.fit();
- window.addEventListener('resize', handleResize);
-
- // Store cleanup function in ref for later use
- resizeCleanupRef.current = () => {
- window.removeEventListener('resize', handleResize);
- };
-
- stableOnReady();
- connectWebSocket();
-
- console.log('[XtermTerminal] Initialization complete');
- } catch (error) {
- console.error('[XtermTerminal] Initialization failed:', error);
- }
- };
-
- init();
-
- // -----------------------------------------------------------------------
- // Cleanup
- // -----------------------------------------------------------------------
-
- return () => {
- console.log('[XtermTerminal] Cleaning up');
- isMounted = false;
-
- // Clean up window resize listener (prevents memory leak)
- if (resizeCleanupRef.current) {
- resizeCleanupRef.current();
- resizeCleanupRef.current = null;
- }
-
- // Clean up line feed timeout (prevents memory leak)
- if (lineFeedTimeoutRef.current) {
- clearTimeout(lineFeedTimeoutRef.current);
- lineFeedTimeoutRef.current = null;
- }
-
- if (reconnectTimeout) clearTimeout(reconnectTimeout);
-
- if (socket) {
- socket.onclose = null;
- socket.onerror = null;
- socket.onmessage = null;
- socket.onopen = null;
- if (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING) {
- socket.close(1000, 'Component unmounted');
- }
- }
-
- try {
- webglAddon?.dispose();
- } catch {}
- try {
- canvasAddon?.dispose();
- } catch {}
-
- terminal?.dispose();
- terminalRef.current = null;
- fitAddonRef.current = null; // Clear fitAddonRef on cleanup
- };
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [wsUrl]);
-
- // =========================================================================
- // Handle Visibility Changes - Critical for proper terminal sizing
- // =========================================================================
-
- /**
- * When isVisible changes (route switch or tab switch), fit the terminal to container
- *
- * Why this is necessary:
- * 1. Problem: FitAddon cannot detect when CSS display changes from 'none' to 'block'
- * 2. When hidden (display: none):
- * - container.offsetWidth = 0
- * - container.offsetHeight = 0
- * - Calling fit() would result in terminal dimensions of 1x1 (xterm.js minimum)
- * 3. When becoming visible:
- * - React updates CSS to display: block
- * - Browser performs layout (reflow)
- * - Container gets actual dimensions (e.g., 1200px × 800px)
- * - But xterm.js doesn't know about this change
- *
- * Solution:
- * - Watch isVisible prop changes
- * - Only fit() when isVisible becomes true
- * - Use requestAnimationFrame to ensure browser layout is complete before fitting
- *
- * Trigger scenarios:
- * - User navigates from /overview to /terminal → isVisible: false → true
- * - User switches terminal tabs → active tab isVisible: false → true
- * - User navigates away from /terminal → isVisible: true → false (no fit needed)
- *
- * Related: This complements window.resize handler which handles browser window size changes
- */
- useEffect(() => {
- if (isVisible && terminalRef.current && containerRef.current) {
- // Use requestAnimationFrame to ensure container has completed layout
- // and has actual dimensions before calling fit()
- requestAnimationFrame(() => {
- fitAddonRef.current?.fit();
- });
- }
- }, [isVisible]);
-
- // =========================================================================
- // Render
- // =========================================================================
-
- return (
-
-
-
- {/* Scroll to bottom button */}
- {hasNewContent && (
-
-
- {newLineCount}
- new {newLineCount === 1 ? 'line' : 'lines'}
-
- )}
-
- );
-}
diff --git a/components/ui/badge.tsx b/components/ui/badge.tsx
deleted file mode 100644
index fd3a406..0000000
--- a/components/ui/badge.tsx
+++ /dev/null
@@ -1,46 +0,0 @@
-import * as React from "react"
-import { Slot } from "@radix-ui/react-slot"
-import { cva, type VariantProps } from "class-variance-authority"
-
-import { cn } from "@/lib/utils"
-
-const badgeVariants = cva(
- "inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
- {
- variants: {
- variant: {
- default:
- "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
- secondary:
- "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
- destructive:
- "border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
- outline:
- "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
- },
- },
- defaultVariants: {
- variant: "default",
- },
- }
-)
-
-function Badge({
- className,
- variant,
- asChild = false,
- ...props
-}: React.ComponentProps<"span"> &
- VariantProps & { asChild?: boolean }) {
- const Comp = asChild ? Slot : "span"
-
- return (
-
- )
-}
-
-export { Badge, badgeVariants }
diff --git a/components/ui/card.tsx b/components/ui/card.tsx
deleted file mode 100644
index 681ad98..0000000
--- a/components/ui/card.tsx
+++ /dev/null
@@ -1,92 +0,0 @@
-import * as React from "react"
-
-import { cn } from "@/lib/utils"
-
-function Card({ className, ...props }: React.ComponentProps<"div">) {
- return (
-
- )
-}
-
-function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
- return (
-
- )
-}
-
-function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
- return (
-
- )
-}
-
-function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
- return (
-
- )
-}
-
-function CardAction({ className, ...props }: React.ComponentProps<"div">) {
- return (
-
- )
-}
-
-function CardContent({ className, ...props }: React.ComponentProps<"div">) {
- return (
-
- )
-}
-
-function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
- return (
-
- )
-}
-
-export {
- Card,
- CardHeader,
- CardFooter,
- CardTitle,
- CardAction,
- CardDescription,
- CardContent,
-}
diff --git a/components/ui/dialog.tsx b/components/ui/dialog.tsx
deleted file mode 100644
index 435473f..0000000
--- a/components/ui/dialog.tsx
+++ /dev/null
@@ -1,143 +0,0 @@
-"use client"
-
-import * as React from "react"
-import * as DialogPrimitive from "@radix-ui/react-dialog"
-import { XIcon } from "lucide-react"
-
-import { cn } from "@/lib/utils"
-
-function Dialog({
- ...props
-}: React.ComponentProps) {
- return
-}
-
-function DialogTrigger({
- ...props
-}: React.ComponentProps) {
- return
-}
-
-function DialogPortal({
- ...props
-}: React.ComponentProps) {
- return
-}
-
-function DialogClose({
- ...props
-}: React.ComponentProps) {
- return
-}
-
-function DialogOverlay({
- className,
- ...props
-}: React.ComponentProps) {
- return (
-
- )
-}
-
-function DialogContent({
- className,
- children,
- showCloseButton = true,
- ...props
-}: React.ComponentProps & {
- showCloseButton?: boolean
-}) {
- return (
-
-
-
- {children}
- {showCloseButton && (
-
-
- Close
-
- )}
-
-
- )
-}
-
-function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
- return (
-
- )
-}
-
-function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
- return (
-
- )
-}
-
-function DialogTitle({
- className,
- ...props
-}: React.ComponentProps) {
- return (
-
- )
-}
-
-function DialogDescription({
- className,
- ...props
-}: React.ComponentProps) {
- return (
-
- )
-}
-
-export {
- Dialog,
- DialogClose,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogOverlay,
- DialogPortal,
- DialogTitle,
- DialogTrigger,
-}
diff --git a/components/ui/dropdown-menu.tsx b/components/ui/dropdown-menu.tsx
deleted file mode 100644
index c97a2d8..0000000
--- a/components/ui/dropdown-menu.tsx
+++ /dev/null
@@ -1,257 +0,0 @@
-"use client"
-
-import * as React from "react"
-import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
-import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
-
-import { cn } from "@/lib/utils"
-
-function DropdownMenu({
- ...props
-}: React.ComponentProps) {
- return
-}
-
-function DropdownMenuPortal({
- ...props
-}: React.ComponentProps) {
- return (
-
- )
-}
-
-function DropdownMenuTrigger({
- ...props
-}: React.ComponentProps) {
- return (
-
- )
-}
-
-function DropdownMenuContent({
- className,
- sideOffset = 4,
- ...props
-}: React.ComponentProps) {
- return (
-
-
-
- )
-}
-
-function DropdownMenuGroup({
- ...props
-}: React.ComponentProps) {
- return (
-
- )
-}
-
-function DropdownMenuItem({
- className,
- inset,
- variant = "default",
- ...props
-}: React.ComponentProps & {
- inset?: boolean
- variant?: "default" | "destructive"
-}) {
- return (
-
- )
-}
-
-function DropdownMenuCheckboxItem({
- className,
- children,
- checked,
- ...props
-}: React.ComponentProps) {
- return (
-
-
-
-
-
-
- {children}
-
- )
-}
-
-function DropdownMenuRadioGroup({
- ...props
-}: React.ComponentProps) {
- return (
-
- )
-}
-
-function DropdownMenuRadioItem({
- className,
- children,
- ...props
-}: React.ComponentProps) {
- return (
-
-
-
-
-
-
- {children}
-
- )
-}
-
-function DropdownMenuLabel({
- className,
- inset,
- ...props
-}: React.ComponentProps & {
- inset?: boolean
-}) {
- return (
-
- )
-}
-
-function DropdownMenuSeparator({
- className,
- ...props
-}: React.ComponentProps) {
- return (
-
- )
-}
-
-function DropdownMenuShortcut({
- className,
- ...props
-}: React.ComponentProps<"span">) {
- return (
-
- )
-}
-
-function DropdownMenuSub({
- ...props
-}: React.ComponentProps) {
- return
-}
-
-function DropdownMenuSubTrigger({
- className,
- inset,
- children,
- ...props
-}: React.ComponentProps & {
- inset?: boolean
-}) {
- return (
-
- {children}
-
-
- )
-}
-
-function DropdownMenuSubContent({
- className,
- ...props
-}: React.ComponentProps) {
- return (
-
- )
-}
-
-export {
- DropdownMenu,
- DropdownMenuPortal,
- DropdownMenuTrigger,
- DropdownMenuContent,
- DropdownMenuGroup,
- DropdownMenuLabel,
- DropdownMenuItem,
- DropdownMenuCheckboxItem,
- DropdownMenuRadioGroup,
- DropdownMenuRadioItem,
- DropdownMenuSeparator,
- DropdownMenuShortcut,
- DropdownMenuSub,
- DropdownMenuSubTrigger,
- DropdownMenuSubContent,
-}
diff --git a/components/ui/fullscreen-dialog.tsx b/components/ui/fullscreen-dialog.tsx
deleted file mode 100644
index 83c577a..0000000
--- a/components/ui/fullscreen-dialog.tsx
+++ /dev/null
@@ -1,181 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { Dialog as DialogPrimitive } from "radix-ui"
-
-import { cn } from "@/lib/utils"
-import { Button } from "@/components/ui/button"
-
-function FullScreenDialog({
- ...props
-}: React.ComponentProps) {
- return
-}
-
-function FullScreenDialogTrigger({
- ...props
-}: React.ComponentProps) {
- return (
-
- )
-}
-
-function FullScreenDialogPortal({
- ...props
-}: React.ComponentProps) {
- return (
-
- )
-}
-
-function FullScreenDialogOverlay({
- className,
- ...props
-}: React.ComponentProps) {
- return (
- {
- e.stopPropagation();
- // Simulate pressing Escape key to close the dialog
- document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
- }}
- {...props}
- />
- )
-}
-
-function FullScreenDialogContent({
- className,
- children,
- ...props
-}: React.ComponentProps) {
- return (
-
-
- e.stopPropagation()}
- onOpenAutoFocus={(e) => e.preventDefault()}
- {...props}
- >
- {children}
-
-
- )
-}
-
-function FullScreenDialogHeader({
- className,
- ...props
-}: React.ComponentProps<"div">) {
- return (
-
- )
-}
-
-function FullScreenDialogFooter({
- className,
- ...props
-}: React.ComponentProps<"div">) {
- return (
-
- )
-}
-
-function FullScreenDialogTitle({
- className,
- ...props
-}: React.ComponentProps) {
- return (
-
- )
-}
-
-function FullScreenDialogDescription({
- className,
- ...props
-}: React.ComponentProps) {
- return (
-
- )
-}
-
-function FullScreenDialogClose({
- className,
- ...props
-}: React.ComponentProps) {
- return (
-
- )
-}
-
-function FullScreenDialogAction({
- className,
- variant = "default",
- ...props
-}: React.ComponentProps<"button"> & {
- variant?: "default" | "destructive"
-}) {
- return (
-
- )
-}
-
-export {
- FullScreenDialog,
- FullScreenDialogTrigger,
- FullScreenDialogPortal,
- FullScreenDialogOverlay,
- FullScreenDialogContent,
- FullScreenDialogHeader,
- FullScreenDialogFooter,
- FullScreenDialogTitle,
- FullScreenDialogDescription,
- FullScreenDialogClose,
- FullScreenDialogAction,
-}
diff --git a/components/ui/input.tsx b/components/ui/input.tsx
deleted file mode 100644
index 8916905..0000000
--- a/components/ui/input.tsx
+++ /dev/null
@@ -1,21 +0,0 @@
-import * as React from "react"
-
-import { cn } from "@/lib/utils"
-
-function Input({ className, type, ...props }: React.ComponentProps<"input">) {
- return (
-
- )
-}
-
-export { Input }
diff --git a/components/ui/kbd.tsx b/components/ui/kbd.tsx
deleted file mode 100644
index 253c69f..0000000
--- a/components/ui/kbd.tsx
+++ /dev/null
@@ -1,28 +0,0 @@
-import { cn } from "@/lib/utils"
-
-function Kbd({ className, ...props }: React.ComponentProps<"kbd">) {
- return (
-
- )
-}
-
-function KbdGroup({ className, ...props }: React.ComponentProps<"div">) {
- return (
-
- )
-}
-
-export { Kbd, KbdGroup }
diff --git a/components/ui/scroll-area.tsx b/components/ui/scroll-area.tsx
deleted file mode 100644
index 8e4fa13..0000000
--- a/components/ui/scroll-area.tsx
+++ /dev/null
@@ -1,58 +0,0 @@
-"use client"
-
-import * as React from "react"
-import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
-
-import { cn } from "@/lib/utils"
-
-function ScrollArea({
- className,
- children,
- ...props
-}: React.ComponentProps) {
- return (
-
-
- {children}
-
-
-
-
- )
-}
-
-function ScrollBar({
- className,
- orientation = "vertical",
- ...props
-}: React.ComponentProps) {
- return (
-
-
-
- )
-}
-
-export { ScrollArea, ScrollBar }
diff --git a/components/ui/select.tsx b/components/ui/select.tsx
deleted file mode 100644
index 25e5439..0000000
--- a/components/ui/select.tsx
+++ /dev/null
@@ -1,187 +0,0 @@
-"use client"
-
-import * as React from "react"
-import * as SelectPrimitive from "@radix-ui/react-select"
-import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
-
-import { cn } from "@/lib/utils"
-
-function Select({
- ...props
-}: React.ComponentProps) {
- return
-}
-
-function SelectGroup({
- ...props
-}: React.ComponentProps) {
- return
-}
-
-function SelectValue({
- ...props
-}: React.ComponentProps) {
- return
-}
-
-function SelectTrigger({
- className,
- size = "default",
- children,
- ...props
-}: React.ComponentProps & {
- size?: "sm" | "default"
-}) {
- return (
-
- {children}
-
-
-
-
- )
-}
-
-function SelectContent({
- className,
- children,
- position = "popper",
- align = "center",
- ...props
-}: React.ComponentProps) {
- return (
-
-
-
-
- {children}
-
-
-
-
- )
-}
-
-function SelectLabel({
- className,
- ...props
-}: React.ComponentProps) {
- return (
-
- )
-}
-
-function SelectItem({
- className,
- children,
- ...props
-}: React.ComponentProps) {
- return (
-
-
-
-
-
-
- {children}
-
- )
-}
-
-function SelectSeparator({
- className,
- ...props
-}: React.ComponentProps) {
- return (
-
- )
-}
-
-function SelectScrollUpButton({
- className,
- ...props
-}: React.ComponentProps) {
- return (
-
-
-
- )
-}
-
-function SelectScrollDownButton({
- className,
- ...props
-}: React.ComponentProps) {
- return (
-
-
-
- )
-}
-
-export {
- Select,
- SelectContent,
- SelectGroup,
- SelectItem,
- SelectLabel,
- SelectScrollDownButton,
- SelectScrollUpButton,
- SelectSeparator,
- SelectTrigger,
- SelectValue,
-}
diff --git a/components/ui/separator.tsx b/components/ui/separator.tsx
deleted file mode 100644
index 275381c..0000000
--- a/components/ui/separator.tsx
+++ /dev/null
@@ -1,28 +0,0 @@
-"use client"
-
-import * as React from "react"
-import * as SeparatorPrimitive from "@radix-ui/react-separator"
-
-import { cn } from "@/lib/utils"
-
-function Separator({
- className,
- orientation = "horizontal",
- decorative = true,
- ...props
-}: React.ComponentProps) {
- return (
-
- )
-}
-
-export { Separator }
diff --git a/components/ui/skeleton.tsx b/components/ui/skeleton.tsx
deleted file mode 100644
index 32ea0ef..0000000
--- a/components/ui/skeleton.tsx
+++ /dev/null
@@ -1,13 +0,0 @@
-import { cn } from "@/lib/utils"
-
-function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
- return (
-
- )
-}
-
-export { Skeleton }
diff --git a/components/ui/sonner.tsx b/components/ui/sonner.tsx
deleted file mode 100644
index 9b20afe..0000000
--- a/components/ui/sonner.tsx
+++ /dev/null
@@ -1,40 +0,0 @@
-"use client"
-
-import {
- CircleCheckIcon,
- InfoIcon,
- Loader2Icon,
- OctagonXIcon,
- TriangleAlertIcon,
-} from "lucide-react"
-import { useTheme } from "next-themes"
-import { Toaster as Sonner, type ToasterProps } from "sonner"
-
-const Toaster = ({ ...props }: ToasterProps) => {
- const { theme = "system" } = useTheme()
-
- return (
- ,
- info: ,
- warning: ,
- error: ,
- loading: ,
- }}
- style={
- {
- "--normal-bg": "var(--popover)",
- "--normal-text": "var(--popover-foreground)",
- "--normal-border": "var(--border)",
- "--border-radius": "var(--radius)",
- } as React.CSSProperties
- }
- {...props}
- />
- )
-}
-
-export { Toaster }
diff --git a/components/ui/spinner.tsx b/components/ui/spinner.tsx
deleted file mode 100644
index a70e713..0000000
--- a/components/ui/spinner.tsx
+++ /dev/null
@@ -1,16 +0,0 @@
-import { Loader2Icon } from "lucide-react"
-
-import { cn } from "@/lib/utils"
-
-function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
- return (
-
- )
-}
-
-export { Spinner }
diff --git a/components/ui/tabs.tsx b/components/ui/tabs.tsx
deleted file mode 100644
index 497ba5e..0000000
--- a/components/ui/tabs.tsx
+++ /dev/null
@@ -1,66 +0,0 @@
-"use client"
-
-import * as React from "react"
-import * as TabsPrimitive from "@radix-ui/react-tabs"
-
-import { cn } from "@/lib/utils"
-
-function Tabs({
- className,
- ...props
-}: React.ComponentProps) {
- return (
-
- )
-}
-
-function TabsList({
- className,
- ...props
-}: React.ComponentProps) {
- return (
-
- )
-}
-
-function TabsTrigger({
- className,
- ...props
-}: React.ComponentProps) {
- return (
-
- )
-}
-
-function TabsContent({
- className,
- ...props
-}: React.ComponentProps) {
- return (
-
- )
-}
-
-export { Tabs, TabsList, TabsTrigger, TabsContent }
diff --git a/components/ui/toggle-group.tsx b/components/ui/toggle-group.tsx
deleted file mode 100644
index 9894607..0000000
--- a/components/ui/toggle-group.tsx
+++ /dev/null
@@ -1,83 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { type VariantProps } from "class-variance-authority"
-import { ToggleGroup as ToggleGroupPrimitive } from "radix-ui"
-
-import { cn } from "@/lib/utils"
-import { toggleVariants } from "@/components/ui/toggle"
-
-const ToggleGroupContext = React.createContext<
- VariantProps & {
- spacing?: number
- }
->({
- size: "default",
- variant: "default",
- spacing: 0,
-})
-
-function ToggleGroup({
- className,
- variant,
- size,
- spacing = 0,
- children,
- ...props
-}: React.ComponentProps &
- VariantProps & {
- spacing?: number
- }) {
- return (
-
-
- {children}
-
-
- )
-}
-
-function ToggleGroupItem({
- className,
- children,
- variant,
- size,
- ...props
-}: React.ComponentProps &
- VariantProps) {
- const context = React.useContext(ToggleGroupContext)
-
- return (
-
- {children}
-
- )
-}
-
-export { ToggleGroup, ToggleGroupItem }
diff --git a/components/ui/toggle.tsx b/components/ui/toggle.tsx
deleted file mode 100644
index 901c7f4..0000000
--- a/components/ui/toggle.tsx
+++ /dev/null
@@ -1,47 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { cva, type VariantProps } from "class-variance-authority"
-import { Toggle as TogglePrimitive } from "radix-ui"
-
-import { cn } from "@/lib/utils"
-
-const toggleVariants = cva(
- "inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap",
- {
- variants: {
- variant: {
- default: "bg-transparent",
- outline:
- "border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground",
- },
- size: {
- default: "h-9 px-2 min-w-9",
- sm: "h-8 px-1.5 min-w-8",
- lg: "h-10 px-2.5 min-w-10",
- },
- },
- defaultVariants: {
- variant: "default",
- size: "default",
- },
- }
-)
-
-function Toggle({
- className,
- variant,
- size,
- ...props
-}: React.ComponentProps &
- VariantProps) {
- return (
-
- )
-}
-
-export { Toggle, toggleVariants }
diff --git a/components/ui/tooltip.tsx b/components/ui/tooltip.tsx
deleted file mode 100644
index af86553..0000000
--- a/components/ui/tooltip.tsx
+++ /dev/null
@@ -1,68 +0,0 @@
-'use client';
-
-import * as React from 'react';
-import * as TooltipPrimitive from '@radix-ui/react-tooltip';
-
-import { cn } from '@/lib/utils';
-
-function TooltipProvider({
- delayDuration = 0,
- ...props
-}: React.ComponentProps) {
- return (
-
- );
-}
-
-function Tooltip({ ...props }: React.ComponentProps) {
- return (
-
-
-
- );
-}
-
-function TooltipTrigger({ ...props }: React.ComponentProps) {
- return ;
-}
-
-function TooltipContent({
- className,
- sideOffset = 4,
- children,
- ...props
-}: React.ComponentProps) {
- return (
-
-
- {children}
-
-
- );
-}
-
-export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
diff --git a/docs/architecture.md b/docs/architecture.md
index c4886a8..5b9a595 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -155,15 +155,11 @@
- 外部协议通信
- Provider-specific 逻辑
-### lib/platform/integrations/sealos/
-
-- auth
-- session
-- k8s
-- devbox
-- aiproxy
-- namespace
-- runtime adapter
+### lib/platform/integrations//
+
+Provider-specific adapters belong here only when a Workspace capability requires
+them. Authentication providers and user-level Kubernetes credentials remain in
+their focused infrastructure boundaries and must not define the Workspace domain.
## 10. 关键流程目录
diff --git a/docs/github-oauth-verification.md b/docs/github-oauth-verification.md
new file mode 100644
index 0000000..e3592e6
--- /dev/null
+++ b/docs/github-oauth-verification.md
@@ -0,0 +1,29 @@
+# GitHub OAuth Verification
+
+Automated tests do not contact GitHub. Complete this checklist with an authorized
+GitHub OAuth App before release.
+
+## Configuration
+
+- Set `BETTER_AUTH_URL` to the exact application origin.
+- Configure `${BETTER_AUTH_URL}/api/auth/callback/github` as the OAuth callback.
+- Set a unique `BETTER_AUTH_SECRET` with at least 32 characters.
+- Configure `GITHUB_CLIENT_ID` and `GITHUB_CLIENT_SECRET`.
+- Start with a new database containing the v3 baseline migration.
+
+## Checklist
+
+1. Open `/login` while signed out and verify GitHub is the only sign-in method.
+2. Complete GitHub authorization and verify the browser returns to `/workspace`.
+3. Verify one `User`, one GitHub `Account`, and one active `Session` were created.
+4. Sign out and verify `/workspace` redirects to `/login`.
+5. Sign in with the same GitHub account and verify the existing User and Account
+ are reused while a new Session is created.
+6. Open `/workspace` in a new request and verify the database session persists.
+7. Revoke the session in the database and verify the next protected request is
+ rejected.
+8. Trigger an OAuth denial or invalid callback and verify the browser returns to
+ `/login?error=oauth` without exposing provider details.
+
+Never include OAuth tokens, session tokens, or kubeconfig content in verification
+notes.
diff --git a/docs/v2-resource-inventory.md b/docs/v2-resource-inventory.md
new file mode 100644
index 0000000..e606748
--- /dev/null
+++ b/docs/v2-resource-inventory.md
@@ -0,0 +1,29 @@
+# v2 Kubernetes Resource Inventory
+
+The v3 foundation does not migrate v2 database records and does not delete
+Kubernetes resources created by v2. Complete this inventory before resetting the
+Fulling database, because the old database contains the ownership and naming
+information needed to clean up those resources.
+
+## Procedure
+
+1. Stop every v2 application instance and reconciliation worker.
+2. Export the old PostgreSQL database and retain it according to the deployment's
+ backup policy.
+3. Record every user kubeconfig and project namespace without placing credential
+ content in tickets, logs, or chat.
+4. For each affected namespace, inventory:
+ - Deployments and StatefulSets
+ - Services and Ingresses
+ - PersistentVolumeClaims
+ - Secrets and ConfigMaps
+ - ServiceAccounts, Roles, and RoleBindings
+ - KubeBlocks Cluster resources and related backups
+5. Record the resource owner, cleanup decision, expected ongoing cost, and person
+ responsible for cleanup.
+6. Confirm the inventory with the operator responsible for the cluster.
+7. Only then deploy the v3 baseline to a new or explicitly reset database.
+
+Do not assume that deleting a v2 PostgreSQL row removed the corresponding
+Kubernetes object. Retain the inventory until every resource has been adopted or
+removed through an operator-approved process.
diff --git a/e2e/global-setup.ts b/e2e/global-setup.ts
new file mode 100644
index 0000000..55de0fb
--- /dev/null
+++ b/e2e/global-setup.ts
@@ -0,0 +1,76 @@
+import type { FullConfig } from '@playwright/test'
+import { PrismaClient } from '@prisma/client'
+import { createHmac } from 'node:crypto'
+import { mkdir, writeFile } from 'node:fs/promises'
+
+export const E2E_USER_ID = 'e2e-user'
+export const E2E_SESSION_ID = 'e2e-session'
+export const E2E_SESSION_TOKEN = 'e2e-session-token'
+
+function signCookie(value: string, secret: string): string {
+ const signature = createHmac('sha256', secret).update(value).digest('base64')
+ return `${value}.${signature}`
+}
+
+export default async function globalSetup(config: FullConfig) {
+ const databaseUrl = process.env.DATABASE_URL
+ const secret = process.env.BETTER_AUTH_SECRET
+ if (!databaseUrl || !secret) {
+ throw new Error('Playwright requires DATABASE_URL and BETTER_AUTH_SECRET for an isolated test database.')
+ }
+
+ const baseURL = config.projects[0]?.use.baseURL
+ if (typeof baseURL !== 'string') {
+ throw new Error('Playwright requires a configured baseURL.')
+ }
+
+ const prisma = new PrismaClient({ datasourceUrl: databaseUrl })
+ try {
+ await prisma.kubeconfig.deleteMany({ where: { userId: E2E_USER_ID } })
+ await prisma.session.deleteMany({ where: { userId: E2E_USER_ID } })
+ await prisma.user.upsert({
+ where: { id: E2E_USER_ID },
+ create: {
+ id: E2E_USER_ID,
+ name: 'Fulling Test User',
+ email: 'e2e@example.com',
+ emailVerified: true,
+ },
+ update: {
+ name: 'Fulling Test User',
+ email: 'e2e@example.com',
+ },
+ })
+ await prisma.session.create({
+ data: {
+ id: E2E_SESSION_ID,
+ token: E2E_SESSION_TOKEN,
+ userId: E2E_USER_ID,
+ expiresAt: new Date(Date.now() + 60 * 60 * 1000),
+ },
+ })
+ } finally {
+ await prisma.$disconnect()
+ }
+
+ const origin = new URL(baseURL)
+ await mkdir('e2e/.auth', { recursive: true })
+ await writeFile(
+ 'e2e/.auth/state.json',
+ JSON.stringify({
+ cookies: [
+ {
+ name: 'better-auth.session_token',
+ value: signCookie(E2E_SESSION_TOKEN, secret),
+ domain: origin.hostname,
+ path: '/',
+ expires: Math.floor(Date.now() / 1000) + 3600,
+ httpOnly: true,
+ secure: origin.protocol === 'https:',
+ sameSite: 'Lax',
+ },
+ ],
+ origins: [],
+ })
+ )
+}
diff --git a/e2e/global-teardown.ts b/e2e/global-teardown.ts
new file mode 100644
index 0000000..c1d0237
--- /dev/null
+++ b/e2e/global-teardown.ts
@@ -0,0 +1,16 @@
+import { PrismaClient } from '@prisma/client'
+
+import { E2E_USER_ID } from './global-setup'
+
+export default async function globalTeardown() {
+ if (!process.env.DATABASE_URL) {
+ return
+ }
+
+ const prisma = new PrismaClient({ datasourceUrl: process.env.DATABASE_URL })
+ try {
+ await prisma.user.deleteMany({ where: { id: E2E_USER_ID } })
+ } finally {
+ await prisma.$disconnect()
+ }
+}
diff --git a/e2e/minimal-flow.spec.ts b/e2e/minimal-flow.spec.ts
new file mode 100644
index 0000000..aebead7
--- /dev/null
+++ b/e2e/minimal-flow.spec.ts
@@ -0,0 +1,101 @@
+import { expect, test } from '@playwright/test'
+
+function expectNoHorizontalOverflow(pageWidth: number, scrollWidth: number) {
+ expect(scrollWidth).toBeLessThanOrEqual(pageWidth + 1)
+}
+
+test.describe('public and protected navigation', () => {
+ test.use({ storageState: { cookies: [], origins: [] } })
+
+ test('landing opens the GitHub-only login page', async ({ page }) => {
+ await page.goto('/')
+ await page.getByRole('link', { name: 'Sign in' }).first().click()
+
+ await expect(page).toHaveURL(/\/login$/)
+ await expect(page.getByRole('button', { name: 'Continue with GitHub' })).toBeVisible()
+ await expect(page.locator('input')).toHaveCount(0)
+ })
+
+ test('anonymous workspace and settings requests redirect to login', async ({ page }) => {
+ await page.goto('/workspace')
+ await expect(page).toHaveURL(/\/login$/)
+
+ await page.goto('/settings/kubeconfig')
+ await expect(page).toHaveURL(/\/login$/)
+ })
+})
+
+test('authenticated user reaches workspace without a kubeconfig', async ({ page }) => {
+ await page.goto('/workspace')
+
+ await expect(page.getByRole('heading', { name: 'Welcome, Fulling Test User' })).toBeVisible()
+ await expect(page.getByText('Not configured')).toBeVisible()
+})
+
+test('kubeconfig settings save, replace, and delete without reading content', async ({ page }) => {
+ let configured = false
+ let updatedAt: string | null = null
+ let savedContent: string | null = null
+
+ await page.route('**/api/kubeconfig', async (route) => {
+ const method = route.request().method()
+ if (method === 'GET') {
+ await route.fulfill({ json: { configured, updatedAt } })
+ return
+ }
+ if (method === 'PUT') {
+ savedContent = (route.request().postDataJSON() as { content: string }).content
+ configured = true
+ updatedAt = '2026-07-13T12:00:00.000Z'
+ await route.fulfill({ json: { configured, updatedAt } })
+ return
+ }
+
+ configured = false
+ updatedAt = null
+ savedContent = null
+ await route.fulfill({ json: { configured, updatedAt } })
+ })
+
+ await page.goto('/settings/kubeconfig')
+ const editor = page.getByLabel('Kubeconfig content')
+ await expect(editor).toHaveValue('')
+
+ await editor.fill('first credential')
+ await page.getByRole('button', { name: 'Save' }).click()
+ await expect(page.getByText('Kubeconfig saved. The stored content remains hidden.')).toBeVisible()
+ await expect(editor).toHaveValue('')
+ expect(savedContent).toBe('first credential')
+
+ await editor.fill('replacement credential')
+ await page.getByRole('button', { name: 'Replace' }).click()
+ await expect(editor).toHaveValue('')
+ expect(savedContent).toBe('replacement credential')
+
+ await page.getByRole('button', { name: 'Delete' }).click()
+ await page.getByRole('button', { name: 'Delete kubeconfig' }).click()
+ await expect(page.getByText('Kubeconfig deleted.')).toBeVisible()
+ expect(savedContent).toBeNull()
+})
+
+test('authenticated pages do not overflow the viewport', async ({ page }) => {
+ await page.goto('/workspace')
+ const dimensions = await page.evaluate(() => ({
+ clientWidth: document.documentElement.clientWidth,
+ scrollWidth: document.documentElement.scrollWidth,
+ }))
+
+ expectNoHorizontalOverflow(dimensions.clientWidth, dimensions.scrollWidth)
+ await expect(page.getByRole('button', { name: 'Sign out' })).toBeVisible()
+})
+
+test('sign-out returns the user to login', async ({ page }) => {
+ await page.route('**/api/auth/sign-out', async (route) => {
+ await route.fulfill({ status: 200, json: { success: true } })
+ })
+ await page.goto('/workspace')
+
+ await page.getByRole('button', { name: 'Sign out' }).click()
+
+ await expect(page).toHaveURL(/\/login$/)
+})
diff --git a/hooks/use-app-runner.ts b/hooks/use-app-runner.ts
deleted file mode 100644
index e76669a..0000000
--- a/hooks/use-app-runner.ts
+++ /dev/null
@@ -1,67 +0,0 @@
-import { useCallback, useMemo, useState } from 'react';
-import { toast } from 'sonner';
-
-const BASE_DIR = '/home/fulling/next';
-
-function buildSkillPrompt(repoUrl?: string) {
- void repoUrl;
- return '/fulling-deploy';
-}
-
-export function useAppRunner(
- sandboxId: string | undefined,
- deployDir: string = './',
- repoUrl?: string
-) {
- const [isRunningSkill, setIsRunningSkill] = useState(false);
-
- // Calculate workdir based on deployDir
- const workdir = useMemo(() => {
- if (deployDir === './' || deployDir === '.') {
- return BASE_DIR;
- }
- // Remove leading ./ if present and join with base dir
- const relativePath = deployDir.replace(/^\.\//, '');
- return `${BASE_DIR}/${relativePath}`;
- }, [deployDir]);
-
- const runDeploySkill = useCallback(async () => {
- if (!sandboxId || isRunningSkill) return;
-
- setIsRunningSkill(true);
- try {
- const prompt = buildSkillPrompt(repoUrl);
- const response = await fetch(`/api/sandbox/${sandboxId}/exec`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- command: `claude -p ${JSON.stringify(prompt)}`,
- workdir,
- }),
- });
- const result = await response.json();
-
- if (response.ok && result.success) {
- toast.success('Deploy Prep Started', {
- description: 'The /fulling-deploy skill is running in the sandbox background.',
- });
- } else {
- toast.error('Failed to Start Deploy Prep', {
- description: result.error || 'Unknown error',
- });
- }
- } catch (error) {
- console.error('Failed to start deploy prep skill:', error);
- toast.error('Failed to Start Deploy Prep', {
- description: 'Network error, please try again',
- });
- } finally {
- setIsRunningSkill(false);
- }
- }, [sandboxId, isRunningSkill, repoUrl, workdir]);
-
- return {
- isRunningSkill,
- runDeploySkill,
- };
-}
diff --git a/hooks/use-environment-variables.ts b/hooks/use-environment-variables.ts
deleted file mode 100644
index e177b26..0000000
--- a/hooks/use-environment-variables.ts
+++ /dev/null
@@ -1,135 +0,0 @@
-/**
- * Hook for managing environment variables with React Query
- * Provides loading, mutation, and caching for environment variables
- */
-
-import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
-import { toast } from 'sonner'
-
-import { DELETE, FetchError, GET, POST, PUT } from '@/lib/fetch-client'
-
-/**
- * Extract user-friendly error message from FetchError
- */
-function getErrorMessage(error: unknown): string {
- if (error instanceof FetchError && error.body && typeof error.body === 'object') {
- const body = error.body as { error?: string }
- if (body.error) {
- return body.error
- }
- }
-
- if (error instanceof Error) {
- return error.message
- }
-
- return 'An unknown error occurred'
-}
-
-export interface EnvironmentVariable {
- id?: string
- key: string
- value: string
- category?: string
- isSecret?: boolean
-}
-
-export interface GroupedEnvironmentVariables {
- auth: EnvironmentVariable[]
- payment: EnvironmentVariable[]
- ttyd: EnvironmentVariable[]
- general: EnvironmentVariable[]
- secret: EnvironmentVariable[]
-}
-
-/**
- * Fetch environment variables for a project
- */
-export function useEnvironmentVariables(projectId: string) {
- return useQuery({
- queryKey: ['environmentVariables', projectId],
- queryFn: async () => {
- const data = await GET(`/api/projects/${projectId}/environment`)
- return data
- },
- staleTime: 5000,
- refetchOnWindowFocus: false,
- })
-}
-
-/**
- * Add or update environment variable
- */
-export function useUpsertEnvironmentVariable(projectId: string) {
- const queryClient = useQueryClient()
-
- return useMutation({
- mutationFn: async (variable: EnvironmentVariable) => {
- if (variable.id) {
- // Update existing
- return await PUT(`/api/projects/${projectId}/environment/${variable.id}`, {
- value: variable.value,
- })
- } else {
- // Create new
- return await POST(`/api/projects/${projectId}/environment`, variable)
- }
- },
- onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: ['environmentVariables', projectId] })
- queryClient.invalidateQueries({ queryKey: ['project', projectId] })
- toast.success('Environment variable saved')
- },
- onError: (error: unknown) => {
- toast.error(getErrorMessage(error))
- },
- })
-}
-
-/**
- * Delete environment variable
- */
-export function useDeleteEnvironmentVariable(projectId: string) {
- const queryClient = useQueryClient()
-
- return useMutation({
- mutationFn: async (variableId: string) => {
- await DELETE(`/api/projects/${projectId}/environment/${variableId}`)
- },
- onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: ['environmentVariables', projectId] })
- queryClient.invalidateQueries({ queryKey: ['project', projectId] })
- toast.success('Environment variable deleted')
- },
- onError: (error: unknown) => {
- toast.error(getErrorMessage(error))
- },
- })
-}
-
-/**
- * Batch update environment variables for a category
- */
-export function useBatchUpdateEnvironmentVariables(projectId: string) {
- const queryClient = useQueryClient()
-
- return useMutation({
- mutationFn: async (params: { category: string; variables: EnvironmentVariable[] }) => {
- return await POST(`/api/projects/${projectId}/environment`, {
- variables: params.variables.map((v) => ({
- key: v.key,
- value: v.value,
- category: params.category,
- isSecret: v.isSecret || false,
- })),
- })
- },
- onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: ['environmentVariables', projectId] })
- queryClient.invalidateQueries({ queryKey: ['project', projectId] })
- },
- onError: (error: unknown) => {
- toast.error(getErrorMessage(error))
- },
- })
-}
diff --git a/hooks/use-project-operations.ts b/hooks/use-project-operations.ts
deleted file mode 100644
index 77138cd..0000000
--- a/hooks/use-project-operations.ts
+++ /dev/null
@@ -1,78 +0,0 @@
-/**
- * Hook for managing project operations (start, stop, delete)
- *
- * Provides a unified interface for project lifecycle management
- */
-
-import { useState } from 'react';
-import { useRouter } from 'next/navigation';
-
-import { POST } from '@/lib/fetch-client';
-import type { ProjectAction } from '@/lib/util/action';
-
-interface UseProjectOperationsReturn {
- /** Execute a project operation */
- executeOperation: (action: ProjectAction) => Promise;
- /** Currently executing operation */
- loading: ProjectAction | null;
- /** Error message if operation failed */
- error: string | null;
-}
-
-/**
- * Hook for project operations
- *
- * @param projectId - Project ID
- * @returns Operation controls and state
- */
-export function useProjectOperations(projectId: string): UseProjectOperationsReturn {
- const router = useRouter();
- const [loading, setLoading] = useState(null);
- const [error, setError] = useState(null);
-
- const executeOperation = async (action: ProjectAction) => {
- setLoading(action);
- setError(null);
-
- try {
- let endpoint = '';
-
- switch (action) {
- case 'START':
- endpoint = `/api/projects/${projectId}/start`;
- break;
- case 'STOP':
- endpoint = `/api/projects/${projectId}/stop`;
- break;
- case 'DELETE':
- endpoint = `/api/projects/${projectId}/delete`;
- break;
- default:
- throw new Error(`Unknown action: ${action}`);
- }
-
- await POST(endpoint);
-
- // For delete, redirect to projects list
- if (action === 'DELETE') {
- router.push('/projects');
- return;
- }
-
- // Refresh the page to show updated status
- router.refresh();
- } catch (err) {
- const errorMessage = `Failed to ${action.toLowerCase()} project`;
- console.error(errorMessage, err);
- setError(errorMessage);
- } finally {
- setLoading(null);
- }
- };
-
- return {
- executeOperation,
- loading,
- error,
- };
-}
\ No newline at end of file
diff --git a/hooks/use-project.ts b/hooks/use-project.ts
deleted file mode 100644
index 8506af6..0000000
--- a/hooks/use-project.ts
+++ /dev/null
@@ -1,48 +0,0 @@
-/**
- * Hook for fetching project data with React Query
- *
- * Provides automatic caching, refetching, and state synchronization
- */
-
-import type { Prisma } from '@prisma/client';
-import { useQuery } from '@tanstack/react-query';
-
-import { GET } from '@/lib/fetch-client';
-
-type Project = Prisma.ProjectGetPayload<{
- include: {
- sandboxes: true;
- databases: true;
- environments: true;
- };
-}>;
-
-interface UseProjectOptions {
- /** Enable automatic refetching every 3 seconds */
- refetchInterval?: number;
- /** Enable refetch on window focus */
- refetchOnWindowFocus?: boolean;
-}
-
-/**
- * Fetch project data with automatic polling
- *
- * @param projectId - Project ID
- * @param options - Query options
- * @returns Query result with project data
- */
-export function useProject(projectId: string, options: UseProjectOptions = {}) {
- const { refetchInterval = 3000, refetchOnWindowFocus = false } = options;
-
- return useQuery({
- queryKey: ['project', projectId],
- queryFn: async () => {
- const data = await GET(`/api/projects/${projectId}`);
- return data;
- },
- refetchInterval,
- refetchOnWindowFocus,
- staleTime: 2000, // Consider data stale after 2 seconds
- retry: 2,
- });
-}
\ No newline at end of file
diff --git a/instrumentation.ts b/instrumentation.ts
deleted file mode 100644
index daf96e2..0000000
--- a/instrumentation.ts
+++ /dev/null
@@ -1,35 +0,0 @@
-/**
- * Next.js Instrumentation Hook
- *
- * This file is called once when the Next.js server starts.
- * Perfect for initializing event listeners and background jobs.
- *
- * @see https://nextjs.org/docs/app/building-your-application/optimizing/instrumentation
- */
-
-import { logger as baseLogger } from '@/lib/logger'
-
-const logger = baseLogger.child({ module: 'instrumentation' })
-
-/**
- * Register function runs once when the Next.js server starts
- * This is the entry point for all application initialization
- */
-export async function register() {
- // Only run on the server side
- if (process.env.NEXT_RUNTIME === 'nodejs') {
- logger.info('[START] Application initialization started')
-
- try {
- // Import and initialize events and jobs
- const { initializeApp } = await import('@/lib/startup')
- await initializeApp()
-
- logger.info('[DONE] Application initialization completed')
- } catch (error) {
- logger.error(`[ERROR] Application initialization failed: ${error}`)
- // Don't throw error to prevent app from crashing
- // Jobs will retry on next reconcile cycle
- }
- }
-}
\ No newline at end of file
diff --git a/integrations/sealos/desktop-sdk/detect-sealos-iframe.test.ts b/integrations/sealos/desktop-sdk/detect-sealos-iframe.test.ts
deleted file mode 100644
index 79a530a..0000000
--- a/integrations/sealos/desktop-sdk/detect-sealos-iframe.test.ts
+++ /dev/null
@@ -1,123 +0,0 @@
-import { afterEach, describe, expect, it, vi } from 'vitest'
-
-import { detectSealosIframe } from '@/integrations/sealos/desktop-sdk/detect-sealos-iframe'
-
-describe('detectSealosIframe', () => {
- afterEach(() => {
- vi.unstubAllGlobals()
- })
-
- it('returns false when no window is provided', () => {
- expect(detectSealosIframe(undefined)).toBe(false)
- })
-
- it('returns false when the window has no ancestor origin', () => {
- expect(detectSealosIframe({ location: {} })).toBe(false)
- })
-
- it('returns false for Sealos origins when the window is not framed', () => {
- const browserWindow = {
- self: 'same-frame',
- top: 'same-frame',
- location: {
- ancestorOrigins: ['https://cloud.sealos.io'],
- },
- }
-
- expect(detectSealosIframe(browserWindow)).toBe(false)
- })
-
- it('returns true for sealos.io ancestor origins', () => {
- expect(
- detectSealosIframe({
- self: 'child-frame',
- top: 'parent-frame',
- location: {
- ancestorOrigins: ['https://cloud.sealos.io'],
- },
- }),
- ).toBe(true)
- })
-
- it('returns true for sealos.run ancestor origins', () => {
- expect(
- detectSealosIframe({
- self: 'child-frame',
- top: 'parent-frame',
- location: {
- ancestorOrigins: ['https://workspace.sealos.run'],
- },
- }),
- ).toBe(true)
- })
-
- it('returns false for non-Sealos ancestor origins', () => {
- expect(
- detectSealosIframe({
- self: 'child-frame',
- top: 'parent-frame',
- location: {
- ancestorOrigins: ['https://example.com'],
- },
- }),
- ).toBe(false)
- })
-
- it('returns false for spoofed Sealos-like ancestor hosts', () => {
- expect(
- detectSealosIframe({
- self: 'child-frame',
- top: 'parent-frame',
- location: {
- ancestorOrigins: ['https://sealos.io.evil.com'],
- },
- }),
- ).toBe(false)
-
- expect(
- detectSealosIframe({
- self: 'child-frame',
- top: 'parent-frame',
- location: {
- ancestorOrigins: ['https://evil-sealos.run.example'],
- },
- }),
- ).toBe(false)
-
- expect(
- detectSealosIframe({
- self: 'child-frame',
- top: 'parent-frame',
- location: {
- ancestorOrigins: ['https://example.com?next=sealos.io'],
- },
- }),
- ).toBe(false)
- })
-
- it('returns false for malformed ancestor origin strings', () => {
- expect(
- detectSealosIframe({
- self: 'child-frame',
- top: 'parent-frame',
- location: {
- ancestorOrigins: ['not a url with sealos.io'],
- },
- }),
- ).toBe(false)
- })
-
- it('returns false when ancestor origin access throws', () => {
- const browserWindow = {
- self: 'child-frame',
- top: 'parent-frame',
- location: {
- get ancestorOrigins() {
- throw new Error('blocked')
- },
- },
- } as Parameters[0]
-
- expect(detectSealosIframe(browserWindow)).toBe(false)
- })
-})
diff --git a/integrations/sealos/desktop-sdk/detect-sealos-iframe.ts b/integrations/sealos/desktop-sdk/detect-sealos-iframe.ts
deleted file mode 100644
index f9cc7e0..0000000
--- a/integrations/sealos/desktop-sdk/detect-sealos-iframe.ts
+++ /dev/null
@@ -1,42 +0,0 @@
-type BrowserWindowLike = {
- self?: unknown
- top?: unknown
- location?: {
- ancestorOrigins?: ArrayLike
- }
-}
-
-/**
- * Detects whether the current browser frame is embedded by Sealos.
- *
- * Expected inputs:
- * - A browser `window` object, or no value in server/test environments.
- *
- * Expected outputs:
- * - Returns true when the first ancestor origin is a known Sealos host.
- *
- * Out of scope:
- * - Does not initialize the Sealos SDK.
- * - Does not authenticate the Fulling user.
- */
-export function detectSealosIframe(browserWindow?: BrowserWindowLike): boolean {
- if (!browserWindow) return false
-
- try {
- if (browserWindow.self === browserWindow.top) return false
-
- const ancestorOrigin = browserWindow.location?.ancestorOrigins?.[0]
- if (!ancestorOrigin) return false
-
- const hostname = new URL(ancestorOrigin).hostname
-
- return (
- hostname === 'sealos.io' ||
- hostname.endsWith('.sealos.io') ||
- hostname === 'sealos.run' ||
- hostname.endsWith('.sealos.run')
- )
- } catch {
- return false
- }
-}
diff --git a/integrations/sealos/desktop-sdk/get-sealos-session.test.ts b/integrations/sealos/desktop-sdk/get-sealos-session.test.ts
deleted file mode 100644
index 9197b2e..0000000
--- a/integrations/sealos/desktop-sdk/get-sealos-session.test.ts
+++ /dev/null
@@ -1,104 +0,0 @@
-import { beforeEach, describe, expect, it, vi } from 'vitest'
-
-const { createSealosApp, sealosApp } = vi.hoisted(() => ({
- createSealosApp: vi.fn(),
- sealosApp: {
- getSession: vi.fn(),
- },
-}))
-
-vi.mock('@zjy365/sealos-desktop-sdk/app', () => ({
- createSealosApp,
- sealosApp,
-}))
-
-import { getSealosSession } from '@/integrations/sealos/desktop-sdk/get-sealos-session'
-
-describe('getSealosSession', () => {
- beforeEach(() => {
- vi.clearAllMocks()
- })
-
- it('initializes the SDK and returns a normalized Sealos auth session', async () => {
- const cleanup = vi.fn()
- createSealosApp.mockReturnValue(cleanup)
- sealosApp.getSession.mockResolvedValue({
- token: 'sealos-token',
- kubeconfig: 'apiVersion: v1',
- user: {
- id: 'user-id',
- name: 'sealos-user',
- avatar: 'https://example.com/avatar.png',
- k8sUsername: 'k8s-user',
- nsid: 'ns-user',
- },
- })
-
- const result = await getSealosSession()
-
- expect(result).toEqual({
- token: 'sealos-token',
- kubeconfig: 'apiVersion: v1',
- namespaceId: 'ns-user',
- user: {
- id: 'user-id',
- name: 'sealos-user',
- avatar: 'https://example.com/avatar.png',
- k8sUsername: 'k8s-user',
- nsid: 'ns-user',
- },
- cleanup,
- })
- expect(createSealosApp).toHaveBeenCalledTimes(1)
- expect(sealosApp.getSession).toHaveBeenCalledTimes(1)
- })
-
- it('uses a no-op cleanup when the SDK initializer returns nothing', async () => {
- createSealosApp.mockReturnValue(undefined)
- sealosApp.getSession.mockResolvedValue({
- token: 'sealos-token',
- kubeconfig: 'apiVersion: v1',
- user: {
- id: 'user-id',
- name: 'sealos-user',
- avatar: '',
- k8sUsername: 'k8s-user',
- nsid: 'ns-user',
- },
- })
-
- const result = await getSealosSession()
-
- expect(() => result.cleanup()).not.toThrow()
- })
-
- it('rethrows session loading errors from the SDK', async () => {
- const error = new Error('session unavailable')
- const cleanup = vi.fn()
- createSealosApp.mockReturnValue(cleanup)
- sealosApp.getSession.mockRejectedValue(error)
-
- await expect(getSealosSession()).rejects.toThrow(error)
- expect(cleanup).toHaveBeenCalledTimes(1)
- })
-
- it('cleans up and throws a clear error when the session token is missing', async () => {
- const cleanup = vi.fn()
- createSealosApp.mockReturnValue(cleanup)
- sealosApp.getSession.mockResolvedValue({
- kubeconfig: 'apiVersion: v1',
- user: {
- id: 'user-id',
- name: 'sealos-user',
- avatar: '',
- k8sUsername: 'k8s-user',
- nsid: 'ns-user',
- },
- })
-
- await expect(getSealosSession()).rejects.toThrow(
- 'Sealos session token is missing',
- )
- expect(cleanup).toHaveBeenCalledTimes(1)
- })
-})
diff --git a/integrations/sealos/desktop-sdk/get-sealos-session.ts b/integrations/sealos/desktop-sdk/get-sealos-session.ts
deleted file mode 100644
index ed7c7f8..0000000
--- a/integrations/sealos/desktop-sdk/get-sealos-session.ts
+++ /dev/null
@@ -1,41 +0,0 @@
-import { createSealosApp, sealosApp } from '@zjy365/sealos-desktop-sdk/app'
-
-import type { SealosAuthSession } from './types'
-
-/**
- * Loads the current Sealos Desktop auth session.
- *
- * Expected inputs:
- * - Must run in a Sealos Desktop iframe after the caller has detected Sealos.
- *
- * Expected outputs:
- * - Returns normalized token, kubeconfig, user, namespace, and cleanup values.
- *
- * Out of scope:
- * - Does not decide whether the current browser is in Sealos.
- * - Does not authenticate the Fulling NextAuth session.
- */
-export async function getSealosSession(): Promise {
- const cleanupApp = createSealosApp()
- let sealosSession: Awaited>
-
- try {
- sealosSession = await sealosApp.getSession()
- } catch (error) {
- cleanupApp?.()
- throw error
- }
-
- if (!sealosSession.token) {
- cleanupApp?.()
- throw new Error('Sealos session token is missing')
- }
-
- return {
- token: sealosSession.token,
- kubeconfig: sealosSession.kubeconfig,
- user: sealosSession.user,
- namespaceId: sealosSession.user.nsid,
- cleanup: cleanupApp ?? (() => {}),
- }
-}
diff --git a/integrations/sealos/desktop-sdk/index.ts b/integrations/sealos/desktop-sdk/index.ts
deleted file mode 100644
index 07cf6d1..0000000
--- a/integrations/sealos/desktop-sdk/index.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-export { detectSealosIframe } from './detect-sealos-iframe'
-export { getSealosSession } from './get-sealos-session'
-export type { SealosAuthSession, SealosUserInfo } from './types'
diff --git a/integrations/sealos/desktop-sdk/types.ts b/integrations/sealos/desktop-sdk/types.ts
deleted file mode 100644
index e180f43..0000000
--- a/integrations/sealos/desktop-sdk/types.ts
+++ /dev/null
@@ -1,15 +0,0 @@
-export interface SealosUserInfo {
- id: string
- name: string
- avatar: string
- k8sUsername: string
- nsid: string
-}
-
-export interface SealosAuthSession {
- token: string
- kubeconfig: string
- user: SealosUserInfo
- namespaceId: string
- cleanup: () => void
-}
diff --git a/landing-candidate-2.png b/landing-candidate-2.png
deleted file mode 100644
index 794caa7263a948b03cb7c753c8c9c9f7984bbb12..0000000000000000000000000000000000000000
GIT binary patch
literal 0
HcmV?d00001
literal 1665810
zcmeFa30PC-)<3#;5@RAk11biwx{;`@K-d`}6>A0(5+Tg9R1=aAhA;+6Vq2R?P^j1f
zwhFc4P}|x`QCkYtYNSZ9Ka)Y|gm`-zeF<6^y-7ZEN9
zH3$r%PysIHg+}NFIib8R6#uI>$qRAJoeBw4CD=`Wd@jL~K6SEL&5|e^Vo<+p+iABRFWw<31
zTzh1M*3&-jUXI{qX%^e1Yk)bxgbIW*RXw97Ys`v25||4iQsWW
zuzP_R2g8ARak$Ur#R|AWj*u`w7>*0zaV`(;6Nd_x#3kg5;0SS;{`NH(Bw-U1<6{Xq
z2(9wDAOhyr4vvGic_2nO*itx`6TuIU<-=i#
z1YmJ|J_ig1#1OzMhz-y2g`hJjKwD3Pn#Tnd)$N-3$ipkkw?#<05L}-=F52^
zA%};90^y+eurnTbtY;@+_9Bm`i@-Ywmn2*dT#^qv0bc<pE;{XeU_Qm{gIgjvja9Gdbir~JOIBen?)J3T8_H{TXTpW?c2O&Xiu{5
z0udja4dfu?0b}Nal7(V!ET0bu1g9&2W#Jy^hp5kr_F^|!pg;>
zTi0;9I2ZINo*2)I1&jmfU>HaZ1_8SO^@?B^AXqq{E>{fJ%O_wH01&46r(J^@JWeN^
zFh@BLz&M;(T?o?xj1tDm1xWEo7Y?TkN)Z#+;A5a+kRE(a4A%glB0_jR+=k773BwNv
z3&sG4eES+)j1M3_F?`apVK@L)giw?w0K?)6#PVA)Kmb4n;R7}YXUOA$3&Gh>ZdNP?
z9YQRK;EQ8nN_Zc10tg|*c_0QCJO%ay&+*|oo``szL=wVMo`6!HATw)o&Nd;)lGUlV{PPMg@SaPpOx
zrJ2|e2<+J(S1u$>l29*?3oZz!2LK$wB}@<&CiDde2uI}68z3Th2Dmx_#FO_4MG(PP
z0Gfzx%rpduo`*J6KGkZ9pzTBv*Fd7WjJY%uD0svMNiid~d5h*x+
zzvo|8k)ewdhL>MoZY_c#CIz1&j}?DA(l|8x2yksiZu=BCjZ4
zuQ3|(b$cgehGl@q0*!@L|FhJ$p0EMgg-5Kg$?_syd7;t3SzDY_vbOj?Z5ctl+9tKH
z{`cC~6YM)TUk_7aEg5g#zx$VO&&Lfm~KnrcRg1
z5-Y`GTd+Icw8TmQz1(gSaTu@
zD~p7|(OE)!Ixdb&%CyJXqU=UIIxab`OkI|2GMO||(5pNwN-mdHnn3p}gG;0uxinEN
zSIWzBKvI2*Ot0iwV#Pe8v^dz3UT#+jg&Ju|v|L^movaavWl~L)Mjc(ONwiqyiJ4Jp
ziHXq)>&mp$b&4d7G$LBcO;yN2*(pV7e6`YCsV}nF6e-dKnJF$!Ce>@?3ZdCxmu2Pf
zSC(s%G*-pL95GSS(o}tMT2YQ&wlb$kvsROoXjPaz^T$R>E20vUWeQzJR;iw6uFNS=
z=rl4@v^*JPQs?la5)~_@Ntv;kJf+m2TFK2SiddVOUTBM1CoP>=SEEszlkA4{tV*>)
zV^0MWOXS(|bm_P$m1i!_DKf9kvddr%X^F>{k_)Y>Jbm26nMv(%W>(EQwJm+}UVN0)
zcKaUKXlaTfmasEo@3K6NJS|Og$DU39v}c7i%IevdDoUCcpOv9ErKgMSx^;>ajZ872
zKa({kZDJp$}lo1x=~Sb*)3g1Y0NU2S|N@~MTIRzW-gQ)Dl5uu
zQCX$Y+?*)BET_0QDW#|YPs&SIm!%3yqSq?2tlR=Yn$0RtO~5;Buxf|J(?tx~QcR+!9GGnDOPLuez(3kh`Ay_RniF4NF
zuZ%ZWRK{gh+Vb?JSsB(sdxA;~I*j6`1y|;lB!FRZvrKYBOk9Fk8D^|lX#@z?#OLQl
z#H6oGEUHXio4Hb*BrMG0%QE>=%i4bf!3N#Gfnff>K(IBg7$CS%S|Ba7l~hJ4qw<5}
z6lJoqd~uFmq^wBF5ZPqXb-GxMI6WaDMN}zODTK*#l{rylwnyjWtu4pHQxf=jDy~|t
zk;j&Zv!c1V!I@dH3UOtTEN`uNZJJFMo+{JCt8&tL0QGsnS$UH|X*RRfR$)plRM^(p
z3<>6<;D0ect4U|F$=69&PC#L1iqxEzqDi_9L+XEmq1!Mnv;Gr|uaiQkCSbfoBfkx&
z=Czr2flV1DO`dqqqLJfq04YhOh9dJiUAh>cyaaqLb0y&td6oJ&X=0&FX{sp5OqJmi
zkT1;Br6}OR5)Z^lt=58D@Rzs_94}dxoWm32V2!d{uoNqoDHCyxDK$zXh?Cy|*C`5{
zra*&h3e;Q=T(5($n<5q5z6Wq!T=0JaT&t~WsV7WCMaj&!;CfOI{|eU{Ri;%T%~Vao
z4utefWsE?fkjm9jnI@?svobd$Cq{0nFh*N=5&6;TisF@ei!Hn;!DKO6
zaOKK4UP^8PcV%gGOip=bp&rkY7U|bo)qwWu;8AgL3sHd5#MmT_Oqv^^0RNS0q=JccCg2@lSgkTeNmC}~OjSfh
zdFaLcZuq?+hdxh65HB0MQLRvb~OS(g`=V|P(G^j^
z3LGI`3zTci??UH}b+VgalJdQzEp}S4iIodTX^&jk5lPvs9(Z1q|Zg}G%f~-GRV6^GY
zYl-Z};@oQlieFyA3d}Vdi%k`I{<<7bCR9wUn2QzZit}?}h+bzl#>I^DEmWlt!b0@d3#IpsS
z-h5};=jk?Z^k!Xt(L~Zm`jL?I`GWjn5U*HoR7crDY(=H_=jR*l*YgF22uQv|!-YBe
zP(ipjJQS)phEP414~4M^fiQy0t?3nyFh`k;fq>SW|Avg%lf#AcdAv
zqdsjS+cZKogoEc>@=e8%ca+R{vC$r);f9}Th|NVX^W?XuQ
z1PJ?<$LKOco9(3zhs_^~?i`|;drukf0L-O?Rz9=y@$Shsd!UfzE-@4u$6
zlk@Kw`?aAbX!absaZSoI|CssH{)8{JOICd>INR>0^5K7UVq*UL{;Ta1zU71r;_o9ngfg3W(<@g42F}{H`zMD)!Nu*gxPcEA3
ze7#`f75*Gs>6yo0{V8Xz6rV<=E#0VkZ#jib#+@&4y>L2}oJyh2B&8;EeefB?1&u+A
zGg`6ClJde^x2E24YbNePjGD%nmQubpw**rc>p9$6xG!_&$~lhM!+-!eFXfBO+Hdw|N{}#6X%hz|8ZS&Fdz>RXl30Z;bH04>%`>gB_&vmc=@dW8tZCKbup%R^az3Q>K#H_#)x~
zj4~ge2Q%Fsf}(d^MZ(qUTaTb91rjAZ@YXesZk$b&y)4k^B+5OUTPKPK-yTBRxZu_h
zz3%n^4-AFE4Sjh9&Zp9EiASLevtXrw7M5(JWu&Fttj*dQ@|(3
zYtw()x%A-FM;=JSy0zKe>Q`qV4^sOzl@t9n1bCv&rH6ZyUA
z#^BjkD|Q_{bhX7j+vNc$g|Uwyb|#)m&@7S#DY`E6r-
z%sb~Uo|yJkyO8~rGjFaBQ}h+hKl=Pj>%r$G;Pa7pxb)w8{GE_M{$$O;^;O?e?#Xm4
zSt+=wxc;Ns<^k6UpQqteaCQ>md^A=XcAcZ#jIJzEodo`ENmraxlqTNO4ERy5-rQc->g4I%>!z
zrDd}Yesa%?O?i(T|8A`P&9@{K%U(Ho*?+mdDfGbRsm9CBuU;xx`GW~O`^S9Y$AhQx
zUyS?UNQmEkAAh;~;QXsEN=_erX46kCGhc2AZT?j-<;k3*Ue50>c(>R6`r5N5h_)$~
z3f`Xl3QI*wNud#F@*aJq&YU@`4&N}V=5}D4O4_|)mK|=bC7~#H2JTIzYd5M;Bbh=%
zry#hqhU#@I1Ts;|Zac+_6C-bF`UDP|=Kj-D6im0G=Szw>srN<-JK!xZrm1_*1=&;k
zHm-eVsA<)**J$^X=t$eBG(7C!j`=JGSeup{
z6g|Fd_XcJzzJYQH&}#K0TIq(B_wpXA+p?1S#)%u+uSw<=Wp5CoObU#8!3G}sD_%`9
z1{#ATmva5^Sp=R;Wq2p+iZNw5ko0`$esKRo0A>IhdYA$^C0WXaFf;*3vnRtb+{Dxt
z%i4+fKN2?le$8cZa~^&%A^P6kul=0A;OEv&_pQskF=N&&q3p^~e$t$kSokk_eKW>F
z-`KDX3yATVuJp}bzHipX_83KO#NIf~l6Rkab1kDUZd1Xoinp=ZQ&9FA@2>UP2X5Rm
zzQF6L3rNF1B7fVsW2x`z>1Srld;jy(e|Yz3-ahThVyQ#EiLlJoCbh?2>>hukZSeDg
z7vIC)eDF+HwvcJ~g&gFQydvju;}o97T^v^(&6-;$IKdmnQ=wEWb@{uQM|omI_=w!cug
zUuON>`p%q`K8pCy!ReZB016W68F>e`wzO<E3ju3#zq7cJ@@3!b
zfBEr^0ZX3^54^JM9}i`x4931wur%h;0eOXXio^2k=?|<2H@$fM^+T?L;uAGX-+Ow`
z(=YFB*`S)r2tP&ACvG23dbni1>2y9#_Q}9+S6>ZSy}5qmOPW2B6EL%?pyj#m7D=C|
zW}+9qVV&H4|CSp&lM?3swCRhl4E?EB0Z>z9R?_#aORF1)zcNK*}mZ{$@y-ZyRKZuhvr
zyxZN>AH3_iUwa=Onz8tc^`|v^f6}jN7`>Fa|3mbTxGO^u!CBM$wOLKhrv0BLcYp1F
zhNF5#o=*S%$zP(cm=%xB*1qxkfw0wkg>lsp)%m*wJKxs~%9r;GimvImUi+x@=*sUZ
zzblB#RqwuV?K125PfyQzW54y4pWcY*>-cynzpHY_8Htaz_r^O9&Iy%#^u^{#H|vaK
zmbAI|$rT}wAcaZSTHZ}~gC4Dz{neD9)ISczm!vd3_3I;VUe5b!;kQAJv~WsoFMW{~Ipxa7j=kT#
zdhpWK{m*9i=Di#>j9Y){5>ynX7IkmB`U@|zrEI!l=I`edW?a2-hQeKZ^!+Z4ZbUYG
z@a^E@HNpM?qa&?9ZOM-6U*NVp^2FZn-zwU0Y?0AL!}Bl9o`AoHaQpu);yBX-g8vo)
z{}$lxWULvTlHHNfmBIT*i}yEddwg5Z8$;jxlPi%o0Ap=DAD;b%`A5TN2ln{T@$v&_
z%W1zYJ2qq8@-y9Eg6MOmH_cQq)?b#iX9aIy=
zzPjnFOIxW+j_tV+yTnmqs%HB6T_^qKc
z#=YA5!SZ)jwagxP==zgSo-PcKjSJmKwahR2Iu
zW>|LJOI=2>zQ6rKi6Z_GpR-2!V^!OWx&_BR-EiQtqonie{Mq=XIKjw=q@s}F>tNdV{G(;Pv3pv;%mDu1={X&
z{`_@mh81OQ+;@M>_A2FfpLG^go_^7{H*nqz#c|`I-5WWMW0^6U9r6W5uYKzJ^4iaz
z^yuon<&V!S*!lKYV5I9o>WyV*27|;3uQm6SdH=z?zT5Fs)a&ucw{KTmABo#kzVX+e
z6VXlcX>Wb}da%$;Wj5|xR#>p}&Fk)EQ=h5qWPHLBKdS!h>nGjk80?$lXX0EOKnN8-0Jn?({<5wTqa6aIl%;tX^1Qurf69NOf
z{-28u8%PW9RFsE(l4bz=Buyg`j&`Z>`wwTm`D)8ob6;7zKP_PJr9(#w8rN*I|JL}%
ztWfsl6YjYOwZ~6B-EV*Ttoi91qNh(b`R!ZrO~}qSpL;IBa%5WPmAT_x>M+||AD%yU
zZpE)VKA1b>m)*L(A+?L%O{DtFxw44)%!l1!zt(n1eW%}8JpZF2VgLD~Lak(fb>i_$
z7wenGmJaS*$1E%u;tc7>znztR->HoYPd+v;um8u0mKPJYtqPJ~jkqa$c-j4X62lgq
zR-NVEOenwj^TT_r{j3tFZhXaw;_p5TIP^SC^i3h}>J97u)jP63|D0PN`?&F@pR-r`
z-o4!9cUkiHRFvXJ&KUfT+{gUpHO~ESq-2$+rH}6~cx3)hqq+s38+)d!=;~%(#n03B
z_IlzqjT5!SAWu+}CnT4%cqoz5c1`GXmd}US9q4
zC#nNTutR~5BzkqF{Ihb@w!Ak
z+tjv`PQL&8uXE||Jh3M0`}HbsdhqM>AFB$W{IY6xWC)Xduwm~`=^IITA9(xB^*_<-
z{w!Ym*2Vi?y0B?WS@ykkTWX$NG)u1-oO10U-4kYI$}(2=>DCPgt9lLvt(doK?y8G>
zKTUakY4U-i)x*#KvAyNItG0Z6o#eD%TXjbCsxh`syf^lX!S4MFe`z_fDmJ8d0{(Mx
z!{5L^`qzpf)D8YDH~uY<|Gz={jLXgjM$<25-Ho1&_ncYfy65>b$J?KOkny0pw|&oj
z{h>SCGp_vXs$Cv=x#W|A1+OjLo7(uZ$=SU6^%Ta{rt|Mmeg8v(
zbaTXV^S56`b38}U_Cerlj>tOJkDonrX61{Yq!?Efevq-=t={zGk1=Z+
z8Oxsh^y!V4_GG>n{d2@CZ?`^5t}uMyPFl9-&_nkYelB*~U=UDcskNX@v|LyQcr8ks)`u&En$nOt6XE?U{L^UmRe(QT*cYL(Z=hLL4rJFbI
zyvjK-?b3&-!9lWMdG`4|8+0oM$}Y72zWw`edOIVpy<&Z%?JRaO_WRnyGY*G6`u6%p
z!!xwkU0?KVyI$KG^SbaFpCldX!S3Dp)MGV^Q>Wx6{lt27DCW)mS+lgwMTQ_Mnrw-hV0D;9-BhN8v7{V8@!yfAYn<
z`=%rwE*g7MeBaOAFI-(V|8x8M*=ei?J4uU{;m%L)n1B?dq6Z8dtu*w@a&BxDI!2#~BdM!!g%
z|H+AtIAI(wVKee%|N7YCu)J5qAItY}hMsxQJ9^QRyP53|*S)vwj19Mow;!E*rXBEnN~P!tMr|nyZTT1)Gpfm?US##7Ay>J{_1En
z##%GqexP^uQ2VC`zp2W?lS|Bk9T$q0*S@;vNGZ0AGw`NnKaw+GpZ*bD9S`~I!qx>=?YCWh|Jdihew!b^XkEp^
zuty%e{_XFDA;L|uCq|$DAW&vml6U~~XBzZlk0jzp4_s2lmuxnc6Cb$UbNi#W&P)mh
zRla%CP-OQ8%53=6$I~@|?hjNNDI1_`w1IpBckbN_4W_W)uo~!*RF@U%WZx-M6>*
zsy6-sOy^cP5f?=8f$2cO64yYZa7yrWGHLD52`i%(*w
zDSz9|`ujGd1NoO#cKh7A#f6XePb~ar>6b)~M(`*S_P{*O7uD(CEL@XvxrQ0P-YaIu
ztLLxGTm3vMeT#LIGJiP<3%M|F?5H%kF5s7#(Q{ulpIh|WuODahoRy!xKotfREZnzy
z1DRk-*Wl+e{H&j3qf2-AIL^k-netiZ^`S49{Bi2#p7r*>_HF(TYnH<2_Tm3~2%p?1
znx*i82&genT;k5>{#w`gU)q7X4_`7-_Q3AwK*h_G6R;$sr4&9*VJyGZ^!@j}*i|1E
zr;z>;TY$xs#rhlQ!xs)RhxQe%no+u{r6T$12AzC^WfZ>$F~>o-WQ&a@jV}YU-q^6#c7wmp~Mg9
z79Njzpp=~G^=)WOL&EPwr*r2{%fO@W9DHf4zwX_A<)>czN6U)#UlE`1_HpH&^{1`1
zKhV~Gn>$yyUof@YwdW*>_*72jBKUs+-~I2d2JUL$t_JRE;I0PlYT&L0?rPw!2JUL$
zt_JRE;I0PlYT&L0?rPw!2JUL$t_JRE;I0PlYT&L0?rPw!2JUL$t_JRE;I0PlYT&L0
z?rPw!2JUL$t_J>pq=8ErlB0}(wHFN|0q=hIu~s;IPr?oL?oZH)A1{4*?_itn_qLUN
z_WsSWwKV>|fgJ4o$!9Ca*L#gC4$p{_1z+O7epMTODU_-}{OMLTd<~Tiz2GMAW@m2e
z9=mD1xTUA>h4L8vfuiBI$$$BEE!$QeY~k@2Uc6HIM!~x0H!Umt6o5Mr6ho>QzB9BTEvKpQJtS<2wq=JK-dATY9+T1C
z)Yb;Xh3Z4dG8)>`)XN|-m0C$;J%S@7_c2;s`!0NdU57GgAqd*-V9wBjP)o|HQKq)a
zjev|yEfbS;I#4~*bz-yhm;@xz!e5`DN{%t9lE|$v4OMb&RkO)=z`#Vh5gO7(t}*e5
zA)c4AW}Iq9P#SCu9qI5VJD6HGwQC%nrH+|Lo4YK&7&@4p%NQAAj5y6Ln-R40>;OU=
zlXPb4JKi9Ky*Xe9`V
zp+&V_*IgYl7EYy0938Qv;u;elq3R@{=K#0p_pKC_196KGD%yy&cF4}gv$|#zSAHyn
zF>=L+M>WTg*k#p4oFfegodIuv@DzuAv9kyn&JJ`T6l!ZIy&gfebtp<>$Q)b@8Ae#+
z^Vkvxd)QG#p-M=tix3bERO5sdV%ty`vyI844UO`KRXkEFN{2}tb=5Fk?v7qI+Gr+s
z%ivi=ZMlQ8=i(AE_u&P`rxsjoY-0ZDo
zBED3Hwu+5Z)pzW&EncN1t1!BmJgC&V+`eEHt^L>Q*sTcONLa15K!l7T0jF8@MQVhFlFW#iq8cQ5wKg!Rb?7Q>eU}jy0%=BhbO}jMVd>p)-NpB(
zz}eRZckze7e2kzwZGfb)2-cR-5PZN8;C7%)2ZJO*nx-;YZdN^o(3)F?ur>$x8I>e5
z1_uNa`Ntrbn`*XHA7VO~=`6yJs5)jhi;Y;C8xVgA=Ee{(B}e~tFke!Yp$?8&ovlNv
z%xotcceJAZtv)1XMolpLqA36&lb8<5;Ak_$mqo@nTV4GiW>YYOYC@GqT)fLUtUjHaoL6xCtxv|b86@WdkB
z0epLHuz{=`X=cDbz@f-+Iz#KmE}B|4Q?y=V*@5rbU
z?R@a_elkr0iXAW@_0Gs8e_<&R!)Ea`*hH82#tXBJCl;pCnXN*kYg)MZVj7A$rG@w90en63d+|`DW
z=01L{i5FI56Opl&9am;7Y}wHv8(9yi&~iFEmp#8Haz}e#)79Eu_?0#Oj0@?S4NB@d
z(i9Asd8GNA4q=xyyB+-}R3wCfWMl{ST?YW6jAqtV+Ymix=ExP_HhY|mRwvEY5inBQ
zTgRP&!8zC;pp33LjkPF1^?qY!zkwJQnFL4}Oc@=jJLR>o{L~*SP_25gKD~8+_ZxIMUeOCmPUnj&{_XTZC&bn#d}IG@i}Y1)<%fsyeB|Rn$^d
zB=JpW8JOGYeSCjpXdA_Ws?1i_93LxA@v>TXE9~n8I4jcM3NAAxC(I|V!Pqj5L|T_HOCN^
zV}@yQ%)n$-wfD8LTNIsC#>jdREfBuSrVXvfeMR8i>|s(Bf
z(sg98CYT``q}I1q*HtsqRRE#Pfwx7CIE^vF8^&7M!@fwj!{w7vW8%_qEv0jGTHpbY
z3A}PgaMyJ}ayXoo>6@?I3ht{`uDrt2tl+hEpm5r0Y
z76Txj4LgW!)S4P_ZA-rh)7691BUSC4RFtG=I$9fSiv#GE=IZu77QG6t{KgO&4Nq?}
zZS`)NT5njT?K8@#XeX74^oSZ604}X|Kd01}YOY80X7-SZg@bq7L}RgjO;_pllu;Cv
zbby)BXrJGmaW;MgHE%u#a7G@A-0H^kW0H~e)UkPl<7g$>fe7xfPxl=~`^c)MUI^g`
zL@DqYDYf>Tc088k@&+JkIYmTb4!>rqZ_^xi%~iS^fv*mtBYGw>YNwG!7=o&_NB|u%
zNNB?_@1QDjs}zCb8#8w!+aN;jr=s9n5EuL@S_#DE-)9aiuIswaU|CiK;s^vE_t|*x
zzL^6#AzkBo=D;rIc7LR-+6HvviVvx8de*aGz}o42_7
zj|^c3@G+7Ebj}T{vvEjZmiT}MNJ}yJdR%v8I9ZGMBV>>dq4gPo$B_Dt)Si1AYncJ2
zlpWZz$^Jm!br*Q&YOJ=`yxGK~0o>e}-FKY=D0Kk8xo{jJ&qY(6-7m1Ef7ISICn|C?
zd^1RAq{Hn%oc0D9AW!?Qz7vlzk+$|-Ti7lAXX7E*I_>jGut4OJF%oSIydp9jVtZiU
zxCzX1ghysE;MNi_*8$D7VLDP}!^UG}k@Z~AQdpBuhG@WWpkCT^E(g}k&aG=;gB1>q
zq6`vPy>g^uFtXlT)^x5natYd4G~XS$!WUQ@8EI@qYlG{6C^Wa9D*jGL)UNo;LUsVGIbefq*(x
zR5ggr)FWnuqUz4hMR}@D5Qpx?1V*Dmx-p3#iLBzW
zoaku`lz{2H%9>k!Mmp%YF9ZqSL5Itm#Ng2w1a>FjCKqHW_Cty4y8-9>NflIPSs(C
zw!YPlp$@{1d#6$(RJxXFgEx
z>Nom|(p7_6PzypuP9qsKuxqyS5Wxh%U9tmP-B=ajL{(B?lkDMINVgHYm4m$oWF<7gBJs1-dqiQu%(|s69jlEQ0K;W
zStFo4@MeM|
z74QbqFwkO${iLhkQjc%XY?L~fS^)McKza$)Eu%7;&4VUidcKdYXbE$YV~sAH0VI
zQdqq#z?P9|<7XikVbn}*WoYR{s)4{n5{L}?mQxO97;ijwM4M4#>7XE;Sqz6-Uvx0zglrZ-?xu6t>5LxH3~fMt
zm!%aAf}F)IqcILcEHk^@R3d~D(a^WijL?T2-YPgjT5Cf+q%4?|doSBCEW+qaaEzi>
zq}7L~98{6K7>*()W?0o3xfNIiRTofC8KkmnO^3I18A)t2+e`=!kqgK#5+DKiAp43`
z0DD{AZEOI(oB%qbaSH`&l>&q|hw2V6wE=1jr{UcQiK4Ys8v@u!02^{0Wbqc4tB4)|
zDUifhy9!ZtQvJzPwbr?X4e^zs8l*b6v{r-Egj*R_H6CIPEY!su4h34i3SmLoRTPM8
z>)A*MT~DM^Lo|1zIfw|L+L%EVDk6~w9a0+Z2L!rELZVQeXaLxcsuMFaM=~J_6C@MC
zbX9Dy0o;!ifRX@GoG9)`a0deub9#3wNr;}Z4UpD*Q0a@|+lR6jpB4f9$7&(BYo-h;
z8$;nlwzQ&b6ilV9+6~I4Y%5X^f*`p*m#_&}xOT9+NEqR=3Bvc9SzmJ*c=r0ygsgvjZE-UHiKM=l^45SJ7{Ba&SQIv-Z~Iv}93z*@NjIrZ*HM}r%1
z8{^D`9M^B)G?D!)9PND=ecU|KY=Bu-jE9WyAg^(>$I{F<6x6X2J%>-KVpk!pb@s(o
zw)q}sA21+r_>#!*@w2eE1^_yM17v)m&&^6w(0s|Y&5TZ#+A+le%<2knn_-8^XMlYG
z?;+|XVb4HtAb3LLjw^F&uhyPhgfOUM@SS|)57Q(y=N3YKe1IvM!6qWYim?UnjjkUN
zL#d`Gaw7ve}7*eZGBZH-{rVOgIU?>vo
zw8+;%!OWD$+#(jDW1FiGaOHZ}A~U;=0
z=I=)ufU{#1h`(T(44V3|k0aE~4pj4K^>tj;;Rd&(3+xagS&Rx9<*5fD$ss{W1VaII
zB!Rj+b_*L}JKH3j2d9OO?x~iKtBU9Z_i|q&gJ%sUL|#lQh~FtF;siQjigzu4OaI
zfYr&sqao7N=XRX=%WNiReLU;7{!3GYEq20PRf!k~t%Mo^mdKguIoKTlMCmLS
z;$n1C>!7Zp1x`c4F>}}TuIqi{ja(!HxGJi3c|#UBIt?(@RRpDwX4A}3Cm;|;uR{Y{
z7U;;pNgIm}z}BGzXhTrYi8rmz2dF;}5CnW=GjJg|Fd_~N`3)?Tuyiqlj(SvAZ|6|Q
z5Nf>?!BF^{T&FOdWd(c>GS82Pz=BFzNGp_ER5WK1z&t2^5bi>;3dsNqa%TrRvkFz(
z5F9z&&@SVpXHa#(IQ*+z4XvTz+PI2`97e|_B+&A>nddMXyGcPVH{$1j
zaHl_j52$NQzMvM!K&skz!Ix_4;QT^d;Bmb?q?1a4To9>4(7}oN+zNslu=JcG-l~yI
zh;X7tn-lzchGoT$bJ)ctfD?w6Q%&cfAl85cKs6_F#yB!M1niwSX($K|Kz;z#%JFOh
zryRg(ylYH8omkx|i%g)LnM%nbaWDH1Gce(;G)lW0BT3TO~g&6hIT*zHDGFX;8?a9aoOTT
z1B>UYU`;xtC$N|=XafMs(#(XKro#xe3h@xcTPjeMrbo$iq!lDPJSmeB7$6j{WRaV3
zWTa!rVMo+$sF^)q;`{p%Z{Pr9A-*A@bPb;4CNJMVhXv4|_v}0Aa|qlP$E2ud1H4?>+2&S}
z^!3PaquI-e>H}TOZiJetWt#)M)DR~95W;afN5w2jr^w;j!o~-;0WY`!4diyLi=L6wV~
zV4g*4q6iy^Hv)Syvk?ZQzeV*Mdd-mh(cQ|!-Zi~!;Bi&%ZH9Ur!mCsp05RF+rUek3
z!%Nab?2I!LX+x{noUPKf2Ff5pGuQSS7%ZIRMCk$a0NA{l9&ox1sW*hsTTepFt|JI&
zD;&&e5dv`%4Pubs9n=eGNdR3Rg7~vJ{O$}IMR|A&l*gcgJwkx2jtNw_&1X=>Bj{PH
zw=atjr``?piWDFrk=q&wec;qOD5a@ch5#riKsW$Es&5C1Vx}lbfbLLyB9Woo61f%L
z3xNlKNLO`{2f@;zp0r&JDJxV9fg^(Lh#LE5cSBa@=!fFfJc4~fy>SWDHW08NT@X^|
z@)^i=avi6Rs1I*(c{>nL5slHQ9K>|Y?ad}GR4f4j$PiVVdchhf7=`76N&^%qSYBo}
zs%o3!;8JUQyJjm7`)Gq0SgV^_JN0l!AC&ySsewO2nXwUF*4~L=0Rd1ICsF}K2E|w^
z0@>AAh$yV)q}I@)!60$pdNOh=_xB@`44GrgNq`*$r%Ir)Kp{DiDwF~B&XZc46~Gvs
zM%K2TWI=>NoHoDA!w4G+0wEzGPN)Uuw(MXJcO%^Z#|RzRccaUDbV{oo{#YZ6L8UWk
zT}N1b7N4*>m{*NBP98S4oCbrCV5<#OGOO=I9}pTWu&f#y4R#T*T2=tU)Z#aJ>b5I0
z7D4LDyt$M*zCh|5<^oZ+6D6Ro&2AD@v1G@H4iU+%y`dQ4jh*e9Ju<|;L536)&f6(L
zY94ihwa%lk5D2(V1mqw?!Qz@16nJkQGRHJ_jq_k^$1a$)$;ADgPpvu`xukY#qpPD+
zJKS_mx8tgMWEx%V=o*KDFNA%EH&lv_9D)ZT9a|(M=u|*u8^Bv8!vIGC;UEE}bjy4(
zsDxoH{eB2Z4MsWun1qn(_#Xk?nW9Bn4U+6!2^JW*4`FO$(gN!l(8PfJP*zEE?vpgO
zE_%jqfF0Nh#!8~=Tx|7WG!QV{agQ(I1*!nL8e<1?wlebrW;H^9Xj>GhzyMO|h?=Hu
zgw~8Gf?pKCRFR}Kr;AH+NkB3-6p2t3aW^tBhyX6cJQhNYA(;VGd@%~C9;stP<=S_K0SRQQ
zBWKW&AV}>31InH5N)Tk
zC0GbscX5fC?Sk-L9}?JnjvZ)rx}ktObiwjSRu2BqGuF(-^7*azhIYq_bT#c71)9
z7v@6188WospR@qrVM^5?ltk)*4L}~>H`|v~X8>ijHlRp=3vm4RBU=#AG}4U#PiC`W
z-hcqLO6x@g)Io<0s{+$NdOEuY!DARy7gPe_yESM)FPIq_k@dF4WEK(tHntVghaFID
zCn15HnLXKoZbYVZbn)p>#e)`>%-5F`h;D-dnUd-TmaY_S_h(SYj~QB4R5hR!Xs3=e
zgHVWzs8@o8(IhmAN(O%_qY5RcokZpUL%$C7MEiP|8^htmvvZNx&k(g$7C^JBUDnnB
zUJo4+z%-Fo-7RaYW;A-spx?v5D2^>i?Num>Q_HGhkPYdD5_I5vHw0UvDzYP(LSt$f
zT4p-U$tB8LL?z!1A37^W=oe(`
zI5TCDOY{s3P-hx2vOxWOs3B&$LOA^N3<$PFJ`Lf`VV~b-JKl9VuwPyLoI56^*?(2XgO2SaNK_)(P^I#yNCXN7tN
z9jCeDz?p!wLob1)8=ptf4rA)kAqv&yhVCP?%`Tz`0ZCCoywSUgAY?#OyRAswCiR6}
z9|Dwv&0v|K(b$L#LvvL|9fk%Zu4*i*VnYeh9E8hAy&@moj$r8dp+UN!(~eMG0KL_l
z=JbI_hc^-t9S8__u}ma`8fV~eD$x#yl9-vp*8+=%JVcFjwz16=DkLQ!9UG7o8axA1FgfTr!qN2eq2)zKMOu_N
z1q8DCHbOg4H2=tNJM=CFfg1t00+yhJ)CQNqb5(AU3a+$4IzUYBcDmV%sf?WJ1*KB2
zAq)XbqA>?Yrx8VMI9xE6o{Ua}1OyfeHPB2$!5Y-SK3)B@+d6i&??Mhp2V`<}U6#Y4&}@XxJ(SR3gEOIo)dsmF&@d=nK!FBG-yjp
zpk)E|Ao9@RQ$~XMIzlg+@PXU_HZ<$n*W~6t9c(tM8`)|ZiLC~X;JZB&0wO}f!=UIg
znn{6jInlU;W;Emw)ZT3}U-0%C_$
zIRw4jXV@)Ui`1%XJ7}$kApA@rIUz6%>|&3Hj7|#xEUyh_v(?b(plHX>GFbI&2^31<
ze^e?+>sz)`WWE`lfRXHab|ADG+{#9#s*x>eghsH06~qKeT@RJn9!EX+j1DRx2v9**
zS@lQ2Pbj1yXxx(6C{_>IFbHHOl~uP=AiwfONV?XJT~mDiFP6>*zRB|5``3LZ4GB$0
z8l%6+D%>$)!bs23;0hImv+4Bs
zc*L}5g!w|K*tMhDv*FQnabwmE<`h}2iptyby}D=f^YH~plRMY-zkdJU%l~p(!4mS-
zxZ@$$|;goI*G)%$fBPdS^2k0_u
z7S5uk>v}>@ql?N1U~$8#kJSzNM;^q6Gdv^6$<8;LN|iNbbupcWTeYj;Nzt553x0-f
zY6=<(h-T2jIqArQQ?{Md0;&hzEMNpep{zL=gbJbM3L_mAx*ee)DCx+IU?}9uY+UYy
zpp6DXtQy{G%!F8J^Vk4gfjatUe|XUjbdjiUConCLJ+Lv=XsGaIZa
zmoKZ+LQtR;5G%Zwo~R8v2dsgh>IfN1dx57B3azlRlFea{Fe8YWJRT_{a*d4s;Uo^958;QQ8!lmKK&++`KERLX3m%%M8KL(1UIy*2b@FF81h0v3!ONxQTh_($||C