diff --git a/scripts/grunt b/scripts/grunt new file mode 100755 index 00000000..bde3e5f8 --- /dev/null +++ b/scripts/grunt @@ -0,0 +1,137 @@ +#!/usr/bin/env bash +# grunt — dispatch a one-shot prompt to opencode.ai Zen (Go subscription). +# +# Usage: +# scripts/grunt [-m MODEL] [-s SYSTEM] [-f FILE]... [-t MAX_TOKENS] [-j] [PROMPT] +# +# Flags: +# -m MODEL Model id (default: deepseek-v4-flash). See `scripts/grunt --models`. +# -s SYSTEM System prompt. May be passed multiple times (concatenated with blank lines). +# -f FILE Attach a file. Repeatable. Each file is appended to the user message +# as a fenced block headed by its path. +# -t MAX_TOKENS Max output tokens (default: 4096). +# -j Print the raw JSON response instead of just the assistant text. +# --models List available models for this account and exit. +# -h, --help Show this help. +# +# Input: PROMPT may be passed as a positional arg, or piped on stdin (or both — stdin +# is appended after the positional arg, separated by a blank line). At least one +# source of prompt text is required. +# +# Output: assistant message text on stdout. Reasoning-only finishes (where the model +# spent all its tokens thinking and produced no content) emit a single line to stderr +# and exit 2 — bump -t and retry. +# +# Auth: reads OPENCODE_API_KEY from the environment and trims surrounding whitespace. +# Exits 1 if unset. +# +# Designed for parallel fan-out: +# ( scripts/grunt -f a.ts "summarize" > a.out ) & +# ( scripts/grunt -f b.ts "summarize" > b.out ) & +# wait + +set -euo pipefail + +ENDPOINT="https://opencode.ai/zen/go/v1" +DEFAULT_MODEL="deepseek-v4-flash" + +usage() { sed -n '2,/^$/p' "$0" | sed 's/^# \{0,1\}//'; } + +model="$DEFAULT_MODEL" +system_parts=() +files=() +max_tokens=4096 +raw_json=0 +list_models=0 + +while [[ $# -gt 0 ]]; do + case "$1" in + -m) model="$2"; shift 2 ;; + -s) system_parts+=("$2"); shift 2 ;; + -f) files+=("$2"); shift 2 ;; + -t) max_tokens="$2"; shift 2 ;; + -j) raw_json=1; shift ;; + --models) list_models=1; shift ;; + -h|--help) usage; exit 0 ;; + --) shift; break ;; + -*) echo "grunt: unknown flag: $1" >&2; exit 64 ;; + *) break ;; + esac +done + +key="${OPENCODE_API_KEY:-}" +key="${key#"${key%%[![:space:]]*}"}" +key="${key%"${key##*[![:space:]]}"}" +if [[ -z "$key" ]]; then + echo "grunt: OPENCODE_API_KEY is not set" >&2 + exit 1 +fi + +if (( list_models )); then + curl -fsS "$ENDPOINT/models" -H "Authorization: Bearer $key" \ + | python3 -c 'import json,sys;[print(m["id"]) for m in json.load(sys.stdin)["data"]]' + exit 0 +fi + +prompt="${1:-}" +if [[ ! -t 0 ]]; then + stdin_text=$(cat) + if [[ -n "$stdin_text" ]]; then + [[ -n "$prompt" ]] && prompt="$prompt"$'\n\n'"$stdin_text" || prompt="$stdin_text" + fi +fi +if [[ -z "$prompt" && ${#files[@]} -eq 0 ]]; then + echo "grunt: no prompt (pass as arg, on stdin, or via -f)" >&2 + exit 64 +fi + +for f in "${files[@]}"; do + [[ -r "$f" ]] || { echo "grunt: cannot read file: $f" >&2; exit 66; } + prompt+=$'\n\n'"### $f"$'\n```\n'"$(cat "$f")"$'\n```' +done + +system="" +if (( ${#system_parts[@]} > 0 )); then + system=$(printf '%s\n\n' "${system_parts[@]}") +fi + +body=$( + jq -n \ + --arg model "$model" \ + --arg system "$system" \ + --arg prompt "$prompt" \ + --argjson max_tokens "$max_tokens" \ + '{model:$model, max_tokens:$max_tokens, + messages: ((if $system == "" then [] else [{role:"system",content:$system}] end) + + [{role:"user",content:$prompt}])}' +) + +response=$(curl -fsS "$ENDPOINT/chat/completions" \ + -H "Authorization: Bearer $key" \ + -H "Content-Type: application/json" \ + --data-binary "$body") + +if (( raw_json )); then + printf '%s\n' "$response" + exit 0 +fi + +python3 - "$response" <<'PY' +import json, sys +d = json.loads(sys.argv[1]) +if "error" in d: + sys.stderr.write(f"grunt: api error: {d['error'].get('message','?')}\n") + sys.exit(1) +msg = d["choices"][0]["message"] +text = msg.get("content") or "" +if not text.strip(): + reasoning = msg.get("reasoning") or msg.get("reasoning_content") or "" + sys.stderr.write( + f"grunt: model produced reasoning only (finish={d['choices'][0].get('finish_reason')}); " + f"raise -t. reasoning preview: {reasoning[:200]!r}\n" + ) + sys.exit(2) +sys.stdout.write(text) +if not text.endswith("\n"): + sys.stdout.write("\n") +PY diff --git a/src/features/admin/settings.ts b/src/features/admin/settings.ts index e10a2bb0..09de05f3 100644 --- a/src/features/admin/settings.ts +++ b/src/features/admin/settings.ts @@ -337,7 +337,7 @@ const handlePaymentProviderPost = settingsHandler({ v === "none" ? "Payment provider disabled" : `Payment provider set to ${v}`, save: (v) => v === "none" - ? settings.update.clearPaymentProvider() + ? settings.update.setPaymentProviderNone() : settings.update.paymentProvider(v as PaymentProviderType), validate: (v) => v !== "none" && !isPaymentProvider(v) ? "Invalid payment provider" : null, diff --git a/src/shared/db/settings.ts b/src/shared/db/settings.ts index 43be3a6d..d94b83ec 100644 --- a/src/shared/db/settings.ts +++ b/src/shared/db/settings.ts @@ -31,8 +31,16 @@ import { deleteAllSessions } from "#shared/db/sessions.ts"; import { createUser, invalidateUsersCache } from "#shared/db/users.ts"; import { nowMs } from "#shared/now.ts"; import { DEFAULT_TIMEZONE } from "#shared/timezone.ts"; -import type { PaymentProviderType, Settings, Theme } from "#shared/types.ts"; -import { isPaymentProvider } from "#shared/types.ts"; +import type { + PaymentProviderSetting, + PaymentProviderType, + Settings, + Theme, +} from "#shared/types.ts"; +import { + isPaymentProvider, + isPaymentProviderSetting, +} from "#shared/types.ts"; import { createAppleWalletReadSettings, createAppleWalletUpdateSettings, @@ -229,6 +237,7 @@ type SpecificFields = { show_public_site: boolean; show_public_api: boolean; payment_provider: PaymentProviderType | null; + payment_provider_setting: PaymentProviderSetting | null; booking_fee: string; square_sandbox: boolean; currency: string; @@ -245,6 +254,7 @@ const data: SettingsData = { country: DEFAULT_COUNTRY, currency: "GBP", payment_provider: null, + payment_provider_setting: null, phone_prefix: "+44", show_public_api: false, show_public_site: false, @@ -379,6 +389,8 @@ const buildSnapshot = async (raw: Map): Promise => { const rawProvider = raw.get(CONFIG_KEYS.PAYMENT_PROVIDER); data.payment_provider = rawProvider && isPaymentProvider(rawProvider) ? rawProvider : null; + data.payment_provider_setting = + rawProvider && isPaymentProviderSetting(rawProvider) ? rawProvider : null; data.booking_fee = raw.get(CONFIG_KEYS.BOOKING_FEE) ?? "0"; data.square_sandbox = raw.get(CONFIG_KEYS.SQUARE_SANDBOX) === "true"; @@ -695,6 +707,9 @@ export const settings = { get paymentProvider(): PaymentProviderType | null { return snap("payment_provider"); }, + get paymentProviderSetting(): PaymentProviderSetting | null { + return snap("payment_provider_setting"); + }, get phonePrefix(): string { return snap("phone_prefix"); }, @@ -793,6 +808,7 @@ export const settings = { clearPaymentProvider: async (): Promise => { await deleteRaw(CONFIG_KEYS.PAYMENT_PROVIDER); data.payment_provider = null; + data.payment_provider_setting = null; }, contactPageText: encryptedUpdate(CONFIG_KEYS.CONTACT_PAGE_TEXT), country: async (v: string): Promise => { @@ -838,10 +854,16 @@ export const settings = { latestScriptVersionName: plaintextUpdate( CONFIG_KEYS.LATEST_SCRIPT_VERSION_NAME, ), - paymentProvider: rawUpdate( - CONFIG_KEYS.PAYMENT_PROVIDER, - "payment_provider", - ) as (v: PaymentProviderType) => Promise, + paymentProvider: async (v: PaymentProviderType): Promise => { + await writeRaw(CONFIG_KEYS.PAYMENT_PROVIDER, v); + data.payment_provider = v; + data.payment_provider_setting = v; + }, + setPaymentProviderNone: async (): Promise => { + await writeRaw(CONFIG_KEYS.PAYMENT_PROVIDER, "none"); + data.payment_provider = null; + data.payment_provider_setting = "none"; + }, showPublicApi: boolUpdate(CONFIG_KEYS.SHOW_PUBLIC_API, "show_public_api"), showPublicSite: boolUpdate( CONFIG_KEYS.SHOW_PUBLIC_SITE, diff --git a/src/shared/settings-nags.ts b/src/shared/settings-nags.ts new file mode 100644 index 00000000..18e1fed1 --- /dev/null +++ b/src/shared/settings-nags.ts @@ -0,0 +1,59 @@ +import { settings } from "#shared/db/settings.ts"; +import { isBunnyCdnEnabled, isBunnyDnsEnabled } from "#shared/config.ts"; + +/** + * Unique identifiers for settings nags that prompt the admin to complete + * required or recommended configuration. + */ +export type NagId = "payment-provider" | "business-email" | "domain"; + +/** + * A single settings nag item presented to the admin. + */ +export type NagItem = { + /** The nag identifier. */ + id: NagId; + /** Human-readable description of what needs to be configured. */ + label: string; + /** Deep link to the settings form where the value can be set. */ + href: string; +}; + +/** + * Returns an ordered list of settings nags for incomplete configuration. + * Items are returned in the order: payment-provider, business-email, domain. + * An empty array means there are no pending nags. + */ +export const getSettingsNagItems = (): NagItem[] => { + const items: NagItem[] = []; + + if (settings.paymentProviderSetting === null) { + items.push({ + id: "payment-provider", + label: "Choose a payment provider on the settings page (saving \"None\" is fine).", + href: "/admin/settings#settings-payment-provider", + }); + } + + if (settings.businessEmail === "") { + items.push({ + id: "business-email", + label: "Set a business email so users have a contact address.", + href: "/admin/settings#settings-business-email", + }); + } + + if ( + settings.customDomain === "" && + settings.bunnySubdomain === "" && + (isBunnyCdnEnabled() || isBunnyDnsEnabled()) + ) { + items.push({ + id: "domain", + label: "Set either a custom domain or a host subdomain in advanced settings.", + href: "/admin/settings-advanced#settings-custom-domain", + }); + } + + return items; +}; diff --git a/src/shared/types.ts b/src/shared/types.ts index a476bc36..9db52755 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -57,6 +57,21 @@ const PAYMENT_PROVIDERS: readonly PaymentProviderType[] = ["stripe", "square"]; /** Type guard: check if a string is a valid PaymentProviderType */ export const isPaymentProvider = createTypeGuard(PAYMENT_PROVIDERS); +/** Persisted payment-provider setting: an explicit provider, "none" (admin saved + * payments-disabled), or absent (never saved — drives the settings nag). */ +export type PaymentProviderSetting = PaymentProviderType | "none"; + +const PAYMENT_PROVIDER_SETTINGS: readonly PaymentProviderSetting[] = [ + "stripe", + "square", + "none", +]; + +/** Type guard: check if a string is a valid PaymentProviderSetting */ +export const isPaymentProviderSetting = createTypeGuard( + PAYMENT_PROVIDER_SETTINGS, +); + /** Event type: standard (one-time) or daily (date-based booking) */ export type EventType = "standard" | "daily"; diff --git a/src/ui/templates/admin/nav.tsx b/src/ui/templates/admin/nav.tsx index deff19db..e775feec 100644 --- a/src/ui/templates/admin/nav.tsx +++ b/src/ui/templates/admin/nav.tsx @@ -8,6 +8,7 @@ import { isReadOnly } from "#shared/env.ts"; import { CsrfForm } from "#shared/forms.tsx"; import { Raw } from "#shared/jsx/jsx-runtime.ts"; import type { AdminSession } from "#shared/types.ts"; +import { SettingsNagBanner } from "#templates/admin/settings-nag-banner.tsx"; /** Read-only mode banner HTML */ export const READ_ONLY_BANNER = @@ -33,6 +34,7 @@ const navLink = (href: string, label: string, active: string): JSX.Element => ( export const AdminNav = ({ session, active }: AdminNavProps): JSX.Element => ( <> {isReadOnly() && } + {session.adminLevel === "owner" && }