/** * Telemetry payload assembly. * * The payload carries the command lifecycle plus the FULL redacted content of * the project `cordis.yml` and `package.json`. It NEVER reads or includes `.env` * — secrets live only in `.env`, and the redactor is the backstop for any that * leak into the two reported files. A file that does not exist (the first * `create` run) simply omits its field, and `package.json` ships only when * `cordis.yml` is present: without it the directory is not an SDK project, and * its manifest belongs to whatever unrelated project the command ran in. * * @module @deepseek-ai/dsh-telemetry/payload */ import { readFile } from 'node:fs/promises' import { join } from 'node:path' import { SecretRedactor } from './secret-redactor.ts' /** Project files whose full (redacted) content ships with the payload. */ const REPORTED_FILES = ['cordis.yml', 'package.json'] as const /** One command's telemetry payload. */ export interface TelemetryPayload { /** The dsh-sdk command that ran (`start`/`dev`/`build`/`config`/`create`). */ command: string /** Wall-clock duration of the command in milliseconds. */ durationMs: number /** Whether the command completed without error. */ success: boolean /** Redacted full text of the project `cordis.yml`, absent when the file does not exist. */ cordisYmlContent?: string /** Redacted full text of the project `package.json`, absent when it or `cordis.yml` does not exist. */ packageJsonContent?: string } /** Inputs for {@link buildTelemetryPayload}. */ export interface BuildTelemetryPayloadInput { /** The dsh-sdk command that ran. */ command: string /** Wall-clock duration of the command in milliseconds. */ durationMs: number /** Whether the command completed without error. */ success: boolean /** Project root whose `cordis.yml` and `package.json` are read. */ projectDir: string /** Redactor applied to reported file content; defaults to a fresh {@link SecretRedactor}. */ redactor?: SecretRedactor } /** Read a project file's text, returning `undefined` when it cannot be read. */ async function readReportedFile(projectDir: string, name: string): Promise { try { return await readFile(join(projectDir, name), 'utf8') } catch { // Missing/unreadable reported file: telemetry omits the field rather than fail. return undefined } } /** * Assemble a redacted telemetry payload for one command invocation. * @param input - command lifecycle facts, project directory, and optional redactor. * @returns the payload with redacted `cordis.yml`/`package.json` content. */ export async function buildTelemetryPayload(input: BuildTelemetryPayloadInput): Promise { const redactor = input.redactor ?? new SecretRedactor() const [cordisYml, packageJson] = await Promise.all( REPORTED_FILES.map(name => readReportedFile(input.projectDir, name)), ) return { command: input.command, durationMs: input.durationMs, success: input.success, ...cordisYml !== undefined ? { cordisYmlContent: redactor.redactText(cordisYml) } : {}, // package.json is an SDK-project manifest only alongside cordis.yml; a // command run in an arbitrary directory must not upload that directory's // unrelated manifest. ...cordisYml !== undefined && packageJson !== undefined ? { packageJsonContent: redactor.redactText(packageJson) } : {}, } }