diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..8cf2c02 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,24 @@ +version: 2 +updates: + - package-ecosystem: bun + directory: "/" + schedule: + interval: weekly + groups: + dev-dependencies: + dependency-type: development + + - package-ecosystem: github-actions + directory: "/" + schedule: + interval: weekly + + - package-ecosystem: docker + directory: "/apps/server" + schedule: + interval: weekly + + - package-ecosystem: docker + directory: "/apps/web" + schedule: + interval: weekly diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f6b6b86 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,55 @@ +name: CI + +on: + pull_request: + branches: [main] + push: + branches: [main] + +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.14 + - run: bun install --frozen-lockfile + # Scoped to files touched by this PR/push, rather than the whole repo, + # so this gate doesn't block on pre-existing lint debt outside the + # change under review. actions/checkout doesn't create a local "main" + # branch (only origin/main), so --since is passed explicitly rather + # than relying on biome.json's vcs.defaultBranch to resolve it. + - run: bunx biome ci --changed --since=origin/main --reporter=github + + typecheck: + name: Typecheck + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.14 + - run: bun install --frozen-lockfile + - run: bun run typecheck + + build: + name: Build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.14 + - run: bun install --frozen-lockfile + - run: bun run build diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..b174cbc --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,33 @@ +name: CodeQL + +on: + pull_request: + branches: [main] + push: + branches: [main] + schedule: + - cron: "24 7 * * 1" + +permissions: + contents: read + +jobs: + analyze: + name: Analyze (${{ matrix.language }}) + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + security-events: write + strategy: + fail-fast: false + matrix: + language: [javascript-typescript] + steps: + - uses: actions/checkout@v4 + - uses: github/codeql-action/init@v3 + with: + languages: ${{ matrix.language }} + - uses: github/codeql-action/analyze@v3 + with: + category: "/language:${{ matrix.language }}" diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml new file mode 100644 index 0000000..6576f16 --- /dev/null +++ b/.github/workflows/dependency-review.yml @@ -0,0 +1,23 @@ +name: Dependency review + +on: + pull_request: + branches: [main] + +permissions: + contents: read + pull-requests: write + +jobs: + dependency-review: + name: Scan dependency changes + runs-on: ubuntu-latest + # Requires the repository's "Dependency graph" setting (Settings -> + # Security -> Dependency graph) to be enabled. Non-blocking here so this + # check doesn't fail every PR until that's turned on. + continue-on-error: true + steps: + - uses: actions/checkout@v4 + - uses: actions/dependency-review-action@v4 + with: + comment-summary-in-pr: on-failure diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 0000000..09f1775 --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,81 @@ +name: Docker + +on: + pull_request: + branches: [main] + paths: + - apps/server/** + - apps/web/** + - packages/** + - package.json + - bun.lock + - turbo.json + - .github/workflows/docker.yml + push: + branches: [main] + tags: ["v*.*.*"] + paths: + - apps/server/** + - apps/web/** + - packages/** + - package.json + - bun.lock + - turbo.json + - .github/workflows/docker.yml + +concurrency: + group: docker-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + name: Build ${{ matrix.image }} + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + strategy: + fail-fast: false + matrix: + include: + - image: server + dockerfile: apps/server/Dockerfile + - image: web + dockerfile: apps/web/Dockerfile + steps: + - uses: actions/checkout@v4 + + - uses: docker/setup-qemu-action@v3 + + - uses: docker/setup-buildx-action@v3 + + - name: Log in to GHCR + if: github.event_name != 'pull_request' + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Derive image metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ghcr.io/${{ github.repository_owner }}/nyxelos-${{ matrix.image }} + tags: | + type=raw,value=latest,enable={{is_default_branch}} + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=sha,prefix=,format=short + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: . + file: ${{ matrix.dockerfile }} + platforms: linux/amd64,linux/arm64 + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha,scope=${{ matrix.image }} + cache-to: type=gha,mode=max,scope=${{ matrix.image }} diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml new file mode 100644 index 0000000..b87611b --- /dev/null +++ b/.github/workflows/package.yml @@ -0,0 +1,74 @@ +name: create-nyxel package + +on: + pull_request: + branches: [main] + paths: + - packages/create-nyxel/** + - .github/workflows/package.yml + push: + branches: [main] + tags: ["create-nyxel@*"] + paths: + - packages/create-nyxel/** + - .github/workflows/package.yml + +concurrency: + group: package-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + name: Build & smoke test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.14 + - run: bun install --frozen-lockfile + - run: bun run --cwd packages/create-nyxel typecheck + - run: bun run --cwd packages/create-nyxel build + - name: Smoke test the CLI + run: | + node packages/create-nyxel/dist/index.js --help + node packages/create-nyxel/dist/index.js --mode pc --dir /tmp/create-nyxel-smoke-pc --yes + node packages/create-nyxel/dist/index.js --mode server --domain nyxel.example.com --dir /tmp/create-nyxel-smoke-server --yes + test -f /tmp/create-nyxel-smoke-pc/docker-compose.yml + test -f /tmp/create-nyxel-smoke-server/Caddyfile + + publish: + name: Publish to npm + needs: build + if: startsWith(github.ref, 'refs/tags/create-nyxel@') + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.14 + - uses: actions/setup-node@v4 + with: + node-version: 20 + registry-url: https://registry.npmjs.org + + - run: bun install --frozen-lockfile + - run: bun run --cwd packages/create-nyxel build + + - name: Verify tag matches package.json version + run: | + TAG_VERSION="${GITHUB_REF#refs/tags/create-nyxel@}" + PKG_VERSION=$(node -p "require('./packages/create-nyxel/package.json').version") + if [ "$TAG_VERSION" != "$PKG_VERSION" ]; then + echo "Tag create-nyxel@$TAG_VERSION does not match package.json version $PKG_VERSION" + exit 1 + fi + + - name: Publish + working-directory: packages/create-nyxel + run: npm publish --provenance --access public + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.github/workflows/pr-title.yml b/.github/workflows/pr-title.yml new file mode 100644 index 0000000..728b531 --- /dev/null +++ b/.github/workflows/pr-title.yml @@ -0,0 +1,31 @@ +name: PR title + +on: + pull_request_target: + types: [opened, edited, synchronize, reopened] + +permissions: + contents: read + pull-requests: read + +jobs: + conventional-commit: + name: Conventional commit format + runs-on: ubuntu-latest + steps: + - uses: amannn/action-semantic-pull-request@v5 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + types: | + feat + fix + docs + style + refactor + perf + test + build + ci + chore + revert diff --git a/README.md b/README.md index ddbaa2d..d51f2db 100644 --- a/README.md +++ b/README.md @@ -21,9 +21,24 @@ Run NyxelOS entirely on your hardware or deploy it across a server cluster. Full --- ### πŸ“– Getting Started & Deployment Modes +#### πŸš€ Just want to run it? `npx create-nyxel` +No checkout, no build toolchain β€” this pulls the published `ghcr.io/quavon-dev/nyxelos-*` images and writes only the Docker Compose files you need to run NyxelOS. +```bash +npx create-nyxel +# or: bunx create-nyxel + +cd nyxel +docker compose up -d +``` +See [`packages/create-nyxel`](packages/create-nyxel) for all options (`--mode`, `--dir`, `--tag`, `--domain`, ...). + +> πŸ’‘ Everything below this point (`git clone`, `bun install`, `docker compose -f docker-compose.*.yml up --build`) is the **development** workflow β€” building from source. Use it if you're contributing to NyxelOS, not just running it. + #### πŸ’» Local Development (Dev Machines / Quick Test) Ideal for development and personal testing without Docker. Requires [Bun](https://bun.sh) 1.3+. ```bash +git clone https://github.com/Quavon-dev/nyxelos.git +cd nyxelos bun install # Setup environment files @@ -43,8 +58,8 @@ Give Nyxel access to your local ecosystem! The `apps/companion-macos` package fu This is the bridge between AI and your desktop life. Full detail in [`apps/companion-macos/README.md`](apps/companion-macos/README.md). -#### 🐳 Docker Compose Deployment (PC Mode / Server Mode) -Deploying via Docker is the recommended path for stability. +#### 🐳 Building the Docker Images From Source (PC Mode / Server Mode) +Prefer to build from this checkout instead of using the published images? Same compose files, with `--build`: **πŸ–₯️ PC Mode (Testing/Home Server):** ```bash @@ -90,12 +105,18 @@ apps/ packages/ db/: Drizzle Schema & Repository Layer (Postgres/SQLite) πŸ—„οΈ model-providers/: Handles routing between local and cloud AI models. ☁️⚑️ + create-nyxel/: The `npx create-nyxel` / `bunx create-nyxel` setup CLI. πŸš€ ``` ### βš™οΈ Development Tools * **DB Migration:** Use `bun run db:generate` and `bun run db:migrate`. * **Knowledge Base:** All documentation lives in the Obsidian vault, automatically synced via ADR-0013. +### πŸ€– CI/CD +* **Pull requests:** lint, typecheck, and build run on every PR (`.github/workflows/ci.yml`), alongside a conventional-commit PR title check, CodeQL analysis, and a dependency review. +* **Docker images:** `apps/server` and `apps/web` are built on every PR (validation only) and pushed to `ghcr.io/quavon-dev/nyxelos-server` / `nyxelos-web` on merges to `main` and version tags (`.github/workflows/docker.yml`). +* **`create-nyxel`:** built and smoke-tested on every PR, published to npm on `create-nyxel@` tags (`.github/workflows/package.yml`). + πŸ”— **Architecture Plan:** [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) πŸ”— **Installation Guide:** [`docs/INSTALL.md`](docs/INSTALL.md) πŸ”— **Obsidian Knowledge Base:** [`knowledge-base/`](knowledge-base/) \ No newline at end of file diff --git a/apps/web/src/app/chat/page.tsx b/apps/web/src/app/chat/page.tsx index 3b2d41f..76f6454 100644 --- a/apps/web/src/app/chat/page.tsx +++ b/apps/web/src/app/chat/page.tsx @@ -3,11 +3,11 @@ import { useMutation, useQuery } from "@tanstack/react-query"; import { ArrowUp, Code2, FileText, Palette, Search } from "lucide-react"; import { useRouter, useSearchParams } from "next/navigation"; -import { useEffect, useState } from "react"; +import { Suspense, useEffect, useState } from "react"; import { - type AttachedFile, - ChatComposerToolbar, - type ChatToolSelection, + type AttachedFile, + ChatComposerToolbar, + type ChatToolSelection, } from "@/components/chat/chat-composer-toolbar"; import { ChatTopBar } from "@/components/chat/chat-top-bar"; import { WorkingDirectoryPicker } from "@/components/chat/working-directory-picker"; @@ -17,18 +17,18 @@ import { type ChatToolMode, trpcClient } from "@/lib/trpc"; import { useInstallation } from "@/lib/use-installation"; const QUICK_ACTIONS = [ - { label: "Summary", icon: FileText, prompt: "Summarize " }, - { label: "Code", icon: Code2, prompt: "Write code to " }, - { label: "Design", icon: Palette, prompt: "Design " }, - { label: "Research", icon: Search, prompt: "Research " }, + { label: "Summary", icon: FileText, prompt: "Summarize " }, + { label: "Code", icon: Code2, prompt: "Write code to " }, + { label: "Design", icon: Palette, prompt: "Design " }, + { label: "Research", icon: Search, prompt: "Research " }, ]; function getGreeting() { - const hour = new Date().getHours(); - if (hour < 5) return "Good night"; - if (hour < 12) return "Good morning"; - if (hour < 18) return "Good afternoon"; - return "Good evening"; + const hour = new Date().getHours(); + if (hour < 5) return "Good night"; + if (hour < 12) return "Good morning"; + if (hour < 18) return "Good afternoon"; + return "Good evening"; } /** The soft multi-color orb above the greeting β€” a purely decorative brand @@ -36,254 +36,244 @@ function getGreeting() { * globals.css) rather than an unrelated palette, so it actually looks like * it belongs to this app. */ function GreetingOrb() { - return ( -
- - -
- ); + return ( +
+ + +
+ ); } export default function ChatLandingPage() { - const router = useRouter(); - const searchParams = useSearchParams(); - const installationQuery = useInstallation(); - const workspaceId = installationQuery.data?.record?.primaryWorkspaceId; - const ownerUserId = installationQuery.data?.record?.ownerUserId; - const defaultWorkingDirectory = - installationQuery.data?.defaultWorkingDirectory ?? ""; - const projectId = searchParams.get("projectId"); + return ( + + + + ); +} + +function ChatLandingPageContent() { + const router = useRouter(); + const searchParams = useSearchParams(); + const installationQuery = useInstallation(); + const workspaceId = installationQuery.data?.record?.primaryWorkspaceId; + const ownerUserId = installationQuery.data?.record?.ownerUserId; + const defaultWorkingDirectory = installationQuery.data?.defaultWorkingDirectory ?? ""; + const projectId = searchParams.get("projectId"); - // The real account name tied to this installation β€” not the demoUser - // stub, which is a fixed "Demo User" fallback for local dev only. - const ownerQuery = useQuery({ - queryKey: ["users", "get", ownerUserId], - queryFn: () => trpcClient.users.get.query({ userId: ownerUserId! }), - enabled: Boolean(ownerUserId), - }); - const modelsQuery = useQuery({ - queryKey: ["models", "list", workspaceId], - queryFn: () => trpcClient.models.list.query({ workspaceId }), - enabled: Boolean(workspaceId), - }); - const workspaceQuery = useQuery({ - queryKey: ["workspace", workspaceId], - queryFn: () => trpcClient.workspaces.get.query({ workspaceId: workspaceId! }), - enabled: Boolean(workspaceId), - }); + // The real account name tied to this installation β€” not the demoUser + // stub, which is a fixed "Demo User" fallback for local dev only. + const ownerQuery = useQuery({ + queryKey: ["users", "get", ownerUserId], + queryFn: () => trpcClient.users.get.query({ userId: ownerUserId! }), + enabled: Boolean(ownerUserId), + }); + const modelsQuery = useQuery({ + queryKey: ["models", "list", workspaceId], + queryFn: () => trpcClient.models.list.query({ workspaceId }), + enabled: Boolean(workspaceId), + }); + const workspaceQuery = useQuery({ + queryKey: ["workspace", workspaceId], + queryFn: () => trpcClient.workspaces.get.query({ workspaceId: workspaceId! }), + enabled: Boolean(workspaceId), + }); - const [message, setMessage] = useState(""); - const [modelId, setModelId] = useState(""); - const [toolSelection, setToolSelection] = useState( - null, - ); - const [toolMode, setToolMode] = useState(null); - const [attachedFile, setAttachedFile] = useState(null); - const [workingDirectory, setWorkingDirectory] = useState(""); + const [message, setMessage] = useState(""); + const [modelId, setModelId] = useState(""); + const [toolSelection, setToolSelection] = useState(null); + const [toolMode, setToolMode] = useState(null); + const [attachedFile, setAttachedFile] = useState(null); + const [workingDirectory, setWorkingDirectory] = useState(""); - useEffect(() => { - const models = modelsQuery.data; - const firstModel = models?.[0]; - if (modelId || !firstModel) return; - const defaultModelId = workspaceQuery.data?.defaultModelId; - const preferred = - defaultModelId && models.some((model) => model.id === defaultModelId) - ? defaultModelId - : firstModel.id; - setModelId(preferred); - }, [modelId, modelsQuery.data, workspaceQuery.data]); + useEffect(() => { + const models = modelsQuery.data; + const firstModel = models?.[0]; + if (modelId || !firstModel) return; + const defaultModelId = workspaceQuery.data?.defaultModelId; + const preferred = + defaultModelId && models.some((model) => model.id === defaultModelId) + ? defaultModelId + : firstModel.id; + setModelId(preferred); + }, [modelId, modelsQuery.data, workspaceQuery.data]); - useEffect(() => { - if (!workingDirectory && defaultWorkingDirectory) { - setWorkingDirectory(defaultWorkingDirectory); - } - }, [workingDirectory, defaultWorkingDirectory]); + useEffect(() => { + if (!workingDirectory && defaultWorkingDirectory) { + setWorkingDirectory(defaultWorkingDirectory); + } + }, [workingDirectory, defaultWorkingDirectory]); - const createChat = useMutation({ - mutationFn: async (vars: { text: string; file: AttachedFile | null }) => { - if (!workspaceId) throw new Error("Installation is incomplete."); - if (!modelId) throw new Error("No model selected."); - if (!workingDirectory.trim()) - throw new Error("Choose a working directory."); - if (!vars.text.trim() && !vars.file) { - throw new Error("Add a message or attach a file."); - } + const createChat = useMutation({ + mutationFn: async (vars: { text: string; file: AttachedFile | null }) => { + if (!workspaceId) throw new Error("Installation is incomplete."); + if (!modelId) throw new Error("No model selected."); + if (!workingDirectory.trim()) throw new Error("Choose a working directory."); + if (!vars.text.trim() && !vars.file) { + throw new Error("Add a message or attach a file."); + } - // A plain chat (toolSelection === null) gets the workspace's default - // agent β€” every skill, every enabled MCP server β€” provisioned - // automatically server-side (see chats.create / auto-agent.ts). Only - // when the toolbar was actually touched do we create a narrower, - // one-off agent here and pin the chat to it. - let agentId: string | undefined; - if (toolSelection) { - const agent = await trpcClient.agents.create.mutate({ - workspaceId, - name: "Chat β€” custom tools", - modelId, - autonomyLevel: "assisted", - skillIds: toolSelection.skillIds, - toolIds: toolSelection.toolIds, - mcpServerIds: toolSelection.mcpServerIds, - mcpToolFilter: toolSelection.mcpToolFilter, - autoAttachWorkspaceTools: false, - }); - agentId = agent.id; - } + // A plain chat (toolSelection === null) gets the workspace's default + // agent β€” every skill, every enabled MCP server β€” provisioned + // automatically server-side (see chats.create / auto-agent.ts). Only + // when the toolbar was actually touched do we create a narrower, + // one-off agent here and pin the chat to it. + let agentId: string | undefined; + if (toolSelection) { + const agent = await trpcClient.agents.create.mutate({ + workspaceId, + name: "Chat β€” custom tools", + modelId, + autonomyLevel: "assisted", + skillIds: toolSelection.skillIds, + toolIds: toolSelection.toolIds, + mcpServerIds: toolSelection.mcpServerIds, + mcpToolFilter: toolSelection.mcpToolFilter, + autoAttachWorkspaceTools: false, + }); + agentId = agent.id; + } - const outgoing = vars.file - ? serializeChatMessageContent(vars.text.trim(), [vars.file]) - : vars.text.trim(); + const outgoing = vars.file + ? serializeChatMessageContent(vars.text.trim(), [vars.file]) + : vars.text.trim(); - const chat = await trpcClient.chats.create.mutate({ - workspaceId, - workingDirectory, - title: vars.text.trim().slice(0, 60) || vars.file?.name || "New chat", - modelId, - agentId, - projectId, - toolMode: toolMode ?? undefined, - }); - return { chat, outgoing }; - }, - onSuccess: ({ chat, outgoing }) => { - sessionStorage.setItem(`nyxel:chat-draft:${chat.id}`, outgoing); - router.push(`/chat/${chat.id}`); - }, - }); + const chat = await trpcClient.chats.create.mutate({ + workspaceId, + workingDirectory, + title: vars.text.trim().slice(0, 60) || vars.file?.name || "New chat", + modelId, + agentId, + projectId, + toolMode: toolMode ?? undefined, + }); + return { chat, outgoing }; + }, + onSuccess: ({ chat, outgoing }) => { + sessionStorage.setItem(`nyxel:chat-draft:${chat.id}`, outgoing); + router.push(`/chat/${chat.id}`); + }, + }); - function handleSubmit(e: React.FormEvent) { - e.preventDefault(); - if ((!message.trim() && !attachedFile) || !modelId || createChat.isPending) - return; - createChat.mutate({ text: message, file: attachedFile }); - } + function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + if ((!message.trim() && !attachedFile) || !modelId || createChat.isPending) return; + createChat.mutate({ text: message, file: attachedFile }); + } - const name = ownerQuery.data?.name?.split(" ")[0]; - const effectiveToolMode = - toolMode ?? workspaceQuery.data?.defaultToolPolicy.mode ?? "default"; + const name = ownerQuery.data?.name?.split(" ")[0]; + const effectiveToolMode = toolMode ?? workspaceQuery.data?.defaultToolPolicy.mode ?? "default"; - return ( -
-
- -
+ return ( +
+
+ +
-
-
- -

- {getGreeting()} - {name ? `, ${name}` : ""} -

-

- How can I{" "} - - help you today? - -

-
+
+
+ +

+ {getGreeting()} + {name ? `, ${name}` : ""} +

+

+ How can I{" "} + + help you today? + +

+
-
-
-