fix
This commit is contained in:
@@ -1,22 +1,16 @@
|
|||||||
// Command seed-platform-admin upserts the first (or additional) platform admin
|
// Command seed-platform-admin upserts a platform admin with a tenant company
|
||||||
// for greenfield / production deploys where no legacy admin_users cutover ran
|
// (membership admin + Free plan), matching self-serve register. Without a
|
||||||
// and seed-demo must not be used.
|
// company, login leaves session company empty and every /api/* tenant call
|
||||||
|
// returns 400 "company not selected".
|
||||||
//
|
//
|
||||||
// Usage:
|
// Usage:
|
||||||
//
|
//
|
||||||
// cd apps/api
|
// npm run seed:platform-admin -- --email you@example.com --password '…'
|
||||||
// go run ./cmd/seed-platform-admin \
|
// npm run seed:platform-admin -- --email you@example.com --promote-only
|
||||||
// -postgres "$env:DATABASE_URL" \
|
// npm run seed:platform-admin -- --email you@example.com --password '…' --company 'Acme Ops'
|
||||||
// -email you@example.com \
|
|
||||||
// -password 'choose-a-strong-password' \
|
|
||||||
// -confirm
|
|
||||||
//
|
//
|
||||||
// Env aliases: none for credentials — pass -email / -password (or use
|
// Credentials are CLI-only (never from .env). DATABASE_URL may come from the
|
||||||
// `npm run seed:platform-admin -- --email … --password …`). DATABASE_URL may
|
// shell or monorepo-root .env via config.LoadDotEnv. Always requires -confirm.
|
||||||
// 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.
|
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -30,6 +24,7 @@ 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/billing"
|
||||||
"github.com/descrybe/descrybe-v2/apps/api/internal/config"
|
"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"
|
||||||
@@ -43,11 +38,13 @@ func main() {
|
|||||||
email := flag.String("email", "", "Platform admin email (required; do not put in .env)")
|
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)")
|
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)")
|
||||||
|
company := flag.String("company", "", "Company/workspace name (default: \"<name> workspace\")")
|
||||||
|
noCompany := flag.Bool("no-company", false, "Skip company creation (staff-only; dashboard tenant APIs will 400)")
|
||||||
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 privilege + optional company writes")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
opts, err := parseOptions(*postgresURL, *email, *password, *name, *promoteOnly, *confirm, os.Getenv("APP_ENV"))
|
opts, err := parseOptions(*postgresURL, *email, *password, *name, *company, *promoteOnly, *noCompany, *confirm, os.Getenv("APP_ENV"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -80,8 +77,30 @@ func main() {
|
|||||||
} else {
|
} else {
|
||||||
fmt.Println(" password: set (argon2id)")
|
fmt.Println(" password: set (argon2id)")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if opts.NoCompany {
|
||||||
|
fmt.Println(" company: skipped (-no-company)")
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
fmt.Println("Sign in on the web app, then open /admin.")
|
fmt.Println("WARNING: without a company, dashboard /api/* calls return 400 company not selected.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
companyID, companyName, companyCreated, err := ensureAdminCompany(ctx, pg, userID, opts.CompanyName)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
billingSvc := &billing.Service{Pool: pg}
|
||||||
|
if err := billingSvc.ProvisionFreePlan(ctx, companyID); err != nil {
|
||||||
|
log.Fatalf("provision Free plan: %v", err)
|
||||||
|
}
|
||||||
|
companyAction := "existing"
|
||||||
|
if companyCreated {
|
||||||
|
companyAction = "created"
|
||||||
|
}
|
||||||
|
fmt.Printf(" company: %s (%s) [%s]\n", companyName, companyID, companyAction)
|
||||||
|
fmt.Println(" plan: Free (provisioned)")
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println("Sign in on the web app (company is selected on login). Platform panel: /admin.")
|
||||||
}
|
}
|
||||||
|
|
||||||
type options struct {
|
type options struct {
|
||||||
@@ -89,10 +108,12 @@ type options struct {
|
|||||||
Email string
|
Email string
|
||||||
Password string
|
Password string
|
||||||
Name string
|
Name string
|
||||||
|
CompanyName string
|
||||||
PromoteOnly bool
|
PromoteOnly bool
|
||||||
|
NoCompany bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseOptions(postgresURL, email, password, name string, promoteOnly, confirm bool, appEnv string) (options, error) {
|
func parseOptions(postgresURL, email, password, name, company string, promoteOnly, noCompany, confirm bool, appEnv string) (options, error) {
|
||||||
if !confirm {
|
if !confirm {
|
||||||
return options{}, fmt.Errorf("-confirm is required (refuses silent privilege grants)")
|
return options{}, fmt.Errorf("-confirm is required (refuses silent privilege grants)")
|
||||||
}
|
}
|
||||||
@@ -116,11 +137,18 @@ func parseOptions(postgresURL, email, password, name string, promoteOnly, confir
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
companyName := strings.TrimSpace(company)
|
||||||
|
if !noCompany && companyName == "" {
|
||||||
|
companyName = display + " workspace"
|
||||||
|
}
|
||||||
|
|
||||||
opts := options{
|
opts := options{
|
||||||
PostgresURL: pg,
|
PostgresURL: pg,
|
||||||
Email: emailNorm,
|
Email: emailNorm,
|
||||||
Name: display,
|
Name: display,
|
||||||
|
CompanyName: companyName,
|
||||||
PromoteOnly: promoteOnly,
|
PromoteOnly: promoteOnly,
|
||||||
|
NoCompany: noCompany,
|
||||||
}
|
}
|
||||||
if promoteOnly {
|
if promoteOnly {
|
||||||
return opts, nil
|
return opts, nil
|
||||||
@@ -225,3 +253,61 @@ func upsertPlatformAdmin(ctx context.Context, pg *pgxpool.Pool, opts options) (u
|
|||||||
}
|
}
|
||||||
return userID, !existed, nil
|
return userID, !existed, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ensureAdminCompany attaches the user to a company as membership admin.
|
||||||
|
// If they already have any active membership, reuses the first company (idempotent repair).
|
||||||
|
func ensureAdminCompany(ctx context.Context, pg *pgxpool.Pool, userID uuid.UUID, companyName string) (uuid.UUID, string, bool, error) {
|
||||||
|
var existingID uuid.UUID
|
||||||
|
var existingName string
|
||||||
|
err := pg.QueryRow(ctx, `
|
||||||
|
SELECT c.id, c.name
|
||||||
|
FROM memberships m
|
||||||
|
JOIN companies c ON c.id = m.company_id
|
||||||
|
WHERE m.user_id = $1 AND m.status = 'active'
|
||||||
|
ORDER BY m.created_at NULLS LAST, c.name
|
||||||
|
LIMIT 1`, userID).Scan(&existingID, &existingName)
|
||||||
|
if err == nil {
|
||||||
|
_, _ = pg.Exec(ctx, `
|
||||||
|
UPDATE memberships
|
||||||
|
SET role = 'admin', status = 'active', updated_at = now()
|
||||||
|
WHERE user_id = $1 AND company_id = $2`, userID, existingID)
|
||||||
|
return existingID, existingName, false, nil
|
||||||
|
}
|
||||||
|
if !errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return uuid.Nil, "", false, fmt.Errorf("lookup memberships: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := pg.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return uuid.Nil, "", false, fmt.Errorf("begin company: %w", err)
|
||||||
|
}
|
||||||
|
defer tx.Rollback(ctx)
|
||||||
|
|
||||||
|
var companyID uuid.UUID
|
||||||
|
err = tx.QueryRow(ctx, `INSERT INTO companies (name) VALUES ($1) RETURNING id`, companyName).Scan(&companyID)
|
||||||
|
if err != nil {
|
||||||
|
return uuid.Nil, "", false, fmt.Errorf("insert company: %w", err)
|
||||||
|
}
|
||||||
|
_, err = tx.Exec(ctx, `
|
||||||
|
INSERT INTO memberships (company_id, user_id, role, status)
|
||||||
|
VALUES ($1, $2, 'admin', 'active')`, companyID, userID)
|
||||||
|
if err != nil {
|
||||||
|
return uuid.Nil, "", false, fmt.Errorf("insert membership: %w", err)
|
||||||
|
}
|
||||||
|
_, err = tx.Exec(ctx, `
|
||||||
|
INSERT INTO company_settings (company_id) VALUES ($1)
|
||||||
|
ON CONFLICT DO NOTHING`, companyID)
|
||||||
|
if err != nil {
|
||||||
|
return uuid.Nil, "", false, fmt.Errorf("company_settings: %w", err)
|
||||||
|
}
|
||||||
|
_, err = tx.Exec(ctx, `
|
||||||
|
INSERT INTO credit_balances (company_id) VALUES ($1)
|
||||||
|
ON CONFLICT DO NOTHING`, companyID)
|
||||||
|
if err != nil {
|
||||||
|
return uuid.Nil, "", false, fmt.Errorf("credit_balances: %w", err)
|
||||||
|
}
|
||||||
|
if err := tx.Commit(ctx); err != nil {
|
||||||
|
return uuid.Nil, "", false, fmt.Errorf("commit company: %w", err)
|
||||||
|
}
|
||||||
|
return companyID, companyName, true, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
|
|
||||||
func TestParseOptionsRequiresPostgres(t *testing.T) {
|
func TestParseOptionsRequiresPostgres(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
_, err := parseOptions("", "a@b.com", "password1", "", false, true, "development")
|
_, err := parseOptions("", "a@b.com", "password1", "", "", false, false, true, "development")
|
||||||
if err == nil || !strings.Contains(err.Error(), "DATABASE_URL") {
|
if err == nil || !strings.Contains(err.Error(), "DATABASE_URL") {
|
||||||
t.Fatalf("err = %v, want DATABASE_URL required", err)
|
t.Fatalf("err = %v, want DATABASE_URL required", err)
|
||||||
}
|
}
|
||||||
@@ -15,7 +15,7 @@ func TestParseOptionsRequiresPostgres(t *testing.T) {
|
|||||||
|
|
||||||
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, false, "development")
|
||||||
if err == nil || !strings.Contains(err.Error(), "-confirm") {
|
if err == nil || !strings.Contains(err.Error(), "-confirm") {
|
||||||
t.Fatalf("err = %v, want -confirm required", err)
|
t.Fatalf("err = %v, want -confirm required", err)
|
||||||
}
|
}
|
||||||
@@ -23,19 +23,37 @@ func TestParseOptionsRequiresConfirm(t *testing.T) {
|
|||||||
|
|
||||||
func TestParseOptionsRequiresEmailAndPassword(t *testing.T) {
|
func TestParseOptionsRequiresEmailAndPassword(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
_, err := parseOptions("postgres://x", "", "password1", "", false, true, "development")
|
_, err := parseOptions("postgres://x", "", "password1", "", "", false, false, true, "development")
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected email error")
|
t.Fatal("expected email error")
|
||||||
}
|
}
|
||||||
_, err = parseOptions("postgres://x", "a@b.com", "short", "", false, true, "development")
|
_, err = parseOptions("postgres://x", "a@b.com", "short", "", "", false, false, true, "development")
|
||||||
if err == nil || !strings.Contains(err.Error(), "8") {
|
if err == nil || !strings.Contains(err.Error(), "8") {
|
||||||
t.Fatalf("err = %v, want min length", err)
|
t.Fatalf("err = %v, want min length", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestParseOptionsDefaultCompanyName(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
opts, err := parseOptions("postgres://x", "ops@example.com", "password1", "Ops", "", false, false, true, "development")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if opts.CompanyName != "Ops workspace" {
|
||||||
|
t.Fatalf("company = %q", opts.CompanyName)
|
||||||
|
}
|
||||||
|
opts, err = parseOptions("postgres://x", "ops@example.com", "password1", "Ops", "Acme", false, false, true, "development")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if opts.CompanyName != "Acme" {
|
||||||
|
t.Fatalf("company = %q", opts.CompanyName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestParseOptionsPromoteOnlySkipsPassword(t *testing.T) {
|
func TestParseOptionsPromoteOnlySkipsPassword(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
opts, err := parseOptions("postgres://x", "Ops@Example.COM", "", "Ops", true, true, "production")
|
opts, err := parseOptions("postgres://x", "Ops@Example.COM", "", "Ops", "Acme", true, false, true, "production")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -45,18 +63,18 @@ func TestParseOptionsPromoteOnlySkipsPassword(t *testing.T) {
|
|||||||
if opts.Password != "" || !opts.PromoteOnly {
|
if opts.Password != "" || !opts.PromoteOnly {
|
||||||
t.Fatalf("promote-only opts = %+v", opts)
|
t.Fatalf("promote-only opts = %+v", opts)
|
||||||
}
|
}
|
||||||
if opts.Name != "Ops" {
|
if opts.CompanyName != "Acme" {
|
||||||
t.Fatalf("name = %q", opts.Name)
|
t.Fatalf("company = %q", opts.CompanyName)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseOptionsRejectsDemoInProduction(t *testing.T) {
|
func TestParseOptionsRejectsDemoInProduction(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
_, err := parseOptions("postgres://x", "demo@descrybe.local", "securepass", "", false, true, "production")
|
_, err := parseOptions("postgres://x", "demo@descrybe.local", "securepass", "", "", false, false, true, "production")
|
||||||
if err == nil || !strings.Contains(err.Error(), "demo/local") {
|
if err == nil || !strings.Contains(err.Error(), "demo/local") {
|
||||||
t.Fatalf("err = %v, want demo/local refuse", err)
|
t.Fatalf("err = %v, want demo/local refuse", err)
|
||||||
}
|
}
|
||||||
_, err = parseOptions("postgres://x", "you@example.com", "DemoPass123!", "", false, true, "prod")
|
_, err = parseOptions("postgres://x", "you@example.com", "DemoPass123!", "", "", false, false, true, "prod")
|
||||||
if err == nil || !strings.Contains(err.Error(), "seed-demo password") {
|
if err == nil || !strings.Contains(err.Error(), "seed-demo password") {
|
||||||
t.Fatalf("err = %v, want demo password refuse", err)
|
t.Fatalf("err = %v, want demo password refuse", err)
|
||||||
}
|
}
|
||||||
@@ -64,7 +82,7 @@ func TestParseOptionsRejectsDemoInProduction(t *testing.T) {
|
|||||||
|
|
||||||
func TestParseOptionsAllowsDemoLocally(t *testing.T) {
|
func TestParseOptionsAllowsDemoLocally(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
opts, err := parseOptions("postgres://x", "demo@descrybe.local", "DemoPass123!", "", false, true, "development")
|
opts, err := parseOptions("postgres://x", "demo@descrybe.local", "DemoPass123!", "", "", false, false, true, "development")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ run([
|
|||||||
adminPass,
|
adminPass,
|
||||||
"-name",
|
"-name",
|
||||||
"Local Admin",
|
"Local Admin",
|
||||||
|
"-company",
|
||||||
|
"Local Admin Co",
|
||||||
"-confirm",
|
"-confirm",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
/**
|
/**
|
||||||
* Upsert a platform admin (is_platform_admin + staff_role=admin).
|
* Upsert a platform admin with a tenant company (fixes empty-company 400s).
|
||||||
* Loads monorepo-root `.env` only for DATABASE_URL (same as the API).
|
* Loads monorepo-root `.env` only for DATABASE_URL.
|
||||||
* Email/password are CLI flags only — never read from .env.
|
* Email/password are CLI flags only — never read from .env.
|
||||||
*
|
*
|
||||||
* Usage (from repo root):
|
* Usage (from repo root):
|
||||||
* npm run seed:platform-admin -- --email you@example.com --password '…'
|
* npm run seed:platform-admin -- --email you@example.com --password '…'
|
||||||
* npm run seed:platform-admin -- --email you@example.com --promote-only
|
* npm run seed:platform-admin -- --email you@example.com --promote-only
|
||||||
*
|
* npm run seed:platform-admin -- --email you@example.com --password '…' --company 'Acme Ops'
|
||||||
* Optional: --name "Display Name"
|
|
||||||
*/
|
*/
|
||||||
import { spawnSync } from "node:child_process";
|
import { spawnSync } from "node:child_process";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
@@ -22,22 +21,34 @@ function usage(msg) {
|
|||||||
console.error(`Usage:
|
console.error(`Usage:
|
||||||
npm run seed:platform-admin -- --email you@example.com --password 'strong-password'
|
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 --promote-only
|
||||||
npm run seed:platform-admin -- --email you@example.com --password '…' --name 'Ops'
|
npm run seed:platform-admin -- --email you@example.com --password '…' --company 'Acme Ops'
|
||||||
|
|
||||||
Email/password must be passed on the CLI (not stored in .env).
|
Email/password must be passed on the CLI (not stored in .env).
|
||||||
DATABASE_URL comes from the shell or monorepo-root .env.`);
|
DATABASE_URL comes from the shell or monorepo-root .env.
|
||||||
|
Creates a company + admin membership + Free plan unless --no-company.`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Parse `--key value` / `--flag` from argv after npm's `--`. */
|
/** Parse `--key value` / `--flag` from argv after npm's `--`. */
|
||||||
function parseArgs(argv) {
|
function parseArgs(argv) {
|
||||||
const out = { email: "", password: "", name: "", promoteOnly: false };
|
const out = {
|
||||||
|
email: "",
|
||||||
|
password: "",
|
||||||
|
name: "",
|
||||||
|
company: "",
|
||||||
|
promoteOnly: false,
|
||||||
|
noCompany: false,
|
||||||
|
};
|
||||||
for (let i = 0; i < argv.length; i++) {
|
for (let i = 0; i < argv.length; i++) {
|
||||||
const a = argv[i];
|
const a = argv[i];
|
||||||
if (a === "--promote-only") {
|
if (a === "--promote-only") {
|
||||||
out.promoteOnly = true;
|
out.promoteOnly = true;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (a === "--no-company") {
|
||||||
|
out.noCompany = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (a === "--email" || a === "-email") {
|
if (a === "--email" || a === "-email") {
|
||||||
out.email = String(argv[++i] || "").trim();
|
out.email = String(argv[++i] || "").trim();
|
||||||
continue;
|
continue;
|
||||||
@@ -50,6 +61,10 @@ function parseArgs(argv) {
|
|||||||
out.name = String(argv[++i] || "").trim();
|
out.name = String(argv[++i] || "").trim();
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (a === "--company" || a === "-company") {
|
||||||
|
out.company = String(argv[++i] || "").trim();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (a === "--help" || a === "-h") usage();
|
if (a === "--help" || a === "-h") usage();
|
||||||
usage(`Unknown argument: ${a}`);
|
usage(`Unknown argument: ${a}`);
|
||||||
}
|
}
|
||||||
@@ -78,9 +93,9 @@ const args = [
|
|||||||
opts.email,
|
opts.email,
|
||||||
"-confirm",
|
"-confirm",
|
||||||
];
|
];
|
||||||
if (opts.name) {
|
if (opts.name) args.push("-name", opts.name);
|
||||||
args.push("-name", opts.name);
|
if (opts.company) args.push("-company", opts.company);
|
||||||
}
|
if (opts.noCompany) args.push("-no-company");
|
||||||
if (opts.promoteOnly) {
|
if (opts.promoteOnly) {
|
||||||
args.push("-promote-only");
|
args.push("-promote-only");
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
Reference in New Issue
Block a user