CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history
emergency-pat.ts
Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.
| 0dc3291 | 1 | // Emergency PAT creator — bypasses the live site entirely. |
| 2 | // | |
| 3 | // Use case: the deployed site is broken (e.g. SW reload loop) such that | |
| 4 | // you can't reach /settings/tokens in the browser, but you need a PAT | |
| 5 | // to `git push` the fix. This script writes a PAT row directly into | |
| 6 | // the production database via DATABASE_URL. | |
| 7 | // | |
| 8 | // Run: bun run scripts/emergency-pat.ts | |
| 9d06598 | 9 | // Required env: DATABASE_URL (from your local .env, or set inline) |
| 0dc3291 | 10 | // |
| 9d06598 | 11 | // Uses RAW SQL via @neondatabase/serverless rather than drizzle's |
| 12 | // schema layer. Reason: when this script is needed, production is | |
| 13 | // usually missing recent migrations, so drizzle's SELECT (which lists | |
| 14 | // every schema column) blows up on missing columns. Raw SQL only | |
| 15 | // touches the columns we name, so schema drift doesn't matter. | |
| 0dc3291 | 16 | |
| 9d06598 | 17 | import { neon } from "@neondatabase/serverless"; |
| 0dc3291 | 18 | |
| 19 | function generateToken(): string { | |
| 20 | const bytes = crypto.getRandomValues(new Uint8Array(32)); | |
| 21 | return ( | |
| 22 | "glc_" + | |
| 23 | Array.from(bytes) | |
| 24 | .map((b) => b.toString(16).padStart(2, "0")) | |
| 25 | .join("") | |
| 26 | ); | |
| 27 | } | |
| 28 | ||
| 29 | async function hashToken(token: string): Promise<string> { | |
| 30 | const data = new TextEncoder().encode(token); | |
| 31 | const hash = await crypto.subtle.digest("SHA-256", data); | |
| 32 | return Array.from(new Uint8Array(hash)) | |
| 33 | .map((b) => b.toString(16).padStart(2, "0")) | |
| 34 | .join(""); | |
| 35 | } | |
| 36 | ||
| 37 | async function main() { | |
| 9d06598 | 38 | const url = process.env.DATABASE_URL; |
| 39 | if (!url) { | |
| 40 | console.error("DATABASE_URL is not set."); | |
| 41 | process.exit(1); | |
| 42 | } | |
| 43 | const sql = neon(url); | |
| 44 | ||
| 0dc3291 | 45 | const requestedUser = process.env.EMERGENCY_PAT_USER?.trim(); |
| 46 | ||
| 9d06598 | 47 | let userRow: { id: string; username: string } | undefined; |
| 0dc3291 | 48 | if (requestedUser) { |
| c36e102 | 49 | // Case-insensitive lookup — username is supposed to be unique-by-value |
| 50 | // but capitalization in some early signups varied. | |
| 9d06598 | 51 | const rows = (await sql` |
| c36e102 | 52 | SELECT id, username FROM users WHERE LOWER(username) = LOWER(${requestedUser}) LIMIT 1 |
| 9d06598 | 53 | `) as Array<{ id: string; username: string }>; |
| 54 | userRow = rows[0]; | |
| 0dc3291 | 55 | } else { |
| 9d06598 | 56 | const rows = (await sql` |
| 57 | SELECT id, username FROM users WHERE is_admin = true ORDER BY created_at ASC LIMIT 1 | |
| 58 | `) as Array<{ id: string; username: string }>; | |
| 59 | userRow = rows[0]; | |
| c36e102 | 60 | } |
| 61 | ||
| 62 | if (!userRow) { | |
| 63 | // Dump the user list so the operator can see what's there. | |
| 64 | const all = (await sql` | |
| 65 | SELECT username, email, is_admin, created_at | |
| 66 | FROM users ORDER BY created_at ASC LIMIT 30 | |
| 67 | `) as Array<{ | |
| 68 | username: string; email: string; is_admin: boolean; created_at: Date; | |
| 69 | }>; | |
| 70 | console.error(""); | |
| 71 | console.error( | |
| 72 | requestedUser | |
| 73 | ? `No user matched "${requestedUser}". Users in this DB:` | |
| 74 | : "No admin user. All users in this DB:" | |
| 75 | ); | |
| 76 | console.error(""); | |
| 77 | for (const u of all) { | |
| 0dc3291 | 78 | console.error( |
| c36e102 | 79 | ` ${u.username.padEnd(24)} ${u.email.padEnd(40)} admin=${u.is_admin}` |
| 0dc3291 | 80 | ); |
| 81 | } | |
| c36e102 | 82 | console.error(""); |
| 83 | console.error( | |
| 84 | "Re-run with EMERGENCY_PAT_USER=<exact-username-from-above>" | |
| 85 | ); | |
| 86 | process.exit(1); | |
| 0dc3291 | 87 | } |
| 88 | ||
| 89 | const token = generateToken(); | |
| 90 | const tokenHash = await hashToken(token); | |
| 9d06598 | 91 | const tokenPrefix = token.slice(0, 12); |
| 0dc3291 | 92 | |
| 9d06598 | 93 | await sql` |
| 94 | INSERT INTO api_tokens (user_id, name, token_hash, token_prefix, scopes) | |
| 95 | VALUES (${userRow.id}, 'emergency-deploy-fix', ${tokenHash}, ${tokenPrefix}, 'admin') | |
| 96 | `; | |
| 97 | ||
| 98 | console.log(""); | |
| 99 | console.log(`User: ${userRow.username}`); | |
| 0dc3291 | 100 | console.log(`Token: ${token}`); |
| 101 | console.log(""); | |
| 9d06598 | 102 | console.log("Copy the token NOW (only shown once). Then run these two:"); |
| 0dc3291 | 103 | console.log(""); |
| 9d06598 | 104 | console.log(` git remote set-url gluecron "https://${userRow.username}:${token}@gluecron.com/ccantynz/Gluecron.com.git"`); |
| 0dc3291 | 105 | console.log(" git push gluecron main"); |
| 106 | process.exit(0); | |
| 107 | } | |
| 108 | ||
| 109 | main().catch((err) => { | |
| 110 | console.error("Failed:", err); | |
| 111 | process.exit(1); | |
| 112 | }); |