661 lines
28 KiB
JavaScript
661 lines
28 KiB
JavaScript
import { createHash } from 'node:crypto'
|
|
import { spawn, spawnSync } from 'node:child_process'
|
|
import {
|
|
access,
|
|
cp,
|
|
lstat,
|
|
mkdir,
|
|
mkdtemp,
|
|
open,
|
|
readFile,
|
|
readdir,
|
|
readlink,
|
|
rename,
|
|
rm,
|
|
stat,
|
|
symlink,
|
|
writeFile,
|
|
} from 'node:fs/promises'
|
|
import { homedir, platform, tmpdir } from 'node:os'
|
|
import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'
|
|
import { fileURLToPath } from 'node:url'
|
|
import { gzipSync } from 'node:zlib'
|
|
|
|
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
|
const configPath = resolve(root, 'config/offline-resources.json')
|
|
const config = JSON.parse(await readFile(configPath, 'utf8'))
|
|
const command = process.argv[2] || 'help'
|
|
const values = new Map(process.argv.slice(3).filter((argument) => argument.startsWith('--') && argument.includes('=')).map((argument) => {
|
|
const offset = argument.indexOf('=')
|
|
return [argument.slice(2, offset), argument.slice(offset + 1)]
|
|
}))
|
|
const flags = new Set(process.argv.slice(3).filter((argument) => argument.startsWith('--') && !argument.includes('=')).map((argument) => argument.slice(2)))
|
|
const defaultLibrary = resolve(homedir(), 'resource-library', config.libraryDirectory)
|
|
const library = resolve(values.get('library') || process.env.WEB_FREECAD_RESOURCE_LIBRARY || defaultLibrary)
|
|
const manifestPath = resolve(library, 'manifest.json')
|
|
const selectedIds = new Set((values.get('only') || '').split(',').filter(Boolean))
|
|
const isSelected = (resource) => selectedIds.size === 0 || selectedIds.has(resource.id)
|
|
const zstdLevel = values.get('compression-level') || '3'
|
|
const knownIds = new Set(['node-runtime', 'npm-cache', ...config.archives.map((entry) => entry.id), ...config.mirrors.map((entry) => entry.id)])
|
|
for (const id of selectedIds) if (!knownIds.has(id)) fail(`Unknown offline resource id: ${id}`)
|
|
|
|
function print(value) {
|
|
process.stdout.write(`${typeof value === 'string' ? value : JSON.stringify(value, null, 2)}\n`)
|
|
}
|
|
|
|
function fail(message) {
|
|
throw new Error(message)
|
|
}
|
|
|
|
async function exists(path) {
|
|
return access(path).then(() => true, () => false)
|
|
}
|
|
|
|
function expandHome(path) {
|
|
return path === '~' ? homedir() : path.startsWith(`~${sep}`) ? resolve(homedir(), path.slice(2)) : path
|
|
}
|
|
|
|
function resolveWorkspacePath(path) {
|
|
const expanded = expandHome(path)
|
|
return isAbsolute(expanded) ? expanded : resolve(root, expanded)
|
|
}
|
|
|
|
async function resourceSource(resource) {
|
|
const candidates = []
|
|
if (resource.sourceEnvironment && process.env[resource.sourceEnvironment]) candidates.push(process.env[resource.sourceEnvironment])
|
|
if (resource.source) candidates.push(resource.source)
|
|
candidates.push(...(resource.sourceCandidates || []))
|
|
for (const candidate of candidates) {
|
|
const path = resolveWorkspacePath(candidate)
|
|
if (await exists(path)) return path
|
|
}
|
|
return null
|
|
}
|
|
|
|
function resourceLibraryPath(resource) {
|
|
return resolve(library, resource.libraryPath)
|
|
}
|
|
|
|
function resourceRestorePath(resource, targetRoot = root) {
|
|
return resolve(targetRoot, resource.restore)
|
|
}
|
|
|
|
async function run(program, args, options = {}) {
|
|
await new Promise((resolveRun, rejectRun) => {
|
|
const child = spawn(program, args, {
|
|
cwd: options.cwd || root,
|
|
env: options.env || process.env,
|
|
stdio: options.stdio || 'inherit',
|
|
})
|
|
child.once('error', rejectRun)
|
|
child.once('exit', (code, signal) => {
|
|
if (code === 0) resolveRun()
|
|
else rejectRun(new Error(`${program} exited with ${code ?? signal}`))
|
|
})
|
|
})
|
|
}
|
|
|
|
async function sha256(path) {
|
|
const digest = createHash('sha256')
|
|
const handle = await open(path, 'r')
|
|
try {
|
|
for await (const chunk of handle.createReadStream()) digest.update(chunk)
|
|
} finally {
|
|
await handle.close().catch(() => {})
|
|
}
|
|
return digest.digest('hex')
|
|
}
|
|
|
|
async function describeFile(path) {
|
|
const metadata = await stat(path)
|
|
return { bytes: metadata.size, sha256: await sha256(path) }
|
|
}
|
|
|
|
async function walkFiles(path, options = {}, prefix = '') {
|
|
const files = []
|
|
for (const entry of await readdir(path, { withFileTypes: true })) {
|
|
const relativePath = prefix ? join(prefix, entry.name) : entry.name
|
|
const absolutePath = resolve(path, entry.name)
|
|
if (entry.isDirectory() && options.recursive !== false) files.push(...await walkFiles(absolutePath, options, relativePath))
|
|
else if ((entry.isFile() || entry.isSymbolicLink()) && (!options.matcher || options.matcher.test(relativePath))) files.push(relativePath)
|
|
}
|
|
return files
|
|
}
|
|
|
|
async function describeTree(path) {
|
|
const paths = (await walkFiles(path)).sort()
|
|
const digest = createHash('sha256')
|
|
let bytes = 0
|
|
for (const relativePath of paths) {
|
|
const absolutePath = resolve(path, relativePath)
|
|
const metadata = await lstat(absolutePath)
|
|
digest.update(`${relativePath}\0${metadata.mode & 0o777}\0`)
|
|
if (metadata.isSymbolicLink()) {
|
|
digest.update(`link:${await readlink(absolutePath)}\0`)
|
|
continue
|
|
}
|
|
bytes += metadata.size
|
|
const handle = await open(absolutePath, 'r')
|
|
try {
|
|
for await (const chunk of handle.createReadStream()) digest.update(chunk)
|
|
} finally {
|
|
await handle.close().catch(() => {})
|
|
}
|
|
digest.update('\0')
|
|
}
|
|
return { files: paths.length, bytes, sha256: digest.digest('hex') }
|
|
}
|
|
|
|
async function gitMetadata(path) {
|
|
const top = spawnSync('git', ['-C', path, 'rev-parse', '--show-toplevel'], { encoding: 'utf8' })
|
|
if (top.status !== 0) return null
|
|
const topLevel = resolve(top.stdout.trim())
|
|
const revision = spawnSync('git', ['-C', path, 'rev-parse', 'HEAD'], { encoding: 'utf8' }).stdout.trim()
|
|
const status = spawnSync('git', ['-C', path, 'status', '--short', '--untracked-files=all'], {
|
|
encoding: 'utf8',
|
|
maxBuffer: 64 * 1024 * 1024,
|
|
}).stdout
|
|
return {
|
|
revision,
|
|
worktreeRoot: relative(root, topLevel),
|
|
sourceWithinWorktree: relative(topLevel, path) || '.',
|
|
dirty: status.length > 0,
|
|
statusSha256: createHash('sha256').update(status).digest('hex'),
|
|
statusEntries: status.split(/\r?\n/).filter(Boolean).length,
|
|
}
|
|
}
|
|
|
|
async function replacePath(temporary, destination) {
|
|
await mkdir(dirname(destination), { recursive: true })
|
|
await rm(destination, { recursive: true, force: true })
|
|
await rename(temporary, destination)
|
|
}
|
|
|
|
async function createArchive(resource) {
|
|
const source = await resourceSource(resource)
|
|
if (!source) {
|
|
if (resource.required) fail(`Required source is missing for ${resource.id}`)
|
|
print(`[offline:sync] skip optional ${resource.id}: source is missing`)
|
|
return null
|
|
}
|
|
const sourceGit = await gitMetadata(source)
|
|
if (resource.revision && sourceGit?.revision !== resource.revision) {
|
|
fail(`${resource.id} revision is ${sourceGit?.revision || 'unknown'}, expected ${resource.revision}`)
|
|
}
|
|
const destination = resourceLibraryPath(resource)
|
|
const temporary = `${destination}.part-${process.pid}`
|
|
await mkdir(dirname(destination), { recursive: true })
|
|
await rm(temporary, { force: true })
|
|
print(`[offline:sync] archive ${resource.id} <- ${source}`)
|
|
await run('tar', [
|
|
'--create',
|
|
`--file=${temporary}`,
|
|
'--use-compress-program',
|
|
`zstd -T0 -${zstdLevel}`,
|
|
'--numeric-owner',
|
|
'--owner=0',
|
|
'--group=0',
|
|
'--directory',
|
|
source,
|
|
'.',
|
|
])
|
|
await replacePath(temporary, destination)
|
|
return {
|
|
id: resource.id,
|
|
kind: 'archive',
|
|
path: relative(library, destination),
|
|
...await describeFile(destination),
|
|
source: sourceGit,
|
|
}
|
|
}
|
|
|
|
async function syncMirror(resource) {
|
|
const source = await resourceSource(resource)
|
|
if (!source) {
|
|
if (resource.required) fail(`Required source is missing for ${resource.id}`)
|
|
print(`[offline:sync] skip optional ${resource.id}: source is missing`)
|
|
return null
|
|
}
|
|
const destination = resourceLibraryPath(resource)
|
|
const temporary = `${destination}.part-${process.pid}`
|
|
await rm(temporary, { recursive: true, force: true })
|
|
await mkdir(temporary, { recursive: true })
|
|
const files = await walkFiles(source, {
|
|
matcher: resource.includePattern ? new RegExp(resource.includePattern) : null,
|
|
recursive: resource.recursive,
|
|
})
|
|
print(`[offline:sync] mirror ${resource.id} (${files.length} files) <- ${source}`)
|
|
for (const relativePath of files) {
|
|
const from = resolve(source, relativePath)
|
|
const to = resolve(temporary, relativePath)
|
|
const metadata = await lstat(from)
|
|
await mkdir(dirname(to), { recursive: true })
|
|
if (metadata.isSymbolicLink()) await symlink(await readlink(from), to)
|
|
else await cp(from, to, { preserveTimestamps: true })
|
|
}
|
|
if (resource.id === 'debian-package-cache' || resource.id === 'freecad-package-cache') {
|
|
const index = spawnSync('dpkg-scanpackages', ['.', '/dev/null'], {
|
|
cwd: temporary,
|
|
encoding: 'utf8',
|
|
maxBuffer: 64 * 1024 * 1024,
|
|
})
|
|
if (index.status !== 0) fail(`dpkg-scanpackages failed for ${resource.id}: ${index.stderr.trim()}`)
|
|
await writeFile(resolve(temporary, 'Packages'), index.stdout)
|
|
await writeFile(resolve(temporary, 'Packages.gz'), gzipSync(index.stdout, { level: 9 }))
|
|
}
|
|
await replacePath(temporary, destination)
|
|
return {
|
|
id: resource.id,
|
|
kind: 'mirror',
|
|
path: relative(library, destination),
|
|
...await describeTree(destination),
|
|
}
|
|
}
|
|
|
|
function cacheIndexPath(cacheRoot, url) {
|
|
const key = `make-fetch-happen:request-cache:${url}`
|
|
const digest = createHash('sha256').update(key).digest('hex')
|
|
return resolve(cacheRoot, '_cacache/index-v5', digest.slice(0, 2), digest.slice(2, 4), digest.slice(4))
|
|
}
|
|
|
|
function cacheContentPath(cacheRoot, integrity) {
|
|
const separator = integrity.indexOf('-')
|
|
const algorithm = integrity.slice(0, separator)
|
|
const digest = Buffer.from(integrity.slice(separator + 1), 'base64').toString('hex')
|
|
return resolve(cacheRoot, '_cacache/content-v2', algorithm, digest.slice(0, 2), digest.slice(2, 4), digest.slice(4))
|
|
}
|
|
|
|
function platformMatches(constraints, value) {
|
|
if (!constraints?.length) return true
|
|
const includes = constraints.filter((entry) => !entry.startsWith('!'))
|
|
const excludes = constraints.filter((entry) => entry.startsWith('!')).map((entry) => entry.slice(1))
|
|
return !excludes.includes(value) && (includes.length === 0 || includes.includes(value))
|
|
}
|
|
|
|
function packageMatchesHost(value) {
|
|
return platformMatches(value.os, 'linux')
|
|
&& platformMatches(value.cpu, process.arch)
|
|
&& platformMatches(value.libc, 'glibc')
|
|
}
|
|
|
|
async function syncNpmCache() {
|
|
const sourceCache = resolve(process.env.npm_config_cache || process.env.NPM_CONFIG_CACHE || resolve(homedir(), '.npm'))
|
|
const destination = resolve(library, 'npm/cache')
|
|
const temporary = `${destination}.part-${process.pid}`
|
|
await rm(temporary, { recursive: true, force: true })
|
|
const urls = new Set()
|
|
for (const lock of config.npmLocks) {
|
|
const path = resolve(root, lock)
|
|
if (!await exists(path)) fail(`npm lock is missing: ${lock}`)
|
|
const document = JSON.parse(await readFile(path, 'utf8'))
|
|
for (const value of Object.values(document.packages || {})) {
|
|
if (value?.resolved?.startsWith('http') && packageMatchesHost(value)) urls.add(value.resolved)
|
|
}
|
|
}
|
|
let copied = 0
|
|
for (const url of [...urls].sort()) {
|
|
const indexSource = cacheIndexPath(sourceCache, url)
|
|
if (!await exists(indexSource)) fail(`npm cache index is missing ${url}`)
|
|
const lines = (await readFile(indexSource, 'utf8')).trim().split(/\r?\n/).filter(Boolean)
|
|
const line = lines.at(-1)
|
|
const entry = JSON.parse(line.slice(line.indexOf('\t') + 1))
|
|
const contentSource = cacheContentPath(sourceCache, entry.integrity)
|
|
if (!await exists(contentSource)) fail(`npm cache content is missing ${url}`)
|
|
for (const source of [indexSource, contentSource]) {
|
|
const path = relative(sourceCache, source)
|
|
const target = resolve(temporary, path)
|
|
await mkdir(dirname(target), { recursive: true })
|
|
await cp(source, target, { preserveTimestamps: true })
|
|
}
|
|
copied += 1
|
|
}
|
|
await replacePath(temporary, destination)
|
|
print(`[offline:sync] npm cache ${copied} locked tarballs`)
|
|
return {
|
|
id: 'npm-cache',
|
|
kind: 'npm-cache',
|
|
path: relative(library, destination),
|
|
packages: copied,
|
|
...await describeTree(destination),
|
|
}
|
|
}
|
|
|
|
async function syncNodeArchive() {
|
|
const source = resolve(root, config.node.archive)
|
|
if (!await exists(source)) fail(`Pinned Node archive is missing: ${source}`)
|
|
const destination = resolve(library, config.node.libraryPath)
|
|
await mkdir(dirname(destination), { recursive: true })
|
|
await cp(source, destination, { preserveTimestamps: true })
|
|
print(`[offline:sync] node ${config.node.version}`)
|
|
return { id: 'node-runtime', kind: 'file', path: relative(library, destination), ...await describeFile(destination) }
|
|
}
|
|
|
|
async function hostToolVersions() {
|
|
const probes = {
|
|
node: [resolve(root, 'nodew'), ['--version']],
|
|
npm: [resolve(root, 'npmw'), ['--version']],
|
|
cmake: ['cmake', ['--version']],
|
|
ninja: ['ninja', ['--version']],
|
|
emscripten: ['emcc', ['--version']],
|
|
chrome: [process.env.CHROME_BIN || resolve(homedir(), '.local/bin/google-chrome'), ['--version']],
|
|
firefox: [process.env.FIREFOX_BIN || 'firefox', ['--version']],
|
|
}
|
|
const result = {}
|
|
for (const [name, [program, args]] of Object.entries(probes)) {
|
|
const probe = spawnSync(program, args, { encoding: 'utf8' })
|
|
result[name] = probe.status === 0 ? `${probe.stdout}${probe.stderr}`.trim().split(/\r?\n/)[0] : null
|
|
}
|
|
return result
|
|
}
|
|
|
|
async function browserExecutables() {
|
|
const browserRoot = resolve(library, 'browsers/ms-playwright')
|
|
const directories = (await readdir(browserRoot, { withFileTypes: true }))
|
|
.filter((entry) => entry.isDirectory())
|
|
.map((entry) => entry.name)
|
|
.sort()
|
|
.reverse()
|
|
const select = (prefix) => directories.find((name) => name.startsWith(`${prefix}-`))
|
|
const chromiumDirectory = select('chromium')
|
|
const firefoxDirectory = select('firefox')
|
|
const webkitDirectory = select('webkit')
|
|
const paths = {
|
|
chromium: chromiumDirectory && resolve(browserRoot, chromiumDirectory, 'chrome-linux64/chrome'),
|
|
firefox: firefoxDirectory && resolve(browserRoot, firefoxDirectory, 'firefox/firefox'),
|
|
webkit: webkitDirectory && resolve(browserRoot, webkitDirectory, 'pw_run.sh'),
|
|
}
|
|
for (const [name, path] of Object.entries(paths)) {
|
|
if (!path || !await exists(path)) fail(`Offline ${name} executable is missing from ${browserRoot}`)
|
|
}
|
|
return Object.fromEntries(Object.entries(paths).map(([name, path]) => [name, relative(library, path)]))
|
|
}
|
|
|
|
async function sync() {
|
|
if (platform() !== 'linux') fail(`This resource profile is Linux-specific; received ${platform()}`)
|
|
if (selectedIds.size > 0) fail('Partial sync is intentionally unsupported; synchronize one complete, internally consistent manifest')
|
|
await mkdir(library, { recursive: true })
|
|
const entries = [await syncNodeArchive(), await syncNpmCache()]
|
|
for (const resource of config.archives) entries.push(await createArchive(resource))
|
|
for (const resource of config.mirrors) entries.push(await syncMirror(resource))
|
|
const manifest = {
|
|
schemaVersion: 1,
|
|
project: config.project,
|
|
generatedAt: new Date().toISOString(),
|
|
library,
|
|
configSha256: await sha256(configPath),
|
|
platform: config.platform,
|
|
hostTools: await hostToolVersions(),
|
|
browserExecutables: await browserExecutables(),
|
|
resources: entries.filter(Boolean),
|
|
}
|
|
await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`)
|
|
print(`[offline:sync] wrote ${manifestPath}`)
|
|
print({ status: 'pass', library, resources: manifest.resources.length })
|
|
}
|
|
|
|
async function readManifest() {
|
|
if (!await exists(manifestPath)) fail(`Offline manifest is missing: ${manifestPath}. Run offline:sync first.`)
|
|
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'))
|
|
if (manifest.schemaVersion !== 1 || manifest.project !== config.project) fail('Offline manifest identity is invalid')
|
|
return manifest
|
|
}
|
|
|
|
async function check() {
|
|
const manifest = await readManifest()
|
|
if (manifest.configSha256 !== await sha256(configPath)) fail('Offline resource config changed after the library was synchronized')
|
|
const expectedIds = new Set([
|
|
'node-runtime',
|
|
'npm-cache',
|
|
...config.archives.filter((entry) => entry.required).map((entry) => entry.id),
|
|
...config.mirrors.filter((entry) => entry.required).map((entry) => entry.id),
|
|
])
|
|
const records = new Map(manifest.resources.map((entry) => [entry.id, entry]))
|
|
for (const id of expectedIds) if (!records.has(id)) fail(`Offline manifest is missing required resource ${id}`)
|
|
for (const record of manifest.resources.filter(isSelected)) {
|
|
const path = resolve(library, record.path)
|
|
if (!await exists(path)) fail(`Offline resource is missing: ${record.id}`)
|
|
const actual = record.kind === 'archive' || record.kind === 'file' ? await describeFile(path) : await describeTree(path)
|
|
if (actual.bytes !== record.bytes || actual.sha256 !== record.sha256 || (record.files != null && actual.files !== record.files)) {
|
|
fail(`Offline resource checksum mismatch: ${record.id}`)
|
|
}
|
|
print(`[offline:check] ${record.id} ${record.sha256.slice(0, 12)}`)
|
|
}
|
|
print({ status: 'pass', library, resources: manifest.resources.length })
|
|
}
|
|
|
|
async function prepareNpmWorkCache(manifest) {
|
|
const npmRecord = manifest.resources.find((entry) => entry.id === 'npm-cache')
|
|
if (!npmRecord) fail('Offline manifest is missing npm-cache')
|
|
const destination = resolve(root, '.cache/offline-npm')
|
|
const marker = resolve(destination, '.resource-sha256')
|
|
const current = await readFile(marker, 'utf8').then((value) => value.trim(), () => '')
|
|
if (current === npmRecord.sha256) return destination
|
|
const temporary = `${destination}.part-${process.pid}`
|
|
await rm(temporary, { recursive: true, force: true })
|
|
await cp(resolve(library, npmRecord.path), temporary, { recursive: true, preserveTimestamps: true })
|
|
await writeFile(resolve(temporary, '.resource-sha256'), `${npmRecord.sha256}\n`)
|
|
await replacePath(temporary, destination)
|
|
return destination
|
|
}
|
|
|
|
async function assertRestoreTarget(targetRoot, destination) {
|
|
const relativePath = relative(targetRoot, destination)
|
|
if (!relativePath || relativePath === '..' || relativePath.startsWith(`..${sep}`) || isAbsolute(relativePath)) {
|
|
fail(`Unsafe restore target: ${destination}`)
|
|
}
|
|
if (await exists(destination) && !flags.has('force')) fail(`Restore target already exists: ${destination}. Use --force to replace it.`)
|
|
}
|
|
|
|
async function extractArchive(resource, record, targetRoot) {
|
|
const destination = resourceRestorePath(resource, targetRoot)
|
|
await assertRestoreTarget(targetRoot, destination)
|
|
const temporary = `${destination}.offline-part-${process.pid}`
|
|
await rm(temporary, { recursive: true, force: true })
|
|
await mkdir(temporary, { recursive: true })
|
|
print(`[offline:restore] ${resource.id} -> ${destination}`)
|
|
await run('tar', [
|
|
'--extract',
|
|
`--file=${resolve(library, record.path)}`,
|
|
'--use-compress-program',
|
|
'zstd -d',
|
|
'--directory',
|
|
temporary,
|
|
])
|
|
await replacePath(temporary, destination)
|
|
}
|
|
|
|
async function restoreMirror(resource, record, targetRoot) {
|
|
if (!resource.restore) return
|
|
const destination = resourceRestorePath(resource, targetRoot)
|
|
await assertRestoreTarget(targetRoot, destination)
|
|
const temporary = `${destination}.offline-part-${process.pid}`
|
|
await rm(temporary, { recursive: true, force: true })
|
|
await mkdir(dirname(temporary), { recursive: true })
|
|
print(`[offline:restore] ${resource.id} -> ${destination}`)
|
|
await cp(resolve(library, record.path), temporary, { recursive: true, preserveTimestamps: true })
|
|
await replacePath(temporary, destination)
|
|
}
|
|
|
|
async function restore() {
|
|
const manifest = await readManifest()
|
|
const targetRoot = resolve(values.get('target') || root)
|
|
await mkdir(targetRoot, { recursive: true })
|
|
const records = new Map(manifest.resources.map((entry) => [entry.id, entry]))
|
|
const resources = [...config.archives, ...config.mirrors.filter((entry) => entry.restore)].filter(isSelected)
|
|
if (selectedIds.size === 0) fail('Restore requires --only=ID,... so replacement scope is explicit')
|
|
for (const resource of resources) {
|
|
const record = records.get(resource.id)
|
|
if (!record) {
|
|
if (resource.required) fail(`Offline manifest is missing ${resource.id}`)
|
|
continue
|
|
}
|
|
if (record.kind === 'archive') await extractArchive(resource, record, targetRoot)
|
|
else await restoreMirror(resource, record, targetRoot)
|
|
}
|
|
if (selectedIds.has('node-runtime')) {
|
|
const record = records.get('node-runtime')
|
|
const destination = resolve(targetRoot, config.node.archive)
|
|
await assertRestoreTarget(targetRoot, destination)
|
|
await mkdir(dirname(destination), { recursive: true })
|
|
await cp(resolve(library, record.path), destination, { preserveTimestamps: true })
|
|
}
|
|
print({ status: 'pass', library, target: targetRoot, restored: [...selectedIds] })
|
|
}
|
|
|
|
async function npmOfflineSmoke() {
|
|
const directory = await mkdtemp(resolve(tmpdir(), 'web-freecad-offline-npm-'))
|
|
try {
|
|
for (const file of ['package.json', 'package-lock.json', '.npmrc']) await cp(resolve(root, file), resolve(directory, file))
|
|
const npmCache = resolve(directory, '.npm-cache')
|
|
await cp(resolve(library, 'npm/cache'), npmCache, { recursive: true, preserveTimestamps: true })
|
|
const runtimeArchive = resolve(library, config.node.libraryPath)
|
|
await run('tar', ['--extract', `--file=${runtimeArchive}`, '--directory', directory])
|
|
const runtime = resolve(directory, `node-v${config.node.version}-linux-x64`)
|
|
print('[offline:smoke] npm ci --offline in an isolated directory')
|
|
await run(resolve(runtime, 'bin/npm'), [
|
|
'ci',
|
|
'--offline',
|
|
'--ignore-scripts',
|
|
`--cache=${npmCache}`,
|
|
], {
|
|
cwd: directory,
|
|
env: {
|
|
...process.env,
|
|
PATH: `${resolve(runtime, 'bin')}:${process.env.PATH}`,
|
|
npm_config_offline: 'true',
|
|
npm_config_audit: 'false',
|
|
npm_config_fund: 'false',
|
|
},
|
|
})
|
|
const installed = JSON.parse(await readFile(resolve(directory, 'node_modules/@bitbybit-dev/occt/package.json'), 'utf8'))
|
|
if (installed.version !== '1.1.1') fail(`Offline npm smoke installed unexpected OCCT ${installed.version}`)
|
|
} finally {
|
|
await rm(directory, { recursive: true, force: true })
|
|
}
|
|
}
|
|
|
|
async function linuxCncNpmOfflineSmoke() {
|
|
const directory = await mkdtemp(resolve(tmpdir(), 'web-freecad-offline-linuxcnc-npm-'))
|
|
const source = resolve(root, 'cnc_wams_gpt6/linuxcnc-master/web')
|
|
try {
|
|
for (const file of ['package.json', 'package-lock.json']) await cp(resolve(source, file), resolve(directory, file))
|
|
const npmCache = resolve(directory, '.npm-cache')
|
|
await cp(resolve(library, 'npm/cache'), npmCache, { recursive: true, preserveTimestamps: true })
|
|
const runtimeDirectory = await mkdtemp(resolve(tmpdir(), 'web-freecad-offline-node-'))
|
|
try {
|
|
await run('tar', ['--extract', `--file=${resolve(library, config.node.libraryPath)}`, '--directory', runtimeDirectory])
|
|
const runtime = resolve(runtimeDirectory, `node-v${config.node.version}-linux-x64`)
|
|
print('[offline:smoke] LinuxCNC Web npm ci --offline in an isolated directory')
|
|
await run(resolve(runtime, 'bin/npm'), [
|
|
'ci',
|
|
'--offline',
|
|
'--ignore-scripts',
|
|
`--cache=${npmCache}`,
|
|
], {
|
|
cwd: directory,
|
|
env: {
|
|
...process.env,
|
|
PATH: `${resolve(runtime, 'bin')}:${process.env.PATH}`,
|
|
npm_config_offline: 'true',
|
|
npm_config_audit: 'false',
|
|
npm_config_fund: 'false',
|
|
},
|
|
})
|
|
const installed = JSON.parse(await readFile(resolve(directory, 'node_modules/playwright/package.json'), 'utf8'))
|
|
if (installed.version !== '1.61.1') fail(`Offline LinuxCNC npm smoke installed unexpected Playwright ${installed.version}`)
|
|
} finally {
|
|
await rm(runtimeDirectory, { recursive: true, force: true })
|
|
}
|
|
} finally {
|
|
await rm(directory, { recursive: true, force: true })
|
|
}
|
|
}
|
|
|
|
async function playwrightOfflineSmoke() {
|
|
const playwrightPath = resolve(root, 'cnc_wams_gpt6/linuxcnc-master/web/node_modules/playwright/index.mjs')
|
|
const executables = await browserExecutables()
|
|
const absoluteExecutables = Object.fromEntries(Object.entries(executables).map(([name, path]) => [name, resolve(library, path)]))
|
|
const script = [
|
|
`import { chromium, firefox, webkit } from ${JSON.stringify(new URL(`file://${playwrightPath}`).href)}`,
|
|
`for (const [name, type] of Object.entries({ chromium, firefox, webkit })) {`,
|
|
` const executablePath = ${JSON.stringify(absoluteExecutables)}[name]`,
|
|
` const browser = await type.launch({ headless: true, executablePath })`,
|
|
` const page = await browser.newPage()`,
|
|
` await page.setContent('<title>offline</title>')`,
|
|
` if (await page.title() !== 'offline') throw new Error(name + ' page smoke failed')`,
|
|
` console.log('[offline:smoke] browser ' + name + ' pass')`,
|
|
` await browser.close()`,
|
|
`}`,
|
|
].join('\n')
|
|
await run(resolve(root, 'nodew'), ['--input-type=module', '--eval', script], {
|
|
env: { ...process.env, PLAYWRIGHT_BROWSERS_PATH: resolve(library, 'browsers/ms-playwright') },
|
|
})
|
|
}
|
|
|
|
async function smoke() {
|
|
await check()
|
|
await npmOfflineSmoke()
|
|
await linuxCncNpmOfflineSmoke()
|
|
await playwrightOfflineSmoke()
|
|
const environment = {
|
|
...process.env,
|
|
WEB_FREECAD_RESOURCE_LIBRARY: library,
|
|
WEB_FREECAD_OFFLINE: '1',
|
|
npm_config_cache: await prepareNpmWorkCache(await readManifest()),
|
|
npm_config_offline: 'true',
|
|
PLAYWRIGHT_BROWSERS_PATH: resolve(library, 'browsers/ms-playwright'),
|
|
FREECAD_SOURCE_OFFLINE: '1',
|
|
OCCT_SOURCE_DIR: resolve(root, '.cache/occt/occt'),
|
|
}
|
|
for (const script of ['check:runtime', 'check:freecad-source', 'check:freecad-private-naming-boundary', 'check:occt-history-artifact', 'test:occt-history', 'test:planegcs', 'build']) {
|
|
print(`[offline:smoke] npm run ${script}`)
|
|
await run(resolve(root, 'npmw'), ['run', script], { env: environment })
|
|
}
|
|
print({ status: 'pass', library, networkFallback: false })
|
|
}
|
|
|
|
function shellQuote(value) {
|
|
return `'${String(value).replaceAll("'", "'\\''")}'`
|
|
}
|
|
|
|
async function environment() {
|
|
const manifest = await readManifest()
|
|
const npmCache = await prepareNpmWorkCache(manifest)
|
|
const firefox = resolve(library, manifest.browserExecutables.firefox)
|
|
const webkit = resolve(library, manifest.browserExecutables.webkit)
|
|
const lines = [
|
|
`export WEB_FREECAD_RESOURCE_LIBRARY=${shellQuote(library)}`,
|
|
'export WEB_FREECAD_OFFLINE=1',
|
|
`export npm_config_cache=${shellQuote(npmCache)}`,
|
|
'export npm_config_offline=true',
|
|
`export PLAYWRIGHT_BROWSERS_PATH=${shellQuote(resolve(library, 'browsers/ms-playwright'))}`,
|
|
'export FREECAD_SOURCE_OFFLINE=1',
|
|
`export OCCT_SOURCE_DIR=${shellQuote(resolve(root, '.cache/occt/occt'))}`,
|
|
`export CHROME_BIN=${shellQuote(process.env.CHROME_BIN || resolve(homedir(), '.local/bin/google-chrome'))}`,
|
|
`export FIREFOX_BIN=${shellQuote(firefox)}`,
|
|
`export WEBKIT_BIN=${shellQuote(webkit)}`,
|
|
'export CI_REAL_OFFLINE=1',
|
|
]
|
|
print(lines.join('\n'))
|
|
}
|
|
|
|
async function inventory() {
|
|
const resources = []
|
|
for (const resource of [...config.archives, ...config.mirrors]) {
|
|
const source = await resourceSource(resource)
|
|
resources.push({ id: resource.id, source, present: Boolean(source), required: resource.required })
|
|
}
|
|
print({ library, config: relative(root, configPath), resources, hostTools: await hostToolVersions() })
|
|
}
|
|
|
|
if (command === 'sync') await sync()
|
|
else if (command === 'check') await check()
|
|
else if (command === 'restore') await restore()
|
|
else if (command === 'smoke') await smoke()
|
|
else if (command === 'env') await environment()
|
|
else if (command === 'inventory') await inventory()
|
|
else {
|
|
print('Usage: node scripts/offline-resource-lib.mjs <inventory|sync|check|restore|smoke|env> [--library=PATH] [--only=ID,...] [--target=PATH] [--force]')
|
|
if (command !== 'help') process.exitCode = 2
|
|
}
|