|
1 | | -#!/usr/bin/env node |
| 1 | +#!/usr/bin/env -S pnpm exec tsx |
2 | 2 | import { existsSync, readFileSync } from "node:fs"; |
| 3 | +import { createRequire } from "node:module"; |
3 | 4 | import { resolve } from "node:path"; |
4 | 5 | import { spawnSync } from "node:child_process"; |
| 6 | +import { pathToFileURL } from "node:url"; |
| 7 | +import { api } from "../apps/web/convex/_generated/api.js"; |
5 | 8 |
|
6 | 9 | const DEFAULT_ENV_FILE = "/private/tmp/stackmatch-vercel-prod.env"; |
7 | 10 | const CONVEX_CLOUD_PATTERN = /^https:\/\/([a-z0-9-]+)\.convex\.cloud\/?$/; |
8 | 11 | const CONVEX_SITE_PATTERN = /^https:\/\/([a-z0-9-]+)\.convex\.site\/?$/; |
| 12 | +const GITHUB_API_BASE_URL = "https://api.github.com"; |
| 13 | +const GITHUB_JSON_ACCEPT_HEADER = "application/vnd.github.v3+json"; |
| 14 | +const GITHUB_OWNER_REPOS_FETCH_PAGE_SIZE = 100; |
| 15 | +const GITHUB_NOT_FOUND_STATUS = 404; |
| 16 | +const GITHUB_RATE_LIMIT_STATUS = 429; |
| 17 | +const WEB_REQUIRE = createRequire(new URL("../apps/web/package.json", import.meta.url)); |
| 18 | +const { normalizeGitHubOwnerType } = await import( |
| 19 | + pathToFileURL(WEB_REQUIRE.resolve("@stackmatch/constants/owner")).href |
| 20 | +); |
| 21 | +const { GITHUB_PUBLIC_REPOS_SCAN_LIMIT } = await import( |
| 22 | + pathToFileURL(WEB_REQUIRE.resolve("@stackmatch/constants/sync")).href |
| 23 | +); |
9 | 24 |
|
10 | 25 | function usage() { |
11 | 26 | console.error(`Usage: |
12 | 27 | pnpm convex:prod --env-file <file> deploy [convex deploy args...] |
13 | 28 | pnpm convex:prod --env-file <file> run <functionName> [jsonArgs] |
14 | 29 | pnpm convex:prod --env-file <file> check-scan-readiness |
| 30 | + pnpm convex:prod --env-file <file> queue-owner-scan <githubOwner> [--write] |
15 | 31 | pnpm backfill:auth-profiles:prod --env-file <file> [--limit 10] [--cursor CURSOR] [--write] |
| 32 | + pnpm queue-owner-scan:prod -- <githubOwner> [--write] |
16 | 33 |
|
17 | 34 | Env file must be pulled from Vercel production, for example: |
18 | 35 | vercel env pull /private/tmp/stackmatch-vercel-prod.env --environment=production --yes |
@@ -80,6 +97,10 @@ function resolveDeployment(env) { |
80 | 97 | return deployment; |
81 | 98 | } |
82 | 99 |
|
| 100 | +function getConvexCloudUrl(deployment) { |
| 101 | + return `https://${deployment}.convex.cloud`; |
| 102 | +} |
| 103 | + |
83 | 104 | function runPnpm(commandArgs, env) { |
84 | 105 | const result = spawnSync("pnpm", commandArgs, { |
85 | 106 | cwd: process.cwd(), |
@@ -143,6 +164,37 @@ function buildRunEnv(env) { |
143 | 164 | return childEnv; |
144 | 165 | } |
145 | 166 |
|
| 167 | +function parseQueueOwnerScanArgs(args) { |
| 168 | + const options = { |
| 169 | + dryRun: true, |
| 170 | + owner: "", |
| 171 | + }; |
| 172 | + |
| 173 | + while (args.length > 0) { |
| 174 | + const arg = args.shift(); |
| 175 | + if (arg === "--write") { |
| 176 | + options.dryRun = false; |
| 177 | + continue; |
| 178 | + } |
| 179 | + if (arg === "--dry-run") { |
| 180 | + options.dryRun = true; |
| 181 | + continue; |
| 182 | + } |
| 183 | + if (!options.owner) { |
| 184 | + options.owner = arg ?? ""; |
| 185 | + continue; |
| 186 | + } |
| 187 | + throw new Error(`Unknown queue-owner-scan argument: ${arg}`); |
| 188 | + } |
| 189 | + |
| 190 | + options.owner = options.owner.trim(); |
| 191 | + if (!options.owner) { |
| 192 | + throw new Error("queue-owner-scan requires a GitHub owner"); |
| 193 | + } |
| 194 | + |
| 195 | + return options; |
| 196 | +} |
| 197 | + |
146 | 198 | function buildBackfillArgs(env, args) { |
147 | 199 | const payload = { |
148 | 200 | apiKey: env.ANALYZE_API_KEY, |
@@ -179,6 +231,136 @@ function buildBackfillArgs(env, args) { |
179 | 231 | return JSON.stringify(payload); |
180 | 232 | } |
181 | 233 |
|
| 234 | +function buildGitHubHeaders(env) { |
| 235 | + const token = env.GITHUB_TOKEN?.trim(); |
| 236 | + return { |
| 237 | + Accept: GITHUB_JSON_ACCEPT_HEADER, |
| 238 | + ...(token ? { Authorization: `token ${token}` } : {}), |
| 239 | + }; |
| 240 | +} |
| 241 | + |
| 242 | +async function fetchGitHubJson(path, env) { |
| 243 | + const response = await fetch(`${GITHUB_API_BASE_URL}${path}`, { |
| 244 | + headers: buildGitHubHeaders(env), |
| 245 | + }); |
| 246 | + |
| 247 | + if (!response.ok) { |
| 248 | + let message = `${response.status} ${response.statusText}`; |
| 249 | + try { |
| 250 | + const body = await response.clone().json(); |
| 251 | + if (typeof body.message === "string") { |
| 252 | + message = `${message}: ${body.message}`; |
| 253 | + } |
| 254 | + } catch { |
| 255 | + // Keep the status-only message when GitHub returns a non-JSON error body. |
| 256 | + } |
| 257 | + |
| 258 | + if ( |
| 259 | + response.status === GITHUB_RATE_LIMIT_STATUS || |
| 260 | + response.headers.get("x-ratelimit-remaining") === "0" |
| 261 | + ) { |
| 262 | + throw new Error(`GitHub rate limit reached: ${message}`); |
| 263 | + } |
| 264 | + |
| 265 | + if (response.status === GITHUB_NOT_FOUND_STATUS) { |
| 266 | + throw new Error(`GitHub owner was not found: ${message}`); |
| 267 | + } |
| 268 | + |
| 269 | + throw new Error(`GitHub request failed: ${message}`); |
| 270 | + } |
| 271 | + |
| 272 | + return response.json(); |
| 273 | +} |
| 274 | + |
| 275 | +function parseGitHubTimestamp(value) { |
| 276 | + if (!value) return undefined; |
| 277 | + const timestamp = Date.parse(value); |
| 278 | + return Number.isFinite(timestamp) ? timestamp : undefined; |
| 279 | +} |
| 280 | + |
| 281 | +function normalizeOwnerProfile(owner, profile) { |
| 282 | + return { |
| 283 | + ...(profile.name ? { name: profile.name } : {}), |
| 284 | + avatarUrl: profile.avatar_url ?? `https://github.com/${owner}.png?size=200`, |
| 285 | + followers: profile.followers ?? 0, |
| 286 | + ...(profile.bio ? { bio: profile.bio } : {}), |
| 287 | + ...(profile.blog ? { website: profile.blog } : {}), |
| 288 | + ...(profile.twitter_username ? { x: profile.twitter_username } : {}), |
| 289 | + ...(profile.location ? { location: profile.location } : {}), |
| 290 | + ...(profile.company ? { company: profile.company } : {}), |
| 291 | + ownerType: normalizeGitHubOwnerType(profile.type), |
| 292 | + }; |
| 293 | +} |
| 294 | + |
| 295 | +function normalizeRepos(repos, fallbackOwner) { |
| 296 | + return repos |
| 297 | + .filter((repo) => !repo.fork && repo.name) |
| 298 | + .sort((a, b) => (b.stargazers_count ?? 0) - (a.stargazers_count ?? 0)) |
| 299 | + .slice(0, GITHUB_PUBLIC_REPOS_SCAN_LIMIT) |
| 300 | + .map((repo) => { |
| 301 | + const pushedAt = parseGitHubTimestamp(repo.pushed_at); |
| 302 | + return { |
| 303 | + owner: repo.owner?.login ?? fallbackOwner, |
| 304 | + name: repo.name, |
| 305 | + ...(pushedAt !== undefined ? { pushedAt } : {}), |
| 306 | + }; |
| 307 | + }); |
| 308 | +} |
| 309 | + |
| 310 | +async function queueOwnerScan(env, deployment, options) { |
| 311 | + if (!env.ANALYZE_API_KEY) { |
| 312 | + throw new Error("ANALYZE_API_KEY is missing from the production env file"); |
| 313 | + } |
| 314 | + |
| 315 | + const encodedOwner = encodeURIComponent(options.owner); |
| 316 | + const [profile, fetchedRepos] = await Promise.all([ |
| 317 | + fetchGitHubJson(`/users/${encodedOwner}`, env), |
| 318 | + fetchGitHubJson( |
| 319 | + `/users/${encodedOwner}/repos?per_page=${GITHUB_OWNER_REPOS_FETCH_PAGE_SIZE}&type=public`, |
| 320 | + env |
| 321 | + ), |
| 322 | + ]); |
| 323 | + |
| 324 | + const canonicalOwner = profile.login ?? options.owner; |
| 325 | + const repos = normalizeRepos(fetchedRepos, canonicalOwner); |
| 326 | + const summary = { |
| 327 | + deployment, |
| 328 | + dryRun: options.dryRun, |
| 329 | + owner: canonicalOwner, |
| 330 | + totalFetchedRepos: fetchedRepos.length, |
| 331 | + queuedCount: repos.length, |
| 332 | + repos: repos.map((repo) => `${repo.owner}/${repo.name}`), |
| 333 | + }; |
| 334 | + |
| 335 | + if (options.dryRun) { |
| 336 | + console.log(JSON.stringify(summary, null, 2)); |
| 337 | + return; |
| 338 | + } |
| 339 | + |
| 340 | + const { ConvexHttpClient } = await import( |
| 341 | + pathToFileURL(WEB_REQUIRE.resolve("convex/browser")).href |
| 342 | + ); |
| 343 | + const client = new ConvexHttpClient(getConvexCloudUrl(deployment)); |
| 344 | + const results = await client.mutation(api.mutations.request_user_scan.requestUserScan, { |
| 345 | + repos, |
| 346 | + apiKey: env.ANALYZE_API_KEY, |
| 347 | + ownerProfile: normalizeOwnerProfile(canonicalOwner, profile), |
| 348 | + }); |
| 349 | + |
| 350 | + console.log( |
| 351 | + JSON.stringify( |
| 352 | + { |
| 353 | + ...summary, |
| 354 | + queuedCount: results.length, |
| 355 | + existingCount: results.filter((repo) => repo.existing).length, |
| 356 | + results, |
| 357 | + }, |
| 358 | + null, |
| 359 | + 2 |
| 360 | + ) |
| 361 | + ); |
| 362 | +} |
| 363 | + |
182 | 364 | const { envFile, command, args } = parseCli(process.argv.slice(2)); |
183 | 365 | const env = parseEnvFile(envFile); |
184 | 366 | const deployment = resolveDeployment(env); |
@@ -220,6 +402,15 @@ if (command === "check-scan-readiness") { |
220 | 402 | process.exit(result.ready ? 0 : 1); |
221 | 403 | } |
222 | 404 |
|
| 405 | +if (command === "queue-owner-scan") { |
| 406 | + const options = parseQueueOwnerScanArgs(args); |
| 407 | + console.error( |
| 408 | + `${options.dryRun ? "Previewing" : "Queueing"} owner scan on live Vercel production deployment: ${deployment}` |
| 409 | + ); |
| 410 | + await queueOwnerScan(env, deployment, options); |
| 411 | + process.exit(0); |
| 412 | +} |
| 413 | + |
223 | 414 | if (command === "backfill-auth-profiles") { |
224 | 415 | const payload = buildBackfillArgs(env, args); |
225 | 416 | console.error(`Backfilling auth profiles on live Vercel production deployment: ${deployment}`); |
|
0 commit comments