@rebasepro/cli 0.12.1-canary.g4e7bcbf → 0.12.1-canary.g6861540
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/commands/telemetry.d.ts +9 -0
- package/dist/index.es.js +520 -11
- package/dist/index.es.js.map +1 -1
- package/dist/telemetry/consent.d.ts +38 -0
- package/dist/telemetry/identity.d.ts +69 -0
- package/dist/telemetry/index.d.ts +72 -0
- package/dist/telemetry/payload.d.ts +78 -0
- package/dist/telemetry/project.d.ts +34 -0
- package/package.json +7 -7
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `rebase telemetry` — the command that makes the rest of it inspectable.
|
|
3
|
+
*
|
|
4
|
+
* The whole subsystem asks for trust it cannot otherwise earn, and the cheapest
|
|
5
|
+
* way to earn it is to stop describing the payload and print it. `show` runs
|
|
6
|
+
* the same builder the sender uses, so what appears here is what would go, not
|
|
7
|
+
* a documentation comment that quietly fell out of date two releases ago.
|
|
8
|
+
*/
|
|
9
|
+
export declare function telemetryCommand(rawArgs: string[]): Promise<void>;
|
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.
|
|
@@ -888,6 +888,369 @@ function openUrl(target, label = "Opening") {
|
|
|
888
888
|
child.unref();
|
|
889
889
|
} catch {}
|
|
890
890
|
}
|
|
891
|
+
/** Duration in coarse bands — enough to see "slow", not enough to fingerprint. */
|
|
892
|
+
function durationBucket(ms) {
|
|
893
|
+
if (!Number.isFinite(ms) || ms < 0) return "unknown";
|
|
894
|
+
if (ms < 5e3) return "<5s";
|
|
895
|
+
if (ms < 3e4) return "5-30s";
|
|
896
|
+
if (ms < 12e4) return "30-120s";
|
|
897
|
+
return "120s+";
|
|
898
|
+
}
|
|
899
|
+
/**
|
|
900
|
+
* Drop anything that is not a permitted value type, and clamp strings.
|
|
901
|
+
*
|
|
902
|
+
* The last line of defence rather than the first. Every call site is supposed
|
|
903
|
+
* to pass enumerated values; this is what stops a future one that forgets from
|
|
904
|
+
* turning into an incident. Strings are capped at 64 characters because no
|
|
905
|
+
* legitimate enumerated value is longer, and a path or a message always is.
|
|
906
|
+
*/
|
|
907
|
+
/**
|
|
908
|
+
* Key names that shadow an `Object.prototype` member.
|
|
909
|
+
*
|
|
910
|
+
* `__proto__` is already excluded by the pattern (it starts with an
|
|
911
|
+
* underscore), and assigning any of these to an object literal shadows rather
|
|
912
|
+
* than pollutes — so nothing is exploitable here. They are refused because a
|
|
913
|
+
* stored property called `constructor` is a trap for every consumer downstream
|
|
914
|
+
* that reaches for `properties.constructor` and gets a string.
|
|
915
|
+
*/
|
|
916
|
+
var RESERVED_KEYS = /* @__PURE__ */ new Set([
|
|
917
|
+
"constructor",
|
|
918
|
+
"prototype",
|
|
919
|
+
"hasownproperty",
|
|
920
|
+
"tostring",
|
|
921
|
+
"valueof"
|
|
922
|
+
]);
|
|
923
|
+
function sanitize(properties) {
|
|
924
|
+
const out = {};
|
|
925
|
+
for (const [key, value] of Object.entries(properties)) {
|
|
926
|
+
if (!/^[a-z][a-z0-9_]{0,31}$/.test(key) || RESERVED_KEYS.has(key.toLowerCase())) continue;
|
|
927
|
+
if (typeof value === "boolean" || typeof value === "number" && Number.isFinite(value)) out[key] = value;
|
|
928
|
+
else if (typeof value === "string" && value.length > 0 && value.length <= 64) {
|
|
929
|
+
if (!/[/\\@:\s]/.test(value)) out[key] = value;
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
return out;
|
|
933
|
+
}
|
|
934
|
+
function cliVersion() {
|
|
935
|
+
try {
|
|
936
|
+
const pkg = createRequire(import.meta.url)("../../package.json");
|
|
937
|
+
return typeof pkg?.version === "string" ? pkg.version : "unknown";
|
|
938
|
+
} catch {
|
|
939
|
+
return "unknown";
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
function buildEvent(event, properties, identity) {
|
|
943
|
+
return {
|
|
944
|
+
schema: 1,
|
|
945
|
+
event,
|
|
946
|
+
machineId: identity.machineId,
|
|
947
|
+
projectId: identity.projectId,
|
|
948
|
+
cliVersion: cliVersion(),
|
|
949
|
+
nodeMajor: Number(process.versions.node.split(".")[0]) || 0,
|
|
950
|
+
platform: os.platform(),
|
|
951
|
+
arch: os.arch(),
|
|
952
|
+
at: (/* @__PURE__ */ new Date()).toISOString().replace(/\.\d{3}Z$/, "Z"),
|
|
953
|
+
properties: sanitize(properties)
|
|
954
|
+
};
|
|
955
|
+
}
|
|
956
|
+
function configPath() {
|
|
957
|
+
return path.join(os.homedir(), ".rebase", "telemetry.json");
|
|
958
|
+
}
|
|
959
|
+
function readConfig() {
|
|
960
|
+
try {
|
|
961
|
+
const raw = JSON.parse(fs.readFileSync(configPath(), "utf-8"));
|
|
962
|
+
if (raw && typeof raw === "object") return {
|
|
963
|
+
version: typeof raw.version === "number" ? raw.version : 1,
|
|
964
|
+
enabled: typeof raw.enabled === "boolean" ? raw.enabled : void 0,
|
|
965
|
+
machineId: typeof raw.machineId === "string" ? raw.machineId : void 0,
|
|
966
|
+
decidedAt: typeof raw.decidedAt === "string" ? raw.decidedAt : void 0
|
|
967
|
+
};
|
|
968
|
+
} catch {}
|
|
969
|
+
return { version: 1 };
|
|
970
|
+
}
|
|
971
|
+
function writeConfig(config) {
|
|
972
|
+
const file = configPath();
|
|
973
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
974
|
+
fs.writeFileSync(file, JSON.stringify({
|
|
975
|
+
...config,
|
|
976
|
+
version: 1
|
|
977
|
+
}, null, 2), {
|
|
978
|
+
encoding: "utf-8",
|
|
979
|
+
mode: 384
|
|
980
|
+
});
|
|
981
|
+
}
|
|
982
|
+
/**
|
|
983
|
+
* The machine's id, generating and persisting one on first use.
|
|
984
|
+
*
|
|
985
|
+
* Only called once consent exists — an id written before the user agreed would
|
|
986
|
+
* be a record we had no right to create, even unsent.
|
|
987
|
+
*/
|
|
988
|
+
function ensureMachineId() {
|
|
989
|
+
const config = readConfig();
|
|
990
|
+
if (config.machineId) return config.machineId;
|
|
991
|
+
const machineId = crypto.randomUUID();
|
|
992
|
+
writeConfig({
|
|
993
|
+
...config,
|
|
994
|
+
machineId
|
|
995
|
+
});
|
|
996
|
+
return machineId;
|
|
997
|
+
}
|
|
998
|
+
/**
|
|
999
|
+
* The checkout's id, generating one if this project has none.
|
|
1000
|
+
*
|
|
1001
|
+
* The directory is created when it is missing, but **only** where a
|
|
1002
|
+
* `rebase.json` proves this really is a project root. An earlier version
|
|
1003
|
+
* required `.rebase/` to already exist, on the assumption that `rebase init`
|
|
1004
|
+
* created it — it does not. It is written only when a scaffold is linked to
|
|
1005
|
+
* Rebase Cloud, so every self-hosted project reported no `projectId` at all,
|
|
1006
|
+
* for ever. That silently broke the funnel this id exists for, on exactly the
|
|
1007
|
+
* population the telemetry is meant to learn about.
|
|
1008
|
+
*
|
|
1009
|
+
* The `rebase.json` check is what keeps the fix from being "scatter `.rebase/`
|
|
1010
|
+
* wherever a command happens to run": no manifest, no project, no directory,
|
|
1011
|
+
* and the event simply reports no project.
|
|
1012
|
+
*/
|
|
1013
|
+
function ensureProjectId(projectRoot) {
|
|
1014
|
+
if (!fs.existsSync(path.join(projectRoot, "rebase.json"))) return void 0;
|
|
1015
|
+
const dir = path.join(projectRoot, ".rebase");
|
|
1016
|
+
try {
|
|
1017
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
1018
|
+
} catch {
|
|
1019
|
+
return;
|
|
1020
|
+
}
|
|
1021
|
+
const file = path.join(dir, "state.json");
|
|
1022
|
+
let state = {};
|
|
1023
|
+
if (fs.existsSync(file)) try {
|
|
1024
|
+
const raw = JSON.parse(fs.readFileSync(file, "utf-8"));
|
|
1025
|
+
if (raw && typeof raw === "object") state = raw;
|
|
1026
|
+
} catch {
|
|
1027
|
+
return;
|
|
1028
|
+
}
|
|
1029
|
+
if (typeof state.telemetryProjectId === "string") return state.telemetryProjectId;
|
|
1030
|
+
const projectId = crypto.randomUUID();
|
|
1031
|
+
try {
|
|
1032
|
+
fs.writeFileSync(file, JSON.stringify({
|
|
1033
|
+
...state,
|
|
1034
|
+
telemetryProjectId: projectId
|
|
1035
|
+
}, null, 2), "utf-8");
|
|
1036
|
+
} catch {
|
|
1037
|
+
return;
|
|
1038
|
+
}
|
|
1039
|
+
return projectId;
|
|
1040
|
+
}
|
|
1041
|
+
//#endregion
|
|
1042
|
+
//#region src/telemetry/project.ts
|
|
1043
|
+
function readProjectPolicy(startDir = process.cwd()) {
|
|
1044
|
+
try {
|
|
1045
|
+
const root = findProjectRoot(startDir);
|
|
1046
|
+
if (!root) return "unset";
|
|
1047
|
+
const file = path.join(root, MANIFEST_FILENAME);
|
|
1048
|
+
if (!fs.existsSync(file)) return "unset";
|
|
1049
|
+
const raw = fs.readFileSync(file, "utf-8");
|
|
1050
|
+
const match = /"telemetry"\s*:\s*(true|false)/.exec(raw);
|
|
1051
|
+
if (!match) return "unset";
|
|
1052
|
+
return match[1] === "false" ? "opt_out" : "ignored_opt_in";
|
|
1053
|
+
} catch {
|
|
1054
|
+
return "unset";
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
function endpoint() {
|
|
1058
|
+
return process.env.REBASE_TELEMETRY_ENDPOINT?.trim() || "https://app.rebase.pro/api/functions/telemetry";
|
|
1059
|
+
}
|
|
1060
|
+
/**
|
|
1061
|
+
* The one function that decides whether anything leaves the machine.
|
|
1062
|
+
*
|
|
1063
|
+
* Every path is a refusal except the last, which is the point: consent is
|
|
1064
|
+
* opt-in, so the default answer at every branch — never asked, unreadable
|
|
1065
|
+
* config, a set env var, a CI runner — is no.
|
|
1066
|
+
*
|
|
1067
|
+
* `DO_NOT_TRACK` is honoured because it is the cross-tool convention
|
|
1068
|
+
* (consoledonottrack.com); a user who has set it globally has already answered
|
|
1069
|
+
* this question and should not be asked again by us.
|
|
1070
|
+
*
|
|
1071
|
+
* `CI` is refused for a different reason: a build runner is not a person, and
|
|
1072
|
+
* counting one is both useless and misleading. A single pipeline re-running on
|
|
1073
|
+
* every push would otherwise outweigh every real developer in the data.
|
|
1074
|
+
*
|
|
1075
|
+
* A project's own `"telemetry": false` is checked *before* the machine setting,
|
|
1076
|
+
* because it has to beat an individual opt-in to be worth anything — see
|
|
1077
|
+
* project.ts on why the reverse is refused.
|
|
1078
|
+
*/
|
|
1079
|
+
function suppressionReason(env = process.env, cwd = process.cwd()) {
|
|
1080
|
+
if (env.DO_NOT_TRACK && env.DO_NOT_TRACK !== "0") return "do_not_track";
|
|
1081
|
+
if (env.REBASE_TELEMETRY_DISABLED && env.REBASE_TELEMETRY_DISABLED !== "0") return "rebase_telemetry_disabled";
|
|
1082
|
+
if (env.CI && env.CI !== "false") return "ci";
|
|
1083
|
+
if (readProjectPolicy(cwd) === "opt_out") return "project_opt_out";
|
|
1084
|
+
const config = readConfig();
|
|
1085
|
+
if (config.enabled === void 0) return "not_asked";
|
|
1086
|
+
if (!config.enabled) return "declined";
|
|
1087
|
+
return null;
|
|
1088
|
+
}
|
|
1089
|
+
function isEnabled(env = process.env, cwd = process.cwd()) {
|
|
1090
|
+
return suppressionReason(env, cwd) === null;
|
|
1091
|
+
}
|
|
1092
|
+
/**
|
|
1093
|
+
* Record the user's answer. `false` is final — nothing prompts again.
|
|
1094
|
+
*
|
|
1095
|
+
* Returns whether the choice could actually be persisted. A read-only or full
|
|
1096
|
+
* home directory makes `writeConfig` throw, and the direction of that failure
|
|
1097
|
+
* matters enormously: someone turning sharing **off** who sees a stack trace,
|
|
1098
|
+
* or worse sees nothing, is left sharing. The caller is expected to say so and
|
|
1099
|
+
* point at `REBASE_TELEMETRY_DISABLED`, which needs no disk.
|
|
1100
|
+
*/
|
|
1101
|
+
function setConsent(enabled) {
|
|
1102
|
+
const config = readConfig();
|
|
1103
|
+
try {
|
|
1104
|
+
writeConfig({
|
|
1105
|
+
...config,
|
|
1106
|
+
enabled,
|
|
1107
|
+
decidedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1108
|
+
machineId: enabled ? config.machineId ?? void 0 : void 0
|
|
1109
|
+
});
|
|
1110
|
+
if (enabled) ensureMachineId();
|
|
1111
|
+
return true;
|
|
1112
|
+
} catch {
|
|
1113
|
+
return false;
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
/**
|
|
1117
|
+
* Build the event that *would* be sent, without sending it.
|
|
1118
|
+
*
|
|
1119
|
+
* This is what `rebase telemetry show` prints, and it is deliberately the same
|
|
1120
|
+
* function the sender uses — a preview assembled by separate code is a promise
|
|
1121
|
+
* that drifts. Returns `null` when nothing would be sent, so the command can
|
|
1122
|
+
* say why instead of showing a payload that is never going anywhere.
|
|
1123
|
+
*/
|
|
1124
|
+
function previewEvent(event, properties = {}, projectRoot) {
|
|
1125
|
+
const config = readConfig();
|
|
1126
|
+
if (!config.machineId) return null;
|
|
1127
|
+
return buildEvent(event, properties, {
|
|
1128
|
+
machineId: config.machineId,
|
|
1129
|
+
projectId: projectRoot ? ensureProjectId(projectRoot) : void 0
|
|
1130
|
+
});
|
|
1131
|
+
}
|
|
1132
|
+
/**
|
|
1133
|
+
* Send one event, or quietly do nothing.
|
|
1134
|
+
*
|
|
1135
|
+
* Three properties this must hold, in order of importance:
|
|
1136
|
+
*
|
|
1137
|
+
* 1. **It never throws.** Telemetry failing is not a reason for `rebase dev`
|
|
1138
|
+
* to fail. Every error is swallowed.
|
|
1139
|
+
* 2. **It never blocks meaningfully.** A two-second ceiling, after which the
|
|
1140
|
+
* command carries on regardless — a collector having a bad day must not
|
|
1141
|
+
* become the CLI hanging.
|
|
1142
|
+
* 3. **It never sends without consent.** Enforced here rather than at the call
|
|
1143
|
+
* sites, so a new call site cannot get it wrong.
|
|
1144
|
+
*/
|
|
1145
|
+
async function recordEvent(event, properties = {}, options = {}) {
|
|
1146
|
+
try {
|
|
1147
|
+
if (!isEnabled(process.env, options.projectRoot ?? process.cwd())) return;
|
|
1148
|
+
const payload = buildEvent(event, properties, {
|
|
1149
|
+
machineId: ensureMachineId(),
|
|
1150
|
+
projectId: options.projectRoot ? ensureProjectId(options.projectRoot) : void 0
|
|
1151
|
+
});
|
|
1152
|
+
const abort = new AbortController();
|
|
1153
|
+
const timer = setTimeout(() => abort.abort(), options.timeoutMs ?? 2e3);
|
|
1154
|
+
try {
|
|
1155
|
+
await fetch(endpoint(), {
|
|
1156
|
+
method: "POST",
|
|
1157
|
+
headers: { "Content-Type": "application/json" },
|
|
1158
|
+
body: JSON.stringify(payload),
|
|
1159
|
+
signal: abort.signal
|
|
1160
|
+
});
|
|
1161
|
+
} finally {
|
|
1162
|
+
clearTimeout(timer);
|
|
1163
|
+
}
|
|
1164
|
+
} catch {}
|
|
1165
|
+
}
|
|
1166
|
+
//#endregion
|
|
1167
|
+
//#region src/telemetry/consent.ts
|
|
1168
|
+
/**
|
|
1169
|
+
* Asking, and what the question looks like.
|
|
1170
|
+
*
|
|
1171
|
+
* ## Why the prompt comes *after* the work
|
|
1172
|
+
*
|
|
1173
|
+
* The first `rebase init` on a machine is the event most worth having, and it
|
|
1174
|
+
* is the one where no consent exists yet. Asking before scaffolding puts a
|
|
1175
|
+
* privacy negotiation in front of someone who has not yet seen the tool do
|
|
1176
|
+
* anything — the worst possible moment, and a reliable way to get a reflexive
|
|
1177
|
+
* no.
|
|
1178
|
+
*
|
|
1179
|
+
* So the question is asked once the project exists and the user has seen it
|
|
1180
|
+
* work. The event's data is still in memory at that point, so nothing is lost
|
|
1181
|
+
* by waiting, and — this is the part that matters — **nothing has been
|
|
1182
|
+
* transmitted or written**. Declining leaves no id, no file, no record.
|
|
1183
|
+
*
|
|
1184
|
+
* ## Why the payload is shown rather than described
|
|
1185
|
+
*
|
|
1186
|
+
* "Anonymous usage data" is a phrase that has been used to mean almost
|
|
1187
|
+
* anything. Printing the exact JSON costs four lines of output and replaces a
|
|
1188
|
+
* claim the user has to take on faith with something they can read. It is also
|
|
1189
|
+
* the same builder the sender uses, so it cannot drift into a comfortable
|
|
1190
|
+
* fiction.
|
|
1191
|
+
*/
|
|
1192
|
+
/** True when we may ask: no decision recorded, and nothing else forbids it. */
|
|
1193
|
+
function shouldPrompt(env = process.env) {
|
|
1194
|
+
return suppressionReason(env) === "not_asked" && Boolean(process.stdin.isTTY);
|
|
1195
|
+
}
|
|
1196
|
+
function renderPreview(event, properties) {
|
|
1197
|
+
const preview = buildEvent(event, properties, {
|
|
1198
|
+
machineId: "<random uuid, generated only if you say yes>",
|
|
1199
|
+
projectId: "<random uuid, per checkout>"
|
|
1200
|
+
});
|
|
1201
|
+
return JSON.stringify(preview, null, 2);
|
|
1202
|
+
}
|
|
1203
|
+
/**
|
|
1204
|
+
* Ask, record the answer, and report it.
|
|
1205
|
+
*
|
|
1206
|
+
* Never throws and never blocks a non-interactive run: `rebase init --yes` in
|
|
1207
|
+
* CI must behave exactly as it does today, which means not asking and not
|
|
1208
|
+
* sending.
|
|
1209
|
+
*/
|
|
1210
|
+
async function promptForConsent(event, properties) {
|
|
1211
|
+
if (!shouldPrompt()) return false;
|
|
1212
|
+
try {
|
|
1213
|
+
console.log("");
|
|
1214
|
+
console.log(chalk.bold("Help improve Rebase?"));
|
|
1215
|
+
console.log("");
|
|
1216
|
+
console.log(chalk.gray(" Rebase is self-hosted, so we have no idea what works and what does not"));
|
|
1217
|
+
console.log(chalk.gray(" unless you tell us. Sharing is entirely optional and off by default."));
|
|
1218
|
+
console.log("");
|
|
1219
|
+
console.log(chalk.gray(" This is exactly what would be sent — nothing more, ever:"));
|
|
1220
|
+
console.log("");
|
|
1221
|
+
console.log(renderPreview(event, properties).split("\n").map((line) => chalk.gray(" " + line)).join("\n"));
|
|
1222
|
+
console.log("");
|
|
1223
|
+
console.log(chalk.gray(" No project names, paths, schemas, URLs or error messages. Change your"));
|
|
1224
|
+
console.log(chalk.gray(` mind any time with ${chalk.cyan("rebase telemetry disable")}.`));
|
|
1225
|
+
console.log("");
|
|
1226
|
+
const { accepted } = await inquirer.prompt([{
|
|
1227
|
+
type: "confirm",
|
|
1228
|
+
name: "accepted",
|
|
1229
|
+
message: "Share anonymous usage data?",
|
|
1230
|
+
default: false
|
|
1231
|
+
}]);
|
|
1232
|
+
setConsent(Boolean(accepted));
|
|
1233
|
+
console.log(accepted ? chalk.green(" Thank you — sharing enabled.") : chalk.gray(" Nothing will be sent. You will not be asked again."));
|
|
1234
|
+
console.log("");
|
|
1235
|
+
return Boolean(accepted);
|
|
1236
|
+
} catch {
|
|
1237
|
+
return false;
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
1240
|
+
/** Human-readable current state, for `rebase telemetry status`. */
|
|
1241
|
+
function describeState(env = process.env) {
|
|
1242
|
+
const reason = suppressionReason(env);
|
|
1243
|
+
const config = readConfig();
|
|
1244
|
+
switch (reason) {
|
|
1245
|
+
case null: return `${chalk.green("enabled")} — schema v1, machine id ${chalk.gray(config.machineId ?? "unset")}`;
|
|
1246
|
+
case "not_asked": return `${chalk.yellow("not configured")} — nothing has been sent, and you have not been asked yet`;
|
|
1247
|
+
case "declined": return `${chalk.gray("disabled")} — you declined${config.decidedAt ? ` on ${config.decidedAt.slice(0, 10)}` : ""}`;
|
|
1248
|
+
case "do_not_track": return `${chalk.gray("disabled")} — the ${chalk.cyan("DO_NOT_TRACK")} environment variable is set`;
|
|
1249
|
+
case "rebase_telemetry_disabled": return `${chalk.gray("disabled")} — the ${chalk.cyan("REBASE_TELEMETRY_DISABLED")} environment variable is set`;
|
|
1250
|
+
case "ci": return `${chalk.gray("disabled")} — this looks like CI (${chalk.cyan("CI")} is set), which is never counted`;
|
|
1251
|
+
case "project_opt_out": return `${chalk.gray("disabled")} — this project's ${chalk.cyan("rebase.json")} sets ${chalk.cyan("\"telemetry\": false")}`;
|
|
1252
|
+
}
|
|
1253
|
+
}
|
|
891
1254
|
//#endregion
|
|
892
1255
|
//#region src/commands/init.ts
|
|
893
1256
|
var access = promisify(fs.access);
|
|
@@ -1226,6 +1589,7 @@ async function linkScaffoldToCloud(options) {
|
|
|
1226
1589
|
}
|
|
1227
1590
|
}
|
|
1228
1591
|
async function createProject$1(options) {
|
|
1592
|
+
const startedAt = Date.now();
|
|
1229
1593
|
if (fs.existsSync(options.targetDirectory)) {
|
|
1230
1594
|
if (fs.readdirSync(options.targetDirectory).length !== 0) {
|
|
1231
1595
|
console.error(`${chalk.red.bold("ERROR")} Directory "${options.projectName}" already exists and is not empty`);
|
|
@@ -1418,6 +1782,18 @@ async function createProject$1(options) {
|
|
|
1418
1782
|
console.log("");
|
|
1419
1783
|
console.log(` ${chalk.cyan("rebase skills install")} ${chalk.gray("or")} ${chalk.cyan(pmCommands.run("skills:install").join(" "))}`);
|
|
1420
1784
|
console.log("");
|
|
1785
|
+
const initProperties = {
|
|
1786
|
+
preset: options.headless ? "none" : options.preset,
|
|
1787
|
+
headless: Boolean(options.headless),
|
|
1788
|
+
package_manager: options.pm,
|
|
1789
|
+
installed_deps: Boolean(options.installDeps),
|
|
1790
|
+
introspected,
|
|
1791
|
+
own_database: Boolean(options.databaseUrl),
|
|
1792
|
+
cloud_linked: Boolean(options.cloudProject),
|
|
1793
|
+
git: Boolean(options.git),
|
|
1794
|
+
duration: durationBucket(Date.now() - startedAt)
|
|
1795
|
+
};
|
|
1796
|
+
if (await promptForConsent("cli.init", initProperties)) await recordEvent("cli.init", initProperties, { projectRoot: options.targetDirectory });
|
|
1421
1797
|
}
|
|
1422
1798
|
/**
|
|
1423
1799
|
* Apply a template preset by replacing the default collection files.
|
|
@@ -1565,7 +1941,11 @@ async function replacePlaceholders(options) {
|
|
|
1565
1941
|
const prereleasePins = [...unreleased].filter(([, version]) => version === "latest" || version.includes("-"));
|
|
1566
1942
|
if (cliIsStable && prereleasePins.length > 0) {
|
|
1567
1943
|
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
|
|
1944
|
+
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 —
|
|
1945
|
+
not a problem with your machine, your network, or your package manager.
|
|
1946
|
+
|
|
1947
|
+
Stopped before writing dependency versions or installing anything. The
|
|
1948
|
+
project directory ${path.basename(options.targetDirectory)}/ was created and is safe to delete.\nPlease report this with the list above.`);
|
|
1569
1949
|
}
|
|
1570
1950
|
for (const [fullPath, originalContent] of fileContents.entries()) {
|
|
1571
1951
|
let content = originalContent.replace(/\{\{PROJECT_NAME\}\}/g, options.projectName);
|
|
@@ -1969,6 +2349,7 @@ async function schemaCommand(subcommand, rawArgs) {
|
|
|
1969
2349
|
return;
|
|
1970
2350
|
}
|
|
1971
2351
|
const projectRoot = requireProjectRoot();
|
|
2352
|
+
recordEvent("cli.schema_generate", { subcommand: subcommand ?? "none" }, { projectRoot });
|
|
1972
2353
|
const backendDir = requireBackendDir(projectRoot);
|
|
1973
2354
|
const activePlugin = getActiveBackendPlugin(backendDir);
|
|
1974
2355
|
if (!activePlugin) {
|
|
@@ -2037,6 +2418,7 @@ async function dbCommand(subcommand, rawArgs) {
|
|
|
2037
2418
|
return;
|
|
2038
2419
|
}
|
|
2039
2420
|
const projectRoot = requireProjectRoot();
|
|
2421
|
+
recordEvent("cli.db_push", { subcommand: subcommand ?? "none" }, { projectRoot });
|
|
2040
2422
|
const backendDir = requireBackendDir(projectRoot);
|
|
2041
2423
|
const activePlugin = getActiveBackendPlugin(backendDir);
|
|
2042
2424
|
if (!activePlugin) {
|
|
@@ -2800,6 +3182,11 @@ async function devCommand(rawArgs) {
|
|
|
2800
3182
|
return;
|
|
2801
3183
|
}
|
|
2802
3184
|
const projectRoot = requireProjectRoot();
|
|
3185
|
+
recordEvent("cli.dev", {
|
|
3186
|
+
backend_only: Boolean(args["--backend-only"]),
|
|
3187
|
+
frontend_only: Boolean(args["--frontend-only"]),
|
|
3188
|
+
generate: Boolean(args["--generate"])
|
|
3189
|
+
}, { projectRoot });
|
|
2803
3190
|
const backendDir = findBackendDir(projectRoot);
|
|
2804
3191
|
const frontendDir = findFrontendDir(projectRoot);
|
|
2805
3192
|
const backendOnly = args["--backend-only"] || false;
|
|
@@ -3877,7 +4264,7 @@ async function buildBundle(options) {
|
|
|
3877
4264
|
console.log(chalk.yellow(` ⚠ ${unusedEntry} is not the bundle's entry point — it is not compiled or shipped.`));
|
|
3878
4265
|
console.log(chalk.dim(` The runtime boots the bundle itself and mounts ${compiled}.`));
|
|
3879
4266
|
console.log(chalk.dim(` Routes defined there will not exist once deployed: move them to ${paths.functions}/,`));
|
|
3880
|
-
console.log(chalk.dim(
|
|
4267
|
+
console.log(chalk.dim(" or run `rebase eject` to make this file the entrypoint and own the image."));
|
|
3881
4268
|
}
|
|
3882
4269
|
log(options, chalk.dim(` compiling ${includes.length} source group(s) → ${path.relative(projectRoot, outDir)}/`));
|
|
3883
4270
|
cleanOutDir(projectRoot, outDir);
|
|
@@ -4305,7 +4692,7 @@ async function foldFrontendIntoBundle(options) {
|
|
|
4305
4692
|
* entrypoint, falls back to the previous behaviour: run every workspace's own
|
|
4306
4693
|
* `build` script. Nothing that built before stops building.
|
|
4307
4694
|
*/
|
|
4308
|
-
function printHelp$
|
|
4695
|
+
function printHelp$5() {
|
|
4309
4696
|
console.log(`
|
|
4310
4697
|
${chalk.bold("rebase build")} — build the apps declared in rebase.json
|
|
4311
4698
|
|
|
@@ -4340,7 +4727,7 @@ async function buildCommand(rawArgs = []) {
|
|
|
4340
4727
|
permissive: true
|
|
4341
4728
|
});
|
|
4342
4729
|
if (args["--help"]) {
|
|
4343
|
-
printHelp$
|
|
4730
|
+
printHelp$5();
|
|
4344
4731
|
return;
|
|
4345
4732
|
}
|
|
4346
4733
|
const projectRoot = requireProjectRoot();
|
|
@@ -4579,7 +4966,7 @@ function projectNameOf(projectRoot) {
|
|
|
4579
4966
|
} catch {}
|
|
4580
4967
|
return path.basename(projectRoot);
|
|
4581
4968
|
}
|
|
4582
|
-
function printHelp$
|
|
4969
|
+
function printHelp$4() {
|
|
4583
4970
|
console.log(`
|
|
4584
4971
|
${chalk.bold("rebase eject")} — take ownership of the server process
|
|
4585
4972
|
|
|
@@ -4606,7 +4993,7 @@ async function ejectCommand(rawArgs = []) {
|
|
|
4606
4993
|
permissive: true
|
|
4607
4994
|
});
|
|
4608
4995
|
if (args["--help"]) {
|
|
4609
|
-
printHelp$
|
|
4996
|
+
printHelp$4();
|
|
4610
4997
|
return;
|
|
4611
4998
|
}
|
|
4612
4999
|
const projectRoot = requireProjectRoot();
|
|
@@ -4750,7 +5137,7 @@ function restoreBackendScripts(projectRoot) {
|
|
|
4750
5137
|
* `rebase.json`) this falls back to the backend workspace's own `start` script,
|
|
4751
5138
|
* which is what such a project has always used.
|
|
4752
5139
|
*/
|
|
4753
|
-
function printHelp$
|
|
5140
|
+
function printHelp$3() {
|
|
4754
5141
|
console.log(`
|
|
4755
5142
|
${chalk.bold("rebase start")} — run a built bundle
|
|
4756
5143
|
|
|
@@ -4776,7 +5163,7 @@ async function startCommand(rawArgs = []) {
|
|
|
4776
5163
|
permissive: true
|
|
4777
5164
|
});
|
|
4778
5165
|
if (args["--help"]) {
|
|
4779
|
-
printHelp$
|
|
5166
|
+
printHelp$3();
|
|
4780
5167
|
return;
|
|
4781
5168
|
}
|
|
4782
5169
|
const projectRoot = requireProjectRoot();
|
|
@@ -5640,6 +6027,107 @@ ${chalk.green.bold("Examples")}
|
|
|
5640
6027
|
`);
|
|
5641
6028
|
}
|
|
5642
6029
|
//#endregion
|
|
6030
|
+
//#region src/commands/telemetry.ts
|
|
6031
|
+
/**
|
|
6032
|
+
* `rebase telemetry` — the command that makes the rest of it inspectable.
|
|
6033
|
+
*
|
|
6034
|
+
* The whole subsystem asks for trust it cannot otherwise earn, and the cheapest
|
|
6035
|
+
* way to earn it is to stop describing the payload and print it. `show` runs
|
|
6036
|
+
* the same builder the sender uses, so what appears here is what would go, not
|
|
6037
|
+
* a documentation comment that quietly fell out of date two releases ago.
|
|
6038
|
+
*/
|
|
6039
|
+
async function telemetryCommand(rawArgs) {
|
|
6040
|
+
switch (rawArgs.slice(3).filter((a) => !a.startsWith("-"))[0]) {
|
|
6041
|
+
case "status":
|
|
6042
|
+
case void 0:
|
|
6043
|
+
printStatus();
|
|
6044
|
+
return;
|
|
6045
|
+
case "show":
|
|
6046
|
+
printPayload();
|
|
6047
|
+
return;
|
|
6048
|
+
case "enable":
|
|
6049
|
+
if (!setConsent(true)) {
|
|
6050
|
+
console.error(chalk.red(`Could not write ${configPath()} — sharing was not enabled.`));
|
|
6051
|
+
process.exitCode = 1;
|
|
6052
|
+
return;
|
|
6053
|
+
}
|
|
6054
|
+
console.log(chalk.green("Anonymous usage sharing enabled."));
|
|
6055
|
+
console.log(chalk.gray(`Inspect what gets sent with ${chalk.cyan("rebase telemetry show")}.`));
|
|
6056
|
+
return;
|
|
6057
|
+
case "disable":
|
|
6058
|
+
if (!setConsent(false)) {
|
|
6059
|
+
console.error(chalk.red(`Could not write ${configPath()} — sharing is still ON.`));
|
|
6060
|
+
console.error(chalk.yellow(`Set ${chalk.cyan("REBASE_TELEMETRY_DISABLED=1")} in your environment instead; it needs no file.`));
|
|
6061
|
+
process.exitCode = 1;
|
|
6062
|
+
return;
|
|
6063
|
+
}
|
|
6064
|
+
console.log(chalk.gray("Anonymous usage sharing disabled. Nothing further will be sent."));
|
|
6065
|
+
return;
|
|
6066
|
+
default:
|
|
6067
|
+
printHelp$2();
|
|
6068
|
+
process.exitCode = 1;
|
|
6069
|
+
}
|
|
6070
|
+
}
|
|
6071
|
+
function printStatus() {
|
|
6072
|
+
console.log("");
|
|
6073
|
+
console.log(` Status: ${describeState()}`);
|
|
6074
|
+
if (readProjectPolicy() === "ignored_opt_in") {
|
|
6075
|
+
console.log("");
|
|
6076
|
+
console.log(chalk.yellow(" Note: this project's rebase.json sets \"telemetry\": true, which is ignored."));
|
|
6077
|
+
console.log(chalk.gray(" A committed file cannot consent on behalf of everyone who clones it."));
|
|
6078
|
+
console.log(chalk.gray(` Only "telemetry": false is honoured there. Use ${chalk.cyan("rebase telemetry enable")}.`));
|
|
6079
|
+
}
|
|
6080
|
+
console.log(` Endpoint: ${chalk.gray(endpoint())}`);
|
|
6081
|
+
console.log(` Config: ${chalk.gray(configPath())}`);
|
|
6082
|
+
console.log("");
|
|
6083
|
+
console.log(chalk.gray(` ${chalk.cyan("rebase telemetry show")} prints the exact payload.`));
|
|
6084
|
+
console.log("");
|
|
6085
|
+
}
|
|
6086
|
+
function printPayload() {
|
|
6087
|
+
if (readConfig().enabled !== true) {
|
|
6088
|
+
console.log("");
|
|
6089
|
+
console.log(` ${describeState()}`);
|
|
6090
|
+
console.log("");
|
|
6091
|
+
console.log(chalk.gray(" Nothing is being sent, so there is no payload to show."));
|
|
6092
|
+
console.log(chalk.gray(` Run ${chalk.cyan("rebase telemetry enable")} first if you want to inspect one.`));
|
|
6093
|
+
console.log("");
|
|
6094
|
+
return;
|
|
6095
|
+
}
|
|
6096
|
+
const event = previewEvent("cli.dev", { first_run: false }, process.cwd());
|
|
6097
|
+
console.log("");
|
|
6098
|
+
console.log(chalk.gray(" Sent to ") + chalk.gray(endpoint()) + chalk.gray(", for example:"));
|
|
6099
|
+
console.log("");
|
|
6100
|
+
console.log(JSON.stringify(event, null, 2).split("\n").map((l) => " " + l).join("\n"));
|
|
6101
|
+
console.log("");
|
|
6102
|
+
console.log(chalk.gray(" Both ids are random. Neither is derived from your machine, your"));
|
|
6103
|
+
console.log(chalk.gray(" hostname, your project name or anything you have typed."));
|
|
6104
|
+
console.log("");
|
|
6105
|
+
}
|
|
6106
|
+
function printHelp$2() {
|
|
6107
|
+
console.log(`
|
|
6108
|
+
${chalk.bold("rebase telemetry")} — anonymous usage sharing (opt-in, off by default)
|
|
6109
|
+
|
|
6110
|
+
${chalk.bold("Commands")}
|
|
6111
|
+
${chalk.blue("status")} Whether anything is being shared, and why ${chalk.gray("(default)")}
|
|
6112
|
+
${chalk.blue("show")} Print the exact payload that would be sent
|
|
6113
|
+
${chalk.blue("enable")} Start sharing
|
|
6114
|
+
${chalk.blue("disable")} Stop sharing, permanently
|
|
6115
|
+
|
|
6116
|
+
${chalk.bold("Project policy")}
|
|
6117
|
+
${chalk.blue("\"telemetry\": false")} in ${chalk.blue("rebase.json")} disables sharing for everyone who
|
|
6118
|
+
clones the repository, overriding each developer's own choice.
|
|
6119
|
+
${chalk.gray("\"telemetry\": true is ignored — a committed file cannot consent for others.")}
|
|
6120
|
+
|
|
6121
|
+
${chalk.bold("Environment")}
|
|
6122
|
+
${chalk.blue("DO_NOT_TRACK")} Set to disable, across every tool that honours it
|
|
6123
|
+
${chalk.blue("REBASE_TELEMETRY_DISABLED")} Set to disable Rebase specifically
|
|
6124
|
+
${chalk.blue("REBASE_TELEMETRY_ENDPOINT")} Send elsewhere — your own collector, for instance
|
|
6125
|
+
|
|
6126
|
+
${chalk.gray("Never shared: project names, paths, collection or table names, database")}
|
|
6127
|
+
${chalk.gray("URLs, hostnames, error messages, stack traces, or exact record counts.")}
|
|
6128
|
+
`);
|
|
6129
|
+
}
|
|
6130
|
+
//#endregion
|
|
5643
6131
|
//#region src/commands/cloud/auth.ts
|
|
5644
6132
|
/**
|
|
5645
6133
|
* `rebase cloud` auth subcommands: login, logout, whoami.
|
|
@@ -10180,7 +10668,8 @@ async function entry(args) {
|
|
|
10180
10668
|
"cloud",
|
|
10181
10669
|
"apps",
|
|
10182
10670
|
"eject",
|
|
10183
|
-
"generate-sdk"
|
|
10671
|
+
"generate-sdk",
|
|
10672
|
+
"telemetry"
|
|
10184
10673
|
].includes(command)) {
|
|
10185
10674
|
printHelp();
|
|
10186
10675
|
return;
|
|
@@ -10251,6 +10740,9 @@ async function entry(args) {
|
|
|
10251
10740
|
case "cloud":
|
|
10252
10741
|
await cloudCommand(effectiveSubcommand, args);
|
|
10253
10742
|
break;
|
|
10743
|
+
case "telemetry":
|
|
10744
|
+
await telemetryCommand(args);
|
|
10745
|
+
break;
|
|
10254
10746
|
default:
|
|
10255
10747
|
console.error(chalk.red(`Unknown command: ${command}`));
|
|
10256
10748
|
console.log("");
|
|
@@ -10301,6 +10793,7 @@ ${chalk.green.bold("API Keys")}
|
|
|
10301
10793
|
${chalk.blue.bold("api-keys list")} List all service API keys
|
|
10302
10794
|
${chalk.blue.bold("api-keys create")} Create a new scoped API key
|
|
10303
10795
|
${chalk.blue.bold("api-keys revoke")} Revoke an existing API key
|
|
10796
|
+
${chalk.blue.bold("telemetry")} Anonymous usage sharing (opt-in, off by default)
|
|
10304
10797
|
${chalk.blue.bold("api-keys")} ${chalk.gray("--help")} Show API key command help
|
|
10305
10798
|
|
|
10306
10799
|
${chalk.green.bold("Rebase Cloud")}
|
|
@@ -10314,7 +10807,23 @@ ${chalk.green.bold("Options")}
|
|
|
10314
10807
|
${chalk.blue("--help, -h")} Show this help message
|
|
10315
10808
|
|
|
10316
10809
|
${chalk.gray("Documentation: https://rebase.pro/docs")}
|
|
10317
|
-
`);
|
|
10810
|
+
${telemetryNotice()}`);
|
|
10811
|
+
}
|
|
10812
|
+
/**
|
|
10813
|
+
* One line about usage sharing, in the global help.
|
|
10814
|
+
*
|
|
10815
|
+
* Every other tool that collects anything prints a first-run notice. Ours asks
|
|
10816
|
+
* at the end of `rebase init` — but someone who installs the CLI and never runs
|
|
10817
|
+
* `init`, or who joins a project someone else scaffolded, would otherwise never
|
|
10818
|
+
* learn the subsystem exists. This is the cheapest place to close that: the
|
|
10819
|
+
* help is what an unfamiliar user reads first.
|
|
10820
|
+
*
|
|
10821
|
+
* It states the current setting rather than a generic sentence, so it is also
|
|
10822
|
+
* the fastest answer to "is this thing on?".
|
|
10823
|
+
*/
|
|
10824
|
+
function telemetryNotice() {
|
|
10825
|
+
const sharing = isEnabled();
|
|
10826
|
+
return chalk.gray(`Usage sharing: ${sharing ? "on" : "off"} — ${chalk.cyan("rebase telemetry")} to inspect or change\n`);
|
|
10318
10827
|
}
|
|
10319
10828
|
//#endregion
|
|
10320
10829
|
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 };
|