From 260fe28ac0b3d779d498c099fb57ac938529a2a4 Mon Sep 17 00:00:00 2001 From: GreenEclipse Date: Thu, 13 Aug 2026 21:38:42 +0200 Subject: [PATCH] fix --- apps/api/cmd/seed-platform-admin/main.go | 17 ++-- apps/api/cmd/seed-platform-admin/main_test.go | 8 ++ apps/api/internal/config/dotenv.go | 7 ++ package.json | 1 + scripts/seed-local.mjs | 15 ++- scripts/seed-platform-admin.mjs | 97 +++++++++++++++++++ 6 files changed, 134 insertions(+), 11 deletions(-) create mode 100644 scripts/seed-platform-admin.mjs diff --git a/apps/api/cmd/seed-platform-admin/main.go b/apps/api/cmd/seed-platform-admin/main.go index 66f8c7b..0445761 100644 --- a/apps/api/cmd/seed-platform-admin/main.go +++ b/apps/api/cmd/seed-platform-admin/main.go @@ -11,7 +11,9 @@ // -password 'choose-a-strong-password' \ // -confirm // -// Env aliases: PLATFORM_ADMIN_EMAIL, PLATFORM_ADMIN_PASSWORD, DATABASE_URL. +// Env aliases: none for credentials — pass -email / -password (or use +// `npm run seed:platform-admin -- --email … --password …`). DATABASE_URL may +// come from the shell or monorepo-root .env via config.LoadDotEnv. // // -promote-only grants admin on an existing user without changing the password // (-password is ignored). Always requires -confirm. @@ -28,15 +30,18 @@ import ( "time" "github.com/descrybe/descrybe-v2/apps/api/internal/auth" + "github.com/descrybe/descrybe-v2/apps/api/internal/config" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" ) func main() { + config.LoadDotEnv() + postgresURL := flag.String("postgres", os.Getenv("DATABASE_URL"), "Postgres URL") - email := flag.String("email", os.Getenv("PLATFORM_ADMIN_EMAIL"), "Platform admin email") - password := flag.String("password", os.Getenv("PLATFORM_ADMIN_PASSWORD"), "Password (min 8; ignored with -promote-only)") + email := flag.String("email", "", "Platform admin email (required; do not put in .env)") + password := flag.String("password", "", "Password (min 8; ignored with -promote-only; do not put in .env)") name := flag.String("name", "", "Display name (defaults to email local-part)") promoteOnly := flag.Bool("promote-only", false, "Grant platform admin on an existing user; do not set password") confirm := flag.Bool("confirm", false, "Required: acknowledge this writes is_platform_admin + staff_role=admin") @@ -93,11 +98,11 @@ func parseOptions(postgresURL, email, password, name string, promoteOnly, confir } pg := strings.TrimSpace(postgresURL) if pg == "" { - return options{}, fmt.Errorf("-postgres / DATABASE_URL is required") + return options{}, fmt.Errorf("-postgres / DATABASE_URL is required (export it, pass -postgres, or put DATABASE_URL in monorepo-root .env — the API loads .env, your shell may not)") } emailNorm := strings.ToLower(strings.TrimSpace(email)) if emailNorm == "" || !strings.Contains(emailNorm, "@") { - return options{}, fmt.Errorf("-email / PLATFORM_ADMIN_EMAIL is required") + return options{}, fmt.Errorf("-email is required (pass on the CLI; do not store admin credentials in .env)") } if err := rejectLocalDemoAccount(emailNorm, appEnv); err != nil { return options{}, err @@ -122,7 +127,7 @@ func parseOptions(postgresURL, email, password, name string, promoteOnly, confir } pass := password // keep as provided (do not trim interior spaces) if len(pass) < 8 { - return options{}, fmt.Errorf("-password / PLATFORM_ADMIN_PASSWORD must be at least 8 characters") + return options{}, fmt.Errorf("-password must be at least 8 characters (pass on the CLI; do not store in .env)") } if err := rejectDemoPassword(pass, appEnv); err != nil { return options{}, err diff --git a/apps/api/cmd/seed-platform-admin/main_test.go b/apps/api/cmd/seed-platform-admin/main_test.go index a327eae..e3a5b08 100644 --- a/apps/api/cmd/seed-platform-admin/main_test.go +++ b/apps/api/cmd/seed-platform-admin/main_test.go @@ -5,6 +5,14 @@ import ( "testing" ) +func TestParseOptionsRequiresPostgres(t *testing.T) { + t.Parallel() + _, err := parseOptions("", "a@b.com", "password1", "", false, true, "development") + if err == nil || !strings.Contains(err.Error(), "DATABASE_URL") { + t.Fatalf("err = %v, want DATABASE_URL required", err) + } +} + func TestParseOptionsRequiresConfirm(t *testing.T) { t.Parallel() _, err := parseOptions("postgres://x", "a@b.com", "password1", "", false, false, "development") diff --git a/apps/api/internal/config/dotenv.go b/apps/api/internal/config/dotenv.go index 793b0c1..373da6b 100644 --- a/apps/api/internal/config/dotenv.go +++ b/apps/api/internal/config/dotenv.go @@ -7,6 +7,13 @@ import ( "strings" ) +// LoadDotEnv loads the monorepo-root .env into the process environment. +// Existing variables are never overridden. Safe for CLI tools that only need +// DATABASE_URL without a full config.Load (production often has no .env file). +func LoadDotEnv() { + loadDotEnv() +} + // loadDotEnv loads the monorepo-root .env into the process environment. // Existing variables (including empty ones set by tests) are never overridden. // Missing file is a no-op — production typically injects env without a file. diff --git a/package.json b/package.json index 549e41b..2b600b4 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "db:wait": "node scripts/wait-postgres.mjs", "migrate": "node scripts/migrate.mjs", "seed": "node scripts/seed-local.mjs", + "seed:platform-admin": "node scripts/seed-platform-admin.mjs", "health": "node scripts/health.mjs", "cutover:deploy-check": "node scripts/cutover-deploy-check.mjs", "cutover:deploy-check:code": "node scripts/cutover-deploy-check.mjs --skip-goose --skip-readyz", diff --git a/scripts/seed-local.mjs b/scripts/seed-local.mjs index 6d47ca6..6480ef7 100644 --- a/scripts/seed-local.mjs +++ b/scripts/seed-local.mjs @@ -34,14 +34,19 @@ function run(args) { if (r.status !== 0) process.exit(r.status ?? 1); } -console.log(`==> set-password ${adminEmail} (platform admin if present)`); +console.log(`==> seed-platform-admin ${adminEmail}`); run([ "run", - "./cmd/migrator", + "./cmd/seed-platform-admin", "-postgres", env.DATABASE_URL, - "-set-password", - `${adminEmail}:${adminPass}`, + "-email", + adminEmail, + "-password", + adminPass, + "-name", + "Local Admin", + "-confirm", ]); console.log( @@ -63,7 +68,7 @@ run([ console.log(""); console.log("Local accounts:"); console.log(` demo: ${demoEmail} / ${demoPass} (also demo@descrybe.test)`); -console.log(` admin: ${adminEmail} / ${adminPass} (platform admin when that user exists)`); +console.log(` admin: ${adminEmail} / ${adminPass} (platform admin)`); console.log(" plan: Enterprise — Local Demo Co"); console.log(`Web: http://localhost:${DEV_WEB_PORT}/login`); console.log(`API: http://localhost:${DEV_API_PORT}`); diff --git a/scripts/seed-platform-admin.mjs b/scripts/seed-platform-admin.mjs new file mode 100644 index 0000000..c0db8e9 --- /dev/null +++ b/scripts/seed-platform-admin.mjs @@ -0,0 +1,97 @@ +#!/usr/bin/env node +/** + * Upsert a platform admin (is_platform_admin + staff_role=admin). + * Loads monorepo-root `.env` only for DATABASE_URL (same as the API). + * Email/password are CLI flags only — never read from .env. + * + * Usage (from repo root): + * npm run seed:platform-admin -- --email you@example.com --password '…' + * npm run seed:platform-admin -- --email you@example.com --promote-only + * + * Optional: --name "Display Name" + */ +import { spawnSync } from "node:child_process"; +import path from "node:path"; +import { detectHostProfile, withGoLowMem } from "./lowmem-env.mjs"; +import { loadRootEnv, repoRoot } from "./root-env.mjs"; + +const env = withGoLowMem(loadRootEnv(process.env), detectHostProfile()); + +function usage(msg) { + if (msg) console.error(msg); + console.error(`Usage: + npm run seed:platform-admin -- --email you@example.com --password 'strong-password' + npm run seed:platform-admin -- --email you@example.com --promote-only + npm run seed:platform-admin -- --email you@example.com --password '…' --name 'Ops' + +Email/password must be passed on the CLI (not stored in .env). +DATABASE_URL comes from the shell or monorepo-root .env.`); + process.exit(1); +} + +/** Parse `--key value` / `--flag` from argv after npm's `--`. */ +function parseArgs(argv) { + const out = { email: "", password: "", name: "", promoteOnly: false }; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === "--promote-only") { + out.promoteOnly = true; + continue; + } + if (a === "--email" || a === "-email") { + out.email = String(argv[++i] || "").trim(); + continue; + } + if (a === "--password" || a === "-password") { + out.password = String(argv[++i] || ""); + continue; + } + if (a === "--name" || a === "-name") { + out.name = String(argv[++i] || "").trim(); + continue; + } + if (a === "--help" || a === "-h") usage(); + usage(`Unknown argument: ${a}`); + } + return out; +} + +const opts = parseArgs(process.argv.slice(2)); + +if (!env.DATABASE_URL) { + usage("DATABASE_URL is required (shell or monorepo-root .env)"); +} +if (!opts.email || !opts.email.includes("@")) { + usage("--email is required"); +} +if (!opts.promoteOnly && opts.password.length < 8) { + usage("--password is required (min 8 characters), or pass --promote-only"); +} + +const apiDir = path.join(repoRoot, "apps", "api"); +const args = [ + "run", + "./cmd/seed-platform-admin", + "-postgres", + env.DATABASE_URL, + "-email", + opts.email, + "-confirm", +]; +if (opts.name) { + args.push("-name", opts.name); +} +if (opts.promoteOnly) { + args.push("-promote-only"); +} else { + args.push("-password", opts.password); +} + +console.log(`==> seed-platform-admin ${opts.email}`); +const r = spawnSync("go", args, { + cwd: apiDir, + env, + stdio: "inherit", + shell: false, +}); +process.exit(r.status ?? 1);