@rebasepro/cli 0.12.1-canary.gf4240e3 → 0.13.0

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.
@@ -24,7 +24,32 @@ export declare function requireClient(rawArgs: string[]): Promise<{
24
24
  client: CloudClient;
25
25
  url: string;
26
26
  }>;
27
+ /** One place a deploy can actually land, as the control plane describes it. */
28
+ export interface DeployTarget {
29
+ clusterId?: string | null;
30
+ provider: string;
31
+ region?: string;
32
+ label?: string;
33
+ baseDomain?: string;
34
+ }
35
+ export interface PlatformConfig {
36
+ tenantBaseDomain?: string;
37
+ deployTargets?: DeployTarget[];
38
+ }
39
+ export declare function fetchPlatformConfig(client: CloudClient, url: string): Promise<PlatformConfig | undefined>;
40
+ /**
41
+ * The base domain tenant projects are served at, derived from the same
42
+ * TENANT_BASE_DOMAIN the ingress and the console read (see
43
+ * saas/backend/src/utils/tenant-domain.ts).
44
+ */
27
45
  export declare function fetchTenantBaseDomain(client: CloudClient, url: string): Promise<string | undefined>;
46
+ /**
47
+ * The infrastructure a deploy for this control plane would ACTUALLY use, in the
48
+ * resolver's own preference order (saas/backend/src/k8s/resolve.ts).
49
+ *
50
+ * @returns the targets, or `undefined` when the control plane cannot say.
51
+ */
52
+ export declare function fetchDeployTargets(client: CloudClient, url: string): Promise<DeployTarget[] | undefined>;
28
53
  /**
29
54
  * Public host for a project — `<subdomain>.<base>`, or the bare subdomain when
30
55
  * the base domain is unknown.
@@ -1,5 +1,36 @@
1
- import { type CloudClient } from "./context";
1
+ import { type DeployTarget, type CloudClient } from "./context";
2
2
  export declare function listProjects(rawArgs: string[]): Promise<void>;
3
+ /**
4
+ * Where this project says it runs.
5
+ *
6
+ * `provider`/`region` are a *request*: no code downstream reads them to pick a
7
+ * deploy target — that comes from the project's cluster record or the ambient
8
+ * in-cluster context (saas/backend/src/k8s/resolve.ts). So a wrong value here is
9
+ * never contradicted by a failure; it just sits in the record. The CLI used to
10
+ * default to `hetzner`/`nbg1` unconditionally, which is how projects running on
11
+ * our GKE cluster came to describe themselves as Hetzner in the console — and
12
+ * `provider` is half the Stripe compute lookup key (`compute_<provider>_<vmSize>`),
13
+ * so that is a mispricing, not a cosmetic slip.
14
+ *
15
+ * The control plane already publishes the infrastructure that actually exists,
16
+ * and the console's create wizard reads it. Ask the same question here.
17
+ *
18
+ * Exported for tests: the decision is pure, so it can be pinned without a
19
+ * control plane. The fetching and the exit live in `resolveRequestedTarget`.
20
+ *
21
+ * @param requested `--provider`, if the caller named one. An explicit flag wins:
22
+ * it is the caller stating intent, and `deploy` corrects the record anyway.
23
+ * @param targets What the control plane says exists, or `undefined` when it
24
+ * cannot say — an older deployment with no `platform-config`, or a failed
25
+ * request. That is different from an empty list, which is a control plane
26
+ * stating it has no infrastructure at all.
27
+ * @returns the target to record, or `null` when the control plane answered that
28
+ * there is none.
29
+ */
30
+ export declare function chooseRequestedTarget(requested: string | undefined, targets: DeployTarget[] | undefined): {
31
+ provider: string;
32
+ region?: string;
33
+ } | null;
3
34
  export declare function createProject(rawArgs: string[]): Promise<void>;
4
35
  export declare function projectInfo(rawArgs: string[], projectRef: string): Promise<void>;
5
36
  export declare function deleteProject(rawArgs: string[], projectRef: string): Promise<void>;
package/dist/index.es.js CHANGED
@@ -528,33 +528,51 @@ async function requireClient(rawArgs) {
528
528
  };
529
529
  }
530
530
  /**
531
- * The base domain tenant projects are served at, as reported by the control
532
- * plane (`platform-config`, which derives it from the same TENANT_BASE_DOMAIN
533
- * the ingress and the console read — see saas/backend/src/utils/tenant-domain.ts).
531
+ * The control plane's public, non-secret self-description (`platform-config`).
534
532
  *
535
- * The CLI cannot know this value: it is per-deployment configuration (production
536
- * serves tenants at `rebase.website`, a dev control plane at `localhost`). It
537
- * used to be hardcoded to `rebase.pro`, so `cloud projects create` congratulated
538
- * the user with a URL that resolves nowhere near their app.
533
+ * None of it is knowable from the CLI side: it is per-deployment configuration
534
+ * production serves tenants at `rebase.website` on GKE, a dev control plane at
535
+ * `localhost` on Docker. Guessing produced two separate lies: a congratulation
536
+ * URL that resolved nowhere near the app, and a `provider` the project does not
537
+ * run on (see `createProject`).
539
538
  *
540
539
  * Cached per host for the process: it is fixed for a control plane's lifetime,
541
540
  * and `projects list` formats one host per row off a single fetch.
542
541
  *
543
- * @returns the base domain, or `undefined` if the control plane doesn't serve
542
+ * @returns the config, or `undefined` if the control plane doesn't serve
544
543
  * `platform-config` (an older deployment) or the request failed. A failure is
545
- * cached too — the caller renders a subdomain either way, and a short-lived
546
- * CLI should not retry once per row.
547
- */
548
- var tenantBaseDomainCache = /* @__PURE__ */ new Map();
549
- function fetchTenantBaseDomain(client, url) {
550
- let pending = tenantBaseDomainCache.get(url);
544
+ * cached too — a short-lived CLI should not retry once per row. Note the
545
+ * distinction callers rely on: `undefined` means "this control plane cannot
546
+ * tell us", whereas `deployTargets: []` is a control plane stating that it has
547
+ * no infrastructure configured.
548
+ */
549
+ var platformConfigCache = /* @__PURE__ */ new Map();
550
+ function fetchPlatformConfig(client, url) {
551
+ let pending = platformConfigCache.get(url);
551
552
  if (!pending) {
552
- pending = client.functions.invoke("platform-config", void 0, { method: "GET" }).then((cfg) => cfg?.tenantBaseDomain?.trim() || void 0).catch(() => void 0);
553
- tenantBaseDomainCache.set(url, pending);
553
+ pending = client.functions.invoke("platform-config", void 0, { method: "GET" }).then((cfg) => cfg ?? void 0).catch(() => void 0);
554
+ platformConfigCache.set(url, pending);
554
555
  }
555
556
  return pending;
556
557
  }
557
558
  /**
559
+ * The base domain tenant projects are served at, derived from the same
560
+ * TENANT_BASE_DOMAIN the ingress and the console read (see
561
+ * saas/backend/src/utils/tenant-domain.ts).
562
+ */
563
+ function fetchTenantBaseDomain(client, url) {
564
+ return fetchPlatformConfig(client, url).then((cfg) => cfg?.tenantBaseDomain?.trim() || void 0);
565
+ }
566
+ /**
567
+ * The infrastructure a deploy for this control plane would ACTUALLY use, in the
568
+ * resolver's own preference order (saas/backend/src/k8s/resolve.ts).
569
+ *
570
+ * @returns the targets, or `undefined` when the control plane cannot say.
571
+ */
572
+ function fetchDeployTargets(client, url) {
573
+ return fetchPlatformConfig(client, url).then((cfg) => Array.isArray(cfg?.deployTargets) ? cfg.deployTargets : void 0);
574
+ }
575
+ /**
558
576
  * Public host for a project — `<subdomain>.<base>`, or the bare subdomain when
559
577
  * the base domain is unknown.
560
578
  *
@@ -904,10 +922,26 @@ function durationBucket(ms) {
904
922
  * turning into an incident. Strings are capped at 64 characters because no
905
923
  * legitimate enumerated value is longer, and a path or a message always is.
906
924
  */
925
+ /**
926
+ * Key names that shadow an `Object.prototype` member.
927
+ *
928
+ * `__proto__` is already excluded by the pattern (it starts with an
929
+ * underscore), and assigning any of these to an object literal shadows rather
930
+ * than pollutes — so nothing is exploitable here. They are refused because a
931
+ * stored property called `constructor` is a trap for every consumer downstream
932
+ * that reaches for `properties.constructor` and gets a string.
933
+ */
934
+ var RESERVED_KEYS = /* @__PURE__ */ new Set([
935
+ "constructor",
936
+ "prototype",
937
+ "hasownproperty",
938
+ "tostring",
939
+ "valueof"
940
+ ]);
907
941
  function sanitize(properties) {
908
942
  const out = {};
909
943
  for (const [key, value] of Object.entries(properties)) {
910
- if (!/^[a-z][a-z0-9_]{0,31}$/.test(key)) continue;
944
+ if (!/^[a-z][a-z0-9_]{0,31}$/.test(key) || RESERVED_KEYS.has(key.toLowerCase())) continue;
911
945
  if (typeof value === "boolean" || typeof value === "number" && Number.isFinite(value)) out[key] = value;
912
946
  else if (typeof value === "string" && value.length > 0 && value.length <= 64) {
913
947
  if (!/[/\\@:\s]/.test(value)) out[key] = value;
@@ -982,16 +1016,29 @@ function ensureMachineId() {
982
1016
  /**
983
1017
  * The checkout's id, generating one if this project has none.
984
1018
  *
985
- * Returns `undefined` when there is nowhere to put it — `.rebase/` is created
986
- * by `rebase init`, so a command run outside a project simply reports no
987
- * project, rather than scattering directories across the filesystem.
1019
+ * The directory is created when it is missing, but **only** where a
1020
+ * `rebase.json` proves this really is a project root. An earlier version
1021
+ * required `.rebase/` to already exist, on the assumption that `rebase init`
1022
+ * created it — it does not. It is written only when a scaffold is linked to
1023
+ * Rebase Cloud, so every self-hosted project reported no `projectId` at all,
1024
+ * for ever. That silently broke the funnel this id exists for, on exactly the
1025
+ * population the telemetry is meant to learn about.
1026
+ *
1027
+ * The `rebase.json` check is what keeps the fix from being "scatter `.rebase/`
1028
+ * wherever a command happens to run": no manifest, no project, no directory,
1029
+ * and the event simply reports no project.
988
1030
  */
989
1031
  function ensureProjectId(projectRoot) {
1032
+ if (!fs.existsSync(path.join(projectRoot, "rebase.json"))) return void 0;
990
1033
  const dir = path.join(projectRoot, ".rebase");
991
- if (!fs.existsSync(dir)) return void 0;
1034
+ try {
1035
+ fs.mkdirSync(dir, { recursive: true });
1036
+ } catch {
1037
+ return;
1038
+ }
992
1039
  const file = path.join(dir, "state.json");
993
1040
  let state = {};
994
- try {
1041
+ if (fs.existsSync(file)) try {
995
1042
  const raw = JSON.parse(fs.readFileSync(file, "utf-8"));
996
1043
  if (raw && typeof raw === "object") state = raw;
997
1044
  } catch {
@@ -1060,16 +1107,29 @@ function suppressionReason(env = process.env, cwd = process.cwd()) {
1060
1107
  function isEnabled(env = process.env, cwd = process.cwd()) {
1061
1108
  return suppressionReason(env, cwd) === null;
1062
1109
  }
1063
- /** Record the user's answer. `false` is final — nothing prompts again. */
1110
+ /**
1111
+ * Record the user's answer. `false` is final — nothing prompts again.
1112
+ *
1113
+ * Returns whether the choice could actually be persisted. A read-only or full
1114
+ * home directory makes `writeConfig` throw, and the direction of that failure
1115
+ * matters enormously: someone turning sharing **off** who sees a stack trace,
1116
+ * or worse sees nothing, is left sharing. The caller is expected to say so and
1117
+ * point at `REBASE_TELEMETRY_DISABLED`, which needs no disk.
1118
+ */
1064
1119
  function setConsent(enabled) {
1065
1120
  const config = readConfig();
1066
- writeConfig({
1067
- ...config,
1068
- enabled,
1069
- decidedAt: (/* @__PURE__ */ new Date()).toISOString(),
1070
- machineId: enabled ? config.machineId ?? void 0 : void 0
1071
- });
1072
- if (enabled) ensureMachineId();
1121
+ try {
1122
+ writeConfig({
1123
+ ...config,
1124
+ enabled,
1125
+ decidedAt: (/* @__PURE__ */ new Date()).toISOString(),
1126
+ machineId: enabled ? config.machineId ?? void 0 : void 0
1127
+ });
1128
+ if (enabled) ensureMachineId();
1129
+ return true;
1130
+ } catch {
1131
+ return false;
1132
+ }
1073
1133
  }
1074
1134
  /**
1075
1135
  * Build the event that *would* be sent, without sending it.
@@ -1899,7 +1959,11 @@ async function replacePlaceholders(options) {
1899
1959
  const prereleasePins = [...unreleased].filter(([, version]) => version === "latest" || version.includes("-"));
1900
1960
  if (cliIsStable && prereleasePins.length > 0) {
1901
1961
  const lines = prereleasePins.map(([name, version]) => ` ${name} → ${version}`).join("\n");
1902
- throw new Error(`Rebase ${cliVersion} is not fully published to npm.\n\nThese packages have no ${cliVersion} release, so the newest thing on the\nregistry is a prerelease:\n\n${lines}\n\nScaffolding would pin those alongside the ${cliVersion} packages and produce\nan app that cannot install or run. That is a release gap in Rebase itself —\nnot a problem with your machine, your network, or your package manager.\n\nStopped before writing dependency versions or installing anything. The\nproject directory ${path.basename(options.targetDirectory)}/ was created and is safe to delete.\nPlease report this with the list above.`);
1962
+ throw new Error(`Rebase ${cliVersion} is not fully published to npm.\n\nThese packages have no ${cliVersion} release, so the newest thing on the\nregistry is a prerelease:\n\n${lines}\n\nScaffolding would pin those alongside the ${cliVersion} packages and produce\nan app that cannot install or run. That is a release gap in Rebase itself
1963
+ not a problem with your machine, your network, or your package manager.
1964
+
1965
+ Stopped before writing dependency versions or installing anything. The
1966
+ project directory ${path.basename(options.targetDirectory)}/ was created and is safe to delete.\nPlease report this with the list above.`);
1903
1967
  }
1904
1968
  for (const [fullPath, originalContent] of fileContents.entries()) {
1905
1969
  let content = originalContent.replace(/\{\{PROJECT_NAME\}\}/g, options.projectName);
@@ -2303,6 +2367,7 @@ async function schemaCommand(subcommand, rawArgs) {
2303
2367
  return;
2304
2368
  }
2305
2369
  const projectRoot = requireProjectRoot();
2370
+ recordEvent("cli.schema_generate", { subcommand: subcommand ?? "none" }, { projectRoot });
2306
2371
  const backendDir = requireBackendDir(projectRoot);
2307
2372
  const activePlugin = getActiveBackendPlugin(backendDir);
2308
2373
  if (!activePlugin) {
@@ -2371,6 +2436,7 @@ async function dbCommand(subcommand, rawArgs) {
2371
2436
  return;
2372
2437
  }
2373
2438
  const projectRoot = requireProjectRoot();
2439
+ recordEvent("cli.db_push", { subcommand: subcommand ?? "none" }, { projectRoot });
2374
2440
  const backendDir = requireBackendDir(projectRoot);
2375
2441
  const activePlugin = getActiveBackendPlugin(backendDir);
2376
2442
  if (!activePlugin) {
@@ -4216,7 +4282,7 @@ async function buildBundle(options) {
4216
4282
  console.log(chalk.yellow(` ⚠ ${unusedEntry} is not the bundle's entry point — it is not compiled or shipped.`));
4217
4283
  console.log(chalk.dim(` The runtime boots the bundle itself and mounts ${compiled}.`));
4218
4284
  console.log(chalk.dim(` Routes defined there will not exist once deployed: move them to ${paths.functions}/,`));
4219
- console.log(chalk.dim(` or run \`rebase eject\` to make this file the entrypoint and own the image.`));
4285
+ console.log(chalk.dim(" or run `rebase eject` to make this file the entrypoint and own the image."));
4220
4286
  }
4221
4287
  log(options, chalk.dim(` compiling ${includes.length} source group(s) → ${path.relative(projectRoot, outDir)}/`));
4222
4288
  cleanOutDir(projectRoot, outDir);
@@ -5998,12 +6064,21 @@ async function telemetryCommand(rawArgs) {
5998
6064
  printPayload();
5999
6065
  return;
6000
6066
  case "enable":
6001
- setConsent(true);
6067
+ if (!setConsent(true)) {
6068
+ console.error(chalk.red(`Could not write ${configPath()} — sharing was not enabled.`));
6069
+ process.exitCode = 1;
6070
+ return;
6071
+ }
6002
6072
  console.log(chalk.green("Anonymous usage sharing enabled."));
6003
6073
  console.log(chalk.gray(`Inspect what gets sent with ${chalk.cyan("rebase telemetry show")}.`));
6004
6074
  return;
6005
6075
  case "disable":
6006
- setConsent(false);
6076
+ if (!setConsent(false)) {
6077
+ console.error(chalk.red(`Could not write ${configPath()} — sharing is still ON.`));
6078
+ console.error(chalk.yellow(`Set ${chalk.cyan("REBASE_TELEMETRY_DISABLED=1")} in your environment instead; it needs no file.`));
6079
+ process.exitCode = 1;
6080
+ return;
6081
+ }
6007
6082
  console.log(chalk.gray("Anonymous usage sharing disabled. Nothing further will be sent."));
6008
6083
  return;
6009
6084
  default:
@@ -6363,6 +6438,54 @@ function providerDefaults(provider) {
6363
6438
  };
6364
6439
  }
6365
6440
  }
6441
+ /**
6442
+ * Where this project says it runs.
6443
+ *
6444
+ * `provider`/`region` are a *request*: no code downstream reads them to pick a
6445
+ * deploy target — that comes from the project's cluster record or the ambient
6446
+ * in-cluster context (saas/backend/src/k8s/resolve.ts). So a wrong value here is
6447
+ * never contradicted by a failure; it just sits in the record. The CLI used to
6448
+ * default to `hetzner`/`nbg1` unconditionally, which is how projects running on
6449
+ * our GKE cluster came to describe themselves as Hetzner in the console — and
6450
+ * `provider` is half the Stripe compute lookup key (`compute_<provider>_<vmSize>`),
6451
+ * so that is a mispricing, not a cosmetic slip.
6452
+ *
6453
+ * The control plane already publishes the infrastructure that actually exists,
6454
+ * and the console's create wizard reads it. Ask the same question here.
6455
+ *
6456
+ * Exported for tests: the decision is pure, so it can be pinned without a
6457
+ * control plane. The fetching and the exit live in `resolveRequestedTarget`.
6458
+ *
6459
+ * @param requested `--provider`, if the caller named one. An explicit flag wins:
6460
+ * it is the caller stating intent, and `deploy` corrects the record anyway.
6461
+ * @param targets What the control plane says exists, or `undefined` when it
6462
+ * cannot say — an older deployment with no `platform-config`, or a failed
6463
+ * request. That is different from an empty list, which is a control plane
6464
+ * stating it has no infrastructure at all.
6465
+ * @returns the target to record, or `null` when the control plane answered that
6466
+ * there is none.
6467
+ */
6468
+ function chooseRequestedTarget(requested, targets) {
6469
+ if (requested) return {
6470
+ provider: requested,
6471
+ region: void 0
6472
+ };
6473
+ if (!targets) return {
6474
+ provider: "hetzner",
6475
+ region: void 0
6476
+ };
6477
+ if (targets.length === 0) return null;
6478
+ const [target] = targets;
6479
+ return {
6480
+ provider: target.provider,
6481
+ region: target.region?.trim() || void 0
6482
+ };
6483
+ }
6484
+ async function resolveRequestedTarget(client, url, requested) {
6485
+ const chosen = chooseRequestedTarget(requested, await fetchDeployTargets(client, url));
6486
+ if (!chosen) fail("This control plane has no deploy targets configured.", `Register a cluster, or pass ${chalk.bold("--provider")} and ${chalk.bold("--region")} to record one anyway.`);
6487
+ return chosen;
6488
+ }
6366
6489
  async function createProject(rawArgs) {
6367
6490
  const args = arg({
6368
6491
  "--name": String,
@@ -6398,9 +6521,10 @@ async function createProject(rawArgs) {
6398
6521
  const subdomain = (args["--subdomain"] || a.subdomain || "").trim().toLowerCase();
6399
6522
  const gitRepoUrl = (args["--repo"] || a.repo || "").trim();
6400
6523
  const gitBranch = (args["--branch"] || a.branch || "main").trim();
6401
- const provider = (args["--provider"] || a.provider || "hetzner").trim();
6524
+ const target = await resolveRequestedTarget(client, url, (args["--provider"] || a.provider)?.trim() || void 0);
6525
+ const provider = target.provider;
6402
6526
  const defaults = providerDefaults(provider);
6403
- const region = (args["--region"] || defaults.region).trim();
6527
+ const region = (args["--region"] || target.region || defaults.region).trim();
6404
6528
  const vmSize = (args["--vm-size"] || defaults.vmSize).trim();
6405
6529
  if (!name || !subdomain) fail("Name and subdomain are required.");
6406
6530
  try {
@@ -10750,7 +10874,23 @@ ${chalk.green.bold("Options")}
10750
10874
  ${chalk.blue("--help, -h")} Show this help message
10751
10875
 
10752
10876
  ${chalk.gray("Documentation: https://rebase.pro/docs")}
10753
- `);
10877
+ ${telemetryNotice()}`);
10878
+ }
10879
+ /**
10880
+ * One line about usage sharing, in the global help.
10881
+ *
10882
+ * Every other tool that collects anything prints a first-run notice. Ours asks
10883
+ * at the end of `rebase init` — but someone who installs the CLI and never runs
10884
+ * `init`, or who joins a project someone else scaffolded, would otherwise never
10885
+ * learn the subsystem exists. This is the cheapest place to close that: the
10886
+ * help is what an unfamiliar user reads first.
10887
+ *
10888
+ * It states the current setting rather than a generic sentence, so it is also
10889
+ * the fastest answer to "is this thing on?".
10890
+ */
10891
+ function telemetryNotice() {
10892
+ const sharing = isEnabled();
10893
+ return chalk.gray(`Usage sharing: ${sharing ? "on" : "off"} — ${chalk.cyan("rebase telemetry")} to inspect or change\n`);
10754
10894
  }
10755
10895
  //#endregion
10756
10896
  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 };