1
/**2
* Per-harness-home anonymous user id shared by telemetry and feedback.3
*4
* The id is a random UUID persisted as a bare line in `.anonymous-user-id` inside the5
* harness home resolved by {@link resolveDshHome} (`$DSH_HOME` > `~/.dsh`),6
* and never derived from the hostname, network address, git remote, or any7
* other identifying source. It is scoped to the harness home, not the8
* machine: every process sharing one `$DSH_HOME` reports the same id, and9
* deleting the file mints a fresh identity on the next launch.10
*11
* Reads and writes are synchronous so boot-time and command consumers can12
* use one API. The result is memoized per resolved file path: one process13
* touches the disk once, and a file deleted mid-run keeps the process's id14
* until the next launch.15
*16
* @module @deepseek-ai/dsh-anonymous-user-id17
*/19
import { randomUUID } from 'node:crypto'20
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'21
import { dirname, join } from 'node:path'22
import type { Branded } from '@deepseek-ai/dsh-brand'23
import { resolveDshHome } from '@deepseek-ai/dsh-home-paths'25
/** A harness-home-scoped anonymous user id (random UUID v4). */26
export type AnonymousUserId = Branded<'AnonymousUserId'>28
/** File inside the harness home storing the id: a bare UUID line, no wrapper format. */29
export const ANONYMOUS_USER_ID_FILE_NAME = '.anonymous-user-id'31
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i33
/** Ambient hooks for locating and generating the id; every field has a default. */34
export interface AnonymousUserIdOptions {35
/** Environment consulted for `DSH_HOME`; defaults to `process.env`. */36
env?: NodeJS.ProcessEnv37
/** UUID generator; defaults to `crypto.randomUUID` (test hook). */38
randomUUID?: () => string39
}41
/** Process-lifetime memo keyed by resolved file path, so distinct test homes never share an id. */42
const memo = new Map<string, AnonymousUserId>()44
/** Read a valid persisted id from the file, or `undefined` when absent/corrupt. */45
function readPersistedId(file: string): AnonymousUserId | undefined {46
let text: string47
try {48
text = readFileSync(file, 'utf8')49
} catch {50
// Absent or unreadable: the caller mints and persists a fresh id.51
return undefined52
}53
const value = text.trim()54
return UUID_PATTERN.test(value) ? (value as AnonymousUserId) : undefined55
}57
/**58
* Return the harness home's anonymous user id, creating and persisting one on59
* first use. A concurrent first launch is settled by an exclusive-create60
* write: the loser rereads the winner's id. (A reread landing in the winner's61
* narrow create-to-write window can still yield two per-process ids for that62
* run; the next launch converges on the persisted one.) Persistence is63
* best-effort — a write failure (read-only home) still returns a usable id64
* for the current run so feedback and telemetry are never blocked.65
* @param options - home-location and UUID-generation seams.66
* @returns the stable per-harness-home anonymous user id.67
*/68
export function getOrCreateAnonymousUserId(options: AnonymousUserIdOptions = {}): AnonymousUserId {69
const file = join(resolveDshHome(undefined, options.env ?? process.env), ANONYMOUS_USER_ID_FILE_NAME)70
const cached = memo.get(file)71
if (cached !== undefined) return cached73
let id = readPersistedId(file)74
if (id === undefined) {75
const generate = options.randomUUID ?? randomUUID76
const created = generate() as AnonymousUserId77
try {78
mkdirSync(dirname(file), { recursive: true })79
writeFileSync(file, `${created}\n`, { encoding: 'utf8', flag: 'wx' })80
id = created81
} catch {82
// A wx refusal (EEXIST) covers both a concurrent winner and a83
// pre-existing corrupt file: the reread adopts a valid winner, and an84
// invalid reread falls through to the overwrite path. Non-EEXIST85
// failures (read-only home) land there too, accepted best-effort below.86
id = readPersistedId(file)87
if (id === undefined) {88
try {89
writeFileSync(file, `${created}\n`, 'utf8')90
} catch {91
// Best-effort persistence: keep the fresh id in memory even when the92
// home is unwritable, so this run still reports a consistent id.93
}94
id = created95
}96
}97
}98
memo.set(file, id)99
return id100
}