oneworks 0.0.0 → 0.1.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,432 @@
1
+ /* eslint-disable max-lines -- bootstrap adapter package resolution keeps cache lookup, install, and CLI parsing together. */
2
+ import { createHash } from 'node:crypto'
3
+ import { existsSync } from 'node:fs'
4
+ import { readFile, readdir, rename, rm, writeFile } from 'node:fs/promises'
5
+ import path from 'node:path'
6
+ import process from 'node:process'
7
+
8
+ import { compareVersionLike, ensureDirectory, resolvePackageManagerEnv, sanitizePackageName } from './npm-package-cache'
9
+ import { resolveBootstrapPackageCacheDir } from './paths'
10
+ import { runBufferedCommand } from './process-utils'
11
+ import { createBootstrapProgress } from './progress'
12
+
13
+ const NPM_BIN = process.platform === 'win32' ? 'npm.cmd' : 'npm'
14
+ const ADAPTER_SCOPE = '@oneworks'
15
+ const ADAPTER_PREFIX = 'adapter-'
16
+
17
+ interface AdapterPackageVersionMetadata {
18
+ checkedAt: string
19
+ installedPackageDir: string
20
+ key: string
21
+ name: string
22
+ resolvedVersion: string
23
+ version: string
24
+ }
25
+
26
+ export interface CliAdapterPackageRequest {
27
+ adapter: string
28
+ cliVersion: string
29
+ }
30
+
31
+ const hashValue = (value: string) => createHash('sha1').update(value).digest('hex')
32
+
33
+ const resolveAdapterPackagesRoot = () => path.join(resolveBootstrapPackageCacheDir(), 'adapter-packages')
34
+
35
+ const resolveAdapterPackageCacheDir = (packageName: string, version: string) => (
36
+ path.join(resolveAdapterPackagesRoot(), sanitizePackageName(packageName), version)
37
+ )
38
+
39
+ const resolveAdapterPackageInstallDir = (cacheDir: string, packageName: string) => (
40
+ path.join(cacheDir, 'node_modules', ...packageName.split('/'))
41
+ )
42
+
43
+ const resolveAdapterPackageMetadataDir = () => path.join(resolveAdapterPackagesRoot(), 'metadata')
44
+
45
+ const resolveAdapterPackageManagerEnv = () => {
46
+ const npmCache = path.join(resolveAdapterPackagesRoot(), 'npm-cache')
47
+ return {
48
+ ...resolvePackageManagerEnv(),
49
+ npm_config_cache: npmCache,
50
+ NPM_CONFIG_CACHE: npmCache
51
+ }
52
+ }
53
+
54
+ const readRegistryFromEnv = () => {
55
+ const env = resolveAdapterPackageManagerEnv()
56
+ return env.npm_config_registry ?? env.NPM_CONFIG_REGISTRY ?? ''
57
+ }
58
+
59
+ const resolveAdapterVersionSpec = (cliVersion: string) => {
60
+ const version = /^(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)/u.exec(cliVersion.trim())
61
+ return version == null ? cliVersion : `^${version[1]}`
62
+ }
63
+
64
+ const resolveAdapterPackageLookupKey = (packageName: string, versionSpec: string) => (
65
+ JSON.stringify({
66
+ name: packageName,
67
+ registry: readRegistryFromEnv(),
68
+ version: versionSpec
69
+ })
70
+ )
71
+
72
+ const resolveAdapterPackageMetadataPath = (packageName: string, versionSpec: string) => {
73
+ const key = resolveAdapterPackageLookupKey(packageName, versionSpec)
74
+ return {
75
+ key,
76
+ metadataPath: path.join(resolveAdapterPackageMetadataDir(), `${hashValue(key)}.json`)
77
+ }
78
+ }
79
+
80
+ const readInstalledPackageVersion = async (packageDir: string) => {
81
+ try {
82
+ const content = await readFile(path.join(packageDir, 'package.json'), 'utf8')
83
+ const parsed = JSON.parse(content) as { name?: unknown; version?: unknown }
84
+ if (parsed.name != null && typeof parsed.name !== 'string') return undefined
85
+ return typeof parsed.version === 'string' ? parsed.version : undefined
86
+ } catch {
87
+ return undefined
88
+ }
89
+ }
90
+
91
+ const formatInstallError = (message: string, stderr: string) => {
92
+ const detail = stderr.trim()
93
+ return detail ? `${message}\n${detail}` : message
94
+ }
95
+
96
+ interface ParsedSemver {
97
+ major: number
98
+ minor: number
99
+ patch: number
100
+ prerelease: string[]
101
+ }
102
+
103
+ const parseSemver = (version: string): ParsedSemver | undefined => {
104
+ const match = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/u.exec(version.trim())
105
+ if (match == null) return undefined
106
+ return {
107
+ major: Number(match[1]),
108
+ minor: Number(match[2]),
109
+ patch: Number(match[3]),
110
+ prerelease: match[4]?.split('.') ?? []
111
+ }
112
+ }
113
+
114
+ const comparePrereleaseIdentifiers = (left: string, right: string) => {
115
+ const leftNumber = /^\d+$/u.test(left) ? Number(left) : undefined
116
+ const rightNumber = /^\d+$/u.test(right) ? Number(right) : undefined
117
+ if (leftNumber != null && rightNumber != null) return leftNumber - rightNumber
118
+ if (leftNumber != null) return -1
119
+ if (rightNumber != null) return 1
120
+ return left.localeCompare(right)
121
+ }
122
+
123
+ const compareSemver = (left: ParsedSemver, right: ParsedSemver) => {
124
+ const coreDiff = left.major - right.major || left.minor - right.minor || left.patch - right.patch
125
+ if (coreDiff !== 0) return coreDiff
126
+ if (left.prerelease.length === 0 && right.prerelease.length === 0) return 0
127
+ if (left.prerelease.length === 0) return 1
128
+ if (right.prerelease.length === 0) return -1
129
+ const maxLength = Math.max(left.prerelease.length, right.prerelease.length)
130
+ for (let index = 0; index < maxLength; index += 1) {
131
+ const leftPart = left.prerelease[index]
132
+ const rightPart = right.prerelease[index]
133
+ if (leftPart == null) return -1
134
+ if (rightPart == null) return 1
135
+ const diff = comparePrereleaseIdentifiers(leftPart, rightPart)
136
+ if (diff !== 0) return diff
137
+ }
138
+ return 0
139
+ }
140
+
141
+ const hasSameSemverCore = (left: ParsedSemver, right: ParsedSemver) => (
142
+ left.major === right.major && left.minor === right.minor && left.patch === right.patch
143
+ )
144
+
145
+ const compareAdapterCacheVersions = (left: string, right: string) => {
146
+ const leftSemver = parseSemver(left)
147
+ const rightSemver = parseSemver(right)
148
+ if (leftSemver != null && rightSemver != null) return compareSemver(leftSemver, rightSemver)
149
+ return compareVersionLike(left, right)
150
+ }
151
+
152
+ const satisfiesCaretVersionSpec = (version: string, versionSpec: string) => {
153
+ const trimmedSpec = versionSpec.trim()
154
+ const minimumVersion = trimmedSpec.startsWith('^') ? trimmedSpec.slice(1) : trimmedSpec
155
+ const parsedVersion = parseSemver(version)
156
+ const parsedMinimum = parseSemver(minimumVersion)
157
+ if (parsedVersion == null || parsedMinimum == null) return version === minimumVersion
158
+ if (compareSemver(parsedVersion, parsedMinimum) < 0) return false
159
+ if (!trimmedSpec.startsWith('^')) return compareSemver(parsedVersion, parsedMinimum) === 0
160
+ if (
161
+ parsedVersion.prerelease.length > 0 &&
162
+ (parsedMinimum.prerelease.length === 0 || !hasSameSemverCore(parsedVersion, parsedMinimum))
163
+ ) {
164
+ return false
165
+ }
166
+ if (parsedMinimum.major > 0) return parsedVersion.major === parsedMinimum.major
167
+ if (parsedMinimum.minor > 0) {
168
+ return parsedVersion.major === 0 && parsedVersion.minor === parsedMinimum.minor
169
+ }
170
+ return parsedVersion.major === 0 &&
171
+ parsedVersion.minor === 0 &&
172
+ parsedVersion.patch === parsedMinimum.patch
173
+ }
174
+
175
+ const readCachedAdapterPackageVersions = async (packageCacheRoot: string, versionSpec?: string) => {
176
+ const trimmedVersionSpec = versionSpec?.trim()
177
+ if (trimmedVersionSpec && !trimmedVersionSpec.startsWith('^')) {
178
+ return [trimmedVersionSpec]
179
+ }
180
+
181
+ return (await readdir(packageCacheRoot, { withFileTypes: true }))
182
+ .filter(entry => entry.isDirectory())
183
+ .map(entry => entry.name)
184
+ }
185
+
186
+ const findCachedAdapterPackageDir = async (packageName: string, versionSpec?: string) => {
187
+ const packageCacheRoot = path.join(resolveAdapterPackagesRoot(), sanitizePackageName(packageName))
188
+ let entries: string[]
189
+ try {
190
+ entries = await readCachedAdapterPackageVersions(packageCacheRoot, versionSpec)
191
+ } catch {
192
+ return undefined
193
+ }
194
+
195
+ const candidates: Array<{ cacheDir: string; version: string }> = []
196
+ for (const entry of entries) {
197
+ const cacheDir = path.join(packageCacheRoot, entry)
198
+ const packageDir = resolveAdapterPackageInstallDir(cacheDir, packageName)
199
+ const installedVersion = await readInstalledPackageVersion(packageDir)
200
+ if (installedVersion === entry && (versionSpec == null || satisfiesCaretVersionSpec(entry, versionSpec))) {
201
+ candidates.push({ cacheDir, version: entry })
202
+ }
203
+ }
204
+
205
+ return candidates
206
+ .sort((left, right) => compareAdapterCacheVersions(right.version, left.version))[0]
207
+ ?.cacheDir
208
+ }
209
+
210
+ const readAdapterPackageVersionMetadata = async (packageName: string, versionSpec: string) => {
211
+ const { key, metadataPath } = resolveAdapterPackageMetadataPath(packageName, versionSpec)
212
+ try {
213
+ const content = await readFile(metadataPath, 'utf8')
214
+ const parsed = JSON.parse(content) as Partial<AdapterPackageVersionMetadata>
215
+ if (
216
+ parsed.key === key &&
217
+ parsed.name === packageName &&
218
+ parsed.version === versionSpec &&
219
+ typeof parsed.resolvedVersion === 'string' &&
220
+ typeof parsed.installedPackageDir === 'string'
221
+ ) {
222
+ return parsed as AdapterPackageVersionMetadata
223
+ }
224
+ } catch {
225
+ // Ignore missing or stale metadata and fall back to the package cache.
226
+ }
227
+ return undefined
228
+ }
229
+
230
+ const writeAdapterPackageVersionMetadata = async (input: {
231
+ installedPackageDir: string
232
+ packageName: string
233
+ resolvedVersion: string
234
+ versionSpec: string
235
+ }) => {
236
+ const { key, metadataPath } = resolveAdapterPackageMetadataPath(input.packageName, input.versionSpec)
237
+ await ensureDirectory(path.dirname(metadataPath))
238
+ const tempPath = `${metadataPath}.${process.pid}.${Date.now()}.tmp`
239
+ const metadata: AdapterPackageVersionMetadata = {
240
+ checkedAt: new Date().toISOString(),
241
+ installedPackageDir: input.installedPackageDir,
242
+ key,
243
+ name: input.packageName,
244
+ resolvedVersion: input.resolvedVersion,
245
+ version: input.versionSpec
246
+ }
247
+ await writeFile(tempPath, `${JSON.stringify(metadata, null, 2)}\n`, 'utf8')
248
+ await rename(tempPath, metadataPath)
249
+ }
250
+
251
+ const parseVersionOutput = (spec: string, output: string) => {
252
+ const normalizedOutput = output.trim()
253
+ if (!normalizedOutput) {
254
+ throw new Error(`No version was returned for ${spec}.`)
255
+ }
256
+
257
+ try {
258
+ const parsed = JSON.parse(normalizedOutput) as unknown
259
+ if (typeof parsed === 'string' && parsed.trim()) {
260
+ return parsed.trim()
261
+ }
262
+ if (Array.isArray(parsed)) {
263
+ const versions = parsed.filter((item): item is string => typeof item === 'string' && item.trim() !== '')
264
+ const latestVersion = versions.sort(compareVersionLike).at(-1)
265
+ if (latestVersion != null) return latestVersion
266
+ }
267
+ } catch {
268
+ // Fall through to unquoted output parsing.
269
+ }
270
+
271
+ const unquotedOutput = normalizedOutput.replace(/^"|"$/g, '').trim()
272
+ if (!unquotedOutput) {
273
+ throw new Error(`Invalid published version for ${spec}: ${normalizedOutput}`)
274
+ }
275
+ return unquotedOutput
276
+ }
277
+
278
+ const resolvePublishedAdapterPackageVersion = async (packageName: string, versionSpec: string) => {
279
+ const spec = `${packageName}@${versionSpec}`
280
+ const result = await runBufferedCommand({
281
+ args: ['view', spec, 'version', '--json'],
282
+ command: NPM_BIN,
283
+ env: resolveAdapterPackageManagerEnv()
284
+ })
285
+ if (result.code !== 0) {
286
+ throw new Error(`Failed to resolve adapter package version for ${spec}:\n${result.stderr.trim()}`)
287
+ }
288
+ return parseVersionOutput(spec, result.stdout)
289
+ }
290
+
291
+ const installAdapterPackage = async (packageName: string, version: string) => {
292
+ const cacheDir = resolveAdapterPackageCacheDir(packageName, version)
293
+ const packageDir = resolveAdapterPackageInstallDir(cacheDir, packageName)
294
+ const installedVersion = await readInstalledPackageVersion(packageDir)
295
+ if (installedVersion === version) {
296
+ return {
297
+ cacheDir,
298
+ packageDir
299
+ }
300
+ }
301
+
302
+ const stagingDir = `${cacheDir}.tmp-${process.pid}-${Date.now()}`
303
+ await rm(stagingDir, { recursive: true, force: true })
304
+ await ensureDirectory(stagingDir)
305
+
306
+ const progress = createBootstrapProgress({
307
+ label: `installing adapter ${packageName}@${version} into bootstrap cache`
308
+ })
309
+ try {
310
+ const result = await runBufferedCommand({
311
+ args: [
312
+ 'install',
313
+ '--prefix',
314
+ stagingDir,
315
+ '--no-audit',
316
+ '--no-fund',
317
+ '--loglevel=error',
318
+ `${packageName}@${version}`
319
+ ],
320
+ command: NPM_BIN,
321
+ env: resolveAdapterPackageManagerEnv()
322
+ })
323
+
324
+ if (result.code !== 0) {
325
+ throw new Error(formatInstallError(
326
+ `Failed to install adapter package ${packageName}@${version}.`,
327
+ result.stderr
328
+ ))
329
+ }
330
+
331
+ await ensureDirectory(path.dirname(cacheDir))
332
+ await rm(cacheDir, { recursive: true, force: true })
333
+ await rename(stagingDir, cacheDir)
334
+ progress.finish(`cached adapter ${packageName}@${version}`)
335
+ } catch (error) {
336
+ progress.fail(`failed to cache adapter ${packageName}@${version}`)
337
+ await rm(stagingDir, { recursive: true, force: true }).catch(() => {})
338
+ throw error
339
+ }
340
+
341
+ return {
342
+ cacheDir,
343
+ packageDir
344
+ }
345
+ }
346
+
347
+ const splitAdapterVersionSelector = (value: string) => {
348
+ const lastAt = value.lastIndexOf('@')
349
+ if (lastAt <= 0) {
350
+ return value
351
+ }
352
+
353
+ if (value.startsWith('@')) {
354
+ const slash = value.indexOf('/')
355
+ if (slash < 0 || lastAt <= slash) {
356
+ return value
357
+ }
358
+ }
359
+
360
+ return value.slice(0, lastAt)
361
+ }
362
+
363
+ export const normalizeAdapterPackageId = (type: string) => {
364
+ const trimmed = splitAdapterVersionSelector(type.trim())
365
+ if (trimmed.startsWith('@')) return trimmed
366
+
367
+ const hasAdapterPrefix = trimmed.startsWith(ADAPTER_PREFIX)
368
+ const adapterId = hasAdapterPrefix ? trimmed.slice(ADAPTER_PREFIX.length) : trimmed
369
+ const normalizedAdapterId = adapterId === 'claude' ? 'claude-code' : adapterId
370
+
371
+ return hasAdapterPrefix ? `${ADAPTER_PREFIX}${normalizedAdapterId}` : normalizedAdapterId
372
+ }
373
+
374
+ export const resolveAdapterPackageName = (type: string) => {
375
+ const normalizedType = normalizeAdapterPackageId(type)
376
+ if (normalizedType.startsWith('@')) return normalizedType
377
+ return normalizedType.startsWith(ADAPTER_PREFIX)
378
+ ? `${ADAPTER_SCOPE}/${normalizedType}`
379
+ : `${ADAPTER_SCOPE}/${ADAPTER_PREFIX}${normalizedType}`
380
+ }
381
+
382
+ export const readCliAdapterPackageRequest = (
383
+ forwardedArgs: string[],
384
+ cliVersion: string,
385
+ fallbackAdapter?: string
386
+ ): CliAdapterPackageRequest | undefined => {
387
+ for (let index = 0; index < forwardedArgs.length; index += 1) {
388
+ const arg = forwardedArgs[index]
389
+ if (arg === '--adapter' || arg === '-A') {
390
+ const adapter = forwardedArgs[index + 1]?.trim()
391
+ return adapter ? { adapter, cliVersion } : undefined
392
+ }
393
+ if (arg.startsWith('--adapter=')) {
394
+ const adapter = arg.slice('--adapter='.length).trim()
395
+ return adapter ? { adapter, cliVersion } : undefined
396
+ }
397
+ if (arg.startsWith('-A') && arg.length > 2) {
398
+ const adapter = arg.slice(2).trim()
399
+ return adapter ? { adapter, cliVersion } : undefined
400
+ }
401
+ }
402
+
403
+ const adapter = fallbackAdapter?.trim()
404
+ return adapter ? { adapter, cliVersion } : undefined
405
+ }
406
+
407
+ export const resolveCliAdapterPackageDir = async (request: CliAdapterPackageRequest) => {
408
+ const packageName = resolveAdapterPackageName(request.adapter)
409
+ const versionSpec = resolveAdapterVersionSpec(request.cliVersion)
410
+ const metadata = await readAdapterPackageVersionMetadata(packageName, versionSpec)
411
+ if (metadata != null) {
412
+ const cachedByMetadata = await findCachedAdapterPackageDir(packageName, metadata.resolvedVersion)
413
+ if (cachedByMetadata != null && existsSync(metadata.installedPackageDir)) {
414
+ return cachedByMetadata
415
+ }
416
+ }
417
+
418
+ const cachedPackageDir = await findCachedAdapterPackageDir(packageName, versionSpec)
419
+ if (cachedPackageDir != null) {
420
+ return cachedPackageDir
421
+ }
422
+
423
+ const resolvedVersion = await resolvePublishedAdapterPackageVersion(packageName, versionSpec)
424
+ const installedPackage = await installAdapterPackage(packageName, resolvedVersion)
425
+ await writeAdapterPackageVersionMetadata({
426
+ installedPackageDir: installedPackage.packageDir,
427
+ packageName,
428
+ resolvedVersion,
429
+ versionSpec
430
+ })
431
+ return installedPackage.cacheDir
432
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,5 @@
1
+ import process from 'node:process'
2
+
3
+ import { runBootstrapCli } from './program'
4
+
5
+ void runBootstrapCli(process.argv)
@@ -0,0 +1,44 @@
1
+ import { spawn } from 'node:child_process'
2
+ import process from 'node:process'
3
+
4
+ import { ensureDesktopInstall } from './desktop-install'
5
+ import type { DesktopInstallMode } from './desktop-mode'
6
+ import { readDesktopPreference, resolveInstallMode } from './desktop-mode'
7
+ import { selectDesktopAsset } from './desktop-release'
8
+
9
+ export type { DesktopInstallMode } from './desktop-mode'
10
+
11
+ export interface LaunchDesktopAppOptions {
12
+ forwardedArgs: string[]
13
+ installMode?: DesktopInstallMode
14
+ persistInstallMode?: boolean
15
+ }
16
+
17
+ export const launchDesktopApp = async (options: LaunchDesktopAppOptions) => {
18
+ const installMode = await resolveInstallMode({
19
+ explicitInstallMode: options.installMode,
20
+ persistInstallMode: options.persistInstallMode
21
+ })
22
+ const install = await ensureDesktopInstall(installMode)
23
+ const workspaceFolder = process.cwd()
24
+ console.error(`[bootstrap] launching desktop app from ${install.installedPath} (${installMode})`)
25
+
26
+ const child = spawn(install.executablePath, options.forwardedArgs, {
27
+ cwd: workspaceFolder,
28
+ detached: true,
29
+ env: {
30
+ ...process.env,
31
+ ONEWORKS_DESKTOP_WORKSPACE: workspaceFolder,
32
+ __ONEWORKS_PROJECT_WORKSPACE_FOLDER__: workspaceFolder
33
+ },
34
+ stdio: 'ignore'
35
+ })
36
+
37
+ child.unref()
38
+ }
39
+
40
+ export const __TEST_ONLY__ = {
41
+ readDesktopPreference,
42
+ resolveInstallMode,
43
+ selectDesktopAsset
44
+ }
@@ -0,0 +1,177 @@
1
+ import { spawn } from 'node:child_process'
2
+ import { access, chmod, copyFile, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
3
+ import path from 'node:path'
4
+ import process from 'node:process'
5
+
6
+ import type { DesktopInstallMode } from './desktop-mode'
7
+ import { downloadReleaseAsset, fetchDesktopRelease, selectDesktopAsset } from './desktop-release'
8
+ import { resolveBootstrapDataDir, resolveRealHomeDir } from './paths'
9
+
10
+ interface DesktopInstallMetadata {
11
+ executablePath: string
12
+ installedPath: string
13
+ releaseTag: string
14
+ }
15
+
16
+ const APP_NAME = 'One Works'
17
+
18
+ const ensureDirectory = async (targetPath: string) => {
19
+ await mkdir(targetPath, { recursive: true })
20
+ }
21
+
22
+ const hasExistingPath = async (targetPath: string) => {
23
+ try {
24
+ await access(targetPath)
25
+ return true
26
+ } catch {
27
+ return false
28
+ }
29
+ }
30
+
31
+ const runSystemCommand = async (command: string, args: string[]) => {
32
+ const child = spawn(command, args, { stdio: 'inherit' })
33
+ return await new Promise<void>((resolve, reject) => {
34
+ child.once('error', reject)
35
+ child.once('exit', (code) => {
36
+ if (code !== 0) {
37
+ reject(new Error(`${command} exited with code ${code ?? 'null'}.`))
38
+ return
39
+ }
40
+ resolve()
41
+ })
42
+ })
43
+ }
44
+
45
+ const resolveDesktopMetadataPath = (installMode: DesktopInstallMode) => (
46
+ path.join(resolveBootstrapDataDir(), 'desktop', `${installMode}.json`)
47
+ )
48
+
49
+ const readDesktopMetadata = async (installMode: DesktopInstallMode) => {
50
+ try {
51
+ const content = await readFile(resolveDesktopMetadataPath(installMode), 'utf8')
52
+ return JSON.parse(content) as DesktopInstallMetadata
53
+ } catch {
54
+ return undefined
55
+ }
56
+ }
57
+
58
+ const writeDesktopMetadata = async (installMode: DesktopInstallMode, metadata: DesktopInstallMetadata) => {
59
+ const filePath = resolveDesktopMetadataPath(installMode)
60
+ await ensureDirectory(path.dirname(filePath))
61
+ await writeFile(filePath, `${JSON.stringify(metadata, null, 2)}\n`)
62
+ }
63
+
64
+ const resolveDownloadsDir = () => path.join(resolveBootstrapDataDir(), 'desktop', 'downloads')
65
+ const resolveCacheInstallRoot = (releaseTag: string) =>
66
+ path.join(resolveBootstrapDataDir(), 'desktop', 'apps', releaseTag)
67
+
68
+ const installDesktopForMac = async (
69
+ releaseTag: string,
70
+ asset: { digest?: string; name: string; url: string },
71
+ installMode: DesktopInstallMode
72
+ ) => {
73
+ const installDir = installMode === 'cache'
74
+ ? resolveCacheInstallRoot(releaseTag)
75
+ : path.join(resolveRealHomeDir(), 'Applications')
76
+ const installPath = path.join(installDir, `${APP_NAME}.app`)
77
+ const archivePath = path.join(resolveDownloadsDir(), asset.name)
78
+ const stagingDir = path.join(resolveBootstrapDataDir(), 'desktop', 'staging', `${installMode}-${releaseTag}`)
79
+ const extractedAppPath = path.join(stagingDir, `${APP_NAME}.app`)
80
+
81
+ await rm(stagingDir, { recursive: true, force: true })
82
+ await ensureDirectory(stagingDir)
83
+ await downloadReleaseAsset(asset, archivePath)
84
+ await runSystemCommand('ditto', ['-x', '-k', archivePath, stagingDir])
85
+ await ensureDirectory(installDir)
86
+ await rm(installPath, { recursive: true, force: true })
87
+ await runSystemCommand('ditto', [extractedAppPath, installPath])
88
+
89
+ return {
90
+ executablePath: path.join(installPath, 'Contents', 'MacOS', APP_NAME),
91
+ installedPath: installPath,
92
+ releaseTag
93
+ } satisfies DesktopInstallMetadata
94
+ }
95
+
96
+ const installDesktopForLinux = async (
97
+ releaseTag: string,
98
+ asset: { digest?: string; name: string; url: string },
99
+ installMode: DesktopInstallMode
100
+ ) => {
101
+ const installDir = installMode === 'cache'
102
+ ? resolveCacheInstallRoot(releaseTag)
103
+ : path.join(resolveRealHomeDir(), '.local', 'opt', 'oneworks')
104
+ const installPath = path.join(installDir, asset.name)
105
+ const archivePath = path.join(resolveDownloadsDir(), asset.name)
106
+
107
+ await ensureDirectory(installDir)
108
+ await downloadReleaseAsset(asset, archivePath)
109
+ await copyFile(archivePath, installPath)
110
+ await chmod(installPath, 0o755)
111
+
112
+ return {
113
+ executablePath: installPath,
114
+ installedPath: installPath,
115
+ releaseTag
116
+ } satisfies DesktopInstallMetadata
117
+ }
118
+
119
+ const installDesktopForWindows = async (
120
+ releaseTag: string,
121
+ asset: { digest?: string; name: string; url: string },
122
+ installMode: DesktopInstallMode
123
+ ) => {
124
+ const localAppData = process.env.LOCALAPPDATA?.trim()
125
+ if (installMode === 'user' && !localAppData) {
126
+ throw new Error('LOCALAPPDATA is required to install the One Works desktop app on Windows.')
127
+ }
128
+
129
+ const installDir = installMode === 'cache'
130
+ ? resolveCacheInstallRoot(releaseTag)
131
+ : path.join(localAppData as string, 'Programs', APP_NAME)
132
+
133
+ const installPath = path.join(installDir, asset.name)
134
+ const archivePath = path.join(resolveDownloadsDir(), asset.name)
135
+
136
+ await ensureDirectory(installDir)
137
+ await downloadReleaseAsset(asset, archivePath)
138
+ await copyFile(archivePath, installPath)
139
+
140
+ return {
141
+ executablePath: installPath,
142
+ installedPath: installPath,
143
+ releaseTag
144
+ } satisfies DesktopInstallMetadata
145
+ }
146
+
147
+ export const ensureDesktopInstall = async (installMode: DesktopInstallMode) => {
148
+ const release = await fetchDesktopRelease()
149
+ const selectedAsset = selectDesktopAsset(release, {
150
+ platform: process.platform,
151
+ arch: process.arch
152
+ })
153
+ if (selectedAsset == null) {
154
+ throw new Error(
155
+ `No supported desktop asset was found for ${process.platform}-${process.arch} in ${release.tagName}.`
156
+ )
157
+ }
158
+
159
+ const currentMetadata = await readDesktopMetadata(installMode)
160
+ if (currentMetadata?.releaseTag === release.tagName && await hasExistingPath(currentMetadata.executablePath)) {
161
+ return currentMetadata
162
+ }
163
+
164
+ const metadata = process.platform === 'darwin'
165
+ ? await installDesktopForMac(release.tagName, selectedAsset, installMode)
166
+ : process.platform === 'linux'
167
+ ? await installDesktopForLinux(release.tagName, selectedAsset, installMode)
168
+ : process.platform === 'win32'
169
+ ? await installDesktopForWindows(release.tagName, selectedAsset, installMode)
170
+ : undefined
171
+ if (metadata == null) {
172
+ throw new Error(`Desktop bootstrap is not supported on ${process.platform}.`)
173
+ }
174
+
175
+ await writeDesktopMetadata(installMode, metadata)
176
+ return metadata
177
+ }