This commit is contained in:
2026-08-13 21:38:42 +02:00
parent db1f341784
commit 260fe28ac0
6 changed files with 134 additions and 11 deletions
+11 -6
View File
@@ -11,7 +11,9 @@
// -password 'choose-a-strong-password' \ // -password 'choose-a-strong-password' \
// -confirm // -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 // -promote-only grants admin on an existing user without changing the password
// (-password is ignored). Always requires -confirm. // (-password is ignored). Always requires -confirm.
@@ -28,15 +30,18 @@ import (
"time" "time"
"github.com/descrybe/descrybe-v2/apps/api/internal/auth" "github.com/descrybe/descrybe-v2/apps/api/internal/auth"
"github.com/descrybe/descrybe-v2/apps/api/internal/config"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
) )
func main() { func main() {
config.LoadDotEnv()
postgresURL := flag.String("postgres", os.Getenv("DATABASE_URL"), "Postgres URL") postgresURL := flag.String("postgres", os.Getenv("DATABASE_URL"), "Postgres URL")
email := flag.String("email", os.Getenv("PLATFORM_ADMIN_EMAIL"), "Platform admin email") email := flag.String("email", "", "Platform admin email (required; do not put in .env)")
password := flag.String("password", os.Getenv("PLATFORM_ADMIN_PASSWORD"), "Password (min 8; ignored with -promote-only)") 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)") 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") 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") 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) pg := strings.TrimSpace(postgresURL)
if pg == "" { 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)) emailNorm := strings.ToLower(strings.TrimSpace(email))
if emailNorm == "" || !strings.Contains(emailNorm, "@") { 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 { if err := rejectLocalDemoAccount(emailNorm, appEnv); err != nil {
return options{}, err 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) pass := password // keep as provided (do not trim interior spaces)
if len(pass) < 8 { 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 { if err := rejectDemoPassword(pass, appEnv); err != nil {
return options{}, err return options{}, err
@@ -5,6 +5,14 @@ import (
"testing" "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) { func TestParseOptionsRequiresConfirm(t *testing.T) {
t.Parallel() t.Parallel()
_, err := parseOptions("postgres://x", "a@b.com", "password1", "", false, false, "development") _, err := parseOptions("postgres://x", "a@b.com", "password1", "", false, false, "development")
+7
View File
@@ -7,6 +7,13 @@ import (
"strings" "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. // loadDotEnv loads the monorepo-root .env into the process environment.
// Existing variables (including empty ones set by tests) are never overridden. // Existing variables (including empty ones set by tests) are never overridden.
// Missing file is a no-op — production typically injects env without a file. // Missing file is a no-op — production typically injects env without a file.
+1
View File
@@ -13,6 +13,7 @@
"db:wait": "node scripts/wait-postgres.mjs", "db:wait": "node scripts/wait-postgres.mjs",
"migrate": "node scripts/migrate.mjs", "migrate": "node scripts/migrate.mjs",
"seed": "node scripts/seed-local.mjs", "seed": "node scripts/seed-local.mjs",
"seed:platform-admin": "node scripts/seed-platform-admin.mjs",
"health": "node scripts/health.mjs", "health": "node scripts/health.mjs",
"cutover:deploy-check": "node scripts/cutover-deploy-check.mjs", "cutover:deploy-check": "node scripts/cutover-deploy-check.mjs",
"cutover:deploy-check:code": "node scripts/cutover-deploy-check.mjs --skip-goose --skip-readyz", "cutover:deploy-check:code": "node scripts/cutover-deploy-check.mjs --skip-goose --skip-readyz",
+10 -5
View File
@@ -34,14 +34,19 @@ function run(args) {
if (r.status !== 0) process.exit(r.status ?? 1); 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([
"run", "run",
"./cmd/migrator", "./cmd/seed-platform-admin",
"-postgres", "-postgres",
env.DATABASE_URL, env.DATABASE_URL,
"-set-password", "-email",
`${adminEmail}:${adminPass}`, adminEmail,
"-password",
adminPass,
"-name",
"Local Admin",
"-confirm",
]); ]);
console.log( console.log(
@@ -63,7 +68,7 @@ run([
console.log(""); console.log("");
console.log("Local accounts:"); console.log("Local accounts:");
console.log(` demo: ${demoEmail} / ${demoPass} (also demo@descrybe.test)`); 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(" plan: Enterprise — Local Demo Co");
console.log(`Web: http://localhost:${DEV_WEB_PORT}/login`); console.log(`Web: http://localhost:${DEV_WEB_PORT}/login`);
console.log(`API: http://localhost:${DEV_API_PORT}`); console.log(`API: http://localhost:${DEV_API_PORT}`);
+97
View File
@@ -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);