From 4497920d0d6aa588a862749ccaba7a0e10b2f2ac Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 2 Jul 2026 09:06:59 +0000 Subject: [PATCH 1/3] ci: add CI pipeline, publish create-nyxel setup CLI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add GitHub Actions workflows for pull-request checks (lint, typecheck, build, conventional-commit PR titles, CodeQL, dependency review), Docker image builds/pushes to GHCR for apps/server and apps/web, and build + npm publish of a new create-nyxel package, plus Dependabot config. create-nyxel is a new npx/bunx setup CLI (packages/create-nyxel) that writes only a docker-compose.yml, .env, and (server mode) Caddyfile pointed at the published GHCR images β€” no repository checkout or source build required. Cloning the repo remains the documented path for development. Also wraps the two pages using useSearchParams() in a Suspense boundary so `next build` succeeds, which the new build/Docker workflows depend on. --- .github/dependabot.yml | 24 ++ .github/workflows/ci.yml | 53 +++++ .github/workflows/codeql.yml | 33 +++ .github/workflows/dependency-review.yml | 19 ++ .github/workflows/docker.yml | 81 +++++++ .github/workflows/package.yml | 74 ++++++ .github/workflows/pr-title.yml | 31 +++ README.md | 25 +- apps/web/src/app/chat/page.tsx | 10 +- apps/web/src/app/mcp-auth/callback/page.tsx | 10 +- biome.json | 3 +- bun.lock | 13 ++ docs/INSTALL.md | 15 +- packages/create-nyxel/README.md | 49 ++++ packages/create-nyxel/package.json | 41 ++++ packages/create-nyxel/src/index.ts | 242 ++++++++++++++++++++ packages/create-nyxel/src/templates.ts | 171 ++++++++++++++ packages/create-nyxel/tsconfig.json | 22 ++ 18 files changed, 910 insertions(+), 6 deletions(-) create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/codeql.yml create mode 100644 .github/workflows/dependency-review.yml create mode 100644 .github/workflows/docker.yml create mode 100644 .github/workflows/package.yml create mode 100644 .github/workflows/pr-title.yml create mode 100644 packages/create-nyxel/README.md create mode 100644 packages/create-nyxel/package.json create mode 100644 packages/create-nyxel/src/index.ts create mode 100644 packages/create-nyxel/src/templates.ts create mode 100644 packages/create-nyxel/tsconfig.json 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..9f5e2c2 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,53 @@ +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 (vcs.defaultBranch in + # biome.json), rather than the whole repo, so this gate doesn't block + # on pre-existing lint debt outside the change under review. + - run: bunx biome ci --changed --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..8166822 --- /dev/null +++ b/.github/workflows/dependency-review.yml @@ -0,0 +1,19 @@ +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 + 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..085ef56 100644 --- a/apps/web/src/app/chat/page.tsx +++ b/apps/web/src/app/chat/page.tsx @@ -3,7 +3,7 @@ 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, @@ -62,6 +62,14 @@ function GreetingOrb() { } export default function ChatLandingPage() { + return ( + + + + ); +} + +function ChatLandingPageContent() { const router = useRouter(); const searchParams = useSearchParams(); const installationQuery = useInstallation(); diff --git a/apps/web/src/app/mcp-auth/callback/page.tsx b/apps/web/src/app/mcp-auth/callback/page.tsx index 092f722..03fcac0 100644 --- a/apps/web/src/app/mcp-auth/callback/page.tsx +++ b/apps/web/src/app/mcp-auth/callback/page.tsx @@ -2,7 +2,7 @@ import { CheckCircle2, Loader2, XCircle } from "lucide-react"; import { useSearchParams } from "next/navigation"; -import { useEffect, useState } from "react"; +import { Suspense, useEffect, useState } from "react"; import { SystemPanel, SystemScreen } from "@/components/system-screen"; import { Button } from "@/components/ui/button"; import { trpcClient } from "@/lib/trpc"; @@ -28,6 +28,14 @@ const DETAIL: Record = { const pendingAuthExchanges = new Map>(); export default function McpAuthCallbackPage() { + return ( + + + + ); +} + +function McpAuthCallbackContent() { const searchParams = useSearchParams(); const code = searchParams.get("code"); const error = searchParams.get("error"); diff --git a/biome.json b/biome.json index 6b2ad8d..f8e3a7f 100644 --- a/biome.json +++ b/biome.json @@ -3,7 +3,8 @@ "vcs": { "enabled": true, "clientKind": "git", - "useIgnoreFile": true + "useIgnoreFile": true, + "defaultBranch": "main" }, "files": { "ignoreUnknown": true, diff --git a/bun.lock b/bun.lock index 359e959..277610b 100644 --- a/bun.lock +++ b/bun.lock @@ -87,6 +87,17 @@ "typescript": "^5.9.0", }, }, + "packages/create-nyxel": { + "name": "create-nyxel", + "version": "0.1.0", + "bin": { + "create-nyxel": "./dist/index.js", + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.9.0", + }, + }, "packages/db": { "name": "@nyxel/db", "version": "0.1.0", @@ -771,6 +782,8 @@ "cosmiconfig": ["cosmiconfig@9.0.2", "", { "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", "js-yaml": "^4.1.0", "parse-json": "^5.2.0" }, "peerDependencies": { "typescript": ">=4.9.5" }, "optionalPeers": ["typescript"] }, "sha512-gtTZxTDau1wL7Y7zifc2dd8jHSK/k6BTx/2Xp/BpdlAdnlYWFVt7qhJqgwi7637yRwRQ3qL4ZidbB4I8tA5VOg=="], + "create-nyxel": ["create-nyxel@workspace:packages/create-nyxel"], + "cron-parser": ["cron-parser@5.6.1", "", { "dependencies": { "luxon": "^3.7.2" } }, "sha512-QBm4o1PwZiuY7KFbVvW7FLC8bozy7YWzv+Fz6KRS7sQghzcbDZCGxr/Bc5b6TQreAoSwuWVP491dIcK0THCX6A=="], "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], diff --git a/docs/INSTALL.md b/docs/INSTALL.md index 93595f6..f0a4d4e 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -5,10 +5,23 @@ This project ships two self-hosting modes: - `PC mode`: SQLite, direct localhost ports, optimized for one person on one machine. - `Server mode`: PostgreSQL, HTTPS, and Caddy for a shared or remotely reachable deployment. +## Quick start (no checkout) + +```bash +npx create-nyxel # or: bunx create-nyxel +cd nyxel +docker compose up -d +``` + +This writes the same compose files described below, pointed at the published +`ghcr.io/quavon-dev/nyxelos-server` / `nyxelos-web` images β€” no repository +checkout required. The rest of this guide covers building the images from +source instead, which the CLI does for you automatically. + ## Prerequisites - Docker Engine with the Compose plugin -- A checked-out copy of this repository +- A checked-out copy of this repository (skip this if you used `create-nyxel` above) - One strong `BETTER_AUTH_SECRET` - For server mode: a DNS record pointing `NYXEL_DOMAIN` at the host diff --git a/packages/create-nyxel/README.md b/packages/create-nyxel/README.md new file mode 100644 index 0000000..db84eac --- /dev/null +++ b/packages/create-nyxel/README.md @@ -0,0 +1,49 @@ +# create-nyxel + +Set up [NyxelOS](https://github.com/Quavon-dev/nyxelos) for **use** β€” no +source checkout, no build toolchain, just Docker. + +```bash +npx create-nyxel +# or +bunx create-nyxel +``` + +This writes a `docker-compose.yml`, `.env`, and (server mode) a `Caddyfile` +into a target directory. Those files reference the prebuilt +`ghcr.io/quavon-dev/nyxelos-server` and `ghcr.io/quavon-dev/nyxelos-web` +images β€” nothing is compiled locally and no application source is +installed. + +```bash +cd nyxel +docker compose up -d +``` + +## Options + +``` +--mode Deployment mode (skips the interactive prompt) +--dir Directory to write into (default: ./nyxel) +--tag Image tag to deploy (default: latest) +--domain Server mode only β€” public domain for TLS (Caddy) +--acme-email Server mode only β€” ACME account email for Caddy +-y, --yes Accept defaults, skip interactive prompts +-v, --version Print the CLI version +-h, --help Show help +``` + +## Developing NyxelOS instead? + +This package is for running NyxelOS, not hacking on it. To contribute or +run it from source, clone the repository instead: + +```bash +git clone https://github.com/Quavon-dev/nyxelos.git +cd nyxelos +bun install +bun dev +``` + +See the [main README](https://github.com/Quavon-dev/nyxelos#readme) for +full development setup. diff --git a/packages/create-nyxel/package.json b/packages/create-nyxel/package.json new file mode 100644 index 0000000..de5a31b --- /dev/null +++ b/packages/create-nyxel/package.json @@ -0,0 +1,41 @@ +{ + "name": "create-nyxel", + "version": "0.1.0", + "description": "Set up NyxelOS for usage (Docker Compose + published images) β€” no source checkout required.", + "license": "MIT", + "type": "module", + "bin": { + "create-nyxel": "./dist/index.js" + }, + "files": [ + "dist" + ], + "engines": { + "node": ">=18" + }, + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/Quavon-dev/nyxelos.git", + "directory": "packages/create-nyxel" + }, + "keywords": [ + "nyxel", + "nyxelos", + "create-nyxel", + "setup", + "docker-compose", + "agentic-os" + ], + "scripts": { + "build": "tsc -p tsconfig.json", + "typecheck": "tsc --noEmit", + "prepublishOnly": "bun run build" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.9.0" + } +} diff --git a/packages/create-nyxel/src/index.ts b/packages/create-nyxel/src/index.ts new file mode 100644 index 0000000..685d06f --- /dev/null +++ b/packages/create-nyxel/src/index.ts @@ -0,0 +1,242 @@ +#!/usr/bin/env node +// create-nyxel β€” sets up NyxelOS *for use*, via the published Docker images. +// +// This is intentionally not the developer workflow. Building Nyxel from +// source (`git clone` + `bun install` + `bun dev`) is documented in the +// project README for contributors. This CLI writes only the handful of +// files a Docker Compose deployment needs (compose file, .env, Caddyfile) +// and never touches application source. +import { randomBytes } from "node:crypto"; +import { mkdir, readdir, writeFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { stdin as input, stdout as output } from "node:process"; +import { createInterface } from "node:readline/promises"; +import { + CADDYFILE, + type RenderOptions, + renderPcCompose, + renderPcEnv, + renderServerCompose, + renderServerEnv, +} from "./templates.js"; + +const DEFAULT_REGISTRY = "ghcr.io/quavon-dev"; +const VERSION = "0.1.0"; + +interface Options { + mode: "pc" | "server" | null; + dir: string; + tag: string; + domain: string; + acmeEmail: string; + yes: boolean; + help: boolean; + version: boolean; +} + +function parseArgs(argv: string[]): Options { + const opts: Options = { + mode: null, + dir: "nyxel", + tag: "latest", + domain: "nyxel.example.com", + acmeEmail: "", + yes: false, + help: false, + version: false, + }; + + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (arg === undefined) continue; + const [flag, inlineValue] = splitFlag(arg); + const next = () => inlineValue ?? argv[++i]; + + switch (flag) { + case "-h": + case "--help": + opts.help = true; + break; + case "-v": + case "--version": + opts.version = true; + break; + case "-y": + case "--yes": + opts.yes = true; + break; + case "--mode": { + const value = next(); + if (value !== "pc" && value !== "server") { + throw new Error(`--mode must be "pc" or "server", got "${value}"`); + } + opts.mode = value; + break; + } + case "--dir": + opts.dir = next() ?? opts.dir; + break; + case "--tag": + opts.tag = next() ?? opts.tag; + break; + case "--domain": + opts.domain = next() ?? opts.domain; + break; + case "--acme-email": + opts.acmeEmail = next() ?? opts.acmeEmail; + break; + default: + if (flag.startsWith("-")) { + throw new Error(`Unknown option: ${flag}`); + } + } + } + + return opts; +} + +function splitFlag(arg: string): [string, string | undefined] { + const eq = arg.indexOf("="); + if (arg.startsWith("--") && eq !== -1) { + return [arg.slice(0, eq), arg.slice(eq + 1)]; + } + return [arg, undefined]; +} + +function printHelp() { + console.log(`create-nyxel β€” set up NyxelOS for use (no source checkout required) + +Usage: + npx create-nyxel [options] + bunx create-nyxel [options] + +Options: + --mode Deployment mode (skips the interactive prompt) + --dir Directory to write into (default: ./nyxel) + --tag Image tag to deploy (default: latest) + --domain Server mode only β€” public domain for TLS (Caddy) + --acme-email Server mode only β€” ACME account email for Caddy + -y, --yes Accept defaults, skip interactive prompts + -v, --version Print the CLI version + -h, --help Show this help + +This only writes a docker-compose.yml, .env, and (server mode) a Caddyfile +that pull prebuilt images from ${DEFAULT_REGISTRY}. It does not clone the +NyxelOS repository or install any application source. + +Want to develop NyxelOS instead? See: + https://github.com/Quavon-dev/nyxelos#-getting-started--deployment-modes +`); +} + +async function prompt(rl: ReturnType, question: string, fallback: string) { + const answer = (await rl.question(question)).trim(); + return answer === "" ? fallback : answer; +} + +async function resolveOptionsInteractively(opts: Options): Promise { + if (opts.yes) { + return { ...opts, mode: opts.mode ?? "pc" }; + } + + const rl = createInterface({ input, output }); + try { + let mode = opts.mode; + if (!mode) { + const answer = await prompt( + rl, + "Deployment mode β€” pc (single machine, SQLite) or server (own domain, Postgres + TLS)? [pc]: ", + "pc", + ); + mode = answer.toLowerCase().startsWith("s") ? "server" : "pc"; + } + + const dir = await prompt(rl, `Directory to set up in? [${opts.dir}]: `, opts.dir); + + let domain = opts.domain; + let acmeEmail = opts.acmeEmail; + if (mode === "server") { + domain = await prompt( + rl, + `Public domain (Caddy will request TLS for it)? [${domain}]: `, + domain, + ); + acmeEmail = await prompt(rl, "ACME account email (optional): ", acmeEmail); + } + + return { ...opts, mode, dir, domain, acmeEmail }; + } finally { + rl.close(); + } +} + +async function ensureEmptyDir(dir: string) { + await mkdir(dir, { recursive: true }); + const entries = await readdir(dir); + if (entries.length > 0) { + throw new Error( + `"${dir}" is not empty. Choose an empty directory with --dir, or remove its contents first.`, + ); + } +} + +async function main() { + const opts = parseArgs(process.argv.slice(2)); + + if (opts.help) { + printHelp(); + return; + } + if (opts.version) { + console.log(VERSION); + return; + } + + const resolved = await resolveOptionsInteractively(opts); + const mode = resolved.mode ?? "pc"; + const dir = resolve(process.cwd(), resolved.dir); + + await ensureEmptyDir(dir); + + const renderOpts: RenderOptions = { + serverImage: `${DEFAULT_REGISTRY}/nyxelos-server:${resolved.tag}`, + webImage: `${DEFAULT_REGISTRY}/nyxelos-web:${resolved.tag}`, + betterAuthSecret: randomBytes(32).toString("hex"), + domain: resolved.domain, + postgresPassword: randomBytes(16).toString("hex"), + acmeEmail: resolved.acmeEmail, + }; + + const files: Array<[string, string]> = [ + [ + "docker-compose.yml", + mode === "pc" ? renderPcCompose(renderOpts) : renderServerCompose(renderOpts), + ], + [".env", mode === "pc" ? renderPcEnv(renderOpts) : renderServerEnv(renderOpts)], + ]; + if (mode === "server") { + files.push(["Caddyfile", CADDYFILE]); + } + + for (const [name, contents] of files) { + await writeFile(resolve(dir, name), contents, "utf8"); + } + + console.log(`\nNyxelOS is ready to run in ${mode} mode at ${dir}\n`); + console.log("Next steps:"); + console.log(` cd ${resolved.dir}`); + if (mode === "server") { + console.log(" # review .env (NYXEL_DOMAIN, POSTGRES_PASSWORD, ACME_EMAIL)"); + } + console.log(" docker compose up -d"); + console.log( + mode === "pc" + ? " # then open http://localhost:3000\n" + : ` # then open https://${resolved.domain}\n`, + ); +} + +main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; +}); diff --git a/packages/create-nyxel/src/templates.ts b/packages/create-nyxel/src/templates.ts new file mode 100644 index 0000000..82f20dc --- /dev/null +++ b/packages/create-nyxel/src/templates.ts @@ -0,0 +1,171 @@ +export interface RenderOptions { + serverImage: string; + webImage: string; + betterAuthSecret: string; + domain: string; + postgresPassword: string; + acmeEmail: string; +} + +export function renderPcEnv(opts: RenderOptions): string { + return `# Generated by create-nyxel β€” PC mode. +# Reference: docker-compose.yml in this directory. + +BETTER_AUTH_SECRET=${opts.betterAuthSecret} + +# Optional β€” enables Anthropic cloud models in the model picker. +ANTHROPIC_API_KEY= +`; +} + +export function renderServerEnv(opts: RenderOptions): string { + return `# Generated by create-nyxel β€” Server mode. +# Reference: docker-compose.yml in this directory. + +# The domain Caddy requests a TLS certificate for. +NYXEL_DOMAIN=${opts.domain} + +# Email address used for ACME account registration / certificate management. +ACME_EMAIL=${opts.acmeEmail} + +# Postgres password (also used inside DATABASE_URL). +POSTGRES_PASSWORD=${opts.postgresPassword} + +# Used to sign Better-Auth session tokens. +BETTER_AUTH_SECRET=${opts.betterAuthSecret} + +# Optional β€” enables Anthropic cloud models in the model picker. +ANTHROPIC_API_KEY= +`; +} + +export function renderPcCompose(opts: RenderOptions): string { + return `# Generated by create-nyxel β€” PC mode. +# Uses the published Nyxel images; no source checkout required. +# Usage: docker compose up -d + +services: + server: + image: ${opts.serverImage} + extra_hosts: + - "host.docker.internal:host-gateway" + environment: + NYXEL_MODE: pc + DB_DRIVER: sqlite + DATABASE_URL: /data/nyxel.sqlite + BETTER_AUTH_SECRET: \${BETTER_AUTH_SECRET:?set BETTER_AUTH_SECRET in .env} + BETTER_AUTH_URL: http://localhost:3001 + WEB_ORIGIN: http://localhost:3000 + PUBLIC_APP_URL: http://localhost:3000 + OLLAMA_BASE_URL: http://host.docker.internal:11434 + LMSTUDIO_BASE_URL: http://host.docker.internal:1234 + ANTHROPIC_API_KEY: \${ANTHROPIC_API_KEY:-} + volumes: + - nyxel-data:/data + ports: + - "3001:3001" + restart: unless-stopped + + web: + image: ${opts.webImage} + environment: + NEXT_PUBLIC_SERVER_URL: http://localhost:3001 + ports: + - "3000:3000" + depends_on: + - server + restart: unless-stopped + +volumes: + nyxel-data: +`; +} + +export function renderServerCompose(opts: RenderOptions): string { + return `# Generated by create-nyxel β€” Server mode. +# Uses the published Nyxel images; no source checkout required. +# Usage: docker compose up -d + +services: + postgres: + image: postgres:17-alpine + environment: + POSTGRES_USER: nyxel + POSTGRES_PASSWORD: \${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env} + POSTGRES_DB: nyxel + volumes: + - nyxel-postgres:/var/lib/postgresql/data + restart: unless-stopped + + server: + image: ${opts.serverImage} + environment: + NYXEL_MODE: server + DB_DRIVER: pg + DATABASE_URL: postgres://nyxel:\${POSTGRES_PASSWORD}@postgres:5432/nyxel + BETTER_AUTH_SECRET: \${BETTER_AUTH_SECRET:?set BETTER_AUTH_SECRET in .env} + BETTER_AUTH_URL: https://\${NYXEL_DOMAIN} + WEB_ORIGIN: https://\${NYXEL_DOMAIN} + PUBLIC_APP_URL: https://\${NYXEL_DOMAIN} + NYXEL_DOMAIN: \${NYXEL_DOMAIN:?set NYXEL_DOMAIN in .env} + ANTHROPIC_API_KEY: \${ANTHROPIC_API_KEY:-} + depends_on: + - postgres + restart: unless-stopped + + web: + image: ${opts.webImage} + environment: + NEXT_PUBLIC_SERVER_URL: https://\${NYXEL_DOMAIN} + depends_on: + - server + restart: unless-stopped + + caddy: + image: caddy:2-alpine + ports: + - "80:80" + - "443:443" + environment: + NYXEL_DOMAIN: \${NYXEL_DOMAIN:?set NYXEL_DOMAIN in .env} + ACME_EMAIL: \${ACME_EMAIL:-} + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile + - nyxel-caddy-data:/data + - nyxel-caddy-config:/config + depends_on: + - server + - web + restart: unless-stopped + +volumes: + nyxel-postgres: + nyxel-caddy-data: + nyxel-caddy-config: +`; +} + +export const CADDYFILE = `{$NYXEL_DOMAIN:localhost} { + encode zstd gzip + + header { + X-Content-Type-Options nosniff + Referrer-Policy strict-origin-when-cross-origin + X-Frame-Options SAMEORIGIN + } + + @health path /healthz + handle @health { + respond "ok" 200 + } + + @api path /trpc/* /api/* + handle @api { + reverse_proxy server:3001 + } + + handle { + reverse_proxy web:3000 + } +} +`; diff --git a/packages/create-nyxel/tsconfig.json b/packages/create-nyxel/tsconfig.json new file mode 100644 index 0000000..7e0deff --- /dev/null +++ b/packages/create-nyxel/tsconfig.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "skipLibCheck": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "isolatedModules": true, + "declaration": false, + "sourceMap": false, + "noEmit": false, + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} From 566437ed447430929d6140d03f5d64365a0be2ee Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 2 Jul 2026 09:08:03 +0000 Subject: [PATCH 2/3] style: reformat touched pages to match biome config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit apps/web/src/app/chat/page.tsx and apps/web/src/app/mcp-auth/callback/page.tsx predated biome's space-indent config and used tabs; reformat them (via `biome check --write`) now that the new lint CI job (biome ci --changed) checks files touched by a change. No behavioral change β€” build and typecheck still pass. --- apps/web/src/app/chat/page.tsx | 480 ++++++++++---------- apps/web/src/app/mcp-auth/callback/page.tsx | 5 +- 2 files changed, 235 insertions(+), 250 deletions(-) diff --git a/apps/web/src/app/chat/page.tsx b/apps/web/src/app/chat/page.tsx index 085ef56..76f6454 100644 --- a/apps/web/src/app/chat/page.tsx +++ b/apps/web/src/app/chat/page.tsx @@ -5,9 +5,9 @@ import { ArrowUp, Code2, FileText, Palette, Search } from "lucide-react"; import { useRouter, useSearchParams } from "next/navigation"; 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,262 +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() { - return ( - - - - ); + 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"); + 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? + +

+
-
-
-