project-manager.ts 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611
  1. /** In-place owner of the reserved desktop profile and its private pnpm state. */
  2. import { valid } from 'semver'
  3. import { spawn } from 'node:child_process'
  4. import {
  5. existsSync,
  6. fsyncSync,
  7. ftruncateSync,
  8. lstatSync,
  9. mkdirSync,
  10. openSync,
  11. closeSync,
  12. readFileSync,
  13. readdirSync,
  14. realpathSync,
  15. unlinkSync,
  16. writeFileSync,
  17. writeSync,
  18. } from 'node:fs'
  19. import { delimiter, dirname, join, resolve, sep } from 'node:path'
  20. import {
  21. DESKTOP_HOST_PACKAGE,
  22. desktopCorePackageOverrides,
  23. verifyDesktopCorePackageSet,
  24. } from './core-package-set.ts'
  25. import type { DesktopPaths } from './paths.ts'
  26. import { removeOwnedDirectory } from './owned-directory.ts'
  27. import type { DesktopRelease } from './release.ts'
  28. import { desktopRuntimeId, readDesktopRuntime, type DesktopRuntimeDescriptor } from './runtime-tree.ts'
  29. import {
  30. desktopPluginLockHash, linkDesktopHostPackages, readDesktopProfileState,
  31. unlinkDesktopHostPackages, validateDesktopPluginGraph, type DesktopProfileState,
  32. } from './profile-packages.ts'
  33. /** Desktop plugin record derived from the installed profile. */
  34. export interface DesktopPluginRecord {
  35. readonly name: string
  36. readonly version: string
  37. readonly enabled: boolean
  38. }
  39. /** Installed desktop project manifest slice. */
  40. interface DesktopProjectManifest {
  41. readonly name: string
  42. readonly private: true
  43. readonly version: string
  44. readonly dependencies: Record<string, string>
  45. readonly dsh: {
  46. readonly profile: {
  47. readonly bundles: string[]
  48. }
  49. }
  50. }
  51. /** Exact executables the desktop shell bundles. */
  52. export interface DesktopRuntimeExecutables {
  53. readonly node: string
  54. readonly pnpm: string
  55. readonly dsh: string
  56. }
  57. /** Hooks that stop the backend before profile writes and restart it after success. */
  58. export interface DesktopProjectHooks {
  59. /** Stop the active backend and await process exit before modifying its files. */
  60. beforeChange(): Promise<void>
  61. /** Start the modified profile after package preparation succeeds. */
  62. afterChange(): Promise<void>
  63. }
  64. /** Supported dependency mutation. */
  65. export type DesktopProjectMutation =
  66. | { readonly type: 'plugin-add'; readonly spec: string }
  67. | { readonly type: 'plugin-remove'; readonly name: string }
  68. | { readonly type: 'plugin-update'; readonly name: string; readonly version: string }
  69. | { readonly type: 'plugin-toggle'; readonly name: string; readonly enabled: boolean }
  70. | { readonly type: 'plugins-disable-all' }
  71. const PROJECT_NAME = '@deepseek-ai/dsh-desktop-runtime'
  72. const DSH_PACKAGE = '@deepseek-ai/dsh'
  73. const CORE_BUILD_PACKAGE = '@deepseek-ai/dsh-subprocess-local'
  74. const DESKTOP_PROFILE_BUNDLES = ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app'] as const
  75. const WORKSPACE_SETTINGS = 'nodeLinker: hoisted\nautoInstallPeers: false\nstrictDepBuilds: true\n'
  76. const PACKAGE_NAME_PATTERN = /^(?:@[a-z0-9][a-z0-9._~-]*\/[a-z0-9][a-z0-9._~-]*|[a-z0-9][a-z0-9._~-]*)$/u
  77. const VERSION_PATTERN = /^[0-9A-Za-z][0-9A-Za-z.+_-]*$/u
  78. const MAX_PNPM_DIAGNOSTIC_BYTES = 64 * 1024
  79. const DESKTOP_REGISTRY = 'https://registry.npmjs.org/'
  80. function errorOf(reason: unknown, fallback: string): Error {
  81. return reason instanceof Error ? reason : new Error(fallback)
  82. }
  83. function writeJson(path: string, value: unknown): void {
  84. writeFileSync(path, `${JSON.stringify(value, undefined, 2)}\n`, { mode: 0o600 })
  85. }
  86. function readJson(path: string): unknown {
  87. return JSON.parse(readFileSync(path, 'utf8'))
  88. }
  89. function workspaceFile(overrides: Readonly<Record<string, string>> = {}): string {
  90. const entries = Object.entries(overrides).sort(([left], [right]) => left.localeCompare(right))
  91. const overrideSection = entries.length === 0
  92. ? ''
  93. : `overrides:\n${entries.map(([name, spec]) => ` ${JSON.stringify(name)}: ${JSON.stringify(spec)}`).join('\n')}\n`
  94. const coreBuildSpec = overrides[CORE_BUILD_PACKAGE]
  95. const coreBuildKey = coreBuildSpec === undefined
  96. ? CORE_BUILD_PACKAGE
  97. : `${CORE_BUILD_PACKAGE}@${coreBuildSpec.replace('file:./', 'file:')}`
  98. return `packages:\n - .\n\n${overrideSection}${WORKSPACE_SETTINGS}allowBuilds:\n node-pty: true\n koffi: true\n fs-ext: true\n ${JSON.stringify(coreBuildKey)}: true\n '@google/genai': false\n protobufjs: false\n node-addon-require-builtin: false\n`
  99. }
  100. function isRecord(value: unknown): value is Record<string, unknown> {
  101. return typeof value === 'object' && value !== null
  102. }
  103. function assertPackageName(name: string): void {
  104. if (!PACKAGE_NAME_PATTERN.test(name)) throw new Error(`desktop project: invalid npm package name ${JSON.stringify(name)}`)
  105. }
  106. function assertVersion(version: string): void {
  107. if (!VERSION_PATTERN.test(version)) throw new Error(`desktop project: invalid exact version ${JSON.stringify(version)}`)
  108. }
  109. /**
  110. * Validate one registry package spec and return its package name.
  111. * @param spec - npm registry name with an optional version or tag.
  112. * @returns Requested package name.
  113. */
  114. export function packageNameFromSpec(spec: string): string {
  115. if (spec === '' || spec.startsWith('-') || /[\s\\]/u.test(spec) || spec.includes('://') || spec.startsWith('file:')) {
  116. throw new Error(`desktop project: unsupported npm package spec ${JSON.stringify(spec)}`)
  117. }
  118. if (spec.startsWith('@')) {
  119. const slash = spec.indexOf('/')
  120. if (slash === -1) throw new Error(`desktop project: invalid scoped package spec ${JSON.stringify(spec)}`)
  121. const versionAt = spec.indexOf('@', slash)
  122. const name = versionAt === -1 ? spec : spec.slice(0, versionAt)
  123. assertPackageName(name)
  124. if (versionAt !== -1) assertVersion(spec.slice(versionAt + 1))
  125. return name
  126. }
  127. const versionAt = spec.indexOf('@')
  128. const name = versionAt === -1 ? spec : spec.slice(0, versionAt)
  129. assertPackageName(name)
  130. if (versionAt !== -1) assertVersion(spec.slice(versionAt + 1))
  131. return name
  132. }
  133. function projectManifest(projectDir: string): DesktopProjectManifest {
  134. const path = join(projectDir, 'package.json')
  135. const value = readJson(path)
  136. const dsh = isRecord(value) && isRecord(value.dsh) ? value.dsh : undefined
  137. const profile = isRecord(dsh?.profile) ? dsh.profile : undefined
  138. if (!isRecord(value) || value.name !== PROJECT_NAME || value.private !== true
  139. || typeof value.version !== 'string' || (value.dependencies !== undefined && !isRecord(value.dependencies))
  140. || !Array.isArray(profile?.bundles) || !profile.bundles.every(bundle => typeof bundle === 'string')) {
  141. throw new Error(`desktop project: invalid desktop profile manifest ${path}`)
  142. }
  143. const manifest = { ...value, dependencies: value.dependencies ?? {} } as unknown as DesktopProjectManifest
  144. if (Object.entries(manifest.dependencies).some(([name, version]) => !PACKAGE_NAME_PATTERN.test(name)
  145. || typeof version !== 'string' || valid(version) !== version)) {
  146. throw new Error('desktop project: plugin dependencies must use exact registry versions')
  147. }
  148. return manifest
  149. }
  150. function profilePluginNames(projectDir: string): readonly string[] {
  151. const bundles = projectManifest(projectDir).dsh.profile.bundles
  152. if (!DESKTOP_PROFILE_BUNDLES.every((bundle, index) => bundles[index] === bundle)) {
  153. throw new Error('desktop project: profile must begin with the built-in desktop bundle list')
  154. }
  155. const plugins = bundles.slice(DESKTOP_PROFILE_BUNDLES.length)
  156. if (new Set(bundles).size !== bundles.length) {
  157. throw new Error('desktop project: profile bundle list contains a duplicate package')
  158. }
  159. for (const plugin of plugins) assertPackageName(plugin)
  160. return plugins
  161. }
  162. function pluginRecords(projectDir: string): readonly DesktopPluginRecord[] {
  163. return Object.keys(projectManifest(projectDir).dependencies).sort().map(name => inspectPlugin(projectDir, name))
  164. }
  165. function writeProfilePlugins(projectDir: string, plugins: readonly DesktopPluginRecord[]): void {
  166. const manifest = projectManifest(projectDir)
  167. writeJson(join(projectDir, 'package.json'), {
  168. ...manifest,
  169. dsh: {
  170. ...manifest.dsh,
  171. profile: {
  172. ...manifest.dsh.profile,
  173. bundles: [...DESKTOP_PROFILE_BUNDLES, ...plugins.filter(plugin => plugin.enabled).map(plugin => plugin.name)],
  174. },
  175. },
  176. } satisfies DesktopProjectManifest)
  177. }
  178. function inspectPlugin(projectDir: string, requestedName: string): DesktopPluginRecord {
  179. const manifestPath = join(projectDir, 'node_modules', ...requestedName.split('/'), 'package.json')
  180. if (!existsSync(manifestPath)) {
  181. throw new Error(`desktop project: installed package ${JSON.stringify(requestedName)} has no manifest`)
  182. }
  183. const manifest = readJson(manifestPath)
  184. if (!isRecord(manifest) || manifest.name !== requestedName || typeof manifest.version !== 'string') {
  185. throw new Error(`desktop project: installed package ${JSON.stringify(requestedName)} has inconsistent name or version`)
  186. }
  187. const dsh = manifest.dsh
  188. const bundle = isRecord(dsh) ? dsh.bundle : undefined
  189. const patch = isRecord(bundle) ? bundle.patch : undefined
  190. if (typeof patch !== 'string' || patch === '') {
  191. throw new Error(`desktop project: ${requestedName}@${manifest.version} does not declare dsh.bundle.patch`)
  192. }
  193. const packageDir = dirname(manifestPath)
  194. const patchPath = resolve(packageDir, patch)
  195. if ((patchPath !== packageDir && !patchPath.startsWith(packageDir + sep)) || !existsSync(patchPath)) {
  196. throw new Error(`desktop project: ${requestedName}@${manifest.version} declares an invalid bundle patch`)
  197. }
  198. return { name: requestedName, version: manifest.version, enabled: profilePluginNames(projectDir).includes(requestedName) }
  199. }
  200. /** Desktop npm project manager with direct writes and no rollback. */
  201. export class DesktopProjectManager {
  202. private lockDescriptor: number | undefined
  203. private descriptor: DesktopRuntimeDescriptor | undefined
  204. /**
  205. * @param paths - Electron-owned package state and reserved desktop profile paths.
  206. * @param runtime - absolute bundled Node.js and pnpm entry paths.
  207. */
  208. constructor(
  209. readonly paths: DesktopPaths,
  210. readonly runtime: DesktopRuntimeExecutables,
  211. ) {}
  212. /** Read the active desktop plugin inventory. */
  213. listPlugins(): readonly DesktopPluginRecord[] {
  214. if (!existsSync(this.paths.profile)) return []
  215. return pluginRecords(this.paths.profile)
  216. }
  217. /**
  218. * Reinitialize the profile, deleting configuration and third-party packages without a backup.
  219. * @param hooks - Stop the Host before resetting files; restart after preparation succeeds.
  220. * @returns Completion of reset; the held lock and shared product data are preserved.
  221. */
  222. async resetConfiguration(hooks: DesktopProjectHooks): Promise<void> {
  223. await this.withLock(async () => {
  224. await hooks.beforeChange()
  225. this.descriptor = this.readRuntime()
  226. for (const entry of readdirSync(this.paths.profile, { withFileTypes: true })) {
  227. const path = join(this.paths.profile, entry.name)
  228. if (path === this.paths.lock) continue
  229. if (entry.isDirectory()) removeOwnedDirectory(path)
  230. else unlinkSync(path)
  231. }
  232. createPluginProfile(this.paths.profile)
  233. this.prepareProfile(this.paths.profile)
  234. await hooks.afterChange()
  235. })
  236. }
  237. /** Read the dsh version supplied by this application's verified resources. */
  238. dshVersion(): string {
  239. return this.currentRuntime().release.version
  240. }
  241. /** Read the release most recently applied to the active profile. */
  242. releaseVersion(): string {
  243. const state = readDesktopProfileState(this.paths.profile)
  244. if (state === undefined) throw new Error('desktop project: active profile has no runtime state')
  245. return state.version
  246. }
  247. /** Reject a profile whose dependency links were prepared for another runtime. */
  248. assertProfileRuntime(projectDir: string): void {
  249. if (existsSync(this.pendingPackages)) throw new Error('desktop project: package preparation is incomplete; retry startup')
  250. if (readDesktopProfileState(projectDir)?.runtimeId !== desktopRuntimeId(this.currentRuntime())) {
  251. throw new Error('desktop project: profile does not match this application runtime')
  252. }
  253. }
  254. /** @returns Whether application resources support profile recovery. */
  255. canRecoverProfile(): boolean {
  256. return this.descriptor !== undefined && existsSync(this.runtime.node) && existsSync(this.runtime.dsh)
  257. }
  258. private get pendingPackages(): string { return join(this.paths.profile, 'desktop-packages-pending') }
  259. private currentRuntime(): DesktopRuntimeDescriptor {
  260. if (this.descriptor === undefined) throw new Error('desktop project: runtime metadata has not been loaded')
  261. return this.descriptor
  262. }
  263. private readRuntime(): DesktopRuntimeDescriptor {
  264. this.descriptor = undefined
  265. return readDesktopRuntime(this.runtime.dsh)
  266. }
  267. private prepareProfile(projectDir: string): void {
  268. const runtime = this.currentRuntime()
  269. linkDesktopHostPackages(projectDir, this.runtime.dsh, runtime)
  270. validateDesktopPluginGraph(projectDir, this.runtime.dsh, runtime, profilePluginNames(projectDir))
  271. }
  272. /** Read release metadata and reconcile its external profile without installing core packages. */
  273. async applyRelease(): Promise<boolean> {
  274. return this.withLock(async () => {
  275. const target = this.readRuntime()
  276. this.descriptor = target
  277. const previous = readDesktopProfileState(this.paths.profile)
  278. if (!existsSync(this.pendingPackages) && previous?.runtimeId === desktopRuntimeId(target)
  279. && previous.lockHash === desktopPluginLockHash(this.paths.profile)
  280. && previous.links.length === target.sharedPackages.length
  281. && previous.links.every(link => existsSync(link.target)
  282. && existsSync(join(this.paths.profile, 'node_modules', link.name))
  283. && realpathSync.native(link.target) === realpathSync.native(join(this.runtime.dsh, 'node_modules', link.name)))) {
  284. return false
  285. }
  286. if (previous === undefined) createPluginProfile(this.paths.profile)
  287. await this.reconcileProfile(this.paths.profile, previous)
  288. return true
  289. })
  290. }
  291. /** Modify the current profile while its backend is stopped; failures retain partial changes. */
  292. async mutate(mutation: DesktopProjectMutation, hooks: DesktopProjectHooks): Promise<void> {
  293. await this.withLock(async () => {
  294. this.currentRuntime()
  295. if (!existsSync(this.paths.profile)) throw new Error('desktop project: active profile is not installed')
  296. await hooks.beforeChange()
  297. if (mutation.type === 'plugins-disable-all') {
  298. const manifest = projectManifest(this.paths.profile)
  299. writeJson(join(this.paths.profile, 'package.json'), {
  300. ...manifest,
  301. dsh: { ...manifest.dsh, profile: { ...manifest.dsh.profile, bundles: [...DESKTOP_PROFILE_BUNDLES] } },
  302. })
  303. this.prepareProfile(this.paths.profile)
  304. await hooks.afterChange()
  305. return
  306. }
  307. const previous = readDesktopProfileState(this.paths.profile)
  308. const packagesChanged = mutation.type !== 'plugin-toggle'
  309. if (packagesChanged) unlinkDesktopHostPackages(this.paths.profile)
  310. try {
  311. await this.applyMutation(this.paths.profile, mutation)
  312. } finally {
  313. if (packagesChanged) linkDesktopHostPackages(this.paths.profile, this.runtime.dsh, this.currentRuntime())
  314. }
  315. await this.reconcileProfile(this.paths.profile, previous, packagesChanged)
  316. await hooks.afterChange()
  317. })
  318. }
  319. private async reconcileProfile(projectDir: string, previous: DesktopProfileState | undefined, packagesChanged = false): Promise<void> {
  320. const target = this.currentRuntime()
  321. const rebuild = (!packagesChanged && existsSync(this.pendingPackages))
  322. || (previous !== undefined && pluginRecords(projectDir).length > 0
  323. && (previous.nodeVersion !== target.release.nodeVersion || previous.platform !== target.platform || previous.arch !== target.arch))
  324. if (rebuild) {
  325. writeFileSync(this.pendingPackages, '')
  326. unlinkDesktopHostPackages(projectDir)
  327. removeOwnedDirectory(join(projectDir, 'node_modules'))
  328. await this.runPnpm(projectDir, ['install', '--frozen-lockfile', '--ignore-scripts'])
  329. }
  330. if (rebuild || packagesChanged) await this.finishPackageOperation(projectDir)
  331. else this.prepareProfile(projectDir)
  332. }
  333. private async finishPackageOperation(projectDir: string): Promise<void> {
  334. this.prepareProfile(projectDir)
  335. await this.runPnpm(projectDir, ['rebuild', '--pending'])
  336. this.prepareProfile(projectDir)
  337. unlinkSync(this.pendingPackages)
  338. }
  339. private async applyMutation(projectDir: string, mutation: Exclude<DesktopProjectMutation, { type: 'plugins-disable-all' }>): Promise<void> {
  340. switch (mutation.type) {
  341. case 'plugin-add': {
  342. const requestedName = packageNameFromSpec(mutation.spec)
  343. if (this.currentRuntime().sharedPackages.some(entry => entry.name === requestedName)) {
  344. throw new Error(`desktop project: cannot install host-owned package ${requestedName}`)
  345. }
  346. await this.runPnpm(projectDir, ['add', mutation.spec, '--save-exact', '--ignore-scripts'])
  347. const installed = { ...inspectPlugin(projectDir, requestedName), enabled: true }
  348. const current = pluginRecords(projectDir).filter(plugin => plugin.name !== installed.name)
  349. writeProfilePlugins(
  350. projectDir,
  351. [...current, installed].sort((left, right) => left.name.localeCompare(right.name)),
  352. )
  353. return
  354. }
  355. case 'plugin-remove': {
  356. assertPackageName(mutation.name)
  357. if (!Object.hasOwn(projectManifest(projectDir).dependencies, mutation.name)) {
  358. throw new Error(`desktop project: plugin ${JSON.stringify(mutation.name)} is not installed`)
  359. }
  360. const remaining = pluginRecords(projectDir).filter(plugin => plugin.name !== mutation.name)
  361. await this.runPnpm(projectDir, ['remove', mutation.name, '--config.ignore-scripts=true'])
  362. writeProfilePlugins(projectDir, remaining)
  363. return
  364. }
  365. case 'plugin-update':
  366. assertPackageName(mutation.name)
  367. assertVersion(mutation.version)
  368. if (!Object.hasOwn(projectManifest(projectDir).dependencies, mutation.name)) {
  369. throw new Error(`desktop project: plugin ${JSON.stringify(mutation.name)} is not installed`)
  370. }
  371. await this.runPnpm(projectDir, ['add', `${mutation.name}@${mutation.version}`, '--save-exact', '--ignore-scripts'])
  372. {
  373. const installed = inspectPlugin(projectDir, mutation.name)
  374. writeProfilePlugins(
  375. projectDir,
  376. pluginRecords(projectDir).map(plugin => plugin.name === installed.name ? installed : plugin),
  377. )
  378. }
  379. return
  380. case 'plugin-toggle': {
  381. assertPackageName(mutation.name)
  382. const plugins = pluginRecords(projectDir)
  383. if (!plugins.some(plugin => plugin.name === mutation.name)) throw new Error(`desktop project: plugin ${mutation.name} is not installed`)
  384. writeProfilePlugins(projectDir, plugins.map(plugin => (
  385. plugin.name === mutation.name ? { ...plugin, enabled: mutation.enabled } : plugin
  386. )))
  387. return
  388. }
  389. default:
  390. mutation satisfies never
  391. }
  392. }
  393. private async runPnpm(projectDir: string, args: readonly string[]): Promise<void> {
  394. const [command, ...commandArgs] = args
  395. if (command === undefined) throw new Error('desktop project: pnpm command is required')
  396. for (const path of [this.paths.root, this.paths.pnpm.store, this.paths.pnpm.cache,
  397. this.paths.pnpm.state, this.paths.pnpm.config, this.paths.pnpm.home]) {
  398. mkdirSync(path, { recursive: true, mode: 0o700 })
  399. }
  400. const npmrc = join(this.paths.pnpm.config, 'npmrc')
  401. if (!existsSync(npmrc)) writeFileSync(npmrc, '', { mode: 0o600 })
  402. const inherited = Object.fromEntries(Object.entries(process.env).filter(([name]) => (
  403. name !== 'NODE_OPTIONS' && name !== 'NODE_PATH' && !/^DSH_DESKTOP_/u.test(name) && !/^(?:npm|pnpm|corepack)_/iu.test(name)
  404. )))
  405. writeFileSync(this.pendingPackages, '')
  406. await new Promise<void>((settle, reject) => {
  407. const child = spawn(this.runtime.node, [
  408. this.runtime.pnpm,
  409. `--config.registry=${DESKTOP_REGISTRY}`,
  410. `--config.store-dir=${this.paths.pnpm.store}`,
  411. '--config.enable-global-virtual-store=false',
  412. `--config.userconfig=${npmrc}`,
  413. command,
  414. ...commandArgs,
  415. ], {
  416. cwd: projectDir,
  417. env: {
  418. ...inherited,
  419. COREPACK_HOME: this.paths.pnpm.home,
  420. NPM_CONFIG_REGISTRY: DESKTOP_REGISTRY,
  421. NPM_CONFIG_STORE_DIR: this.paths.pnpm.store,
  422. NPM_CONFIG_USERCONFIG: npmrc,
  423. PATH: `${dirname(this.runtime.node)}${delimiter}${process.env.PATH ?? ''}`,
  424. PNPM_HOME: this.paths.pnpm.home,
  425. XDG_CACHE_HOME: this.paths.pnpm.cache,
  426. XDG_CONFIG_HOME: this.paths.pnpm.config,
  427. XDG_STATE_HOME: this.paths.pnpm.state,
  428. },
  429. stdio: ['ignore', 'pipe', 'pipe'],
  430. })
  431. let failure: Error | undefined
  432. let diagnostics = ''
  433. let completed = false
  434. const appendDiagnostics = (chunk: string): void => {
  435. diagnostics = (diagnostics + chunk).slice(-MAX_PNPM_DIAGNOSTIC_BYTES)
  436. }
  437. child.stdout.setEncoding('utf8')
  438. child.stdout.on('data', appendDiagnostics)
  439. child.stderr.setEncoding('utf8')
  440. child.stderr.on('data', appendDiagnostics)
  441. const complete = (settleChild: () => void): void => {
  442. if (completed) return
  443. completed = true
  444. try {
  445. this.writeLockOwner(process.pid)
  446. } catch (error) {
  447. reject(errorOf(error, 'desktop project: failed to return the package transaction lock to Electron'))
  448. return
  449. }
  450. settleChild()
  451. }
  452. child.once('error', (error) => { failure = error })
  453. child.once('close', (code, signal) => {
  454. complete(() => {
  455. if (failure !== undefined) { reject(failure); return }
  456. if (code === 0) {
  457. settle()
  458. return
  459. }
  460. reject(new Error(
  461. `desktop project: pnpm exited with ${String(code ?? signal)}${diagnostics.trim() === '' ? '' : `: ${diagnostics.trim()}`}`,
  462. ))
  463. })
  464. })
  465. try {
  466. if (child.pid === undefined) throw new Error('desktop project: pnpm did not report a process id')
  467. this.writeLockOwner(child.pid)
  468. } catch (error) {
  469. failure = errorOf(error, 'desktop project: failed to assign the package transaction lock to pnpm')
  470. child.kill('SIGKILL')
  471. }
  472. })
  473. }
  474. private writeLockOwner(pid: number): void {
  475. const descriptor = this.lockDescriptor
  476. if (descriptor === undefined) throw new Error('desktop project: package transaction lost its lock')
  477. const content = Buffer.from(`${String(pid)}\n`)
  478. ftruncateSync(descriptor, 0)
  479. writeSync(descriptor, content, 0, content.byteLength, 0)
  480. fsyncSync(descriptor)
  481. }
  482. private async withLock<T>(operation: () => Promise<T>): Promise<T> {
  483. mkdirSync(this.paths.profile, { recursive: true, mode: 0o700 })
  484. if (lstatSync(this.paths.profile).isSymbolicLink()) throw new Error('desktop project: profile directory must not be a link')
  485. let descriptor: number
  486. try {
  487. descriptor = openSync(this.paths.lock, 'wx', 0o600)
  488. } catch (error) {
  489. if ((error as NodeJS.ErrnoException).code === 'EEXIST') {
  490. const lock = lstatSync(this.paths.lock)
  491. if (lock.isSymbolicLink() || !lock.isFile()) {
  492. throw new Error('desktop project: package transaction lock is not a regular file')
  493. }
  494. const owner = Number.parseInt(readFileSync(this.paths.lock, 'utf8').trim(), 10)
  495. let active = !Number.isSafeInteger(owner) || owner <= 0
  496. if (!active) {
  497. try {
  498. process.kill(owner, 0)
  499. active = true
  500. } catch (signalError) {
  501. active = (signalError as NodeJS.ErrnoException).code !== 'ESRCH'
  502. }
  503. }
  504. if (active) throw new Error('desktop project: another package transaction is active')
  505. unlinkSync(this.paths.lock)
  506. descriptor = openSync(this.paths.lock, 'wx', 0o600)
  507. } else {
  508. throw error
  509. }
  510. }
  511. try {
  512. this.lockDescriptor = descriptor
  513. this.writeLockOwner(process.pid)
  514. return await operation()
  515. } finally {
  516. this.lockDescriptor = undefined
  517. closeSync(descriptor)
  518. unlinkSync(this.paths.lock)
  519. }
  520. }
  521. }
  522. /** Create build-only project metadata for materializing the signed runtime. */
  523. export function createRuntimeProjectMetadata(projectDir: string, release: DesktopRelease): void {
  524. mkdirSync(projectDir, { recursive: true, mode: 0o700 })
  525. const packageSet = verifyDesktopCorePackageSet(projectDir, release.version)
  526. const manifest: DesktopProjectManifest = {
  527. name: PROJECT_NAME,
  528. private: true,
  529. version: '0.0.0',
  530. dependencies: desktopCorePackageOverrides(packageSet),
  531. dsh: { profile: { bundles: [...DESKTOP_PROFILE_BUNDLES] } },
  532. }
  533. writeJson(join(projectDir, 'package.json'), manifest)
  534. writeFileSync(
  535. join(projectDir, 'pnpm-workspace.yaml'),
  536. workspaceFile(desktopCorePackageOverrides(packageSet)),
  537. { mode: 0o600 },
  538. )
  539. }
  540. /**
  541. * Create metadata for the unpackaged development project that links the current workspace.
  542. * @param projectDir - Disposable development profile directory.
  543. * @param release - Release identity shared by the linked CLI package and Electron shell.
  544. */
  545. export function createDevelopmentProjectMetadata(projectDir: string, release: DesktopRelease): void {
  546. mkdirSync(projectDir, { recursive: true, mode: 0o700 })
  547. const manifest = {
  548. name: PROJECT_NAME,
  549. private: true,
  550. version: '0.0.0',
  551. dependencies: {
  552. [DSH_PACKAGE]: release.version,
  553. [DESKTOP_HOST_PACKAGE]: release.version,
  554. },
  555. dsh: { profile: { bundles: [...DESKTOP_PROFILE_BUNDLES] } },
  556. }
  557. writeJson(join(projectDir, 'package.json'), manifest)
  558. writeFileSync(join(projectDir, 'pnpm-workspace.yaml'), workspaceFile(), { mode: 0o600 })
  559. }
  560. /** Create the first external plugin profile without running a package manager. */
  561. export function createPluginProfile(projectDir: string): void {
  562. mkdirSync(projectDir, { recursive: true, mode: 0o700 })
  563. writeJson(join(projectDir, 'package.json'), {
  564. name: PROJECT_NAME, private: true, version: '0.0.0', dependencies: {},
  565. dsh: { profile: { bundles: [...DESKTOP_PROFILE_BUNDLES] } },
  566. } satisfies DesktopProjectManifest)
  567. writeFileSync(join(projectDir, 'pnpm-workspace.yaml'), workspaceFile(), { mode: 0o600 })
  568. }