diff --git a/.env.example b/.env.example
new file mode 100644
index 000000000..ab2e16195
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,142 @@
+# corvus/.env.example
+#
+# Canonical list of environment variables used across this repository.
+# - DO NOT store real secrets here. Use CI secrets or your local secret manager (direnv, .env.local, etc.).
+# - This file is a documentation + example file. Copy it to .env for local development and fill in real values where required.
+# - Frontend/public envs: the shared getEnv helper accepts keys with no prefix, PUBLIC_ or VITE_.
+
+########################################
+# Global / environment
+########################################
+# NODE_ENV - common runtime mode. optional. values: development|production
+NODE_ENV=development
+
+# Generic site fallback used by resolveSiteUrl() when provider-specific keys are missing
+# Example: https://example.com or http://localhost:4321
+SITE_URL=http://localhost:9988
+
+########################################
+# Local dev ports (sane defaults used by shared env helper)
+########################################
+# These are the PORTS mapping used across the web packages. Change only if you have port conflicts.
+CHAT_PORT=4323
+DOCS_PORT=4321
+MARKETING_PORT=9988
+PLUGINS_PORT=9990
+
+########################################
+# Web apps / Static sites
+########################################
+# Docs site
+DOCS_URL=http://localhost:${DOCS_PORT}
+# Marketing site - provider-aware. Local default uses MARKETING_PORT above.
+MARKETING_URL=http://localhost:${MARKETING_PORT}
+
+# Hosting provider fallbacks (set by provider automatically in their build environments)
+# These are read by resolveSiteUrl({ providerKeys: { cloudflare: 'CF_PAGES_URL', vercel: 'VERCEL_URL', netlify: 'URL' }})
+CF_PAGES_URL=
+URL=
+VERCEL_URL=
+
+########################################
+# Frontend public keys (safe for client; mark explicitly if client-side)
+########################################
+# Public keys that are intentionally exposed to browser code should use PUBLIC_ or VITE_ prefix
+# Example (marketing analytics; safe to be public):
+PUBLIC_AHREFS_KEY=your_ahrefs_public_key_here
+
+########################################
+# Agent runtime / backend (Corvus)
+########################################
+# CORVUS_API_KEY - API key for internal agent auth (keep secret)
+CORVUS_API_KEY=your_corvus_api_key_here
+CORVUS_GATEWAY_HOST=127.0.0.1
+CORVUS_GATEWAY_PORT=4000
+CORVUS_OPEN_SKILLS_ENABLED=false
+CORVUS_WORKSPACE=default
+
+########################################
+# SurrealDB (primary app DB for agent runtime)
+########################################
+# Use a local dev SurrealDB for development. Production should be set via secrets.
+CORVUS_SURREALDB_DATABASE=corvus_db
+CORVUS_SURREALDB_NAMESPACE=corvus
+CORVUS_SURREALDB_PASSWORD=your_local_dev_db_password_here
+CORVUS_SURREALDB_TOKEN=
+CORVUS_SURREALDB_URL=http://127.0.0.1:8000
+CORVUS_SURREALDB_USERNAME=root
+
+# Test-specific SurrealDB (used by CI/tests when present)
+CORVUS_TEST_SURREALDB_DATABASE=corvus_test_db
+CORVUS_TEST_SURREALDB_NAMESPACE=corvus_test
+CORVUS_TEST_SURREALDB_URL=http://127.0.0.1:8001
+
+########################################
+# Third-party provider keys (examples/placeholders)
+########################################
+# Google APIs (if used)
+GOOGLE_API_KEY=your_google_api_key_here
+
+# Anthropic / OpenAI / LLM providers
+ANTHROPIC_API_KEY=your_anthropic_api_key_here
+OPENAI_API_KEY=your_openai_api_key_here
+
+# GitHub / CI tokens
+GH_TOKEN=your_gh_token_here
+GITHUB_TOKEN=your_github_token_here
+
+########################################
+# CI, release & publishing secrets (store in GitHub Secrets / CI provider)
+########################################
+# GPG / signing used in release pipelines (example placeholders only)
+SIGNING_IN_MEMORY_KEY=your_signing_key_base64_here
+SIGNING_IN_MEMORY_KEY_PASSWORD=your_signing_key_password_here
+
+# Maven Central
+MAVEN_CENTRAL_USERNAME=your_maven_username
+MAVEN_CENTRAL_PASSWORD=your_maven_password
+
+# Cargo / crates.io
+CARGO_REGISTRY_TOKEN=your_crates_io_token_here
+
+# NPM / packages
+NPM_TOKEN=your_npm_token_here
+
+# DockerHub
+DOCKERHUB_USERNAME=your_dockerhub_username_here
+DOCKERHUB_TOKEN=your_dockerhub_token_here
+
+# Code quality / telemetry
+SONAR_TOKEN=
+CODECOV_TOKEN=
+
+########################################
+# Gradle / build / repo config
+########################################
+# Project coordinates and build cache credentials (optional)
+GROUP=com.profiletailors
+VERSION=0.1.0
+BUILD_CACHE_USER=
+BUILD_CACHE_PWD=
+PRIREPO_URL=
+PRIREPO_USERNAME=
+PRIREPO_PASSWORD=
+ENABLE_LOCAL_CONFIG=true
+
+########################################
+# Optional / feature toggles & local helpers
+########################################
+# If you need a shorter local override for SITE_URL per app, set the specific one above (MARKETING_URL, DOCS_URL, ...)
+
+# Example debug flags
+DEBUG=true
+
+########################################
+# Notes
+########################################
+# - Copy this file to .env and fill in the secret values before running local services.
+# - For CI and releases: add all SECRET values (signing keys, repository tokens, package tokens) to your
+# repository's GitHub Secrets or your CI provider's secret store. Never commit real credentials to the repo.
+# - Frontend/public variables: prefix with PUBLIC_ or VITE_ if they are safe to expose to the browser.
+# - The repo's shared env helper will prefer provider-specific envs (CF_PAGES_URL, VERCEL_URL, URL) when present
+# and fall back to the primary key (MARKETING_URL / DOCS_URL / SITE_URL) for site resolution.
diff --git a/.github/config/changelog.json b/.github/config/changelog.json
old mode 100755
new mode 100644
index 1906b4a91..d9edd6f0e
--- a/.github/config/changelog.json
+++ b/.github/config/changelog.json
@@ -1,8 +1,7 @@
{
- "$schema": "https://raw.githubusercontent.com/mikepenz/release-changelog-builder-action/main/configs/json-schema.json",
- "template": "## 📢 What's Changed\n\n#{{CHANGELOG}}\n\n🔐 Uncategorized Changes
\n\n#{{UNCATEGORIZED}}\n \n\n## Contributors:\n- #{{CONTRIBUTORS}}",
- "commit_template": "- #{{TITLE}} #{{MERGE_SHA}}",
- "pr_template": "- #{{TITLE}} - #{{MERGE_SHA}} - ##{{NUMBER}}",
+ "template": "## 📢 What's Changed\n\n{{CHANGELOG}}\n\n\n🔐 Uncategorized Changes
\n\n{{UNCATEGORIZED}}\n \n\n## Contributors:\n- {{CONTRIBUTORS}}",
+ "commit_template": "- {{TITLE}} ({{MERGE_SHA}})",
+ "pr_template": "- {{TITLE}} — #{{NUMBER}} (merge {{MERGE_SHA}})",
"empty_template": "- No changes in this release",
"exclude_merge_branches": ["hotfix/temp-*", "owner/qa", "owner/test"],
"tag_resolver": {
@@ -73,13 +72,13 @@
"on_property": "title",
"method": "regexr",
"pattern": "!!\\s*$",
- "target": "$'pr|breaking-change"
+ "target": "pr|breaking-change"
},
{
"on_property": "title",
"method": "regexr",
- "pattern": "\\.\\.\\s*$",
- "target": "$'pr|changelog-ignore"
+ "pattern": "\\.\\.\\.\\s*$",
+ "target": "pr|changelog-ignore"
},
{
"on_property": "title",
@@ -89,7 +88,7 @@
}
],
"sort": {
- "order": "ASC",
+ "order": "DESC",
"on_property": "mergedAt"
},
"max_tags_to_fetch": 200,
diff --git a/clients/web/apps/docs/astro.config.mjs b/clients/web/apps/docs/astro.config.mjs
index 4043febc7..dbf9fbd1f 100644
--- a/clients/web/apps/docs/astro.config.mjs
+++ b/clients/web/apps/docs/astro.config.mjs
@@ -1,10 +1,55 @@
import starlight from "@astrojs/starlight";
import { defineConfig } from "astro/config";
import { viewTransitions } from "astro-vtbot/starlight-view-transitions";
+import { getPortFromUrl, PORTS, resolveSiteUrl } from "@corvus/shared/env";
+import { loadEnv } from "vite";
+
+const DEFAULT_DEV_URL = `http://localhost:${PORTS.DOCS}`;
+const DEFAULT_PROD_URL = "https://docs.profiletailors.com";
+
+const mode = process.env.NODE_ENV || "production";
+const env = loadEnv(mode, process.cwd(), "");
+const docsUrl = resolveSiteUrl({
+ env,
+ primaryKey: "DOCS_URL",
+ localDefault: DEFAULT_DEV_URL,
+ productionDefault: DEFAULT_PROD_URL,
+ genericKeys: ["SITE_URL"],
+ providerKeys: {
+ cloudflare: "CF_PAGES_URL",
+ vercel: "VERCEL_URL",
+ netlify: "URL",
+ },
+ isProdLike: mode === "production",
+});
+const resolvedPort = getPortFromUrl(docsUrl, PORTS.DOCS);
+
+function computeBaseFromUrl(url) {
+ if (!url) return "/";
+ try {
+ const pathname = new URL(url).pathname || "/";
+ // If root, keep '/'
+ if (pathname === "/") return "/";
+ // Remove trailing slash for non-root paths
+ return pathname.endsWith("/") ? pathname.slice(0, -1) : pathname;
+ } catch (e) {
+ return "/";
+ }
+}
+
+const base = computeBaseFromUrl(docsUrl);
export default defineConfig({
- site: "https://dallay.github.io",
- base: "/corvus",
+ site: docsUrl,
+ base, // computed from the provider/site URL so subpath deployments work
+ server: {
+ host: true,
+ port: resolvedPort,
+ },
+ preview: {
+ host: true,
+ port: resolvedPort,
+ },
integrations: [
starlight({
title: "Corvus",
diff --git a/clients/web/packages/shared/test/env.test.mjs b/clients/web/packages/shared/test/env.test.mjs
new file mode 100644
index 000000000..4a151f878
--- /dev/null
+++ b/clients/web/packages/shared/test/env.test.mjs
@@ -0,0 +1,115 @@
+import { describe, it, expect } from "vitest";
+import { resolveSiteUrl, getPortFromUrl, PORTS } from "../env.mjs";
+
+const DEFAULT_DEV_URL = `http://localhost:${PORTS.DOCS}`;
+const DEFAULT_PROD_URL = "https://docs.profiletailors.com";
+
+describe("resolveSiteUrl and getPortFromUrl matrix", () => {
+ it("resolveSiteUrl_prefers_DOCS_URL_over_providers_and_falls_back_to_SITE_URL", () => {
+ // Explicit DOCS_URL should win
+ const explicit = resolveSiteUrl({
+ env: { DOCS_URL: "http://explicit.example/path" },
+ primaryKey: "DOCS_URL",
+ localDefault: DEFAULT_DEV_URL,
+ productionDefault: DEFAULT_PROD_URL,
+ genericKeys: ["SITE_URL"],
+ providerKeys: { cloudflare: "CF_PAGES_URL", vercel: "VERCEL_URL", netlify: "URL" },
+ isProdLike: false,
+ });
+ expect(explicit).toBe("http://explicit.example/path");
+
+ // Cloudflare provider should be selected when DOCS_URL missing
+ const cloudflare = resolveSiteUrl({
+ env: { CF_PAGES_URL: "docs.cloudflare.test/sub" },
+ primaryKey: "DOCS_URL",
+ localDefault: DEFAULT_DEV_URL,
+ productionDefault: DEFAULT_PROD_URL,
+ genericKeys: ["SITE_URL"],
+ providerKeys: { cloudflare: "CF_PAGES_URL", vercel: "VERCEL_URL", netlify: "URL" },
+ isProdLike: false,
+ });
+ expect(cloudflare).toBe("https://docs.cloudflare.test/sub");
+
+ // Vercel provider
+ const vercel = resolveSiteUrl({
+ env: { VERCEL_URL: "my-vercel.app/docs" },
+ primaryKey: "DOCS_URL",
+ localDefault: DEFAULT_DEV_URL,
+ productionDefault: DEFAULT_PROD_URL,
+ genericKeys: ["SITE_URL"],
+ providerKeys: { cloudflare: "CF_PAGES_URL", vercel: "VERCEL_URL", netlify: "URL" },
+ isProdLike: false,
+ });
+ expect(vercel).toBe("https://my-vercel.app/docs");
+
+ // Netlify provider (URL)
+ const netlify = resolveSiteUrl({
+ env: { URL: "https://netlify.example/base/" },
+ primaryKey: "DOCS_URL",
+ localDefault: DEFAULT_DEV_URL,
+ productionDefault: DEFAULT_PROD_URL,
+ genericKeys: ["SITE_URL"],
+ providerKeys: { cloudflare: "CF_PAGES_URL", vercel: "VERCEL_URL", netlify: "URL" },
+ isProdLike: false,
+ });
+ expect(netlify).toBe("https://netlify.example/base");
+
+ // Generic SITE_URL fallback
+ const generic = resolveSiteUrl({
+ env: { SITE_URL: "http://fallback.test:9001/fpath" },
+ primaryKey: "DOCS_URL",
+ localDefault: DEFAULT_DEV_URL,
+ productionDefault: DEFAULT_PROD_URL,
+ genericKeys: ["SITE_URL"],
+ providerKeys: { cloudflare: "CF_PAGES_URL", vercel: "VERCEL_URL", netlify: "URL" },
+ isProdLike: false,
+ });
+ expect(generic).toBe("http://fallback.test:9001/fpath");
+
+ // No envs -> dev fallback
+ const fallbackDev = resolveSiteUrl({
+ env: {},
+ primaryKey: "DOCS_URL",
+ localDefault: DEFAULT_DEV_URL,
+ productionDefault: DEFAULT_PROD_URL,
+ genericKeys: ["SITE_URL"],
+ providerKeys: { cloudflare: "CF_PAGES_URL", vercel: "VERCEL_URL", netlify: "URL" },
+ isProdLike: false,
+ });
+ expect(fallbackDev).toBe(DEFAULT_DEV_URL);
+
+ // No envs -> prod fallback when isProdLike
+ const fallbackProd = resolveSiteUrl({
+ env: {},
+ primaryKey: "DOCS_URL",
+ localDefault: DEFAULT_DEV_URL,
+ productionDefault: DEFAULT_PROD_URL,
+ genericKeys: ["SITE_URL"],
+ providerKeys: { cloudflare: "CF_PAGES_URL", vercel: "VERCEL_URL", netlify: "URL" },
+ isProdLike: true,
+ });
+ expect(fallbackProd).toBe(DEFAULT_PROD_URL);
+ });
+
+ it("getPortFromUrl_derives_port_from_docsUrl", () => {
+ // URL with explicit port
+ const withPort = "http://example.com:8080/path";
+ expect(getPortFromUrl(withPort, PORTS.DOCS)).toBe(8080);
+
+ // URL without port -> fallback
+ const withoutPort = "https://example.com/path";
+ expect(getPortFromUrl(withoutPort, PORTS.DOCS)).toBe(PORTS.DOCS);
+
+ // Derived from resolveSiteUrl result
+ const docsUrl = resolveSiteUrl({
+ env: { DOCS_URL: "http://localhost:12345/docs" },
+ primaryKey: "DOCS_URL",
+ localDefault: DEFAULT_DEV_URL,
+ productionDefault: DEFAULT_PROD_URL,
+ genericKeys: ["SITE_URL"],
+ providerKeys: { cloudflare: "CF_PAGES_URL", vercel: "VERCEL_URL", netlify: "URL" },
+ isProdLike: false,
+ });
+ expect(getPortFromUrl(docsUrl, PORTS.DOCS)).toBe(12345);
+ });
+});