ossclip 0.1.14 → 0.1.16
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/README.md +7 -1
- package/editor-dist/assets/{index-CKpBGMBB.js → index-CdwzWN3j.js} +26 -23
- package/editor-dist/index.html +1 -1
- package/package.json +4 -4
- package/src/doctor.ts +21 -12
- package/src/interactive/produce-argv.ts +6 -1
- package/src/interactive/produce-wizard.ts +3 -0
- package/src/llm-detect.ts +62 -0
- package/src/produce.ts +32 -8
- package/src/program.ts +176 -45
- package/src/setup/plan.ts +13 -9
- package/src/setup/provider.ts +5 -0
- package/src/setup/setup.ts +13 -3
- package/src/telemetry.ts +468 -0
package/src/telemetry.ts
ADDED
|
@@ -0,0 +1,468 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { z } from "zod/v4";
|
|
5
|
+
import { CONFIG_DIR } from "@ossclip/core";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Anonymous opt-out usage telemetry (FINDINGS §134).
|
|
9
|
+
*
|
|
10
|
+
* The layering follows the house split: everything above the "I/O" divider is
|
|
11
|
+
* pure — testable without a filesystem, a TTY or a network — and the thin I/O
|
|
12
|
+
* below it (state file, PostHog POST, readline) is injectable where a test
|
|
13
|
+
* needs to see through it.
|
|
14
|
+
*
|
|
15
|
+
* Privacy floor, non-negotiable: no event ever carries a file path, a file
|
|
16
|
+
* name, transcript text, `--intent` text, a prompt, or a key. `assertSafeProps`
|
|
17
|
+
* is the drift guard that keeps FUTURE events honest about it, not just the
|
|
18
|
+
* ones written today.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The baked-in default is the project's WRITE-ONLY ingest key — public by
|
|
23
|
+
* design (every shipped analytics client carries one; it can only capture,
|
|
24
|
+
* never read), and baked rather than env-read because ossclip is a
|
|
25
|
+
* distributed CLI: an end user who installed from npm has no POSTHOG_API_KEY
|
|
26
|
+
* in their environment, so an env-only key would mean telemetry that never
|
|
27
|
+
* reports from exactly the machines it exists to count (§134). The env var
|
|
28
|
+
* still OVERRIDES the default for development against another project.
|
|
29
|
+
*
|
|
30
|
+
* With the key real, the test suite's hermeticity comes from vitest.config.ts
|
|
31
|
+
* exporting OSSCLIP_TELEMETRY=0 into every test process — see the
|
|
32
|
+
* hermetic-suite tests in telemetry.test.ts. Typed `string`, not the literal,
|
|
33
|
+
* so the `=== POSTHOG_PLACEHOLDER` checks stay ordinary comparisons.
|
|
34
|
+
*/
|
|
35
|
+
export const POSTHOG_PLACEHOLDER = "phc_REPLACE_ME";
|
|
36
|
+
export const POSTHOG_KEY: string =
|
|
37
|
+
process.env.POSTHOG_API_KEY ?? "phc_B8y7hMMmHYVEmkUfiLfuBcWoWM5GjbnaT9oBZLZnyPB3";
|
|
38
|
+
export const POSTHOG_HOST: string = process.env.POSTHOG_HOST ?? "https://eu.i.posthog.com";
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The whole latency budget telemetry is allowed to cost a run: one POST,
|
|
42
|
+
* aborted at this cap, no retries (§134). A metrics request must never be
|
|
43
|
+
* the slowest part of somebody's render.
|
|
44
|
+
*/
|
|
45
|
+
export const FLUSH_TIMEOUT_MS = 2500;
|
|
46
|
+
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
// Pure
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
const freshDefaults = () => ({
|
|
52
|
+
anonymousId: randomUUID(),
|
|
53
|
+
enabled: true,
|
|
54
|
+
noticeShown: false,
|
|
55
|
+
produceCount: 0,
|
|
56
|
+
ratingAsked: 0,
|
|
57
|
+
ratingDone: false,
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Per-field `.catch` so a hand-edited or truncated telemetry.json degrades a
|
|
62
|
+
* FIELD at a time (a corrupt `enabled` must not throw away the anonymousId),
|
|
63
|
+
* and an outer `.catch` so a document that isn't even an object resets whole
|
|
64
|
+
* — a corrupt state file must never crash the CLI it is riding in.
|
|
65
|
+
*/
|
|
66
|
+
export const TelemetryStateSchema = z
|
|
67
|
+
.object({
|
|
68
|
+
anonymousId: z.string().min(1).catch(() => randomUUID()),
|
|
69
|
+
enabled: z.boolean().catch(true),
|
|
70
|
+
noticeShown: z.boolean().catch(false),
|
|
71
|
+
produceCount: z.number().int().min(0).catch(0),
|
|
72
|
+
ratingAsked: z.number().int().min(0).catch(0),
|
|
73
|
+
ratingDone: z.boolean().catch(false),
|
|
74
|
+
})
|
|
75
|
+
.catch(freshDefaults);
|
|
76
|
+
export type TelemetryState = z.infer<typeof TelemetryStateSchema>;
|
|
77
|
+
|
|
78
|
+
export function defaultTelemetryState(): TelemetryState {
|
|
79
|
+
return freshDefaults();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export type TelemetryOffReason = "placeholder-key" | "env" | "do-not-track" | "config";
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Why telemetry is off, or null when it is on. Precedence, most global first:
|
|
86
|
+
* a keyless build can never send (§134's hard-off invariant), an exported env
|
|
87
|
+
* var is the user's word in THIS shell, DO_NOT_TRACK is the ecosystem-wide
|
|
88
|
+
* spelling of the same word, and the config file is the persisted preference.
|
|
89
|
+
* The reason (not just the boolean) exists for `ossclip telemetry status`,
|
|
90
|
+
* which must name the switch that won.
|
|
91
|
+
*/
|
|
92
|
+
export function telemetryOffReason(
|
|
93
|
+
env: NodeJS.ProcessEnv,
|
|
94
|
+
state: { enabled: boolean },
|
|
95
|
+
apiKey: string = POSTHOG_KEY,
|
|
96
|
+
): TelemetryOffReason | null {
|
|
97
|
+
if (apiKey === POSTHOG_PLACEHOLDER) return "placeholder-key";
|
|
98
|
+
if (["0", "false", "off"].includes(env.OSSCLIP_TELEMETRY ?? "")) return "env";
|
|
99
|
+
if (["1", "true"].includes(env.DO_NOT_TRACK ?? "")) return "do-not-track";
|
|
100
|
+
if (state.enabled === false) return "config";
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function telemetryDisabled(
|
|
105
|
+
env: NodeJS.ProcessEnv,
|
|
106
|
+
state: { enabled: boolean },
|
|
107
|
+
apiKey: string = POSTHOG_KEY,
|
|
108
|
+
): boolean {
|
|
109
|
+
return telemetryOffReason(env, state, apiKey) !== null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Substrings no event prop key may contain, case-insensitively. This is the
|
|
114
|
+
* privacy promise as code: a future `source_path` or `intentText` prop fails
|
|
115
|
+
* loudly in the unit tests instead of quietly shipping user data. "text" and
|
|
116
|
+
* "hook" cover the producer's copy fields; "key" covers credentials.
|
|
117
|
+
*/
|
|
118
|
+
export const FORBIDDEN_PROP_KEYS = [
|
|
119
|
+
"path",
|
|
120
|
+
"file",
|
|
121
|
+
"dir",
|
|
122
|
+
"transcript",
|
|
123
|
+
"intent",
|
|
124
|
+
"prompt",
|
|
125
|
+
"key",
|
|
126
|
+
"hook",
|
|
127
|
+
"text",
|
|
128
|
+
] as const;
|
|
129
|
+
|
|
130
|
+
export function assertSafeProps(props: Record<string, unknown>): void {
|
|
131
|
+
for (const key of Object.keys(props)) {
|
|
132
|
+
const lower = key.toLowerCase();
|
|
133
|
+
for (const forbidden of FORBIDDEN_PROP_KEYS) {
|
|
134
|
+
if (lower.includes(forbidden)) {
|
|
135
|
+
throw new Error(
|
|
136
|
+
`telemetry prop "${key}" contains forbidden substring "${forbidden}" — ` +
|
|
137
|
+
"event props must never carry paths, names, text or keys (FINDINGS §134)",
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export interface EventContext {
|
|
145
|
+
anonymousId: string;
|
|
146
|
+
version: string;
|
|
147
|
+
platform: string;
|
|
148
|
+
arch: string;
|
|
149
|
+
nodeMajor: number;
|
|
150
|
+
ci: boolean;
|
|
151
|
+
/** Injectable for tests; production always rides POSTHOG_KEY. */
|
|
152
|
+
apiKey?: string;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** The PostHog capture body — https://posthog.com/docs/api/capture */
|
|
156
|
+
export interface CaptureEvent {
|
|
157
|
+
api_key: string;
|
|
158
|
+
event: string;
|
|
159
|
+
distinct_id: string;
|
|
160
|
+
timestamp: string;
|
|
161
|
+
properties: Record<string, unknown>;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function buildEvent(
|
|
165
|
+
name: string,
|
|
166
|
+
props: Record<string, unknown>,
|
|
167
|
+
ctx: EventContext,
|
|
168
|
+
): CaptureEvent {
|
|
169
|
+
assertSafeProps(props);
|
|
170
|
+
return {
|
|
171
|
+
api_key: ctx.apiKey ?? POSTHOG_KEY,
|
|
172
|
+
event: name,
|
|
173
|
+
distinct_id: ctx.anonymousId,
|
|
174
|
+
timestamp: new Date().toISOString(),
|
|
175
|
+
properties: {
|
|
176
|
+
...props,
|
|
177
|
+
app_version: ctx.version,
|
|
178
|
+
os: ctx.platform,
|
|
179
|
+
arch: ctx.arch,
|
|
180
|
+
node_major: ctx.nodeMajor,
|
|
181
|
+
ci: ctx.ci,
|
|
182
|
+
// Anonymous events, never person profiles — the PostHog-side half of
|
|
183
|
+
// the anonymity promise (and the cheaper event class, incidentally).
|
|
184
|
+
$process_person_profile: false,
|
|
185
|
+
},
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* The exact seconds never leave the machine — a duration is close to a
|
|
191
|
+
* fingerprint of a specific take, and a bucket answers the only question we
|
|
192
|
+
* have ("short-form or long-form users?") without carrying one.
|
|
193
|
+
*/
|
|
194
|
+
export function durationBucket(seconds: number): "<1m" | "1-5m" | "5-15m" | ">15m" {
|
|
195
|
+
if (seconds < 60) return "<1m";
|
|
196
|
+
if (seconds <= 300) return "1-5m";
|
|
197
|
+
if (seconds <= 900) return "5-15m";
|
|
198
|
+
return ">15m";
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Ask after the 3rd successful produce (someone still around at three runs
|
|
203
|
+
* has an opinion worth having), never again after an answer, and never more
|
|
204
|
+
* than twice — two Enter-skips is an answer too.
|
|
205
|
+
*/
|
|
206
|
+
export function shouldAskRating(state: TelemetryState): boolean {
|
|
207
|
+
return !state.ratingDone && state.ratingAsked < 2 && state.produceCount >= 3;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Parsed, not coerced (§93a shape): only a lone 1-5 counts. "3.5", "great"
|
|
212
|
+
* and "" are all a skip, never a Number()-mangled score.
|
|
213
|
+
*/
|
|
214
|
+
const RatingSchema = z
|
|
215
|
+
.string()
|
|
216
|
+
.trim()
|
|
217
|
+
.regex(/^[1-5]$/)
|
|
218
|
+
.transform(Number);
|
|
219
|
+
|
|
220
|
+
export function parseRating(line: string): number | null {
|
|
221
|
+
const parsed = RatingSchema.safeParse(line);
|
|
222
|
+
return parsed.success ? parsed.data : null;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* The one-time first-run notice. LOUD by design — an opt-out default is only
|
|
227
|
+
* honest if the opt-out is impossible to miss — and it states the privacy
|
|
228
|
+
* floor in the same breath.
|
|
229
|
+
*/
|
|
230
|
+
export const NOTICE = [
|
|
231
|
+
"▸ ossclip collects anonymous usage events — command counts, durations, the LLM",
|
|
232
|
+
" provider name. It NEVER sends your footage, transcripts, file names, paths,",
|
|
233
|
+
" or prompts. Turn it off any time: `ossclip telemetry off` or OSSCLIP_TELEMETRY=0.",
|
|
234
|
+
' Details and the full event list: README, "Telemetry".',
|
|
235
|
+
].join("\n");
|
|
236
|
+
|
|
237
|
+
export const RATING_PROMPT = "Rate ossclip so far? 1-5, Enter to skip: ";
|
|
238
|
+
|
|
239
|
+
// ---------------------------------------------------------------------------
|
|
240
|
+
// I/O
|
|
241
|
+
// ---------------------------------------------------------------------------
|
|
242
|
+
|
|
243
|
+
export function telemetryStatePath(configDir: string = CONFIG_DIR): string {
|
|
244
|
+
return join(configDir, "telemetry.json");
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Read-only: a load never writes. Persistence happens only at the explicit
|
|
249
|
+
* saveState call sites (notice shown, produce counted, rating answered,
|
|
250
|
+
* on/off toggled) — which is what lets the placeholder-key build guarantee
|
|
251
|
+
* "no state writes" by simply never reaching those sites (§134).
|
|
252
|
+
*/
|
|
253
|
+
export function loadState(configDir: string = CONFIG_DIR): TelemetryState {
|
|
254
|
+
let raw: string;
|
|
255
|
+
try {
|
|
256
|
+
raw = readFileSync(telemetryStatePath(configDir), "utf8");
|
|
257
|
+
} catch {
|
|
258
|
+
return defaultTelemetryState(); // no file yet — first run
|
|
259
|
+
}
|
|
260
|
+
let parsed: unknown;
|
|
261
|
+
try {
|
|
262
|
+
parsed = JSON.parse(raw);
|
|
263
|
+
} catch {
|
|
264
|
+
parsed = undefined; // corrupt file → the schema's outer catch resets it
|
|
265
|
+
}
|
|
266
|
+
return TelemetryStateSchema.parse(parsed);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export function saveState(state: TelemetryState, configDir: string = CONFIG_DIR): void {
|
|
270
|
+
mkdirSync(configDir, { recursive: true });
|
|
271
|
+
// 0600: the anonymousId is not a secret, but a state file only its owner
|
|
272
|
+
// can read costs nothing and forecloses the question.
|
|
273
|
+
writeFileSync(telemetryStatePath(configDir), `${JSON.stringify(state, null, 2)}\n`, {
|
|
274
|
+
mode: 0o600,
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function cliVersion(): string {
|
|
279
|
+
// Same manifest-read as program.ts's .version() (R22 §113) — a literal here
|
|
280
|
+
// would report a stale number for the life of every release after it.
|
|
281
|
+
try {
|
|
282
|
+
return (
|
|
283
|
+
JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")) as {
|
|
284
|
+
version: string;
|
|
285
|
+
}
|
|
286
|
+
).version;
|
|
287
|
+
} catch {
|
|
288
|
+
return "0.0.0";
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function isCi(env: NodeJS.ProcessEnv): boolean {
|
|
293
|
+
return env.CI !== undefined && !["", "0", "false"].includes(env.CI);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* One instance per CLI invocation. `record` buffers, `flush` sends one batch;
|
|
298
|
+
* both are internally safe — telemetry must never break, slow (beyond
|
|
299
|
+
* FLUSH_TIMEOUT_MS) or noisy-up a run, so errors are swallowed HERE rather
|
|
300
|
+
* than try/caught at every call site.
|
|
301
|
+
*/
|
|
302
|
+
export class Telemetry {
|
|
303
|
+
private readonly events: CaptureEvent[] = [];
|
|
304
|
+
private readonly ctx: EventContext;
|
|
305
|
+
|
|
306
|
+
constructor(
|
|
307
|
+
private readonly env: NodeJS.ProcessEnv,
|
|
308
|
+
readonly state: TelemetryState,
|
|
309
|
+
private readonly fetchImpl: typeof fetch = fetch,
|
|
310
|
+
ctx?: Partial<EventContext>,
|
|
311
|
+
) {
|
|
312
|
+
this.ctx = {
|
|
313
|
+
anonymousId: ctx?.anonymousId ?? state.anonymousId,
|
|
314
|
+
version: ctx?.version ?? cliVersion(),
|
|
315
|
+
platform: ctx?.platform ?? process.platform,
|
|
316
|
+
arch: ctx?.arch ?? process.arch,
|
|
317
|
+
nodeMajor: ctx?.nodeMajor ?? Number.parseInt(process.versions.node, 10),
|
|
318
|
+
ci: ctx?.ci ?? isCi(env),
|
|
319
|
+
apiKey: ctx?.apiKey,
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
get disabled(): boolean {
|
|
324
|
+
return telemetryDisabled(this.env, this.state, this.ctx.apiKey ?? POSTHOG_KEY);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
record(name: string, props: Record<string, unknown> = {}): void {
|
|
328
|
+
try {
|
|
329
|
+
if (this.disabled) return;
|
|
330
|
+
this.events.push(buildEvent(name, props, this.ctx));
|
|
331
|
+
} catch {
|
|
332
|
+
// Fail CLOSED: a prop key tripping assertSafeProps means the event
|
|
333
|
+
// would have carried something the privacy floor forbids — dropping it
|
|
334
|
+
// is the correct production outcome (no data beats wrong data), and
|
|
335
|
+
// the unit tests on buildEvent are where the throw is seen and fixed.
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
async flush(): Promise<void> {
|
|
340
|
+
const batch = this.events.splice(0);
|
|
341
|
+
if (batch.length === 0) return;
|
|
342
|
+
const ac = new AbortController();
|
|
343
|
+
const timer = setTimeout(() => ac.abort(), FLUSH_TIMEOUT_MS);
|
|
344
|
+
try {
|
|
345
|
+
// Raced against our own abort, not just handed the signal: an injected
|
|
346
|
+
// fetch (tests) or a polyfill that ignores `signal` would otherwise
|
|
347
|
+
// hang this await past the cap — the exact promise flush exists to keep.
|
|
348
|
+
await Promise.race([
|
|
349
|
+
this.fetchImpl(`${POSTHOG_HOST}/batch/`, {
|
|
350
|
+
method: "POST",
|
|
351
|
+
headers: { "content-type": "application/json" },
|
|
352
|
+
body: JSON.stringify({ api_key: this.ctx.apiKey ?? POSTHOG_KEY, batch }),
|
|
353
|
+
signal: ac.signal,
|
|
354
|
+
}),
|
|
355
|
+
new Promise<void>((resolveRace) => {
|
|
356
|
+
ac.signal.addEventListener("abort", () => resolveRace(), { once: true });
|
|
357
|
+
}),
|
|
358
|
+
]);
|
|
359
|
+
} catch {
|
|
360
|
+
// Swallow everything — refused, offline, DNS, 4xx/5xx, abort. No
|
|
361
|
+
// retries either: a metrics batch is not worth a second attempt's
|
|
362
|
+
// latency, and the next run sends fresh events anyway.
|
|
363
|
+
} finally {
|
|
364
|
+
clearTimeout(timer);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/** Prints the first-run notice once and persists the flag. */
|
|
370
|
+
export function maybeShowNotice(
|
|
371
|
+
state: TelemetryState,
|
|
372
|
+
out: (s: string) => void,
|
|
373
|
+
configDir: string = CONFIG_DIR,
|
|
374
|
+
): void {
|
|
375
|
+
if (state.noticeShown) return;
|
|
376
|
+
out(NOTICE);
|
|
377
|
+
state.noticeShown = true;
|
|
378
|
+
saveState(state, configDir);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* The per-invocation entry point program.ts calls before dispatch. With the
|
|
383
|
+
* placeholder key this returns an inert instance WITHOUT touching disk —
|
|
384
|
+
* shipping keyless must be silent (§134), and it is also what keeps every
|
|
385
|
+
* `buildProgram()` in the test suite from writing ~/.ossclip.
|
|
386
|
+
*/
|
|
387
|
+
export function bootstrapTelemetry(
|
|
388
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
389
|
+
out: (s: string) => void = console.log,
|
|
390
|
+
): Telemetry {
|
|
391
|
+
if (POSTHOG_KEY === POSTHOG_PLACEHOLDER) {
|
|
392
|
+
return new Telemetry(env, defaultTelemetryState());
|
|
393
|
+
}
|
|
394
|
+
// Env-level off (OSSCLIP_TELEMETRY / DO_NOT_TRACK) returns BEFORE any disk
|
|
395
|
+
// touch: a run the user switched off must not read or create ~/.ossclip
|
|
396
|
+
// state — and it is also what keeps the test suite (which exports
|
|
397
|
+
// OSSCLIP_TELEMETRY=0 from vitest.config.ts) out of the real home dir.
|
|
398
|
+
if (telemetryDisabled(env, { enabled: true })) {
|
|
399
|
+
return new Telemetry(env, defaultTelemetryState());
|
|
400
|
+
}
|
|
401
|
+
let telemetry: Telemetry;
|
|
402
|
+
try {
|
|
403
|
+
const state = loadState();
|
|
404
|
+
telemetry = new Telemetry(env, state);
|
|
405
|
+
if (!telemetry.disabled && !state.noticeShown) {
|
|
406
|
+
maybeShowNotice(state, out);
|
|
407
|
+
// Piggybacks on noticeShown — one flag, sent exactly once. Flushed
|
|
408
|
+
// fire-and-forget because the first command may be one that never
|
|
409
|
+
// flushes itself (doctor, setup); the request rides while it runs.
|
|
410
|
+
telemetry.record("cli_first_run", {});
|
|
411
|
+
void telemetry.flush();
|
|
412
|
+
}
|
|
413
|
+
} catch {
|
|
414
|
+
// A read-only home dir or a hostile state file must never take the CLI
|
|
415
|
+
// down — telemetry degrades to inert, the run proceeds.
|
|
416
|
+
telemetry = new Telemetry(env, defaultTelemetryState());
|
|
417
|
+
}
|
|
418
|
+
return telemetry;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* The one-time rating ask, AFTER the run's own output so it is the last thing
|
|
423
|
+
* on screen. TTY-only and CI-excluded: a prompt into a pipe is a hang, and a
|
|
424
|
+
* prompt at a CI log is noise. Internally safe like the class methods.
|
|
425
|
+
*/
|
|
426
|
+
export async function maybeAskRating(
|
|
427
|
+
telemetry: Telemetry,
|
|
428
|
+
opts: {
|
|
429
|
+
isTTY?: boolean;
|
|
430
|
+
ci?: boolean;
|
|
431
|
+
ask?: () => Promise<string | null>;
|
|
432
|
+
configDir?: string;
|
|
433
|
+
} = {},
|
|
434
|
+
): Promise<void> {
|
|
435
|
+
try {
|
|
436
|
+
if (telemetry.disabled) return;
|
|
437
|
+
if (!shouldAskRating(telemetry.state)) return;
|
|
438
|
+
const tty = opts.isTTY ?? (process.stdout.isTTY === true && process.stdin.isTTY === true);
|
|
439
|
+
if (!tty) return;
|
|
440
|
+
if (opts.ci ?? isCi(process.env)) return;
|
|
441
|
+
const line = await (opts.ask ?? askRatingOnce)();
|
|
442
|
+
const score = line === null ? null : parseRating(line);
|
|
443
|
+
if (score !== null) {
|
|
444
|
+
telemetry.record("rating_submitted", { score });
|
|
445
|
+
telemetry.state.ratingDone = true;
|
|
446
|
+
} else {
|
|
447
|
+
// Empty, invalid, or timed out — all a skip; two skips end the asking.
|
|
448
|
+
telemetry.state.ratingAsked += 1;
|
|
449
|
+
}
|
|
450
|
+
saveState(telemetry.state, opts.configDir);
|
|
451
|
+
} catch {
|
|
452
|
+
// A rating prompt failing must never fail the produce that preceded it.
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
async function askRatingOnce(): Promise<string | null> {
|
|
457
|
+
const { createInterface } = await import("node:readline/promises");
|
|
458
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
459
|
+
try {
|
|
460
|
+
// 30s and gone: an unattended terminal must not hold the process open —
|
|
461
|
+
// a timeout is just Enter pressed by nobody.
|
|
462
|
+
return await rl.question(RATING_PROMPT, { signal: AbortSignal.timeout(30_000) });
|
|
463
|
+
} catch {
|
|
464
|
+
return null;
|
|
465
|
+
} finally {
|
|
466
|
+
rl.close();
|
|
467
|
+
}
|
|
468
|
+
}
|