onework 0.1.0-alpha.0 → 0.1.0-alpha.2
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.
- package/__tests__/adapter-package-cache.spec.ts +109 -0
- package/__tests__/cli.spec.ts +216 -0
- package/__tests__/desktop-app.spec.ts +106 -0
- package/__tests__/npm-package.spec.ts +149 -0
- package/__tests__/package-launcher.spec.ts +23 -0
- package/__tests__/redirect-packages.spec.ts +27 -0
- package/__tests__/runtime-package.spec.ts +211 -0
- package/cli.js +3 -5
- package/package-version-refresh-worker.cjs +324 -0
- package/package.json +15 -9
- package/src/adapter-package-cache.ts +432 -0
- package/src/cli.ts +5 -0
- package/src/desktop-app.ts +44 -0
- package/src/desktop-install.ts +177 -0
- package/src/desktop-mode.ts +92 -0
- package/src/desktop-release.ts +198 -0
- package/src/npm-package-cache.ts +185 -0
- package/src/npm-package-install.ts +139 -0
- package/src/npm-package.ts +151 -0
- package/src/package-config.ts +23 -0
- package/src/package-launcher.ts +85 -0
- package/src/paths.ts +19 -0
- package/src/process-utils.ts +126 -0
- package/src/program.ts +286 -0
- package/src/progress.ts +174 -0
- package/src/runtime-package.ts +126 -0
- package/README.md +0 -9
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import process from 'node:process'
|
|
4
|
+
|
|
5
|
+
import { isCancel, select } from '@clack/prompts'
|
|
6
|
+
|
|
7
|
+
import { resolveBootstrapDataDir } from './paths'
|
|
8
|
+
|
|
9
|
+
export type DesktopInstallMode = 'cache' | 'user'
|
|
10
|
+
|
|
11
|
+
interface DesktopPreferenceState {
|
|
12
|
+
installMode?: DesktopInstallMode
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const DEFAULT_INSTALL_MODE: DesktopInstallMode = 'user'
|
|
16
|
+
|
|
17
|
+
const ensureDirectory = async (targetPath: string) => {
|
|
18
|
+
await mkdir(targetPath, { recursive: true })
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const resolveDesktopPreferencePath = () => path.join(resolveBootstrapDataDir(), 'desktop', 'preferences.json')
|
|
22
|
+
|
|
23
|
+
const readJsonFile = async <T>(filePath: string) => {
|
|
24
|
+
try {
|
|
25
|
+
const content = await readFile(filePath, 'utf8')
|
|
26
|
+
return JSON.parse(content) as T
|
|
27
|
+
} catch {
|
|
28
|
+
return undefined
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const writeJsonFile = async (filePath: string, value: unknown) => {
|
|
33
|
+
await ensureDirectory(path.dirname(filePath))
|
|
34
|
+
await writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const readDesktopPreference = async () => (
|
|
38
|
+
await readJsonFile<DesktopPreferenceState>(resolveDesktopPreferencePath())
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
const writeDesktopPreference = async (installMode: DesktopInstallMode) => {
|
|
42
|
+
await writeJsonFile(resolveDesktopPreferencePath(), { installMode })
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const promptDesktopInstallMode = async () => {
|
|
46
|
+
const selectedMode = await select<DesktopInstallMode>({
|
|
47
|
+
message: 'Choose how to launch the desktop app',
|
|
48
|
+
options: [
|
|
49
|
+
{
|
|
50
|
+
value: 'user',
|
|
51
|
+
label: 'User directory',
|
|
52
|
+
hint: 'Install into the user application directory'
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
value: 'cache',
|
|
56
|
+
label: 'Bootstrap cache',
|
|
57
|
+
hint: 'Keep the app inside the bootstrap cache'
|
|
58
|
+
}
|
|
59
|
+
]
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
if (isCancel(selectedMode)) {
|
|
63
|
+
throw new Error('Desktop launch was cancelled.')
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return selectedMode
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export const resolveInstallMode = async (input: {
|
|
70
|
+
explicitInstallMode?: DesktopInstallMode
|
|
71
|
+
persistInstallMode?: boolean
|
|
72
|
+
}) => {
|
|
73
|
+
if (input.explicitInstallMode != null) {
|
|
74
|
+
if (input.persistInstallMode === true) {
|
|
75
|
+
await writeDesktopPreference(input.explicitInstallMode)
|
|
76
|
+
}
|
|
77
|
+
return input.explicitInstallMode
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const preference = await readDesktopPreference()
|
|
81
|
+
if (preference?.installMode != null) {
|
|
82
|
+
return preference.installMode
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
86
|
+
return DEFAULT_INSTALL_MODE
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const selectedMode = await promptDesktopInstallMode()
|
|
90
|
+
await writeDesktopPreference(selectedMode)
|
|
91
|
+
return selectedMode
|
|
92
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import type { Buffer } from 'node:buffer'
|
|
2
|
+
import { createHash } from 'node:crypto'
|
|
3
|
+
import { createWriteStream } from 'node:fs'
|
|
4
|
+
import { mkdir, unlink } from 'node:fs/promises'
|
|
5
|
+
import https from 'node:https'
|
|
6
|
+
import path from 'node:path'
|
|
7
|
+
import process from 'node:process'
|
|
8
|
+
|
|
9
|
+
import { createBootstrapProgress } from './progress'
|
|
10
|
+
|
|
11
|
+
interface GitHubReleaseAsset {
|
|
12
|
+
browser_download_url?: string
|
|
13
|
+
digest?: string
|
|
14
|
+
name: string
|
|
15
|
+
url: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface GitHubReleaseResponse {
|
|
19
|
+
assets?: GitHubReleaseAsset[]
|
|
20
|
+
draft?: boolean
|
|
21
|
+
tag_name?: string
|
|
22
|
+
tagName?: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface DesktopRelease {
|
|
26
|
+
assets: GitHubReleaseAsset[]
|
|
27
|
+
tagName: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const GITHUB_RELEASES_API = 'https://api.github.com/repos/oneworks-ai/app/releases'
|
|
31
|
+
const DESKTOP_RELEASE_TAG_PREFIX = 'pkg/oneworks-desktop/v'
|
|
32
|
+
const RELEASE_TAG_OVERRIDE = process.env.ONEWORKS_BOOTSTRAP_DESKTOP_RELEASE_TAG?.trim()
|
|
33
|
+
|
|
34
|
+
const ensureDirectory = async (targetPath: string) => {
|
|
35
|
+
await mkdir(targetPath, { recursive: true })
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const parseContentLength = (value: string | string[] | undefined) => {
|
|
39
|
+
const rawValue = Array.isArray(value) ? value[0] : value
|
|
40
|
+
if (rawValue == null) return undefined
|
|
41
|
+
|
|
42
|
+
const parsed = Number.parseInt(rawValue, 10)
|
|
43
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const requestJson = async <T>(url: string) => (
|
|
47
|
+
await new Promise<T>((resolve, reject) => {
|
|
48
|
+
https.get(url, {
|
|
49
|
+
headers: {
|
|
50
|
+
Accept: 'application/vnd.github+json',
|
|
51
|
+
'User-Agent': 'oneworks'
|
|
52
|
+
}
|
|
53
|
+
}, (response) => {
|
|
54
|
+
const statusCode = response.statusCode ?? 0
|
|
55
|
+
if (statusCode < 200 || statusCode >= 300) {
|
|
56
|
+
response.resume()
|
|
57
|
+
reject(new Error(`GitHub API request failed: HTTP ${statusCode}`))
|
|
58
|
+
return
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
let content = ''
|
|
62
|
+
response.setEncoding('utf8')
|
|
63
|
+
response.on('data', (chunk: string) => {
|
|
64
|
+
content += chunk
|
|
65
|
+
})
|
|
66
|
+
response.on('end', () => {
|
|
67
|
+
try {
|
|
68
|
+
resolve(JSON.parse(content) as T)
|
|
69
|
+
} catch (error) {
|
|
70
|
+
reject(error)
|
|
71
|
+
}
|
|
72
|
+
})
|
|
73
|
+
}).on('error', reject)
|
|
74
|
+
})
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
export const fetchDesktopRelease = async (): Promise<DesktopRelease> => {
|
|
78
|
+
const release = RELEASE_TAG_OVERRIDE
|
|
79
|
+
? await requestJson<GitHubReleaseResponse>(
|
|
80
|
+
`${GITHUB_RELEASES_API}/tags/${encodeURIComponent(RELEASE_TAG_OVERRIDE)}`
|
|
81
|
+
)
|
|
82
|
+
: (await requestJson<GitHubReleaseResponse[]>(`${GITHUB_RELEASES_API}?per_page=50`))
|
|
83
|
+
.find(item => (
|
|
84
|
+
item.draft !== true &&
|
|
85
|
+
typeof item.tag_name === 'string' &&
|
|
86
|
+
item.tag_name.startsWith(DESKTOP_RELEASE_TAG_PREFIX) &&
|
|
87
|
+
/^\d+\.\d+\.\d+$/u.test(item.tag_name.slice(DESKTOP_RELEASE_TAG_PREFIX.length))
|
|
88
|
+
))
|
|
89
|
+
const tagName = release?.tag_name ?? release?.tagName
|
|
90
|
+
if (!tagName || !Array.isArray(release?.assets)) {
|
|
91
|
+
throw new Error('Invalid desktop release metadata returned by GitHub.')
|
|
92
|
+
}
|
|
93
|
+
return {
|
|
94
|
+
assets: release.assets.map(asset => ({
|
|
95
|
+
...asset,
|
|
96
|
+
url: asset.browser_download_url ?? asset.url
|
|
97
|
+
})),
|
|
98
|
+
tagName
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export const selectDesktopAsset = (release: DesktopRelease, runtime: {
|
|
103
|
+
arch: string
|
|
104
|
+
platform: NodeJS.Platform
|
|
105
|
+
}) => {
|
|
106
|
+
if (runtime.platform === 'darwin') {
|
|
107
|
+
return release.assets.find(asset => asset.name.endsWith(`-mac-${runtime.arch}.zip`))
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (runtime.platform === 'linux') {
|
|
111
|
+
const appImageArch = runtime.arch === 'x64' ? 'x86_64' : runtime.arch
|
|
112
|
+
return release.assets.find(asset => asset.name.endsWith(`-linux-${appImageArch}.AppImage`))
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (runtime.platform === 'win32') {
|
|
116
|
+
return release.assets.find(asset => asset.name.endsWith(`-win-${runtime.arch}.exe`))
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return undefined
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export const downloadReleaseAsset = async (asset: GitHubReleaseAsset, destinationPath: string) => {
|
|
123
|
+
await ensureDirectory(path.dirname(destinationPath))
|
|
124
|
+
|
|
125
|
+
return await new Promise<void>((resolve, reject) => {
|
|
126
|
+
const hash = createHash('sha256')
|
|
127
|
+
const file = createWriteStream(destinationPath)
|
|
128
|
+
let downloadedBytes = 0
|
|
129
|
+
let progress: ReturnType<typeof createBootstrapProgress> | undefined
|
|
130
|
+
|
|
131
|
+
file.on('error', (error) => {
|
|
132
|
+
progress?.fail(`failed to download ${asset.name}`)
|
|
133
|
+
reject(error)
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
const request = https.get(asset.url, {
|
|
137
|
+
headers: {
|
|
138
|
+
'User-Agent': 'oneworks'
|
|
139
|
+
}
|
|
140
|
+
}, (response) => {
|
|
141
|
+
const statusCode = response.statusCode ?? 0
|
|
142
|
+
const redirectLocation = response.headers.location
|
|
143
|
+
|
|
144
|
+
if (statusCode >= 300 && statusCode < 400 && redirectLocation != null) {
|
|
145
|
+
file.close()
|
|
146
|
+
void unlink(destinationPath).catch(() => {})
|
|
147
|
+
downloadReleaseAsset({
|
|
148
|
+
...asset,
|
|
149
|
+
url: new URL(redirectLocation, asset.url).toString()
|
|
150
|
+
}, destinationPath).then(resolve, reject)
|
|
151
|
+
return
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (statusCode < 200 || statusCode >= 300) {
|
|
155
|
+
response.resume()
|
|
156
|
+
file.close()
|
|
157
|
+
reject(new Error(`Failed to download ${asset.name}: HTTP ${statusCode}`))
|
|
158
|
+
return
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
progress = createBootstrapProgress({
|
|
162
|
+
label: `downloading ${asset.name}`,
|
|
163
|
+
total: parseContentLength(response.headers['content-length'])
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
response.on('data', (chunk: Buffer) => {
|
|
167
|
+
downloadedBytes += chunk.length
|
|
168
|
+
hash.update(chunk)
|
|
169
|
+
progress?.update(downloadedBytes)
|
|
170
|
+
})
|
|
171
|
+
response.on('error', (error) => {
|
|
172
|
+
progress?.fail(`failed to download ${asset.name}`)
|
|
173
|
+
file.close()
|
|
174
|
+
reject(error)
|
|
175
|
+
})
|
|
176
|
+
response.pipe(file)
|
|
177
|
+
file.on('finish', () => {
|
|
178
|
+
file.close(() => {
|
|
179
|
+
const expectedDigest = asset.digest?.replace(/^sha256:/, '')
|
|
180
|
+
if (expectedDigest && hash.digest('hex') !== expectedDigest) {
|
|
181
|
+
progress?.fail(`failed to verify ${asset.name}`)
|
|
182
|
+
reject(new Error(`Downloaded desktop asset digest mismatch for ${asset.name}.`))
|
|
183
|
+
return
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
progress?.finish(`downloaded ${asset.name}`)
|
|
187
|
+
resolve()
|
|
188
|
+
})
|
|
189
|
+
})
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
request.on('error', (error) => {
|
|
193
|
+
progress?.fail(`failed to download ${asset.name}`)
|
|
194
|
+
file.close()
|
|
195
|
+
reject(error)
|
|
196
|
+
})
|
|
197
|
+
})
|
|
198
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import { existsSync } from 'node:fs'
|
|
3
|
+
import { access, mkdir, readFile, rename, writeFile } from 'node:fs/promises'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
import process from 'node:process'
|
|
6
|
+
|
|
7
|
+
import { resolveBootstrapPackageCacheDir, resolveRealHomeDir } from './paths'
|
|
8
|
+
|
|
9
|
+
const DEFAULT_PACKAGE_TAG = 'latest'
|
|
10
|
+
const DEFAULT_PACKAGE_LOOKUP_TIMEOUT_MS = 1_000
|
|
11
|
+
const DEFAULT_CACHE_FIRST = true
|
|
12
|
+
|
|
13
|
+
interface PublishedPackageVersionMetadata {
|
|
14
|
+
lookupKey: string
|
|
15
|
+
packageName: string
|
|
16
|
+
packageTag: string
|
|
17
|
+
resolvedAt: string
|
|
18
|
+
version: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const ensureDirectory = async (targetPath: string) => {
|
|
22
|
+
await mkdir(targetPath, { recursive: true })
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const sanitizePackageName = (packageName: string) => packageName.replace(/^@/, '').replace(/[\\/]/g, '__')
|
|
26
|
+
|
|
27
|
+
export const splitPackageName = (packageName: string) => packageName.split('/')
|
|
28
|
+
|
|
29
|
+
export const compareVersionLike = (left: string, right: string) => (
|
|
30
|
+
left.localeCompare(right, 'en', {
|
|
31
|
+
numeric: true,
|
|
32
|
+
sensitivity: 'base'
|
|
33
|
+
})
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
const hashValue = (value: string) => createHash('sha1').update(value).digest('hex')
|
|
37
|
+
|
|
38
|
+
export const resolvePackageTag = () => process.env.ONEWORKS_BOOTSTRAP_PACKAGE_TAG?.trim() || DEFAULT_PACKAGE_TAG
|
|
39
|
+
|
|
40
|
+
export const resolvePackageLookupTimeoutMs = () => {
|
|
41
|
+
const rawValue = process.env.ONEWORKS_BOOTSTRAP_PACKAGE_LOOKUP_TIMEOUT_MS?.trim()
|
|
42
|
+
if (!rawValue) {
|
|
43
|
+
return DEFAULT_PACKAGE_LOOKUP_TIMEOUT_MS
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const parsedValue = Number.parseInt(rawValue, 10)
|
|
47
|
+
return Number.isFinite(parsedValue) && parsedValue > 0
|
|
48
|
+
? parsedValue
|
|
49
|
+
: DEFAULT_PACKAGE_LOOKUP_TIMEOUT_MS
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export const shouldUseCachedPackageVersionFirst = () => {
|
|
53
|
+
const rawValue = process.env.ONEWORKS_BOOTSTRAP_PACKAGE_CACHE_FIRST?.trim().toLowerCase()
|
|
54
|
+
if (rawValue == null || rawValue === '') {
|
|
55
|
+
return DEFAULT_CACHE_FIRST
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return !['0', 'false', 'no', 'off'].includes(rawValue)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export const resolvePackageCacheDir = (packageName: string, version: string) => (
|
|
62
|
+
path.join(resolveBootstrapPackageCacheDir(), 'npm', sanitizePackageName(packageName), version)
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
export const resolvePackageCacheRootDir = (packageName: string) => (
|
|
66
|
+
path.join(resolveBootstrapPackageCacheDir(), 'npm', sanitizePackageName(packageName))
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
export const resolvePackageInstallDir = (cacheDir: string, packageName: string) => (
|
|
70
|
+
path.join(cacheDir, 'node_modules', ...splitPackageName(packageName))
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
const resolvePackageVersionMetadataDir = () => path.join(resolveBootstrapPackageCacheDir(), 'npm-version-cache')
|
|
74
|
+
|
|
75
|
+
const resolveProjectNpmrc = () => {
|
|
76
|
+
const projectNpmrc = path.resolve(process.cwd(), '.npmrc')
|
|
77
|
+
return existsSync(projectNpmrc) ? projectNpmrc : undefined
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export const resolvePackageManagerEnv = () => {
|
|
81
|
+
const userConfig = process.env.npm_config_userconfig ?? process.env.NPM_CONFIG_USERCONFIG ?? resolveProjectNpmrc()
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
...process.env,
|
|
85
|
+
HOME: resolveRealHomeDir(),
|
|
86
|
+
USERPROFILE: resolveRealHomeDir(),
|
|
87
|
+
npm_config_cache: path.join(resolveBootstrapPackageCacheDir(), 'npm-cache'),
|
|
88
|
+
npm_config_replace_registry_host: 'never',
|
|
89
|
+
npm_config_update_notifier: 'false',
|
|
90
|
+
NPM_CONFIG_REPLACE_REGISTRY_HOST: 'never',
|
|
91
|
+
...(userConfig != null
|
|
92
|
+
? {
|
|
93
|
+
NPM_CONFIG_USERCONFIG: userConfig,
|
|
94
|
+
npm_config_userconfig: userConfig
|
|
95
|
+
}
|
|
96
|
+
: {})
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const readOptionalFile = async (filePath: string | undefined) => {
|
|
101
|
+
if (filePath == null || filePath === '') {
|
|
102
|
+
return undefined
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
try {
|
|
106
|
+
return await readFile(filePath, 'utf8')
|
|
107
|
+
} catch {
|
|
108
|
+
return undefined
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const resolvePackageLookupKey = async (packageName: string) => {
|
|
113
|
+
const env = resolvePackageManagerEnv()
|
|
114
|
+
const userConfig = env.npm_config_userconfig ?? env.NPM_CONFIG_USERCONFIG
|
|
115
|
+
const userConfigContent = await readOptionalFile(userConfig)
|
|
116
|
+
|
|
117
|
+
return JSON.stringify({
|
|
118
|
+
packageName,
|
|
119
|
+
packageTag: resolvePackageTag(),
|
|
120
|
+
registry: env.npm_config_registry ?? env.NPM_CONFIG_REGISTRY ?? '',
|
|
121
|
+
userConfig: userConfig ?? '',
|
|
122
|
+
userConfigContentHash: userConfigContent == null ? '' : hashValue(userConfigContent)
|
|
123
|
+
})
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const resolvePackageVersionMetadataPath = async (packageName: string) => {
|
|
127
|
+
const lookupKey = await resolvePackageLookupKey(packageName)
|
|
128
|
+
return {
|
|
129
|
+
lookupKey,
|
|
130
|
+
metadataPath: path.join(
|
|
131
|
+
resolvePackageVersionMetadataDir(),
|
|
132
|
+
`${sanitizePackageName(packageName)}-${hashValue(lookupKey)}.json`
|
|
133
|
+
)
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export const readPublishedPackageVersionMetadata = async (packageName: string) => {
|
|
138
|
+
const { lookupKey, metadataPath } = await resolvePackageVersionMetadataPath(packageName)
|
|
139
|
+
|
|
140
|
+
try {
|
|
141
|
+
const content = await readFile(metadataPath, 'utf8')
|
|
142
|
+
const parsed = JSON.parse(content) as Partial<PublishedPackageVersionMetadata>
|
|
143
|
+
if (
|
|
144
|
+
parsed.lookupKey === lookupKey &&
|
|
145
|
+
parsed.packageName === packageName &&
|
|
146
|
+
parsed.packageTag === resolvePackageTag() &&
|
|
147
|
+
typeof parsed.version === 'string' &&
|
|
148
|
+
parsed.version.trim()
|
|
149
|
+
) {
|
|
150
|
+
return {
|
|
151
|
+
metadataPath,
|
|
152
|
+
version: parsed.version.trim()
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
} catch {
|
|
156
|
+
// Ignore missing or invalid metadata and use the registry path.
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return undefined
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export const writePublishedPackageVersionMetadata = async (packageName: string, version: string) => {
|
|
163
|
+
const { lookupKey, metadataPath } = await resolvePackageVersionMetadataPath(packageName)
|
|
164
|
+
await ensureDirectory(path.dirname(metadataPath))
|
|
165
|
+
|
|
166
|
+
const tempPath = `${metadataPath}.${process.pid}.${Date.now()}.tmp`
|
|
167
|
+
const metadata: PublishedPackageVersionMetadata = {
|
|
168
|
+
lookupKey,
|
|
169
|
+
packageName,
|
|
170
|
+
packageTag: resolvePackageTag(),
|
|
171
|
+
resolvedAt: new Date().toISOString(),
|
|
172
|
+
version
|
|
173
|
+
}
|
|
174
|
+
await writeFile(tempPath, `${JSON.stringify(metadata, null, 2)}\n`, 'utf8')
|
|
175
|
+
await rename(tempPath, metadataPath)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export const isExistingPath = async (targetPath: string) => {
|
|
179
|
+
try {
|
|
180
|
+
await access(targetPath)
|
|
181
|
+
return true
|
|
182
|
+
} catch {
|
|
183
|
+
return false
|
|
184
|
+
}
|
|
185
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { readFile, readdir, rename, rm } from 'node:fs/promises'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import process from 'node:process'
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
compareVersionLike,
|
|
7
|
+
ensureDirectory,
|
|
8
|
+
isExistingPath,
|
|
9
|
+
resolvePackageCacheDir,
|
|
10
|
+
resolvePackageCacheRootDir,
|
|
11
|
+
resolvePackageInstallDir,
|
|
12
|
+
resolvePackageManagerEnv
|
|
13
|
+
} from './npm-package-cache'
|
|
14
|
+
import { runBufferedCommand } from './process-utils'
|
|
15
|
+
import { createBootstrapProgress } from './progress'
|
|
16
|
+
|
|
17
|
+
const NPM_BIN = process.platform === 'win32' ? 'npm.cmd' : 'npm'
|
|
18
|
+
|
|
19
|
+
interface InstalledPackageInfo {
|
|
20
|
+
packageDir: string
|
|
21
|
+
version: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const readInstalledPackageVersion = async (packageDir: string) => {
|
|
25
|
+
const packageJsonPath = path.join(packageDir, 'package.json')
|
|
26
|
+
if (!(await isExistingPath(packageJsonPath))) {
|
|
27
|
+
return undefined
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
const content = await readFile(packageJsonPath, 'utf8')
|
|
32
|
+
const packageJson = JSON.parse(content) as { version?: unknown }
|
|
33
|
+
return typeof packageJson.version === 'string' ? packageJson.version : undefined
|
|
34
|
+
} catch {
|
|
35
|
+
return undefined
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const findInstalledPublishedPackageVersion = async (packageName: string) => {
|
|
40
|
+
let versions: string[]
|
|
41
|
+
try {
|
|
42
|
+
versions = (await readdir(resolvePackageCacheRootDir(packageName), { withFileTypes: true }))
|
|
43
|
+
.filter(entry => entry.isDirectory())
|
|
44
|
+
.map(entry => entry.name)
|
|
45
|
+
} catch {
|
|
46
|
+
return undefined
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const installedVersions: string[] = []
|
|
50
|
+
for (const version of versions) {
|
|
51
|
+
const packageDir = resolvePackageInstallDir(resolvePackageCacheDir(packageName, version), packageName)
|
|
52
|
+
const installedVersion = await readInstalledPackageVersion(packageDir)
|
|
53
|
+
if (installedVersion === version) {
|
|
54
|
+
installedVersions.push(version)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return installedVersions.sort(compareVersionLike).at(-1)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const formatInstallError = (message: string, stderr: string) => {
|
|
62
|
+
const detail = stderr.trim()
|
|
63
|
+
return detail ? `${message}\n${detail}` : message
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export const installPublishedPackage = async (packageName: string, version: string): Promise<InstalledPackageInfo> => {
|
|
67
|
+
const cacheDir = resolvePackageCacheDir(packageName, version)
|
|
68
|
+
const packageDir = resolvePackageInstallDir(cacheDir, packageName)
|
|
69
|
+
const installedVersion = await readInstalledPackageVersion(packageDir)
|
|
70
|
+
if (installedVersion === version) {
|
|
71
|
+
return { packageDir, version }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const stagingDir = `${cacheDir}.tmp-${process.pid}-${Date.now()}`
|
|
75
|
+
await rm(stagingDir, { recursive: true, force: true })
|
|
76
|
+
await ensureDirectory(stagingDir)
|
|
77
|
+
|
|
78
|
+
const progress = createBootstrapProgress({
|
|
79
|
+
label: `installing ${packageName}@${version} into bootstrap cache`
|
|
80
|
+
})
|
|
81
|
+
try {
|
|
82
|
+
const result = await runBufferedCommand({
|
|
83
|
+
command: NPM_BIN,
|
|
84
|
+
args: [
|
|
85
|
+
'install',
|
|
86
|
+
'--prefix',
|
|
87
|
+
stagingDir,
|
|
88
|
+
'--no-audit',
|
|
89
|
+
'--no-fund',
|
|
90
|
+
'--loglevel=error',
|
|
91
|
+
`${packageName}@${version}`
|
|
92
|
+
],
|
|
93
|
+
env: resolvePackageManagerEnv()
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
if (result.code !== 0) {
|
|
97
|
+
throw new Error(formatInstallError(`Failed to install ${packageName}@${version}.`, result.stderr))
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
await ensureDirectory(path.dirname(cacheDir))
|
|
101
|
+
await rm(cacheDir, { recursive: true, force: true })
|
|
102
|
+
await rename(stagingDir, cacheDir)
|
|
103
|
+
progress.finish(`cached ${packageName}@${version}`)
|
|
104
|
+
} catch (error) {
|
|
105
|
+
progress.fail(`failed to cache ${packageName}@${version}`)
|
|
106
|
+
await rm(stagingDir, { recursive: true, force: true }).catch(() => {})
|
|
107
|
+
throw error
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
packageDir: resolvePackageInstallDir(cacheDir, packageName),
|
|
112
|
+
version
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export const resolvePackageBinEntrypoint = async (packageDir: string, commandName?: string) => {
|
|
117
|
+
const packageJsonContent = await readFile(path.join(packageDir, 'package.json'), 'utf8')
|
|
118
|
+
const packageJson = JSON.parse(packageJsonContent) as { bin?: unknown }
|
|
119
|
+
const { bin } = packageJson
|
|
120
|
+
|
|
121
|
+
if (typeof bin === 'string') {
|
|
122
|
+
return path.resolve(packageDir, bin)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (bin == null || typeof bin !== 'object') {
|
|
126
|
+
throw new Error(`Package ${packageDir} does not expose a CLI bin.`)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const binEntries = Object.entries(bin).filter((entry): entry is [string, string] => typeof entry[1] === 'string')
|
|
130
|
+
if (binEntries.length === 0) {
|
|
131
|
+
throw new Error(`Package ${packageDir} does not expose a CLI bin.`)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const matchedEntry = commandName != null
|
|
135
|
+
? binEntries.find(([binName]) => binName === commandName)
|
|
136
|
+
: undefined
|
|
137
|
+
|
|
138
|
+
return path.resolve(packageDir, (matchedEntry ?? binEntries[0])[1])
|
|
139
|
+
}
|