diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4690eec..a9f5657 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -156,6 +156,11 @@ jobs: steps: - name: Check out code uses: actions/checkout@v5 + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true - name: Set up Bun uses: oven-sh/setup-bun@v2 diff --git a/.gitignore b/.gitignore index b7febfe..3617ff2 100644 --- a/.gitignore +++ b/.gitignore @@ -48,3 +48,6 @@ Thumbs.db /build/ /website/dist/ /packages/profitctl/native/ + +# Browser CLI assets are generated from the current Go source. +/website/public/demo/ diff --git a/examples/website_demo/baseline.yml b/examples/website_demo/baseline.yml new file mode 100644 index 0000000..7db3d3b --- /dev/null +++ b/examples/website_demo/baseline.yml @@ -0,0 +1,37 @@ +# Fictional AI SaaS scenario. These rates are illustrative, not vendor quotes. +project: + name: Fictional Research SaaS - Baseline +fixed_costs: + - name: Sample monthly infrastructure + amount: 400 + period: monthly + layer: infrastructure +variable_costs: + - name: Sample research requests + cost_per_unit: 0.08 + units_per_user: 60 + distribution: uniform + min: 59 + max: 61 + layer: service + - name: Sample other costs per user + cost_per_unit: 1.50 + units_per_user: 1 + distribution: uniform + min: 0.99 + max: 1.01 + layer: application +pricing: + plans: + - name: Standard + price: 20 +covenants: + - type: threshold + field: operating_margin + operator: gte + value: 60 + message: Operating margin must be at least 60% +simulation: + base_users: 1000 + growth_factor: 1 + iterations: 100 diff --git a/examples/website_demo/growth.yml b/examples/website_demo/growth.yml new file mode 100644 index 0000000..afe30b9 --- /dev/null +++ b/examples/website_demo/growth.yml @@ -0,0 +1,37 @@ +# Fictional AI SaaS scenario. Usage rises from 60 to 300 requests per user. +project: + name: Fictional Research SaaS - More Usage +fixed_costs: + - name: Sample monthly infrastructure + amount: 400 + period: monthly + layer: infrastructure +variable_costs: + - name: Sample research requests + cost_per_unit: 0.08 + units_per_user: 300 + distribution: uniform + min: 299 + max: 301 + layer: service + - name: Sample other costs per user + cost_per_unit: 1.50 + units_per_user: 1 + distribution: uniform + min: 0.99 + max: 1.01 + layer: application +pricing: + plans: + - name: Standard + price: 20 +covenants: + - type: threshold + field: operating_margin + operator: gte + value: 60 + message: Operating margin must be at least 60% +simulation: + base_users: 1000 + growth_factor: 1 + iterations: 100 diff --git a/examples/website_demo/optimized.yml b/examples/website_demo/optimized.yml new file mode 100644 index 0000000..f021232 --- /dev/null +++ b/examples/website_demo/optimized.yml @@ -0,0 +1,37 @@ +# Fictional AI SaaS scenario. A cheaper request path preserves the same usage. +project: + name: Fictional Research SaaS - Cheaper Route +fixed_costs: + - name: Sample monthly infrastructure + amount: 400 + period: monthly + layer: infrastructure +variable_costs: + - name: Sample research requests + cost_per_unit: 0.02 + units_per_user: 300 + distribution: uniform + min: 299 + max: 301 + layer: service + - name: Sample other costs per user + cost_per_unit: 1.50 + units_per_user: 1 + distribution: uniform + min: 0.99 + max: 1.01 + layer: application +pricing: + plans: + - name: Standard + price: 20 +covenants: + - type: threshold + field: operating_margin + operator: gte + value: 60 + message: Operating margin must be at least 60% +simulation: + base_users: 1000 + growth_factor: 1 + iterations: 100 diff --git a/website/README.md b/website/README.md index 50e1678..8d48ad4 100644 --- a/website/README.md +++ b/website/README.md @@ -1,21 +1,57 @@ # ProfitCtl Website -Standalone landing page app for ProfitCtl. +Static product website for ProfitCTL, with a real CLI terminal demo, a separate +cost calculator, and local documentation at `/docs/`. ## Env - `PUBLIC_REPO_URL` for GitHub button links -- `PUBLIC_SITE_ORIGIN` for canonical metadata +- `PUBLIC_SITE_ORIGIN` for canonical metadata, the sitemap, and crawler guidance. + The production origin defaults to `https://profitctl.com`. + Set it to the actual public origin before deploying elsewhere. +- Cloudflare Pages: set `GO_VERSION=1.26.8` to match `go.mod`. ## Run +Install the Go version specified by `go.mod` and Bun 1.3.3. Both development and +production builds compile the current CLI for the browser. ```bash -npm install -npm run dev +bun install --frozen-lockfile +bun run dev ``` ## Build ```bash -npm run build +bun run build +bun run typecheck ``` + +The build produces static files in `dist/`, including `robots.txt` and +`sitemap.xml`. It checks the demo calculations, page metadata, social image, +headings, structured data, and local links. The terminal runs the actual CLI as +WebAssembly in a worker, against the fictional files in `examples/website_demo/`. +It validates a baseline, simulates rising usage, and compares a cheaper route. +The story loops automatically. Selecting a step holds its result. Reduced-motion +settings show a single comparison without looping. Playback pauses when the demo is off screen or +the page is hidden. No provider calls or local-file access are enabled. + +The build generates compressed WebAssembly, its matching Go runtime and +license, and scenario downloads in `public/demo/`. These assets are ignored by +Git and rebuilt for each deployment. The loader accepts either an already +decoded HTTP response or a compressed file. The real WebAssembly commands and +read-only workspace are checked by `scripts/check-terminal-demo.mjs`. + +The separate calculator remains a simplified illustration. Neither it nor the +terminal scenario uses live provider prices. + +The header and footer pair the ProfitCTL wordmark with the approved bold-plus +calculator mark. Browser icons and the sharing thumbnail use muted sage and +forest colors. Icon links and the sharing image are versioned to refresh caches. Development credit appears on +both pages and in their structured metadata: IntelIP and Hudson Aikins. + +Local source and a successful build do not establish publication +or search indexing. Deployment and domain changes require maintainer approval. + +The homepage and documentation follow the browser light or dark preference through +native CSS, including cards, controls, text, and browser theme colors. diff --git a/website/astro.config.mjs b/website/astro.config.mjs index 7b42345..a607118 100644 --- a/website/astro.config.mjs +++ b/website/astro.config.mjs @@ -1,6 +1,8 @@ import { defineConfig } from "astro/config"; export default defineConfig({ + site: process.env.PUBLIC_SITE_ORIGIN || "https://profitctl.com", + trailingSlash: "always", compressHTML: true, devToolbar: { enabled: false, diff --git a/website/package.json b/website/package.json index 1e44a57..e19b461 100644 --- a/website/package.json +++ b/website/package.json @@ -5,8 +5,8 @@ "type": "module", "packageManager": "bun@1.3.3", "scripts": { - "dev": "astro dev", - "build": "astro build", + "dev": "node scripts/build-terminal-demo.mjs && astro dev", + "build": "node scripts/build-terminal-demo.mjs && bun scripts/check-terminal-demo.mjs && astro build && bun scripts/check-demo.mjs && node scripts/check-seo.mjs", "preview": "astro preview", "typecheck": "astro check && tsc --noEmit" }, diff --git a/website/public/apple-touch-icon.png b/website/public/apple-touch-icon.png new file mode 100644 index 0000000..bdd292b Binary files /dev/null and b/website/public/apple-touch-icon.png differ diff --git a/website/public/favicon-96.png b/website/public/favicon-96.png new file mode 100644 index 0000000..5bae826 Binary files /dev/null and b/website/public/favicon-96.png differ diff --git a/website/public/favicon.ico b/website/public/favicon.ico new file mode 100644 index 0000000..1b6f7c8 Binary files /dev/null and b/website/public/favicon.ico differ diff --git a/website/public/favicon.svg b/website/public/favicon.svg new file mode 100644 index 0000000..ea856a8 --- /dev/null +++ b/website/public/favicon.svg @@ -0,0 +1 @@ +ProfitCtl diff --git a/website/public/icons/profitctl-192.png b/website/public/icons/profitctl-192.png new file mode 100644 index 0000000..a6cc351 Binary files /dev/null and b/website/public/icons/profitctl-192.png differ diff --git a/website/public/images/profitctl-social-calculator.jpg b/website/public/images/profitctl-social-calculator.jpg new file mode 100644 index 0000000..8d1d563 Binary files /dev/null and b/website/public/images/profitctl-social-calculator.jpg differ diff --git a/website/public/images/profitctl-social-calculator.svg b/website/public/images/profitctl-social-calculator.svg new file mode 100644 index 0000000..f15ed9e --- /dev/null +++ b/website/public/images/profitctl-social-calculator.svg @@ -0,0 +1,15 @@ + + + + + ProfitCTL + OPEN-SOURCE COST MODELING FOR AI PRODUCTS + Know your AI costs. + Protect your margin. + Model a change. Compare your options. Decide before you ship. + + + + + ProfitCTL · MIT licensed + diff --git a/website/public/images/profitctl-social.jpg b/website/public/images/profitctl-social.jpg new file mode 100644 index 0000000..1d2eaca Binary files /dev/null and b/website/public/images/profitctl-social.jpg differ diff --git a/website/public/images/profitctl-social.svg b/website/public/images/profitctl-social.svg new file mode 100644 index 0000000..ebc9feb --- /dev/null +++ b/website/public/images/profitctl-social.svg @@ -0,0 +1,13 @@ + + + + + ProfitCTL + OPEN-SOURCE COST MODELING FOR AI PRODUCTS + Know your AI costs. + Protect your margin. + Model a change. Compare your options. Decide before you ship. + + + ProfitCTL · MIT licensed + diff --git a/website/scripts/build-terminal-demo.mjs b/website/scripts/build-terminal-demo.mjs new file mode 100644 index 0000000..0d285e8 --- /dev/null +++ b/website/scripts/build-terminal-demo.mjs @@ -0,0 +1,37 @@ +import { execFileSync } from "node:child_process"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { gzipSync } from "node:zlib"; + +const repository = fileURLToPath(new URL("../../", import.meta.url)); +const output = new URL("../public/demo/", import.meta.url); +const temporary = await mkdtemp(join(tmpdir(), "profitctl-browser-")); +try { + execFileSync("go", ["build", "-ldflags=-s -w -X main.version=browser-demo", "-o", join(temporary, "profitctl.wasm"), "."], { + cwd: repository, + env: { ...process.env, GOOS: "js", GOARCH: "wasm", CGO_ENABLED: "0" }, + stdio: "inherit", + }); + const compressed = gzipSync(await readFile(join(temporary, "profitctl.wasm")), { level: 9 }); + if (compressed.length > 25 * 1024 * 1024) throw new Error("Browser demo exceeds the Pages asset limit."); + await mkdir(output, { recursive: true }); + await writeFile(new URL("profitctl.wasm.gz", output), compressed); + const goRoot = execFileSync("go", ["env", "GOROOT"], { cwd: repository, encoding: "utf8" }).trim(); + for (const [source, name] of [["lib/wasm/wasm_exec.js", "wasm_exec.js"], ["LICENSE", "GO-LICENSE.txt"]]) { + const destination = new URL(name, output); + await rm(destination, { force: true }); + await writeFile(destination, await readFile(join(goRoot, source))); + } + const scenarios = {}; + for (const name of ["baseline", "growth", "optimized"]) { + const text = await readFile(join(repository, "examples/website_demo", `${name}.yml`), "utf8"); + scenarios[`demo/${name}.yml`] = text; + await writeFile(new URL(`${name}.yml`, output), text); + } + await writeFile(new URL("scenarios.json", output), JSON.stringify(scenarios)); + console.log(`Real ProfitCTL browser demo built (${(compressed.length / 1024 / 1024).toFixed(2)} MiB compressed).`); +} finally { + await rm(temporary, { recursive: true, force: true }); +} diff --git a/website/scripts/check-demo.mjs b/website/scripts/check-demo.mjs new file mode 100644 index 0000000..8ee6e51 --- /dev/null +++ b/website/scripts/check-demo.mjs @@ -0,0 +1,26 @@ +import assert from "node:assert/strict"; +import { calculateExample } from "../src/scripts/demo.ts"; + +const feature = calculateExample({ users: 1000, requests: 60, price: 20 }); +assert.equal(feature.revenue, 20000); +assert.equal(feature.baseline.cost, 2500); +assert.equal(feature.candidate.cost, 6700); +assert.equal(feature.candidate.margin, 66.5); +assert.equal(feature.addedCost, 4200); +assert.equal(feature.minimumPrice, 16.75); +assert.equal(feature.passes, true); + +const pricing = calculateExample({ users: 1000, requests: 60, price: 12 }); +assert.ok(Math.abs(pricing.candidate.margin - 44.1666666667) < 1e-8); +assert.equal(pricing.passes, false); +const growth = calculateExample({ users: 10000, requests: 220, price: 20 }); +assert.equal(growth.candidate.cost, 191400); +assert.equal(growth.candidate.margin, 4.3); +assert.equal(growth.passes, false); +assert.equal(calculateExample({ users: 1000, requests: 0, price: 20 }).addedCost, 0); +for (const bad of [0, -1, NaN, Infinity]) { + assert.throws(() => calculateExample({ users: bad, requests: 60, price: 20 }), RangeError); + assert.throws(() => calculateExample({ users: 1000, requests: 60, price: bad }), RangeError); +} +assert.throws(() => calculateExample({ users: 1000, requests: -1, price: 20 }), RangeError); +console.log("Interactive example: costs, margin decisions, and invalid inputs verified."); diff --git a/website/scripts/check-seo.mjs b/website/scripts/check-seo.mjs new file mode 100644 index 0000000..42195a1 --- /dev/null +++ b/website/scripts/check-seo.mjs @@ -0,0 +1,90 @@ +import assert from "node:assert/strict"; +import { readFile, stat } from "node:fs/promises"; + +const output = new URL("../dist/", import.meta.url); +const [html, robots, sitemap] = await Promise.all( + ["index.html", "robots.txt", "sitemap.xml"].map((file) => + readFile(new URL(file, output), "utf8"), + ), +); +const origin = process.env.PUBLIC_SITE_ORIGIN || "https://profitctl.com"; +const canonical = new URL("./", origin).href; +assert.ok(html.includes(`rel="canonical" href="${canonical}"`)); +assert.ok(html.includes(`property="og:url" content="${canonical}"`)); +assert.ok(html.includes('name="robots" content="index, follow"')); +assert.ok(robots.includes("User-agent: *\nAllow: /\n")); +assert.ok(robots.includes(`Sitemap: ${new URL("sitemap.xml", origin)}`)); +assert.ok(sitemap.includes(`${canonical}`)); +const schema = JSON.parse( + html.match(/]*type="application\/ld\+json"[^>]*>(.*?)<\/script>/s)[1], +); +assert.equal(schema["@type"], "SoftwareApplication"); +assert.equal(schema.url, canonical); +const siteSchema = [...html.matchAll(/]*type="application\/ld\+json"[^>]*>(.*?)<\/script>/sg)] + .map((match) => JSON.parse(match[1])) + .find((data) => data["@type"] === "WebSite"); +assert.equal(siteSchema?.name, "ProfitCTL"); +assert.equal(siteSchema?.url, canonical); + +const routes = ["/", "/docs/"]; +const pages = new Map(); +for (const route of routes) { + const page = await readFile(new URL(route === "/" ? "index.html" : "docs/index.html", output), "utf8"); + const url = new URL(route, origin).href; + pages.set(route, page); + assert.ok(!page.includes("profitctl.intelip.co"), `Retired domain in page: ${route}`); + assert.ok(page.includes(`rel="canonical" href="${url}"`), `Missing canonical: ${route}`); + assert.ok(page.includes(`property="og:url" content="${url}"`), `Missing social URL: ${route}`); + assert.ok(page.includes(`property="og:image" content="${new URL("/images/profitctl-social-calculator.jpg", origin).href}"`)); + assert.ok(page.includes('name="twitter:card" content="summary_large_image"')); + assert.equal([...page.matchAll(/([^<]+)<\/title>/)?.[1].length > 20); + assert.ok(page.match(/name="description" content="([^"]+)"/)?.[1].length > 80); + assert.ok(sitemap.includes(`${url}`), `Missing sitemap entry: ${route}`); + const data = JSON.parse(page.match(/]*type="application\/ld\+json"[^>]*>(.*?)<\/script>/s)[1]); + assert.equal(data["@type"], route === "/" ? "SoftwareApplication" : "TechArticle"); + assert.equal(data.url, url); + assert.equal(data.author.name, "Hudson Aikins"); + assert.equal(data.publisher.name, "IntelIP"); + assert.ok(page.includes('name="author" content="IntelIP and Hudson Aikins"')); + assert.ok(page.includes('Developed by IntelIP and Hudson Aikins.')); + assert.ok(page.includes('>GitHub ')); + assert.ok(page.includes('rel="icon" type="image/png" sizes="96x96" href="/favicon-96.png?v=calculator"'), `Missing crawlable favicon: ${route}`); + assert.ok(page.includes('rel="icon" type="image/svg+xml" sizes="any" href="/favicon.svg?v=calculator"')); + assert.ok(page.includes('rel="shortcut icon" href="/favicon.ico?v=calculator"')); + assert.ok(page.includes('rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png?v=calculator"')); + assert.ok(page.includes('name="color-scheme" content="light dark"')); + assert.ok(page.includes('name="theme-color" content="#f5f5ee" media="(prefers-color-scheme: light)"')); + assert.ok(page.includes('name="theme-color" content="#080e0c" media="(prefers-color-scheme: dark)"')); + assert.ok(!page.includes('class="brand-mark"'), `Old letter badge: ${route}`); + assert.ok(page.includes('aria-label="ProfitCTL home">ProfitCTL')); +} + +for (const [route, page] of pages) { + for (const match of page.matchAll(/]*href="([^"]+)"[^>]*>/g)) { + const link = new URL(match[1], new URL(route, origin)); + if (link.origin !== new URL(origin).origin) continue; + if (/\bdownload(?:[\s=>])/.test(match[0])) { + assert.ok((await stat(new URL(link.pathname.slice(1), output))).isFile(), `Missing download: ${match[1]}`); + continue; + } + const target = pages.get(link.pathname); + assert.ok(target, `Unknown internal destination: ${match[1]}`); + if (link.hash) assert.ok(target.includes(`id="${decodeURIComponent(link.hash.slice(1))}"`), `Missing linked section: ${match[1]}`); + } +} +assert.ok(html.includes('id="demo"') && html.includes("Real CLI")); +assert.ok(html.includes('id="calculator"') && html.includes("Interactive example")); +assert.ok((await stat(new URL("images/profitctl-social-calculator.jpg", output))).size > 10000); +for (const [file, size] of [["favicon-96.png", 96], ["icons/profitctl-192.png", 192], ["apple-touch-icon.png", 180]]) { + const icon = await readFile(new URL(file, output)); + assert.equal(icon.subarray(1, 4).toString(), "PNG", `Invalid icon: ${file}`); + assert.equal(icon.readUInt32BE(16), size, `Wrong icon width: ${file}`); + assert.equal(icon.readUInt32BE(20), size, `Wrong icon height: ${file}`); +} +const legacyIcon = await readFile(new URL("favicon.ico", output)); +assert.equal(legacyIcon.readUInt16LE(2), 1); +assert.equal(legacyIcon.readUInt16LE(4), 4); +assert.ok((await readFile(new URL("favicon.svg", output), "utf8")).includes('viewBox="0 0 64 64"')); +assert.ok(schema.image.includes(new URL("/icons/profitctl-192.png", origin).href)); +console.log("Both pages: metadata, brand icons, social image, structured data, sitemap, and internal destinations verified."); diff --git a/website/scripts/check-terminal-demo.mjs b/website/scripts/check-terminal-demo.mjs new file mode 100644 index 0000000..d54cdc8 --- /dev/null +++ b/website/scripts/check-terminal-demo.mjs @@ -0,0 +1,54 @@ +import assert from "node:assert/strict"; +import { webcrypto } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import { createContext, runInContext } from "node:vm"; +import { gunzipSync } from "node:zlib"; +import { createDemoFilesystem } from "../src/scripts/cli-filesystem.ts"; +import { terminalStory } from "../src/scripts/terminal-story.ts"; + +const output = new URL("../public/demo/", import.meta.url); +const [compressed, runtimeSource, files] = await Promise.all([ + readFile(new URL("profitctl.wasm.gz", output)), + readFile(new URL("wasm_exec.js", output), "utf8"), + readFile(new URL("scenarios.json", output), "utf8").then(JSON.parse), +]); +assert.ok(compressed.length < 25 * 1024 * 1024); +const module = await WebAssembly.compile(gunzipSync(compressed)); +async function run(args) { + let transcript = ""; + let exitCode = 0; + const context = createContext({ + fs: createDemoFilesystem(files, (text) => { transcript += text; }), + process: { cwd: () => "/", getuid: () => -1, getgid: () => -1, geteuid: () => -1, getegid: () => -1, getgroups: () => [], pid: -1, ppid: -1, umask: () => 0 }, + crypto: webcrypto, performance, TextEncoder, TextDecoder, Uint8Array, ArrayBuffer, DataView, WebAssembly, + setTimeout, clearTimeout, console, + }); + runInContext(runtimeSource, context); + const runtime = new context.Go(); + runtime.argv = ["profitctl", ...args]; + runtime.env = { NO_COLOR: "1" }; + runtime.exit = (code) => { exitCode = code; }; + await runtime.run(await WebAssembly.instantiate(module, runtime.importObject)); + return { output: transcript, exitCode }; +} +for (const step of terminalStory) { + const result = await run(step.args); + assert.equal(result.exitCode, step.exitCode, step.title + "\n" + result.output); + assert.ok(result.output.length > 0, step.title); +} +const comparisons = await run(["compare", "demo/baseline.yml", "demo/growth.yml", "demo/optimized.yml", "--json"]); +assert.equal(comparisons.exitCode, 1, comparisons.output); +const comparison = JSON.parse(comparisons.output); +assert.deepEqual(comparison.scenarios.map((scenario) => scenario.name), ["baseline", "growth", "optimized"]); +for (const [index, margin] of [66.5, -29.5, 60.5].entries()) { + assert.ok(Math.abs(comparison.scenarios[index].operating_margin - margin) < 1e-8); +} +assert.deepEqual(comparison.scenarios.map((scenario) => scenario.covenants_passed), [true, false, true]); +assert.equal(comparison.scenarios[1].revenue, comparison.scenarios[2].revenue); +assert.ok(comparison.scenarios[2].cost_per_user < comparison.scenarios[1].cost_per_user); +const replay = await run(terminalStory[1].args); +assert.equal(replay.exitCode, 0, replay.output); +const unavailable = await run(["validate", "-f", "/etc/passwd"]); +assert.equal(unavailable.exitCode, 2); +assert.match(unavailable.output, /failed to read config/); +console.log("Real WebAssembly CLI: all story commands, a repeated run, and workspace isolation verified."); diff --git a/website/src/components/TerminalDemo.astro b/website/src/components/TerminalDemo.astro new file mode 100644 index 0000000..65e3714 --- /dev/null +++ b/website/src/components/TerminalDemo.astro @@ -0,0 +1,50 @@ +--- +import { terminalStory } from "../scripts/terminal-story"; +--- +
+

01 / ProfitCTL in action

Watch a margin problem unfold.

Run the product in your browser. Follow a fictional AI SaaS from healthy margins to higher usage, then compare a cheaper route.

+
+
+