Merge origin/master into feature/tui-first-run-welcome
# Conflicts: # apps/cli/README.i18n.yaml # apps/cli/README.md # apps/cli/README.zh.md # apps/cli/src/tui.ts # apps/cli/tests/tui-keyless-smoke.e2e.ts
This commit is contained in:
734 files changed
+19613
-3519
No files matched your search
@@ -1,10 +1,12 @@
|
||||
/**
|
||||
* AppCLIEntry — the pre-cordis boot glue the config-tree dsh surfaces share
|
||||
* for the Web/headless surface.
|
||||
* Everything here is what must exist before the Loader runs: layered env,
|
||||
* the patch composition over the shipped base and surface overlay (profile json + CLI
|
||||
* flags + the resolved frontend dist), and the fail-loud triple after the
|
||||
* tree settles.
|
||||
* Everything here is what must exist before the Loader runs: the patch
|
||||
* composition over the shipped base and surface overlay (profile json + CLI
|
||||
* flags + the resolved frontend dist), and the fail-loud triple after the tree
|
||||
* settles. The environment is what the bin already loaded (ambient plus the
|
||||
* invoking directory's `.env`); `$DSH_HOME/.env` belongs to the credential
|
||||
* provider and is never hoisted here.
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs'
|
||||
@@ -14,8 +16,7 @@ import { join, resolve } from 'node:path'
|
||||
import { Context } from 'cordis'
|
||||
import type { PatchOptions } from '@cordisjs/plugin-include'
|
||||
import yaml from 'js-yaml'
|
||||
import { boot, installFailLoud, loadEnv, loadOverlayPatches, loadPersonalPatches } from '@deepseek-ai/dsh-app-boot'
|
||||
import { resolveDshHome } from '@deepseek-ai/dsh-paths'
|
||||
import { boot, installFailLoud, loadOverlayPatches, loadPersonalPatches } from '@deepseek-ai/dsh-app-boot'
|
||||
// Empty type import carries the httpServer Context merge for the port read below.
|
||||
import type {} from '@deepseek-ai/dsh-host-webserver'
|
||||
|
||||
@@ -23,6 +24,9 @@ import type {} from '@deepseek-ai/dsh-host-webserver'
|
||||
const PROFILE_DIR = '.dsh-tmp-profile'
|
||||
const PROFILE_FILE = 'config.json'
|
||||
|
||||
/** The session-telemetry row id the DSH_TELEMETRY_DISABLED switch targets (mounted in web.cordis.yml). */
|
||||
const TELEMETRY_ROW_ID = 'telemetry-otel'
|
||||
|
||||
/** The webserver schema's all-interfaces bind literal: gates LAN-authority derivation here and the printed LAN URL in web.ts. */
|
||||
const ALL_INTERFACES_HOST = '0.0.0.0'
|
||||
|
||||
@@ -58,6 +62,38 @@ export function resolveLanTrust(
|
||||
return { lanAddresses, trustedHosts: [...lanAddresses, ...extra] }
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the telemetry opt-out switch into its boot patch. ANY non-empty
|
||||
* value (including `'0'`/`'false'`) disables: a privacy switch prefers
|
||||
* off-by-mistake over on-by-mistake. Throws when the switch is set but the
|
||||
* row is absent — a silently no-op "disabled" privacy switch would keep
|
||||
* exporting while the user believes it is off.
|
||||
* @param disabledEnv - the raw `DSH_TELEMETRY_DISABLED` value (`undefined` when unset).
|
||||
* @param hasRow - whether the composition carries the {@link TELEMETRY_ROW_ID} row.
|
||||
* @returns the disable patch, or `undefined` when telemetry stays enabled.
|
||||
*/
|
||||
export function resolveTelemetryPatch(disabledEnv: string | undefined, hasRow: boolean): PatchOptions | undefined {
|
||||
if ((disabledEnv ?? '') === '') return undefined
|
||||
if (!hasRow) {
|
||||
throw new Error(`dsh: DSH_TELEMETRY_DISABLED is set but row "${TELEMETRY_ROW_ID}" is not in this composition`)
|
||||
}
|
||||
return { id: TELEMETRY_ROW_ID, disabled: true }
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a config file carries the telemetry row, parsed under the same
|
||||
* `!!js`-tolerant dialect the boot uses — the `hasRow` input for launchers
|
||||
* that compose their patch lists outside {@link AppCLIEntry} (the TUI).
|
||||
* @param file - absolute path of the config or overlay file.
|
||||
* @returns true when a top-level (or inserted) row has the telemetry id.
|
||||
*/
|
||||
export function configHasTelemetryRow(file: string): boolean {
|
||||
const doc = yaml.load(readFileSync(file, 'utf8'), { schema: includeYamlSchema })
|
||||
if (!Array.isArray(doc)) throw new Error(`dsh: ${file} is not a top-level entry list`)
|
||||
return (doc as { id?: string; insert?: { id?: string }[] }[]).some(row =>
|
||||
row.id === TELEMETRY_ROW_ID || (row.insert ?? []).some(inserted => inserted.id === TELEMETRY_ROW_ID))
|
||||
}
|
||||
|
||||
/** One profile-json key mapped onto a yml row's config field. */
|
||||
interface ProfileMapping {
|
||||
jsonPath: string
|
||||
@@ -144,12 +180,11 @@ export class AppCLIEntry {
|
||||
constructor(private readonly options: AppCLIEntryOptions) {}
|
||||
|
||||
/**
|
||||
* Run the boot chain: layered env → patch composition → Loader include
|
||||
* boot (dev row before await) → fail-loud triple.
|
||||
* Run the boot chain: patch composition → Loader include boot (dev row
|
||||
* before await) → fail-loud triple.
|
||||
* @returns the settled root context and the listening port.
|
||||
*/
|
||||
async run(): Promise<{ ctx: Context; port: number }> {
|
||||
this.loadEnvLayers()
|
||||
this.composePatches()
|
||||
await this.bootTree()
|
||||
this.assertBoot()
|
||||
@@ -159,11 +194,6 @@ export class AppCLIEntry {
|
||||
return { ctx: this.ctx, port }
|
||||
}
|
||||
|
||||
/** Layered .env: ambient > cwd (bin already loaded) > $DSH_HOME (loadEnvFile never overrides). */
|
||||
private loadEnvLayers(): void {
|
||||
loadEnv('dsh', resolveDshHome())
|
||||
}
|
||||
|
||||
/**
|
||||
* Compose the patch set from profile json, CLI flags, and the resolved
|
||||
* frontend dist. Patches replace a row's config wholesale, so each patched row's yml
|
||||
@@ -208,6 +238,12 @@ export class AppCLIEntry {
|
||||
if (yml === undefined) throw new Error(`dsh: patch target row "${id}" not found in ${this.options.configPath}`)
|
||||
return { id, config: { ...(yml.config ?? {}) as Record<string, unknown>, ...bag } }
|
||||
})
|
||||
|
||||
// Telemetry opt-out: a row can only be turned off at the patch layer
|
||||
// (config cannot disable an entry), and the switch must hold BEFORE the
|
||||
// plugin constructs — its exporter.url validation is load-time fail-loud.
|
||||
const telemetryPatch = resolveTelemetryPatch(process.env.DSH_TELEMETRY_DISABLED, rows.has(TELEMETRY_ROW_ID))
|
||||
if (telemetryPatch !== undefined) this.patches.push(telemetryPatch)
|
||||
}
|
||||
|
||||
/** Shared Loader boot; the dev HMR row mounts before await so the fail-loud sweep covers it. */
|
||||
|
||||
@@ -82,6 +82,17 @@ export async function runHeadless(task: string): Promise<void> {
|
||||
})
|
||||
const { ctx, port } = await entry.run()
|
||||
const dispose = async (): Promise<void> => { await ctx.fiber.dispose() }
|
||||
// Signal exits must still dispose the tree: the composition mounts
|
||||
// exit-drained plugins (telemetry's queued tail and shutdown marker would
|
||||
// otherwise be lost), and Node's default signal exit skips disposal.
|
||||
let signalled = false
|
||||
const disposeAndExit = (code: number): void => {
|
||||
if (signalled) return
|
||||
signalled = true
|
||||
void dispose().finally(() => { process.exit(code) })
|
||||
}
|
||||
process.on('SIGTERM', () => { disposeAndExit(143) })
|
||||
process.on('SIGINT', () => { disposeAndExit(130) })
|
||||
// The headless session is web-observable while it runs (same composition).
|
||||
process.stderr.write(`dsh: observing at http://127.0.0.1:${String(port)}\n`)
|
||||
const api = new InProcessApiClient(toFetchHandler(ctx.apiProxy))
|
||||
|
||||
+23
-12
@@ -26,13 +26,13 @@ import {
|
||||
addHarnessSourceSection,
|
||||
boot,
|
||||
installFailLoud,
|
||||
loadEnv,
|
||||
loadOverlayPatches,
|
||||
loadPersonalPatches,
|
||||
resolveConfigPath,
|
||||
} from '@deepseek-ai/dsh-app-boot'
|
||||
import { resolveDshHome } from '@deepseek-ai/dsh-paths'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { configHasTelemetryRow, resolveTelemetryPatch } from './app-cli-entry.ts'
|
||||
import { SESSION_QUERY_SQLITE_PATH_KEY } from '@deepseek-ai/dsh-session-query-sqlite'
|
||||
import { CONFIGURED_AGENT_IDENTITIES_KEY } from '@deepseek-ai/dsh-agent-loop'
|
||||
import type { Context } from 'cordis'
|
||||
@@ -134,16 +134,17 @@ export async function runTui(
|
||||
process.exit(1)
|
||||
}
|
||||
installFailLoud(NAME)
|
||||
// The bin already loaded the invoking directory's .env; the personal .env
|
||||
// only fills what is still unset (process.loadEnvFile never overrides).
|
||||
const dshHome = resolveDshHome()
|
||||
loadEnv(NAME, dshHome)
|
||||
const showFirstRunWelcome = !await hasTuiFirstRunWelcomeAcknowledgement(
|
||||
dshHome,
|
||||
TUI_FIRST_RUN_WELCOME_NOTICE_VERSION,
|
||||
)
|
||||
// Both .env layers are loaded, so switching the workspace here cannot alter
|
||||
// environment precedence. The cwd IS the workspace seam: the shipped config
|
||||
// The bin already loaded the invoking directory's .env, and that is the
|
||||
// whole environment: $DSH_HOME/.env is credentials-local's writable store,
|
||||
// and hoisting it would make every stored key read as a read-only ambient
|
||||
// override on the next run — unrotatable from the TUI or the web page.
|
||||
// The environment is settled, so switching the workspace here cannot alter
|
||||
// its precedence. The cwd IS the workspace seam: the shipped config
|
||||
// resolves the session cwd and the HMR watch root from it, so one chdir moves
|
||||
// both together. Sessions themselves live under the Harness home so `/resume`
|
||||
// spans every workspace, and are unaffected by this chdir.
|
||||
@@ -212,16 +213,26 @@ export async function runTui(
|
||||
// demo or test config would silently run on the user's provider and model.
|
||||
// `--config-replace` additionally discards the base and the surface overlay.
|
||||
const replaceTree = configReplace !== undefined
|
||||
const patches = replaceTree ? [] : [
|
||||
...loadOverlayPatches(NAME, TUI_OVERLAY),
|
||||
...resolvedConfig === undefined
|
||||
? loadPersonalPatches(NAME, dshHome) ?? []
|
||||
: loadOverlayPatches(NAME, resolveConfigPath(resolvedConfig, undefined)),
|
||||
const bootConfig = resolvedConfigReplace === undefined ? BASE_CONFIG : resolveConfigPath(resolvedConfigReplace, undefined)
|
||||
// Same opt-out semantics as the web surface (resolveTelemetryPatch: any
|
||||
// non-empty value disables; setting the switch against a tree without the
|
||||
// row fails loud rather than silently no-opping a privacy switch). The row
|
||||
// presence is checked against the tree actually booting, so a
|
||||
// --config-replace tree is judged on its own rows, not the shipped base's.
|
||||
const telemetryPatch = resolveTelemetryPatch(process.env.DSH_TELEMETRY_DISABLED, configHasTelemetryRow(bootConfig))
|
||||
const patches = [
|
||||
...replaceTree ? [] : [
|
||||
...loadOverlayPatches(NAME, TUI_OVERLAY),
|
||||
...resolvedConfig === undefined
|
||||
? loadPersonalPatches(NAME) ?? []
|
||||
: loadOverlayPatches(NAME, resolveConfigPath(resolvedConfig, undefined)),
|
||||
],
|
||||
...telemetryPatch === undefined ? [] : [telemetryPatch],
|
||||
]
|
||||
const queryIndexPath = join(tmpdir(), SESSION_QUERY_DB)
|
||||
const ctx = await boot(
|
||||
NAME,
|
||||
resolvedConfigReplace === undefined ? BASE_CONFIG : resolveConfigPath(resolvedConfigReplace, undefined),
|
||||
bootConfig,
|
||||
patches,
|
||||
(hostCtx) => {
|
||||
// The launcher owns session identity and the exit line: a config-mounted
|
||||
|
||||
Reference in New Issue
Block a user