repository.ts 9.4 KB

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