@shockpkg/core
Version:
shockpkg core
990 lines (918 loc) • 24.1 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.Manager = void 0;
var _nodeFs = require("node:fs");
var _promises = require("node:fs/promises");
var _nodePath = require("node:path");
var _nodeStream = require("node:stream");
var _promises2 = require("node:stream/promises");
var _nodeCrypto = require("node:crypto");
var _constants = require("./constants.js");
var _dispatcher = require("./dispatcher.js");
var _stream = require("./stream.js");
var _packages = require("./packages.js");
var _meta = require("./meta.js");
/**
* Retry once on error.
*
* @param f The function to try.
* @returns The result.
*/
async function retry(f) {
return f().catch(async () => f());
}
/**
* Package manager.
*/
class Manager {
/**
* Root path.
*/
/**
* The default headers for HTTP requests.
*/
headers = {
// eslint-disable-next-line @typescript-eslint/naming-convention
'User-Agent': `${_meta.NAME}/${_meta.VERSION}`
};
/**
* A fetch-like interface requiring only a sebset of features.
*/
fetch = typeof fetch === 'undefined' ? null : fetch;
/**
* Package install before events.
*/
eventPackageInstallBefore = new _dispatcher.Dispatcher(this);
/**
* Package install after events.
*/
eventPackageInstallAfter = new _dispatcher.Dispatcher(this);
/**
* Package install current events.
*/
eventPackageInstallCurrent = new _dispatcher.Dispatcher(this);
/**
* Package download before events.
*/
eventPackageDownloadBefore = new _dispatcher.Dispatcher(this);
/**
* Package download after events.
*/
eventPackageDownloadAfter = new _dispatcher.Dispatcher(this);
/**
* Package download progress events.
*/
eventPackageDownloadProgress = new _dispatcher.Dispatcher(this);
/**
* Package cleanup before events.
*/
eventPackageCleanupBefore = new _dispatcher.Dispatcher(this);
/**
* Package cleanup after events.
*/
eventPackageCleanupAfter = new _dispatcher.Dispatcher(this);
/**
* Packages instance.
*/
/**
* Manager constructor.
*
* @param path The path, defaults to environment variable or relative.
*/
constructor(path = null) {
this.path = this._createPath(path);
this._packages = this._createPackages();
}
/**
* Packages URL.
*
* @returns The URL.
*/
get packagesUrl() {
// eslint-disable-next-line no-process-env
return process.env[_constants.PACKAGES_URL_ENV] || _constants.PACKAGES_URL;
}
/**
* Packages file.
*
* @returns The file.
*/
get packagesFile() {
return _constants.PACKAGES_FILE;
}
/**
* Package file.
*
* @returns The path.
*/
get packageFile() {
return _constants.PACKAGE_FILE;
}
/**
* Meta directory.
*
* @returns The directory.
*/
get metaDir() {
return _constants.META_DIR;
}
/**
* Packages loaded.
*
* @returns Is loaded.
*/
get loaded() {
return this._packages.loaded;
}
/**
* Assert instance all loaded, including the packages list.
* Implies all active assertions.
*/
assertLoaded() {
if (!this.loaded) {
throw new Error('Packages list not loaded');
}
}
/**
* Ensure load if exists.
*/
async ensureLoad() {
if (!this.loaded) {
await this.load();
}
}
/**
* Ensure loaded.
*/
async ensureLoaded() {
await this.ensureLoad();
this.assertLoaded();
}
/**
* Load packages if exist.
*/
async load() {
await this._packages.readIfExists();
}
/**
* Iterate over the packages.
*
* @yields Package object.
*/
async *packages() {
await this.ensureLoaded();
for (const entry of this._packages.packages()) {
yield entry;
}
}
/**
* Get package by the unique name.
*
* @param name Package name.
* @returns The package or null.
*/
async packageByName(name) {
await this.ensureLoaded();
return this._packages.byName(name);
}
/**
* Get package by the sha256 hash.
*
* @param sha256 Package sha256.
* @returns The package or null.
*/
async packageBySha256(sha256) {
await this.ensureLoaded();
return this._packages.bySha256(sha256);
}
/**
* Get package by the sha1 hash.
*
* @param sha1 Package sha1.
* @returns The package or null.
*/
async packageBySha1(sha1) {
await this.ensureLoaded();
return this._packages.bySha1(sha1);
}
/**
* Get package by the md5 hash.
*
* @param md5 Package md5.
* @returns The package or null.
*/
async packageByMd5(md5) {
await this.ensureLoaded();
return this._packages.byMd5(md5);
}
/**
* Get package by the unique value.
*
* @param unique Package unique.
* @returns The package or null.
*/
async packageByUnique(unique) {
await this.ensureLoaded();
return this._packages.byUnique(unique);
}
/**
* Read package install receipt.
*
* @param pkg The package.
* @returns Install receipt.
*/
async receipt(pkg) {
await this.ensureLoaded();
const name = await this._asName(pkg);
const pkgf = await this.pathToPackageMeta(name, this.packageFile);
const r = await (0, _promises.readFile)(pkgf, 'utf8').then(s => JSON.parse(s)).catch(() => null);
if (!r) {
throw new Error(`Package is not installed: ${name}`);
}
return r;
}
/**
* Get package install file.
*
* @param pkg The package.
* @returns Path to install file.
*/
async file(pkg) {
await this.ensureLoaded();
pkg = await this._asPackage(pkg);
const data = await this.receipt(pkg);
return this.pathToPackage(pkg, data.file);
}
/**
* Verify package install file, using size and hash.
*
* @param pkg The package.
*/
async packageInstallVerify(pkg) {
await this.ensureLoaded();
pkg = await this._asPackage(pkg);
const data = await this.receipt(pkg);
const {
sha256,
file,
size
} = data;
const filePath = await this.pathToPackage(pkg, file);
const stat = await (0, _promises.lstat)(filePath);
const fSize = stat.size;
if (fSize !== size) {
throw new Error(`Invalid file size: ${fSize}`);
}
const stream = (0, _nodeFs.createReadStream)(filePath);
let hashsum = '';
const hash = (0, _nodeCrypto.createHash)('sha256');
hash.setEncoding('hex');
hash.on('finish', () => {
hashsum = hash.read();
});
await (0, _promises2.pipeline)(stream, hash);
if (hashsum !== sha256) {
throw new Error(`Invalid sha256 hash: ${hashsum}`);
}
}
/**
* Update the package manager installed data.
* Updates the packages list.
*
* @returns Update report.
*/
async update() {
// Read data, update list, write list to file, return report.
const data = await this._requestPackages();
// Try to determined what gets updated.
try {
await this.ensureLoad();
} catch {
// Ignore errors like outdated format version.
}
const report = this._packages.update(data);
await this._packages.write();
return report;
}
/**
* Check if a package is installed.
*
* @param pkg The package.
* @returns True if already installed, else false.
*/
async isInstalled(pkg) {
await this.ensureLoaded();
pkg = await this._asPackage(pkg);
try {
await this.receipt(pkg);
} catch {
return false;
}
return true;
}
/**
* Check if a package is installed and up-to-date.
*
* @param pkg The package.
* @returns True if already up-to-date, else false.
*/
async isCurrent(pkg) {
await this.ensureLoaded();
pkg = await this._asPackage(pkg);
let data = null;
try {
data = await this.receipt(pkg);
} catch {
return false;
}
return !!(data.sha256 === pkg.sha256 && data.size === pkg.size && data.file === pkg.file && data.name === pkg.name);
}
/**
* List all installed packages.
*
* @returns A list of installed package objects.
*/
async installed() {
await this.ensureLoaded();
const list = [];
for (const entry of await this._packageDirectories()) {
// eslint-disable-next-line no-await-in-loop
const pkg = await this.packageByName(entry);
// eslint-disable-next-line no-await-in-loop
if (pkg && (await this.isInstalled(pkg))) {
list.push(pkg);
}
}
return list;
}
/**
* List all outdated packages.
*
* @returns The list of outdated package objects.
*/
async outdated() {
await this.ensureLoaded();
const list = [];
for (const entry of await this._packageDirectories()) {
// eslint-disable-next-line no-await-in-loop
const pkg = await this.packageByName(entry);
// eslint-disable-next-line no-await-in-loop
if (pkg && !(await this.isCurrent(pkg))) {
list.push(pkg);
}
}
return list;
}
/**
* Upgrade any outdated packages.
*
* @returns List of packages upgraded.
*/
async upgrade() {
await this.ensureLoaded();
const outdated = await this.outdated();
const list = [];
for (const pkg of outdated) {
list.push({
package: pkg,
// eslint-disable-next-line no-await-in-loop
install: await this.install(pkg)
});
}
return list;
}
/**
* Install package.
* Returns the list of packages processed to install.
* Returns empty array if current version is already installed.
*
* @param pkg The package.
* @returns List of packages processed to complete the install.
*/
async install(pkg) {
await this.ensureLoaded();
pkg = await this._asPackage(pkg);
const fetch = this._ensureFetch();
// If current version is installed, skip.
const installed = await this.isCurrent(pkg);
if (installed) {
this.eventPackageInstallCurrent.trigger({
package: pkg
});
return [];
}
// Find the closest current installed parent, if any.
const packages = [pkg];
for (let p = pkg.parent; p; p = p.parent) {
packages.push(p);
}
packages.reverse();
const [srcPkg] = packages;
// Find the lowest slice to read before compression.
// Build transforms to pipe the source slice through.
let slice = null;
const transforms = [];
{
let i = 1;
while (i < packages.length) {
const p = packages[i++];
const [ss, sl] = p.getZippedSlice();
if (slice) {
slice[0] += ss;
slice[1] = sl;
} else {
slice = [ss, sl];
}
const d = p.getZippedDecompressor();
if (d) {
transforms.push(d);
break;
}
}
while (i < packages.length) {
const p = packages[i++];
const [ss, sl] = p.getZippedSlice();
transforms.push(new _stream.SliceStream(ss, sl));
const d = p.getZippedDecompressor();
if (d) {
transforms.push(d);
}
}
}
this.eventPackageInstallBefore.trigger({
package: pkg
});
const outFile = await this.pathToPackage(pkg, pkg.file);
const tmpDir = await this.pathToPackageMeta(pkg, _constants.TEMP_DIR);
const tmpFile = (0, _nodePath.join)(tmpDir, `${pkg.sha256}${_constants.TEMP_EXT}`);
const metaFile = await this.pathToPackageMeta(pkg, this.packageFile);
// Create temporary directory, cleanup on failure.
await (0, _promises.rm)(tmpDir, {
recursive: true,
force: true
});
await (0, _promises.mkdir)(tmpDir, {
recursive: true
});
const fd = await (0, _promises.open)(tmpFile, 'wx');
try {
const output = new _stream.WriterStream(tmpFile, {
fd
});
this.eventPackageDownloadBefore.trigger({
package: pkg
});
this.eventPackageDownloadProgress.trigger({
package: pkg,
total: pkg.size,
amount: 0
});
// Create output file, monitoring write progress.
output.on('wrote', () => {
this.eventPackageDownloadProgress.trigger({
package: pkg,
total: pkg.size,
amount: output.bytesWritten
});
});
let input;
const url = srcPkg.source;
if (slice) {
const [start, size] = slice;
if (size > 0) {
const init = {
headers: {
...this.headers,
Range: `bytes=${start}-${start + size - 1}`
}
};
const res = await retry(async () => fetch(url, init)).catch(err => {
if (err) {
throw new Error(this._fetchErrorMessage(err));
}
throw err;
});
const {
status
} = res;
if (status !== 206) {
throw new Error(`Invalid resume status: ${status}: ${url}`);
}
const cl = res.headers.get('content-length');
if (cl && +cl !== size) {
throw new Error(`Invalid resume content-length: ${cl}: ${url}`);
}
const {
body
} = res;
try {
input = _nodeStream.Readable.fromWeb(body);
} catch {
input = body;
}
} else if (size === 0) {
input = new _stream.EmptyStream();
} else {
throw new Error(`Cannot download negative size: ${size}`);
}
} else {
const init = {
headers: this.headers
};
const res = await retry(async () => fetch(url, init)).catch(err => {
if (err) {
throw new Error(this._fetchErrorMessage(err));
}
throw err;
});
const {
status
} = res;
if (status !== 200) {
throw new Error(`Invalid download status: ${status}: ${url}`);
}
const cl = res.headers.get('content-length');
if (cl && +cl !== srcPkg.size) {
throw new Error(`Invalid download content-length: ${cl}: ${url}`);
}
const {
body
} = res;
try {
input = _nodeStream.Readable.fromWeb(body);
} catch {
input = body;
}
}
// Hash the last readable stream to verify package.
const hash = (0, _nodeCrypto.createHash)('sha256');
const {
length
} = transforms;
const lastData = length ? transforms[length - 1] : input;
lastData.on('data', data => {
hash.update(data);
});
// Pipe all the streams through the pipeline.
// Work around types failing on variable args.
await (0, _promises2.pipeline)(input, ...transforms, output);
// Verify the write size.
if (output.bytesWritten !== pkg.size) {
throw new Error(`Invalid extract size: ${output.bytesWritten}`);
}
// Verify the file hash.
const hashed = hash.digest().toString('hex');
if (hashed !== pkg.sha256) {
throw new Error(`Invalid sha256 hash: ${hashed}`);
}
this.eventPackageDownloadAfter.trigger({
package: pkg
});
// Move the final file into place and write package file.
// Write the package receipt last, means successful install.
await this._packageDirsEnsure(pkg);
await (0, _promises.rm)(metaFile, {
force: true
});
await (0, _promises.rm)(outFile, {
force: true
});
await (0, _promises.rename)(tmpFile, outFile);
await this._packageMetaReceiptWrite(pkg);
} finally {
// Should normally closed when stream ends.
await fd.close();
await (0, _promises.rm)(tmpDir, {
recursive: true,
force: true
});
}
this.eventPackageInstallAfter.trigger({
package: pkg
});
return packages;
}
/**
* Remove package.
*
* @param pkg The package.
* @returns True if removed, false if nothing to remove.
*/
async remove(pkg) {
await this.ensureLoaded();
const dir = await this.pathToPackage(pkg);
const stat = await (0, _promises.lstat)(dir).catch(() => null);
if (!stat) {
return false;
}
const dirMeta = await this.pathToPackageMeta(pkg);
// Remove meta directory first, avoid partial installed state.
await (0, _promises.rm)(dirMeta, {
recursive: true,
force: true
});
await (0, _promises.rm)(dir, {
recursive: true,
force: true
});
return true;
}
/**
* Check if package name is obsolete.
*
* @param pkg The package.
* @returns True if package obslete, else false.
*/
async isObsolete(pkg) {
await this.ensureLoaded();
return !pkg.startsWith('.') && !(await this.packageByName(pkg)) && (0, _promises.access)(await this.pathToPackageMeta(pkg)).then(() => true, () => false);
}
/**
* List obsolete package names.
*
* @returns A list of obsolete package names.
*/
async obsolete() {
await this.ensureLoaded();
const list = [];
for (const entry of await this._packageDirectories()) {
// eslint-disable-next-line no-await-in-loop
if (await this.isObsolete(entry)) {
list.push(entry);
}
}
return list;
}
/**
* Cleanup all obsolete and outdated packages.
*
* @returns Lists of removed packages.
*/
async cleanup() {
await this.ensureLoaded();
const list = [];
for (const pkg of await this._packageDirectories()) {
// Remove any temporary directory if present.
// eslint-disable-next-line no-await-in-loop
const tmpDir = await this.pathToPackageMeta(pkg, _constants.TEMP_DIR);
// eslint-disable-next-line no-await-in-loop
await (0, _promises.rm)(tmpDir, {
recursive: true,
force: true
});
// eslint-disable-next-line no-await-in-loop
if (await this.isObsolete(pkg)) {
this.eventPackageCleanupBefore.trigger({
package: pkg
});
// eslint-disable-next-line no-await-in-loop
const removed = await this.remove(pkg);
this.eventPackageCleanupAfter.trigger({
package: pkg,
removed
});
list.push({
package: pkg,
removed
});
}
}
return list;
}
/**
* Join path on the base path.
*
* @param parts Path parts.
* @returns Joined path.
*/
pathTo(...parts) {
return (0, _nodePath.join)(this.path, ...parts);
}
/**
* Join path on the meta path.
*
* @param parts Path parts.
* @returns Joined path.
*/
pathToMeta(...parts) {
return this.pathTo(this.metaDir, ...parts);
}
/**
* Join path on package base path.
*
* @param pkg The package.
* @param parts Path parts.
* @returns Joined path.
*/
async pathToPackage(pkg, ...parts) {
await this.ensureLoaded();
return this.pathTo(await this._asName(pkg), ...parts);
}
/**
* Join path on package meta path.
*
* @param pkg The package.
* @param parts Path parts.
* @returns Joined path.
*/
async pathToPackageMeta(pkg, ...parts) {
await this.ensureLoaded();
return this.pathTo(await this._asName(pkg), this.metaDir, ...parts);
}
/**
* Get package object by object, name, or hash.
* Throw error if package is unknown.
*
* @param pkg The package.
* @returns Package object.
*/
async _asPackage(pkg) {
await this.ensureLoaded();
if (typeof pkg === 'string') {
const p = await this.packageByUnique(pkg);
if (!p) {
throw new Error(`Unknown package: ${pkg}`);
}
return p;
}
return pkg;
}
/**
* Get package name by object, name, or hash.
* If package object is passed, uses name from the object.
* If string is passed and unknown, returns that same string.
*
* @param pkg The package.
* @returns Package object.
*/
async _asName(pkg) {
await this.ensureLoaded();
return typeof pkg === 'string' ? (await this.packageByUnique(pkg))?.name ?? pkg : pkg.name;
}
/**
* Write package installed receipt.
*
* @param pkg The package.
*/
async _packageMetaReceiptWrite(pkg) {
await this.ensureLoaded();
pkg = await this._asPackage(pkg);
const pkgf = await this.pathToPackageMeta(pkg, this.packageFile);
const pkgfTmp = `${pkgf}${_constants.TEMP_EXT}`;
const receipt = await this._packageMetaReceiptFromPackage(pkg);
await (0, _promises.rm)(pkgfTmp, {
force: true
});
await (0, _promises.writeFile)(pkgfTmp, JSON.stringify(receipt, null, '\t'), {
flag: 'wx'
});
await (0, _promises.rename)(pkgfTmp, pkgf);
}
/**
* Create package installed receipt object from a package.
*
* @param pkg The package.
* @returns Receipt object.
*/
async _packageMetaReceiptFromPackage(pkg) {
await this.ensureLoaded();
pkg = await this._asPackage(pkg);
const r = {
name: pkg.name,
file: pkg.file,
size: pkg.size,
sha256: pkg.sha256,
source: pkg.source
};
return r;
}
/**
* Ensure package directory exists.
*
* @param pkg The package.
*/
async _packageDirsEnsure(pkg) {
await this.ensureLoaded();
pkg = await this._asPackage(pkg);
const dir = await this.pathToPackage(pkg);
const dirMeta = await this.pathToPackageMeta(pkg);
await (0, _promises.mkdir)(dir, {
recursive: true
});
await (0, _promises.mkdir)(dirMeta, {
recursive: true
});
}
/**
* Ensure fetch-like function is set.
*
* @returns The fetch-like function.
*/
_ensureFetch() {
const {
fetch
} = this;
if (!fetch) {
throw new Error('Default fetch not available');
}
return fetch;
}
/**
* Get fetch error messsage.
*
* @param error Error object.
* @returns Error message.
*/
_fetchErrorMessage(error) {
const {
message,
cause
} = error;
let msg = message;
if (cause) {
const {
name,
code
} = cause;
const info = [name, code].filter(Boolean).join(' ');
if (info) {
msg += ` (${info})`;
}
}
return msg;
}
/**
* List directories under package manger control.
*
* @returns The recognized package directories.
*/
async _packageDirectories() {
return (await (0, _promises.readdir)(this.path, {
withFileTypes: true
})).filter(e => !e.name.startsWith('.') && e.isDirectory()).map(e => e.name).sort();
}
/**
* Request the packages file.
*
* @returns File contents as string.
*/
async _requestPackages() {
const fetch = this._ensureFetch();
const url = this.packagesUrl;
const init = {
headers: {
...this.headers,
// eslint-disable-next-line @typescript-eslint/naming-convention
'Cache-Control': 'max-age=0',
Pragma: 'no-cache'
}
};
const res = await retry(async () => fetch(url, init)).catch(err => {
if (err) {
throw new Error(this._fetchErrorMessage(err));
}
throw err;
});
const {
status
} = res;
if (status !== 200) {
throw new Error(`Invalid response status: ${status}: ${url}`);
}
return res.text();
}
/**
* Ensure base directories exists.
*/
async _ensureDirs() {
await (0, _promises.mkdir)(this.path, {
recursive: true
});
await (0, _promises.mkdir)(this.pathToMeta(), {
recursive: true
});
}
/**
* Create the main path.
*
* @param path The path, defaults to environment variable or relative.
* @returns Main path.
*/
_createPath(path) {
// Use specified, or environment variable, or relative default.
// eslint-disable-next-line no-process-env
return path || process.env[_constants.PATH_ENV] || _constants.MAIN_DIR;
}
/**
* Create the Packages instance.
*
* @returns Packages instance.
*/
_createPackages() {
return new _packages.Packages(this.pathToMeta(this.packagesFile));
}
}
exports.Manager = Manager;
//# sourceMappingURL=manager.js.map