From a5827d66023c8fa382a400416e04b7e634f0c1bb Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 18 May 2026 23:44:26 +0000 Subject: [PATCH 1/4] feat(vscode-cloud): fix internet access, type safety, and improve UX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Set `enableInternet = true` on CodeServerContainer so the container can reach the R2 S3-compat HTTPS endpoint (for geesefs FUSE mount) and github.com for repo cloning — this was blocking both features - Fix `onStop` signature to use `StopParams` from @cloudflare/containers instead of a manually typed object with wider `reason: string` - Fix `DurableObjectState` cast to the correct `SqlStorage` type - Call `renewActivityTimeout()` directly instead of via duck-typing — the method is part of the public Container API - Guard `getConfig()` with try/catch so SQL SELECT errors on an uninitialised table return null rather than throwing - Add `/__internal/state` RPC on the DO to expose container state JSON - Add `/status` endpoint on the Worker returning userId + container state - Revamp first-visit setup page: copy button, idle timeout display, reset-password form, R2/GitHub info section - Add Google OAuth landing page at /auth/login (branded sign-in card with Google logo); /auth/login?direct= still redirects immediately - Redirect OAuth callback to /setup so users see their password before code-server prompts for it - Require GOOGLE_CLIENT_SECRET in authenticate() consistently with handleAuthRoutes() to prevent accidental dev-mode fallback - Document FUSE availability and internet access in wrangler.jsonc https://claude.ai/code/session_01VSbGCNLi5Ydg8ViQijJEzG --- apps/vscode-cloud/src/auth.ts | 60 ++++++++++++++- apps/vscode-cloud/src/index.ts | 127 +++++++++++++++++++++++-------- apps/vscode-cloud/wrangler.jsonc | 3 + 3 files changed, 153 insertions(+), 37 deletions(-) diff --git a/apps/vscode-cloud/src/auth.ts b/apps/vscode-cloud/src/auth.ts index 1dcfa67..5057edf 100644 --- a/apps/vscode-cloud/src/auth.ts +++ b/apps/vscode-cloud/src/auth.ts @@ -33,8 +33,59 @@ export async function handleAuthRoutes( ): Promise { if (!env.GOOGLE_CLIENT_ID || !env.GOOGLE_CLIENT_SECRET || !env.SESSION_STORE) return null; - // ── /auth/login — redirect to Google consent screen ────────────────────── - if (url.pathname === "/auth/login") { + // ── /auth/login landing page ────────────────────────────────────────────── + // Show a branded sign-in page rather than jumping straight to Google so + // users know which app they are authenticating into. + if (url.pathname === "/auth/login" && request.method === "GET" && !url.searchParams.has("direct")) { + const params = new URLSearchParams({ + client_id: env.GOOGLE_CLIENT_ID, + redirect_uri: `${url.origin}/auth/callback`, + response_type: "code", + scope: "openid email profile", + prompt: "select_account", + }); + const googleUrl = `https://accounts.google.com/o/oauth2/v2/auth?${params}`; + const html = ` + + + + + Sign in — code-server + + + +
+

Sign in to code-server

+

Your personal cloud IDE — one container, fully isolated.

+ + + Sign in with Google + +
+ +`; + return new Response(html, { headers: { "Content-Type": "text/html; charset=utf-8" } }); + } + + // ── /auth/login?direct — skip landing page, redirect straight to Google ── + if (url.pathname === "/auth/login" && url.searchParams.has("direct")) { const params = new URLSearchParams({ client_id: env.GOOGLE_CLIENT_ID, redirect_uri: `${url.origin}/auth/callback`, @@ -87,10 +138,11 @@ export async function handleAuthRoutes( { expirationTtl: ttl } ); + // Redirect to /setup so users see their password before code-server prompts for it. return new Response(null, { status: 302, headers: { - Location: "/", + Location: "/setup", "Set-Cookie": `__session=${sessionId}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${ttl}`, }, }); @@ -128,7 +180,7 @@ export async function authenticate( return authenticateViaAccess(request, env); } - if (env.GOOGLE_CLIENT_ID && env.SESSION_STORE) { + if (env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET && env.SESSION_STORE) { return authenticateViaGoogleSession(request, env); } diff --git a/apps/vscode-cloud/src/index.ts b/apps/vscode-cloud/src/index.ts index 3e7b3e7..f9c6343 100644 --- a/apps/vscode-cloud/src/index.ts +++ b/apps/vscode-cloud/src/index.ts @@ -1,4 +1,5 @@ import { Container } from "@cloudflare/containers"; +import type { StopParams } from "@cloudflare/containers"; import { authenticate, handleAuthRoutes } from "./auth"; // ─── Env ───────────────────────────────────────────────────────────────────── @@ -56,9 +57,11 @@ export interface Env { */ export class CodeServerContainer extends Container { defaultPort = 8080; + // Containers need internet to reach R2's HTTPS endpoint and github.com for cloning. + enableInternet = true; - constructor(ctx: DurableObjectState, env: Env) { - super(ctx as DurableObjectState<{}>, env); + constructor(ctx: DurableObjectState, env: Env) { + super(ctx, env); this.sleepAfter = env.SLEEP_AFTER ?? "30m"; } @@ -74,13 +77,18 @@ export class CodeServerContainer extends Container { } private getConfig(key: string): string | null { - const rows = [ - ...this.ctx.storage.sql.exec( - "SELECT value FROM user_config WHERE key = ?", - key - ), - ]; - return rows.length > 0 ? (rows[0].value as string) : null; + try { + const rows = [ + ...this.ctx.storage.sql.exec( + "SELECT value FROM user_config WHERE key = ?", + key + ), + ]; + return rows.length > 0 ? (rows[0].value as string) : null; + } catch { + // Table doesn't exist yet — return null so callers fall back to defaults. + return null; + } } private setConfig(key: string, value: string): void { @@ -119,7 +127,7 @@ export class CodeServerContainer extends Container { console.log(`[code-server] Container started — DO id: ${this.ctx.id}`); } - override onStop(_: { exitCode: number; reason: string }): void { + override onStop(_: StopParams): void { console.log(`[code-server] Container stopped — DO id: ${this.ctx.id}`); } @@ -153,10 +161,16 @@ export class CodeServerContainer extends Container { return Response.json({ ok: true }); } + // ── Internal RPC: container health / state ──────────────────────────────── + if (url.pathname === "/__internal/state") { + const state = await this.getState(); + return Response.json(state); + } + // ── Proxy to code-server ────────────────────────────────────────────────── // Renew idle timer on every proxied request so an active browser session - // keeps the container alive even if wrangler's sleepAfter would fire. - (this as unknown as { renewActivityTimeout?: () => void }).renewActivityTimeout?.(); + // keeps the container alive even if sleepAfter would fire. + this.renewActivityTimeout(); const userId = this.getConfig("user_id") ?? "default"; const password = this.getOrCreatePassword(); @@ -178,47 +192,84 @@ export class CodeServerContainer extends Container { // ─── Worker entry point ─────────────────────────────────────────────────────── -function firstVisitPage(email: string, password: string): Response { +function setupPage(email: string, password: string, sleepAfter: string): Response { const html = ` - code-server — your password + code-server — setup
Sign out -

Your cloud IDE is starting

-

Signed in as ${email}

+

Your cloud IDE is ready starting…

+

Signed in as ${email}

+
Your unique password
-
${password}
+
+ ${password} + +

- This password is unique to you and stored securely — it never changes unless - you request a reset at /reset-password. - Copy it before clicking below. + This password is unique to you — it never changes unless you request a reset. + The IDE sleeps after ${sleepAfter} of inactivity to save cost + and wakes automatically on your next visit. +

+ +
+ + Copy & open IDE → + +
+ +
+
+ +
+

+ GitHub repos configured for auto-clone are loaded into + /home/coder/workspace/ on first boot. Workspace changes sync + automatically to R2 via FUSE.

- - Copy & open code-server → -
+ `; return new Response(html, { @@ -252,6 +303,7 @@ export default { }; // Register userId in the DO's SQLite so it can build the R2 prefix. + // This is idempotent and cheap (a single SQL upsert). await stub.fetch( new Request("http://internal/__internal/init", { method: "POST", @@ -260,13 +312,13 @@ export default { }) ); - // ── /setup — first-visit password reveal page ──────────────────────────── + // ── /setup — password reveal + first-visit instructions ────────────────── if (url.pathname === "/setup") { const pwResp = await stub.fetch( new Request("http://internal/__internal/password") ); const { password } = (await pwResp.json()) as { password: string }; - return firstVisitPage(email, password); + return setupPage(email, password, env.SLEEP_AFTER ?? "30m"); } // ── /reset-password — regenerate password (POST only) ─────────────────── @@ -279,6 +331,15 @@ export default { return Response.redirect(new URL("/setup", url).toString(), 303); } + // ── /status — container state (JSON) ──────────────────────────────────── + if (url.pathname === "/status") { + const stateResp = await stub.fetch( + new Request("http://internal/__internal/state") + ); + const state = await stateResp.json(); + return Response.json({ userId, email, container: state }); + } + // ── Proxy everything else to code-server ───────────────────────────────── return stub.fetch(request); }, diff --git a/apps/vscode-cloud/wrangler.jsonc b/apps/vscode-cloud/wrangler.jsonc index eca9e55..46a7829 100644 --- a/apps/vscode-cloud/wrangler.jsonc +++ b/apps/vscode-cloud/wrangler.jsonc @@ -6,6 +6,9 @@ "compatibility_flags": ["nodejs_compat"], // ── Container definition ────────────────────────────────────────────────── + // FUSE (/dev/fuse) is available in Cloudflare Containers by default since 2025-11-21. + // Internet access is enabled at the class level via `enableInternet = true` so the + // container can reach the R2 S3-compat endpoint and github.com. "containers": [ { "class_name": "CodeServerContainer", From 5765f806961e32fd11e9fcab9e58e78e57835c61 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 19 May 2026 00:18:27 +0000 Subject: [PATCH 2/4] feat(vscode-cloud): passwordless login, landing page, and pre-installed AI/dev extensions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Auth - Switch code-server from --auth password to --auth none; the Cloudflare Worker (CF Access JWT or Google OAuth session) is the auth gatekeeper — the container is never directly reachable, so no password is needed - Remove all password generation, storage, and reset machinery from the Container DO and Worker (getOrCreatePassword, /setup, /reset-password, /__internal/password) - /auth/login now redirects straight to Google; the landing page explains the app before users sign in Landing page - Unauthenticated GET requests to any path (Google OAuth mode) now render a full landing page instead of bouncing to /auth/login - Six feature tiles: isolated container, R2 persistence, GitHub auto-clone, Claude Code + Codex CLI, pre-installed extensions, idle sleep billing - Responsive CSS-grid layout; adapts sign-in button to auth mode (Google / CF Access / dev mode) Dockerfile - Install python3, python3-pip, python3-venv for Python development - Install Claude Code CLI (@anthropic-ai/claude-code) globally - Install Codex CLI (@openai/codex) globally - Pre-install ten VS Code extensions at build time via Open VSX: GitLens, Prettier, ESLint, Python, Tailwind CSS IntelliSense, Path Intellisense, Code Runner, Material Icon Theme, Error Lens, Code Spell Checker (failures are non-fatal; IDE still launches without them) entrypoint.sh - Remove PASSWORD env var and --auth password flag - Richer default workspace settings.json: icon theme, font ligatures, word wrap, tab size, GitLens current-line blame, Error Lens levels, spell checker enabled https://claude.ai/code/session_01VSbGCNLi5Ydg8ViQijJEzG --- apps/vscode-cloud/Dockerfile | 37 +++- apps/vscode-cloud/entrypoint.sh | 29 ++- apps/vscode-cloud/src/auth.ts | 59 +----- apps/vscode-cloud/src/index.ts | 313 +++++++++++++++---------------- apps/vscode-cloud/wrangler.jsonc | 3 + 5 files changed, 215 insertions(+), 226 deletions(-) diff --git a/apps/vscode-cloud/Dockerfile b/apps/vscode-cloud/Dockerfile index 34717ea..e96805b 100644 --- a/apps/vscode-cloud/Dockerfile +++ b/apps/vscode-cloud/Dockerfile @@ -1,9 +1,8 @@ FROM codercom/code-server:ubuntu -# Run as root to install system packages and configure FUSE +# ── System packages (run as root) ───────────────────────────────────────────── USER root -# Install tools + FUSE3 support RUN apt-get update && apt-get install -y \ git \ curl \ @@ -11,26 +10,52 @@ RUN apt-get update && apt-get install -y \ vim \ build-essential \ fuse3 \ + python3 \ + python3-pip \ + python3-venv \ && rm -rf /var/lib/apt/lists/* -# Install geesefs — an S3/R2-compatible FUSE filesystem with local caching and live sync +# geesefs — S3/R2-compatible FUSE filesystem RUN curl -L https://github.com/yandex-cloud/geesefs/releases/latest/download/geesefs-linux-amd64 \ -o /usr/local/bin/geesefs && chmod +x /usr/local/bin/geesefs # Allow non-root users to mount FUSE filesystems RUN echo "user_allow_other" >> /etc/fuse.conf -# Install Node.js LTS +# Node.js LTS (used by code-server and the CLI tools below) RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && \ apt-get install -y nodejs && \ rm -rf /var/lib/apt/lists/* -# Create workspace mount point owned by coder +# ── AI coding CLIs ──────────────────────────────────────────────────────────── +# Installed globally so they are on PATH for every user and every terminal session. +RUN npm install -g \ + @anthropic-ai/claude-code \ + @openai/codex + +# Workspace mount point RUN mkdir -p /home/coder/workspace && chown -R coder:coder /home/coder -# Switch back to coder user +# ── VS Code extensions (pre-installed at build time) ───────────────────────── +# Installed from Open VSX Registry (code-server's default marketplace). +# Failures are non-fatal — the IDE still launches without them. USER coder +RUN for ext in \ + eamodio.gitlens \ + esbenp.prettier-vscode \ + dbaeumer.vscode-eslint \ + ms-python.python \ + bradlc.vscode-tailwindcss \ + christian-kohler.path-intellisense \ + formulahendry.code-runner \ + pkief.material-icon-theme \ + usernamehw.errorlens \ + streetsidesoftware.code-spell-checker; \ + do \ + code-server --install-extension "$ext" || echo "[docker] Warning: failed to install $ext"; \ + done + WORKDIR /home/coder COPY --chown=coder:coder entrypoint.sh /home/coder/entrypoint.sh diff --git a/apps/vscode-cloud/entrypoint.sh b/apps/vscode-cloud/entrypoint.sh index adc4e17..e0e145c 100644 --- a/apps/vscode-cloud/entrypoint.sh +++ b/apps/vscode-cloud/entrypoint.sh @@ -63,7 +63,8 @@ if [[ -n "$GITHUB_REPOS" ]]; then if [[ -d "$target/.git" ]]; then echo "[entrypoint] Pulling latest for ${repo}" - git -C "$target" pull --ff-only 2>/dev/null || echo "[entrypoint] Warning: pull failed for ${repo}, using cached copy" + git -C "$target" pull --ff-only 2>/dev/null \ + || echo "[entrypoint] Warning: pull failed for ${repo}, using cached copy" else echo "[entrypoint] Cloning ${repo}" git clone --depth=50 "https://github.com/${repo}.git" "$target" \ @@ -72,7 +73,7 @@ if [[ -n "$GITHUB_REPOS" ]]; then done fi -# ── VSCode workspace settings for FUSE performance ─────────────────────────── +# ── VSCode workspace settings ───────────────────────────────────────────────── # Written only once; users can override later without it being clobbered. VSCODE_DIR="${WORKSPACE}/.vscode" SETTINGS_FILE="${VSCODE_DIR}/settings.json" @@ -80,6 +81,16 @@ if [[ ! -f "$SETTINGS_FILE" ]]; then mkdir -p "$VSCODE_DIR" cat > "$SETTINGS_FILE" <<'SETTINGS' { + "workbench.iconTheme": "material-icon-theme", + "editor.formatOnSave": true, + "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.fontFamily": "'JetBrains Mono', 'Fira Code', monospace", + "editor.fontSize": 14, + "editor.fontLigatures": true, + "editor.tabSize": 2, + "editor.wordWrap": "on", + "terminal.integrated.defaultProfile.linux": "bash", + "terminal.integrated.fontSize": 13, "files.watcherExclude": { "**/.git/objects/**": true, "**/.git/subtree-cache/**": true, @@ -87,7 +98,8 @@ if [[ ! -f "$SETTINGS_FILE" ]]; then "**/__pycache__/**": true }, "files.exclude": { - "**/.git": true + "**/.git": true, + "**/__pycache__": true }, "search.followSymlinks": false, "search.exclude": { @@ -95,13 +107,16 @@ if [[ ! -f "$SETTINGS_FILE" ]]; then "**/.git": true, "**/__pycache__": true }, - "editor.formatOnSave": true, - "terminal.integrated.defaultProfile.linux": "bash" + "gitlens.currentLine.enabled": true, + "errorLens.enabledDiagnosticLevels": ["error", "warning"], + "cSpell.enabled": true } SETTINGS echo "[entrypoint] VSCode workspace settings written" fi # ── Start code-server ───────────────────────────────────────────────────────── -# PASSWORD is injected by the Durable Object via envVars before container start. -exec /usr/bin/entrypoint.sh --bind-addr 0.0.0.0:8080 --auth password "${WORKSPACE}" +# Auth is handled by the Cloudflare Worker (CF Access JWT or Google OAuth session). +# The container is only reachable through the authenticated Worker, so --auth none +# is safe — no password prompt is shown to users. +exec /usr/bin/entrypoint.sh --bind-addr 0.0.0.0:8080 --auth none "${WORKSPACE}" diff --git a/apps/vscode-cloud/src/auth.ts b/apps/vscode-cloud/src/auth.ts index 5057edf..94e9f56 100644 --- a/apps/vscode-cloud/src/auth.ts +++ b/apps/vscode-cloud/src/auth.ts @@ -33,59 +33,8 @@ export async function handleAuthRoutes( ): Promise { if (!env.GOOGLE_CLIENT_ID || !env.GOOGLE_CLIENT_SECRET || !env.SESSION_STORE) return null; - // ── /auth/login landing page ────────────────────────────────────────────── - // Show a branded sign-in page rather than jumping straight to Google so - // users know which app they are authenticating into. - if (url.pathname === "/auth/login" && request.method === "GET" && !url.searchParams.has("direct")) { - const params = new URLSearchParams({ - client_id: env.GOOGLE_CLIENT_ID, - redirect_uri: `${url.origin}/auth/callback`, - response_type: "code", - scope: "openid email profile", - prompt: "select_account", - }); - const googleUrl = `https://accounts.google.com/o/oauth2/v2/auth?${params}`; - const html = ` - - - - - Sign in — code-server - - - -
-

Sign in to code-server

-

Your personal cloud IDE — one container, fully isolated.

- - - Sign in with Google - -
- -`; - return new Response(html, { headers: { "Content-Type": "text/html; charset=utf-8" } }); - } - - // ── /auth/login?direct — skip landing page, redirect straight to Google ── - if (url.pathname === "/auth/login" && url.searchParams.has("direct")) { + // ── /auth/login — redirect to Google consent screen ───────────────────── + if (url.pathname === "/auth/login" && request.method === "GET") { const params = new URLSearchParams({ client_id: env.GOOGLE_CLIENT_ID, redirect_uri: `${url.origin}/auth/callback`, @@ -138,11 +87,11 @@ export async function handleAuthRoutes( { expirationTtl: ttl } ); - // Redirect to /setup so users see their password before code-server prompts for it. + // Redirect to / — the Worker proxies straight to code-server (no password page needed). return new Response(null, { status: 302, headers: { - Location: "/setup", + Location: "/", "Set-Cookie": `__session=${sessionId}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${ttl}`, }, }); diff --git a/apps/vscode-cloud/src/index.ts b/apps/vscode-cloud/src/index.ts index f9c6343..5737345 100644 --- a/apps/vscode-cloud/src/index.ts +++ b/apps/vscode-cloud/src/index.ts @@ -8,39 +8,26 @@ export interface Env { CODE_SERVER: DurableObjectNamespace; // ── Cloudflare Access (production — option A) ───────────────────────────── - /** e.g. https://yourteam.cloudflareaccess.com — set via wrangler var */ TEAM_DOMAIN?: string; - /** Application Audience tag from the Access dashboard — set via wrangler var */ POLICY_AUD?: string; // ── Google OAuth (production — option B) ───────────────────────────────── - /** Google OAuth client ID — set via wrangler var */ GOOGLE_CLIENT_ID?: string; - /** Google OAuth client secret — set via wrangler secret */ GOOGLE_CLIENT_SECRET?: string; - /** KV namespace for storing Google OAuth sessions (7-day TTL) */ SESSION_STORE?: KVNamespace; // ── Container behaviour ─────────────────────────────────────────────────── - /** How long to keep container alive after last request. Default: 30m */ SLEEP_AFTER: string; // ── R2 workspace storage ───────────────────────────────────────────────── - /** R2 bucket binding — used to verify the bucket exists at deploy time */ WORKSPACE_BUCKET: R2Bucket; - /** R2 API token access key (wrangler secret) — passed to container for FUSE mount */ R2_ACCESS_KEY_ID?: string; - /** R2 API token secret key (wrangler secret) — passed to container for FUSE mount */ R2_SECRET_ACCESS_KEY?: string; - /** Cloudflare account ID (wrangler secret) — needed for the R2 S3-compat endpoint */ R2_ACCOUNT_ID?: string; - /** R2 bucket name (wrangler var) */ R2_BUCKET_NAME: string; // ── GitHub repo auto-cloning ────────────────────────────────────────────── - /** Comma-separated list of "owner/repo" pairs to clone on container start */ GITHUB_REPOS?: string; - /** GitHub personal access token for private repos (wrangler secret) */ GITHUB_TOKEN?: string; } @@ -48,16 +35,11 @@ export interface Env { /** * One CodeServerContainer instance = one isolated code-server per user. - * - * Responsibilities: - * - Generate and persist a cryptographically random password per user in SQLite. - * - Inject that password + R2/GitHub credentials as env vars on container start. - * - Expose internal RPC endpoints for the Worker (init, password, reset). - * - Forward all other requests to code-server, renewing the idle timer each time. + * Auth is handled by the Worker — the container runs with --auth none. */ export class CodeServerContainer extends Container { defaultPort = 8080; - // Containers need internet to reach R2's HTTPS endpoint and github.com for cloning. + // Required so the container can reach R2's S3-compat HTTPS endpoint and github.com. enableInternet = true; constructor(ctx: DurableObjectState, env: Env) { @@ -65,7 +47,7 @@ export class CodeServerContainer extends Container { this.sleepAfter = env.SLEEP_AFTER ?? "30m"; } - // ── SQLite helpers ───────────────────────────────────────────────────────── + // ── SQLite — store userId for R2 prefix ─────────────────────────────────── private initSchema(): void { this.ctx.storage.sql.exec(` @@ -86,7 +68,6 @@ export class CodeServerContainer extends Container { ]; return rows.length > 0 ? (rows[0].value as string) : null; } catch { - // Table doesn't exist yet — return null so callers fall back to defaults. return null; } } @@ -100,39 +81,18 @@ export class CodeServerContainer extends Container { ); } - /** - * Return the user's password, generating a secure random one on first call. - * Uses base-62 chars (A-Z a-z 0-9) so it's safe for shell env vars and URLs. - */ - private getOrCreatePassword(): string { - this.initSchema(); - - const stored = this.getConfig("password"); - if (stored) return stored; - - const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; - const bytes = new Uint8Array(24); - crypto.getRandomValues(bytes); - const password = Array.from(bytes) - .map((b) => chars[b % chars.length]) - .join(""); - - this.setConfig("password", password); - return password; - } - - // ── Lifecycle hooks ──────────────────────────────────────────────────────── + // ── Lifecycle ────────────────────────────────────────────────────────────── override onStart(): void { - console.log(`[code-server] Container started — DO id: ${this.ctx.id}`); + console.log(`[code-server] started — ${this.ctx.id}`); } override onStop(_: StopParams): void { - console.log(`[code-server] Container stopped — DO id: ${this.ctx.id}`); + console.log(`[code-server] stopped — ${this.ctx.id}`); } override onError(error: unknown): void { - console.error(`[code-server] Container error:`, error); + console.error(`[code-server] error:`, error); } // ── Request handler ──────────────────────────────────────────────────────── @@ -140,7 +100,7 @@ export class CodeServerContainer extends Container { override async fetch(request: Request): Promise { const url = new URL(request.url); - // ── Internal RPC: register userId for this DO (idempotent) ─────────────── + // ── Internal RPC: register userId (idempotent) ─────────────────────────── if (url.pathname === "/__internal/init" && request.method === "POST") { const { userId } = (await request.json()) as { userId: string }; this.initSchema(); @@ -148,35 +108,18 @@ export class CodeServerContainer extends Container { return Response.json({ ok: true }); } - // ── Internal RPC: retrieve the current password ─────────────────────────── - if (url.pathname === "/__internal/password") { - return Response.json({ password: this.getOrCreatePassword() }); - } - - // ── Internal RPC: reset password and restart container ──────────────────── - if (url.pathname === "/__internal/reset-password" && request.method === "POST") { - this.initSchema(); - this.ctx.storage.sql.exec("DELETE FROM user_config WHERE key = 'password'"); - try { await this.stop(); } catch { /* already stopped */ } - return Response.json({ ok: true }); - } - - // ── Internal RPC: container health / state ──────────────────────────────── + // ── Internal RPC: container state ──────────────────────────────────────── if (url.pathname === "/__internal/state") { const state = await this.getState(); return Response.json(state); } // ── Proxy to code-server ────────────────────────────────────────────────── - // Renew idle timer on every proxied request so an active browser session - // keeps the container alive even if sleepAfter would fire. this.renewActivityTimeout(); const userId = this.getConfig("user_id") ?? "default"; - const password = this.getOrCreatePassword(); this.envVars = { - PASSWORD: password, R2_ACCESS_KEY_ID: this.env.R2_ACCESS_KEY_ID ?? "", R2_SECRET_ACCESS_KEY: this.env.R2_SECRET_ACCESS_KEY ?? "", R2_ACCOUNT_ID: this.env.R2_ACCOUNT_ID ?? "", @@ -190,93 +133,159 @@ export class CodeServerContainer extends Container { } } -// ─── Worker entry point ─────────────────────────────────────────────────────── +// ─── Landing page ───────────────────────────────────────────────────────────── + +function landingPage(env: Env): Response { + const hasGoogleAuth = !!(env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET); + const hasCFAccess = !!(env.TEAM_DOMAIN && env.POLICY_AUD); + + const signInButton = hasGoogleAuth + ? `Sign in with Google` + : hasCFAccess + ? `Sign in` + : `Open IDE (dev mode)`; -function setupPage(email: string, password: string, sleepAfter: string): Response { const html = ` - code-server — setup + Cloud IDE -
- Sign out -

Your cloud IDE is ready starting…

-

Signed in as ${email}

- -
Your unique password
-
- ${password} - -
-

- This password is unique to you — it never changes unless you request a reset. - The IDE sleeps after ${sleepAfter} of inactivity to save cost - and wakes automatically on your next visit. +

+ +
+
Powered by Cloudflare Containers
+

Your personal
cloud IDE

+

+ A full VS Code environment that spins up in seconds, + persists your work to R2, and sleeps when you're away — so you pay only while coding.

- -
-

- GitHub repos configured for auto-clone are loaded into - /home/coder/workspace/ on first boot. Workspace changes sync - automatically to R2 via FUSE. -

-
- +
+
+
📦
+
Isolated container per user
+
Every user gets their own container — no shared state, no conflicts.
+
+
+
💾
+
Workspace persisted to R2
+
Files sync automatically to Cloudflare R2 via FUSE. Survive restarts.
+
+
+
🔄
+
GitHub repos auto-cloned
+
Configure repos once — they appear in your workspace on first boot.
+
+
+
🤖
+
Claude Code + Codex CLI
+
AI coding assistants pre-installed and ready in every terminal.
+
+
+
🔌
+
Extensions pre-installed
+
GitLens, ESLint, Prettier, Python, Tailwind, and more — ready on launch.
+
+
+
💤
+
Idle sleep — pay while coding
+
Containers hibernate after inactivity and wake instantly on your return.
+
+
+ + +
+ Built on Cloudflare Containers · code-server by Coder +
`; + return new Response(html, { headers: { "Content-Type": "text/html; charset=utf-8" }, }); } +// ─── Worker entry point ─────────────────────────────────────────────────────── + export default { async fetch(request: Request, env: Env): Promise { const url = new URL(request.url); @@ -287,23 +296,30 @@ export default { } // ── Google OAuth routes (login / callback / logout) ───────────────────── - // Must run before authenticate() so unauthenticated users can reach /auth/login const authRouteResp = await handleAuthRoutes(request, env, url); if (authRouteResp) return authRouteResp; - // ── Authenticate via CF Access JWT, Google session, or dev header ──────── + // ── Authenticate ───────────────────────────────────────────────────────── const authResult = await authenticate(request, env); - if (authResult instanceof Response) return authResult; + + // If not authenticated and Google OAuth is configured: show landing page + // for GET requests instead of bouncing straight to the login redirect. + if (authResult instanceof Response) { + const isGoogleOAuth = !!(env.GOOGLE_CLIENT_ID && !env.TEAM_DOMAIN); + if (isGoogleOAuth && request.method === "GET") { + return landingPage(env); + } + return authResult; + } const { email, userId } = authResult; - // ── Route to the user's personal container instance ───────────────────── + // ── Route to the user's personal container ────────────────────────────── const stub = env.CODE_SERVER.getByName(userId) as unknown as { fetch(req: Request): Promise; }; - // Register userId in the DO's SQLite so it can build the R2 prefix. - // This is idempotent and cheap (a single SQL upsert). + // Register userId in the DO (idempotent — one SQL upsert per request). await stub.fetch( new Request("http://internal/__internal/init", { method: "POST", @@ -312,26 +328,7 @@ export default { }) ); - // ── /setup — password reveal + first-visit instructions ────────────────── - if (url.pathname === "/setup") { - const pwResp = await stub.fetch( - new Request("http://internal/__internal/password") - ); - const { password } = (await pwResp.json()) as { password: string }; - return setupPage(email, password, env.SLEEP_AFTER ?? "30m"); - } - - // ── /reset-password — regenerate password (POST only) ─────────────────── - if (url.pathname === "/reset-password" && request.method === "POST") { - await stub.fetch( - new Request("http://internal/__internal/reset-password", { - method: "POST", - }) - ); - return Response.redirect(new URL("/setup", url).toString(), 303); - } - - // ── /status — container state (JSON) ──────────────────────────────────── + // ── /status — container state for debugging ────────────────────────────── if (url.pathname === "/status") { const stateResp = await stub.fetch( new Request("http://internal/__internal/state") @@ -340,7 +337,7 @@ export default { return Response.json({ userId, email, container: state }); } - // ── Proxy everything else to code-server ───────────────────────────────── + // ── Proxy to code-server ───────────────────────────────────────────────── return stub.fetch(request); }, } satisfies ExportedHandler; diff --git a/apps/vscode-cloud/wrangler.jsonc b/apps/vscode-cloud/wrangler.jsonc index 46a7829..81bfb5b 100644 --- a/apps/vscode-cloud/wrangler.jsonc +++ b/apps/vscode-cloud/wrangler.jsonc @@ -90,6 +90,9 @@ ], // ── Secrets (set via CLI, never committed) ──────────────────────────────── + // Auth is handled by the Worker — code-server runs with --auth none. + // The container is only reachable through the authenticated Worker proxy. + // // R2 credentials for FUSE mount (geesefs inside the container): // wrangler secret put R2_ACCESS_KEY_ID // wrangler secret put R2_SECRET_ACCESS_KEY From 815ff0930778ba2e4c42e7a5cac62eb3df6d0454 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 19 May 2026 00:31:28 +0000 Subject: [PATCH 3/4] feat(vscode-cloud): teams dashboard, WakaTime tracking, and updated landing page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Teams (new src/teams.ts + src/admin.ts) - D1-backed data layer: teams, team_members, team_invites tables auto-created via initTeamsSchema() on first /admin request - Admin dashboard at /admin — lists all teams the user belongs to (ADMIN_EMAILS env var grants platform-wide visibility) - Create team at /admin/team/new — creator becomes team admin - Team detail at /admin/team/:id — members table with email, role, container status (fetched live from each user's DO), and join date - Invite members: admin fills in email → generates a 7-day invite URL to copy and share (no email infrastructure required) - Accept invite at /admin/accept-invite?token=... — validates token, adds user as member, deletes the single-use invite token - Remove member: team admin can remove any member except themselves - Pending invites table with remaining expiry shown on team detail page - WakaTime card on team detail page explaining the pre-installed extension and linking to wakatime.com/dashboard WakaTime time tracking - Add WAKATIME_API_KEY env var — if set, auto-writes ~/.wakatime.cfg inside every container so the extension activates without manual setup - Add WakaTime.vscode-wakatime to pre-installed extension list in Dockerfile Landing page (src/index.ts) - Reworked hero: "The cloud IDE for developer teams" - Separate "For developers" feature grid (6 tiles) and a "Built for engineering teams" CTA section with purple accent and 5 bullet pills (team accounts, admin dashboard, email invites, WakaTime, container status) - Nav shows "Team dashboard" link alongside primary sign-in action - Footer updated to mention WakaTime Config (wrangler.jsonc) - Add D1 binding: TEAMS_DB / vscode-cloud-teams - Add ADMIN_EMAILS var (comma-separated platform-admin emails) - Add WAKATIME_API_KEY var with setup note - Updated initial-setup comment block to include wrangler d1 create step https://claude.ai/code/session_01VSbGCNLi5Ydg8ViQijJEzG --- apps/vscode-cloud/Dockerfile | 3 +- apps/vscode-cloud/entrypoint.sh | 11 + apps/vscode-cloud/src/admin.ts | 468 +++++++++++++++++++++++++++++++ apps/vscode-cloud/src/index.ts | 277 ++++++++++-------- apps/vscode-cloud/src/teams.ts | 183 ++++++++++++ apps/vscode-cloud/wrangler.jsonc | 32 ++- 6 files changed, 855 insertions(+), 119 deletions(-) create mode 100644 apps/vscode-cloud/src/admin.ts create mode 100644 apps/vscode-cloud/src/teams.ts diff --git a/apps/vscode-cloud/Dockerfile b/apps/vscode-cloud/Dockerfile index e96805b..b261d31 100644 --- a/apps/vscode-cloud/Dockerfile +++ b/apps/vscode-cloud/Dockerfile @@ -51,7 +51,8 @@ RUN for ext in \ formulahendry.code-runner \ pkief.material-icon-theme \ usernamehw.errorlens \ - streetsidesoftware.code-spell-checker; \ + streetsidesoftware.code-spell-checker \ + WakaTime.vscode-wakatime; \ do \ code-server --install-extension "$ext" || echo "[docker] Warning: failed to install $ext"; \ done diff --git a/apps/vscode-cloud/entrypoint.sh b/apps/vscode-cloud/entrypoint.sh index e0e145c..cc4ebe5 100644 --- a/apps/vscode-cloud/entrypoint.sh +++ b/apps/vscode-cloud/entrypoint.sh @@ -115,6 +115,17 @@ SETTINGS echo "[entrypoint] VSCode workspace settings written" fi +# ── WakaTime auto-config ───────────────────────────────────────────────────── +# If WAKATIME_API_KEY is injected by the Worker, write ~/.wakatime.cfg so the +# pre-installed extension activates without manual setup. +if [[ -n "$WAKATIME_API_KEY" ]]; then + cat > "$HOME/.wakatime.cfg" < + + + + + ${title} — Teams + + + + +
+ ${body} +
+ +`; +} + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function statusBadge(status: string): string { + const cls = + status === "running" || status === "healthy" + ? "badge-running" + : status === "stopped" || status === "stopped_with_code" + ? "badge-stopped" + : "badge-sleeping"; + return `${status}`; +} + +async function getContainerStatus( + doNs: DurableObjectNamespace, + userId: string +): Promise { + try { + const stub = doNs.getByName(userId) as unknown as { fetch(r: Request): Promise }; + const r = await stub.fetch(new Request("http://internal/__internal/state")); + const state = (await r.json()) as { status?: string }; + return state?.status ?? "unknown"; + } catch { + return "unknown"; + } +} + +// ─── Route handler ──────────────────────────────────────────────────────────── + +export async function handleAdminRoutes( + request: Request, + env: Env, + url: URL, + userEmail: string, + userId: string +): Promise { + if (!env.TEAMS_DB) return null; + if (!url.pathname.startsWith("/admin")) return null; + + await initTeamsSchema(env.TEAMS_DB); + + const isGlobalAdmin = + env.ADMIN_EMAILS + ?.split(",") + .map((e) => e.trim().toLowerCase()) + .includes(userEmail.toLowerCase()) ?? false; + + const path = url.pathname; + const flash = (msg: string, ok = true) => + `
${msg}
`; + + // ── GET /admin ───────────────────────────────────────────────────────────── + if (path === "/admin" && request.method === "GET") { + const teams = isGlobalAdmin + ? await getAllTeams(env.TEAMS_DB) + : await getTeamsByUser(env.TEAMS_DB, userId); + + const cards = teams.length + ? teams + .map( + (t) => ` +
+
${esc(t.name)}
+
Created ${new Date(t.created_at).toLocaleDateString()}
+ +
` + ) + .join("") + : `

No teams yet. Create one to get started.

`; + + const body = ` +
+
+

Teams Dashboard

+

Manage your developer teams, track activity, and invite members.

+
+ + New Team +
+
${cards}
`; + return new Response(shell("Dashboard", body, userEmail), { + headers: { "Content-Type": "text/html; charset=utf-8" }, + }); + } + + // ── GET /admin/team/new ──────────────────────────────────────────────────── + if (path === "/admin/team/new" && request.method === "GET") { + const body = ` +
+

Create Team

+ ← Back +
+
+
+
+
+ + +
+
+ + Cancel +
+
+
+
`; + return new Response(shell("New Team", body, userEmail), { + headers: { "Content-Type": "text/html; charset=utf-8" }, + }); + } + + // ── POST /admin/team/create ──────────────────────────────────────────────── + if (path === "/admin/team/create" && request.method === "POST") { + const form = await request.formData(); + const name = (form.get("name") as string | null)?.trim(); + if (!name) return redirect("/admin/team/new"); + const team = await createTeam(env.TEAMS_DB, name, userId, userEmail); + return redirect(`/admin/team/${team.id}`); + } + + // ── /admin/team/:id ──────────────────────────────────────────────────────── + const teamMatch = path.match(/^\/admin\/team\/([^/]+)$/); + if (teamMatch) { + const teamId = teamMatch[1]; + const team = await getTeam(env.TEAMS_DB, teamId); + if (!team) return new Response("Team not found", { status: 404 }); + + const role = await getMemberRole(env.TEAMS_DB, teamId, userId); + if (!role && !isGlobalAdmin) + return new Response("Forbidden", { status: 403 }); + + const isTeamAdmin = role === "admin" || isGlobalAdmin; + + if (request.method === "POST") { + const form = await request.formData(); + const action = form.get("_action") as string | null; + + if (action === "invite" && isTeamAdmin) { + const email = (form.get("email") as string | null)?.trim().toLowerCase(); + if (!email) return redirect(`/admin/team/${teamId}?err=noemail`); + const token = await createInvite(env.TEAMS_DB, teamId, email, userId); + return redirect(`/admin/team/${teamId}?invite=${token}`); + } + + if (action === "remove" && isTeamAdmin) { + const targetId = form.get("member_id") as string | null; + if (targetId && targetId !== userId) + await removeMember(env.TEAMS_DB, teamId, targetId); + return redirect(`/admin/team/${teamId}`); + } + } + + // GET — build team detail page + const [members, invites] = await Promise.all([ + getTeamMembers(env.TEAMS_DB, teamId), + isTeamAdmin ? getPendingInvites(env.TEAMS_DB, teamId) : Promise.resolve([]), + ]); + + // Fetch container status for each member in parallel + const statuses = await Promise.all( + members.map((m) => getContainerStatus(env.CODE_SERVER, m.user_id)) + ); + + const newInviteToken = url.searchParams.get("invite"); + const errParam = url.searchParams.get("err"); + + const inviteFlash = newInviteToken + ? flash( + `Invite link generated — copy and send it to the new team member: +
${url.origin}/admin/accept-invite?token=${newInviteToken}
`, + true + ) + : errParam === "noemail" + ? flash("Please enter an email address.", false) + : ""; + + const memberRows = members + .map( + (m, i) => ` + + ${esc(m.email)} + ${m.role} + ${statusBadge(statuses[i])} + ${new Date(m.joined_at).toLocaleDateString()} + + ${ + isTeamAdmin && m.user_id !== userId + ? `
+ + + +
` + : `you` + } + + ` + ) + .join(""); + + const inviteRows = invites + .map( + (inv) => ` + + ${esc(inv.email)} + Expires ${new Date(inv.expires_at).toLocaleDateString()} + + Copy link + + ` + ) + .join(""); + + const inviteForm = isTeamAdmin + ? `
+ +
+
+ + +
+ +
+
` + : ""; + + const body = ` +
+
+

${esc(team.name)}

+

${members.length} member${members.length !== 1 ? "s" : ""}

+
+ ← All teams +
+ ${inviteFlash} +
+

Members

+ + + + + + + ${memberRows || ``} +
EmailRoleContainerJoined
No members yet.
+
+ ${ + isTeamAdmin + ? `
+

Invite member

+
+

+ An invite link will be generated — share it with your developer via email or Slack. + Links expire after 7 days. +

+ ${inviteForm} +
+
+
+

Pending invites

+ ${ + invites.length + ? ` + + ${inviteRows} +
EmailExpiry
` + : `

No pending invites.

` + } +
` + : "" + } +
+

WakaTime time tracking

+
+ WakaTime is pre-installed in every container. Each developer configures their own + API key via Settings → Extensions → WakaTime or the + ~/.wakatime.cfg file in their terminal. + View team stats at + wakatime.com/dashboard + once members have connected their accounts. +
+
`; + + return new Response(shell(team.name, body, userEmail), { + headers: { "Content-Type": "text/html; charset=utf-8" }, + }); + } + + // ── GET /admin/accept-invite ─────────────────────────────────────────────── + if (path === "/admin/accept-invite" && request.method === "GET") { + const token = url.searchParams.get("token"); + if (!token) return new Response("Missing token", { status: 400 }); + + const invite = await getInvite(env.TEAMS_DB, token); + if (!invite || invite.expires_at < Date.now()) { + return new Response( + shell( + "Invalid invite", + `
This invite link is invalid or has expired.
+ ← Dashboard`, + userEmail + ), + { headers: { "Content-Type": "text/html; charset=utf-8" } } + ); + } + + await Promise.all([ + addMember(env.TEAMS_DB, invite.team_id, userId, userEmail), + deleteInvite(env.TEAMS_DB, token), + ]); + + return redirect(`/admin/team/${invite.team_id}`); + } + + return null; +} + +// ─── Utils ──────────────────────────────────────────────────────────────────── + +function redirect(location: string): Response { + return new Response(null, { status: 303, headers: { Location: location } }); +} + +function esc(s: string): string { + return s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); +} diff --git a/apps/vscode-cloud/src/index.ts b/apps/vscode-cloud/src/index.ts index 5737345..25549e4 100644 --- a/apps/vscode-cloud/src/index.ts +++ b/apps/vscode-cloud/src/index.ts @@ -1,25 +1,32 @@ import { Container } from "@cloudflare/containers"; import type { StopParams } from "@cloudflare/containers"; import { authenticate, handleAuthRoutes } from "./auth"; +import { handleAdminRoutes } from "./admin"; // ─── Env ───────────────────────────────────────────────────────────────────── export interface Env { CODE_SERVER: DurableObjectNamespace; - // ── Cloudflare Access (production — option A) ───────────────────────────── + // ── Cloudflare Access (option A) ────────────────────────────────────────── TEAM_DOMAIN?: string; POLICY_AUD?: string; - // ── Google OAuth (production — option B) ───────────────────────────────── + // ── Google OAuth (option B) ─────────────────────────────────────────────── GOOGLE_CLIENT_ID?: string; GOOGLE_CLIENT_SECRET?: string; SESSION_STORE?: KVNamespace; + // ── Teams ───────────────────────────────────────────────────────────────── + /** D1 database for teams, members, and invites */ + TEAMS_DB?: D1Database; + /** Comma-separated emails with platform-wide admin access to /admin */ + ADMIN_EMAILS?: string; + // ── Container behaviour ─────────────────────────────────────────────────── SLEEP_AFTER: string; - // ── R2 workspace storage ───────────────────────────────────────────────── + // ── R2 workspace storage ────────────────────────────────────────────────── WORKSPACE_BUCKET: R2Bucket; R2_ACCESS_KEY_ID?: string; R2_SECRET_ACCESS_KEY?: string; @@ -29,17 +36,16 @@ export interface Env { // ── GitHub repo auto-cloning ────────────────────────────────────────────── GITHUB_REPOS?: string; GITHUB_TOKEN?: string; + + // ── WakaTime ────────────────────────────────────────────────────────────── + /** Optional: auto-configure WakaTime API key in every container (~/.wakatime.cfg) */ + WAKATIME_API_KEY?: string; } // ─── Container / Durable Object ────────────────────────────────────────────── -/** - * One CodeServerContainer instance = one isolated code-server per user. - * Auth is handled by the Worker — the container runs with --auth none. - */ export class CodeServerContainer extends Container { defaultPort = 8080; - // Required so the container can reach R2's S3-compat HTTPS endpoint and github.com. enableInternet = true; constructor(ctx: DurableObjectState, env: Env) { @@ -47,8 +53,6 @@ export class CodeServerContainer extends Container { this.sleepAfter = env.SLEEP_AFTER ?? "30m"; } - // ── SQLite — store userId for R2 prefix ─────────────────────────────────── - private initSchema(): void { this.ctx.storage.sql.exec(` CREATE TABLE IF NOT EXISTS user_config ( @@ -81,8 +85,6 @@ export class CodeServerContainer extends Container { ); } - // ── Lifecycle ────────────────────────────────────────────────────────────── - override onStart(): void { console.log(`[code-server] started — ${this.ctx.id}`); } @@ -95,12 +97,9 @@ export class CodeServerContainer extends Container { console.error(`[code-server] error:`, error); } - // ── Request handler ──────────────────────────────────────────────────────── - override async fetch(request: Request): Promise { const url = new URL(request.url); - // ── Internal RPC: register userId (idempotent) ─────────────────────────── if (url.pathname === "/__internal/init" && request.method === "POST") { const { userId } = (await request.json()) as { userId: string }; this.initSchema(); @@ -108,13 +107,10 @@ export class CodeServerContainer extends Container { return Response.json({ ok: true }); } - // ── Internal RPC: container state ──────────────────────────────────────── if (url.pathname === "/__internal/state") { - const state = await this.getState(); - return Response.json(state); + return Response.json(await this.getState()); } - // ── Proxy to code-server ────────────────────────────────────────────────── this.renewActivityTimeout(); const userId = this.getConfig("user_id") ?? "default"; @@ -127,6 +123,7 @@ export class CodeServerContainer extends Container { USER_ID: userId, GITHUB_REPOS: this.env.GITHUB_REPOS ?? "", GITHUB_TOKEN: this.env.GITHUB_TOKEN ?? "", + WAKATIME_API_KEY: this.env.WAKATIME_API_KEY ?? "", }; return super.fetch(request); @@ -139,24 +136,29 @@ function landingPage(env: Env): Response { const hasGoogleAuth = !!(env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET); const hasCFAccess = !!(env.TEAM_DOMAIN && env.POLICY_AUD); - const signInButton = hasGoogleAuth - ? `Sign in with Google` + const signInHref = hasGoogleAuth + ? "/auth/login" : hasCFAccess - ? `Sign in` - : `Open IDE (dev mode)`; + ? "/" + : "/?user=dev"; + const signInLabel = hasGoogleAuth + ? "Sign in with Google" + : hasCFAccess + ? "Sign in" + : "Open IDE (dev mode)"; const html = ` - Cloud IDE + Cloud IDE — VS Code for every developer @@ -223,65 +256,92 @@ function landingPage(env: Env): Response {
-
Powered by Cloudflare Containers
-

Your personal
cloud IDE

-

- A full VS Code environment that spins up in seconds, - persists your work to R2, and sleeps when you're away — so you pay only while coding. -

-
- ${signInButton} - - Learn more - -
- -
-
-
📦
-
Isolated container per user
-
Every user gets their own container — no shared state, no conflicts.
+ +
+
Powered by Cloudflare Containers
+

The cloud IDE for
developer teams

+

+ A full VS Code environment per developer — isolated, persistent, and + ready in seconds. Manage your whole team from one dashboard. +

+ -
-
💾
-
Workspace persisted to R2
-
Files sync automatically to Cloudflare R2 via FUSE. Survive restarts.
+
+ + +
+ +
+
+
📦
+
Isolated container per user
+
Your own VS Code — no shared state, no conflicts with teammates.
+
+
+
💾
+
Workspace persisted to R2
+
Files sync automatically to Cloudflare R2 via FUSE. Survive restarts.
+
+
+
🔄
+
GitHub repos auto-cloned
+
Configure repos once — they appear in your workspace on first boot.
+
+
+
🤖
+
Claude Code + Codex CLI
+
AI coding assistants pre-installed and ready in every terminal.
+
+
+
🔌
+
10+ extensions pre-installed
+
GitLens, ESLint, Prettier, Python, Tailwind, Error Lens, and more.
+
+
+
💤
+
Idle sleep
+
Containers hibernate after inactivity and wake instantly on return.
+
-
-
🔄
-
GitHub repos auto-cloned
-
Configure repos once — they appear in your workspace on first boot.
-
-
-
🤖
-
Claude Code + Codex CLI
-
AI coding assistants pre-installed and ready in every terminal.
+
+ + +
+
+

Built for engineering teams

+

+ Group your developers into teams, track coding time with + WakaTime, and onboard new members with a single invite link — + all from a central admin dashboard. +

-
-
🔌
-
Extensions pre-installed
-
GitLens, ESLint, Prettier, Python, Tailwind, and more — ready on launch.
+
+
Team accounts with role-based access
+
Admin dashboard to manage members
+
Invite developers by email — 7-day links
+
WakaTime time tracking pre-installed
+
Per-member container status at a glance
-
-
💤
-
Idle sleep — pay while coding
-
Containers hibernate after inactivity and wake instantly on your return.
+
- Built on Cloudflare Containers · code-server by Coder + Built on Cloudflare Containers · code-server by Coder · WakaTime time tracking
`; - return new Response(html, { - headers: { "Content-Type": "text/html; charset=utf-8" }, - }); + return new Response(html, { headers: { "Content-Type": "text/html; charset=utf-8" } }); } // ─── Worker entry point ─────────────────────────────────────────────────────── @@ -290,36 +350,32 @@ export default { async fetch(request: Request, env: Env): Promise { const url = new URL(request.url); - // ── Health check (unauthenticated) ────────────────────────────────────── if (url.pathname === "/health") { return Response.json({ status: "ok", ts: Date.now() }); } - // ── Google OAuth routes (login / callback / logout) ───────────────────── const authRouteResp = await handleAuthRoutes(request, env, url); if (authRouteResp) return authRouteResp; - // ── Authenticate ───────────────────────────────────────────────────────── const authResult = await authenticate(request, env); - // If not authenticated and Google OAuth is configured: show landing page - // for GET requests instead of bouncing straight to the login redirect. if (authResult instanceof Response) { const isGoogleOAuth = !!(env.GOOGLE_CLIENT_ID && !env.TEAM_DOMAIN); - if (isGoogleOAuth && request.method === "GET") { - return landingPage(env); - } + if (isGoogleOAuth && request.method === "GET") return landingPage(env); return authResult; } const { email, userId } = authResult; - // ── Route to the user's personal container ────────────────────────────── + // ── Admin / teams routes ───────────────────────────────────────────────── + const adminResp = await handleAdminRoutes(request, env, url, email, userId); + if (adminResp) return adminResp; + + // ── User's own container ───────────────────────────────────────────────── const stub = env.CODE_SERVER.getByName(userId) as unknown as { fetch(req: Request): Promise; }; - // Register userId in the DO (idempotent — one SQL upsert per request). await stub.fetch( new Request("http://internal/__internal/init", { method: "POST", @@ -328,16 +384,11 @@ export default { }) ); - // ── /status — container state for debugging ────────────────────────────── if (url.pathname === "/status") { - const stateResp = await stub.fetch( - new Request("http://internal/__internal/state") - ); - const state = await stateResp.json(); - return Response.json({ userId, email, container: state }); + const r = await stub.fetch(new Request("http://internal/__internal/state")); + return Response.json({ userId, email, container: await r.json() }); } - // ── Proxy to code-server ───────────────────────────────────────────────── return stub.fetch(request); }, } satisfies ExportedHandler; diff --git a/apps/vscode-cloud/src/teams.ts b/apps/vscode-cloud/src/teams.ts new file mode 100644 index 0000000..9a5db37 --- /dev/null +++ b/apps/vscode-cloud/src/teams.ts @@ -0,0 +1,183 @@ +// ─── Types ──────────────────────────────────────────────────────────────────── + +export interface Team { + id: string; + name: string; + created_by: string; + created_at: number; +} + +export interface TeamMember { + team_id: string; + user_id: string; + email: string; + role: "admin" | "member"; + joined_at: number; +} + +export interface TeamInvite { + token: string; + team_id: string; + email: string; + invited_by: string; + created_at: number; + expires_at: number; +} + +// ─── Schema ─────────────────────────────────────────────────────────────────── + +export async function initTeamsSchema(db: D1Database): Promise { + await db.batch([ + db.prepare(`CREATE TABLE IF NOT EXISTS teams ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + created_by TEXT NOT NULL, + created_at INTEGER NOT NULL + )`), + db.prepare(`CREATE TABLE IF NOT EXISTS team_members ( + team_id TEXT NOT NULL, + user_id TEXT NOT NULL, + email TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'member', + joined_at INTEGER NOT NULL, + PRIMARY KEY (team_id, user_id) + )`), + db.prepare(`CREATE TABLE IF NOT EXISTS team_invites ( + token TEXT PRIMARY KEY, + team_id TEXT NOT NULL, + email TEXT NOT NULL, + invited_by TEXT NOT NULL, + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL + )`), + ]); +} + +// ─── Teams ──────────────────────────────────────────────────────────────────── + +export async function createTeam( + db: D1Database, + name: string, + creatorId: string, + creatorEmail: string +): Promise { + const id = crypto.randomUUID(); + const now = Date.now(); + await db.batch([ + db.prepare(`INSERT INTO teams (id, name, created_by, created_at) VALUES (?, ?, ?, ?)`) + .bind(id, name, creatorId, now), + db.prepare( + `INSERT INTO team_members (team_id, user_id, email, role, joined_at) VALUES (?, ?, ?, 'admin', ?)` + ).bind(id, creatorId, creatorEmail, now), + ]); + return { id, name, created_by: creatorId, created_at: now }; +} + +export async function getAllTeams(db: D1Database): Promise { + const r = await db.prepare(`SELECT * FROM teams ORDER BY created_at DESC`).all(); + return r.results; +} + +export async function getTeamsByUser(db: D1Database, userId: string): Promise { + const r = await db + .prepare( + `SELECT t.* FROM teams t + JOIN team_members m ON t.id = m.team_id + WHERE m.user_id = ? + ORDER BY t.created_at DESC` + ) + .bind(userId) + .all(); + return r.results; +} + +export async function getTeam(db: D1Database, id: string): Promise { + return db.prepare(`SELECT * FROM teams WHERE id = ?`).bind(id).first(); +} + +// ─── Members ────────────────────────────────────────────────────────────────── + +export async function getTeamMembers(db: D1Database, teamId: string): Promise { + const r = await db + .prepare(`SELECT * FROM team_members WHERE team_id = ? ORDER BY joined_at ASC`) + .bind(teamId) + .all(); + return r.results; +} + +export async function getMemberRole( + db: D1Database, + teamId: string, + userId: string +): Promise<"admin" | "member" | null> { + const row = await db + .prepare(`SELECT role FROM team_members WHERE team_id = ? AND user_id = ?`) + .bind(teamId, userId) + .first<{ role: string }>(); + return (row?.role as "admin" | "member") ?? null; +} + +export async function addMember( + db: D1Database, + teamId: string, + userId: string, + email: string, + role: "admin" | "member" = "member" +): Promise { + await db + .prepare( + `INSERT INTO team_members (team_id, user_id, email, role, joined_at) VALUES (?, ?, ?, ?, ?) + ON CONFLICT(team_id, user_id) DO NOTHING` + ) + .bind(teamId, userId, email, role, Date.now()) + .run(); +} + +export async function removeMember( + db: D1Database, + teamId: string, + userId: string +): Promise { + await db + .prepare(`DELETE FROM team_members WHERE team_id = ? AND user_id = ?`) + .bind(teamId, userId) + .run(); +} + +// ─── Invites ────────────────────────────────────────────────────────────────── + +export async function createInvite( + db: D1Database, + teamId: string, + email: string, + invitedBy: string +): Promise { + const token = crypto.randomUUID(); + const now = Date.now(); + await db + .prepare( + `INSERT INTO team_invites (token, team_id, email, invited_by, created_at, expires_at) + VALUES (?, ?, ?, ?, ?, ?)` + ) + .bind(token, teamId, email, invitedBy, now, now + 7 * 24 * 60 * 60 * 1000) + .run(); + return token; +} + +export async function getInvite(db: D1Database, token: string): Promise { + return db.prepare(`SELECT * FROM team_invites WHERE token = ?`).bind(token).first(); +} + +export async function getPendingInvites(db: D1Database, teamId: string): Promise { + const r = await db + .prepare( + `SELECT * FROM team_invites WHERE team_id = ? AND expires_at > ? ORDER BY created_at DESC` + ) + .bind(teamId, Date.now()) + .all(); + return r.results; +} + +export async function deleteInvite(db: D1Database, token: string): Promise { + await db.prepare(`DELETE FROM team_invites WHERE token = ?`).bind(token).run(); +} diff --git a/apps/vscode-cloud/wrangler.jsonc b/apps/vscode-cloud/wrangler.jsonc index 81bfb5b..624cb2e 100644 --- a/apps/vscode-cloud/wrangler.jsonc +++ b/apps/vscode-cloud/wrangler.jsonc @@ -39,6 +39,17 @@ }, ], + // ── D1 database — teams, members, invites ──────────────────────────────── + // Create with: wrangler d1 create vscode-cloud-teams + // Then paste the returned database_id below. + "d1_databases": [ + { + "binding": "TEAMS_DB", + "database_name": "vscode-cloud-teams", + "database_id": "", // paste the ID returned by `wrangler d1 create vscode-cloud-teams` + }, + ], + // ── KV namespace — Google OAuth sessions ───────────────────────────────── // Only needed when using Google OAuth (option B auth). // Create with: wrangler kv namespace create SESSION_STORE @@ -73,6 +84,17 @@ // ── R2 bucket name ──────────────────────────────────────────────────────── "R2_BUCKET_NAME": "vscode-cloud-workspaces", + // ── Teams & admin ───────────────────────────────────────────────────────── + // Comma-separated emails with platform-wide admin access to /admin. + // Any authenticated user can also access /admin for teams they belong to. + "ADMIN_EMAILS": "", + + // ── WakaTime (optional) ─────────────────────────────────────────────────── + // If set, auto-writes ~/.wakatime.cfg in every container so users don't + // need to configure their API key manually. + // Get your key at wakatime.com/settings/account (or use a team API key). + "WAKATIME_API_KEY": "", + // ── GitHub repo auto-cloning ────────────────────────────────────────────── // Comma-separated "owner/repo" pairs cloned into /workspace on container start. // Example: "myorg/frontend,myorg/api" @@ -91,22 +113,22 @@ // ── Secrets (set via CLI, never committed) ──────────────────────────────── // Auth is handled by the Worker — code-server runs with --auth none. - // The container is only reachable through the authenticated Worker proxy. // - // R2 credentials for FUSE mount (geesefs inside the container): + // R2 credentials (FUSE mount inside container): // wrangler secret put R2_ACCESS_KEY_ID // wrangler secret put R2_SECRET_ACCESS_KEY // wrangler secret put R2_ACCOUNT_ID // - // Google OAuth (if using auth option B): + // Google OAuth: // wrangler secret put GOOGLE_CLIENT_SECRET // - // GitHub (if cloning private repos via GITHUB_REPOS): + // GitHub (private repos): // wrangler secret put GITHUB_TOKEN // // Initial setup: // wrangler login // wrangler r2 bucket create vscode-cloud-workspaces - // wrangler kv namespace create SESSION_STORE # only if using Google OAuth + // wrangler d1 create vscode-cloud-teams # paste database_id above + // wrangler kv namespace create SESSION_STORE # paste id in kv_namespaces above // wrangler deploy } From 01f871f66f7ea8888b58d4cd8fe0c99b3a05003b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 19 May 2026 01:12:33 +0000 Subject: [PATCH 4/4] docs(vscode-cloud): rewrite README with full setup guide Complete rewrite reflecting the current implementation: - Architecture diagram updated: passwordless auth, D1 teams DB, WakaTime, AI CLIs, admin dashboard - Step-by-step quick start: install, login, create R2/D1/KV resources, set secrets, configure wrangler.jsonc, deploy - Auth option A (CF Access) and option B (Google OAuth) each with numbered steps and exact config snippets - R2 setup: how to generate an API token, FUSE performance settings - GitHub auto-clone: config and private repo PAT - Teams dashboard: first-time setup, workflow walkthrough, routes table - WakaTime: manual per-user setup, auto-configure via WAKATIME_API_KEY - Pre-installed tools: AI CLIs table, VS Code extensions table - Full configuration reference: env vars, secrets, Cloudflare resources, container sizing - Cost estimates and all HTTP endpoints - Local development and troubleshooting sections Removed: all references to passwords, /setup, /reset-password (removed in previous commits) https://claude.ai/code/session_01VSbGCNLi5Ydg8ViQijJEzG --- apps/vscode-cloud/README.md | 429 ++++++++++++++++++++++++++---------- 1 file changed, 317 insertions(+), 112 deletions(-) diff --git a/apps/vscode-cloud/README.md b/apps/vscode-cloud/README.md index b6aac13..0423a1c 100644 --- a/apps/vscode-cloud/README.md +++ b/apps/vscode-cloud/README.md @@ -1,6 +1,6 @@ # vscode-cloud -Per-user [code-server](https://github.com/coder/code-server) instances on Cloudflare Containers — one isolated VS Code environment per authenticated user, with workspace files persisted to R2 via FUSE and GitHub repos auto-cloned on first boot. +Per-user [code-server](https://github.com/coder/code-server) instances on Cloudflare Containers — one isolated VS Code environment per authenticated user, with workspace files persisted to R2, GitHub repos auto-cloned on first boot, a team management dashboard, and WakaTime time tracking pre-installed. ## Architecture @@ -8,155 +8,217 @@ Per-user [code-server](https://github.com/coder/code-server) instances on Cloudf Browser │ ▼ -Worker (auth + routing) +Worker (auth + routing) ├─ CF Access JWT ─────────┐ ├─ Google OAuth session ───┤──▶ AuthUser { email, userId } └─ Dev mode header ────────┘ │ - ▼ - Durable Object (per user, named by userId) - ├─ SQLite: password, userId - └─ Container (code-server on :8080) - ├─ geesefs FUSE → R2: users/{userId}/ - └─ git clone → GITHUB_REPOS + ├─ /admin/* ──▶ Teams handler ──▶ D1 (teams / members / invites) + │ + └─ /* ──▶ Durable Object (per user, named by userId) + ├─ SQLite: userId → R2 prefix + └─ Container (code-server on :8080) + ├─ geesefs FUSE → R2: users/{userId}/ + ├─ git clone → GITHUB_REPOS + └─ ~/.wakatime.cfg (if WAKATIME_API_KEY set) ``` - **One Durable Object + Container per user** — fully isolated VS Code environments keyed by sanitised email. -- **R2 workspace persistence** — geesefs mounts the R2 bucket at `/workspace` via FUSE; reads/writes sync automatically. +- **Passwordless** — auth is enforced at the Worker layer (CF Access JWT or Google OAuth). `code-server` runs with `--auth none`; the container is never directly reachable. +- **R2 workspace persistence** — [geesefs](https://github.com/yandex-cloud/geesefs) mounts the R2 bucket at `/home/coder/workspace` via FUSE; reads/writes sync automatically. - **GitHub repo auto-cloning** — set `GITHUB_REPOS` to clone repos into the workspace on container start. -- **Two auth modes** — Cloudflare Access (enterprise) or Google OAuth (simpler setup), with dev-mode fallback. -- **Secure random passwords** — 24-char base-62 string generated via Web Crypto on first boot, stored in DO SQLite. -- **Idle cost control** — containers sleep after configurable inactivity (`SLEEP_AFTER`); `renewActivityTimeout()` keeps them alive while the browser tab is open. +- **Teams dashboard** — create teams, invite members by email, manage roles, and see live container status per member. +- **WakaTime time tracking** — extension pre-installed in every container; optionally auto-configured via `WAKATIME_API_KEY`. +- **AI coding tools** — Claude Code and Codex CLIs installed globally; available in every terminal session. +- **Idle cost control** — containers sleep after configurable inactivity (`SLEEP_AFTER`) and wake automatically on next request. --- -## Quick start (dev mode) +## Quick start + +### Prerequisites + +- [Node.js](https://nodejs.org) 18+ +- [Wrangler CLI](https://developers.cloudflare.com/workers/wrangler/install-and-update/) (`npm install -g wrangler`) +- [Docker](https://docs.docker.com/get-docker/) (required to build the container image locally) +- A Cloudflare account on the **Workers Paid** plan ($5/mo minimum) + +### 1. Install dependencies ```bash +cd apps/vscode-cloud npm install -wrangler login -wrangler r2 bucket create vscode-cloud-workspaces -wrangler deploy ``` -With `TEAM_DOMAIN`, `POLICY_AUD`, and `GOOGLE_CLIENT_ID` all empty, auth falls back to the `x-user-id` header or `?user=` query param. **Local testing only.** +### 2. Log in to Cloudflare -``` -https://vscode-cloud..workers.dev/setup?user=alice +```bash +wrangler login ``` -Copy the generated password, click **Open code-server**. +### 3. Create cloud resources ---- +```bash +# R2 bucket for workspace files (one shared bucket, per-user prefixes) +wrangler r2 bucket create vscode-cloud-workspaces + +# D1 database for teams, members, and invites +wrangler d1 create vscode-cloud-teams +# → Copy the returned database_id into wrangler.jsonc → d1_databases[0].database_id + +# KV namespace for Google OAuth sessions (skip if using CF Access instead) +wrangler kv namespace create SESSION_STORE +# → Copy the returned id into wrangler.jsonc → kv_namespaces[0].id +``` + +### 4. Set secrets -## Auth option A — Cloudflare Access +```bash +# R2 API token — generate at dash.cloudflare.com → R2 → Manage R2 API tokens +# Grant "Object Read & Write" on the vscode-cloud-workspaces bucket +wrangler secret put R2_ACCESS_KEY_ID +wrangler secret put R2_SECRET_ACCESS_KEY +wrangler secret put R2_ACCOUNT_ID # your Cloudflare account ID -Best for teams already using Cloudflare One. Validates the `Cf-Access-Jwt-Assertion` JWT from Access. +# Google OAuth client secret (only needed for Google OAuth auth) +wrangler secret put GOOGLE_CLIENT_SECRET -### 1. Create an Access application +# GitHub PAT with repo scope (only needed for private repos in GITHUB_REPOS) +wrangler secret put GITHUB_TOKEN +``` -1. [Cloudflare One](https://one.dash.cloudflare.com/) → **Access** → **Applications** → **Add** → **Self-hosted** -2. Set the domain to your Worker route (e.g. `code.example.com`) -3. Add an Allow policy (e.g. emails in `@yourcompany.com`) -4. Copy the **AUD tag** from the Basic information tab +### 5. Configure `wrangler.jsonc` -### 2. Configure +Open `wrangler.jsonc` and fill in the blanks: ```jsonc -// wrangler.jsonc +"d1_databases": [{ "database_id": "" }], +"kv_namespaces": [{ "id": "" }], + "vars": { - "TEAM_DOMAIN": "https://yourteam.cloudflareaccess.com", - "POLICY_AUD": "your-aud-tag" + "GOOGLE_CLIENT_ID": ".apps.googleusercontent.com", + "ADMIN_EMAILS": "you@example.com", // platform-wide admin access + "WAKATIME_API_KEY": "", // optional — see WakaTime section + "GITHUB_REPOS": "myorg/frontend,myorg/api", // optional + "SLEEP_AFTER": "30m" } ``` -### 3. Deploy +### 6. Deploy ```bash wrangler deploy ``` +The Worker URL is printed at the end: +``` +https://vscode-cloud..workers.dev +``` + --- -## Auth option B — Google OAuth +## Auth setup -Simpler for public-facing apps. Stores 7-day sessions in KV. +### Option A — Cloudflare Access (recommended for teams) -### 1. Create OAuth credentials +Best if your organisation already uses Cloudflare One. CF Access validates the `Cf-Access-Jwt-Assertion` JWT before the request reaches the Worker. -[console.cloud.google.com](https://console.cloud.google.com) → APIs & Services → Credentials → **Create OAuth 2.0 Client ID** +1. Go to [Cloudflare One](https://one.dash.cloudflare.com/) → **Access** → **Applications** → **Add** → **Self-hosted** +2. Set the application domain to your Worker URL (e.g. `code.example.com`) +3. Add an **Allow** policy scoped to your team's emails or identity provider +4. Copy the **AUD tag** from the application's Basic information tab -- Application type: **Web application** -- Authorised redirect URI: `https://vscode-cloud..workers.dev/auth/callback` +```jsonc +// wrangler.jsonc +"vars": { + "TEAM_DOMAIN": "https://yourteam.cloudflareaccess.com", + "POLICY_AUD": "" +} +``` + +```bash +wrangler deploy +``` + +### Option B — Google OAuth -### 2. Create the KV namespace +Simpler for public-facing deployments. Sessions are stored as 7-day KV entries. + +1. Open [Google Cloud Console](https://console.cloud.google.com) → **APIs & Services** → **Credentials** → **Create OAuth 2.0 Client ID** +2. Application type: **Web application** +3. Authorised redirect URI: `https://vscode-cloud..workers.dev/auth/callback` +4. Copy the **Client ID** and **Client Secret** ```bash +# Create the KV namespace (if not done in step 3 above) wrangler kv namespace create SESSION_STORE # Paste the returned id into wrangler.jsonc → kv_namespaces[0].id -``` -### 3. Configure +wrangler secret put GOOGLE_CLIENT_SECRET +``` ```jsonc // wrangler.jsonc "vars": { - "GOOGLE_CLIENT_ID": "your-client-id.apps.googleusercontent.com" + "GOOGLE_CLIENT_ID": ".apps.googleusercontent.com" } ``` ```bash -wrangler secret put GOOGLE_CLIENT_SECRET +wrangler deploy ``` -### 4. Deploy +Users visit the landing page, click **Sign in with Google**, and land directly in their IDE. -```bash -wrangler deploy -``` +### Dev mode (no auth) + +Leave `TEAM_DOMAIN`, `POLICY_AUD`, and `GOOGLE_CLIENT_ID` all empty. The Worker accepts an `x-user-id` header or `?user=` query param. **Never use this in production.** -Users hit `/auth/login` to authenticate, then get their `/setup` password page. +``` +https://vscode-cloud..workers.dev/?user=alice +``` --- ## R2 workspace persistence -Each user's workspace lives at `users/{userId}/` inside the shared R2 bucket, FUSE-mounted at `/home/coder/workspace` via [geesefs](https://github.com/yandex-cloud/geesefs). +Each user's workspace lives at `users/{userId}/` inside the shared R2 bucket, FUSE-mounted at `/home/coder/workspace` inside the container via geesefs. -### Setup +Without R2 credentials the container falls back to an ephemeral local workspace — files are lost when the container sleeps. -```bash -# Create bucket -wrangler r2 bucket create vscode-cloud-workspaces +### Generate an R2 API token -# Generate an R2 API token at dash.cloudflare.com → R2 → Manage R2 API tokens -# Give it Object Read & Write on vscode-cloud-workspaces -wrangler secret put R2_ACCESS_KEY_ID -wrangler secret put R2_SECRET_ACCESS_KEY -wrangler secret put R2_ACCOUNT_ID # your Cloudflare account ID -``` +1. [Cloudflare Dashboard](https://dash.cloudflare.com) → **R2** → **Manage R2 API tokens** → **Create API token** +2. Permissions: **Object Read & Write** +3. Scope: **Specific bucket** → `vscode-cloud-workspaces` -Without these secrets the container falls back to an ephemeral local workspace (lost on restart). +```bash +wrangler secret put R2_ACCESS_KEY_ID # Token Access Key ID +wrangler secret put R2_SECRET_ACCESS_KEY # Token Secret Access Key +wrangler secret put R2_ACCOUNT_ID # Your Cloudflare account ID +``` -### FUSE performance tuning +### FUSE performance settings -The entrypoint mounts with `--attr-cache-ttl 60s --type-cache-ttl 60s` to reduce metadata round-trips. The container also writes a `.vscode/settings.json` on first boot: +The entrypoint uses `--attr-cache-ttl 60s --type-cache-ttl 60s` to batch metadata calls. On first boot a `.vscode/settings.json` is written to the workspace: ```json { - "files.watcherExclude": { "**/.git/objects/**": true, "**/node_modules/**": true }, - "search.followSymlinks": false + "files.watcherExclude": { + "**/.git/objects/**": true, + "**/node_modules/**": true + }, + "search.followSymlinks": false, + "editor.formatOnSave": true } ``` -This prevents the file watcher from hammering the FUSE mount on large repos. +This prevents the VS Code file watcher from hammering the FUSE mount on large repos. --- ## GitHub repo auto-cloning -Set `GITHUB_REPOS` to a comma-separated list of `owner/repo` pairs. On each container start the entrypoint clones any repo that isn't already present, or pulls the latest if it is. - ```jsonc // wrangler.jsonc "vars": { @@ -164,27 +226,125 @@ Set `GITHUB_REPOS` to a comma-separated list of `owner/repo` pairs. On each cont } ``` +On each container start the entrypoint: +- Clones any repo not already present (`--depth=50`) +- Pulls the latest if the repo directory already exists + +Because the workspace is R2-backed, the clone only runs once per user. + For private repos: ```bash wrangler secret put GITHUB_TOKEN # PAT with repo scope ``` -Repos are cloned with `--depth=50` into `/home/coder/workspace//`. Because the workspace is R2-backed, the clone only runs once per user — subsequent container starts pull instead. +--- + +## Teams dashboard + +### First-time setup + +The D1 schema is created automatically on the first request to `/admin` — no migrations to run manually. + +Designate platform admins (who can see all teams) via `wrangler.jsonc`: + +```jsonc +"vars": { + "ADMIN_EMAILS": "alice@example.com,bob@example.com" +} +``` + +Any authenticated user can also access `/admin` for teams they belong to. + +### Workflow + +1. **Sign in** → go to `/admin` +2. Click **New Team** → enter a team name → you become the team admin +3. On the team detail page, enter a developer's email and click **Send invite** +4. Copy the generated invite URL and share it (Slack, email, etc.) +5. The developer signs in, visits the invite URL, and is added to the team +6. The members table shows each developer's **email**, **role**, **container status** (live), and **join date** +7. Team admins can remove members at any time + +Invite links expire after **7 days** and are single-use. + +### Routes + +| Route | Auth | Description | +|---|---|---| +| `GET /admin` | User | Dashboard — teams you belong to (or all teams if admin) | +| `GET /admin/team/new` | User | Create team form | +| `POST /admin/team/create` | User | Create team, become admin | +| `GET /admin/team/:id` | Member | Team detail: members, invites, WakaTime info | +| `POST /admin/team/:id` | Admin | Invite member or remove member | +| `GET /admin/accept-invite?token=` | User | Accept invite, join team | --- -## Endpoints +## WakaTime time tracking -| Path | Auth | Description | -|------|------|-------------| -| `GET /health` | None | Uptime check — returns `{ status: "ok" }` | -| `GET /auth/login` | None | Google OAuth — redirect to consent screen | -| `GET /auth/callback` | None | Google OAuth — exchange code, set session cookie | -| `GET /auth/logout` | None | Google OAuth — clear session, redirect to login | -| `GET /setup` | Auth | Show the user's unique password (first-visit page) | -| `POST /reset-password` | Auth | Regenerate password, reboot container, redirect to `/setup` | -| `GET /*` | Auth | Proxied to the user's code-server instance | +The [WakaTime](https://wakatime.com) extension is pre-installed in every container. It tracks coding time automatically and syncs to wakatime.com. + +### Manual setup (per developer) + +Each developer configures their own API key after opening the IDE: + +1. Open the Command Palette (`Ctrl+Shift+P`) → **WakaTime: API Key** +2. Paste the key from [wakatime.com/settings/account](https://wakatime.com/settings/account) + +Or via terminal: + +```bash +cat > ~/.wakatime.cfg < +EOF +``` + +### Auto-configure for all containers + +Set a shared API key (e.g. a WakaTime Teams project key) in `wrangler.jsonc`: + +```jsonc +"vars": { + "WAKATIME_API_KEY": "" +} +``` + +The entrypoint writes `~/.wakatime.cfg` automatically on container start — no manual setup required. + +View team stats at [wakatime.com/dashboard](https://wakatime.com/dashboard). + +--- + +## Pre-installed tools + +### AI CLIs (available in every terminal) + +| Tool | Command | Install | +|---|---|---| +| Claude Code | `claude` | `@anthropic-ai/claude-code` | +| OpenAI Codex | `codex` | `@openai/codex` | + +### VS Code extensions + +Installed at image build time from [Open VSX Registry](https://open-vsx.org): + +| Extension | Purpose | +|---|---| +| GitLens | Git blame, history, and branch visualisation | +| Prettier | Code formatting | +| ESLint | JavaScript/TypeScript linting | +| Python | Python language support | +| Tailwind CSS IntelliSense | Tailwind class autocomplete | +| Path Intellisense | Filename autocomplete | +| Code Runner | Run code snippets from the editor | +| Material Icon Theme | File icons | +| Error Lens | Inline error and warning display | +| Code Spell Checker | Spell checking in source files | +| WakaTime | Automatic time tracking | + +Extension failures during image build are non-fatal — the IDE still launches. --- @@ -193,72 +353,117 @@ Repos are cloned with `--depth=50` into `/home/coder/workspace//`. Be ### Environment variables (`wrangler.jsonc` vars) | Variable | Default | Description | -|----------|---------|-------------| -| `SLEEP_AFTER` | `"30m"` | Container idle timeout. Supports `"30s"`, `"1h"`, etc. Max 24h | -| `TEAM_DOMAIN` | `""` | Auth option A — CF Access team URL | -| `POLICY_AUD` | `""` | Auth option A — Access AUD tag | -| `GOOGLE_CLIENT_ID` | `""` | Auth option B — Google OAuth client ID | +|---|---|---| +| `SLEEP_AFTER` | `"30m"` | Container idle timeout. `"30s"`, `"1h"`, etc. Max 24h | +| `TEAM_DOMAIN` | `""` | Auth A — CF Access team URL | +| `POLICY_AUD` | `""` | Auth A — CF Access AUD tag | +| `GOOGLE_CLIENT_ID` | `""` | Auth B — Google OAuth client ID | +| `ADMIN_EMAILS` | `""` | Comma-separated emails with platform-wide `/admin` access | +| `WAKATIME_API_KEY` | `""` | Auto-configure WakaTime in every container | | `R2_BUCKET_NAME` | `"vscode-cloud-workspaces"` | R2 bucket name | -| `GITHUB_REPOS` | `""` | Comma-separated `owner/repo` list to clone | +| `GITHUB_REPOS` | `""` | Comma-separated `owner/repo` list to auto-clone | ### Secrets (`wrangler secret put`) | Secret | Description | -|--------|-------------| +|---|---| | `R2_ACCESS_KEY_ID` | R2 API token key | | `R2_SECRET_ACCESS_KEY` | R2 API token secret | | `R2_ACCOUNT_ID` | Cloudflare account ID | -| `GOOGLE_CLIENT_SECRET` | Google OAuth client secret (option B) | -| `GITHUB_TOKEN` | GitHub PAT for private repos | +| `GOOGLE_CLIENT_SECRET` | Google OAuth client secret (auth option B) | +| `GITHUB_TOKEN` | GitHub PAT with `repo` scope (private repos only) | + +### Cloudflare resources + +| Resource | Binding | Purpose | +|---|---|---| +| R2 bucket | `WORKSPACE_BUCKET` | Workspace file storage | +| D1 database | `TEAMS_DB` | Teams, members, invites | +| KV namespace | `SESSION_STORE` | Google OAuth sessions (auth B only) | ### Container sizing -Set in `wrangler.jsonc` → `containers[].instance_type`: +Set in `wrangler.jsonc` → `containers[0].instance_type`: -| Type | vCPU | RAM | Cost/hr | Use case | -|------|------|-----|---------|---------| -| `basic` | 0.5 | 512 MB | ~$0.15 | Light use only | -| `standard-1` | 1 | 2 GB | ~$0.30 | **Default — works well** | -| `standard-2` | 2 | 4 GB | ~$0.60 | Heavier projects / monorepos | +| Type | vCPU | RAM | Cost/hr | Recommended for | +|---|---|---|---|---| +| `basic` | 0.5 | 512 MB | ~$0.15 | Light use / testing only | +| `standard-1` | 1 | 2 GB | ~$0.30 | **Default — most projects** | +| `standard-2` | 2 | 4 GB | ~$0.60 | Monorepos / heavy workloads | --- ## Cost estimates -Containers are **on-demand** by default — billed only while running, ~$0 while sleeping. +Containers are **on-demand** — billed only while running, ~$0 while sleeping. | Scenario | Est. monthly cost | -|----------|------------------| -| 1 user, always-on (standard-1) | ~$75 | -| 10 users, 2hr/day avg (standard-1) | ~$18 | -| 100 users, 1hr/day avg (standard-1) | ~$80 | +|---|---| +| 1 developer, always-on (`standard-1`) | ~$75 | +| 10 developers, 2 hr/day avg | ~$18 | +| 100 developers, 1 hr/day avg | ~$80 | + +Add $5/mo for Workers Paid plan + ~$10 for KV/R2/D1 usage at scale. -Add $5/mo for Workers Paid plan + ~$10 for KV/R2 usage at scale. +Check live container status: -Check container status: ```bash npx wrangler containers list --status ``` --- -## Resetting a password - -```bash -curl -X POST https://code.example.com/reset-password \ - -H "Cf-Access-Jwt-Assertion: " - # or pass the __session cookie for Google OAuth -``` +## Endpoints -This deletes the stored password from SQLite, stops the container, then redirects to `/setup` where the new password is shown. The container restarts fresh on next request. +| Path | Auth | Description | +|---|---|---| +| `GET /` | — | Landing page (unauthenticated) or proxied to code-server (authenticated) | +| `GET /health` | None | `{ status: "ok", ts: }` | +| `GET /auth/login` | None | Redirect to Google OAuth consent screen | +| `GET /auth/callback` | None | OAuth token exchange, set session cookie | +| `GET /auth/logout` | None | Clear session, redirect to landing | +| `GET /status` | Auth | JSON: `{ userId, email, container: { status, ... } }` | +| `GET /admin` | Auth | Teams dashboard | +| `GET /admin/team/new` | Auth | Create team form | +| `POST /admin/team/create` | Auth | Submit new team | +| `GET /admin/team/:id` | Member | Team detail: members, invites | +| `POST /admin/team/:id` | Admin | Invite or remove a member | +| `GET /admin/accept-invite?token=` | Auth | Accept invite, join team | --- ## Local development ```bash -npm run dev # wrangler dev (no real containers; simulates Workers runtime) -npm run build # type-check only +npm run dev # wrangler dev — simulates Workers runtime (no real containers) +npm run build # TypeScript type-check only +``` + +In dev mode leave all auth vars empty and append `?user=yourname` to switch between simulated users: + ``` +http://localhost:8787/?user=alice +http://localhost:8787/admin?user=alice +``` + +--- + +## Troubleshooting + +**Container won't start** +Run `npx wrangler containers list --status`. Cold starts can take up to ~13 seconds on first request after a sleep period. + +**R2 mount failing / workspace empty after restart** +Check that all three R2 secrets are set (`R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY`, `R2_ACCOUNT_ID`). The container logs will show `[entrypoint] R2 mount ready` or the fallback message. + +**WakaTime not tracking** +Open the Command Palette → **WakaTime: API Key** and verify the key. Alternatively check `cat ~/.wakatime.cfg` in the integrated terminal. + +**Google OAuth "redirect_uri_mismatch"** +The redirect URI in Google Cloud Console must exactly match `https://.workers.dev/auth/callback` — no trailing slash. + +**Invite link not working** +Invite links expire after 7 days and are single-use. Generate a new one from the team detail page. -In `wrangler dev`, leave all auth vars empty and use `?user=yourname` to switch between simulated users. +**Teams dashboard empty** +Verify `TEAMS_DB` has a valid `database_id` in `wrangler.jsonc` and that `wrangler d1 create vscode-cloud-teams` was run. The D1 schema is created automatically on first `/admin` request.