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('offline')`, ` 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 [--library=PATH] [--only=ID,...] [--target=PATH] [--force]') if (command !== 'help') process.exitCode = 2 }