@rebasepro/cli 0.12.1-canary.g4e7bcbf → 0.12.1-canary.g52d71ee

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.
package/dist/index.es.js CHANGED
@@ -12,9 +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 { createRequire } from "module";
15
16
  import { BUNDLE_FORMAT_VERSION, RUNTIME_CONTRACT_VERSION, computeSchemaVersion, deserializeCollections, findStorageSuffixCollision, normalizeStorageSources, storageEnvSuffix } from "@rebasepro/types";
16
17
  import { generateSDK } from "@rebasepro/codegen";
17
- import { createRequire } from "module";
18
18
  //#region src/utils/package-manager.ts
19
19
  /**
20
20
  * Package manager detection and command abstraction.
@@ -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
  *
@@ -888,6 +906,369 @@ function openUrl(target, label = "Opening") {
888
906
  child.unref();
889
907
  } catch {}
890
908
  }
909
+ /** Duration in coarse bands — enough to see "slow", not enough to fingerprint. */
910
+ function durationBucket(ms) {
911
+ if (!Number.isFinite(ms) || ms < 0) return "unknown";
912
+ if (ms < 5e3) return "<5s";
913
+ if (ms < 3e4) return "5-30s";
914
+ if (ms < 12e4) return "30-120s";
915
+ return "120s+";
916
+ }
917
+ /**
918
+ * Drop anything that is not a permitted value type, and clamp strings.
919
+ *
920
+ * The last line of defence rather than the first. Every call site is supposed
921
+ * to pass enumerated values; this is what stops a future one that forgets from
922
+ * turning into an incident. Strings are capped at 64 characters because no
923
+ * legitimate enumerated value is longer, and a path or a message always is.
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
+ ]);
941
+ function sanitize(properties) {
942
+ const out = {};
943
+ for (const [key, value] of Object.entries(properties)) {
944
+ if (!/^[a-z][a-z0-9_]{0,31}$/.test(key) || RESERVED_KEYS.has(key.toLowerCase())) continue;
945
+ if (typeof value === "boolean" || typeof value === "number" && Number.isFinite(value)) out[key] = value;
946
+ else if (typeof value === "string" && value.length > 0 && value.length <= 64) {
947
+ if (!/[/\\@:\s]/.test(value)) out[key] = value;
948
+ }
949
+ }
950
+ return out;
951
+ }
952
+ function cliVersion() {
953
+ try {
954
+ const pkg = createRequire(import.meta.url)("../../package.json");
955
+ return typeof pkg?.version === "string" ? pkg.version : "unknown";
956
+ } catch {
957
+ return "unknown";
958
+ }
959
+ }
960
+ function buildEvent(event, properties, identity) {
961
+ return {
962
+ schema: 1,
963
+ event,
964
+ machineId: identity.machineId,
965
+ projectId: identity.projectId,
966
+ cliVersion: cliVersion(),
967
+ nodeMajor: Number(process.versions.node.split(".")[0]) || 0,
968
+ platform: os.platform(),
969
+ arch: os.arch(),
970
+ at: (/* @__PURE__ */ new Date()).toISOString().replace(/\.\d{3}Z$/, "Z"),
971
+ properties: sanitize(properties)
972
+ };
973
+ }
974
+ function configPath() {
975
+ return path.join(os.homedir(), ".rebase", "telemetry.json");
976
+ }
977
+ function readConfig() {
978
+ try {
979
+ const raw = JSON.parse(fs.readFileSync(configPath(), "utf-8"));
980
+ if (raw && typeof raw === "object") return {
981
+ version: typeof raw.version === "number" ? raw.version : 1,
982
+ enabled: typeof raw.enabled === "boolean" ? raw.enabled : void 0,
983
+ machineId: typeof raw.machineId === "string" ? raw.machineId : void 0,
984
+ decidedAt: typeof raw.decidedAt === "string" ? raw.decidedAt : void 0
985
+ };
986
+ } catch {}
987
+ return { version: 1 };
988
+ }
989
+ function writeConfig(config) {
990
+ const file = configPath();
991
+ fs.mkdirSync(path.dirname(file), { recursive: true });
992
+ fs.writeFileSync(file, JSON.stringify({
993
+ ...config,
994
+ version: 1
995
+ }, null, 2), {
996
+ encoding: "utf-8",
997
+ mode: 384
998
+ });
999
+ }
1000
+ /**
1001
+ * The machine's id, generating and persisting one on first use.
1002
+ *
1003
+ * Only called once consent exists — an id written before the user agreed would
1004
+ * be a record we had no right to create, even unsent.
1005
+ */
1006
+ function ensureMachineId() {
1007
+ const config = readConfig();
1008
+ if (config.machineId) return config.machineId;
1009
+ const machineId = crypto.randomUUID();
1010
+ writeConfig({
1011
+ ...config,
1012
+ machineId
1013
+ });
1014
+ return machineId;
1015
+ }
1016
+ /**
1017
+ * The checkout's id, generating one if this project has none.
1018
+ *
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.
1030
+ */
1031
+ function ensureProjectId(projectRoot) {
1032
+ if (!fs.existsSync(path.join(projectRoot, "rebase.json"))) return void 0;
1033
+ const dir = path.join(projectRoot, ".rebase");
1034
+ try {
1035
+ fs.mkdirSync(dir, { recursive: true });
1036
+ } catch {
1037
+ return;
1038
+ }
1039
+ const file = path.join(dir, "state.json");
1040
+ let state = {};
1041
+ if (fs.existsSync(file)) try {
1042
+ const raw = JSON.parse(fs.readFileSync(file, "utf-8"));
1043
+ if (raw && typeof raw === "object") state = raw;
1044
+ } catch {
1045
+ return;
1046
+ }
1047
+ if (typeof state.telemetryProjectId === "string") return state.telemetryProjectId;
1048
+ const projectId = crypto.randomUUID();
1049
+ try {
1050
+ fs.writeFileSync(file, JSON.stringify({
1051
+ ...state,
1052
+ telemetryProjectId: projectId
1053
+ }, null, 2), "utf-8");
1054
+ } catch {
1055
+ return;
1056
+ }
1057
+ return projectId;
1058
+ }
1059
+ //#endregion
1060
+ //#region src/telemetry/project.ts
1061
+ function readProjectPolicy(startDir = process.cwd()) {
1062
+ try {
1063
+ const root = findProjectRoot(startDir);
1064
+ if (!root) return "unset";
1065
+ const file = path.join(root, MANIFEST_FILENAME);
1066
+ if (!fs.existsSync(file)) return "unset";
1067
+ const raw = fs.readFileSync(file, "utf-8");
1068
+ const match = /"telemetry"\s*:\s*(true|false)/.exec(raw);
1069
+ if (!match) return "unset";
1070
+ return match[1] === "false" ? "opt_out" : "ignored_opt_in";
1071
+ } catch {
1072
+ return "unset";
1073
+ }
1074
+ }
1075
+ function endpoint() {
1076
+ return process.env.REBASE_TELEMETRY_ENDPOINT?.trim() || "https://app.rebase.pro/api/functions/telemetry";
1077
+ }
1078
+ /**
1079
+ * The one function that decides whether anything leaves the machine.
1080
+ *
1081
+ * Every path is a refusal except the last, which is the point: consent is
1082
+ * opt-in, so the default answer at every branch — never asked, unreadable
1083
+ * config, a set env var, a CI runner — is no.
1084
+ *
1085
+ * `DO_NOT_TRACK` is honoured because it is the cross-tool convention
1086
+ * (consoledonottrack.com); a user who has set it globally has already answered
1087
+ * this question and should not be asked again by us.
1088
+ *
1089
+ * `CI` is refused for a different reason: a build runner is not a person, and
1090
+ * counting one is both useless and misleading. A single pipeline re-running on
1091
+ * every push would otherwise outweigh every real developer in the data.
1092
+ *
1093
+ * A project's own `"telemetry": false` is checked *before* the machine setting,
1094
+ * because it has to beat an individual opt-in to be worth anything — see
1095
+ * project.ts on why the reverse is refused.
1096
+ */
1097
+ function suppressionReason(env = process.env, cwd = process.cwd()) {
1098
+ if (env.DO_NOT_TRACK && env.DO_NOT_TRACK !== "0") return "do_not_track";
1099
+ if (env.REBASE_TELEMETRY_DISABLED && env.REBASE_TELEMETRY_DISABLED !== "0") return "rebase_telemetry_disabled";
1100
+ if (env.CI && env.CI !== "false") return "ci";
1101
+ if (readProjectPolicy(cwd) === "opt_out") return "project_opt_out";
1102
+ const config = readConfig();
1103
+ if (config.enabled === void 0) return "not_asked";
1104
+ if (!config.enabled) return "declined";
1105
+ return null;
1106
+ }
1107
+ function isEnabled(env = process.env, cwd = process.cwd()) {
1108
+ return suppressionReason(env, cwd) === null;
1109
+ }
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
+ */
1119
+ function setConsent(enabled) {
1120
+ const config = readConfig();
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
+ }
1133
+ }
1134
+ /**
1135
+ * Build the event that *would* be sent, without sending it.
1136
+ *
1137
+ * This is what `rebase telemetry show` prints, and it is deliberately the same
1138
+ * function the sender uses — a preview assembled by separate code is a promise
1139
+ * that drifts. Returns `null` when nothing would be sent, so the command can
1140
+ * say why instead of showing a payload that is never going anywhere.
1141
+ */
1142
+ function previewEvent(event, properties = {}, projectRoot) {
1143
+ const config = readConfig();
1144
+ if (!config.machineId) return null;
1145
+ return buildEvent(event, properties, {
1146
+ machineId: config.machineId,
1147
+ projectId: projectRoot ? ensureProjectId(projectRoot) : void 0
1148
+ });
1149
+ }
1150
+ /**
1151
+ * Send one event, or quietly do nothing.
1152
+ *
1153
+ * Three properties this must hold, in order of importance:
1154
+ *
1155
+ * 1. **It never throws.** Telemetry failing is not a reason for `rebase dev`
1156
+ * to fail. Every error is swallowed.
1157
+ * 2. **It never blocks meaningfully.** A two-second ceiling, after which the
1158
+ * command carries on regardless — a collector having a bad day must not
1159
+ * become the CLI hanging.
1160
+ * 3. **It never sends without consent.** Enforced here rather than at the call
1161
+ * sites, so a new call site cannot get it wrong.
1162
+ */
1163
+ async function recordEvent(event, properties = {}, options = {}) {
1164
+ try {
1165
+ if (!isEnabled(process.env, options.projectRoot ?? process.cwd())) return;
1166
+ const payload = buildEvent(event, properties, {
1167
+ machineId: ensureMachineId(),
1168
+ projectId: options.projectRoot ? ensureProjectId(options.projectRoot) : void 0
1169
+ });
1170
+ const abort = new AbortController();
1171
+ const timer = setTimeout(() => abort.abort(), options.timeoutMs ?? 2e3);
1172
+ try {
1173
+ await fetch(endpoint(), {
1174
+ method: "POST",
1175
+ headers: { "Content-Type": "application/json" },
1176
+ body: JSON.stringify(payload),
1177
+ signal: abort.signal
1178
+ });
1179
+ } finally {
1180
+ clearTimeout(timer);
1181
+ }
1182
+ } catch {}
1183
+ }
1184
+ //#endregion
1185
+ //#region src/telemetry/consent.ts
1186
+ /**
1187
+ * Asking, and what the question looks like.
1188
+ *
1189
+ * ## Why the prompt comes *after* the work
1190
+ *
1191
+ * The first `rebase init` on a machine is the event most worth having, and it
1192
+ * is the one where no consent exists yet. Asking before scaffolding puts a
1193
+ * privacy negotiation in front of someone who has not yet seen the tool do
1194
+ * anything — the worst possible moment, and a reliable way to get a reflexive
1195
+ * no.
1196
+ *
1197
+ * So the question is asked once the project exists and the user has seen it
1198
+ * work. The event's data is still in memory at that point, so nothing is lost
1199
+ * by waiting, and — this is the part that matters — **nothing has been
1200
+ * transmitted or written**. Declining leaves no id, no file, no record.
1201
+ *
1202
+ * ## Why the payload is shown rather than described
1203
+ *
1204
+ * "Anonymous usage data" is a phrase that has been used to mean almost
1205
+ * anything. Printing the exact JSON costs four lines of output and replaces a
1206
+ * claim the user has to take on faith with something they can read. It is also
1207
+ * the same builder the sender uses, so it cannot drift into a comfortable
1208
+ * fiction.
1209
+ */
1210
+ /** True when we may ask: no decision recorded, and nothing else forbids it. */
1211
+ function shouldPrompt(env = process.env) {
1212
+ return suppressionReason(env) === "not_asked" && Boolean(process.stdin.isTTY);
1213
+ }
1214
+ function renderPreview(event, properties) {
1215
+ const preview = buildEvent(event, properties, {
1216
+ machineId: "<random uuid, generated only if you say yes>",
1217
+ projectId: "<random uuid, per checkout>"
1218
+ });
1219
+ return JSON.stringify(preview, null, 2);
1220
+ }
1221
+ /**
1222
+ * Ask, record the answer, and report it.
1223
+ *
1224
+ * Never throws and never blocks a non-interactive run: `rebase init --yes` in
1225
+ * CI must behave exactly as it does today, which means not asking and not
1226
+ * sending.
1227
+ */
1228
+ async function promptForConsent(event, properties) {
1229
+ if (!shouldPrompt()) return false;
1230
+ try {
1231
+ console.log("");
1232
+ console.log(chalk.bold("Help improve Rebase?"));
1233
+ console.log("");
1234
+ console.log(chalk.gray(" Rebase is self-hosted, so we have no idea what works and what does not"));
1235
+ console.log(chalk.gray(" unless you tell us. Sharing is entirely optional and off by default."));
1236
+ console.log("");
1237
+ console.log(chalk.gray(" This is exactly what would be sent — nothing more, ever:"));
1238
+ console.log("");
1239
+ console.log(renderPreview(event, properties).split("\n").map((line) => chalk.gray(" " + line)).join("\n"));
1240
+ console.log("");
1241
+ console.log(chalk.gray(" No project names, paths, schemas, URLs or error messages. Change your"));
1242
+ console.log(chalk.gray(` mind any time with ${chalk.cyan("rebase telemetry disable")}.`));
1243
+ console.log("");
1244
+ const { accepted } = await inquirer.prompt([{
1245
+ type: "confirm",
1246
+ name: "accepted",
1247
+ message: "Share anonymous usage data?",
1248
+ default: false
1249
+ }]);
1250
+ setConsent(Boolean(accepted));
1251
+ console.log(accepted ? chalk.green(" Thank you — sharing enabled.") : chalk.gray(" Nothing will be sent. You will not be asked again."));
1252
+ console.log("");
1253
+ return Boolean(accepted);
1254
+ } catch {
1255
+ return false;
1256
+ }
1257
+ }
1258
+ /** Human-readable current state, for `rebase telemetry status`. */
1259
+ function describeState(env = process.env) {
1260
+ const reason = suppressionReason(env);
1261
+ const config = readConfig();
1262
+ switch (reason) {
1263
+ case null: return `${chalk.green("enabled")} — schema v1, machine id ${chalk.gray(config.machineId ?? "unset")}`;
1264
+ case "not_asked": return `${chalk.yellow("not configured")} — nothing has been sent, and you have not been asked yet`;
1265
+ case "declined": return `${chalk.gray("disabled")} — you declined${config.decidedAt ? ` on ${config.decidedAt.slice(0, 10)}` : ""}`;
1266
+ case "do_not_track": return `${chalk.gray("disabled")} — the ${chalk.cyan("DO_NOT_TRACK")} environment variable is set`;
1267
+ case "rebase_telemetry_disabled": return `${chalk.gray("disabled")} — the ${chalk.cyan("REBASE_TELEMETRY_DISABLED")} environment variable is set`;
1268
+ case "ci": return `${chalk.gray("disabled")} — this looks like CI (${chalk.cyan("CI")} is set), which is never counted`;
1269
+ case "project_opt_out": return `${chalk.gray("disabled")} — this project's ${chalk.cyan("rebase.json")} sets ${chalk.cyan("\"telemetry\": false")}`;
1270
+ }
1271
+ }
891
1272
  //#endregion
892
1273
  //#region src/commands/init.ts
893
1274
  var access = promisify(fs.access);
@@ -1226,6 +1607,7 @@ async function linkScaffoldToCloud(options) {
1226
1607
  }
1227
1608
  }
1228
1609
  async function createProject$1(options) {
1610
+ const startedAt = Date.now();
1229
1611
  if (fs.existsSync(options.targetDirectory)) {
1230
1612
  if (fs.readdirSync(options.targetDirectory).length !== 0) {
1231
1613
  console.error(`${chalk.red.bold("ERROR")} Directory "${options.projectName}" already exists and is not empty`);
@@ -1418,6 +1800,18 @@ async function createProject$1(options) {
1418
1800
  console.log("");
1419
1801
  console.log(` ${chalk.cyan("rebase skills install")} ${chalk.gray("or")} ${chalk.cyan(pmCommands.run("skills:install").join(" "))}`);
1420
1802
  console.log("");
1803
+ const initProperties = {
1804
+ preset: options.headless ? "none" : options.preset,
1805
+ headless: Boolean(options.headless),
1806
+ package_manager: options.pm,
1807
+ installed_deps: Boolean(options.installDeps),
1808
+ introspected,
1809
+ own_database: Boolean(options.databaseUrl),
1810
+ cloud_linked: Boolean(options.cloudProject),
1811
+ git: Boolean(options.git),
1812
+ duration: durationBucket(Date.now() - startedAt)
1813
+ };
1814
+ if (await promptForConsent("cli.init", initProperties)) await recordEvent("cli.init", initProperties, { projectRoot: options.targetDirectory });
1421
1815
  }
1422
1816
  /**
1423
1817
  * Apply a template preset by replacing the default collection files.
@@ -1565,7 +1959,11 @@ async function replacePlaceholders(options) {
1565
1959
  const prereleasePins = [...unreleased].filter(([, version]) => version === "latest" || version.includes("-"));
1566
1960
  if (cliIsStable && prereleasePins.length > 0) {
1567
1961
  const lines = prereleasePins.map(([name, version]) => ` ${name} → ${version}`).join("\n");
1568
- 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.`);
1569
1967
  }
1570
1968
  for (const [fullPath, originalContent] of fileContents.entries()) {
1571
1969
  let content = originalContent.replace(/\{\{PROJECT_NAME\}\}/g, options.projectName);
@@ -1969,6 +2367,7 @@ async function schemaCommand(subcommand, rawArgs) {
1969
2367
  return;
1970
2368
  }
1971
2369
  const projectRoot = requireProjectRoot();
2370
+ recordEvent("cli.schema_generate", { subcommand: subcommand ?? "none" }, { projectRoot });
1972
2371
  const backendDir = requireBackendDir(projectRoot);
1973
2372
  const activePlugin = getActiveBackendPlugin(backendDir);
1974
2373
  if (!activePlugin) {
@@ -2037,6 +2436,7 @@ async function dbCommand(subcommand, rawArgs) {
2037
2436
  return;
2038
2437
  }
2039
2438
  const projectRoot = requireProjectRoot();
2439
+ recordEvent("cli.db_push", { subcommand: subcommand ?? "none" }, { projectRoot });
2040
2440
  const backendDir = requireBackendDir(projectRoot);
2041
2441
  const activePlugin = getActiveBackendPlugin(backendDir);
2042
2442
  if (!activePlugin) {
@@ -2800,6 +3200,11 @@ async function devCommand(rawArgs) {
2800
3200
  return;
2801
3201
  }
2802
3202
  const projectRoot = requireProjectRoot();
3203
+ recordEvent("cli.dev", {
3204
+ backend_only: Boolean(args["--backend-only"]),
3205
+ frontend_only: Boolean(args["--frontend-only"]),
3206
+ generate: Boolean(args["--generate"])
3207
+ }, { projectRoot });
2803
3208
  const backendDir = findBackendDir(projectRoot);
2804
3209
  const frontendDir = findFrontendDir(projectRoot);
2805
3210
  const backendOnly = args["--backend-only"] || false;
@@ -3877,7 +4282,7 @@ async function buildBundle(options) {
3877
4282
  console.log(chalk.yellow(` ⚠ ${unusedEntry} is not the bundle's entry point — it is not compiled or shipped.`));
3878
4283
  console.log(chalk.dim(` The runtime boots the bundle itself and mounts ${compiled}.`));
3879
4284
  console.log(chalk.dim(` Routes defined there will not exist once deployed: move them to ${paths.functions}/,`));
3880
- 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."));
3881
4286
  }
3882
4287
  log(options, chalk.dim(` compiling ${includes.length} source group(s) → ${path.relative(projectRoot, outDir)}/`));
3883
4288
  cleanOutDir(projectRoot, outDir);
@@ -4305,7 +4710,7 @@ async function foldFrontendIntoBundle(options) {
4305
4710
  * entrypoint, falls back to the previous behaviour: run every workspace's own
4306
4711
  * `build` script. Nothing that built before stops building.
4307
4712
  */
4308
- function printHelp$4() {
4713
+ function printHelp$5() {
4309
4714
  console.log(`
4310
4715
  ${chalk.bold("rebase build")} — build the apps declared in rebase.json
4311
4716
 
@@ -4340,7 +4745,7 @@ async function buildCommand(rawArgs = []) {
4340
4745
  permissive: true
4341
4746
  });
4342
4747
  if (args["--help"]) {
4343
- printHelp$4();
4748
+ printHelp$5();
4344
4749
  return;
4345
4750
  }
4346
4751
  const projectRoot = requireProjectRoot();
@@ -4579,7 +4984,7 @@ function projectNameOf(projectRoot) {
4579
4984
  } catch {}
4580
4985
  return path.basename(projectRoot);
4581
4986
  }
4582
- function printHelp$3() {
4987
+ function printHelp$4() {
4583
4988
  console.log(`
4584
4989
  ${chalk.bold("rebase eject")} — take ownership of the server process
4585
4990
 
@@ -4606,7 +5011,7 @@ async function ejectCommand(rawArgs = []) {
4606
5011
  permissive: true
4607
5012
  });
4608
5013
  if (args["--help"]) {
4609
- printHelp$3();
5014
+ printHelp$4();
4610
5015
  return;
4611
5016
  }
4612
5017
  const projectRoot = requireProjectRoot();
@@ -4750,7 +5155,7 @@ function restoreBackendScripts(projectRoot) {
4750
5155
  * `rebase.json`) this falls back to the backend workspace's own `start` script,
4751
5156
  * which is what such a project has always used.
4752
5157
  */
4753
- function printHelp$2() {
5158
+ function printHelp$3() {
4754
5159
  console.log(`
4755
5160
  ${chalk.bold("rebase start")} — run a built bundle
4756
5161
 
@@ -4776,7 +5181,7 @@ async function startCommand(rawArgs = []) {
4776
5181
  permissive: true
4777
5182
  });
4778
5183
  if (args["--help"]) {
4779
- printHelp$2();
5184
+ printHelp$3();
4780
5185
  return;
4781
5186
  }
4782
5187
  const projectRoot = requireProjectRoot();
@@ -5640,6 +6045,107 @@ ${chalk.green.bold("Examples")}
5640
6045
  `);
5641
6046
  }
5642
6047
  //#endregion
6048
+ //#region src/commands/telemetry.ts
6049
+ /**
6050
+ * `rebase telemetry` — the command that makes the rest of it inspectable.
6051
+ *
6052
+ * The whole subsystem asks for trust it cannot otherwise earn, and the cheapest
6053
+ * way to earn it is to stop describing the payload and print it. `show` runs
6054
+ * the same builder the sender uses, so what appears here is what would go, not
6055
+ * a documentation comment that quietly fell out of date two releases ago.
6056
+ */
6057
+ async function telemetryCommand(rawArgs) {
6058
+ switch (rawArgs.slice(3).filter((a) => !a.startsWith("-"))[0]) {
6059
+ case "status":
6060
+ case void 0:
6061
+ printStatus();
6062
+ return;
6063
+ case "show":
6064
+ printPayload();
6065
+ return;
6066
+ case "enable":
6067
+ if (!setConsent(true)) {
6068
+ console.error(chalk.red(`Could not write ${configPath()} — sharing was not enabled.`));
6069
+ process.exitCode = 1;
6070
+ return;
6071
+ }
6072
+ console.log(chalk.green("Anonymous usage sharing enabled."));
6073
+ console.log(chalk.gray(`Inspect what gets sent with ${chalk.cyan("rebase telemetry show")}.`));
6074
+ return;
6075
+ case "disable":
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
+ }
6082
+ console.log(chalk.gray("Anonymous usage sharing disabled. Nothing further will be sent."));
6083
+ return;
6084
+ default:
6085
+ printHelp$2();
6086
+ process.exitCode = 1;
6087
+ }
6088
+ }
6089
+ function printStatus() {
6090
+ console.log("");
6091
+ console.log(` Status: ${describeState()}`);
6092
+ if (readProjectPolicy() === "ignored_opt_in") {
6093
+ console.log("");
6094
+ console.log(chalk.yellow(" Note: this project's rebase.json sets \"telemetry\": true, which is ignored."));
6095
+ console.log(chalk.gray(" A committed file cannot consent on behalf of everyone who clones it."));
6096
+ console.log(chalk.gray(` Only "telemetry": false is honoured there. Use ${chalk.cyan("rebase telemetry enable")}.`));
6097
+ }
6098
+ console.log(` Endpoint: ${chalk.gray(endpoint())}`);
6099
+ console.log(` Config: ${chalk.gray(configPath())}`);
6100
+ console.log("");
6101
+ console.log(chalk.gray(` ${chalk.cyan("rebase telemetry show")} prints the exact payload.`));
6102
+ console.log("");
6103
+ }
6104
+ function printPayload() {
6105
+ if (readConfig().enabled !== true) {
6106
+ console.log("");
6107
+ console.log(` ${describeState()}`);
6108
+ console.log("");
6109
+ console.log(chalk.gray(" Nothing is being sent, so there is no payload to show."));
6110
+ console.log(chalk.gray(` Run ${chalk.cyan("rebase telemetry enable")} first if you want to inspect one.`));
6111
+ console.log("");
6112
+ return;
6113
+ }
6114
+ const event = previewEvent("cli.dev", { first_run: false }, process.cwd());
6115
+ console.log("");
6116
+ console.log(chalk.gray(" Sent to ") + chalk.gray(endpoint()) + chalk.gray(", for example:"));
6117
+ console.log("");
6118
+ console.log(JSON.stringify(event, null, 2).split("\n").map((l) => " " + l).join("\n"));
6119
+ console.log("");
6120
+ console.log(chalk.gray(" Both ids are random. Neither is derived from your machine, your"));
6121
+ console.log(chalk.gray(" hostname, your project name or anything you have typed."));
6122
+ console.log("");
6123
+ }
6124
+ function printHelp$2() {
6125
+ console.log(`
6126
+ ${chalk.bold("rebase telemetry")} — anonymous usage sharing (opt-in, off by default)
6127
+
6128
+ ${chalk.bold("Commands")}
6129
+ ${chalk.blue("status")} Whether anything is being shared, and why ${chalk.gray("(default)")}
6130
+ ${chalk.blue("show")} Print the exact payload that would be sent
6131
+ ${chalk.blue("enable")} Start sharing
6132
+ ${chalk.blue("disable")} Stop sharing, permanently
6133
+
6134
+ ${chalk.bold("Project policy")}
6135
+ ${chalk.blue("\"telemetry\": false")} in ${chalk.blue("rebase.json")} disables sharing for everyone who
6136
+ clones the repository, overriding each developer's own choice.
6137
+ ${chalk.gray("\"telemetry\": true is ignored — a committed file cannot consent for others.")}
6138
+
6139
+ ${chalk.bold("Environment")}
6140
+ ${chalk.blue("DO_NOT_TRACK")} Set to disable, across every tool that honours it
6141
+ ${chalk.blue("REBASE_TELEMETRY_DISABLED")} Set to disable Rebase specifically
6142
+ ${chalk.blue("REBASE_TELEMETRY_ENDPOINT")} Send elsewhere — your own collector, for instance
6143
+
6144
+ ${chalk.gray("Never shared: project names, paths, collection or table names, database")}
6145
+ ${chalk.gray("URLs, hostnames, error messages, stack traces, or exact record counts.")}
6146
+ `);
6147
+ }
6148
+ //#endregion
5643
6149
  //#region src/commands/cloud/auth.ts
5644
6150
  /**
5645
6151
  * `rebase cloud` auth subcommands: login, logout, whoami.
@@ -5932,6 +6438,54 @@ function providerDefaults(provider) {
5932
6438
  };
5933
6439
  }
5934
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
+ }
5935
6489
  async function createProject(rawArgs) {
5936
6490
  const args = arg({
5937
6491
  "--name": String,
@@ -5967,9 +6521,10 @@ async function createProject(rawArgs) {
5967
6521
  const subdomain = (args["--subdomain"] || a.subdomain || "").trim().toLowerCase();
5968
6522
  const gitRepoUrl = (args["--repo"] || a.repo || "").trim();
5969
6523
  const gitBranch = (args["--branch"] || a.branch || "main").trim();
5970
- 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;
5971
6526
  const defaults = providerDefaults(provider);
5972
- const region = (args["--region"] || defaults.region).trim();
6527
+ const region = (args["--region"] || target.region || defaults.region).trim();
5973
6528
  const vmSize = (args["--vm-size"] || defaults.vmSize).trim();
5974
6529
  if (!name || !subdomain) fail("Name and subdomain are required.");
5975
6530
  try {
@@ -10180,7 +10735,8 @@ async function entry(args) {
10180
10735
  "cloud",
10181
10736
  "apps",
10182
10737
  "eject",
10183
- "generate-sdk"
10738
+ "generate-sdk",
10739
+ "telemetry"
10184
10740
  ].includes(command)) {
10185
10741
  printHelp();
10186
10742
  return;
@@ -10251,6 +10807,9 @@ async function entry(args) {
10251
10807
  case "cloud":
10252
10808
  await cloudCommand(effectiveSubcommand, args);
10253
10809
  break;
10810
+ case "telemetry":
10811
+ await telemetryCommand(args);
10812
+ break;
10254
10813
  default:
10255
10814
  console.error(chalk.red(`Unknown command: ${command}`));
10256
10815
  console.log("");
@@ -10301,6 +10860,7 @@ ${chalk.green.bold("API Keys")}
10301
10860
  ${chalk.blue.bold("api-keys list")} List all service API keys
10302
10861
  ${chalk.blue.bold("api-keys create")} Create a new scoped API key
10303
10862
  ${chalk.blue.bold("api-keys revoke")} Revoke an existing API key
10863
+ ${chalk.blue.bold("telemetry")} Anonymous usage sharing (opt-in, off by default)
10304
10864
  ${chalk.blue.bold("api-keys")} ${chalk.gray("--help")} Show API key command help
10305
10865
 
10306
10866
  ${chalk.green.bold("Rebase Cloud")}
@@ -10314,7 +10874,23 @@ ${chalk.green.bold("Options")}
10314
10874
  ${chalk.blue("--help, -h")} Show this help message
10315
10875
 
10316
10876
  ${chalk.gray("Documentation: https://rebase.pro/docs")}
10317
- `);
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`);
10318
10894
  }
10319
10895
  //#endregion
10320
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 };