Merge master into worktree-windows-runtime
This commit is contained in:
@@ -13,10 +13,15 @@
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/bin.js",
|
||||
"lib/assets",
|
||||
"lib/types/**/*.d.ts",
|
||||
@@ -29,9 +34,11 @@
|
||||
"commander": "^15.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
|
||||
30
packages/sdk/create-sdk/src/invariant.ts
Normal file
30
packages/sdk/create-sdk/src/invariant.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/create-sdk`.
|
||||
* @module @deepseek-ai/create-sdk/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/create-sdk'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'create-sdk-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: this SDK build-time package owns no live event stream or mutable data;
|
||||
* generated output and consumer tests cover its contract.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
@@ -6,7 +6,14 @@
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [
|
||||
{ "path": "../helper" },
|
||||
{ "path": "../../../vendor/cordis" }
|
||||
{
|
||||
"path": "../helper"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { defineConfig } from 'tsdown'
|
||||
|
||||
/** Bundle the library and create bin, then mirror package-owned terminal templates. */
|
||||
export default defineConfig({
|
||||
entry: ['lib/types/index.js', 'lib/types/bin.js'],
|
||||
entry: ['lib/types/index.js', 'lib/types/invariant.js', 'lib/types/bin.js'],
|
||||
outDir: 'lib',
|
||||
format: ['esm'],
|
||||
platform: 'node',
|
||||
|
||||
@@ -10,10 +10,15 @@
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/assets",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
@@ -29,12 +34,14 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-brand": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-brand": "workspace:^",
|
||||
"@deepseek-ai/dsh-hooks-claude": "workspace:^",
|
||||
"@deepseek-ai/dsh-hooks-codex": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence-sqlite": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-subagent": "workspace:^",
|
||||
|
||||
@@ -15,8 +15,19 @@ import type {
|
||||
PackageScriptResource,
|
||||
} from '../resources.ts'
|
||||
|
||||
/** Return the installable package name for a bare package or package subpath. */
|
||||
function installablePackageName(specifier: string): string {
|
||||
const segments = specifier.split('/')
|
||||
const expectedSegments = specifier.startsWith('@') ? 2 : 1
|
||||
if (segments.length < expectedSegments || segments.slice(0, expectedSegments).some(segment => segment.length === 0)) {
|
||||
throw new Error(`invalid bare package specifier: ${JSON.stringify(specifier)}`)
|
||||
}
|
||||
return segments.slice(0, expectedSegments).join('/')
|
||||
}
|
||||
|
||||
/** Create a runtime NPM dependency resource. */
|
||||
function npmDependency(_owner: string, name: string): NpmDependencyResource {
|
||||
function npmDependency(_owner: string, specifier: string): NpmDependencyResource {
|
||||
const name = installablePackageName(specifier)
|
||||
return {
|
||||
kind: 'npm-dependency',
|
||||
key: resourceKey(`npm-dependency:${name}`),
|
||||
@@ -52,7 +63,7 @@ export function cordisConfigEntry(
|
||||
}
|
||||
}
|
||||
|
||||
/** Couple one bare-package Cordis config entry to its mandatory runtime NPM dependency. */
|
||||
/** Couple one bare-package or subpath Cordis entry to its installable NPM package. */
|
||||
export function npmCordisConfigEntry(
|
||||
owner: string,
|
||||
value: CordisConfigEntry,
|
||||
|
||||
@@ -9,7 +9,7 @@ import type { ProjectProfile } from '../../project/types.ts'
|
||||
import { loadHelperTemplate } from '../../templates/template-assets.ts'
|
||||
import { FeatureOption, FixedFeature } from '../feature.ts'
|
||||
import { ProjectContribution } from '../resources.ts'
|
||||
import { npmCordisConfigEntry, requiredString } from './helpers.ts'
|
||||
import { cordisConfigEntry, npmCordisConfigEntry, requiredString } from './helpers.ts'
|
||||
|
||||
const ID = featureId('spine')
|
||||
const PERSONA = loadHelperTemplate<Record<string, never>>('persona.txt.tpl').render({}).trimEnd()
|
||||
@@ -37,6 +37,10 @@ class SpineOption extends FeatureOption {
|
||||
...npmCordisConfigEntry(ID, { id: 'tools', name: '@deepseek-ai/dsh-tools' }, []),
|
||||
...npmCordisConfigEntry(ID, { id: 'agent', name: '@deepseek-ai/dsh-agent' }),
|
||||
...npmCordisConfigEntry(ID, { id: 'invariants', name: '@deepseek-ai/dsh-invariants' }),
|
||||
cordisConfigEntry(ID, { id: 'session-invariant', name: '@deepseek-ai/dsh-session/invariant' }),
|
||||
cordisConfigEntry(ID, { id: 'agent-invariant', name: '@deepseek-ai/dsh-agent/invariant' }),
|
||||
...npmCordisConfigEntry(ID, { id: 'scope-invariant', name: '@deepseek-ai/dsh-scope/invariant' }),
|
||||
cordisConfigEntry(ID, { id: 'agent-loop-invariant', name: '@deepseek-ai/dsh-agent-loop/invariant' }),
|
||||
...npmCordisConfigEntry(ID, {
|
||||
id: 'agent-loop',
|
||||
name: '@deepseek-ai/dsh-agent-loop',
|
||||
|
||||
30
packages/sdk/helper/src/invariant.ts
Normal file
30
packages/sdk/helper/src/invariant.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-helper`.
|
||||
* @module @deepseek-ai/dsh-helper/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-helper'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'helper-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: this SDK build-time package owns no live event stream or mutable data;
|
||||
* generated output and consumer tests cover its contract.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
@@ -188,9 +188,15 @@ describe('SdkProject and ProjectEditSession', () => {
|
||||
.toContain('sessionId: !!js process.env.DSH_SDK_SESSION_ID')
|
||||
expect(project.cordis.entry('tui')?.config).not.toHaveProperty('model')
|
||||
expect(project.cordis.entry('agent-loop')?.config).toEqual({ agents: [] })
|
||||
expect(project.cordis.entry('session-invariant')?.name).toBe('@deepseek-ai/dsh-session/invariant')
|
||||
expect(project.cordis.entry('agent-invariant')?.name).toBe('@deepseek-ai/dsh-agent/invariant')
|
||||
expect(project.cordis.entry('scope-invariant')?.name).toBe('@deepseek-ai/dsh-scope/invariant')
|
||||
expect(project.cordis.entry('agent-loop-invariant')?.name).toBe('@deepseek-ai/dsh-agent-loop/invariant')
|
||||
expect(project.cordis.entry('system-prompt')?.config?.persona).toContain('{{cwd}}')
|
||||
expect(project.packageManifest().dependencies?.['@cordisjs/plugin-timer']).toBe('^1.1.2')
|
||||
expect(project.packageManifest().dependencies?.['@cordisjs/plugin-hmr']).toBe('^1.0.15')
|
||||
expect(project.packageManifest().dependencies?.['@deepseek-ai/dsh-scope']).toBe('^0.0.1')
|
||||
expect(project.packageManifest().dependencies).not.toHaveProperty('@deepseek-ai/dsh-scope/invariant')
|
||||
expect(project.packageManifest().dependencies).not.toHaveProperty('node-addon-require-builtin')
|
||||
expect(project.cordis.entry('hmr')).toMatchObject({ name: '@cordisjs/plugin-hmr' })
|
||||
expect(project.cordis.entry('llm-deepseek')?.config).not.toHaveProperty('baseURL')
|
||||
@@ -888,6 +894,11 @@ describe('extension points', () => {
|
||||
expect(stringArray({ value: [1] }, 'value')).toHaveLength(1)
|
||||
expect(cordisConfigEntry('owner', { id: 'entry', name: 'pkg' }).ownedConfigKeys).toEqual([])
|
||||
expect(npmCordisConfigEntry('owner', { id: 'entry', name: 'pkg' })[1].ownedConfigKeys).toEqual([])
|
||||
expect(npmCordisConfigEntry('owner', { id: 'entry', name: '@scope/pkg/subpath' })[0].name).toBe('@scope/pkg')
|
||||
expect(npmCordisConfigEntry('owner', { id: 'entry', name: 'pkg/subpath' })[0].name).toBe('pkg')
|
||||
for (const invalid of ['', '@scope', '@scope/']) {
|
||||
expect(() => npmCordisConfigEntry('owner', { id: 'entry', name: invalid })).toThrow('invalid bare package specifier')
|
||||
}
|
||||
expect(environmentResource('owner', 'EMPTY', undefined)).not.toHaveProperty('value')
|
||||
const builtins = createBuiltinRegistry(profile)
|
||||
expect(builtins.get(featureId('app')).defaultOptions(profile)).toEqual(['embed'])
|
||||
|
||||
@@ -6,14 +6,35 @@
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [
|
||||
{ "path": "../../util/brand" },
|
||||
{ "path": "../../compact/compact-basic" },
|
||||
{ "path": "../../hooks/hooks-claude" },
|
||||
{ "path": "../../hooks/hooks-codex" },
|
||||
{ "path": "../../session-persistence/session-persistence-jsonl" },
|
||||
{ "path": "../../session-persistence/session-persistence-sqlite" },
|
||||
{ "path": "../../subagent/tool-subagent" },
|
||||
{ "path": "../../web/tool-web" },
|
||||
{ "path": "../../../vendor/cordis" }
|
||||
{
|
||||
"path": "../../util/brand"
|
||||
},
|
||||
{
|
||||
"path": "../../compact/compact-basic"
|
||||
},
|
||||
{
|
||||
"path": "../../hooks/hooks-claude"
|
||||
},
|
||||
{
|
||||
"path": "../../hooks/hooks-codex"
|
||||
},
|
||||
{
|
||||
"path": "../../session-persistence/session-persistence-jsonl"
|
||||
},
|
||||
{
|
||||
"path": "../../session-persistence/session-persistence-sqlite"
|
||||
},
|
||||
{
|
||||
"path": "../../subagent/tool-subagent"
|
||||
},
|
||||
{
|
||||
"path": "../../web/tool-web"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { defineConfig } from 'tsdown'
|
||||
|
||||
/** Bundle helper runtime and mirror template assets beside the bundle. */
|
||||
export default defineConfig({
|
||||
entry: ['lib/types/index.js'],
|
||||
entry: ['lib/types/index.js', 'lib/types/invariant.js'],
|
||||
outDir: 'lib',
|
||||
format: ['esm'],
|
||||
platform: 'node',
|
||||
|
||||
@@ -14,6 +14,10 @@
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./dev/tsdown-config": {
|
||||
"types": "./lib/types/dev/tsdown-config.d.ts",
|
||||
"default": "./lib/dev/tsdown-config.js"
|
||||
@@ -21,6 +25,7 @@
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/bin.js",
|
||||
"lib/dev/tsdown-config.js",
|
||||
"lib/local-plugin-loader-hooks.js",
|
||||
@@ -38,16 +43,22 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-app-boot": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"tsdown": "^0.22.2",
|
||||
"tsx": "^4.22.4"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"tsdown": { "optional": true },
|
||||
"tsx": { "optional": true }
|
||||
"tsdown": {
|
||||
"optional": true
|
||||
},
|
||||
"tsx": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-app-boot": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"tsdown": "^0.22.2",
|
||||
"tsx": "^4.22.4"
|
||||
|
||||
30
packages/sdk/scripts/src/invariant.ts
Normal file
30
packages/sdk/scripts/src/invariant.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-scripts`.
|
||||
* @module @deepseek-ai/dsh-scripts/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-scripts'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'scripts-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: this SDK build-time package owns no live event stream or mutable data;
|
||||
* generated output and consumer tests cover its contract.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
@@ -135,6 +135,7 @@ describe('Commander launcher arguments', () => {
|
||||
expect(parseDshSdkArgs(['start'])).toEqual({ command: 'start', forwarded: [], help: false })
|
||||
expect(parseDshSdkArgs(['dev', 'index.ts'])).toMatchObject({ command: 'dev', target: 'index.ts' })
|
||||
expect(parseDshSdkArgs(['-h'])).toMatchObject({ help: true })
|
||||
expect(parseDshSdkArgs(['--help'])).toMatchObject({ help: true })
|
||||
expect(() => parseDshSdkArgs(['unknown'])).toThrow()
|
||||
expect(() => parseDshSdkArgs(['config', 'extra'])).toThrow()
|
||||
expect(() => parseDshSdkArgs(['config', '--', 'extra'])).toThrow('does not accept forwarded')
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
{ "path": "../helper" },
|
||||
{ "path": "../telemetry" },
|
||||
{ "path": "../../ui/app-boot" },
|
||||
{ "path": "../../../vendor/cordis" }
|
||||
{ "path": "../../../vendor/cordis" },
|
||||
{ "path": "../../support/invariants" }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -7,6 +7,10 @@ export default defineConfig([
|
||||
fixedExtension: false, outputOptions: { codeSplitting: false }, dts: false, clean: false,
|
||||
copy: [{ from: 'src/templates/assets/*', to: 'lib/assets' }],
|
||||
},
|
||||
{
|
||||
entry: ['lib/types/invariant.js'], outDir: 'lib', format: ['esm'], platform: 'node', target: 'es2024',
|
||||
fixedExtension: false, outputOptions: { codeSplitting: false }, dts: false, clean: false,
|
||||
},
|
||||
{
|
||||
entry: ['lib/types/bin.js'], outDir: 'lib', format: ['esm'], platform: 'node', target: 'es2024',
|
||||
fixedExtension: false, outputOptions: { codeSplitting: false }, dts: false, clean: false,
|
||||
|
||||
@@ -7,7 +7,7 @@ Launcher-side telemetry primitives for the dsh-sdk toolchain. This is a plain li
|
||||
| `SecretRedactor` | Conservative safety backstop: replaces secret-shaped values (secret-like keys, known token shapes, PEM blocks, URL credentials, high-entropy opaque tokens) with a placeholder in both parsed values (`redactValue`) and raw text (`redactText`). Never drops a field or line. |
|
||||
| `ConsentResolver` | Parses (never boots) a project `cordis.yml` and reads the telemetry entry's enabled/disabled state as consent; `DO_NOT_TRACK`/CI env force a hard opt-out. |
|
||||
| `buildTelemetryPayload` | Assembles `{command, durationMs, success, cordisYmlContent, packageJsonContent}`, running the redactor over the full `cordis.yml` and `package.json` text. Never reads `.env`; `package.json` ships only alongside a `cordis.yml`, so a command run in a non-SDK directory never uploads that directory's unrelated manifest. |
|
||||
| `getOrCreateAnonymousId` | Random UUID persisted in a per-user GLOBAL config file (never in the project, never derived from git). |
|
||||
| `getOrCreateAnonymousId` | Random UUID persisted in the harness home resolved by [`@deepseek-ai/dsh-paths`](../../util/paths/README.md) (`$DSH_HOME` > `~/.dsh`), scoped to that home rather than the machine, never derived from git. |
|
||||
| `TelemetryReporter` | Fire-and-forget send: `report()` never blocks or throws; delivery resolves on every path; `flush()` optionally drains in-flight sends within a cap. |
|
||||
|
||||
Consent is carried by the telemetry entry in `cordis.yml`, so disabling telemetry is disabling that entry. Telemetry reports by default and is off only when a present telemetry entry is explicitly `disabled`: a missing `cordis.yml` (first `create`), an enabled entry, or a `cordis.yml` with no telemetry entry all report. `DO_NOT_TRACK`/CI always deny. The no-config and absent-entry defaults are configurable on `ConsentResolver`.
|
||||
|
||||
@@ -11,11 +11,16 @@
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
@@ -26,10 +31,14 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-brand": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-paths": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-brand": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-paths": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,63 +1,50 @@
|
||||
/**
|
||||
* Per-machine anonymous telemetry id.
|
||||
* Per-harness-home anonymous telemetry id.
|
||||
*
|
||||
* The id is a random UUID persisted in a per-user GLOBAL config file — never in
|
||||
* the project, and never derived from the git remote, repository URL, or any
|
||||
* other identifying source (a derived id would make "anonymous" a fiction). The
|
||||
* same id is reused across projects on one machine so telemetry counts machines,
|
||||
* not repositories.
|
||||
* The id is a random UUID persisted directly in the harness home resolved by
|
||||
* {@link resolveDshHome} (`$DSH_HOME` > `~/.dsh`), and never derived from the
|
||||
* git remote, repository URL, or any other identifying source (a derived id
|
||||
* would make "anonymous" a fiction). The id is scoped to the harness home, not
|
||||
* the machine: every command sharing one `$DSH_HOME` reuses the same id, so the
|
||||
* default `~/.dsh` counts per-OS-user home directories, while a relocated
|
||||
* `$DSH_HOME` moves the id with the rest of the harness data — the single-root
|
||||
* convention this package shares, not a telemetry-specific policy.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-telemetry/anonymous-id
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import { homedir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import type { Branded } from '@deepseek-ai/dsh-brand'
|
||||
import { resolveDshHome } from '@deepseek-ai/dsh-paths'
|
||||
|
||||
/** A machine-scoped anonymous telemetry id (random UUID v4). */
|
||||
/** A harness-home-scoped anonymous telemetry id (random UUID v4). */
|
||||
export type AnonymousId = Branded<'AnonymousId'>
|
||||
|
||||
/** Config directory name owned by the DeepSeek Harness across tools. */
|
||||
const CONFIG_NAMESPACE = 'deepseek-harness'
|
||||
|
||||
/** Default file, inside the global config dir, storing the anonymous id. */
|
||||
/** Default file, inside the harness home, storing the anonymous id. */
|
||||
export const ANONYMOUS_ID_FILE_NAME = 'telemetry.json'
|
||||
|
||||
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
||||
|
||||
/** Ambient seams for locating and generating the id; every field has a default. */
|
||||
export interface AnonymousIdOptions {
|
||||
/** Environment consulted for `DSH_CONFIG_HOME`/`XDG_CONFIG_HOME`/`APPDATA`; defaults to `process.env`. */
|
||||
/** Environment consulted for `DSH_HOME`; defaults to `process.env`. */
|
||||
env?: NodeJS.ProcessEnv
|
||||
/** Platform string used to pick the Windows path; defaults to `process.platform`. */
|
||||
platform?: NodeJS.Platform
|
||||
/** Home directory resolver; defaults to `os.homedir`. */
|
||||
homeDir?: () => string
|
||||
/** UUID generator; defaults to `crypto.randomUUID` (test seam). */
|
||||
randomUUID?: () => string
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the per-user global config directory for harness tooling.
|
||||
* Precedence: `DSH_CONFIG_HOME` (explicit override) > `XDG_CONFIG_HOME` >
|
||||
* platform default (`%APPDATA%` on Windows, else `~/.config`).
|
||||
* @param options - environment, platform, and home-directory seams.
|
||||
* @returns absolute config directory path for the harness namespace.
|
||||
* Resolve the single-root harness home that stores the anonymous id.
|
||||
* Delegates to {@link resolveDshHome} so telemetry shares the harness's one
|
||||
* home-resolution policy (`DSH_HOME` > `~/.dsh`) instead of maintaining a
|
||||
* second config-directory convention.
|
||||
* @param options - environment seam.
|
||||
* @returns absolute harness home path.
|
||||
*/
|
||||
export function globalConfigDir(options: AnonymousIdOptions = {}): string {
|
||||
const env = options.env ?? process.env
|
||||
const platform = options.platform ?? process.platform
|
||||
const home = options.homeDir ?? homedir
|
||||
if (env.DSH_CONFIG_HOME !== undefined && env.DSH_CONFIG_HOME.length > 0) return env.DSH_CONFIG_HOME
|
||||
if (env.XDG_CONFIG_HOME !== undefined && env.XDG_CONFIG_HOME.length > 0) {
|
||||
return join(env.XDG_CONFIG_HOME, CONFIG_NAMESPACE)
|
||||
}
|
||||
if (platform === 'win32' && env.APPDATA !== undefined && env.APPDATA.length > 0) {
|
||||
return join(env.APPDATA, CONFIG_NAMESPACE)
|
||||
}
|
||||
return join(home(), '.config', CONFIG_NAMESPACE)
|
||||
return resolveDshHome(undefined, options.env ?? process.env)
|
||||
}
|
||||
|
||||
/** Read a valid persisted id from the store, or `undefined` when absent/corrupt. */
|
||||
@@ -84,11 +71,11 @@ async function readPersistedId(file: string): Promise<AnonymousId | undefined> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the machine's anonymous id, creating and persisting one on first use.
|
||||
* Return the harness home's anonymous id, creating and persisting one on first use.
|
||||
* Persistence is best-effort: a write failure still returns a usable id for the
|
||||
* current run so telemetry is never blocked by config-dir permissions.
|
||||
* @param options - config-location and UUID-generation seams.
|
||||
* @returns the stable per-machine anonymous id.
|
||||
* @returns the stable per-harness-home anonymous id.
|
||||
*/
|
||||
export async function getOrCreateAnonymousId(options: AnonymousIdOptions = {}): Promise<AnonymousId> {
|
||||
const file = join(globalConfigDir(options), ANONYMOUS_ID_FILE_NAME)
|
||||
|
||||
30
packages/sdk/telemetry/src/invariant.ts
Normal file
30
packages/sdk/telemetry/src/invariant.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-telemetry`.
|
||||
* @module @deepseek-ai/dsh-telemetry/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-telemetry'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'telemetry-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: this SDK build-time package owns no live event stream or mutable data;
|
||||
* generated output and consumer tests cover its contract.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
@@ -1,6 +1,7 @@
|
||||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { isAbsolute, join, resolve } from 'node:path'
|
||||
import { defaultDshHome } from '@deepseek-ai/dsh-paths'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
ANONYMOUS_ID_FILE_NAME,
|
||||
@@ -23,37 +24,26 @@ afterEach(async () => {
|
||||
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
||||
|
||||
describe('globalConfigDir', () => {
|
||||
it('prefers an explicit DSH_CONFIG_HOME override', () => {
|
||||
expect(globalConfigDir({ env: { DSH_CONFIG_HOME: '/custom/dsh' } })).toBe('/custom/dsh')
|
||||
it('prefers an explicit DSH_HOME override', () => {
|
||||
expect(globalConfigDir({ env: { DSH_HOME: '/custom/dsh' } })).toBe('/custom/dsh')
|
||||
})
|
||||
|
||||
it('falls back to XDG_CONFIG_HOME under the harness namespace', () => {
|
||||
expect(globalConfigDir({ env: { XDG_CONFIG_HOME: '/xdg' } })).toBe(join('/xdg', 'deepseek-harness'))
|
||||
})
|
||||
|
||||
it('uses %APPDATA% on Windows', () => {
|
||||
expect(globalConfigDir({ env: { APPDATA: 'C:/Users/x/AppData/Roaming' }, platform: 'win32' }))
|
||||
.toBe(join('C:/Users/x/AppData/Roaming', 'deepseek-harness'))
|
||||
})
|
||||
|
||||
it('falls back to ~/.config on Windows without APPDATA and on posix', () => {
|
||||
const home = () => '/home/dev'
|
||||
expect(globalConfigDir({ env: {}, platform: 'win32', homeDir: home }))
|
||||
.toBe(join('/home/dev', '.config', 'deepseek-harness'))
|
||||
expect(globalConfigDir({ env: {}, platform: 'linux', homeDir: home }))
|
||||
.toBe(join('/home/dev', '.config', 'deepseek-harness'))
|
||||
it('falls back to ~/.dsh when DSH_HOME is unset', () => {
|
||||
expect(globalConfigDir({ env: {} })).toBe(resolve(defaultDshHome()))
|
||||
})
|
||||
|
||||
it('reads process.env by default', () => {
|
||||
// No override supplied: the call must not throw and must return an absolute path.
|
||||
expect(globalConfigDir()).toContain('deepseek-harness')
|
||||
// The ambient DSH_HOME is unknown here, so assert only the invariant the
|
||||
// resolver guarantees rather than a specific location.
|
||||
expect(isAbsolute(globalConfigDir())).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getOrCreateAnonymousId', () => {
|
||||
it('creates, persists, and returns a UUID on first use', async () => {
|
||||
const dir = await tempDir()
|
||||
const id = await getOrCreateAnonymousId({ env: { DSH_CONFIG_HOME: dir } })
|
||||
const id = await getOrCreateAnonymousId({ env: { DSH_HOME: dir } })
|
||||
expect(id).toMatch(UUID)
|
||||
const stored: unknown = JSON.parse(await readFile(join(dir, ANONYMOUS_ID_FILE_NAME), 'utf8'))
|
||||
expect(stored).toEqual({ anonymousId: id })
|
||||
@@ -61,15 +51,15 @@ describe('getOrCreateAnonymousId', () => {
|
||||
|
||||
it('returns the same persisted id on subsequent calls', async () => {
|
||||
const dir = await tempDir()
|
||||
const first = await getOrCreateAnonymousId({ env: { DSH_CONFIG_HOME: dir } })
|
||||
const second = await getOrCreateAnonymousId({ env: { DSH_CONFIG_HOME: dir } })
|
||||
const first = await getOrCreateAnonymousId({ env: { DSH_HOME: dir } })
|
||||
const second = await getOrCreateAnonymousId({ env: { DSH_HOME: dir } })
|
||||
expect(second).toBe(first)
|
||||
})
|
||||
|
||||
it('uses the injected UUID generator', async () => {
|
||||
const dir = await tempDir()
|
||||
const id = await getOrCreateAnonymousId({
|
||||
env: { DSH_CONFIG_HOME: dir },
|
||||
env: { DSH_HOME: dir },
|
||||
randomUUID: () => '00000000-0000-4000-8000-000000000000',
|
||||
})
|
||||
expect(id).toBe('00000000-0000-4000-8000-000000000000')
|
||||
@@ -78,23 +68,23 @@ describe('getOrCreateAnonymousId', () => {
|
||||
it('regenerates when the stored file is corrupt JSON', async () => {
|
||||
const dir = await tempDir()
|
||||
await writeFile(join(dir, ANONYMOUS_ID_FILE_NAME), 'not json', 'utf8')
|
||||
const id = await getOrCreateAnonymousId({ env: { DSH_CONFIG_HOME: dir } })
|
||||
const id = await getOrCreateAnonymousId({ env: { DSH_HOME: dir } })
|
||||
expect(id).toMatch(UUID)
|
||||
})
|
||||
|
||||
it('regenerates when the stored value is not a valid UUID or object', async () => {
|
||||
const dir = await tempDir()
|
||||
await writeFile(join(dir, ANONYMOUS_ID_FILE_NAME), JSON.stringify({ anonymousId: 'nope' }), 'utf8')
|
||||
expect(await getOrCreateAnonymousId({ env: { DSH_CONFIG_HOME: dir } })).toMatch(UUID)
|
||||
expect(await getOrCreateAnonymousId({ env: { DSH_HOME: dir } })).toMatch(UUID)
|
||||
await writeFile(join(dir, ANONYMOUS_ID_FILE_NAME), '123', 'utf8')
|
||||
expect(await getOrCreateAnonymousId({ env: { DSH_CONFIG_HOME: dir } })).toMatch(UUID)
|
||||
expect(await getOrCreateAnonymousId({ env: { DSH_HOME: dir } })).toMatch(UUID)
|
||||
})
|
||||
|
||||
it('returns a usable id even when persistence fails', async () => {
|
||||
const dir = await tempDir()
|
||||
// A regular file where a directory is expected makes mkdir/writeFile fail.
|
||||
await writeFile(join(dir, 'blocker'), 'x', 'utf8')
|
||||
const id = await getOrCreateAnonymousId({ env: { DSH_CONFIG_HOME: join(dir, 'blocker') } })
|
||||
const id = await getOrCreateAnonymousId({ env: { DSH_HOME: join(dir, 'blocker') } })
|
||||
expect(id).toMatch(UUID)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{ "path": "../../util/brand" }
|
||||
{ "path": "../../util/brand" },
|
||||
{ "path": "../../util/paths" },
|
||||
{ "path": "../../support/invariants" }
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user