@mostra/cli 0.0.0-stage → 0.2.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.
- package/LICENSE +202 -0
- package/README.md +18 -2
- package/dist/bin.d.ts +1 -0
- package/dist/bin.js +79 -0
- package/dist/bin.js.map +1 -0
- package/dist/bootstrap-DfEe_yPr.js +113 -0
- package/dist/bootstrap-DfEe_yPr.js.map +1 -0
- package/dist/exit-CBIf8nAU.js +79 -0
- package/dist/exit-CBIf8nAU.js.map +1 -0
- package/dist/flush-BCuWs0Zh.js +718 -0
- package/dist/flush-BCuWs0Zh.js.map +1 -0
- package/dist/index.d.ts +230 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3 -0
- package/dist/reference/cli.md +977 -0
- package/dist/reference/diagnostics.json +1264 -0
- package/dist/reference/errors/MOSTRA_A11Y_MINOR.md +23 -0
- package/dist/reference/errors/MOSTRA_A11Y_SERIOUS.md +23 -0
- package/dist/reference/errors/MOSTRA_ACTION_CANCELLED.md +23 -0
- package/dist/reference/errors/MOSTRA_ACTION_EVENT_INVALID.md +23 -0
- package/dist/reference/errors/MOSTRA_ACTION_EXPORT_INVALID.md +23 -0
- package/dist/reference/errors/MOSTRA_ACTION_INTERRUPTED.md +23 -0
- package/dist/reference/errors/MOSTRA_ACTION_RUN_NOT_FOUND.md +23 -0
- package/dist/reference/errors/MOSTRA_ACTION_THREW.md +23 -0
- package/dist/reference/errors/MOSTRA_ACTION_TOO_LARGE.md +23 -0
- package/dist/reference/errors/MOSTRA_ACTION_UNKNOWN_FUNCTION.md +23 -0
- package/dist/reference/errors/MOSTRA_ACTION_UNSERIALIZABLE.md +23 -0
- package/dist/reference/errors/MOSTRA_ARTIFACT_DIR_NOT_EMPTY.md +23 -0
- package/dist/reference/errors/MOSTRA_ARTIFACT_ENTRY_AMBIGUOUS.md +23 -0
- package/dist/reference/errors/MOSTRA_ARTIFACT_NOT_FOUND.md +23 -0
- package/dist/reference/errors/MOSTRA_ARTIFACT_NO_ENTRY.md +23 -0
- package/dist/reference/errors/MOSTRA_ARTIFACT_NO_PAGE.md +23 -0
- package/dist/reference/errors/MOSTRA_ARTIFACT_SOURCE_MISSING.md +23 -0
- package/dist/reference/errors/MOSTRA_ARTIFACT_SOURCE_UNREADABLE.md +23 -0
- package/dist/reference/errors/MOSTRA_ARTIFACT_TEMPLATE_UNKNOWN.md +23 -0
- package/dist/reference/errors/MOSTRA_ARTIFACT_VERSION_COLLECTED.md +23 -0
- package/dist/reference/errors/MOSTRA_ARTIFACT_VERSION_NOT_FOUND.md +23 -0
- package/dist/reference/errors/MOSTRA_BUILD_CRASHED.md +23 -0
- package/dist/reference/errors/MOSTRA_BUILD_FAILED.md +23 -0
- package/dist/reference/errors/MOSTRA_BUILD_TIMEOUT.md +23 -0
- package/dist/reference/errors/MOSTRA_BUILD_UNRESOLVED.md +23 -0
- package/dist/reference/errors/MOSTRA_CLI_CONFIG_UNWRITABLE.md +23 -0
- package/dist/reference/errors/MOSTRA_CLI_UNKNOWN_COMMAND.md +23 -0
- package/dist/reference/errors/MOSTRA_CLI_USAGE.md +23 -0
- package/dist/reference/errors/MOSTRA_CONSOLE_ERROR.md +23 -0
- package/dist/reference/errors/MOSTRA_CONSOLE_WARNING.md +23 -0
- package/dist/reference/errors/MOSTRA_DEP_INSTALL_FAILED.md +23 -0
- package/dist/reference/errors/MOSTRA_DEP_MISSING.md +23 -0
- package/dist/reference/errors/MOSTRA_DEP_NOT_FOUND.md +23 -0
- package/dist/reference/errors/MOSTRA_DEP_OFFLINE.md +23 -0
- package/dist/reference/errors/MOSTRA_DEP_SPEC_INVALID.md +23 -0
- package/dist/reference/errors/MOSTRA_DEP_TIMEOUT.md +23 -0
- package/dist/reference/errors/MOSTRA_DEP_WARM_CONFLICT.md +23 -0
- package/dist/reference/errors/MOSTRA_ENV_BIND_FAILED.md +23 -0
- package/dist/reference/errors/MOSTRA_ENV_BROWSER_MISSING.md +23 -0
- package/dist/reference/errors/MOSTRA_ENV_BUNDLE_DOWNLOAD_FAILED.md +23 -0
- package/dist/reference/errors/MOSTRA_ENV_BUNDLE_INVALID.md +23 -0
- package/dist/reference/errors/MOSTRA_ENV_BUNDLE_UNAVAILABLE.md +23 -0
- package/dist/reference/errors/MOSTRA_ENV_NODE_UNSUPPORTED.md +23 -0
- package/dist/reference/errors/MOSTRA_ENV_PORT_TAKEN.md +23 -0
- package/dist/reference/errors/MOSTRA_ENV_RUNTIME_MISSING.md +23 -0
- package/dist/reference/errors/MOSTRA_ENV_SERVICE_FAILED.md +23 -0
- package/dist/reference/errors/MOSTRA_ENV_SHELL_MISSING.md +23 -0
- package/dist/reference/errors/MOSTRA_ENV_WARM_MISSING.md +23 -0
- package/dist/reference/errors/MOSTRA_FIELD_CONFLICT.md +23 -0
- package/dist/reference/errors/MOSTRA_FIELD_DUPLICATE_ID.md +23 -0
- package/dist/reference/errors/MOSTRA_FIELD_ID_INVALID.md +23 -0
- package/dist/reference/errors/MOSTRA_FIELD_ID_UNSTABLE.md +23 -0
- package/dist/reference/errors/MOSTRA_FIELD_MUTATION_ID_REUSED.md +23 -0
- package/dist/reference/errors/MOSTRA_FIELD_SHARED.md +23 -0
- package/dist/reference/errors/MOSTRA_FIELD_TOO_LARGE.md +23 -0
- package/dist/reference/errors/MOSTRA_FIELD_UNBOUND.md +23 -0
- package/dist/reference/errors/MOSTRA_HOME_DISK_FULL.md +23 -0
- package/dist/reference/errors/MOSTRA_HOME_GC_INCOMPLETE.md +23 -0
- package/dist/reference/errors/MOSTRA_HOME_GONE.md +23 -0
- package/dist/reference/errors/MOSTRA_HOME_INTEGRITY.md +23 -0
- package/dist/reference/errors/MOSTRA_HOME_LEDGER_UNREADABLE.md +23 -0
- package/dist/reference/errors/MOSTRA_HOME_LOCKED.md +23 -0
- package/dist/reference/errors/MOSTRA_HOME_MISMATCH.md +23 -0
- package/dist/reference/errors/MOSTRA_HOME_NOT_ALWAYS_ON.md +23 -0
- package/dist/reference/errors/MOSTRA_HOME_NOT_OWNED.md +23 -0
- package/dist/reference/errors/MOSTRA_HOME_SOCKET_TOO_LONG.md +23 -0
- package/dist/reference/errors/MOSTRA_HOME_TOO_NEW.md +23 -0
- package/dist/reference/errors/MOSTRA_HOST_CONNECTION_CLOSED.md +23 -0
- package/dist/reference/errors/MOSTRA_HOST_CURSOR_COMPACTED.md +23 -0
- package/dist/reference/errors/MOSTRA_HOST_IDENTITY_UNAVAILABLE.md +23 -0
- package/dist/reference/errors/MOSTRA_HOST_INITIALIZE_ORDER.md +23 -0
- package/dist/reference/errors/MOSTRA_HOST_INTERNAL_ERROR.md +23 -0
- package/dist/reference/errors/MOSTRA_HOST_INVALID_PARAMS.md +23 -0
- package/dist/reference/errors/MOSTRA_HOST_MALFORMED.md +23 -0
- package/dist/reference/errors/MOSTRA_HOST_MESSAGE_TOO_LARGE.md +23 -0
- package/dist/reference/errors/MOSTRA_HOST_METHOD_UNKNOWN.md +23 -0
- package/dist/reference/errors/MOSTRA_HOST_NOTIFICATION_UNSUPPORTED.md +23 -0
- package/dist/reference/errors/MOSTRA_HOST_PORT_UNSUPPORTED.md +23 -0
- package/dist/reference/errors/MOSTRA_HOST_REQUEST_ID_REUSED.md +23 -0
- package/dist/reference/errors/MOSTRA_HOST_REVISION_UNSUPPORTED.md +23 -0
- package/dist/reference/errors/MOSTRA_HOST_SHUTDOWN_REFUSED.md +23 -0
- package/dist/reference/errors/MOSTRA_HOST_SUBSCRIPTION_UNKNOWN.md +23 -0
- package/dist/reference/errors/MOSTRA_HOST_TIMEOUT.md +23 -0
- package/dist/reference/errors/MOSTRA_HYGIENE_CONTENT_TYPE.md +23 -0
- package/dist/reference/errors/MOSTRA_HYGIENE_CSRF_MISSING.md +23 -0
- package/dist/reference/errors/MOSTRA_HYGIENE_HEADERS_TOO_LARGE.md +23 -0
- package/dist/reference/errors/MOSTRA_HYGIENE_HOST_REFUSED.md +23 -0
- package/dist/reference/errors/MOSTRA_HYGIENE_NOT_SERVED.md +23 -0
- package/dist/reference/errors/MOSTRA_HYGIENE_ORIGIN_REFUSED.md +23 -0
- package/dist/reference/errors/MOSTRA_HYGIENE_RATE_LIMITED.md +23 -0
- package/dist/reference/errors/MOSTRA_HYGIENE_REQUEST_MALFORMED.md +23 -0
- package/dist/reference/errors/MOSTRA_HYGIENE_SCOPE_DENIED.md +23 -0
- package/dist/reference/errors/MOSTRA_HYGIENE_SESSION_CONFLICT.md +23 -0
- package/dist/reference/errors/MOSTRA_HYGIENE_SESSION_MISSING.md +23 -0
- package/dist/reference/errors/MOSTRA_HYGIENE_TOKEN_INVALID.md +23 -0
- package/dist/reference/errors/MOSTRA_HYGIENE_TOKEN_REVOKED.md +23 -0
- package/dist/reference/errors/MOSTRA_LIVE_SOURCE_FAILED.md +23 -0
- package/dist/reference/errors/MOSTRA_LIVE_TOO_LARGE.md +23 -0
- package/dist/reference/errors/MOSTRA_MANIFEST_INVALID.md +23 -0
- package/dist/reference/errors/MOSTRA_MANIFEST_NOT_LITERAL.md +23 -0
- package/dist/reference/errors/MOSTRA_MANIFEST_UNKNOWN_KEY.md +23 -0
- package/dist/reference/errors/MOSTRA_NETWORK_EXTERNAL_FAILED.md +23 -0
- package/dist/reference/errors/MOSTRA_NETWORK_REQUEST_FAILED.md +23 -0
- package/dist/reference/errors/MOSTRA_PLATFORM_UNSUPPORTED.md +23 -0
- package/dist/reference/errors/MOSTRA_PUBLISH_ID_CONFLICT.md +23 -0
- package/dist/reference/errors/MOSTRA_PUBLISH_SOURCE_CHANGED.md +23 -0
- package/dist/reference/errors/MOSTRA_PUBLISH_VERIFY_FAILED.md +23 -0
- package/dist/reference/errors/MOSTRA_REGRESSION_ACTION_REMOVED.md +23 -0
- package/dist/reference/errors/MOSTRA_REGRESSION_ARIA.md +23 -0
- package/dist/reference/errors/MOSTRA_REGRESSION_FIELD_REMOVED.md +23 -0
- package/dist/reference/errors/MOSTRA_REGRESSION_LIVE_REMOVED.md +23 -0
- package/dist/reference/errors/MOSTRA_REGRESSION_PAGE_REMOVED.md +23 -0
- package/dist/reference/errors/MOSTRA_REGRESSION_PIXELS.md +23 -0
- package/dist/reference/errors/MOSTRA_RUNTIME_BLANK.md +23 -0
- package/dist/reference/errors/MOSTRA_RUNTIME_NOT_READY.md +23 -0
- package/dist/reference/errors/MOSTRA_RUNTIME_PAGE_ERROR.md +23 -0
- package/dist/reference/errors/MOSTRA_RUNTIME_WORKER_CRASHED.md +23 -0
- package/dist/reference/errors/MOSTRA_RUNTIME_WORKER_PARKED.md +23 -0
- package/dist/reference/errors/MOSTRA_SECRET_MISSING.md +23 -0
- package/dist/reference/errors/MOSTRA_SMOKE_BUDGET.md +23 -0
- package/dist/reference/errors/MOSTRA_SMOKE_NO_EFFECT.md +23 -0
- package/dist/reference/errors/MOSTRA_SUBMIT_ALREADY.md +23 -0
- package/dist/reference/errors/MOSTRA_SUBMIT_BLOCKED.md +23 -0
- package/dist/reference/errors/MOSTRA_SUBMIT_CHANNEL_DENIED.md +23 -0
- package/dist/reference/errors/MOSTRA_SUBMIT_NOT_FOUND.md +23 -0
- package/dist/reference/errors/MOSTRA_SUBMIT_POLICY_NONE.md +23 -0
- package/dist/reference/errors/MOSTRA_SUBMIT_REVISION_MOVED.md +23 -0
- package/dist/reference/errors/MOSTRA_SUBMIT_SIGNAL_INVALID.md +23 -0
- package/dist/reference/errors/MOSTRA_SUBMIT_VIEW_INVALID.md +23 -0
- package/dist/reference/errors/MOSTRA_TARGET_UNAVAILABLE.md +23 -0
- package/dist/reference/errors/MOSTRA_TYPE_ERROR.md +23 -0
- package/dist/reference/errors/MOSTRA_TYPE_UNTYPED_PACKAGE.md +23 -0
- package/dist/reference/errors/MOSTRA_VERIFY_ACTION_SKIPPED.md +23 -0
- package/dist/reference/errors/MOSTRA_VERIFY_BASELINE_MISSING.md +23 -0
- package/dist/reference/errors/MOSTRA_VERIFY_CRASHED.md +23 -0
- package/dist/reference/errors/MOSTRA_VERIFY_LIVE_FAILED.md +23 -0
- package/dist/reference/errors/MOSTRA_VERIFY_NO_BASELINE.md +23 -0
- package/dist/reference/errors/MOSTRA_VERIFY_SECRET_MISSING.md +23 -0
- package/dist/reference/errors/MOSTRA_VERIFY_SKIP_CONSEQUENCE.md +23 -0
- package/dist/reference/errors/MOSTRA_VERIFY_TIMEOUT.md +23 -0
- package/dist/reference/telemetry.md +82 -0
- package/dist/run-BJaRnz-Q.js +3824 -0
- package/dist/run-BJaRnz-Q.js.map +1 -0
- package/dist/run-CMyjOm0Q.js +2 -0
- package/dist/telemetry/flush-main.d.ts +1 -0
- package/dist/telemetry/flush-main.js +14 -0
- package/dist/telemetry/flush-main.js.map +1 -0
- package/package.json +62 -3
- package/src/bin.ts +70 -0
- package/src/bootstrap.ts +156 -0
- package/src/browser.ts +186 -0
- package/src/client.ts +313 -0
- package/src/command.ts +147 -0
- package/src/commands/admin.ts +791 -0
- package/src/commands/authoring.ts +244 -0
- package/src/commands/dev.ts +106 -0
- package/src/commands/index.ts +71 -0
- package/src/commands/interaction.ts +288 -0
- package/src/commands/shared.ts +71 -0
- package/src/commands/viewing.ts +153 -0
- package/src/docs.ts +111 -0
- package/src/exit.ts +89 -0
- package/src/help.ts +170 -0
- package/src/home.ts +111 -0
- package/src/index.ts +23 -0
- package/src/node-gate.ts +15 -0
- package/src/parse.ts +246 -0
- package/src/run.ts +387 -0
- package/src/telemetry/decision.ts +101 -0
- package/src/telemetry/events.ts +147 -0
- package/src/telemetry/flush-main.ts +13 -0
- package/src/telemetry/flush.ts +143 -0
- package/src/telemetry/harness.ts +39 -0
- package/src/telemetry/index.ts +318 -0
- package/src/telemetry/store.ts +379 -0
- package/src/telemetry/timezone.ts +72 -0
- package/src/telemetry/words.ts +87 -0
- package/src/telemetry/zone-sets.ts +182 -0
- package/src/telemetry/zones.generated.ts +720 -0
- package/src/version.ts +43 -0
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sending the queue (D140 item 6): a detached process the CLI starts after a command, at most one
|
|
3
|
+
* at a time and at most once a minute. It reads the decision again before it sends, so consent
|
|
4
|
+
* withdrawn in between drops the events; it posts at most 100 events per request with no header
|
|
5
|
+
* but `Content-Type`, `User-Agent` and the ones HTTP itself needs, and keeps what a failed
|
|
6
|
+
* attempt couldn't send for the next flush.
|
|
7
|
+
*/
|
|
8
|
+
import { spawn } from "node:child_process";
|
|
9
|
+
import { request as httpRequest } from "node:http";
|
|
10
|
+
import { request as httpsRequest } from "node:https";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import type { Environment } from "../home.ts";
|
|
13
|
+
import { cliVersion, fromSource, packageRoot } from "../version.ts";
|
|
14
|
+
import { buildEndpoint, decide } from "./decision.ts";
|
|
15
|
+
import { TelemetryEvent } from "./events.ts";
|
|
16
|
+
import { QUEUE_MAX_EVENTS, type TelemetryStore } from "./store.ts";
|
|
17
|
+
|
|
18
|
+
/** At most this many events per request. */
|
|
19
|
+
export const BATCH = 100;
|
|
20
|
+
/** A flush starts at most this often. */
|
|
21
|
+
export const FLUSH_INTERVAL_MS = 60_000;
|
|
22
|
+
/** The flush interval; `MOSTRA_TEST_FLUSH_INTERVAL_MS` shortens it, for tests only. */
|
|
23
|
+
function flushInterval(env: Environment): number {
|
|
24
|
+
const test = Number(env["MOSTRA_TEST_FLUSH_INTERVAL_MS"]);
|
|
25
|
+
return Number.isInteger(test) && test >= 0 && test < FLUSH_INTERVAL_MS ? test : FLUSH_INTERVAL_MS;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** One request's limit. */
|
|
29
|
+
const REQUEST_TIMEOUT_MS = 5000;
|
|
30
|
+
|
|
31
|
+
/** Posts one batch; resolves with whether the collector took it (any 2xx). */
|
|
32
|
+
export function post(endpoint: string, body: string): Promise<boolean> {
|
|
33
|
+
return new Promise((resolve) => {
|
|
34
|
+
const url = new URL(endpoint);
|
|
35
|
+
const send = url.protocol === "https:" ? httpsRequest : httpRequest;
|
|
36
|
+
const req = send(
|
|
37
|
+
url,
|
|
38
|
+
{
|
|
39
|
+
method: "POST",
|
|
40
|
+
headers: {
|
|
41
|
+
"Content-Type": "application/json",
|
|
42
|
+
"User-Agent": `mostra-cli/${cliVersion()}`,
|
|
43
|
+
"Content-Length": Buffer.byteLength(body),
|
|
44
|
+
},
|
|
45
|
+
timeout: REQUEST_TIMEOUT_MS,
|
|
46
|
+
},
|
|
47
|
+
(response) => {
|
|
48
|
+
response.resume();
|
|
49
|
+
response.on("end", () =>
|
|
50
|
+
resolve((response.statusCode ?? 0) >= 200 && (response.statusCode ?? 0) < 300),
|
|
51
|
+
);
|
|
52
|
+
response.on("error", () => resolve(false));
|
|
53
|
+
},
|
|
54
|
+
);
|
|
55
|
+
req.on("timeout", () => req.destroy());
|
|
56
|
+
req.on("error", () => resolve(false));
|
|
57
|
+
req.end(body);
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Sends the queue: the decision again, then batches until one fails. Events the schema refuses
|
|
63
|
+
* never leave the machine.
|
|
64
|
+
*/
|
|
65
|
+
export async function flush(
|
|
66
|
+
store: TelemetryStore,
|
|
67
|
+
env: Environment,
|
|
68
|
+
now: () => number,
|
|
69
|
+
): Promise<void> {
|
|
70
|
+
const settings = store.read();
|
|
71
|
+
// The queue holds only events of uses that aren't embedded; the rest of the order applies again.
|
|
72
|
+
const decision = decide({
|
|
73
|
+
env,
|
|
74
|
+
buildEndpoint: buildEndpoint(),
|
|
75
|
+
embedded: false,
|
|
76
|
+
settings,
|
|
77
|
+
askFirst: false,
|
|
78
|
+
});
|
|
79
|
+
if (!decision.enabled || decision.endpoint === undefined) {
|
|
80
|
+
// Consent withdrawn (or never given) since the events were queued: they go unsent.
|
|
81
|
+
if (decision.reason !== "no-collector" && settings.ok) store.drop(store.queued(now()), now());
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
// The queue holds at most QUEUE_MAX_EVENTS, so a flush needs at most this many batches.
|
|
85
|
+
for (let round = 0; round <= Math.ceil(QUEUE_MAX_EVENTS / BATCH); round += 1) {
|
|
86
|
+
const lines = store.queued(now()).slice(0, BATCH);
|
|
87
|
+
if (lines.length === 0) return;
|
|
88
|
+
const events: unknown[] = [];
|
|
89
|
+
for (const line of lines) {
|
|
90
|
+
try {
|
|
91
|
+
const parsed = TelemetryEvent.safeParse(JSON.parse(line));
|
|
92
|
+
if (parsed.success) events.push(parsed.data);
|
|
93
|
+
} catch {
|
|
94
|
+
// Not an event: dropped with the batch.
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
const sent = events.length === 0 || (await post(decision.endpoint, JSON.stringify({ events })));
|
|
98
|
+
if (!sent) return;
|
|
99
|
+
// Nothing removed (another process changed the queue, or the lock was out of reach): stop
|
|
100
|
+
// rather than send the same batch again.
|
|
101
|
+
if (store.drop(lines, now()) === 0) return;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** The flusher's entry in the CLI's package. */
|
|
106
|
+
function flusherEntry(): { readonly entry: string; readonly nodeArgs: readonly string[] } {
|
|
107
|
+
const root = packageRoot();
|
|
108
|
+
return fromSource()
|
|
109
|
+
? {
|
|
110
|
+
entry: join(root, "src", "telemetry", "flush-main.ts"),
|
|
111
|
+
nodeArgs: ["--conditions=@mostra/source"],
|
|
112
|
+
}
|
|
113
|
+
: { entry: join(root, "dist", "telemetry", "flush-main.js"), nodeArgs: [] };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Starts a flusher when the queue has events and no flush started in the last minute, fully
|
|
118
|
+
* detached: no stdio, its own process group, never waited for, so `mostra … | jq` and every
|
|
119
|
+
* harness reading to end of file finish when the command does.
|
|
120
|
+
*/
|
|
121
|
+
export function startFlusher(store: TelemetryStore, env: Environment, now: number): void {
|
|
122
|
+
if (store.queued(now).length === 0) return;
|
|
123
|
+
let due = false;
|
|
124
|
+
store.update((current) => {
|
|
125
|
+
const last = current.lastFlushAt === undefined ? 0 : Date.parse(current.lastFlushAt);
|
|
126
|
+
if (Number.isFinite(last) && now - last < flushInterval(env)) return current;
|
|
127
|
+
due = true;
|
|
128
|
+
return { ...current, lastFlushAt: new Date(now).toISOString() };
|
|
129
|
+
});
|
|
130
|
+
if (!due) return;
|
|
131
|
+
const { entry, nodeArgs } = flusherEntry();
|
|
132
|
+
try {
|
|
133
|
+
const child = spawn(process.execPath, [...nodeArgs, entry, store.dir], {
|
|
134
|
+
detached: true,
|
|
135
|
+
stdio: "ignore",
|
|
136
|
+
env: { ...process.env, ...env } as NodeJS.ProcessEnv,
|
|
137
|
+
});
|
|
138
|
+
child.on("error", () => {});
|
|
139
|
+
child.unref();
|
|
140
|
+
} catch {
|
|
141
|
+
// Telemetry never changes a command's outcome.
|
|
142
|
+
}
|
|
143
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which agent ran the CLI (D140 item 9), from environment markers each agent documents or sets in
|
|
3
|
+
* its source (VERIFIED_FACTS, WP-10). Only the agent's ID is ever sent, never a marker's value.
|
|
4
|
+
*/
|
|
5
|
+
import type { Environment } from "../home.ts";
|
|
6
|
+
|
|
7
|
+
/** The agents Mostra recognizes, in the order their markers are checked. */
|
|
8
|
+
export const HARNESSES = ["claude-code", "codex", "gemini-cli", "opencode", "cursor"] as const;
|
|
9
|
+
|
|
10
|
+
/** A recognized agent, `other` for an agent Mostra doesn't know, `none` for no agent at all. */
|
|
11
|
+
export type Harness = (typeof HARNESSES)[number] | "other" | "none";
|
|
12
|
+
|
|
13
|
+
/** Each agent's own marker. */
|
|
14
|
+
const MARKERS: Readonly<Record<(typeof HARNESSES)[number], string>> = {
|
|
15
|
+
"claude-code": "CLAUDECODE",
|
|
16
|
+
codex: "CODEX_THREAD_ID",
|
|
17
|
+
"gemini-cli": "GEMINI_CLI",
|
|
18
|
+
opencode: "OPENCODE",
|
|
19
|
+
cursor: "CURSOR_AGENT",
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const set = (value: string | undefined): boolean => value !== undefined && value !== "";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The agent that ran this process: `AI_AGENT` when it starts with a known ID (the convention
|
|
26
|
+
* `@vercel/detect-agent` reads), else the first agent whose own marker is set, else `other` when
|
|
27
|
+
* `AI_AGENT` or `AGENT` names something else, else `none`. Cursor's editor terminal sets
|
|
28
|
+
* `CURSOR_TRACE_ID` without `CURSOR_AGENT`: that is a person, so it isn't `cursor`.
|
|
29
|
+
*/
|
|
30
|
+
export function detectHarness(env: Environment): Harness {
|
|
31
|
+
const declared = env["AI_AGENT"];
|
|
32
|
+
if (set(declared)) {
|
|
33
|
+
const known = HARNESSES.find((id) => declared?.startsWith(id));
|
|
34
|
+
if (known) return known;
|
|
35
|
+
}
|
|
36
|
+
for (const id of HARNESSES) if (set(env[MARKERS[id]])) return id;
|
|
37
|
+
if (set(declared) || set(env["AGENT"])) return "other";
|
|
38
|
+
return "none";
|
|
39
|
+
}
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The standalone CLI's telemetry (D119, D140): the only code in Mostra that can send an event, and
|
|
3
|
+
* only `@mostra/cli` may import it (a dependency-cruiser rule). The runner calls {@link begin}
|
|
4
|
+
* before a command's work and {@link end} after it; everything else here is the decision, the
|
|
5
|
+
* words, the schema, the store and the flusher.
|
|
6
|
+
*/
|
|
7
|
+
import { type DiagnosticCode, diagnosticCatalog } from "@mostra/protocol";
|
|
8
|
+
import type { Environment } from "../home.ts";
|
|
9
|
+
import { cliVersion } from "../version.ts";
|
|
10
|
+
import { buildEndpoint, decide, type TelemetryDecision, truthy } from "./decision.ts";
|
|
11
|
+
import {
|
|
12
|
+
type CommonFields,
|
|
13
|
+
catalogCodes,
|
|
14
|
+
durationBucket,
|
|
15
|
+
minute,
|
|
16
|
+
SCHEMA,
|
|
17
|
+
TelemetryEvent,
|
|
18
|
+
} from "./events.ts";
|
|
19
|
+
import { startFlusher } from "./flush.ts";
|
|
20
|
+
import { detectHarness } from "./harness.ts";
|
|
21
|
+
import { TelemetryStore } from "./store.ts";
|
|
22
|
+
import { asksFirst, type ZoneSources } from "./timezone.ts";
|
|
23
|
+
import { ANSWER, NOTICE_ASK_FIRST, NOTICE_DEFAULT, QUESTION, statusLine } from "./words.ts";
|
|
24
|
+
|
|
25
|
+
export { TelemetryStore } from "./store.ts";
|
|
26
|
+
export { TURNED } from "./words.ts";
|
|
27
|
+
|
|
28
|
+
/** The catalog's codes: only these leave the machine in `error` and `codes`. */
|
|
29
|
+
const CATALOG = new Set(Object.keys(diagnosticCatalog));
|
|
30
|
+
|
|
31
|
+
/** Variables CI services set; any of them means `ci: true`, and the question is never asked. */
|
|
32
|
+
const CI_MARKERS = [
|
|
33
|
+
"CI",
|
|
34
|
+
"CONTINUOUS_INTEGRATION",
|
|
35
|
+
"GITHUB_ACTIONS",
|
|
36
|
+
"GITLAB_CI",
|
|
37
|
+
"BUILDKITE",
|
|
38
|
+
"CIRCLECI",
|
|
39
|
+
"TRAVIS",
|
|
40
|
+
"TF_BUILD",
|
|
41
|
+
"JENKINS_URL",
|
|
42
|
+
"TEAMCITY_VERSION",
|
|
43
|
+
"BITBUCKET_BUILD_NUMBER",
|
|
44
|
+
"CODEBUILD_BUILD_ID",
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
/** Whether this process runs in CI. */
|
|
48
|
+
export function inCi(env: Environment): boolean {
|
|
49
|
+
return CI_MARKERS.some((name) => truthy(env[name]));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** What a session of one command needs. */
|
|
53
|
+
export interface TelemetryOptions {
|
|
54
|
+
readonly env: Environment;
|
|
55
|
+
readonly configDir: string;
|
|
56
|
+
/** The command's path from the definitions (`daemon install`), or `unknown`. */
|
|
57
|
+
readonly command: string;
|
|
58
|
+
readonly json: boolean;
|
|
59
|
+
readonly tty: { readonly stdin: boolean; readonly stderr: boolean };
|
|
60
|
+
/** The command reads stdin, so the question would take its input. */
|
|
61
|
+
readonly readsStdin: boolean;
|
|
62
|
+
/** `telemetry`, `docs`, `--help`, `--version`: no notice, no question, no event. */
|
|
63
|
+
readonly quiet: boolean;
|
|
64
|
+
/** Whether the home is a host's (its `config.json` mark, or a file that can't be read). */
|
|
65
|
+
readonly hostHome: boolean;
|
|
66
|
+
/** Writes to stderr. */
|
|
67
|
+
readonly stderr: (text: string) => void;
|
|
68
|
+
/** Asks one line on stderr and reads the answer, `undefined` for none within `timeoutMs`. */
|
|
69
|
+
readonly ask: (question: string, timeoutMs: number) => Promise<string | undefined | "interrupt">;
|
|
70
|
+
/** The clock; tests move it. */
|
|
71
|
+
readonly now?: () => number;
|
|
72
|
+
/** The build's endpoint; tests pass their own. Default {@link buildEndpoint}. */
|
|
73
|
+
readonly buildEndpoint?: string;
|
|
74
|
+
/** Where the zone names come from; tests replace `/etc/localtime` and `Intl`. */
|
|
75
|
+
readonly zones?: Omit<ZoneSources, "env">;
|
|
76
|
+
/** Starts the flusher after queueing; tests of the queue turn it off. Default true. */
|
|
77
|
+
readonly flush?: boolean;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** How long the question waits for an answer. */
|
|
81
|
+
export const QUESTION_TIMEOUT_MS = 60_000;
|
|
82
|
+
|
|
83
|
+
/** One command's telemetry. */
|
|
84
|
+
export interface TelemetrySession {
|
|
85
|
+
/** Before the command's work: the notice, or the question. Resolves `false` when Ctrl-C ended it. */
|
|
86
|
+
begin(): Promise<boolean>;
|
|
87
|
+
/** Records an event of the command (`verify.result`, `publish.done`). */
|
|
88
|
+
record(event: {
|
|
89
|
+
readonly event: "verify.result" | "publish.done";
|
|
90
|
+
readonly fields: Readonly<Record<string, unknown>>;
|
|
91
|
+
}): void;
|
|
92
|
+
/** After the command: queues its events and starts the flusher, when telemetry allows. */
|
|
93
|
+
end(outcome: {
|
|
94
|
+
readonly exit: number;
|
|
95
|
+
readonly durationMs: number;
|
|
96
|
+
readonly error?: string;
|
|
97
|
+
/** The instance was host-spawned. */
|
|
98
|
+
readonly hostInstance: boolean;
|
|
99
|
+
}): void;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The current decision, for `telemetry status` and the session. */
|
|
103
|
+
export function currentDecision(options: {
|
|
104
|
+
readonly env: Environment;
|
|
105
|
+
readonly store: TelemetryStore;
|
|
106
|
+
readonly embedded: boolean;
|
|
107
|
+
readonly buildEndpoint?: string;
|
|
108
|
+
readonly zones?: Omit<ZoneSources, "env">;
|
|
109
|
+
}): TelemetryDecision {
|
|
110
|
+
return decide({
|
|
111
|
+
env: options.env,
|
|
112
|
+
buildEndpoint: options.buildEndpoint ?? buildEndpoint(),
|
|
113
|
+
embedded: options.embedded,
|
|
114
|
+
settings: options.store.read(),
|
|
115
|
+
askFirst: asksFirst({ ...options.zones, env: options.env }),
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Starts one command's telemetry. */
|
|
120
|
+
export function telemetrySession(options: TelemetryOptions): TelemetrySession {
|
|
121
|
+
const now = options.now ?? Date.now;
|
|
122
|
+
const store = new TelemetryStore(options.configDir);
|
|
123
|
+
const pending: {
|
|
124
|
+
event: "verify.result" | "publish.done";
|
|
125
|
+
fields: Readonly<Record<string, unknown>>;
|
|
126
|
+
}[] = [];
|
|
127
|
+
const decision = (embedded: boolean): TelemetryDecision =>
|
|
128
|
+
currentDecision({
|
|
129
|
+
env: options.env,
|
|
130
|
+
store,
|
|
131
|
+
embedded,
|
|
132
|
+
...(options.buildEndpoint !== undefined ? { buildEndpoint: options.buildEndpoint } : {}),
|
|
133
|
+
...(options.zones !== undefined ? { zones: options.zones } : {}),
|
|
134
|
+
});
|
|
135
|
+
const shown = (): boolean => {
|
|
136
|
+
const read = store.read();
|
|
137
|
+
return read.ok && read.settings.noticeShownAt !== undefined;
|
|
138
|
+
};
|
|
139
|
+
/** Records that the notice was shown; `false` when the store can't be written. */
|
|
140
|
+
const markShown = (): boolean =>
|
|
141
|
+
store.update((current) => ({ ...current, noticeShownAt: new Date(now()).toISOString() })) !==
|
|
142
|
+
undefined;
|
|
143
|
+
const harness = detectHarness(options.env);
|
|
144
|
+
const ci = inCi(options.env);
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
async begin() {
|
|
148
|
+
if (options.quiet) return true;
|
|
149
|
+
const current = decision(options.hostHome);
|
|
150
|
+
// No collector, a host's home, or a person who said no: nothing to say (D140 item 7).
|
|
151
|
+
if (current.reason === "no-collector" || current.reason === "embedded") return true;
|
|
152
|
+
// The notice is recorded before it is printed: a store that can't record it (a directory
|
|
153
|
+
// that can't be written) means no notice, and so nothing ever sent.
|
|
154
|
+
if (current.enabled) {
|
|
155
|
+
if (!shown() && markShown()) options.stderr(NOTICE_DEFAULT);
|
|
156
|
+
return true;
|
|
157
|
+
}
|
|
158
|
+
if (current.reason !== "default" || !current.askFirst) return true;
|
|
159
|
+
const canAsk =
|
|
160
|
+
options.tty.stdin &&
|
|
161
|
+
options.tty.stderr &&
|
|
162
|
+
!options.json &&
|
|
163
|
+
!ci &&
|
|
164
|
+
harness === "none" &&
|
|
165
|
+
!options.readsStdin;
|
|
166
|
+
if (!canAsk) {
|
|
167
|
+
// Once, and on its own record: it never stands for the default notice.
|
|
168
|
+
const read = store.read();
|
|
169
|
+
if (read.ok && read.settings.askFirstNoticeShownAt === undefined) {
|
|
170
|
+
const marked = store.update((settings) => ({
|
|
171
|
+
...settings,
|
|
172
|
+
askFirstNoticeShownAt: new Date(now()).toISOString(),
|
|
173
|
+
}));
|
|
174
|
+
if (marked !== undefined) options.stderr(NOTICE_ASK_FIRST);
|
|
175
|
+
}
|
|
176
|
+
return true;
|
|
177
|
+
}
|
|
178
|
+
// A person who can answer and never did is asked, whatever notices they saw before.
|
|
179
|
+
const answer = await options.ask(QUESTION, QUESTION_TIMEOUT_MS);
|
|
180
|
+
const yes = typeof answer === "string" && /^\s*y(es)?\s*$/i.test(answer);
|
|
181
|
+
const stored = store.update((settings) => ({
|
|
182
|
+
...settings,
|
|
183
|
+
choice: yes ? "on" : "off",
|
|
184
|
+
noticeShownAt: new Date(now()).toISOString(),
|
|
185
|
+
}));
|
|
186
|
+
options.stderr(
|
|
187
|
+
stored === undefined
|
|
188
|
+
? `${statusLine({ enabled: false, reason: "unreadable", askFirst: true, settingsFile: store.settingsFile })}\n`
|
|
189
|
+
: yes
|
|
190
|
+
? ANSWER.yes
|
|
191
|
+
: answer === undefined
|
|
192
|
+
? ANSWER.timeout
|
|
193
|
+
: ANSWER.no,
|
|
194
|
+
);
|
|
195
|
+
return answer !== "interrupt";
|
|
196
|
+
},
|
|
197
|
+
|
|
198
|
+
record(event) {
|
|
199
|
+
pending.push({ event: event.event, fields: event.fields });
|
|
200
|
+
},
|
|
201
|
+
|
|
202
|
+
end(outcome) {
|
|
203
|
+
if (options.quiet) return;
|
|
204
|
+
const current = decision(options.hostHome || outcome.hostInstance);
|
|
205
|
+
if (!current.enabled || current.consent === undefined || !shown()) return;
|
|
206
|
+
const id = store.installId();
|
|
207
|
+
if (!id) return;
|
|
208
|
+
const common: CommonFields = {
|
|
209
|
+
schema: SCHEMA,
|
|
210
|
+
at: minute(new Date(now())),
|
|
211
|
+
installId: id.id,
|
|
212
|
+
version: cliVersion(),
|
|
213
|
+
os: process.platform === "darwin" ? "darwin" : "linux",
|
|
214
|
+
arch: process.arch,
|
|
215
|
+
node: Number(process.versions.node.split(".")[0]),
|
|
216
|
+
harness,
|
|
217
|
+
ci,
|
|
218
|
+
consent: current.consent,
|
|
219
|
+
};
|
|
220
|
+
const events: unknown[] = [];
|
|
221
|
+
if (id.created) events.push({ ...common, event: "cli.first_run" });
|
|
222
|
+
for (const item of pending) {
|
|
223
|
+
const fields =
|
|
224
|
+
item.event === "verify.result"
|
|
225
|
+
? {
|
|
226
|
+
status: item.fields["status"],
|
|
227
|
+
...catalogCodes((item.fields["codes"] as string[] | undefined) ?? [], CATALOG),
|
|
228
|
+
}
|
|
229
|
+
: item.fields;
|
|
230
|
+
events.push({ ...common, ...fields, event: item.event });
|
|
231
|
+
}
|
|
232
|
+
events.push({
|
|
233
|
+
...common,
|
|
234
|
+
event: "cli.command",
|
|
235
|
+
command: options.command,
|
|
236
|
+
exit: outcome.exit,
|
|
237
|
+
duration: durationBucket(outcome.durationMs),
|
|
238
|
+
json: options.json,
|
|
239
|
+
...(outcome.error !== undefined && CATALOG.has(outcome.error as DiagnosticCode)
|
|
240
|
+
? { error: outcome.error }
|
|
241
|
+
: {}),
|
|
242
|
+
});
|
|
243
|
+
const lines: string[] = [];
|
|
244
|
+
for (const event of events) {
|
|
245
|
+
const parsed = TelemetryEvent.safeParse(event);
|
|
246
|
+
if (parsed.success) lines.push(JSON.stringify(parsed.data));
|
|
247
|
+
}
|
|
248
|
+
if (truthy(options.env["MOSTRA_TELEMETRY_DEBUG"])) {
|
|
249
|
+
// Exactly what would be sent, and nothing sent.
|
|
250
|
+
for (const line of lines) options.stderr(`mostra telemetry (debug, not sent): ${line}\n`);
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
store.append(lines, now());
|
|
254
|
+
if (options.flush !== false) startFlusher(store, options.env, now());
|
|
255
|
+
},
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** `mostra telemetry status`'s result. */
|
|
260
|
+
export interface TelemetryStatus {
|
|
261
|
+
readonly enabled: boolean;
|
|
262
|
+
readonly reason: TelemetryDecision["reason"];
|
|
263
|
+
readonly askFirst: boolean;
|
|
264
|
+
readonly installId?: string;
|
|
265
|
+
readonly endpoint?: string;
|
|
266
|
+
readonly queued: number;
|
|
267
|
+
readonly noticeShownAt?: string;
|
|
268
|
+
readonly settingsFile: string;
|
|
269
|
+
/** With reason `unreadable`: the file that can't be read (the home's `config.json`, or this). */
|
|
270
|
+
readonly unreadableFile?: string;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** `mostra telemetry status`: the decision, the install ID and the queue. */
|
|
274
|
+
export function telemetryStatus(options: {
|
|
275
|
+
readonly env: Environment;
|
|
276
|
+
readonly configDir: string;
|
|
277
|
+
readonly embedded: boolean;
|
|
278
|
+
/**
|
|
279
|
+
* The home's `config.json` when it can't be read: the runner sends nothing then, as for a
|
|
280
|
+
* host's home, and status says so (Opus review finding 6).
|
|
281
|
+
*/
|
|
282
|
+
readonly homeConfigUnreadable?: string;
|
|
283
|
+
readonly now?: number;
|
|
284
|
+
}): { readonly status: TelemetryStatus; readonly text: string } {
|
|
285
|
+
const store = new TelemetryStore(options.configDir);
|
|
286
|
+
const decided = currentDecision({ env: options.env, store, embedded: options.embedded });
|
|
287
|
+
const homeBlocks =
|
|
288
|
+
options.homeConfigUnreadable !== undefined && decided.reason !== "no-collector";
|
|
289
|
+
const decision = homeBlocks
|
|
290
|
+
? { ...decided, enabled: false, reason: "unreadable" as const }
|
|
291
|
+
: decided;
|
|
292
|
+
const unreadableFile = homeBlocks
|
|
293
|
+
? options.homeConfigUnreadable
|
|
294
|
+
: decision.reason === "unreadable"
|
|
295
|
+
? store.settingsFile
|
|
296
|
+
: undefined;
|
|
297
|
+
const read = store.read();
|
|
298
|
+
const settings = read.ok ? read.settings : {};
|
|
299
|
+
const queued = read.ok ? store.queued(options.now ?? Date.now()).length : 0;
|
|
300
|
+
const status: TelemetryStatus = {
|
|
301
|
+
enabled: decision.enabled,
|
|
302
|
+
reason: decision.reason,
|
|
303
|
+
askFirst: decision.askFirst,
|
|
304
|
+
...(settings.installId !== undefined ? { installId: settings.installId } : {}),
|
|
305
|
+
...(decision.endpoint !== undefined ? { endpoint: decision.endpoint } : {}),
|
|
306
|
+
queued,
|
|
307
|
+
...(settings.noticeShownAt !== undefined ? { noticeShownAt: settings.noticeShownAt } : {}),
|
|
308
|
+
settingsFile: store.settingsFile,
|
|
309
|
+
...(unreadableFile !== undefined ? { unreadableFile } : {}),
|
|
310
|
+
};
|
|
311
|
+
const lines = [
|
|
312
|
+
statusLine({ ...decision, settingsFile: unreadableFile ?? store.settingsFile }),
|
|
313
|
+
...(status.installId !== undefined ? [`Install ID: ${status.installId}`] : []),
|
|
314
|
+
`Queued events: ${queued}`,
|
|
315
|
+
`Settings: ${store.settingsFile}`,
|
|
316
|
+
];
|
|
317
|
+
return { status, text: lines.join("\n") };
|
|
318
|
+
}
|