Files
Web_FreeCAD_Bitbybit/scripts/offline-resource-lib.mjs
wangdequan ca6fe46030
Some checks failed
real-verification / chrome (push) Has been cancelled
real-verification / freecad-oracle (push) Has been cancelled
real-verification / wasm (push) Has been cancelled
feat: add self-contained offline development resources
2026-08-12 02:36:09 -04:00

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: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
}