diff --git a/.agents/skills/codflow-setup/SKILL.md b/.agents/skills/codflow-setup/SKILL.md index 8ec116b..a4fe06e 100644 --- a/.agents/skills/codflow-setup/SKILL.md +++ b/.agents/skills/codflow-setup/SKILL.md @@ -135,20 +135,23 @@ grep -rn "00000000-0000\|00000000000000000000000000000000" \ # expected: no matches — fix any hit before continuing ``` -**Propagate renamed D1 database through scripts:** If the database name differs -from `codflow-os-db`, it is hardcoded in multiple locations that must be updated: -- `cod-server/package.json`: `db:migrate:local` and `db:migrate:remote` scripts -- `cod-server/scripts/seed-local.mjs`: both `--local` and `--remote` wrangler - d1 execute calls -- `cod-client-astro/scripts/seed-admin.mjs`: both `--local` and `--remote` - wrangler d1 execute calls +**Set the D1 database name once:** the scripts are not hardcoded — the seeders, +the migration wrapper (`cod-server/scripts/d1.mjs`) and the R2 setup all read +`COD_DB_NAME` from `/.env` via `cod-server/scripts/cloud-env.mjs` +(precedence: `process.env` > `.env` > default). Set it there: -After updating, grep the repo to confirm only README/doc mentions of the old -name remain: +```bash +cp .env.example .env # at the repo root +# COD_DB_NAME= +``` + +The `database_name` / `database_id` in each `wrangler.toml` still has to match +the database you created — those are wrangler's own config, not script input. +Confirm no script or config still pins the sample name: ```bash grep -r "codflow-os-db" --exclude-dir=node_modules --exclude-dir=.git -# expected: only documentation files, zero script/config hits +# expected: only .env.example defaults and documentation, zero script hits ``` Confirm the filled `wrangler.toml` files are not tracked by git: diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..f8d9b3e --- /dev/null +++ b/.env.example @@ -0,0 +1,23 @@ +# Unified CodFlow Cloud resource values — single source of truth for the +# seeder scripts, the D1 migration wrapper, the R2 CORS setup, and the +# storefront deploy helper. +# +# Copy this file to .env at the repo root and fill in YOUR values. +# .env is gitignored — never commit real values. +# +# Precedence for every key: process.env > .env > built-in default. +# +# COD_ACCOUNT_ID — Cloudflare account ID (dashboard → account menu) +# COD_DB_NAME — D1 database name (wrangler d1 create ) +# COD_R2_BUCKET_NAME — R2 bucket name (wrangler r2 bucket create ) +# COD_SERVER_URL — deployed cod-server origin (storefront fetches this). +# Defaults to http://localhost:8787 for local dev; +# the storefront deploy refuses to ship a localhost +# value unless you pass --force-local. +# COD_MEDIA_DOMAIN — R2 custom domain, e.g. media.yourdomain.com + +# COD_ACCOUNT_ID= +# COD_DB_NAME=codflow-os-db +# COD_R2_BUCKET_NAME=codflow-images +# COD_SERVER_URL=https://api.yourdomain.com +# COD_MEDIA_DOMAIN=media.yourdomain.com diff --git a/.opencode/skills/codflow-setup/SKILL.md b/.opencode/skills/codflow-setup/SKILL.md index 9f88e51..b73c7fa 100644 --- a/.opencode/skills/codflow-setup/SKILL.md +++ b/.opencode/skills/codflow-setup/SKILL.md @@ -135,20 +135,23 @@ grep -rn "00000000-0000\|00000000000000000000000000000000" \ # expected: no matches — fix any hit before continuing ``` -**Propagate renamed D1 database through scripts:** If the database name differs -from `codflow-os-db`, it is hardcoded in multiple locations that must be updated: -- `cod-server/package.json`: `db:migrate:local` and `db:migrate:remote` scripts -- `cod-server/scripts/seed-local.mjs`: both `--local` and `--remote` wrangler - d1 execute calls -- `cod-client-astro/scripts/seed-admin.mjs`: both `--local` and `--remote` - wrangler d1 execute calls +**Set the D1 database name once:** the scripts are not hardcoded — the seeders, +the migration wrapper (`cod-server/scripts/d1.mjs`) and the R2 setup all read +`COD_DB_NAME` from `/.env` via `cod-server/scripts/cloud-env.mjs` +(precedence: `process.env` > `.env` > default). Set it there: -After updating, grep the repo to confirm only README/doc mentions of the old -name remain: +```bash +cp .env.example .env # at the repo root +# COD_DB_NAME= +``` + +The `database_name` / `database_id` in each `wrangler.toml` still has to match +the database you created — those are wrangler's own config, not script input. +Confirm no script or config still pins the sample name: ```bash grep -r "codflow-os-db" --exclude-dir=node_modules --exclude-dir=.git -# expected: only documentation files, zero script/config hits +# expected: only .env.example defaults and documentation, zero script hits ``` Confirm the filled `wrangler.toml` files are not tracked by git: diff --git a/AGENTS.md b/AGENTS.md index ba7f193..0d2b8fd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,6 +43,20 @@ npm run seed:admin` (sign-up is disabled by design). `cod-astro/theme01` has extra validators — see `cod-astro/theme01/AGENTS.md` for its commands. +## Cloud resource configuration + +Resource names and URLs are **not** hardcoded in scripts. The seeders, the +D1 migration wrapper (`cod-server/scripts/d1.mjs`), the R2 CORS setup and +the storefront deploy helper all read `/.env` through +`cod-server/scripts/cloud-env.mjs`. Precedence is +**`process.env` > `.env` > built-in default**; the committed template is +`/.env.example`. + +Keys: `COD_ACCOUNT_ID`, `COD_DB_NAME`, `COD_R2_BUCKET_NAME`, +`COD_SERVER_URL`, `COD_MEDIA_DOMAIN`. Add a key to `DEFAULTS` in +`cloud-env.mjs` and to `.env.example` together — a key in one and not the +other is how these drift. + ## Verification - After changing TypeScript: run `npm run typecheck` in the affected package. @@ -84,6 +98,11 @@ npm run seed:admin` (sign-up is disabled by design). workspace members. It keeps a single Vite major across astro/vitest; removing it reintroduces the dual-Vite boot crash (`Missing field 'moduleType'`). Keep it in sync when astro bumps Vite. +- `COD_SERVER_URL` defaults to `http://localhost:8787` so local dev works + untouched. A deployed Worker can never reach that, so + `cod-astro/theme01/scripts/deploy.mjs` refuses to deploy a loopback value + unless `--force-local` is passed. Set the real origin in the root `.env` + before deploying the storefront. - Local D1 state is **shared** through `/.wrangler-shared`: cod-server's dev/migrate scripts write there via `--persist-to`, and cod-client-astro's astro dev reads the same files via the Cloudflare diff --git a/README.md b/README.md index d4fa7fe..b9301d9 100644 --- a/README.md +++ b/README.md @@ -164,16 +164,24 @@ npm ci ``` ### 2. Create Cloudflare Resources +Names are yours to choose — scripts read them from the root `.env` +(see step 3), so nothing is hardcoded. + ```bash wrangler login -wrangler d1 create codflow-os-db -wrangler r2 bucket create codflow-images +wrangler d1 create my-codflow-db +wrangler r2 bucket create my-codflow-images wrangler kv namespace create RATE_LIMIT wrangler kv namespace create OAUTH_KV ``` ### 3. Configure Environment ```bash +# Repo root — resource names for the seeders, the D1 wrapper, the R2 CORS +# setup and the storefront deploy helper. Precedence: process.env > .env > default. +cp .env.example .env +# Set COD_ACCOUNT_ID, COD_DB_NAME, COD_R2_BUCKET_NAME, COD_SERVER_URL, COD_MEDIA_DOMAIN + # Backend cd cod-server cp .dev.vars.example .dev.vars diff --git a/cod-astro/theme01/package.json b/cod-astro/theme01/package.json index e5adcc9..3e6b591 100644 --- a/cod-astro/theme01/package.json +++ b/cod-astro/theme01/package.json @@ -6,7 +6,7 @@ "dev": "astro dev", "build": "astro build", "preview": "astro build && wrangler dev", - "deploy": "astro build && wrangler deploy --var COD_SERVER_URL:https://api.codflow.store", + "deploy": "npm run build && node scripts/deploy.mjs", "cf-typegen": "wrangler types --env-interface CloudflareEnv src/env.d.ts", "test": "vitest --run", "test:watch": "vitest", diff --git a/cod-astro/theme01/scripts/deploy.mjs b/cod-astro/theme01/scripts/deploy.mjs new file mode 100644 index 0000000..14f38c1 --- /dev/null +++ b/cod-astro/theme01/scripts/deploy.mjs @@ -0,0 +1,67 @@ +#!/usr/bin/env node +/** + * Deploy the storefront worker with COD_SERVER_URL injected from the unified + * root .env (COD_SERVER_URL) — see cod-server/scripts/cloud-env.mjs. + * STORE_API_KEY is a worker secret, set separately via `wrangler secret put`. + * + * COD_SERVER_URL defaults to http://localhost:8787 so `npm run dev` works out + * of the box. A deployed Worker can never reach that address, so shipping it + * produces a storefront whose every API call fails. Deployment is refused when + * the value resolves to localhost unless --force-local is passed. + * + * Usage: + * npm run deploy + * npm run deploy -- --force-local # intentionally deploy the local value + */ + +import { execSync } from "node:child_process"; +import { getCloudEnv } from "../../../cod-server/scripts/cloud-env.mjs"; + +const forceLocal = process.argv.includes("--force-local"); +const { serverUrl } = getCloudEnv(); + +/** Loopback hosts a deployed Worker can never reach. */ +function isLoopbackUrl(value) { + let hostname; + try { + ({ hostname } = new URL(value)); + } catch { + return false; // not a URL — let wrangler report it + } + return ( + hostname === "localhost" || + hostname === "127.0.0.1" || + hostname === "0.0.0.0" || + hostname === "[::1]" || + hostname === "::1" || + hostname.endsWith(".localhost") + ); +} + +if (isLoopbackUrl(serverUrl) && !forceLocal) { + console.error(` +Error: COD_SERVER_URL resolves to a local address (${serverUrl}). + +A deployed Worker cannot reach your machine, so this would ship a storefront +whose every API call fails. + +Set the deployed cod-server origin in /.env: + + COD_SERVER_URL=https://api.yourdomain.com + +See /.env.example for the full template, then re-run: + + npm run deploy + +To deploy the local value anyway (rarely what you want): + + npm run deploy -- --force-local +`); + process.exit(1); +} + +if (forceLocal && isLoopbackUrl(serverUrl)) { + console.warn(`Warning: deploying with a local COD_SERVER_URL (${serverUrl}) — --force-local was passed.`); +} + +execSync(`npx wrangler deploy --var COD_SERVER_URL:${serverUrl}`, { stdio: "inherit" }); diff --git a/cod-astro/theme01/wrangler.jsonc b/cod-astro/theme01/wrangler.jsonc index d2a9e1e..89f9d26 100644 --- a/cod-astro/theme01/wrangler.jsonc +++ b/cod-astro/theme01/wrangler.jsonc @@ -10,18 +10,11 @@ }, "observability": { "enabled": true - }, - // Replace with your own values before deploying: - // COD_SERVER_URL → your cod-server Worker's public URL. The committed - // default targets local dev; for production deploy pass - // `wrangler deploy --var COD_SERVER_URL:https://api.yourdomain.com` - // STORE_API_KEY → a secret — set it via `wrangler secret put STORE_API_KEY` - // (do NOT put the real key here). Local dev reads both from .dev.vars. - "vars": { - // LOCAL DEV DEFAULT. Production deploys MUST override: - // npx wrangler deploy --var COD_SERVER_URL:https://api.yourdomain.com - // Forgetting the override breaks every storefront API fetch (products, - // landing pages — everything 302s home). Deploy scripts must pass it. - "COD_SERVER_URL": "http://localhost:8787" } + // COD_SERVER_URL is injected at deploy time from the unified root .env + // by `npm run deploy` → scripts/deploy.mjs — never hardcode it here. + // Local dev reads it from this package's own .dev.vars via astro:env; + // cod-server/scripts/cloud-env.mjs only reads the root .env, not .dev.vars. + // STORE_API_KEY is a secret — set it via `wrangler secret put STORE_API_KEY` + // (do NOT put the real key here). } diff --git a/cod-client-astro/scripts/seed-admin.mjs b/cod-client-astro/scripts/seed-admin.mjs index b9b497d..262acde 100644 --- a/cod-client-astro/scripts/seed-admin.mjs +++ b/cod-client-astro/scripts/seed-admin.mjs @@ -10,8 +10,9 @@ * admin@example.com / Admin). Set them when you run the script: * ADMIN_EMAIL=you@example.com ADMIN_NAME=You node scripts/seed-admin.mjs * - * The database name must match the D1 binding in cod-server/wrangler.toml - * (and this package's wrangler.toml) — default `codflow-os-db`. + * The D1 database name comes from the unified root .env (COD_DB_NAME, via + * ../../cod-server/scripts/cloud-env.mjs) and must match the binding in + * cod-server/wrangler.toml. * * Local state is the repo-shared ../.wrangler-shared (same file cod-server * migrates to — persist-to is resolved relative to this package's cwd — and @@ -27,6 +28,9 @@ import { writeFileSync, unlinkSync } from "node:fs"; import { execSync } from "node:child_process"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { getCloudEnv } from "../../cod-server/scripts/cloud-env.mjs"; + +const { dbName } = getCloudEnv(); const scryptAsync = promisify(scrypt); @@ -104,11 +108,11 @@ WHERE user_id = (SELECT id FROM users WHERE email = '${email}') AND provider_id try { console.log("\n=== Seeding admin (local) ==="); - run(`npx wrangler d1 execute codflow-os-db --local --persist-to ../.wrangler-shared --file "${tmpFile}"`); + run(`npx wrangler d1 execute ${dbName} --local --persist-to ../.wrangler-shared --file "${tmpFile}"`); if (remote) { console.log("\n=== Seeding admin (remote) ==="); - run(`npx wrangler d1 execute codflow-os-db --remote --file "${tmpFile}"`); + run(`npx wrangler d1 execute ${dbName} --remote --file "${tmpFile}"`); } } finally { unlinkSync(tmpFile); diff --git a/cod-server/package.json b/cod-server/package.json index e4f7c45..d773242 100644 --- a/cod-server/package.json +++ b/cod-server/package.json @@ -8,8 +8,8 @@ "deploy": "wrangler deploy", "build:ci": "wrangler deploy --dry-run --outdir dist", "db:generate": "drizzle-kit generate", - "db:migrate:local": "wrangler d1 migrations apply codflow-os-db --local --persist-to ../.wrangler-shared", - "db:migrate:remote": "wrangler d1 migrations apply codflow-os-db --remote", + "db:migrate:local": "node scripts/d1.mjs migrations apply --local --persist-to ../.wrangler-shared", + "db:migrate:remote": "node scripts/d1.mjs migrations apply --remote", "db:seed:local": "node scripts/seed-local.mjs", "db:setup:local": "npm run db:migrate:local && npm run db:seed:local", "db:seed:remote": "node scripts/seed-local.mjs --remote", diff --git a/cod-server/scripts/cloud-env.mjs b/cod-server/scripts/cloud-env.mjs new file mode 100644 index 0000000..4a6afa6 --- /dev/null +++ b/cod-server/scripts/cloud-env.mjs @@ -0,0 +1,63 @@ +/** + * Unified Cloud resource values — single source of truth for CodFlow scripts. + * + * Reads /.env (gitignored) for the D1 database name, R2 bucket and + * worker URLs. Every workspace imports this helper instead of hardcoding + * resource values. Precedence: process.env > .env > default. + * + * Template keys live in /.env.example. + */ + +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +const ROOT = fileURLToPath(new URL("../../", import.meta.url)); + +const DEFAULTS = { + COD_ACCOUNT_ID: "", + COD_DB_NAME: "codflow-os-db", + COD_R2_BUCKET_NAME: "codflow-images", + COD_SERVER_URL: "http://localhost:8787", + COD_MEDIA_DOMAIN: "media.example.com", +}; + +function parseEnv(text) { + const out = {}; + for (const line of text.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + const eq = trimmed.indexOf("="); + if (eq === -1) continue; + const key = trimmed.slice(0, eq).trim(); + let value = trimmed.slice(eq + 1).trim(); + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1); + } + out[key] = value; + } + return out; +} + +export function getCloudEnv() { + let fileEnv = {}; + try { + fileEnv = parseEnv(readFileSync(ROOT + ".env", "utf8")); + } catch { + // No .env file present — fall back to defaults below. + } + const merged = {}; + for (const key of Object.keys(DEFAULTS)) { + const fromProcess = process.env[key]; + merged[key] = fromProcess ? fromProcess : fileEnv[key] ?? DEFAULTS[key]; + } + return { + accountId: merged.COD_ACCOUNT_ID, + dbName: merged.COD_DB_NAME, + bucketName: merged.COD_R2_BUCKET_NAME, + serverUrl: merged.COD_SERVER_URL, + mediaDomain: merged.COD_MEDIA_DOMAIN, + }; +} diff --git a/cod-server/scripts/d1.mjs b/cod-server/scripts/d1.mjs new file mode 100644 index 0000000..8c52294 --- /dev/null +++ b/cod-server/scripts/d1.mjs @@ -0,0 +1,36 @@ +#!/usr/bin/env node +/** + * Wrapper for `wrangler d1` that injects the D1 database name read from the + * unified root .env (COD_DB_NAME) — see ./cloud-env.mjs. + * + * The database name lands in the correct positional slot: + * wrangler d1 execute ... + * wrangler d1 migrations apply ... + * wrangler d1 migrations list + * wrangler d1 delete + * + * Usage: + * node scripts/d1.mjs execute --remote --command "…" + * node scripts/d1.mjs migrations apply --local --persist-to ../.wrangler-shared + * + * Runs through a shell deliberately: npx resolves to npx.cmd on Windows, which + * Node refuses to spawn without one (EINVAL, CVE-2024-27980), and shell mode + * does not escape argv for us — so arguments are quoted below instead. + */ + +import { execSync } from "node:child_process"; +import { getCloudEnv } from "./cloud-env.mjs"; + +const args = process.argv.slice(2); +if (args.length === 0) { + console.error("Usage: node scripts/d1.mjs "); + process.exit(1); +} + +const { dbName } = getCloudEnv(); +const parts = [...args]; +const dbIndex = parts[0] === "migrations" ? 2 : 1; +parts.splice(dbIndex, 0, dbName); + +const quoted = parts.map((a) => (/\s/.test(a) ? `"${a}"` : a)).join(" "); +execSync(`npx wrangler d1 ${quoted}`, { stdio: "inherit" }); diff --git a/cod-server/scripts/seed-ecotrack-companies.mjs b/cod-server/scripts/seed-ecotrack-companies.mjs index 14dafe7..46bd37e 100644 --- a/cod-server/scripts/seed-ecotrack-companies.mjs +++ b/cod-server/scripts/seed-ecotrack-companies.mjs @@ -22,9 +22,11 @@ import { join } from "path"; import { pathToFileURL } from "url"; import { buildAllEcotrackCompanyUpserts } from "../src/endpoints/delivery-companies/providers/ecotrack/seed-sql.ts"; import { ECOTRACK_COURIERS } from "../../cod-shared/lib/ecotrack-couriers.ts"; +import { getCloudEnv } from "./cloud-env.mjs"; const remote = process.argv.includes("--remote"); const dryRun = process.argv.includes("--dry-run"); +const { dbName } = getCloudEnv(); const statements = buildAllEcotrackCompanyUpserts(ECOTRACK_COURIERS); const sql = statements.join("\n"); @@ -44,7 +46,7 @@ const target = remote try { execSync( - `npx wrangler d1 execute codflow-os-db ${target} --file ${sqlFile}`, + `npx wrangler d1 execute ${dbName} ${target} --file ${sqlFile}`, { cwd: process.cwd(), stdio: "inherit" } ); console.log(`\n[seed-ecotrack] ✓ ${statements.length} companies upserted (${remote ? "remote" : "local"} D1)`); diff --git a/cod-server/scripts/seed-local.mjs b/cod-server/scripts/seed-local.mjs index b800af5..6896205 100644 --- a/cod-server/scripts/seed-local.mjs +++ b/cod-server/scripts/seed-local.mjs @@ -21,10 +21,12 @@ import { readFileSync } from "fs"; import { createHash } from "crypto"; import { fileURLToPath } from "url"; import path from "path"; +import { getCloudEnv } from "./cloud-env.mjs"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const root = path.resolve(__dirname, ".."); const remote = process.argv.includes("--remote"); +const { dbName } = getCloudEnv(); // ── 1. Resolve the store API key ──────────────────────────────────────────── // Precedence: $STORE_API_KEY env var → cod-astro/theme01/.dev.vars → dev default. @@ -207,7 +209,7 @@ for (const img of images) { function run(sql) { const target = remote ? "--remote -y" : "--local --persist-to ../.wrangler-shared"; execSync( - `npx wrangler d1 execute codflow-os-db ${target} --command "${sql.replace(/"/g, '\\"')}"`, + `npx wrangler d1 execute ${dbName} ${target} --command "${sql.replace(/"/g, '\\"')}"`, { cwd: root, stdio: "pipe" } ); } @@ -230,4 +232,4 @@ console.log(` products : ${products.length} (${products.filter(p => p.storeFe console.log(` variants : ${variants.length}`); console.log(` images : ${images.length}`); console.log(`\n rawKey : ${rawKey.slice(0, 24)}...`); -console.log(`\n target : ${remote ? "remote D1 (codflow-os-db)" : "local D1 (.wrangler-shared)"}\n`); +console.log(`\n target : ${remote ? `remote D1 (${dbName})` : "local D1 (.wrangler-shared)"}\n`); diff --git a/cod-server/scripts/setup-r2-cors.mjs b/cod-server/scripts/setup-r2-cors.mjs index e2822e1..93753f5 100644 --- a/cod-server/scripts/setup-r2-cors.mjs +++ b/cod-server/scripts/setup-r2-cors.mjs @@ -14,13 +14,17 @@ */ import { S3Client, PutBucketCorsCommand, GetBucketCorsCommand } from "@aws-sdk/client-s3"; +import { getCloudEnv } from "./cloud-env.mjs"; + +const CLOUD = getCloudEnv(); // ─── CONFIG ────────────────────────────────────────────────────────────────── -// Values are read from env vars first. Edit the fallbacks if you prefer. -const ACCOUNT_ID = process.env.CF_ACCOUNT_ID ?? ""; +// Resource values (bucket name, account id) come from the unified root .env +// (see ./cloud-env.mjs); credentials always come from the environment. +const ACCOUNT_ID = process.env.CF_ACCOUNT_ID ?? CLOUD.accountId; const ACCESS_KEY_ID = process.env.R2_ACCESS_KEY_ID ?? ""; const SECRET_ACCESS_KEY = process.env.R2_SECRET_ACCESS_KEY ?? ""; -const BUCKET_NAME = process.env.R2_BUCKET_NAME ?? "codflow-images"; +const BUCKET_NAME = process.env.R2_BUCKET_NAME ?? CLOUD.bucketName; // CORS: allow PUT + GET from any origin (browsers need PUT for presigned upload) // Restrict AllowedOrigins to your specific domain in production if preferred. @@ -39,11 +43,12 @@ if (!ACCOUNT_ID || !ACCESS_KEY_ID || !SECRET_ACCESS_KEY) { console.error(` Error: Missing required credentials. -Set these environment variables before running: - CF_ACCOUNT_ID — your Cloudflare account ID +Set these variables before running (credentials from env, resource values from +the unified root .env): + CF_ACCOUNT_ID — your Cloudflare account ID (env or .env COD_ACCOUNT_ID) R2_ACCESS_KEY_ID — R2 API token key ID R2_SECRET_ACCESS_KEY — R2 API token secret - R2_BUCKET_NAME — bucket name (default: codflow-images) + R2_BUCKET_NAME — bucket name (default: .env COD_R2_BUCKET_NAME) Get R2 API tokens from: Cloudflare Dashboard → R2 → Manage R2 API Tokens → Create API Token