Files
recflare/packages/tools/src/cmd/admin.cmd.ts
T
Devin Zuczek e286532ac6 kv-to-d1
2026-10-26 13:10:37 -04:00

671 lines
28 KiB
TypeScript

import * as readline from 'node:readline'
import { Command } from '@commander-js/extra-typings'
import Table from 'cli-table3'
import {
customAvatarItemUpsertStatements,
readCustomAvatarItemExport,
} from '../../../../apps/api/src/custom-avatar-items-load'
import {
DEFAULT_STARTING_TOKENS,
PLUS_MEMBERS_SQL,
plusReloadSql,
} from '../../../../apps/econ/src/currency'
import { execSql, execSqlFile, readRootEnv, resolveRemote, sqlStr, target } from '../d1'
import { hashPassword } from '../password'
import { getRepoRoot } from '../path'
import type { D1ExecResult } from '../d1'
/**
* Operator-facing admin tools for the shared `recflare` D1 database. Each command
* shells out to `wrangler d1 execute recflare` — no running worker or auth token
* needed — defaulting to the local dev database and targeting the deployed one only
* with `--remote`. Password hashing comes from @repo/domain, the same code the auth
* worker uses, so a hash set here always verifies at login.
*
* runx admin set-password --account 1 [--remote]
* runx admin clear-password --username alice [--remote]
* runx admin lookup --username alice [--remote]
* runx admin grant-developer --account 1 [--revoke] [--remote]
* runx admin grant-influencer --account 1 [--revoke] [--remote]
* runx admin grant-plus --username alice [--revoke] [--remote]
* runx admin reload-plus <amount> [--dry-run] [--remote]
* runx admin cai-load [--file <export.json>] [--dry-run] [--remote]
* runx admin settings-import-kv [--namespace-id <id>] [--overwrite] [--dry-run] [--remote]
*/
/**
* Resolve the account selector into a SQL WHERE fragment. Exactly one of
* `--account` / `--username` must be given. Account ids are validated numeric;
* usernames match the indexed, case-insensitive `username_lower` generated column.
*/
function whereClause(account?: string, username?: string): { where: string; label: string } {
if ((account == null) === (username == null)) {
throw new Error('provide exactly one of --account or --username')
}
if (account != null) {
if (!/^\d+$/.test(account)) throw new Error('--account must be a numeric account id')
return { where: `account_id = ${account}`, label: `account ${account}` }
}
return {
where: `username_lower = '${sqlStr(username!.toLowerCase())}'`,
label: `username "${username}"`,
}
}
/** Prompt for a line of input without echoing what's typed (for passwords). */
function promptHidden(query: string): Promise<string> {
return new Promise((resolve) => {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
terminal: true,
})
// Mute the echo of typed characters; write the prompt ourselves.
;(rl as unknown as { _writeToOutput: (s: string) => void })._writeToOutput = () => {}
process.stdout.write(query)
rl.question('', (answer) => {
process.stdout.write('\n')
rl.close()
resolve(answer)
})
})
}
/**
* Get the new password: from `--password`, else from piped stdin (for scripting),
* else prompted interactively (hidden, entered twice and compared).
*/
async function resolvePassword(flag?: string): Promise<string> {
if (flag != null && flag !== '') return flag
if (!process.stdin.isTTY) {
const chunks: Buffer[] = []
for await (const chunk of process.stdin) chunks.push(chunk as Buffer)
const piped = Buffer.concat(chunks)
.toString('utf8')
.replace(/\r?\n$/, '')
if (piped === '') throw new Error('no password provided on stdin')
return piped
}
const first = await promptHidden('New password: ')
if (first === '') throw new Error('password must not be empty')
const second = await promptHidden('Confirm password: ')
if (first !== second) throw new Error('passwords did not match')
return first
}
/**
* Fail when a WHERE-scoped UPDATE matched no row (i.e. no such account). Relies on
* the statement's `RETURNING account_id` — wrangler's `--json` meta doesn't reliably
* carry a `changes` count, but the returned rows always reflect what actually matched.
*/
function assertMatched(res: D1ExecResult, label: string): void {
if (res.results.length < 1) throw new Error(`no account found for ${label}`)
}
const setPassword = new Command('set-password')
.description("Set (or replace) an account's login password")
.option('--account <id>', 'Account id to target')
.option('--username <name>', 'Username to target (case-insensitive)')
.option('--password <password>', 'The new password (omit to be prompted, or pipe via stdin)')
.option('--local', 'Target the local dev database (the default).', false)
.option('--remote', 'Target the deployed database instead of the local dev database.', false)
.action(async (opts) => {
const { where, label } = whereClause(opts.account, opts.username)
const remote = resolveRemote(opts)
const password = await resolvePassword(opts.password)
const hash = await hashPassword(password)
const sql = `UPDATE account SET data = json_set(data, '$.passwordHash', '${sqlStr(hash)}') WHERE ${where} RETURNING account_id`
console.log(`Setting password for ${label} on ${target(remote)}`)
assertMatched(await execSql(sql, remote), label)
console.log(chalk.green(`✓ password set for ${label}`))
})
const clearPassword = new Command('clear-password')
.description("Remove an account's password so it has no login credential")
.option('--account <id>', 'Account id to target')
.option('--username <name>', 'Username to target (case-insensitive)')
.option('--local', 'Target the local dev database (the default).', false)
.option('--remote', 'Target the deployed database instead of the local dev database.', false)
.action(async (opts) => {
const { where, label } = whereClause(opts.account, opts.username)
const remote = resolveRemote(opts)
const sql = `UPDATE account SET data = json_remove(data, '$.passwordHash') WHERE ${where} RETURNING account_id`
console.log(`Clearing password for ${label} on ${target(remote)}`)
assertMatched(await execSql(sql, remote), label)
console.log(chalk.green(`✓ password cleared for ${label}`))
})
/**
* Build a `grant-<thing>` command that toggles a boolean flag on the account blob.
* `jsonKey` is the account field (e.g. `isDeveloper`) — a fixed literal, not user input.
*
* `noun` is what the flag IS, and it is not always "role": the role flags feed the
* /role/:role lookup and the token's `role` claim, while `hasPlus` is an entitlement that
* rides on its own `rn.plus` claim and confers no role at all. Getting that word right in
* the output is the difference between an operator believing they granted a staff power
* and knowing they granted a subscription.
*/
function grantRoleCommand(name: string, jsonKey: string, roleLabel: string, noun = 'role') {
return new Command(name)
.description(`Grant (or, with --revoke, remove) ${roleLabel} on an account`)
.option('--account <id>', 'Account id to target')
.option('--username <name>', 'Username to target (case-insensitive)')
.option('--revoke', `Remove ${roleLabel} instead of granting it`, false)
.option('--local', 'Target the local dev database (the default).', false)
.option('--remote', 'Target the deployed database instead of the local dev database.', false)
.action(async (opts) => {
const { where, label } = whereClause(opts.account, opts.username)
const remote = resolveRemote(opts)
const value = opts.revoke ? 'false' : 'true'
const sql = `UPDATE account SET data = json_set(data, '$.${jsonKey}', json('${value}')) WHERE ${where} RETURNING account_id`
const verb = opts.revoke ? 'Revoking' : 'Granting'
console.log(`${verb} ${roleLabel} ${noun} for ${label} on ${target(remote)}`)
assertMatched(await execSql(sql, remote), label)
console.log(
chalk.green(`✓ ${roleLabel} ${noun} ${opts.revoke ? 'revoked' : 'granted'} for ${label}`)
)
})
}
const grantDeveloper = grantRoleCommand('grant-developer', 'isDeveloper', 'developer')
const grantModerator = grantRoleCommand('grant-moderator', 'isModerator', 'moderator')
/**
* The influencer role, the account's `isInfluencer` flag. `auth` stamps it into the token's
* `role` claim as `influencer` on the next login; nothing else reads the flag.
*/
const grantInfluencer = grantRoleCommand('grant-influencer', 'isInfluencer', 'influencer')
/**
* Rec Room Plus, the account's `hasPlus` flag. Players normally get it themselves by
* claiming a Discord role on the website; this is the operator's way in — and the ONLY
* one, since the `developer` role deliberately no longer confers Plus.
*
* Granting does not take effect until the account's NEXT login: `auth` stamps `hasPlus`
* into the token as `rn.plus` when it mints one, and `econ` reads nothing else. Tokens
* last a day and the client never refreshes them, so tell the player to restart the game
* and sign in again.
*
* Revoking has the same lag in reverse — a player keeps Plus until their current token
* expires. It is not a way to cut someone off immediately.
*/
const grantPlus = grantRoleCommand('grant-plus', 'hasPlus', 'Rec Room Plus', 'subscription')
/**
* Rec Room Studio upload access, the account's `hasStudio` flag. Staff normally set it
* from the website (`/settings/recroomstudio`); this is the operator's way in. `auth`
* stamps it into the token as the `betastudio` role, so like Plus it lands on the
* player's next Studio sign-in or refresh.
*/
const grantStudio = grantRoleCommand('grant-studio', 'hasStudio', 'Rec Room Studio', 'access')
/**
* The Rec Room Plus token reload: credit every subscriber's RecCenterTokens balance by
* `amount`, in one statement. Nothing schedules this — an operator runs it when the
* subscription's tokens are due (`just reload-plus 1000 --remote`), so running it twice
* credits twice; `--dry-run` shows who would be credited and what they hold, and changes
* nothing.
*
* Who is a subscriber is `account.hasPlus`, found through the `has_plus` generated column
* (auth migration 0009) — the same flag `grant-plus` and the website's Discord claim set,
* read straight off the row, so it does not lag a login the way the token's `rn.plus`
* claim does. A subscriber who has never touched econ has no balance row yet; they get one
* holding their signup grant plus the reload, so the grant isn't lost — see
* `plusReloadSql`. The grant amount is `RECFLARE_STARTING_TOKENS` from the environment
* or .env, the value a deploy ships to econ, falling back to econ's built-in default.
*
* Runs under econ's wrangler config because `balance` is econ's table; locally that
* means econ's dev D1 state, which has to hold both `balance` AND a migrated `account`.
*/
/** A row of `PLUS_MEMBERS_SQL` (and, sans `username`, of the reload's `RETURNING`). */
interface PlusMemberRow {
account_id: number
username?: string | null
/** NULL when the account has no balance row yet, i.e. its signup grant is still pending. */
amount: number | null
}
const reloadPlus = new Command('reload-plus')
.description('Credit every Rec Room Plus subscriber with <amount> RecCenterTokens')
.argument('<amount>', 'Tokens to add to each subscriber (a positive integer)')
.option('--dry-run', 'List the subscribers and their balances without crediting', false)
.option('--local', 'Target the local dev database (the default).', false)
.option('--remote', 'Target the deployed database instead of the local dev database.', false)
.action(async (amountArg, opts) => {
if (!/^\d+$/.test(amountArg) || Number(amountArg) < 1) {
throw new Error('<amount> must be a positive integer')
}
const amount = Number(amountArg)
const remote = resolveRemote(opts)
const startingRaw = await readRootEnv('RECFLARE_STARTING_TOKENS')
const startingTokens = startingRaw == null ? DEFAULT_STARTING_TOKENS : Number(startingRaw)
if (!Number.isInteger(startingTokens) || startingTokens < 0) {
throw new Error(`RECFLARE_STARTING_TOKENS is not a non-negative integer: ${startingRaw}`)
}
console.log(`Plus subscribers on ${target(remote)}`)
const before = (await execSql<PlusMemberRow>(PLUS_MEMBERS_SQL, remote, 'econ')).results
if (before.length === 0) {
console.log(chalk.yellow('no accounts have hasPlus set — nothing to reload'))
return
}
const table = new Table({ head: ['account', 'username', 'tokens'] })
for (const r of before) {
table.push([
String(r.account_id),
r.username ?? '',
r.amount == null ? chalk.dim(`(none: grant ${startingTokens} pending)`) : String(r.amount),
])
}
console.log(table.toString())
if (opts.dryRun) {
console.log(
chalk.yellow(`dry run: would add ${amount} tokens to ${before.length} account(s)`)
)
return
}
console.log(`Adding ${amount} tokens to ${before.length} account(s)`)
const credited = (
await execSql<PlusMemberRow>(plusReloadSql(amount, startingTokens), remote, 'econ')
).results
const after = new Table({ head: ['account', 'tokens'] })
for (const r of credited) after.push([String(r.account_id), String(r.amount)])
console.log(after.toString())
console.log(chalk.green(`✓ ${amount} tokens added to ${credited.length} account(s)`))
})
/**
* The exports `cai-load` reads when no `--file` is given, in PRECEDENCE order: each only adds
* ids the ones before it lack, so the studio87 extras never overwrite a 2025 item.
*/
const CUSTOM_AVATAR_ITEM_EXPORTS = [
'apps/econ/static/db/2025-1-cai.json',
'apps/econ/static/db/studio87-extras.json',
]
/**
* Load the first-party custom avatar items into `custom_avatar_item` (owned by the `api`
* worker). Like `runx catalog load`: the table's STRUCTURE is a migration, its first-party
* CONTENTS are not, and this is the reload. Each record lands as the row's JSON with its
* `CreatorAccountId` forced to the Coach account (1) — what makes it stock content here — and
* each save's `ThumbnailFileName` put under `avatar/` and its assetbundle hashes blanked — they
* are the PC builds' hashes, and the same save serves the Quest builds too. (The statements
* come from api's `custom-avatar-items-load.ts`, the same ones the migration generator writes.)
* An id already present is REPLACED, and nothing is deleted — the players' own shirts share the
* table and an export never mentions them. Re-running is always safe.
*/
const caiLoad = new Command('cai-load')
.description('Load first-party custom avatar items from an export JSON (merges; never deletes)')
.option(
'--file <paths...>',
'The export(s) to load, relative to the repo root (default: every committed export)',
CUSTOM_AVATAR_ITEM_EXPORTS
)
.option(
'--dry-run',
'Read and validate the export, print what would be written, change nothing',
false
)
.option('--local', 'Target the local dev database (the default).', false)
.option('--remote', 'Target the deployed database instead of the local dev database.', false)
.action(async (opts) => {
const remote = resolveRemote(opts)
// A later export only ADDS: an id an earlier one already holds is skipped, never replaced.
// (A duplicate within one export is still refused.)
const records: ReturnType<typeof readCustomAvatarItemExport> = []
const seen = new Set<string>()
for (const file of opts.file) {
const full = path.isAbsolute(file) ? file : path.join(getRepoRoot(), file)
if (!(await fs.pathExists(full))) throw new Error(`no export at ${file}`)
// Tolerate the BOM a .NET export carries (`JSON.parse` rejects U+FEFF).
const text = (await fs.readFile(full, 'utf8')).replace(/^\uFEFF/, '')
let skipped = 0
for (const record of readCustomAvatarItemExport(JSON.parse(text) as unknown)) {
if (seen.has(record.CustomAvatarItemId)) {
skipped++
continue
}
seen.add(record.CustomAvatarItemId)
records.push(record)
}
if (skipped > 0) {
console.log(chalk.yellow(`${file}: skipped ${skipped} id(s) an earlier export holds`))
}
}
if (records.length === 0)
throw new Error('the export holds no records — refusing to call that a load')
const statements = customAvatarItemUpsertStatements(records)
const sql = statements.join('\n')
console.log(
`Merging ${records.length} custom avatar items from ${opts.file.join(', ')} into ${target(remote)}`
)
if (opts.dryRun) {
console.log(
chalk.cyan(
`--dry-run: built ${statements.length} statements (${(sql.length / 1024).toFixed(0)} KB); nothing was written.`
)
)
return
}
const count = async (): Promise<number> =>
Number(
(await execSql('SELECT COUNT(*) AS n FROM custom_avatar_item', remote, 'api')).results[0]
?.n ?? 0
)
const before = await count()
// Written to a temp file rather than passed as `--command`: a multi-megabyte argv is not
// something to rely on, and `--file` is the path wrangler batches (or, remotely, imports).
const file = path.join(os.tmpdir(), `recflare-cai-${Date.now()}.sql`)
await fs.writeFile(file, sql)
try {
await execSqlFile(file, remote, 'api')
} finally {
await fs.remove(file)
}
// Prove the load landed rather than trusting the exit: wrangler's meta carries no usable
// row count, and a file that fails partway leaves a partial load rather than an error.
// A merge only adds, so the table must hold at least what was written; then spot-check
// ids from across the export, the last one mattering most.
const after = await count()
if (after < records.length) {
throw new Error(
`load did not land: expected at least ${records.length} rows, the table holds ${after}. Re-run it.`
)
}
const probes = [
records[0],
records[Math.floor(records.length / 2)],
records[records.length - 1],
].map((r) => `'${sqlStr(r.CustomAvatarItemId)}'`)
const found = Number(
(
await execSql(
`SELECT COUNT(*) AS n FROM custom_avatar_item WHERE custom_avatar_item_id IN (${probes.join(', ')})`,
remote,
'api'
)
).results[0]?.n ?? 0
)
if (found !== probes.length) {
throw new Error(
`load did not land: ${probes.length - found} of ${probes.length} probe rows are missing. Re-run it.`
)
}
const added = after - before
console.log(
`${added} new, ${records.length - added} replaced in place, 0 removed (${after} rows now)`
)
console.log(chalk.green(`✓ custom avatar items loaded into ${target(remote)}`))
})
/** The KV binding the player settings used to live under — the key `RECFLARE_KV` files its id by. */
const SETTINGS_KV_BINDING = 'RECFLARE_PLAYER_SETTINGS'
/** Every settings key in that namespace was `player:<accountId>`. */
const SETTINGS_KV_PREFIX = 'player:'
/** The KV bulk-get API takes at most this many keys per request. */
const KV_BULK_GET_LIMIT = 100
/**
* Run `wrangler kv …` from the `playersettings` directory — the namespace was that worker's
* binding, so with `--local` the dev-store state wrangler kept for it is under its .wrangler/.
*/
async function runKv(args: string[], remote: boolean): Promise<string> {
cd(path.join(getRepoRoot(), 'apps', 'playersettings'))
const out = await $`pnpm exec wrangler kv ${args} ${remote ? '--remote' : '--local'}`.quiet()
return out.stdout
}
/** The JSON wrangler prints after its banner (which can carry ANSI `[` sequences of its own). */
function parseWranglerJson<T>(stdout: string): T {
const m = /^[[{]/m.exec(stdout)
if (!m) throw new Error(`unexpected wrangler output:\n${stdout}`)
return JSON.parse(stdout.slice(m.index)) as T
}
/** The old namespace's id, from the `RECFLARE_KV` entry the deploys used to splice it from. */
async function settingsNamespaceIdFromEnv(): Promise<string> {
const raw = await readRootEnv('RECFLARE_KV')
const id = raw ? (JSON.parse(raw) as Record<string, string>)[SETTINGS_KV_BINDING] : undefined
if (id) return id
throw new Error(
`no ${SETTINGS_KV_BINDING} id in RECFLARE_KV — pass --namespace-id, or add the old namespace's id to .env`
)
}
/**
* Copy the old `RECFLARE_PLAYER_SETTINGS` KV namespace into the `player_settings` table (owned
* by the `playersettings` worker). A KV value was the player's whole map as a JSON object of
* strings, which is exactly what the row's `data` holds, so each key becomes one row.
*
* Rows that already exist are MERGED, with D1 winning: `json_patch(kv, d1)` keeps every key
* the live row has and adds the ones only KV knew. That makes the import safe to run after
* the workers have cut over (a player who has since toggled something keeps it) and to
* re-run. `--overwrite` makes KV win outright instead. Nothing is deleted on either side.
*/
const settingsImportKv = new Command('settings-import-kv')
.description('Copy the old RECFLARE_PLAYER_SETTINGS KV namespace into player_settings (merges)')
.option(
'--namespace-id <id>',
`The KV namespace to read (default: ${SETTINGS_KV_BINDING} in RECFLARE_KV from .env; "local" with --local)`
)
.option(
'--overwrite',
'Let the KV value replace a row that already exists (the default merges, keeping what D1 has)',
false
)
.option('--dry-run', 'Read the namespace, report what would be written, change nothing', false)
.option('--local', 'Target the local dev database and KV store (the default).', false)
.option('--remote', 'Target the deployed database and namespace instead.', false)
.action(async (opts) => {
const remote = resolveRemote(opts)
const namespaceId = opts.namespaceId ?? (remote ? await settingsNamespaceIdFromEnv() : 'local')
console.log(
`Reading KV namespace ${namespaceId} (${remote ? 'remote' : 'local'}) into ${target(remote)}`
)
const keys = parseWranglerJson<Array<{ name: string }>>(
await runKv(
['key', 'list', '--namespace-id', namespaceId, '--prefix', SETTINGS_KV_PREFIX],
remote
)
).map((k) => k.name)
console.log(`${keys.length} ${SETTINGS_KV_PREFIX}* key(s) in the namespace`)
const rows: Array<{ accountId: number; data: string }> = []
const skipped: string[] = []
let empty = 0
for (let i = 0; i < keys.length; i += KV_BULK_GET_LIMIT) {
const chunk = keys.slice(i, i + KV_BULK_GET_LIMIT)
const file = path.join(os.tmpdir(), `recflare-kv-keys-${Date.now()}-${i}.json`)
await fs.writeFile(file, JSON.stringify(chunk))
let got: Record<string, { value: string | null }>
try {
got = parseWranglerJson(
await runKv(['bulk', 'get', file, '--namespace-id', namespaceId], remote)
)
} finally {
await fs.remove(file)
}
for (const key of chunk) {
const accountId = Number(key.slice(SETTINGS_KV_PREFIX.length))
const value = got[key]?.value
let parsed: unknown = null
try {
parsed = value == null ? null : JSON.parse(value)
} catch {
parsed = null
}
const isMap =
parsed !== null &&
typeof parsed === 'object' &&
!Array.isArray(parsed) &&
Object.values(parsed).every((v) => typeof v === 'string')
if (!Number.isSafeInteger(accountId) || accountId <= 0 || !isMap) {
skipped.push(key)
continue
}
// An empty map is what a player with no row reads as; nothing to import.
if (Object.keys(parsed as object).length === 0) {
empty++
continue
}
rows.push({ accountId, data: JSON.stringify(parsed) })
}
console.log(` fetched ${Math.min(i + chunk.length, keys.length)}/${keys.length}`)
}
for (const key of skipped) console.log(chalk.yellow(` skipping ${key}: not a settings map`))
const onConflict = opts.overwrite
? 'excluded.data'
: 'json_patch(excluded.data, player_settings.data)'
const statements = rows.map(
(r) =>
`INSERT INTO player_settings (account_id, data) VALUES (${r.accountId}, '${sqlStr(r.data)}') ON CONFLICT (account_id) DO UPDATE SET data = ${onConflict};`
)
console.log(
`${rows.length} player(s) to import, ${empty} empty, ${skipped.length} skipped` +
(opts.overwrite ? ' (KV overwrites existing rows)' : ' (existing rows merge, D1 wins)')
)
if (opts.dryRun) {
console.log(
chalk.cyan(`--dry-run: built ${statements.length} statements; nothing was written.`)
)
return
}
if (rows.length === 0) {
console.log(chalk.green('✓ nothing to import'))
return
}
const count = async (): Promise<number> =>
Number(
(await execSql('SELECT COUNT(*) AS n FROM player_settings', remote, 'playersettings'))
.results[0]?.n ?? 0
)
const before = await count()
const sqlFile = path.join(os.tmpdir(), `recflare-settings-${Date.now()}.sql`)
await fs.writeFile(sqlFile, statements.join('\n'))
try {
await execSqlFile(sqlFile, remote, 'playersettings')
} finally {
await fs.remove(sqlFile)
}
// Prove it landed: a merge never deletes, so the table holds at least every imported id.
const after = await count()
const ids = rows.map((r) => r.accountId)
const found = Number(
(
await execSql(
`SELECT COUNT(*) AS n FROM player_settings WHERE account_id IN (${ids.join(', ')})`,
remote,
'playersettings'
)
).results[0]?.n ?? 0
)
if (found !== ids.length) {
throw new Error(
`import did not land: ${ids.length - found} of ${ids.length} rows are missing. Re-run it.`
)
}
const added = after - before
console.log(
`${added} new row(s), ${rows.length - added} merged into existing rows (${after} rows now)`
)
console.log(chalk.green(`✓ player settings imported into ${target(remote)}`))
})
const lookup = new Command('lookup')
.description('Print an account by id or username')
.option('--account <id>', 'Account id to look up')
.option('--username <name>', 'Username to look up (case-insensitive)')
.option('--local', 'Target the local dev database (the default).', false)
.option('--remote', 'Target the deployed database instead of the local dev database.', false)
.action(async (opts) => {
const { where, label } = whereClause(opts.account, opts.username)
const remote = resolveRemote(opts)
const sql = `SELECT
json_extract(data, '$.accountId') AS accountId,
json_extract(data, '$.username') AS username,
json_extract(data, '$.platform') AS platform,
json_extract(data, '$.platformId') AS platformId,
json_extract(data, '$.createdAt') AS createdAt,
json_extract(data, '$.lastLoginTime') AS lastLoginTime,
(json_extract(data, '$.passwordHash') IS NOT NULL) AS hasPassword,
(json_extract(data, '$.isDeveloper') = 1) AS isDeveloper,
(json_extract(data, '$.isModerator') = 1) AS isModerator,
(json_extract(data, '$.isInfluencer') = 1) AS isInfluencer,
(json_extract(data, '$.hasStudio') = 1) AS hasStudio
FROM account WHERE ${where}`
const res = await execSql(sql, remote)
const row = res.results[0]
if (!row) {
console.log(chalk.yellow(`no account found for ${label} on ${target(remote)}`))
return
}
const asText = (v: unknown): string =>
v == null
? ''
: typeof v === 'object'
? JSON.stringify(v)
: String(v as number | string | boolean)
const boolKeys = new Set([
'hasPassword',
'isDeveloper',
'isModerator',
'isInfluencer',
'hasStudio',
])
const table = new Table()
for (const [key, value] of Object.entries(row)) {
const shown = boolKeys.has(key) ? (value === 1 ? 'yes' : 'no') : asText(value)
table.push({ [key]: shown })
}
console.log(table.toString())
})
export const adminCmd = new Command('admin')
.description('Operator tools for accounts on the shared recflare D1 database')
// Bare `admin` (no subcommand) prints help and exits cleanly, rather than
// commander's default "missing command" error (exit 1).
.action((_opts, command: Command) => command.outputHelp())
.addCommand(setPassword)
.addCommand(clearPassword)
.addCommand(grantDeveloper)
.addCommand(grantModerator)
.addCommand(grantInfluencer)
.addCommand(grantPlus)
.addCommand(grantStudio)
.addCommand(reloadPlus)
.addCommand(caiLoad)
.addCommand(settingsImportKv)
.addCommand(lookup)
.addHelpText(
'after',
`
Select an account with --account <id> or --username <name>.
Target --local (default) or --remote (production; needs RECFLARE_D1 in .env).
Add --help to any subcommand for its options, e.g. \`runx admin set-password --help\`.
Examples:
$ runx admin set-password --account 1 # prompts, hidden
$ echo "s3cret" | runx admin set-password --account 1
$ runx admin clear-password --username alice
$ runx admin grant-developer --account 1 [--revoke]
$ runx admin grant-moderator --username alice --remote
$ runx admin grant-plus --username alice # Rec Room Plus; takes effect next login
$ runx admin reload-plus 1000 --remote # +1000 tokens to every Plus subscriber
$ runx admin reload-plus 1000 --dry-run # just list them
$ runx admin cai-load # merge every committed first-party export (local)
$ runx admin cai-load --remote --file path/to/export.json
$ runx admin lookup --username alice --remote`
)