repository.ts 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205
  1. /**
  2. * Repository knowledge for the packer: where this tree's workspaces, profile
  3. * composition, and config trees are, and how to report a pack.
  4. *
  5. * The library half takes all of this as parameters. Keeping the lookup here is what
  6. * lets the same library pack a different tree, and what keeps `pack.ts` free of
  7. * assumptions about pnpm workspaces or the `dsh` CLI.
  8. * @module @deepseek-ai/dsh-experimental-webworker-packer/src/repository
  9. */
  10. import { execFileSync } from 'node:child_process'
  11. import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs'
  12. import { tmpdir } from 'node:os'
  13. import { join, relative } from 'node:path'
  14. import { DSH_HOME_ENV } from '@deepseek-ai/dsh-home-paths'
  15. import type { ConfigTree, ImageTree, PackResult } from './pack.ts'
  16. /**
  17. * Repository directories scanned for workspace and vendored packages. The
  18. * image only ever materializes runtime packages, which live here. The Landlock
  19. * package family contributes its unchanged JavaScript entry from `native/`;
  20. * examples and python never occur on a roster's dependency chain.
  21. */
  22. const WORKSPACE_SCAN_ROOTS = ['vendor', 'packages', 'native/landlock-run/packages', 'apps']
  23. /** Composition entry point package: the `dsh` CLI, run from source. */
  24. const CLI_PACKAGE = 'apps/cli'
  25. /** Composition entry point: the `dsh` CLI, run from source. */
  26. const CLI_ENTRY = `${CLI_PACKAGE}/src/bin.ts`
  27. /** Repository-owned deterministic filesystem content offered by the preview. */
  28. const PREVIEW_EXAMPLE_ROOT = 'packages/experimental/webworker-runtime/tests/fixtures/vfs-example'
  29. /** One built-in Preview source and the trees packed into its overlay. */
  30. export interface PreviewFixture {
  31. /** URL/query-safe identifier. */
  32. readonly id: string
  33. /** User-facing chooser label. */
  34. readonly label: string
  35. /** User-facing chooser detail. */
  36. readonly description: string
  37. /** Opaque trees packed into this fixture's overlay archive. */
  38. readonly trees: readonly ImageTree[]
  39. }
  40. /**
  41. * Index every workspace and vendored package by name.
  42. * @param repoRoot - Absolute repository root.
  43. * @returns Package name to absolute directory.
  44. */
  45. export function indexWorkspacePackages(repoRoot: string): Map<string, string> {
  46. const index = new Map<string, string>()
  47. const visit = (directory: string): void => {
  48. const manifest = join(directory, 'package.json')
  49. if (existsSync(manifest)) {
  50. const name = (JSON.parse(readFileSync(manifest, 'utf8')) as { name?: unknown }).name
  51. if (typeof name === 'string') index.set(name, directory)
  52. // A package root owns its subtree; anything below (test fixtures,
  53. // nested manifests) is not a separate workspace package.
  54. return
  55. }
  56. for (const entry of readdirSync(directory, { withFileTypes: true })) {
  57. if (!entry.isDirectory()) continue
  58. if (entry.name === 'node_modules' || entry.name.startsWith('.')) continue
  59. visit(join(directory, entry.name))
  60. }
  61. }
  62. for (const scanRoot of WORKSPACE_SCAN_ROOTS) {
  63. const absolute = join(repoRoot, scanRoot)
  64. if (existsSync(absolute)) visit(absolute)
  65. }
  66. return index
  67. }
  68. /**
  69. * Compose one profile through the real CLI dump path, leaving `!!js`
  70. * unevaluated. The dump runs against a throwaway Harness home and default
  71. * layers only, so the image is the shipped profile: the machine's `$DSH_HOME`
  72. * — its profile manifest with locally installed bundles, and its patch files —
  73. * would otherwise leak this machine's plugins into the image and break the
  74. * same-tree-same-bytes guarantee.
  75. * @param repoRoot - Absolute repository root.
  76. * @param profile - Profile name to compose.
  77. * @returns The composed YAML.
  78. */
  79. export function composeProfile(repoRoot: string, profile: string): string {
  80. const home = mkdtempSync(join(tmpdir(), 'dsh-pack-home-'))
  81. try {
  82. return execFileSync(
  83. process.execPath,
  84. ['--import', 'tsx/esm', join(repoRoot, CLI_ENTRY), '--profile', profile, '--dump-default-config'],
  85. { cwd: repoRoot, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, env: { ...process.env, [DSH_HOME_ENV]: home } },
  86. )
  87. } finally {
  88. rmSync(home, { recursive: true, force: true })
  89. }
  90. }
  91. /** One `dsh.configTrees` declaration entry, validated field by field. */
  92. interface ConfigTreeDeclaration {
  93. mount: string
  94. path: string
  95. scanRoster?: boolean
  96. }
  97. /**
  98. * Config trees the CLI package declares for deployment images
  99. * (`dsh.configTrees` in its package.json): `path` is relative to the CLI
  100. * package root, `mount` is the image path, `scanRoster` feeds the tree's yml
  101. * plugin rows into the pack roster. The CLI owns its config layout; this
  102. * reader follows the declaration instead of naming directories. A malformed
  103. * declaration refuses the pack.
  104. * @param repoRoot - Absolute repository root.
  105. * @returns Trees with absolute source directories.
  106. */
  107. export function configTrees(repoRoot: string): ConfigTree[] {
  108. const packageDir = join(repoRoot, CLI_PACKAGE)
  109. const manifest = JSON.parse(readFileSync(join(packageDir, 'package.json'), 'utf8')) as {
  110. dsh?: { configTrees?: unknown }
  111. }
  112. const declared = manifest.dsh?.configTrees
  113. if (declared === undefined) return []
  114. if (!Array.isArray(declared)) {
  115. throw new Error(`vfs image: ${CLI_PACKAGE} dsh.configTrees must be an array`)
  116. }
  117. const mounts = new Set<string>()
  118. return declared.map((entry, index) => {
  119. const tree = entry as Partial<ConfigTreeDeclaration> | null
  120. const at = `${CLI_PACKAGE} dsh.configTrees[${String(index)}]`
  121. if (tree === null || typeof tree !== 'object'
  122. || typeof tree.mount !== 'string' || tree.mount === ''
  123. || typeof tree.path !== 'string' || tree.path === ''
  124. || (tree.scanRoster !== undefined && typeof tree.scanRoster !== 'boolean')) {
  125. throw new Error(`vfs image: ${at} must declare a string mount, a string path, and an optional boolean scanRoster`)
  126. }
  127. if (mounts.has(tree.mount)) {
  128. throw new Error(`vfs image: ${at} repeats mount ${JSON.stringify(tree.mount)}`)
  129. }
  130. mounts.add(tree.mount)
  131. return {
  132. mount: tree.mount,
  133. directory: join(packageDir, tree.path),
  134. ...tree.scanRoster === undefined ? {} : { scanRoster: tree.scanRoster },
  135. }
  136. })
  137. }
  138. /**
  139. * Built-in filesystem fixtures offered by the repository preview.
  140. * Session and Workspace semantics remain opaque here; the owning runtime tests
  141. * validate those files through their production readers.
  142. * @param repoRoot - Absolute repository root.
  143. * @returns Named chooser entries and their overlay trees.
  144. */
  145. export function previewFixtures(repoRoot: string): PreviewFixture[] {
  146. const root = join(repoRoot, PREVIEW_EXAMPLE_ROOT)
  147. return [{
  148. id: 'vfs-example',
  149. label: 'Built-in showcase',
  150. description: 'Sample workspace, tool cards, subagents, and paged history.',
  151. trees: ['home', 'workspace'].map(mount => ({ mount, directory: join(root, mount) })),
  152. }]
  153. }
  154. /**
  155. * Render one pack as the lines a build log should carry.
  156. *
  157. * Refusals and unresolved dependencies are the two states a reader must not miss, so
  158. * they are spelled out rather than counted.
  159. * @param result - What the pack produced.
  160. * @param repoRoot - Absolute repository root, for relative paths.
  161. * @param outputFile - Where the image was written.
  162. * @returns Lines to print.
  163. */
  164. export function describePack(result: PackResult, repoRoot: string, outputFile: string): string[] {
  165. const sizeOf = (prefix: string): number => Object.entries(result.files)
  166. .filter(([name]) => name.startsWith(prefix))
  167. .reduce((sum, [, bytes]) => sum + bytes.byteLength, 0)
  168. const megabytes = (bytes: number): string => `${(bytes / 1024 / 1024).toFixed(2)} MB`
  169. const workspaceCount = result.workspacePackages
  170. const heaviest = [...result.packages.entries()]
  171. .map(([name, count]) => ({ name, count, bytes: sizeOf(`node_modules/${name}/`) }))
  172. .sort((left, right) => right.bytes - left.bytes)
  173. .slice(0, 12)
  174. return [
  175. `vfs image: ${relative(repoRoot, outputFile)}`,
  176. ` roster entries ${String(result.roster.length)}`,
  177. ` packages ${String(result.packages.size)} (${String(workspaceCount)} workspace)`,
  178. ` files ${String(Object.keys(result.files).length)}`,
  179. ` raw ${megabytes(Object.values(result.files).reduce((sum, bytes) => sum + bytes.byteLength, 0))}`,
  180. ` compressed ${megabytes(result.image.byteLength)}`,
  181. ` config + presets ${megabytes(sizeOf('config/'))}`,
  182. ` javascript entries ${String(result.javascriptEntries)} (dropped ${String(result.executables.length)} executable scripts, ${String(result.pageBundles.length)} page bundles verbatim)`,
  183. ` wrapper contract ${result.contract}`,
  184. ` transform ${String(result.transform.rewritten)} of ${String(result.transform.visited)} reached entries rewritten, ${String(result.droppedJavascriptEntries)} unreachable dropped`,
  185. ` unresolved ${String(result.unresolvedExternalRequests.length)} third-party request(s) left to fail loud at require time`,
  186. ' heaviest packages:',
  187. ...heaviest.map(entry => ` ${entry.bytes.toString().padStart(9)} B ${entry.name} (${String(entry.count)} files)`),
  188. ...result.missing.length === 0
  189. ? []
  190. : [' unresolved dependencies:', ...result.missing.map(entry => ` ${entry}`)],
  191. '',
  192. ]
  193. }