@rebasepro/cli 0.13.1-canary.gcd6689e → 0.13.1-canary.gef9608c

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +1,32 @@
1
+ /** A user as the admin API returns it, reduced to what this command needs. */
2
+ export interface ResolvedUser {
3
+ id: string;
4
+ email: string;
5
+ }
6
+ /**
7
+ * Pick the user with exactly this email out of a search response.
8
+ *
9
+ * `/api/admin/users?search=` is an `ILIKE '%…%'` over email **or display
10
+ * name**, ordered by role count descending. This used to take row `[0]` and
11
+ * reset it, then print the email it had been *given* as confirmation — so two
12
+ * ordinary situations ended in a successful-looking reset of somebody else's
13
+ * account:
14
+ *
15
+ * - a substring collision: `bob@example.com` also matches
16
+ * `robert.bob@example.com`;
17
+ * - a display name, which is user-controlled and accepted up to 255
18
+ * characters with no constraint on its content, containing an address
19
+ * belonging to someone else.
20
+ *
21
+ * The ordering makes it worse rather than better — `array_length(roles) DESC
22
+ * NULLS LAST` puts the most privileged match first, so the account most likely
23
+ * to be reset by mistake is an admin's.
24
+ *
25
+ * Returns `undefined` when nothing matched exactly, which the caller reports
26
+ * rather than falling through to a guess. The direct-database fallback below
27
+ * has always matched with `eq(usersTable.email, email)`; this is the same
28
+ * definition, so the command no longer resets different accounts depending on
29
+ * whether the backend happened to be running.
30
+ */
31
+ export declare function selectUserForEmail(payload: unknown, email: string): ResolvedUser | undefined;
1
32
  export declare function authCommand(subcommand: string | undefined, rawArgs: string[]): Promise<void>;
@@ -68,4 +68,26 @@ export declare function formatCdTarget(cwd: string, targetDirectory: string): st
68
68
  * triggering the non-TTY error. */
69
69
  export declare function printInitHelp(): void;
70
70
  export declare function createRebaseApp(rawArgs: string[]): Promise<void>;
71
+ /**
72
+ * The runtime image tag to pin, given the version of the CLI doing the scaffolding.
73
+ *
74
+ * Only a stable release publishes `rebasepro/server` — a multi-arch build on
75
+ * every push to main would cost minutes per commit for an image nobody pulls.
76
+ * So pinning a prerelease CLI's own version writes a tag that cannot exist, and
77
+ * `docker compose up` fails on `manifest unknown`, which is the same dead end
78
+ * as the missing-repository bug this pinning was added to prevent.
79
+ *
80
+ * A prerelease therefore falls back to `latest`, which is correct rather than
81
+ * merely available: a bundle's manifest declares the runtime range it needs
82
+ * (`^1`), the image supplies only `@rebasepro/server`, and the framework a
83
+ * bundle runs is installed from its own `deps.declared` at boot. The current
84
+ * stable runtime boots a canary bundle by design.
85
+ *
86
+ * A floating tag is a real cost — it is what pinning exists to avoid — so say
87
+ * so in the file rather than leaving a reader to discover it.
88
+ */
89
+ export declare function resolveRuntimeImageTag(cliVersion: string): {
90
+ tag: string;
91
+ note?: string;
92
+ };
71
93
  export declare function configureEnvFile(targetDirectory: string, databaseUrl?: string): Promise<void>;
package/dist/index.es.js CHANGED
@@ -12,8 +12,9 @@ import crypto from "crypto";
12
12
  import { execSync, spawn, spawnSync } from "child_process";
13
13
  import os from "os";
14
14
  import { createRebaseClient } from "@rebasepro/client";
15
+ import dotenv from "dotenv";
15
16
  import { createRequire } from "module";
16
- import { BUNDLE_FORMAT_VERSION, RUNTIME_CONTRACT_VERSION, computeSchemaVersion, deserializeCollections, findStorageSuffixCollision, normalizeStorageSources, storageEnvSuffix } from "@rebasepro/types";
17
+ import { BUNDLE_FORMAT_VERSION, DEFAULT_DATA_SOURCE_KEY, RUNTIME_CONTRACT_VERSION, computeSchemaVersion, deserializeCollections, findStorageSuffixCollision, getDataSourceCapabilities, normalizeStorageSources, storageEnvSuffix } from "@rebasepro/types";
17
18
  import { generateSDK } from "@rebasepro/codegen";
18
19
  //#region src/utils/package-manager.ts
19
20
  /**
@@ -289,6 +290,34 @@ function findEnvFile(projectRoot) {
289
290
  return null;
290
291
  }
291
292
  /**
293
+ * Read the project's `.env` into a plain object.
294
+ *
295
+ * One reader, because there were four: `dotenv` in `start`, a hand-rolled
296
+ * `indexOf("=")` loop in `api-keys`, a single-key regex in `auth`, and its own
297
+ * splitting in `cloud env`. `dotenv` is a declared dependency of this package,
298
+ * so the other three existed for no reason and disagreed with the correct one
299
+ * on the two things people actually write in a `.env`:
300
+ *
301
+ * - `export KEY=value`, which the hand-rolled parser keyed as
302
+ * `export KEY` — so the command reported the key as unset while it was
303
+ * right there in the file;
304
+ * - `KEY=value # comment`, whose comment travelled into the value and then
305
+ * into an `Authorization` header, coming back as a 401 with nothing
306
+ * pointing at the cause.
307
+ *
308
+ * Returns `{}` when the project has no `.env`, so callers can treat "absent"
309
+ * and "empty" alike.
310
+ */
311
+ function readEnvFile(projectRoot) {
312
+ const envFile = findEnvFile(projectRoot);
313
+ if (!envFile || !fs.existsSync(envFile)) return {};
314
+ try {
315
+ return dotenv.parse(fs.readFileSync(envFile, "utf-8"));
316
+ } catch {
317
+ return {};
318
+ }
319
+ }
320
+ /**
292
321
  * Resolve a binary from the project's node_modules/.bin.
293
322
  * Checks backend, root, parent monorepo root, then falls back to PATH.
294
323
  */
@@ -2013,6 +2042,32 @@ function readCliVersion() {
2013
2042
  } catch {}
2014
2043
  return "latest";
2015
2044
  }
2045
+ /**
2046
+ * The runtime image tag to pin, given the version of the CLI doing the scaffolding.
2047
+ *
2048
+ * Only a stable release publishes `rebasepro/server` — a multi-arch build on
2049
+ * every push to main would cost minutes per commit for an image nobody pulls.
2050
+ * So pinning a prerelease CLI's own version writes a tag that cannot exist, and
2051
+ * `docker compose up` fails on `manifest unknown`, which is the same dead end
2052
+ * as the missing-repository bug this pinning was added to prevent.
2053
+ *
2054
+ * A prerelease therefore falls back to `latest`, which is correct rather than
2055
+ * merely available: a bundle's manifest declares the runtime range it needs
2056
+ * (`^1`), the image supplies only `@rebasepro/server`, and the framework a
2057
+ * bundle runs is installed from its own `deps.declared` at boot. The current
2058
+ * stable runtime boots a canary bundle by design.
2059
+ *
2060
+ * A floating tag is a real cost — it is what pinning exists to avoid — so say
2061
+ * so in the file rather than leaving a reader to discover it.
2062
+ */
2063
+ function resolveRuntimeImageTag(cliVersion) {
2064
+ if (/^\d+\.\d+\.\d+-/.test(cliVersion)) return {
2065
+ tag: "latest",
2066
+ note: `# Scaffolded by a prerelease CLI (${cliVersion}), which publishes no runtime image,\n# so this floats to the newest stable runtime. Pin an exact version once you
2067
+ # deploy: a moving tag changes what you are running with no version changing.`
2068
+ };
2069
+ return { tag: cliVersion };
2070
+ }
2016
2071
  async function configureEnvFile(targetDirectory, databaseUrl) {
2017
2072
  const envExamplePath = path.join(targetDirectory, ".env.example");
2018
2073
  const envPath = path.join(targetDirectory, ".env");
@@ -2031,8 +2086,9 @@ async function configureEnvFile(targetDirectory, databaseUrl) {
2031
2086
  envContent = envContent.replace(/^#\s*REBASE_SERVICE_KEY=.*$/m, `REBASE_SERVICE_KEY=${serviceKey}`);
2032
2087
  const composeApiPort = /^PORT=(\d+)/m.exec(envContent)?.[1] ?? "3001";
2033
2088
  envContent = envContent.replace(/^#\s*CORS_ORIGINS=.*$/m, `CORS_ORIGINS=http://localhost:${composeApiPort}`);
2034
- const runtimeVersion = readCliVersion();
2035
- envContent = /^#?\s*REBASE_VERSION=.*$/m.test(envContent) ? envContent.replace(/^#?\s*REBASE_VERSION=.*$/m, `REBASE_VERSION=${runtimeVersion}`) : `${envContent.trimEnd()}\n\n# The Rebase runtime image tag docker-compose.yml pulls.\n# Change this and restart to upgrade; your project bundle is untouched.\nREBASE_VERSION=${runtimeVersion}\n`;
2089
+ const { tag: runtimeVersion, note } = resolveRuntimeImageTag(readCliVersion());
2090
+ const pinned = `${note ? `${note}\n` : ""}REBASE_VERSION=${runtimeVersion}`;
2091
+ envContent = /^#?\s*REBASE_VERSION=.*$/m.test(envContent) ? envContent.replace(/^#?\s*REBASE_VERSION=.*$/m, pinned) : `${envContent.trimEnd()}\n\n# The Rebase runtime image tag docker-compose.yml pulls.\n# Change this and restart to upgrade; your project bundle is untouched.\n${pinned}\n`;
2036
2092
  if (databaseUrl) {
2037
2093
  if (/[\r\n]/.test(databaseUrl)) throw new Error("Invalid DATABASE_URL: multiline values are not allowed.");
2038
2094
  const { pinSearchPath } = await import("@rebasepro/server-postgres");
@@ -2367,7 +2423,7 @@ async function schemaCommand(subcommand, rawArgs) {
2367
2423
  return;
2368
2424
  }
2369
2425
  const projectRoot = requireProjectRoot();
2370
- recordEvent("cli.schema_generate", { subcommand: subcommand ?? "none" }, { projectRoot });
2426
+ recordEvent("cli.schema", { subcommand: subcommand ?? "none" }, { projectRoot });
2371
2427
  const backendDir = requireBackendDir(projectRoot);
2372
2428
  const activePlugin = getActiveBackendPlugin(backendDir);
2373
2429
  if (!activePlugin) {
@@ -2436,7 +2492,7 @@ async function dbCommand(subcommand, rawArgs) {
2436
2492
  return;
2437
2493
  }
2438
2494
  const projectRoot = requireProjectRoot();
2439
- recordEvent("cli.db_push", { subcommand: subcommand ?? "none" }, { projectRoot });
2495
+ recordEvent("cli.db", { subcommand: subcommand ?? "none" }, { projectRoot });
2440
2496
  const backendDir = requireBackendDir(projectRoot);
2441
2497
  const activePlugin = getActiveBackendPlugin(backendDir);
2442
2498
  if (!activePlugin) {
@@ -3074,6 +3130,106 @@ function resolveBackendPaths(app, projectRoot) {
3074
3130
  };
3075
3131
  }
3076
3132
  //#endregion
3133
+ //#region src/utils/collection-drift.ts
3134
+ /**
3135
+ * Which edits under `config/collections` can put the *SQL* schema out of date.
3136
+ *
3137
+ * `rebase dev` watches that directory and, on any change, either tells the
3138
+ * developer to run `rebase schema generate` / `rebase db push` or runs them.
3139
+ * The watcher is recursive and knows nothing about what it is watching, so it
3140
+ * said that about every file under the directory — including a
3141
+ * `collections/firestore/exercises.ts`, whose documents live in Firestore and
3142
+ * for which there is no Drizzle schema to regenerate and no database to push
3143
+ * to. Advice that is wrong on every edit is advice a developer learns to
3144
+ * ignore, which is worse than none: the same box is the only warning for the
3145
+ * Postgres collection next to it, where it is real.
3146
+ *
3147
+ * Two independent reasons a change cannot affect the SQL schema, both checked
3148
+ * here:
3149
+ *
3150
+ * 1. **The loader would never read the file.** `loadCollectionsFromDirectory`
3151
+ * reads the top level of the collections directory only — no recursion, no
3152
+ * `index`, no tests, no declarations. A file it does not read cannot change
3153
+ * what it returns, and the watcher must not claim otherwise.
3154
+ * 2. **Every collection in it is served by another engine.** A Firestore or
3155
+ * MongoDB collection has no table, no migration and no policies.
3156
+ *
3157
+ * The engine check reads the source text rather than importing the module: the
3158
+ * CLI runs as plain Node and cannot evaluate a project's TypeScript, and a
3159
+ * watcher must answer in the time between two keystrokes. Anything it cannot
3160
+ * read confidently counts as SQL-affecting — a spurious warning is a nuisance,
3161
+ * a suppressed one hides real drift.
3162
+ */
3163
+ /**
3164
+ * Would `loadCollectionsFromDirectory` load this file?
3165
+ *
3166
+ * Mirrors that loader's own `isCollectionFile` plus its flat (non-recursive)
3167
+ * scan. `relativePath` is relative to the collections directory, as `fs.watch`
3168
+ * reports it.
3169
+ */
3170
+ function isLoadedCollectionFile(relativePath) {
3171
+ const normalized = relativePath.split(path.sep).join("/");
3172
+ if (normalized.includes("/")) return false;
3173
+ const file = normalized;
3174
+ if (!file.endsWith(".ts") && !file.endsWith(".js")) return false;
3175
+ if (file.startsWith(".")) return false;
3176
+ if (file.includes(".test.")) return false;
3177
+ if (file.endsWith(".d.ts")) return false;
3178
+ if (file === "index.ts" || file === "index.js") return false;
3179
+ return true;
3180
+ }
3181
+ var ENGINE_LITERAL = /\bengine\s*:\s*["'`]([^"'`]+)["'`]/g;
3182
+ var DATA_SOURCE_LITERAL = /\bdataSource\s*:\s*["'`]([^"'`]+)["'`]/g;
3183
+ /**
3184
+ * Drop comments, so a `// engine: "firestore"` in a docblock cannot silence a
3185
+ * warning about the Postgres collection the file actually declares.
3186
+ *
3187
+ * Deliberately naive — it does not understand strings, so a `//` inside one
3188
+ * (a URL in a default value) eats the rest of that line. That only ever removes
3189
+ * text, and removing an engine literal makes this file *more* likely to warn,
3190
+ * which is the side to be wrong on.
3191
+ */
3192
+ function stripComments(source) {
3193
+ return source.replace(/\/\*[\s\S]*?\*\//g, " ").replace(/\/\/[^\n]*/g, " ");
3194
+ }
3195
+ function literals(source, pattern) {
3196
+ const found = [];
3197
+ for (const match of source.matchAll(pattern)) found.push(match[1]);
3198
+ return found;
3199
+ }
3200
+ /**
3201
+ * Does this collection source declare anything a SQL toolchain would own?
3202
+ *
3203
+ * The fallback order matches `resolveDataSource`: an explicit `engine` wins,
3204
+ * and a collection that only names a `dataSource` is resolved as if the key
3205
+ * were the engine — which is what that function does when no registry is
3206
+ * available, and the CLI has none. An engine nobody recognises counts as
3207
+ * relational, for the reason `isRelationalCollection` gives.
3208
+ *
3209
+ * A file declaring several collections is SQL-affecting if *any* of them is.
3210
+ */
3211
+ function declaresRelationalCollection(rawSource) {
3212
+ const source = stripComments(rawSource);
3213
+ const engines = literals(source, ENGINE_LITERAL);
3214
+ const declared = engines.length > 0 ? engines : literals(source, DATA_SOURCE_LITERAL).filter((key) => key !== DEFAULT_DATA_SOURCE_KEY);
3215
+ if (declared.length === 0) return true;
3216
+ return declared.some((engine) => getDataSourceCapabilities(engine).supportsRelations);
3217
+ }
3218
+ /**
3219
+ * Can this edit have changed the generated SQL schema?
3220
+ *
3221
+ * Answers `true` when it cannot tell — an unreadable file is a reason to warn,
3222
+ * not a reason to go quiet.
3223
+ */
3224
+ function affectsSqlSchema(collectionsDir, relativePath) {
3225
+ if (!isLoadedCollectionFile(relativePath)) return false;
3226
+ try {
3227
+ return declaresRelationalCollection(fs.readFileSync(path.join(collectionsDir, relativePath), "utf8"));
3228
+ } catch {
3229
+ return true;
3230
+ }
3231
+ }
3232
+ //#endregion
3077
3233
  //#region src/commands/dev.ts
3078
3234
  /**
3079
3235
  * CLI command: rebase dev
@@ -3386,14 +3542,18 @@ async function devCommand(rawArgs) {
3386
3542
  const collectionsDir = path.join(projectRoot, "config", "collections");
3387
3543
  if (fs.existsSync(collectionsDir)) {
3388
3544
  let watchDebounce = null;
3545
+ let sqlSchemaAffected = false;
3389
3546
  fs.watch(collectionsDir, { recursive: true }, (eventType, filename) => {
3390
3547
  if (!filename || filename.startsWith(".") || filename.endsWith(".tmp")) return;
3548
+ sqlSchemaAffected = sqlSchemaAffected || affectsSqlSchema(collectionsDir, filename);
3391
3549
  if (watchDebounce) clearTimeout(watchDebounce);
3392
3550
  watchDebounce = setTimeout(async () => {
3393
- console.log(chalk.yellow(`\n 🔄 Collection change detected (${filename}). Regenerating schema & SDK...`));
3551
+ const regenerateSchema = sqlSchemaAffected;
3552
+ sqlSchemaAffected = false;
3553
+ console.log(chalk.yellow(`\n 🔄 Collection change detected (${filename}). Regenerating ${regenerateSchema ? "schema & SDK" : "SDK"}...`));
3394
3554
  try {
3395
3555
  const activePlugin = getActiveBackendPlugin(backendDir);
3396
- const pluginCli = activePlugin ? resolvePluginCliScript(backendDir, activePlugin) : null;
3556
+ const pluginCli = regenerateSchema && activePlugin ? resolvePluginCliScript(backendDir, activePlugin) : null;
3397
3557
  if (pluginCli) await execa(tsxBin, [
3398
3558
  pluginCli,
3399
3559
  "schema",
@@ -3409,7 +3569,7 @@ async function devCommand(rawArgs) {
3409
3569
  stdio: "inherit",
3410
3570
  env
3411
3571
  });
3412
- console.log(chalk.green(" ✓ Schema & SDK regenerated successfully. Hono will reload."));
3572
+ console.log(chalk.green(` ✓ ${regenerateSchema ? "Schema & SDK" : "SDK"} regenerated successfully. Hono will reload.`));
3413
3573
  } catch (err) {
3414
3574
  console.error(chalk.red(` ✗ Failed to regenerate schema/SDK: ${err instanceof Error ? err.message : err}`));
3415
3575
  }
@@ -3434,12 +3594,14 @@ async function devCommand(rawArgs) {
3434
3594
  let driftDebounce = null;
3435
3595
  fs.watch(collectionsDir, { recursive: true }, (_eventType, filename) => {
3436
3596
  if (!filename || filename.startsWith(".") || filename.endsWith(".tmp")) return;
3597
+ if (!affectsSqlSchema(collectionsDir, filename)) return;
3437
3598
  if (driftDebounce) clearTimeout(driftDebounce);
3438
3599
  driftDebounce = setTimeout(() => {
3600
+ const shown = filename.length > 31 ? `…${filename.slice(-30)}` : filename.padEnd(31);
3439
3601
  console.log([
3440
3602
  "",
3441
3603
  chalk.yellow(" ┌──────────────────────────────────────────────────────────────┐"),
3442
- chalk.yellow(" │ ⚠️ Collection file changed: ") + chalk.white(filename.padEnd(31)) + chalk.yellow("│"),
3604
+ chalk.yellow(" │ ⚠️ Collection file changed: ") + chalk.white(shown) + chalk.yellow("│"),
3443
3605
  chalk.yellow(" │ │"),
3444
3606
  chalk.yellow(" │ Your schema may be out of sync. Run: │"),
3445
3607
  chalk.yellow(" │ ") + chalk.cyan("rebase schema generate") + chalk.yellow(" regenerate Drizzle schema │"),
@@ -5300,6 +5462,48 @@ async function startWorkspaceBackend(projectRoot, env) {
5300
5462
  * Subcommands:
5301
5463
  * reset-password — Reset a user's password
5302
5464
  */
5465
+ /**
5466
+ * Pick the user with exactly this email out of a search response.
5467
+ *
5468
+ * `/api/admin/users?search=` is an `ILIKE '%…%'` over email **or display
5469
+ * name**, ordered by role count descending. This used to take row `[0]` and
5470
+ * reset it, then print the email it had been *given* as confirmation — so two
5471
+ * ordinary situations ended in a successful-looking reset of somebody else's
5472
+ * account:
5473
+ *
5474
+ * - a substring collision: `bob@example.com` also matches
5475
+ * `robert.bob@example.com`;
5476
+ * - a display name, which is user-controlled and accepted up to 255
5477
+ * characters with no constraint on its content, containing an address
5478
+ * belonging to someone else.
5479
+ *
5480
+ * The ordering makes it worse rather than better — `array_length(roles) DESC
5481
+ * NULLS LAST` puts the most privileged match first, so the account most likely
5482
+ * to be reset by mistake is an admin's.
5483
+ *
5484
+ * Returns `undefined` when nothing matched exactly, which the caller reports
5485
+ * rather than falling through to a guess. The direct-database fallback below
5486
+ * has always matched with `eq(usersTable.email, email)`; this is the same
5487
+ * definition, so the command no longer resets different accounts depending on
5488
+ * whether the backend happened to be running.
5489
+ */
5490
+ function selectUserForEmail(payload, email) {
5491
+ const wanted = email.trim().toLowerCase();
5492
+ if (!wanted) return void 0;
5493
+ const rows = Array.isArray(payload) ? payload : payload && typeof payload === "object" && Array.isArray(payload.users) ? payload.users : [];
5494
+ for (const row of rows) {
5495
+ if (!row || typeof row !== "object") continue;
5496
+ const record = row;
5497
+ const rowEmail = typeof record.email === "string" ? record.email.trim().toLowerCase() : void 0;
5498
+ if (!rowEmail || rowEmail !== wanted) continue;
5499
+ const id = typeof record.id === "string" ? record.id : typeof record.uid === "string" ? record.uid : void 0;
5500
+ if (!id) continue;
5501
+ return {
5502
+ id,
5503
+ email: record.email
5504
+ };
5505
+ }
5506
+ }
5303
5507
  async function authCommand(subcommand, rawArgs) {
5304
5508
  if (!subcommand || subcommand === "--help") {
5305
5509
  printAuthHelp();
@@ -5336,12 +5540,8 @@ async function resetPassword(rawArgs) {
5336
5540
  process.exit(1);
5337
5541
  }
5338
5542
  const projectRoot = requireProjectRoot();
5339
- let envServiceKey;
5340
5543
  const envFile = findEnvFile(projectRoot);
5341
- if (envFile && fs.existsSync(envFile)) try {
5342
- const match = fs.readFileSync(envFile, "utf8").match(/^\s*REBASE_SERVICE_KEY\s*=\s*['"]?(.*?)['"]?\s*$/m);
5343
- if (match && match[1]) envServiceKey = match[1];
5344
- } catch {}
5544
+ const envServiceKey = readEnvFile(projectRoot).REBASE_SERVICE_KEY;
5345
5545
  let baseUrl = process.env.REBASE_BASE_URL;
5346
5546
  let serviceKey = process.env.REBASE_SERVICE_KEY || envServiceKey;
5347
5547
  const statePath = path.join(projectRoot, ".rebase", "state.json");
@@ -5361,7 +5561,7 @@ async function resetPassword(rawArgs) {
5361
5561
  try {
5362
5562
  const finalPass = newPassword || "NewPassword123!";
5363
5563
  const cleanBaseUrl = baseUrl.replace(/\/+$/, "");
5364
- const searchUrl = `${cleanBaseUrl}/api/admin/users?search=${encodeURIComponent(email)}&limit=1`;
5564
+ const searchUrl = `${cleanBaseUrl}/api/admin/users?search=${encodeURIComponent(email)}&limit=50`;
5365
5565
  const searchRes = await fetch(searchUrl, { headers: {
5366
5566
  "Authorization": `Bearer ${serviceKey}`,
5367
5567
  "Accept": "application/json"
@@ -5369,18 +5569,9 @@ async function resetPassword(rawArgs) {
5369
5569
  if (!searchRes.ok) throw new Error(`Failed to list users: ${searchRes.statusText}`);
5370
5570
  const searchData = await searchRes.json();
5371
5571
  if (!searchData || typeof searchData !== "object") throw new Error("Invalid response format from user search API.");
5372
- let userId;
5373
- if (Array.isArray(searchData)) {
5374
- const firstUser = searchData[0];
5375
- if (firstUser && typeof firstUser === "object" && "id" in firstUser && typeof firstUser.id === "string") userId = firstUser.id;
5376
- else if (firstUser && typeof firstUser === "object" && "uid" in firstUser && typeof firstUser.uid === "string") userId = firstUser.uid;
5377
- } else if ("users" in searchData && Array.isArray(searchData.users)) {
5378
- const firstUser = searchData.users[0];
5379
- if (firstUser && typeof firstUser === "object" && "id" in firstUser && typeof firstUser.id === "string") userId = firstUser.id;
5380
- else if (firstUser && typeof firstUser === "object" && "uid" in firstUser && typeof firstUser.uid === "string") userId = firstUser.uid;
5381
- }
5382
- if (!userId) throw new Error(`User not found with email: ${email}`);
5383
- const resetUrl = `${cleanBaseUrl}/api/admin/users/${userId}/reset-password`;
5572
+ const matched = selectUserForEmail(searchData, email);
5573
+ if (!matched) throw new Error(`No user has the email ${email}.`);
5574
+ const resetUrl = `${cleanBaseUrl}/api/admin/users/${matched.id}/reset-password`;
5384
5575
  const resetRes = await fetch(resetUrl, {
5385
5576
  method: "POST",
5386
5577
  headers: {
@@ -5397,7 +5588,7 @@ async function resetPassword(rawArgs) {
5397
5588
  console.log("API reset successful.");
5398
5589
  console.log(chalk.bold(" 🔑 Rebase Auth — Reset Password (via API)"));
5399
5590
  console.log("");
5400
- console.log(` ${chalk.gray("Email:")} ${email}`);
5591
+ console.log(` ${chalk.gray("Email:")} ${matched.email}`);
5401
5592
  console.log(` ${chalk.gray("Password:")} ${finalPass}`);
5402
5593
  console.log("");
5403
5594
  return;
@@ -5465,10 +5656,12 @@ async function resetPassword() {
5465
5656
  if (result.length > 0) {
5466
5657
  console.log("✅ Password reset for: " + result[0].email);
5467
5658
  ${!newPassword ? "console.log(\" New password: \" + newPassword);" : ""}
5468
- } else {
5469
- console.log("✗ User not found: " + email);
5659
+ process.exit(0);
5470
5660
  }
5471
- process.exit(0);
5661
+ // Nothing was updated, so nothing was reset. Exiting 0 here reported
5662
+ // success for a no-op, which is what a script would have believed.
5663
+ console.error("✗ User not found: " + email);
5664
+ process.exit(1);
5472
5665
  }
5473
5666
 
5474
5667
  resetPassword().catch(console.error);
@@ -5486,11 +5679,20 @@ resetPassword().catch(console.error);
5486
5679
  stdio: "inherit",
5487
5680
  env
5488
5681
  });
5682
+ const cleanup = () => {
5683
+ try {
5684
+ fs.unlinkSync(tmpScriptPath);
5685
+ } catch {}
5686
+ };
5489
5687
  return new Promise((resolve) => {
5688
+ child.on("error", (err) => {
5689
+ cleanup();
5690
+ console.error(chalk.red("✗ Could not run the reset script."));
5691
+ console.error(chalk.gray(` ${err.message}`));
5692
+ process.exit(1);
5693
+ });
5490
5694
  child.on("close", (code) => {
5491
- try {
5492
- fs.unlinkSync(tmpScriptPath);
5493
- } catch {}
5695
+ cleanup();
5494
5696
  if (code !== 0) process.exit(code ?? 1);
5495
5697
  resolve();
5496
5698
  });
@@ -5786,25 +5988,13 @@ ${chalk.green.bold("Examples")}
5786
5988
  * create — Create a new API key
5787
5989
  * revoke — Revoke an existing API key
5788
5990
  */
5789
- function loadEnv(projectRoot) {
5790
- const envFile = findEnvFile(projectRoot);
5791
- const env = {};
5792
- if (envFile && fs.existsSync(envFile)) {
5793
- const content = fs.readFileSync(envFile, "utf-8");
5794
- for (const line of content.split("\n")) {
5795
- const trimmed = line.trim();
5796
- if (!trimmed || trimmed.startsWith("#")) continue;
5797
- const idx = trimmed.indexOf("=");
5798
- if (idx > 0) {
5799
- const key = trimmed.slice(0, idx).trim();
5800
- let value = trimmed.slice(idx + 1).trim();
5801
- if (value.startsWith("\"") && value.endsWith("\"") || value.startsWith("'") && value.endsWith("'")) value = value.slice(1, -1);
5802
- env[key] = value;
5803
- }
5804
- }
5805
- }
5806
- return env;
5807
- }
5991
+ /**
5992
+ * Was a hand-rolled `indexOf("=")` loop. It keyed `export KEY=value` as
5993
+ * `export KEY` and carried a trailing `# comment` into the value — so a key
5994
+ * that was present read as absent, or reached an `Authorization` header with a
5995
+ * comment attached and came back 401. See `readEnvFile`.
5996
+ */
5997
+ var loadEnv = readEnvFile;
5808
5998
  function resolveBaseUrl(env, projectRoot) {
5809
5999
  if (env.REBASE_BASE_URL) return env.REBASE_BASE_URL;
5810
6000
  if (projectRoot) try {
@@ -10913,6 +11103,6 @@ function telemetryNotice() {
10913
11103
  return chalk.gray(`Usage sharing: ${sharing ? "on" : "off"} — ${chalk.cyan("rebase telemetry")} to inspect or change\n`);
10914
11104
  }
10915
11105
  //#endregion
10916
- export { CURRENT_RUNTIME_RANGE, DEFAULT_BUNDLE_DIR, DEFAULT_CONFIG_DIR, DEFAULT_CRONS_DIR, DEFAULT_FUNCTIONS_DIR, DEFAULT_SCHEMA_FILE, DEV_PORT_FILENAME, MANIFEST_FILENAME, ManifestError, TEMPLATE_PLACEHOLDER_FILES, appsCommand, assessManagedCompatibility, authCommand, buildBundle, buildCommand, buildInitQuestions, buildStaticBundle, buildableApps, cloudCommand, collectDeclaredDependencies, configureEnvFile, createRebaseApp, dbCommand, detectFrameworkDepDrift, detectNativeDependencies, detectPackageManager, detectStorageAuthorize, devCommand, doctorCommand, ejectCommand, entry, findBackendApp, findBackendDir, findEnvFile, findFrontendDir, findProjectRoot, findUnusedServerEntry, foldStaticIntoBundle, formatCdTarget, generateSdkCommand, getActiveBackendPlugin, getPMCommands, getProjectPort, isIdentifierLike, isPnpmAvailable, loadManifest, manifestExists, manifestPath, normalizeEsmSpecifiers, pnpmAvailabilityFromProbe, positionals, printInitHelp, requireBackendDir, requireProjectRoot, resetPnpmAvailabilityCache, resolveBackendPaths, resolveCliVersion, resolveExampleBaseUrl, resolveLocalBin, resolvePluginCliScript, resolveStartPort, resolveTsx, schemaCommand, startCommand, synthesizeManifest, validateManifest, validateProjectName, validateTsxInstallation, writeManifest };
11106
+ export { CURRENT_RUNTIME_RANGE, DEFAULT_BUNDLE_DIR, DEFAULT_CONFIG_DIR, DEFAULT_CRONS_DIR, DEFAULT_FUNCTIONS_DIR, DEFAULT_SCHEMA_FILE, DEV_PORT_FILENAME, MANIFEST_FILENAME, ManifestError, TEMPLATE_PLACEHOLDER_FILES, appsCommand, assessManagedCompatibility, authCommand, buildBundle, buildCommand, buildInitQuestions, buildStaticBundle, buildableApps, cloudCommand, collectDeclaredDependencies, configureEnvFile, createRebaseApp, dbCommand, detectFrameworkDepDrift, detectNativeDependencies, detectPackageManager, detectStorageAuthorize, devCommand, doctorCommand, ejectCommand, entry, findBackendApp, findBackendDir, findEnvFile, findFrontendDir, findProjectRoot, findUnusedServerEntry, foldStaticIntoBundle, formatCdTarget, generateSdkCommand, getActiveBackendPlugin, getPMCommands, getProjectPort, isIdentifierLike, isPnpmAvailable, loadManifest, manifestExists, manifestPath, normalizeEsmSpecifiers, pnpmAvailabilityFromProbe, positionals, printInitHelp, readEnvFile, requireBackendDir, requireProjectRoot, resetPnpmAvailabilityCache, resolveBackendPaths, resolveCliVersion, resolveExampleBaseUrl, resolveLocalBin, resolvePluginCliScript, resolveRuntimeImageTag, resolveStartPort, resolveTsx, schemaCommand, selectUserForEmail, startCommand, synthesizeManifest, validateManifest, validateProjectName, validateTsxInstallation, writeManifest };
10917
11107
 
10918
11108
  //# sourceMappingURL=index.es.js.map