mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-08-29 04:26:38 +00:00
Merge pull request #2905 from deepseek-harness/turtle/local-build-banner-version
Show build version in local Web banner
This commit is contained in:
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-18-client-build-environment.md
|
||||
2026-08-18-client-build-environment.md: 45ed6c8bc68e0f08157fb56a91ae4f6165e6e431
|
||||
2026-08-18-client-build-environment.zh.md: bb9633721401f66b443a65253dcbc0241f45d328
|
||||
2026-08-18-client-build-environment.md: c23a73e9520392e41949f95d04029dbf34213dd0
|
||||
2026-08-18-client-build-environment.zh.md: 5dbb5deeee75eb0f1b2cadf7e38ad8bfc5661f5c
|
||||
|
||||
@@ -18,7 +18,9 @@ The Vite config and the shared tsdown preset for dynamic client bundles use one
|
||||
|
||||
The `DSH_CLIENT_*` prefix itself declares that a value is public. Credentials, paths, and other Host- or CI-only values must not use it.
|
||||
|
||||
The root build wrapper supplies one exact public environment to both bundlers. It derives `DSH_CLIENT_COMMIT_HASH` as the seven-character prefix of the source Git HEAD for every complete build; an explicit value supports build environments without repository metadata. `pnpm run build` otherwise inherits the caller's `DSH_CLIENT_*` values, while `pnpm run build:official` selects the repository's official artifact profile without shell-specific environment syntax and sets `DSH_CLIENT_BUILD_PROFILE=official` for deployment-specific business registrations. A successful complete build writes the exact public environment and a digest covering the Vite output and every dynamic client bundle. Partial build commands do not replace that record.
|
||||
The root build wrapper supplies one exact public environment to both bundlers. Every complete build carries the root package version as `DSH_CLIENT_VERSION` and the seven-character source Git HEAD prefix as `DSH_CLIENT_COMMIT_HASH`; an explicit commit supports build environments without repository metadata. A default local build also samples Git status before building and sets `DSH_CLIENT_GIT_DIRTY=true` for any staged, unstaged, untracked, or submodule change. Clean checkouts and sources without Git metadata omit the dirty field. These repository-owned fields replace inherited values, while `pnpm run build` otherwise inherits the caller's remaining `DSH_CLIENT_*` values.
|
||||
|
||||
`pnpm run build:official` selects the repository's official artifact profile without shell-specific environment syntax. Its exact environment carries the version and commit, sets `DSH_CLIENT_BUILD_PROFILE=official` for deployment-specific business registrations, and omits local dirty metadata. A successful complete build writes the exact public environment and a digest covering the Vite output and every dynamic client bundle. Partial build commands do not replace that record. `pnpm run dev:web` instead samples the default local environment once at startup and passes that environment to every watcher stage for the session. It does not validate the complete-build record because the watcher stages rewrite every recorded artifact.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
@@ -30,8 +32,12 @@ The root build wrapper supplies one exact public environment to both bundlers. I
|
||||
|
||||
**Standardize on `import.meta.env`.** Dynamic plugins are emitted as independent CommonJS factories and cannot retain `import.meta`. Business code would still need two interfaces depending on the artifact path.
|
||||
|
||||
**Reuse the last complete-build record for watchers.** Watcher stages rewrite every recorded client artifact, so the artifact digest becomes stale during normal development. An official-build record would also make edited local source retain the official profile and title. Startup sampling gives every stage one local metadata snapshot without coupling watcher restarts to the recorded artifact digest.
|
||||
|
||||
**Resample Git state during every watcher rebuild.** Vite and tsdown fix define substitutions when their long-lived watchers start. Restarting the build pipeline on repository status changes would make an incremental source edit rebuild unrelated artifacts; sampling once at startup keeps the stages consistent without rebuilding for later status changes.
|
||||
|
||||
## Consequences
|
||||
|
||||
The Vite static shell and shared tsdown dynamic bundles receive the same string for a given `DSH_CLIENT_*` build-process variable. An unset static property read evaluates to `undefined`; non-`DSH_CLIENT_*` values cannot enter browser artifacts through this mechanism, and business code cannot enumerate the build process environment. Every complete build carries its short source revision as public display metadata. CI build gates select the official profile without exposing its public values to source tests or unrelated workflow steps. npm packing and built Web tests verify the recorded environment and current artifact digest, so a default build followed by an official pack request, a partial rebuild, or modified output fails before consumption.
|
||||
The Vite static shell and shared tsdown dynamic bundles receive the same string for a given `DSH_CLIENT_*` build-process variable. An unset static property read evaluates to `undefined`; non-`DSH_CLIENT_*` values cannot enter browser artifacts through this mechanism, and business code cannot enumerate the build process environment. Every complete build carries its package version and short source revision as public display metadata; a dirty default build additionally identifies its uncommitted source state. CI build gates select the official profile without exposing its public values to source tests or unrelated workflow steps. npm packing and built Web tests verify the recorded environment and current artifact digest, so a default build followed by an official pack request, a partial rebuild, or modified output fails before consumption. Watch builds retain their startup snapshot until `pnpm run dev:web` restarts.
|
||||
|
||||
Every `DSH_CLIENT_*` value referenced by business code becomes public artifact content, so a misnamed value can disclose information. Build choices are fixed when the artifact is generated; a setting that must change after deployment requires a validated, transported, and documented runtime configuration mechanism.
|
||||
|
||||
@@ -18,7 +18,9 @@ Vite 配置与动态 client bundle 的共享 tsdown preset 使用同一 define
|
||||
|
||||
`DSH_CLIENT_*` 的名称本身表示公开性。凭据、路径和其他仅供 Host 或 CI 使用的值不得使用该前缀。
|
||||
|
||||
根构建包装脚本向两个 bundler 提供同一份精确的公开环境。每次完整构建都会把源码 Git HEAD 的七位前缀派生为 `DSH_CLIENT_COMMIT_HASH`;没有仓库元数据的构建环境可显式提供该值。除此之外,`pnpm run build` 继承调用方的 `DSH_CLIENT_*` 值,`pnpm run build:official` 则不依赖特定 shell 的环境变量语法,直接选择仓库的官方产物 profile,并设置 `DSH_CLIENT_BUILD_PROFILE=official` 供部署专属业务注册使用。完整构建成功后会写入精确的公开环境,以及覆盖 Vite 输出和所有动态 client bundle 的摘要;局部构建命令不会替换该记录。
|
||||
根构建包装脚本向两个 bundler 提供同一份精确的公开环境。每次完整构建都以 `DSH_CLIENT_VERSION` 携带根包版本,并以 `DSH_CLIENT_COMMIT_HASH` 携带源码 Git HEAD 的七位前缀;没有仓库元数据的构建环境可显式提供 commit。默认本地构建还会在构建前读取 Git 状态;存在任何暂存、未暂存、未跟踪或子模块变化时设置 `DSH_CLIENT_GIT_DIRTY=true`。没有变化的 worktree 和没有 Git 元数据的源码不携带 dirty 字段。这些由仓库持有的字段会替换继承值,除此之外,`pnpm run build` 继续继承调用方剩余的 `DSH_CLIENT_*` 值。
|
||||
|
||||
`pnpm run build:official` 不依赖特定 shell 的环境变量语法,直接选择仓库的官方产物 profile。它的精确环境携带版本和 commit,设置 `DSH_CLIENT_BUILD_PROFILE=official` 供部署专属业务注册使用,并省略本地 dirty 元数据。完整构建成功后会写入精确的公开环境,以及覆盖 Vite 输出和所有动态 client bundle 的摘要;局部构建命令不会替换该记录。`pnpm run dev:web` 则会在启动时读取一次默认本地环境,并在本次会话中把该环境传给所有 watcher stage。它不会校验完整构建记录,因为 watcher stage 会重写记录覆盖的全部产物。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
@@ -30,8 +32,12 @@ Vite 配置与动态 client bundle 的共享 tsdown preset 使用同一 define
|
||||
|
||||
**统一改用 `import.meta.env`。** 动态插件输出为独立 CJS factory,不能保留 `import.meta`;业务代码仍会因产物路径不同而使用两套接口。
|
||||
|
||||
**让 watcher 复用上次完整构建记录。** watcher stage 会重写记录覆盖的全部 client 产物,因此正常开发期间产物摘要就会变为陈旧。官方构建记录还会让经过编辑的本地源码继续携带官方 profile 和标题。启动时读取一次可以让所有 stage 共用同一份本地元数据快照,同时避免 watcher 重启依赖记录的产物摘要。
|
||||
|
||||
**每次 watcher 重建都重新读取 Git 状态。** Vite 和 tsdown 在长驻 watcher 启动时固定 define 替换。仓库状态变化时重启构建流水线,会使一次增量源码修改重建无关产物;启动时只读取一次可以保持各 stage 一致,同时避免因后续状态变化而重新构建。
|
||||
|
||||
## Consequences
|
||||
|
||||
Vite 静态壳和共享 tsdown 动态 bundle 对同一 `DSH_CLIENT_*` 构建进程变量产生相同字符串值。未设置的静态点访问得到 `undefined`,非 `DSH_CLIENT_*` 值不会通过该机制进入浏览器产物,业务代码也无法枚举构建进程环境。每次完整构建都携带可公开展示的短源码 revision。CI 构建门禁选择官方 profile,而不把其中的公开值暴露给源码测试或无关 workflow 步骤。npm 打包与 built Web 测试会校验记录中的环境及当前产物摘要,因此默认构建后请求官方打包、局部重建或修改输出都会在消费产物前失败。
|
||||
Vite 静态壳和共享 tsdown 动态 bundle 对同一 `DSH_CLIENT_*` 构建进程变量产生相同字符串值。未设置的静态点访问得到 `undefined`,非 `DSH_CLIENT_*` 值不会通过该机制进入浏览器产物,业务代码也无法枚举构建进程环境。每次完整构建都携带可公开展示的包版本和短源码 revision;dirty 的默认构建还会标明其源码存在未提交变化。CI 构建门禁选择官方 profile,而不把其中的公开值暴露给源码测试或无关 workflow 步骤。npm 打包与 built Web 测试会校验记录中的环境及当前产物摘要,因此默认构建后请求官方打包、局部重建或修改输出都会在消费产物前失败。watch build 会保留启动时的快照,直到 `pnpm run dev:web` 重启。
|
||||
|
||||
任何被业务代码引用的 `DSH_CLIENT_*` 值都会成为公开产物内容,命名错误可能泄露信息。构建选择在产物生成时固定;需要部署后变化的设置必须使用拥有校验、传输和文档的运行时配置机制。
|
||||
|
||||
@@ -29,22 +29,52 @@ if (!isBuildRecordReader(readClientBuildRecord)) {
|
||||
}
|
||||
const record: unknown = readClientBuildRecord(resolve(import.meta.dirname, '../../..'))
|
||||
if (typeof record !== 'object' || record === null) throw new TypeError('client build record must be an object')
|
||||
const clientBuildEnvironment: unknown = Reflect.get(record, 'environment')
|
||||
if (typeof clientBuildEnvironment !== 'object' || clientBuildEnvironment === null) {
|
||||
throw new TypeError('client build record environment must be an object')
|
||||
}
|
||||
const clientBuildEnvironment = requireObject(
|
||||
Reflect.get(record, 'environment'),
|
||||
'client build record environment must be an object',
|
||||
)
|
||||
|
||||
function isBuildRecordReader(value: unknown): value is (root: string) => unknown {
|
||||
return typeof value === 'function'
|
||||
}
|
||||
|
||||
function requireObject(value: unknown, message: string): Record<string, unknown> {
|
||||
if (!isUnknownRecord(value)) throw new TypeError(message)
|
||||
return value
|
||||
}
|
||||
|
||||
function isUnknownRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null
|
||||
}
|
||||
|
||||
/** Read one optional string from the verified client build record. */
|
||||
function clientBuildValue(name: string): string | undefined {
|
||||
const value = clientBuildEnvironment[name]
|
||||
if (value !== undefined && typeof value !== 'string') {
|
||||
throw new TypeError(`client build record environment ${name} must be a string`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
it('boots the built plugin graph and renders a fixture session end to end', async () => {
|
||||
mountAssembledApp()
|
||||
|
||||
// The sidebar renders from the boot graph: every inject layer activated.
|
||||
const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 })
|
||||
expect(document.querySelector('svg[viewBox="26 0 156 24"]')).not.toBeNull()
|
||||
expect(screen.queryByText('DSH Local Build')).toBeNull()
|
||||
if (clientBuildValue('DSH_CLIENT_BUILD_PROFILE') === 'official') {
|
||||
expect(document.querySelector('svg[viewBox="26 0 156 24"]')).not.toBeNull()
|
||||
expect(screen.queryByText('DSH Local Build')).toBeNull()
|
||||
} else {
|
||||
expect(document.querySelector('svg[viewBox="0 0 23.16 17.04"]')).not.toBeNull()
|
||||
const version = clientBuildValue('DSH_CLIENT_VERSION')
|
||||
if (version === undefined) throw new Error('default client build record must carry DSH_CLIENT_VERSION')
|
||||
const commit = clientBuildValue('DSH_CLIENT_COMMIT_HASH')
|
||||
const buildVersion = version
|
||||
+ (commit === undefined ? '' : `-${commit}`)
|
||||
+ (clientBuildValue('DSH_CLIENT_GIT_DIRTY') === 'true' ? '-dirty' : '')
|
||||
screen.getByText('DSH Local Build')
|
||||
screen.getByText(buildVersion)
|
||||
}
|
||||
// The compact layout dropped group session counts; the fixture workspace
|
||||
// group row renders immediately with its sessions beneath it.
|
||||
const fixtureGroup = (await within(tree).findAllByText('fixture'))
|
||||
|
||||
@@ -302,12 +302,10 @@ async function bootPreview(origin: string, browser: Browser): Promise<void> {
|
||||
// report the older one.
|
||||
expect(bootLine).toContain(`image lowering=${WRAPPER_CONTRACT}`)
|
||||
expect(bootLine).toContain('data overlays=1')
|
||||
// The hero's workspace picker is the client tree's first interactive
|
||||
// surface, so it appears only once the startup chain completed over the
|
||||
// tunnel.
|
||||
await page.getByRole('textbox', { name: 'Choose workspace' }).waitFor({ timeout: HERO_TIMEOUT_MS })
|
||||
// The versioned notice is the seeded preview's first stable interactive
|
||||
// surface after the startup chain completes over the tunnel.
|
||||
const continueButton = page.getByRole('button', { name: 'Continue' })
|
||||
await continueButton.waitFor({ timeout: 30_000 })
|
||||
await continueButton.waitFor({ timeout: HERO_TIMEOUT_MS })
|
||||
await continueButton.click()
|
||||
const configureLater = page.getByRole('button', { name: 'Configure later' })
|
||||
await configureLater.waitFor({ timeout: 30_000 })
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/development.md
|
||||
development.md: 904245f93546122e4e3a54e020b302a3bcd39d1a
|
||||
development.zh.md: 0faa0a1e06b6bc2cef23076c004fa4f4ba0a4360
|
||||
development.md: d37be2802004a044ea3516506bbbb51305c622de
|
||||
development.zh.md: 3a661c3356a77f6c8c85316e74cf14a97fd90489
|
||||
|
||||
+1
-1
@@ -75,7 +75,7 @@ Both tsdown passes use the same complete workspace match. They neither scan buil
|
||||
|
||||
Typert runs only during Host tsdown, seeded by `tsconfig.host.json`. It analyzes Host types and generates both Host reflection artifacts and the Host-for-Client Remote projection; Client tsdown does not start Typert. Consequently, `pnpm run typecheck` runs the complete Host lib phase before Client tsc, while `pnpm run build` continues through Client tsdown and the Web build. The [API Remotes generated-contract build note](../.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.md) records this ordering decision.
|
||||
|
||||
`pnpm run build` embeds the caller's exact `DSH_CLIENT_*` environment and uses no public client values when none are set. `pnpm run build:official` is the cross-platform local equivalent of the CI and release artifact build. Each successful complete build writes a gitignored record that binds those values to the Vite output and dynamic client bundles; release packing and built Web tests reject a missing record or artifacts changed by a later partial build.
|
||||
`pnpm run build` embeds the root package version, the seven-character source commit, and a dirty marker when Git reports local changes; it also inherits other caller-supplied `DSH_CLIENT_*` values. `pnpm run build:official` is the cross-platform local equivalent of the CI and release artifact build and omits the local dirty marker. Each successful complete build writes a gitignored record that binds the exact public values to the Vite output and dynamic client bundles; release packing and built Web tests reject a missing record or artifacts changed by a later partial build. `pnpm run dev:web` still requires the artifact tree from a prior complete build, but it samples the current version and Git state once at startup and shares that environment across every watcher stage for the session; it does not validate the complete-build record because the watcher stages rewrite its recorded artifacts.
|
||||
|
||||
Static analysis and tests resolve workspace imports through the base `paths` map to `src` and must pass on a clean tree; gates that consume built `lib/` output declare that dependency explicitly. Generated Host-for-Client Remote declarations are the deliberate exception: the public `typecheck`, `lint`, and `doc-typecheck` commands generate them first, while internal `*:contracts-ready` scripts assume that an invoking public command or scheduler gate already depends on the Typert contract-generation pass or the complete build. See the [solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md) for the two-aggregate setup, the [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md) for tsc-first emit ownership, and the [Typert Remote note](../.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md) for the gate-preparation contract.
|
||||
|
||||
|
||||
@@ -79,7 +79,7 @@ pnpm run build:web
|
||||
|
||||
Typert 只在 Host tsdown 中以 `tsconfig.host.json` 为种子运行。它分析 Host 类型并生成 Host 反射产物及 Host-for-Client Remote 投影;Client tsdown 不启动 Typert。`pnpm run typecheck` 因此先执行完整 Host lib 阶段,再运行 Client tsc;`pnpm run build` 继续执行 Client tsdown 和 Web 构建。该顺序的决策记录见 [API Remotes 生成约定构建 Note](../.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.zh.md)。
|
||||
|
||||
`pnpm run build` 会内联调用方精确的 `DSH_CLIENT_*` 环境;未设置时不使用任何公开 client 值。`pnpm run build:official` 是与 CI 和 release 产物构建等价的跨平台本地命令。每次完整构建成功后都会写入一份被 gitignore 的记录,把这些值与 Vite 输出及动态 client bundle 绑定;release 打包和 built Web 测试会拒绝缺少记录或被后续局部构建改动的产物。
|
||||
`pnpm run build` 会内联根包版本、七位源码 commit,并在 Git 报告本地变化时内联 dirty 标记;调用方提供的其他 `DSH_CLIENT_*` 值也会被继承。`pnpm run build:official` 是与 CI 和 release 产物构建等价的跨平台本地命令,并省略本地 dirty 标记。每次完整构建成功后都会写入一份被 gitignore 的记录,把精确公开值与 Vite 输出及动态 client bundle 绑定;release 打包和 built Web 测试会拒绝缺少记录或被后续局部构建改动的产物。`pnpm run dev:web` 仍需要先执行完整构建来准备产物树,但会在启动时读取一次当前版本和 Git 状态,并在本次会话的所有 watcher stage 之间共享该环境;它不会校验完整构建记录,因为 watcher stage 会重写记录覆盖的产物。
|
||||
|
||||
静态分析和测试通过 base 的 `paths` 映射把工作区 import 解析到 `src`,且必须在干净树上通过;消费构建产物 `lib/` 的门禁显式声明该依赖。生成的 Host-for-Client Remote 声明是有意设置的例外:公共 `typecheck`、`lint` 和 `doc-typecheck` 命令会先生成这些声明,而内部 `*:contracts-ready` 脚本假定调用它的公共命令或调度器门禁已经依赖 Typert 约定生成阶段或完整构建。两个 aggregate 的设置见 [solution-root Note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.zh.md),tsc-first 发射职责见 [ts-build-config Note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.zh.md),门禁准备约定见 [Typert Remote Agent Note](../.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md)。
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-sidebar/README.md
|
||||
README.md: b924c2e6d18217d9689b7c21137321856e14da2e
|
||||
README.zh.md: 214a2727243d6d9151e31cfcfbc0bdb886c2fcdf
|
||||
README.md: 075a132b9fc35ec4aee871690b436d3380e616fb
|
||||
README.zh.md: 39ec5d58cd7270fbefdaa81eb0a6a022229d7963
|
||||
|
||||
@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
|
||||
|
||||
Sidebar shell plugin: the brand row, New Session action, layout-owned collapse control, scroll-aware region seat, and bottom-pinned Settings seat. [ui-workspace](../ui-workspace/README.md) owns the Workspace and Session browser rendered into `sidebar.workspaces`; this package neither derives its rows nor owns its view preferences. Collapse into the layout-owned 56px rail remains presentation-local. Contract: the [slot system standard](../../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md).
|
||||
|
||||
The expanded brand row renders `sidebar.brand.mark` and `sidebar.brand.name` as independent single slots, while the collapsed rail renders the same mark slot. Without occupants, the shell uses the fish mark and a `DSH Local Build` label carrying the build's 7-character `DSH_CLIENT_COMMIT_HASH` badge. A deployment package can replace either value without replacing the New Session control or rail geometry; declaration-aware `slots.inject()` lets such a package activate before or after the sidebar.
|
||||
The expanded brand row renders `sidebar.brand.mark` and `sidebar.brand.name` as independent single slots, while the collapsed rail renders the same mark slot. Without occupants, the shell uses the fish mark and a localized local-build label. A complete build stacks below it a code badge assembled as `version[-commit][-dirty]` from `DSH_CLIENT_VERSION`, the optional 7-character `DSH_CLIENT_COMMIT_HASH`, and `DSH_CLIENT_GIT_DIRTY=true`; missing version metadata omits the badge. A deployment package can replace either value without replacing the New Session control or rail geometry; declaration-aware `slots.inject()` lets such a package activate before or after the sidebar.
|
||||
|
||||
New Session starts the runtime's page-local frontend Session Intent. The runtime targets the explicit Workspace used by a scoped action, otherwise the current Session's Workspace, otherwise the most recently active Workspace; when none exists it clears into the blank New Session page. Workspace-specific controls and the shared picker belong to ui-workspace.
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
侧边栏外壳插件:负责品牌行、New Session 操作、布局持有的折叠控件、可感知滚动的区域 seat,以及固定在底部的 Settings seat。[ui-workspace](../ui-workspace/README.zh.md) 持有渲染到 `sidebar.workspaces` 的 Workspace 与 Session 浏览器;本包既不派生其中的行,也不持有其视图偏好。折叠到布局拥有的 56px 轨道仍属于本地呈现行为。约定:[slot 系统标准](../../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.zh.md)。
|
||||
|
||||
展开的品牌行把 `sidebar.brand.mark` 与 `sidebar.brand.name` 渲染为两个独立的 single slot,收起轨道则渲染同一个 mark slot。没有占位者时,外壳使用鱼形标记,以及带有构建期 7 位 `DSH_CLIENT_COMMIT_HASH` 徽标的 `DSH Local Build` 标签。部署包可以单独替换任一值,而无须替换 New Session 控件或轨道几何;声明感知的 `slots.inject()` 让这种包无论先于还是后于侧边栏激活都能生效。
|
||||
展开的品牌行把 `sidebar.brand.mark` 与 `sidebar.brand.name` 渲染为两个独立的 single slot,收起轨道则渲染同一个 mark slot。没有占位者时,外壳使用鱼形标记和本地化的本地构建标签。完整构建会在标签下方显示代码徽标;该徽标由 `DSH_CLIENT_VERSION`、可选的 7 位 `DSH_CLIENT_COMMIT_HASH` 与 `DSH_CLIENT_GIT_DIRTY=true` 组装成 `version[-commit][-dirty]`;缺少版本元数据时不显示徽标。部署包可以单独替换任一值,而无须替换 New Session 控件或轨道几何;声明感知的 `slots.inject()` 让这种包无论先于还是后于侧边栏激活都能生效。
|
||||
|
||||
New Session 会启动运行时的页面局部前端 Session Intent。运行时优先使用作用域操作明确指定的 Workspace,否则使用当前 Session 所属 Workspace,再否则使用最近活跃 Workspace;一个 Workspace 都没有时则清空选择,进入空白 New Session 页面。Workspace 专属控件与共享选择器由 ui-workspace 持有。
|
||||
|
||||
|
||||
@@ -159,6 +159,23 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.localBuildBrand {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
gap: 1px;
|
||||
height: 24px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.localBuildTitle {
|
||||
font-size: 12px;
|
||||
line-height: 13px;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.iconButton {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
@@ -210,18 +227,20 @@
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.buildRevision {
|
||||
.buildVersion {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 16px;
|
||||
padding: 0 4px;
|
||||
border-radius: 3px;
|
||||
height: 10px;
|
||||
padding: 0 3px;
|
||||
border-radius: 2px;
|
||||
color: var(--dsw-alias-label-primary-inverted);
|
||||
background: var(--dsw-alias-label-primary);
|
||||
font-family: var(--ds-font-family-code);
|
||||
font-size: 8px;
|
||||
font-size: 6px;
|
||||
font-weight: 500;
|
||||
line-height: 16px;
|
||||
line-height: 10px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* New Session: 38px bar, 12px radius (figma 133:7634 geometry, squared-off
|
||||
|
||||
@@ -34,6 +34,16 @@ const COLLAPSE_SETTLE_MS = 150
|
||||
*/
|
||||
const SCROLLBAR_LINGER_MS = 2000
|
||||
|
||||
/** Format complete-build metadata for the local brand badge. */
|
||||
function localBuildVersion(): string | undefined {
|
||||
const version = process.env.DSH_CLIENT_VERSION
|
||||
if (version === undefined) return undefined
|
||||
const commit = process.env.DSH_CLIENT_COMMIT_HASH
|
||||
return version
|
||||
+ (commit === undefined ? '' : `-${commit}`)
|
||||
+ (process.env.DSH_CLIENT_GIT_DIRTY === 'true' ? '-dirty' : '')
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the sidebar column shell.
|
||||
* @param props - composed slot props (runtime share + injected callbacks, contract/slots.ts).
|
||||
@@ -111,6 +121,8 @@ export function SidebarRoot({
|
||||
}
|
||||
}, [pointerInside])
|
||||
|
||||
const buildVersion = localBuildVersion()
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={column}
|
||||
@@ -141,14 +153,14 @@ export function SidebarRoot({
|
||||
</span>
|
||||
<span className={css.brandName}>
|
||||
{renderSlot('sidebar.brand.name', {}, {
|
||||
fallback: (
|
||||
<>
|
||||
<span className={css.fallbackBrandName}>{t('brand.localBuild')}</span>
|
||||
{process.env.DSH_CLIENT_COMMIT_HASH
|
||||
? <span className={css.buildRevision}>{process.env.DSH_CLIENT_COMMIT_HASH}</span>
|
||||
: null}
|
||||
</>
|
||||
),
|
||||
fallback: buildVersion === undefined
|
||||
? <span className={css.fallbackBrandName}>{t('brand.localBuild')}</span>
|
||||
: (
|
||||
<span className={css.localBuildBrand}>
|
||||
<span className={css.localBuildTitle}>{t('brand.localBuild')}</span>
|
||||
<span className={css.buildVersion}>{buildVersion}</span>
|
||||
</span>
|
||||
),
|
||||
})}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
+22
-14
@@ -138,14 +138,18 @@ exports[`sidebar shell snapshots > renders the expanded column (wordmark, capsul
|
||||
style="display: contents;"
|
||||
>
|
||||
<span
|
||||
class="fallbackBrandName"
|
||||
class="localBuildBrand"
|
||||
>
|
||||
DSH Local Build
|
||||
</span>
|
||||
<span
|
||||
class="buildRevision"
|
||||
>
|
||||
abc1234
|
||||
<span
|
||||
class="localBuildTitle"
|
||||
>
|
||||
DSH Local Build
|
||||
</span>
|
||||
<span
|
||||
class="buildVersion"
|
||||
>
|
||||
1.2.3-rc.4-abc1234-dirty
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</span>
|
||||
@@ -264,14 +268,18 @@ exports[`sidebar shell snapshots > renders the expanded column in the default lo
|
||||
style="display: contents;"
|
||||
>
|
||||
<span
|
||||
class="fallbackBrandName"
|
||||
class="localBuildBrand"
|
||||
>
|
||||
DSH 本地构建
|
||||
</span>
|
||||
<span
|
||||
class="buildRevision"
|
||||
>
|
||||
abc1234
|
||||
<span
|
||||
class="localBuildTitle"
|
||||
>
|
||||
DSH 本地构建
|
||||
</span>
|
||||
<span
|
||||
class="buildVersion"
|
||||
>
|
||||
1.2.3-rc.4-abc1234-dirty
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</span>
|
||||
|
||||
@@ -100,6 +100,8 @@ describe('SidebarRoot shell', () => {
|
||||
|
||||
it('renders generic brand fallbacks when no package fills the slots', () => {
|
||||
vi.stubEnv('DSH_CLIENT_COMMIT_HASH', '0123456')
|
||||
vi.stubEnv('DSH_CLIENT_GIT_DIRTY', 'true')
|
||||
vi.stubEnv('DSH_CLIENT_VERSION', '1.2.3-rc.4')
|
||||
const { container } = render(<SidebarRoot
|
||||
collapsed={false} width={300}
|
||||
useSessions={neverHook} useSessionPendingInteraction={useSessionPendingInteraction} useWorkspaces={neverHook}
|
||||
@@ -109,10 +111,39 @@ describe('SidebarRoot shell', () => {
|
||||
/>)
|
||||
|
||||
expect(screen.getByText('DSH Local Build')).toBeTruthy()
|
||||
expect(screen.getByText('0123456')).toBeTruthy()
|
||||
expect(screen.getByText('1.2.3-rc.4-0123456-dirty')).toBeTruthy()
|
||||
expect(container.querySelector('svg')).not.toBeNull()
|
||||
})
|
||||
|
||||
it.each([
|
||||
[{ DSH_CLIENT_VERSION: '1.2.3' }, '1.2.3'],
|
||||
[{ DSH_CLIENT_COMMIT_HASH: 'abcdef0', DSH_CLIENT_VERSION: '1.2.3' }, '1.2.3-abcdef0'],
|
||||
])('omits unavailable build-version suffixes from %j', (environment, expected) => {
|
||||
for (const [name, value] of Object.entries(environment)) vi.stubEnv(name, value)
|
||||
render(<SidebarRoot
|
||||
collapsed={false} width={300}
|
||||
useSessions={neverHook} useSessionPendingInteraction={useSessionPendingInteraction} useWorkspaces={neverHook}
|
||||
startSession={vi.fn()} toggleSidebar={vi.fn()} t={t}
|
||||
renderSlot={((_key: string, _owner: unknown, options?: { fallback?: ReactNode }) =>
|
||||
options?.fallback ?? null) as SidebarRootComponentProps['renderSlot']}
|
||||
/>)
|
||||
|
||||
expect(screen.getByText('DSH Local Build')).toBeTruthy()
|
||||
expect(screen.getByText(expected)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('retains the local-build fallback without complete build metadata', () => {
|
||||
render(<SidebarRoot
|
||||
collapsed={false} width={300}
|
||||
useSessions={neverHook} useSessionPendingInteraction={useSessionPendingInteraction} useWorkspaces={neverHook}
|
||||
startSession={vi.fn()} toggleSidebar={vi.fn()} t={t}
|
||||
renderSlot={((_key: string, _owner: unknown, options?: { fallback?: ReactNode }) =>
|
||||
options?.fallback ?? null) as SidebarRootComponentProps['renderSlot']}
|
||||
/>)
|
||||
|
||||
expect(screen.getByText('DSH Local Build')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('hands the region its wide flag and clamps expandSidebar to the collapsed state', () => {
|
||||
const b = mountShell()
|
||||
expect(b.regionOwner().wide).toBe(true)
|
||||
|
||||
@@ -20,7 +20,11 @@ import { apply, inject } from '@deepseek-ai/dsh-client-ui-sidebar/client'
|
||||
// the shipped Chinese copy, so they state the browser they assume.
|
||||
usePinnedBrowserLanguages('zh-CN')
|
||||
|
||||
beforeEach(() => { vi.stubEnv('DSH_CLIENT_COMMIT_HASH', 'abc1234') })
|
||||
beforeEach(() => {
|
||||
vi.stubEnv('DSH_CLIENT_COMMIT_HASH', 'abc1234')
|
||||
vi.stubEnv('DSH_CLIENT_GIT_DIRTY', 'true')
|
||||
vi.stubEnv('DSH_CLIENT_VERSION', '1.2.3-rc.4')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
|
||||
+6
-7
@@ -6,8 +6,9 @@ import { resolve } from 'node:path'
|
||||
import { parseArgs } from 'node:util'
|
||||
import {
|
||||
CLIENT_BUILD_RECORD_PATH,
|
||||
CLIENT_BUILD_PROFILE_SELECTOR,
|
||||
clientBuildProcessEnvironment,
|
||||
repositoryCommitHash,
|
||||
repositoryClientBuildEnvironment,
|
||||
resolveClientBuildEnvironment,
|
||||
writeClientBuildRecord,
|
||||
} from './client-build-environment.ts'
|
||||
@@ -34,12 +35,10 @@ function main(): void {
|
||||
allowPositionals: false,
|
||||
})
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
const parentEnvironment = {
|
||||
...process.env,
|
||||
DSH_CLIENT_COMMIT_HASH: repositoryCommitHash(root, process.env),
|
||||
}
|
||||
const clientEnvironment = resolveClientBuildEnvironment(parentEnvironment, values.profile)
|
||||
const buildEnvironment = clientBuildProcessEnvironment(parentEnvironment, clientEnvironment)
|
||||
const repositoryEnvironment = repositoryClientBuildEnvironment(root, process.env)
|
||||
const profile = values.profile ?? process.env[CLIENT_BUILD_PROFILE_SELECTOR]
|
||||
const clientEnvironment = resolveClientBuildEnvironment(repositoryEnvironment, profile)
|
||||
const buildEnvironment = clientBuildProcessEnvironment(process.env, clientEnvironment)
|
||||
|
||||
rmSync(resolve(root, CLIENT_BUILD_RECORD_PATH), { force: true })
|
||||
runScript('build:lib', buildEnvironment)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
@@ -7,8 +8,12 @@ import {
|
||||
assertClientBuildEnvironment,
|
||||
clientBuildEnvironmentDefines,
|
||||
clientBuildProcessEnvironment,
|
||||
officialClientBuildEnvironment,
|
||||
readClientBuildRecord,
|
||||
repositoryClientBuildEnvironment,
|
||||
repositoryCommitHash,
|
||||
repositoryGitDirty,
|
||||
repositoryVersion,
|
||||
resolveClientBuildEnvironment,
|
||||
writeClientBuildRecord,
|
||||
} from './client-build-environment.ts'
|
||||
@@ -51,12 +56,34 @@ function buildFixture(environment: Record<string, string>): string {
|
||||
return fixtureRoot
|
||||
}
|
||||
|
||||
function git(root: string, args: readonly string[]): string {
|
||||
return execFileSync('git', [...args], {
|
||||
cwd: root,
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
}).trim()
|
||||
}
|
||||
|
||||
function repositoryFixture(version = '1.2.3-rc.4'): string {
|
||||
const fixtureRoot = mkdtempSync(join(tmpdir(), 'dsh-client-build-repository-'))
|
||||
roots.push(fixtureRoot)
|
||||
write(join(fixtureRoot, 'package.json'), `${JSON.stringify({ version })}\n`)
|
||||
write(join(fixtureRoot, 'tracked.txt'), 'committed\n')
|
||||
git(fixtureRoot, ['init'])
|
||||
git(fixtureRoot, ['config', 'user.name', 'DSH test'])
|
||||
git(fixtureRoot, ['config', 'user.email', 'dsh-test@example.invalid'])
|
||||
git(fixtureRoot, ['add', 'package.json', 'tracked.txt'])
|
||||
git(fixtureRoot, ['commit', '-m', 'fixture'])
|
||||
return fixtureRoot
|
||||
}
|
||||
|
||||
describe('client build environment', () => {
|
||||
it('requires an exact public environment for a named artifact profile', () => {
|
||||
const expected = {
|
||||
DSH_CLIENT_BUILD_PROFILE: 'official',
|
||||
DSH_CLIENT_COMMIT_HASH: COMMIT_HASH.slice(0, 7),
|
||||
DSH_CLIENT_TITLE: 'DeepSeek Harness',
|
||||
DSH_CLIENT_VERSION: '1.2.3',
|
||||
} as const
|
||||
|
||||
expect(() => { assertClientBuildEnvironment({ PATH: '/bin', ...expected }, expected) }).not.toThrow()
|
||||
@@ -73,7 +100,9 @@ describe('client build environment', () => {
|
||||
DSH_BUILD_CLIENT_PROFILE: 'official',
|
||||
DSH_CLIENT_BUILD_PROFILE: 'local',
|
||||
DSH_CLIENT_COMMIT_HASH: COMMIT_HASH.slice(0, 7),
|
||||
DSH_CLIENT_GIT_DIRTY: 'true',
|
||||
DSH_CLIENT_TITLE: 'Local title',
|
||||
DSH_CLIENT_VERSION: '1.2.3',
|
||||
DSH_CLIENT_EXTRA: 'local-extra',
|
||||
}
|
||||
|
||||
@@ -84,24 +113,108 @@ describe('client build environment', () => {
|
||||
DSH_CLIENT_BUILD_PROFILE: 'official',
|
||||
DSH_CLIENT_COMMIT_HASH: COMMIT_HASH.slice(0, 7),
|
||||
DSH_CLIENT_TITLE: 'DeepSeek Harness',
|
||||
DSH_CLIENT_VERSION: '1.2.3',
|
||||
})
|
||||
expect(() => {
|
||||
resolveClientBuildEnvironment({ DSH_BUILD_CLIENT_PROFILE: 'official' })
|
||||
}).toThrow(/DSH_CLIENT_COMMIT_HASH/)
|
||||
expect(() => {
|
||||
resolveClientBuildEnvironment({
|
||||
DSH_BUILD_CLIENT_PROFILE: 'official',
|
||||
DSH_CLIENT_COMMIT_HASH: COMMIT_HASH.slice(0, 7),
|
||||
})
|
||||
}).toThrow(/DSH_CLIENT_VERSION/)
|
||||
expect(() => { resolveClientBuildEnvironment({}, 'unknown') }).toThrow(/unknown client build profile/)
|
||||
expect(clientBuildProcessEnvironment(parent, {
|
||||
DSH_CLIENT_BUILD_PROFILE: 'official',
|
||||
DSH_CLIENT_COMMIT_HASH: COMMIT_HASH.slice(0, 7),
|
||||
DSH_CLIENT_TITLE: 'DeepSeek Harness',
|
||||
DSH_CLIENT_VERSION: '1.2.3',
|
||||
})).toEqual({
|
||||
PATH: '/bin',
|
||||
DSH_CLIENT_BUILD_PROFILE: 'official',
|
||||
DSH_CLIENT_COMMIT_HASH: COMMIT_HASH.slice(0, 7),
|
||||
DSH_CLIENT_TITLE: 'DeepSeek Harness',
|
||||
DSH_CLIENT_VERSION: '1.2.3',
|
||||
})
|
||||
expect(repositoryCommitHash('/unused', { DSH_CLIENT_COMMIT_HASH: COMMIT_HASH })).toBe(COMMIT_HASH.slice(0, 7))
|
||||
})
|
||||
|
||||
it('owns repository version, commit, and dirty metadata for complete builds', () => {
|
||||
const fixtureRoot = repositoryFixture()
|
||||
const commit = git(fixtureRoot, ['rev-parse', '--short=7', 'HEAD'])
|
||||
|
||||
expect(repositoryVersion(fixtureRoot)).toBe('1.2.3-rc.4')
|
||||
expect(repositoryGitDirty(fixtureRoot)).toBe(false)
|
||||
expect(repositoryClientBuildEnvironment(fixtureRoot, {
|
||||
DSH_CLIENT_COMMIT_HASH: COMMIT_HASH,
|
||||
DSH_CLIENT_EXTRA: 'preserved',
|
||||
DSH_CLIENT_GIT_DIRTY: 'true',
|
||||
DSH_CLIENT_VERSION: 'spoofed',
|
||||
})).toEqual({
|
||||
DSH_CLIENT_COMMIT_HASH: COMMIT_HASH.slice(0, 7),
|
||||
DSH_CLIENT_EXTRA: 'preserved',
|
||||
DSH_CLIENT_VERSION: '1.2.3-rc.4',
|
||||
})
|
||||
expect(officialClientBuildEnvironment(fixtureRoot)).toEqual({
|
||||
DSH_CLIENT_BUILD_PROFILE: 'official',
|
||||
DSH_CLIENT_COMMIT_HASH: commit,
|
||||
DSH_CLIENT_TITLE: 'DeepSeek Harness',
|
||||
DSH_CLIENT_VERSION: '1.2.3-rc.4',
|
||||
})
|
||||
|
||||
write(join(fixtureRoot, '.gitignore'), 'ignored.txt\n')
|
||||
git(fixtureRoot, ['add', '.gitignore'])
|
||||
git(fixtureRoot, ['commit', '-m', 'ignore fixture'])
|
||||
write(join(fixtureRoot, 'ignored.txt'), 'ignored\n')
|
||||
expect(repositoryGitDirty(fixtureRoot)).toBe(false)
|
||||
rmSync(join(fixtureRoot, 'ignored.txt'))
|
||||
|
||||
write(join(fixtureRoot, 'tracked.txt'), 'unstaged\n')
|
||||
expect(repositoryGitDirty(fixtureRoot)).toBe(true)
|
||||
write(join(fixtureRoot, 'tracked.txt'), 'committed\n')
|
||||
expect(repositoryGitDirty(fixtureRoot)).toBe(false)
|
||||
|
||||
write(join(fixtureRoot, 'tracked.txt'), 'staged\n')
|
||||
git(fixtureRoot, ['add', 'tracked.txt'])
|
||||
expect(repositoryGitDirty(fixtureRoot)).toBe(true)
|
||||
git(fixtureRoot, ['commit', '-m', 'staged fixture'])
|
||||
expect(repositoryGitDirty(fixtureRoot)).toBe(false)
|
||||
|
||||
write(join(fixtureRoot, 'untracked.txt'), 'untracked\n')
|
||||
expect(repositoryGitDirty(fixtureRoot)).toBe(true)
|
||||
expect(repositoryClientBuildEnvironment(fixtureRoot, {
|
||||
DSH_CLIENT_COMMIT_HASH: COMMIT_HASH,
|
||||
})).toEqual({
|
||||
DSH_CLIENT_COMMIT_HASH: COMMIT_HASH.slice(0, 7),
|
||||
DSH_CLIENT_GIT_DIRTY: 'true',
|
||||
DSH_CLIENT_VERSION: '1.2.3-rc.4',
|
||||
})
|
||||
|
||||
rmSync(join(fixtureRoot, 'untracked.txt'))
|
||||
const submoduleSource = repositoryFixture('9.8.7')
|
||||
git(fixtureRoot, ['-c', 'protocol.file.allow=always', 'submodule', 'add', submoduleSource, 'submodule'])
|
||||
git(fixtureRoot, ['commit', '-am', 'submodule fixture'])
|
||||
expect(repositoryGitDirty(fixtureRoot)).toBe(false)
|
||||
write(join(fixtureRoot, 'submodule/tracked.txt'), 'modified submodule\n')
|
||||
expect(repositoryGitDirty(fixtureRoot)).toBe(true)
|
||||
})
|
||||
|
||||
it('omits dirty metadata when repository metadata is unavailable', () => {
|
||||
const fixtureRoot = mkdtempSync(join(tmpdir(), 'dsh-client-build-no-git-'))
|
||||
roots.push(fixtureRoot)
|
||||
write(join(fixtureRoot, 'package.json'), '{"version":"2.0.0"}\n')
|
||||
|
||||
expect(repositoryGitDirty(fixtureRoot)).toBeUndefined()
|
||||
expect(repositoryClientBuildEnvironment(fixtureRoot, {
|
||||
DSH_CLIENT_COMMIT_HASH: COMMIT_HASH,
|
||||
DSH_CLIENT_GIT_DIRTY: 'true',
|
||||
})).toEqual({
|
||||
DSH_CLIENT_COMMIT_HASH: COMMIT_HASH.slice(0, 7),
|
||||
DSH_CLIENT_VERSION: '2.0.0',
|
||||
})
|
||||
})
|
||||
|
||||
it('defines only public client values over a non-enumerable fallback', () => {
|
||||
expect(clientBuildEnvironmentDefines({
|
||||
PATH: '/bin',
|
||||
@@ -151,6 +264,7 @@ describe('client build environment', () => {
|
||||
DSH_CLIENT_BUILD_PROFILE: 'official',
|
||||
DSH_CLIENT_COMMIT_HASH: COMMIT_HASH.slice(0, 7),
|
||||
DSH_CLIENT_TITLE: 'DeepSeek Harness',
|
||||
DSH_CLIENT_VERSION: '1.2.3',
|
||||
}
|
||||
const official = buildFixture(officialEnvironment)
|
||||
const defaultBuild = buildFixture({})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { execFileSync, spawnSync } from 'node:child_process'
|
||||
import {
|
||||
existsSync,
|
||||
globSync,
|
||||
@@ -25,6 +25,9 @@ const OFFICIAL_CLIENT_BUILD_ENVIRONMENT = {
|
||||
/** Public variable carrying the source commit embedded in client artifacts. */
|
||||
const CLIENT_COMMIT_HASH_VARIABLE = 'DSH_CLIENT_COMMIT_HASH'
|
||||
|
||||
/** Public variable carrying the repository package version embedded in client artifacts. */
|
||||
const CLIENT_VERSION_VARIABLE = 'DSH_CLIENT_VERSION'
|
||||
|
||||
/** Repository-relative path of the complete client build record. */
|
||||
export const CLIENT_BUILD_RECORD_PATH = '.dsh-build/client-build-environment.json'
|
||||
|
||||
@@ -57,6 +60,76 @@ export function repositoryCommitHash(root: string, environment: NodeJS.ProcessEn
|
||||
return value.slice(0, 7).toLowerCase()
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the repository package version used by browser build metadata.
|
||||
* @param root - repository root containing the authoritative package.json.
|
||||
* @returns the repository's semver-compatible package version.
|
||||
*/
|
||||
export function repositoryVersion(root: string): string {
|
||||
const path = resolve(root, 'package.json')
|
||||
let manifest: unknown
|
||||
try {
|
||||
manifest = JSON.parse(readFileSync(path, 'utf8'))
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : String(error)
|
||||
throw new Error(`cannot read repository version from ${path}: ${detail}`)
|
||||
}
|
||||
if (!isObject(manifest) || typeof manifest.version !== 'string'
|
||||
|| !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(manifest.version)) {
|
||||
throw new Error(`repository package.json has an invalid version ${JSON.stringify(isObject(manifest) ? manifest.version : undefined)}`)
|
||||
}
|
||||
return manifest.version
|
||||
}
|
||||
|
||||
/**
|
||||
* Read whether Git reports any staged, unstaged, untracked, or submodule change.
|
||||
* @param root - repository root whose worktree is inspected.
|
||||
* @returns true or false inside a Git worktree; undefined without Git metadata.
|
||||
*/
|
||||
export function repositoryGitDirty(root: string): boolean | undefined {
|
||||
const probe = spawnSync('git', ['rev-parse', '--is-inside-work-tree'], {
|
||||
cwd: root,
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
})
|
||||
if (probe.error !== undefined || probe.status !== 0 || probe.stdout.trim() !== 'true') return undefined
|
||||
|
||||
const status = spawnSync('git', ['status', '--porcelain=v1', '--untracked-files=normal'], {
|
||||
cwd: root,
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
})
|
||||
if (status.error !== undefined) throw status.error
|
||||
if (status.status !== 0) {
|
||||
throw new Error(`git status failed in ${root}: ${status.stderr.trim() || String(status.status)}`)
|
||||
}
|
||||
return status.stdout !== ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the public environment for a complete default build from one checkout.
|
||||
* Repository-owned metadata replaces inherited values; other public values pass through.
|
||||
* @param root - repository root supplying version and Git metadata.
|
||||
* @param environment - caller environment supplying optional commit and public extensions.
|
||||
* @returns complete public client environment for the default build.
|
||||
*/
|
||||
export function repositoryClientBuildEnvironment(
|
||||
root: string,
|
||||
environment: NodeJS.ProcessEnv = process.env,
|
||||
): ClientBuildEnvironment {
|
||||
const inherited = { ...clientBuildEnvironment(environment) }
|
||||
delete inherited.DSH_CLIENT_COMMIT_HASH
|
||||
delete inherited.DSH_CLIENT_GIT_DIRTY
|
||||
delete inherited.DSH_CLIENT_VERSION
|
||||
const dirty = repositoryGitDirty(root)
|
||||
return {
|
||||
...inherited,
|
||||
DSH_CLIENT_COMMIT_HASH: repositoryCommitHash(root, environment),
|
||||
...(dirty === true ? { DSH_CLIENT_GIT_DIRTY: 'true' } : {}),
|
||||
DSH_CLIENT_VERSION: repositoryVersion(root),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the exact public values required by an official build at one commit.
|
||||
* @param root - repository root whose HEAD must match the built source.
|
||||
@@ -69,6 +142,7 @@ export function officialClientBuildEnvironment(
|
||||
): Readonly<Record<`DSH_CLIENT_${string}`, string>> {
|
||||
return {
|
||||
DSH_CLIENT_COMMIT_HASH: repositoryCommitHash(root, environment),
|
||||
DSH_CLIENT_VERSION: repositoryVersion(root),
|
||||
...OFFICIAL_CLIENT_BUILD_ENVIRONMENT,
|
||||
}
|
||||
}
|
||||
@@ -115,10 +189,18 @@ export function resolveClientBuildEnvironment(
|
||||
if (profile === undefined) return clientBuildEnvironment(environment)
|
||||
if (profile === 'official') {
|
||||
const commitHash = environment[CLIENT_COMMIT_HASH_VARIABLE]
|
||||
const version = environment[CLIENT_VERSION_VARIABLE]
|
||||
if (commitHash === undefined) {
|
||||
throw new Error(`${CLIENT_COMMIT_HASH_VARIABLE} is required for the official client build profile`)
|
||||
}
|
||||
return { DSH_CLIENT_COMMIT_HASH: commitHash, ...OFFICIAL_CLIENT_BUILD_ENVIRONMENT }
|
||||
if (version === undefined) {
|
||||
throw new Error(`${CLIENT_VERSION_VARIABLE} is required for the official client build profile`)
|
||||
}
|
||||
return {
|
||||
DSH_CLIENT_COMMIT_HASH: commitHash,
|
||||
DSH_CLIENT_VERSION: version,
|
||||
...OFFICIAL_CLIENT_BUILD_ENVIRONMENT,
|
||||
}
|
||||
}
|
||||
throw new Error(`unknown client build profile ${JSON.stringify(profile)}; expected "official"`)
|
||||
}
|
||||
|
||||
+39
-1
@@ -3,7 +3,45 @@ import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { expect, it } from 'vitest'
|
||||
import type { TsdownBundle } from 'tsdown'
|
||||
import { discoverLibraryDirs, discoverPluginDirs, watchClientPlugins } from './dev-web.ts'
|
||||
import { writeClientBuildRecord } from './client-build-environment.ts'
|
||||
import {
|
||||
devWebBuildEnvironment,
|
||||
discoverLibraryDirs,
|
||||
discoverPluginDirs,
|
||||
watchClientPlugins,
|
||||
} from './dev-web.ts'
|
||||
|
||||
it('samples one local environment at startup without validating watcher outputs', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-dev-web-environment-'))
|
||||
try {
|
||||
await mkdir(join(root, 'apps/web/dist'), { recursive: true })
|
||||
await mkdir(join(root, 'packages/client/example/lib'), { recursive: true })
|
||||
await writeFile(join(root, 'package.json'), JSON.stringify({ version: '1.2.3' }))
|
||||
await writeFile(join(root, 'apps/web/dist/index.html'), '<main></main>')
|
||||
await writeFile(join(root, 'packages/client/example/lib/client.js'), 'module.exports = {}\n')
|
||||
writeClientBuildRecord(root, {
|
||||
DSH_CLIENT_BUILD_PROFILE: 'official',
|
||||
DSH_CLIENT_COMMIT_HASH: 'fffffff',
|
||||
DSH_CLIENT_TITLE: 'DeepSeek Harness',
|
||||
DSH_CLIENT_VERSION: '1.2.2',
|
||||
})
|
||||
await writeFile(join(root, 'packages/client/example/lib/client.js'), 'module.exports = { changed: true }\n')
|
||||
|
||||
expect(devWebBuildEnvironment(root, {
|
||||
PATH: '/bin',
|
||||
DSH_BUILD_CLIENT_PROFILE: 'official',
|
||||
DSH_CLIENT_COMMIT_HASH: 'abc1234',
|
||||
DSH_CLIENT_EXTRA: 'launch-value',
|
||||
})).toEqual({
|
||||
PATH: '/bin',
|
||||
DSH_CLIENT_COMMIT_HASH: 'abc1234',
|
||||
DSH_CLIENT_EXTRA: 'launch-value',
|
||||
DSH_CLIENT_VERSION: '1.2.3',
|
||||
})
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('discovers dsh.client packages with sibling roles', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-dev-web-discovery-'))
|
||||
|
||||
@@ -34,6 +34,11 @@ import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import { execa } from 'execa'
|
||||
import { build } from 'tsdown'
|
||||
import type { TsdownBundle } from 'tsdown'
|
||||
import {
|
||||
CLIENT_BUILD_PROFILE_SELECTOR,
|
||||
clientBuildProcessEnvironment,
|
||||
repositoryClientBuildEnvironment,
|
||||
} from './client-build-environment.ts'
|
||||
|
||||
const repoRoot = fileURLToPath(new URL('..', import.meta.url))
|
||||
|
||||
@@ -49,6 +54,19 @@ const SHELL_PACKAGE = '@deepseek-ai/dsh-web-frontend'
|
||||
*/
|
||||
const TEST_INFRASTRUCTURE_PREFIX = 'packages/test-support/'
|
||||
|
||||
/**
|
||||
* Sample one local public environment for every long-lived watcher stage.
|
||||
* @param root - repository root supplying version and Git metadata.
|
||||
* @param environment - watcher launch environment supplying public extensions.
|
||||
* @returns process environment shared by tsdown and spawned watcher stages.
|
||||
*/
|
||||
export function devWebBuildEnvironment(
|
||||
root: string,
|
||||
environment: NodeJS.ProcessEnv = process.env,
|
||||
): NodeJS.ProcessEnv {
|
||||
return clientBuildProcessEnvironment(environment, repositoryClientBuildEnvironment(root, environment))
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover the watch workspace by declaration: every packages/<group>/<name>
|
||||
* whose package.json carries `dsh.client` with platform "web" is a client
|
||||
@@ -175,6 +193,16 @@ interface StageHandle {
|
||||
const invokedPath = process.argv[1]
|
||||
const isMain = invokedPath !== undefined && import.meta.url === pathToFileURL(resolve(invokedPath)).href
|
||||
if (isMain) {
|
||||
const buildEnvironment = devWebBuildEnvironment(repoRoot, process.env)
|
||||
for (const name of Object.keys(process.env)) {
|
||||
if (name === CLIENT_BUILD_PROFILE_SELECTOR || name.startsWith('DSH_CLIENT_')) {
|
||||
Reflect.deleteProperty(process.env, name)
|
||||
}
|
||||
}
|
||||
for (const [name, value] of Object.entries(buildEnvironment)) {
|
||||
if (name.startsWith('DSH_CLIENT_') && value !== undefined) process.env[name] = value
|
||||
}
|
||||
|
||||
const pluginDirs = discoverPluginDirs()
|
||||
const libraryDirs = discoverLibraryDirs()
|
||||
if (pluginDirs.length === 0) {
|
||||
|
||||
@@ -29,6 +29,7 @@ function write(path: string, content: string): void {
|
||||
function buildFixture(environment: Record<string, string>): string {
|
||||
const root = mkdtempSync(join(tmpdir(), 'dsh-release-build-'))
|
||||
roots.push(root)
|
||||
write(join(root, 'package.json'), `${JSON.stringify({ version: environment.DSH_CLIENT_VERSION ?? '0.0.1' })}\n`)
|
||||
write(join(root, 'apps/web/dist/index.html'), '<main></main>')
|
||||
write(join(root, 'packages/client/example/lib/client.js'), 'module.exports = {}\n')
|
||||
writeClientBuildRecord(root, environment)
|
||||
@@ -106,11 +107,13 @@ describe('release families', () => {
|
||||
vi.stubEnv('DSH_CLIENT_COMMIT_HASH', officialEnvironment.DSH_CLIENT_COMMIT_HASH)
|
||||
const official = buildFixture(officialEnvironment)
|
||||
const defaultBuild = buildFixture({})
|
||||
const missing = join(defaultBuild, 'missing')
|
||||
write(join(missing, 'package.json'), `${JSON.stringify({ version: officialEnvironment.DSH_CLIENT_VERSION })}\n`)
|
||||
|
||||
expect(() => { dsh.verifyBuildArtifacts(official) }).not.toThrow()
|
||||
expect(() => { dsh.verifyBuildArtifacts(defaultBuild) }).toThrow(/DSH_CLIENT_TITLE/)
|
||||
expect(() => { dsh.verifyBuildArtifacts(join(defaultBuild, 'missing')) }).toThrow(/record.*missing/)
|
||||
expect(() => { vendor.verifyBuildArtifacts(join(defaultBuild, 'missing')) }).not.toThrow()
|
||||
expect(() => { dsh.verifyBuildArtifacts(missing) }).toThrow(/record.*missing/)
|
||||
expect(() => { vendor.verifyBuildArtifacts(missing) }).not.toThrow()
|
||||
|
||||
write(join(official, 'packages/client/example/lib/client.js'), 'module.exports = { changed: true }\n')
|
||||
expect(() => { dsh.verifyBuildArtifacts(official) }).toThrow(/artifacts differ/)
|
||||
|
||||
Reference in New Issue
Block a user