From 1414bca092dba9cd49659135d3d058939e8460da Mon Sep 17 00:00:00 2001 From: Daniel Ehrhardt Date: Thu, 22 Oct 2026 19:46:31 +0200 Subject: [PATCH 1/4] Every install without a key runs free for 7 days Official builds gave 7 days only to installs that were used before licences; a new install was refused until it had a key. Now every install without a key gets 7 free days from its first start. They are stored once, so restarts never extend them. Installs with a key, installs that already have their days and builds from source behave as before. --- packages/core/src/license/license.ts | 35 +++++------ packages/core/test/license.test.ts | 89 ++++++++++++++++++---------- packages/shared/src/license.ts | 4 +- 3 files changed, 74 insertions(+), 54 deletions(-) diff --git a/packages/core/src/license/license.ts b/packages/core/src/license/license.ts index 2834b300..88517748 100644 --- a/packages/core/src/license/license.ts +++ b/packages/core/src/license/license.ts @@ -3,8 +3,8 @@ * * The key and the licence server's last answer live in meta keys `license.*` (not in Settings, which paired phones can * read). The key is checked at start, every 6 hours and on demand against `/api/license`; network errors and 5xx - * keep the last answer. Release builds refuse new runs while the licence is missing, invalid or expired; existing - * installs get 7 days to add a key, and a key that could not be checked yet counts for 7 days. + * keep the last answer. Release builds refuse new runs while the licence is missing, invalid or expired; an install + * without a key runs free for 7 days from its first start, and a key that could not be checked yet counts for 7 days. */ import { LICENSE_INVALID, @@ -17,12 +17,11 @@ import { type LicenseStatus, } from "@godmode/shared"; import { COMPILED, VERSION, config } from "../config"; -import { deleteMeta, get, getMeta, setMeta } from "../db"; +import { deleteMeta, getMeta, setMeta } from "../db"; import { bus } from "../events/bus"; import { logger } from "../log"; import { audit } from "../services/audit"; import { notify } from "../services/notifications"; -import { getSettings } from "../services/settings"; import { HttpError, parseJson } from "../util"; const log = logger("license"); @@ -42,8 +41,9 @@ const META = { verdict: "license.verdict", /** Since when a key has waited for its first answer; kept across key changes until one is answered. */ unverifiedSince: "license.unverified_since", + /** The end of the free week; set once, never moved or cleared. */ graceEndsAt: "license.grace_ends_at", - /** When this install first ran a version with licences (the grace period is decided then, once). */ + /** When this install first ran a version with licences. */ firstStart: "license.first_start", }; @@ -208,13 +208,13 @@ export function licenseState(): LicenseState { let message: string | null = null; if (blocked) { if (status === "missing" && graceEndsAt) { - message = `The 7 days to add a licence key ended on ${shortDate(graceEndsAt)}. Add your key or start the free trial to keep your agents working.`; + message = `Your 7 free days ended on ${shortDate(graceEndsAt)}. Add your licence key or start the free trial to keep your agents working.`; } else if (status === "missing") message = "Godmode needs a licence to start new work. Add your licence key or start the 14-day free trial."; else if (status === "unverified") message = "Godmode couldn't check your licence key for 7 days. Connect to the internet, then refresh the licence."; else if (status === "invalid") message = refusedKeyMessage(verdict!); else message = "Your Godmode licence has ended. Renew it or add another key to keep your agents working."; } else if (status === "grace" && graceEndsAt) { - message = `Add your licence key by ${shortDate(graceEndsAt)}. Until then everything works as before.`; + message = `Free until ${shortDate(graceEndsAt)}. Then add your licence key or start the 14-day free trial.`; } else if (status === "unverified" && unverifiedUntil) { message = `Your key couldn't be checked yet. Godmode accepts it until ${shortDate(unverifiedUntil)} and tries again every few hours.`; } else if (status === "past_due") { @@ -415,16 +415,11 @@ export function noteLicenseRefusal(what: string, err: HttpError) { notify("warning", "Godmode needs an active licence", `${what} couldn't start. ${err.message}`, "/settings/license"); } -/** An install that was used before this version (set up, or with chats, runs or more than the default agent). */ -function usedBefore(): boolean { - const count = (table: string) => get<{ c: number }>(`SELECT COUNT(*) AS c FROM ${table}`)?.c ?? 0; - return getSettings().onboardingComplete || count("runs") > 0 || count("conversations") > 0 || count("agents") > 1; -} - /** * Before anything can start a run: takes the key from `GODMODE_LICENSE` (and keeps it from child processes) — at once - * when none is stored, after the next check when it differs from the stored one — and, on the first start with - * licences, gives an install that was already used 7 days to add a key. + * when none is stored, after the next check when it differs from the stored one — and, in builds that check licences, + * gives the install its 7 free days once, from the first start without a key (a new install: its very first start). + * Restarts never extend them. */ export function initLicense(): void { const fromEnv = process.env.GODMODE_LICENSE; @@ -439,12 +434,10 @@ export function initLicense(): void { log.info("licence key taken from GODMODE_LICENSE", { key: key.slice(-5) }); } else if (stored !== key) pendingEnvKey = key; } - if (!getMeta(META.firstStart)) { - setMeta(META.firstStart, iso(clock())); - if (!getMeta(META.key) && usedBefore()) { - setMeta(META.graceEndsAt, iso(clock() + LICENSE_GRACE_MS)); - log.info("existing install without a licence key: 7 days to add one"); - } + if (!getMeta(META.firstStart)) setMeta(META.firstStart, iso(clock())); + if (licenseEnforced() && !getMeta(META.key) && !getMeta(META.graceEndsAt)) { + setMeta(META.graceEndsAt, iso(clock() + LICENSE_GRACE_MS)); + log.info("no licence key: free for 7 days"); } signature = JSON.stringify(licenseState()); } diff --git a/packages/core/test/license.test.ts b/packages/core/test/license.test.ts index c5b62cea..1c888d63 100644 --- a/packages/core/test/license.test.ts +++ b/packages/core/test/license.test.ts @@ -1,12 +1,11 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test"; import type { LicenseState } from "@godmode/shared"; import { setupEnv, type TestEnv } from "./fixtures/runner-harness"; -import { get, getMeta, run as sql } from "../src/db"; +import { get, getMeta, run as sql, setMeta } from "../src/db"; import { getAccessToken } from "../src/server/auth"; import { ensureDefaultAgent } from "../src/agents/service"; import { waitForRun } from "../src/runner/runner"; import { startChat } from "../src/services/conversations"; -import { updateSettings } from "../src/services/settings"; import { createRoutine } from "../src/services/routines"; import { triggerRoutine } from "../src/scheduler/scheduler"; import { listNotifications } from "../src/services/notifications"; @@ -99,46 +98,74 @@ async function putKey(key: string): Promise<{ status: number; body: LicenseState return { status: res.status, body: await res.json() }; } -describe("grace for installs from before licences", () => { - test("a fresh install gets none and is gated", () => { +describe("the free week", () => { + const day = (ms: number) => new Date(ms).toLocaleDateString("en-US", { month: "short", day: "numeric" }); + + test("a new install without a key runs free for 7 days from its first start, and restarts don't extend them", () => { + const firstStart = new Date(now).toISOString(); + const ends = now + LICENSE_GRACE_MS; initLicense(); const s = licenseState(); - expect(s.status).toBe("missing"); - expect(s.graceEndsAt).toBeNull(); - expect(s.blocked).toBe(true); + expect(s.status).toBe("grace"); + expect(s.blocked).toBe(false); + expect(s.graceEndsAt).toBe(new Date(ends).toISOString()); + expect(s.message).toBe(`Free until ${day(ends)}. Then add your licence key or start the 14-day free trial.`); + expect(() => requireLicense()).not.toThrow(); + + now += 3 * DAY; + initLicense(); + expect(licenseState().graceEndsAt).toBe(s.graceEndsAt); + expect(getMeta("license.first_start")).toBe(firstStart); + + now += 5 * DAY; + initLicense(); + const after = licenseState(); + expect(after.status).toBe("missing"); + expect(after.blocked).toBe(true); + expect(after.graceEndsAt).toBe(s.graceEndsAt); + expect(after.message).toBe(`Your 7 free days ended on ${day(ends)}. Add your licence key or start the free trial to keep your agents working.`); expect(() => requireLicense()).toThrow(LicenseRequiredError); + expect(getMeta("license.first_start")).toBe(firstStart); }); - test("an install that was already used gets 7 days once, never extended", () => { - updateSettings({ onboardingComplete: true }); - try { - initLicense(); - const s = licenseState(); - expect(s.status).toBe("grace"); - expect(s.blocked).toBe(false); - expect(s.graceEndsAt).toBe(new Date(now + LICENSE_GRACE_MS).toISOString()); - expect(s.message).toContain("Add your licence key by"); - - now += 3 * DAY; - initLicense(); - expect(licenseState().graceEndsAt).toBe(s.graceEndsAt); - - now += 5 * DAY; - const after = licenseState(); - expect(after.status).toBe("missing"); - expect(after.blocked).toBe(true); - expect(after.message).toContain("The 7 days to add a licence key ended"); - } finally { - updateSettings({ onboardingComplete: false }); - } + test("an install that already has its days keeps them as they are", () => { + setMeta("license.first_start", new Date(now - 6 * DAY).toISOString()); + setMeta("license.grace_ends_at", new Date(now + DAY).toISOString()); + initLicense(); + expect(licenseState()).toMatchObject({ status: "grace", blocked: false, graceEndsAt: new Date(now + DAY).toISOString() }); + }); + + test("an older install that was refused without a key gets its 7 days from this start", () => { + setMeta("license.first_start", new Date(now - 20 * DAY).toISOString()); + initLicense(); + expect(licenseState()).toMatchObject({ status: "grace", blocked: false, graceEndsAt: new Date(now + LICENSE_GRACE_MS).toISOString() }); + }); + + test("a build that doesn't check licences doesn't start the clock", () => { + __setLicenseEnforcedForTests(false); + initLicense(); + expect(licenseState()).toMatchObject({ status: "missing", graceEndsAt: null, message: null, blocked: false }); }); - test("GODMODE_LICENSE seeds the key, and leaves the environment", () => { + test("GODMODE_LICENSE seeds the key, and leaves the environment; an install with a key gets no free days", () => { process.env.GODMODE_LICENSE = ` ${KEY.toLowerCase()} `; initLicense(); expect(process.env.GODMODE_LICENSE).toBeUndefined(); expect(getMeta("license.key")).toBe(KEY); - expect(licenseState().status).toBe("unverified"); + expect(licenseState()).toMatchObject({ status: "unverified", graceEndsAt: null }); + }); + + test("a key that ends during the free week leaves the rest of the week", async () => { + initLicense(); + const ends = now + LICENSE_GRACE_MS; + answer = trial(); + await putKey(KEY); + answer = json({ valid: false, plan: "monthly", status: "canceled", trialEndsAt: null, renewsAt: null, cancelAtPeriodEnd: false, manageUrl: null }); + const s = await refreshLicense(); + expect(s).toMatchObject({ status: "expired", blocked: false }); + expect(s.message).toBe(`This licence has ended. Everything keeps working until ${day(ends)}.`); + now = ends; + expect(licenseState()).toMatchObject({ status: "expired", blocked: true }); }); }); diff --git a/packages/shared/src/license.ts b/packages/shared/src/license.ts index d3b4ecd0..385127a4 100644 --- a/packages/shared/src/license.ts +++ b/packages/shared/src/license.ts @@ -14,7 +14,7 @@ export function isLicenseKey(input: string): boolean { /** * - `active`, `trial`, `past_due` (Stripe still retries the payment): the key is good. - * - `grace`: an install from before licences has 7 days to add a key. + * - `grace`: the free week: an install without a key runs free for 7 days from its first start. * - `unverified`: a key that could not be checked yet; accepted for 7 days after it was entered. * - `missing`, `invalid`, `expired`: new runs are refused (in release builds). */ @@ -31,7 +31,7 @@ export interface LicenseState { trialEndsAt: ISODate | null; renewsAt: ISODate | null; cancelAtPeriodEnd: boolean; - /** Existing installs: the end of the 7-day window to add a key (set once, never extended). */ + /** The end of the free week of an install without a key (set once, never extended). */ graceEndsAt: ISODate | null; /** The end of the window an unverified key is accepted for. */ unverifiedUntil: ISODate | null; From 28114d176c05ffb2b16ac37ea0d97feb9a13aed0 Mon Sep 17 00:00:00 2001 From: Daniel Ehrhardt Date: Thu, 22 Oct 2026 19:46:31 +0200 Subject: [PATCH 2/4] App: say what the free week is and what comes after it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Banner, onboarding, Settings → License and the gate now speak to a new user in the free week: free until a date, then add a key or start the 14-day trial. The banner gets a trial button and stays off the licence page. The trial line under the trial button said 7 days; it is 14. --- .../src/components/license/activate.tsx | 18 ++++++--- .../src/components/license/license-banner.tsx | 36 +++++++++++++----- .../src/components/license/license-gate.tsx | 4 +- .../components/onboarding/activate-step.tsx | 38 +++++++++++++++++-- .../src/components/onboarding/done-step.tsx | 6 ++- .../components/settings/license-section.tsx | 20 ++++++++-- 6 files changed, 96 insertions(+), 26 deletions(-) diff --git a/apps/desktop/src/components/license/activate.tsx b/apps/desktop/src/components/license/activate.tsx index cd65e4df..5a7ad2a8 100644 --- a/apps/desktop/src/components/license/activate.tsx +++ b/apps/desktop/src/components/license/activate.tsx @@ -114,6 +114,16 @@ export function LicenseKeyForm({ ); } +export function KeyDivider({ className }: { className?: string }) { + return ( +
+ + Already have a key? + +
+ ); +} + /** Everything it takes to get a licence: the trial, the plans, a key, and help when the key is lost. */ export function ActivatePanel({ source, @@ -143,14 +153,10 @@ export function ActivatePanel({

- {opened ? "Finish the checkout in your browser, then paste your licence key below." : "7 days free, then $39 a month. Cancel any time."} + {opened ? "Finish the checkout in your browser, then paste your licence key below." : "14 days free, then $39 a month. Cancel any time."}

-
- - Already have a key? - -
+ diff --git a/apps/desktop/src/components/license/license-banner.tsx b/apps/desktop/src/components/license/license-banner.tsx index b694da16..6e19d877 100644 --- a/apps/desktop/src/components/license/license-banner.tsx +++ b/apps/desktop/src/components/license/license-banner.tsx @@ -1,9 +1,9 @@ import { useState, type ReactNode } from "react"; -import { useNavigate } from "react-router"; +import { useLocation, useNavigate } from "react-router"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { AnimatePresence, motion } from "motion/react"; import { differenceInCalendarDays, format } from "date-fns"; -import { CreditCard, KeyRound, Sparkles, X } from "lucide-react"; +import { CreditCard, Gift, KeyRound, Sparkles, X } from "lucide-react"; import type { LicenseState } from "@godmode/shared"; import { Button } from "@/components/ui/button"; import { api } from "@/lib/api"; @@ -12,6 +12,7 @@ import { openExternal } from "@/lib/desktop"; import { useLicense } from "@/lib/hooks"; import { qk } from "@/lib/queryKeys"; import { cn } from "@/lib/utils"; +import { checkoutUrl } from "./activate"; const DAY_MS = 86_400_000; @@ -22,7 +23,13 @@ interface Notice { tone: "info" | "warning"; icon: ReactNode; text: ReactNode; - action: { label: string; run: () => void } | null; + action: Action | null; + secondary?: Action; +} + +interface Action { + label: string; + run: () => void; } const today = () => format(new Date(), "yyyy-MM-dd"); @@ -41,7 +48,7 @@ function inDays(iso: string): string { return days <= 0 ? "today" : days === 1 ? "tomorrow" : `in ${days} days`; } -function noticeFor(s: LicenseState, actions: { settings: () => void; manage: () => void; refresh: () => void }): Notice | null { +function noticeFor(s: LicenseState, actions: { settings: () => void; manage: () => void; refresh: () => void; trial: () => void }): Notice | null { if (s.blocked) return null; if (s.status === "trial" && s.trialEndsAt) { const left = new Date(s.trialEndsAt).getTime() - Date.now(); @@ -72,9 +79,10 @@ function noticeFor(s: LicenseState, actions: { settings: () => void; manage: () return { kind: "grace", tone: "info", - icon: , - text: <>Add your licence key by {format(new Date(s.graceEndsAt), "MMMM d")}. Until then everything works as before., - action: { label: "Add key", run: actions.settings }, + icon: , + text: <>Free until {format(new Date(s.graceEndsAt), "MMMM d")}. Then add your licence key or start the 14-day free trial., + action: { label: "Start free trial", run: actions.trial }, + secondary: { label: "Add key", run: actions.settings }, }; } if (s.status === "unverified" && s.unverifiedUntil) { @@ -89,9 +97,10 @@ function noticeFor(s: LicenseState, actions: { settings: () => void; manage: () return null; } -/** One slim line above the page when the licence needs attention soon; dismissed, it stays away for the day. */ +/** One slim line above the page when the licence needs attention soon (not on the licence page); dismissed, it stays away for the day. */ export function LicenseBanner() { const navigate = useNavigate(); + const { pathname } = useLocation(); const qc = useQueryClient(); const { data } = useLicense(); const [, setDismissed] = useState(0); @@ -101,9 +110,13 @@ export function LicenseBanner() { settings: () => navigate("/settings/license"), manage: () => data.manageUrl && void openExternal(data.manageUrl), refresh: () => refresh.mutate(), + trial: () => { + navigate("/settings/license"); + void openExternal(checkoutUrl("banner")); + }, }) : null; - const shown = notice && !dismissedToday(notice.kind) ? notice : null; + const shown = notice && !dismissedToday(notice.kind) && !pathname.startsWith("/settings/license") ? notice : null; const dismiss = (kind: Kind) => { try { @@ -135,6 +148,11 @@ export function LicenseBanner() { > {shown.icon}

{shown.text}

+ {shown.secondary && ( + + )} {shown.action && ( ) )} diff --git a/apps/desktop/src/components/onboarding/done-step.tsx b/apps/desktop/src/components/onboarding/done-step.tsx index 0ac2edb3..58f130c0 100644 --- a/apps/desktop/src/components/onboarding/done-step.tsx +++ b/apps/desktop/src/components/onboarding/done-step.tsx @@ -1,4 +1,5 @@ import { useState } from "react"; +import { format } from "date-fns"; import { motion } from "motion/react"; import { ArrowRight, Bot, CircleCheck, CircleDashed, Plug, ShieldCheck } from "lucide-react"; import { MASCOT_CHARACTER, MASCOT_COLOR, type LicenseState } from "@godmode/shared"; @@ -62,10 +63,11 @@ export function DoneStep({ ]; const license = summary.license; const licensed = !!license?.keyHint && !license.blocked && ["active", "trial", "past_due", "unverified"].includes(license.status); + const freeUntil = license?.enforced && license.status === "grace" && license.graceEndsAt ? format(new Date(license.graceEndsAt), "MMM d") : null; items.push({ label: "Godmode Pro", - ok: licensed ? true : license?.blocked ? false : null, - detail: licensed ? (license!.status === "trial" ? "Free trial active" : "Activated") : license?.status === "grace" ? "Add your key in Settings → License" : "Skipped", + ok: licensed || freeUntil ? true : license?.blocked ? false : null, + detail: licensed ? (license!.status === "trial" ? "Free trial active" : "Activated") : freeUntil ? `Free until ${freeUntil}` : "Skipped", }); if (serverMode) items.push({ diff --git a/apps/desktop/src/components/settings/license-section.tsx b/apps/desktop/src/components/settings/license-section.tsx index 62f8dad6..0c09d516 100644 --- a/apps/desktop/src/components/settings/license-section.tsx +++ b/apps/desktop/src/components/settings/license-section.tsx @@ -1,6 +1,6 @@ import { useState } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { format, formatDistanceToNow } from "date-fns"; +import { differenceInCalendarDays, format, formatDistanceToNow } from "date-fns"; import { ExternalLink, KeyRound, RefreshCw, Sparkles } from "lucide-react"; import { toast } from "sonner"; import type { LicensePlan, LicenseState, LicenseStatus } from "@godmode/shared"; @@ -31,7 +31,7 @@ const STATUS: Record = { const day = (iso: string) => format(new Date(iso), "MMMM d, yyyy"); +function daysLeft(iso: string): string { + const days = differenceInCalendarDays(new Date(iso), new Date()); + return days <= 0 ? "ends today" : days === 1 ? "1 day left" : `${days} days left`; +} + export function LicenseSection() { const license = useLicense(); return ( @@ -98,15 +103,22 @@ function LicenseCard({ state: s }: { state: LicenseState }) { }); if (!s.keyHint) { + const freeWeek = s.status === "grace" && s.graceEndsAt ? s.graceEndsAt : null; return ( <> - {s.message && {s.message}} + {s.message && !freeWeek && {s.message}} } description="Every feature in every plan. Your agents, chats and logins stay on this computer." - actions={{STATUS[s.status].label}} + actions={{status.label}} > + {freeWeek && ( + + {day(freeWeek)} + · {daysLeft(freeWeek)} + + )}
{local ? : Add the licence key in Godmode on the computer itself.}
{!s.enforced && } From 118aad42b0d4297d9242108a6156f56afa2064ca Mon Sep 17 00:00:00 2001 From: Daniel Ehrhardt Date: Thu, 22 Oct 2026 19:46:31 +0200 Subject: [PATCH 3/4] Site: download Godmode without a key /download shows the Mac, Windows and Linux downloads to everyone: free for 7 days, no card. A key from the link or cookie is still shown, to copy into the app. The download endpoints keep their rate limit and only serve the release files; install.sh no longer needs GODMODE_LICENSE. The homepage gets the no-card path next to the trial, and Linux in the hero. --- apps/website/src/components/StickyCta.astro | 1 + .../website/src/components/sections/Faq.astro | 6 +- .../src/components/sections/FinalCta.astro | 7 + .../src/components/sections/Hero.astro | 9 +- .../src/components/sections/Pricing.astro | 10 + apps/website/src/lib/entitlement.ts | 25 --- apps/website/src/pages/download/[platform].ts | 17 +- .../website/src/pages/download/file/[name].ts | 8 +- apps/website/src/pages/download/index.astro | 183 ++++++++++++------ scripts/install.sh | 15 +- 10 files changed, 170 insertions(+), 111 deletions(-) diff --git a/apps/website/src/components/StickyCta.astro b/apps/website/src/components/StickyCta.astro index 57130b26..1c9dfe70 100644 --- a/apps/website/src/components/StickyCta.astro +++ b/apps/website/src/components/StickyCta.astro @@ -10,6 +10,7 @@ import { COUPON, PRICING } from '@/config/site'; {PRICING.trialDays} days free + diff --git a/apps/website/src/components/sections/Faq.astro b/apps/website/src/components/sections/Faq.astro index 05968320..751834af 100644 --- a/apps/website/src/components/sections/Faq.astro +++ b/apps/website/src/components/sections/Faq.astro @@ -27,8 +27,8 @@ const faqs: { key: string; q: string; a: string }[] = [ }, { key: 'trial', - q: 'How does the free trial work?', - a: `Pick monthly or yearly and start your ${PRICING.trialDays}-day trial with a card, Apple Pay or Google Pay. Nothing is charged today. Cancel in the billing portal (one click from the app or your welcome page) before the ${PRICING.trialDays} days are up and you pay nothing. Otherwise your plan starts automatically when the trial ends: $${monthly.price} a month or $${yearly.price} a year, renewing until you cancel.`, + q: 'Can I try it for free?', + a: `Yes, two ways. Download Godmode and use it free for 7 days: no card, no key, every feature. Or pick monthly or yearly and start your ${PRICING.trialDays}-day trial with a card, Apple Pay or Google Pay. Nothing is charged today. Cancel in the billing portal (one click from the app or your welcome page) before the ${PRICING.trialDays} days are up and you pay nothing. Otherwise your plan starts automatically when the trial ends: $${monthly.price} a month or $${yearly.price} a year, renewing until you cancel. Either way, everything you set up stays when you add your licence key.`, }, { key: 'claude', @@ -68,7 +68,7 @@ const faqs: { key: string; q: string; a: string }[] = [ { key: 'free', q: 'Godmode is open source. Why pay?', - a: `You don’t have to, if you can build software yourself. The whole app is on GitHub under the MIT License, free to build and change. Godmode Pro is for everyone else: the ready-to-install apps for Mac, Windows and Linux, automatic updates, the always-on version, and help from the people who build it. Your subscription also pays for Godmode’s development.`, + a: `You don’t have to, if you can build software yourself. The whole app is on GitHub under the MIT License, free to build and change, and your own build never asks for a key. Godmode Pro is for everyone else: the ready-to-install apps for Mac, Windows and Linux (free for 7 days, then with a Pro key), automatic updates, the always-on version, and help from the people who build it. Your subscription also pays for Godmode’s development.`, }, { key: 'cloud', diff --git a/apps/website/src/components/sections/FinalCta.astro b/apps/website/src/components/sections/FinalCta.astro index c95ea8a9..685768a5 100644 --- a/apps/website/src/components/sections/FinalCta.astro +++ b/apps/website/src/components/sections/FinalCta.astro @@ -27,6 +27,13 @@ import { COUPON, FOUNDING, PRICING, yearlySavings } from '@/config/site'; +

$0 today · price locked for life · founding offer ends in diff --git a/apps/website/src/components/sections/Hero.astro b/apps/website/src/components/sections/Hero.astro index cc733098..14d7473e 100644 --- a/apps/website/src/components/sections/Hero.astro +++ b/apps/website/src/components/sections/Hero.astro @@ -54,6 +54,13 @@ import recording from '@/assets/shots/hero-recording.jpg'; Watch it work +

  • Founder sets up your first {FOUNDING.setupAutomations} automations
  • @@ -62,7 +69,7 @@ import recording from '@/assets/shots/hero-recording.jpg';

- Mac and Windows + Mac, Windows and Linux No coding, just plain words Your passwords stay on your computer

diff --git a/apps/website/src/components/sections/Pricing.astro b/apps/website/src/components/sections/Pricing.astro index 77d70b39..50e5d89e 100644 --- a/apps/website/src/components/sections/Pricing.astro +++ b/apps/website/src/components/sections/Pricing.astro @@ -148,6 +148,16 @@ const guarantees = [ } +
+

+ + Not ready for a card? Download Godmode and use it free for 7 days. Add a licence key when you’re ready, and everything you set up stays. +

+ + Download free + +
+

Everything a founding seat gets you

diff --git a/apps/website/src/lib/entitlement.ts b/apps/website/src/lib/entitlement.ts index 59a68848..2aa1b71b 100644 --- a/apps/website/src/lib/entitlement.ts +++ b/apps/website/src/lib/entitlement.ts @@ -1,29 +1,4 @@ import { env } from 'cloudflare:workers'; -import type { AstroCookies } from 'astro'; -import { db } from './db'; -import { isLicenseKey } from './license'; -import { ORDER_COOKIE, verify } from './signed'; /** While the site runs on Stripe test keys, test-mode purchases count; with live keys only live ones do. */ export const minLivemode = () => (env.STRIPE_SECRET_KEY?.includes('_test_') ? 0 : 1); - -/** True when this browser holds a paid order (signed cookie) or the request carries a paid license key. */ -export async function entitled(cookies: AstroCookies, key: string | null): Promise { - const sessionId = await verify(cookies.get(ORDER_COOKIE)?.value); - if (sessionId) { - const row = await db() - .prepare(`SELECT 1 FROM orders WHERE session_id = ? AND status = 'paid' AND livemode >= ?`) - .bind(sessionId, minLivemode()) - .first(); - if (row) return true; - } - const normalized = key?.trim().toUpperCase() ?? ''; - if (isLicenseKey(normalized)) { - const row = await db() - .prepare(`SELECT 1 FROM orders WHERE license_key = ? AND status = 'paid' AND livemode >= ?`) - .bind(normalized, minLivemode()) - .first(); - if (row) return true; - } - return false; -} diff --git a/apps/website/src/pages/download/[platform].ts b/apps/website/src/pages/download/[platform].ts index d1718b55..4f23626c 100644 --- a/apps/website/src/pages/download/[platform].ts +++ b/apps/website/src/pages/download/[platform].ts @@ -1,26 +1,15 @@ import type { APIRoute } from 'astro'; import { assetRedirect, latestRelease, PLATFORMS } from '@/lib/releases'; -import { entitled } from '@/lib/entitlement'; import { allowed } from '@/lib/limit'; -import { keyFromCookie, setKeyCookie } from '@/lib/signed'; export const prerender = false; -/** /download/mac, /download/windows — licensed customers only (order cookie, key cookie, or a legacy ?key=). */ -export const GET: APIRoute = async ({ params, url, cookies, request }) => { +/** /download/mac, /download/windows, … — the latest release's file for that platform. No key needed: the app runs free for 7 days. */ +export const GET: APIRoute = async ({ params, request }) => { const platform = params.platform ?? ''; + if (!Object.hasOwn(PLATFORMS, platform)) return new Response('Unknown platform', { status: 404 }); const target = PLATFORMS[platform]; - if (!target) return new Response('Unknown platform', { status: 404 }); if (!(await allowed('CHECKOUT_LIMIT', request))) return new Response('Too many requests', { status: 429 }); - - const queryKey = url.searchParams.get('key'); - if (queryKey) await setKeyCookie(cookies, queryKey, url.protocol === 'https:'); - const key = queryKey ?? (await keyFromCookie(cookies)); - if (!(await entitled(cookies, key))) { - const back = new URLSearchParams({ platform }); - if (key) back.set('invalid', '1'); - return new Response(null, { status: 303, headers: { location: `/download?${back}`, 'cache-control': 'no-store' } }); - } try { const release = await latestRelease(); const asset = release.assets.find((a) => target.match.test(a.name)); diff --git a/apps/website/src/pages/download/file/[name].ts b/apps/website/src/pages/download/file/[name].ts index 53f3269b..d6a93572 100644 --- a/apps/website/src/pages/download/file/[name].ts +++ b/apps/website/src/pages/download/file/[name].ts @@ -1,6 +1,5 @@ import type { APIRoute } from 'astro'; import { assetRedirect, assetText, releaseFor } from '@/lib/releases'; -import { entitled } from '@/lib/entitlement'; import { allowed } from '@/lib/limit'; export const prerender = false; @@ -8,14 +7,11 @@ export const prerender = false; // The headless server binaries and their checksums, as fetched by /install.sh. const SERVER_FILE = /^godmode-(darwin|linux|windows)-(x64|arm64|x64-baseline)(\.exe)?(\.sha256)?$/; -/** /download/file/godmode-linux-x64?key=GM-…[&version=v0.2.0] — licensed customers only. */ -export const GET: APIRoute = async ({ params, url, cookies, request }) => { +/** /download/file/godmode-linux-x64[?version=v0.2.0]: only these release files, no key needed. */ +export const GET: APIRoute = async ({ params, url, request }) => { const name = params.name ?? ''; if (!SERVER_FILE.test(name)) return new Response('Not found', { status: 404 }); if (!(await allowed('CHECKOUT_LIMIT', request))) return new Response('Too many requests', { status: 429 }); - if (!(await entitled(cookies, url.searchParams.get('key')))) { - return new Response('A valid license key is required (GODMODE_LICENSE).', { status: 403 }); - } try { const release = await releaseFor(url.searchParams.get('version')); const asset = release.assets.find((a) => a.name === name); diff --git a/apps/website/src/pages/download/index.astro b/apps/website/src/pages/download/index.astro index 2b3af530..287d0151 100644 --- a/apps/website/src/pages/download/index.astro +++ b/apps/website/src/pages/download/index.astro @@ -25,74 +25,149 @@ const ua = Astro.request.headers.get('user-agent') ?? ''; const phone = /iPhone|iPad|iPod|Android/i.test(ua); const guess = /Windows/i.test(ua) ? 'windows' : /Linux/i.test(ua) && !phone ? 'linux' : 'mac'; const asked = Astro.url.searchParams.get('platform') ?? ''; -const selected = PLATFORMS[asked] ? asked : guess; -const invalid = Astro.url.searchParams.has('invalid'); +const selected = Object.hasOwn(PLATFORMS, asked) ? asked : guess; + +const apps = [ + { id: 'mac', name: 'macOS', note: 'Apple silicon', icon: 'apple' }, + { id: 'windows', name: 'Windows', note: '64-bit installer', icon: 'monitor' }, + { id: 'linux', name: 'Linux', note: 'AppImage', icon: 'terminal' }, +]; +const more = [ + { id: 'mac-intel', label: 'macOS Intel' }, + { id: 'windows-msi', label: 'Windows MSI' }, + { id: 'linux-deb', label: '.deb' }, + { id: 'linux-rpm', label: '.rpm' }, +]; +const servers = Object.entries(PLATFORMS) + .filter(([id]) => id.startsWith('server-')) + .map(([id, p]) => ({ id, label: p.label.replace('Headless server · ', '') })); +const install = `curl -fsSL ${SITE.url}/install.sh | sh`; +const pageUrl = `${SITE.url}/download`; +const handoff = `mailto:?subject=${encodeURIComponent('Download Godmode')}&body=${encodeURIComponent(`Godmode for Mac, Windows and Linux, free for 7 days:\n${pageUrl}\n`)}`; Astro.response.headers.set('cache-control', 'no-store'); Astro.response.headers.set('referrer-policy', 'same-origin'); --- - +