dsh 0.2.0-rc.2: in-container update (experimental channel), path-agnostic compose, upstream patches adapted to 0.1.x/0.2.x
This commit is contained in:
commit
e6dcef9bf5
302 files changed
+75178
No files matched your search
@@ -0,0 +1,203 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { join, resolve } from 'node:path'
|
||||
|
||||
const PACKAGE_NAME_PATTERN = /^(?:@[a-z0-9][a-z0-9._-]{0,126}\/[a-z0-9][a-z0-9._-]{0,126}|[a-z0-9][a-z0-9._-]{0,213})$/i
|
||||
const SELECTION_SCHEMA = 1
|
||||
|
||||
function packagePath(root, packageName) {
|
||||
if (!PACKAGE_NAME_PATTERN.test(packageName)) return undefined
|
||||
return join(root, 'node_modules', ...packageName.split('/'), 'package.json')
|
||||
}
|
||||
|
||||
async function optionalBytes(path) {
|
||||
return readFile(path).then(
|
||||
bytes => ({ present: true, bytes }),
|
||||
error => error?.code === 'ENOENT' ? { present: false, bytes: Buffer.alloc(0) } : Promise.reject(error),
|
||||
)
|
||||
}
|
||||
|
||||
function parseObject(bytes, label) {
|
||||
let value
|
||||
try {
|
||||
value = JSON.parse(bytes.toString('utf8'))
|
||||
} catch (error) {
|
||||
throw new Error(`${label} is not valid JSON`, { cause: error })
|
||||
}
|
||||
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new Error(`${label} must contain a JSON object`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function profileFields(manifest) {
|
||||
const dependencies = manifest.dependencies ?? {}
|
||||
if (dependencies === null || typeof dependencies !== 'object' || Array.isArray(dependencies)) {
|
||||
throw new Error('Web Profile dependencies must be an object')
|
||||
}
|
||||
for (const [name, spec] of Object.entries(dependencies)) {
|
||||
if (typeof spec !== 'string' || spec.length === 0) {
|
||||
throw new Error(`Web Profile dependency ${name} must have a non-empty string spec`)
|
||||
}
|
||||
}
|
||||
const bundles = manifest.dsh?.profile?.bundles ?? []
|
||||
if (!Array.isArray(bundles) || bundles.some(name => typeof name !== 'string')) {
|
||||
throw new Error('Web Profile dsh.profile.bundles must be a string array')
|
||||
}
|
||||
return { dependencies, bundles }
|
||||
}
|
||||
|
||||
function disabledPositions(value) {
|
||||
if (value === undefined) return new Map()
|
||||
if (value === null || typeof value !== 'object' || Array.isArray(value)
|
||||
|| value.schema !== SELECTION_SCHEMA || !Array.isArray(value.disabled)) {
|
||||
throw new Error('User Plugin selection state is invalid')
|
||||
}
|
||||
const positions = new Map()
|
||||
for (const entry of value.disabled) {
|
||||
if (entry === null || typeof entry !== 'object' || Array.isArray(entry)
|
||||
|| !PACKAGE_NAME_PATTERN.test(entry.name) || !Number.isSafeInteger(entry.index) || entry.index < 0) {
|
||||
throw new Error('User Plugin disabled-order entry is invalid')
|
||||
}
|
||||
if (positions.has(entry.name)) throw new Error(`User Plugin ${entry.name} has duplicate disabled-order entries`)
|
||||
positions.set(entry.name, entry.index)
|
||||
}
|
||||
return positions
|
||||
}
|
||||
|
||||
function sourceForSpec(spec) {
|
||||
if (/^(?:file|link):/i.test(spec) || /^(?:\.{1,2}\/|\/)/.test(spec)) return 'file'
|
||||
if (/^(?:git(?:\+[^:]+)?|github|gitlab|bitbucket):/i.test(spec) || /\.git(?:#|$)/i.test(spec)) return 'git'
|
||||
if (/^https?:/i.test(spec)) return 'url'
|
||||
if (/^npm:/i.test(spec) || /^(?:[~^<>=*]|\d|v\d)/i.test(spec) || spec === 'latest' || spec === 'next') return 'registry'
|
||||
return 'other'
|
||||
}
|
||||
|
||||
function updateRevision(hash, label, file) {
|
||||
hash.update(`${label}:${file.present ? '1' : '0'}:${String(file.bytes.length)}:`)
|
||||
hash.update(file.bytes)
|
||||
hash.update('\0')
|
||||
}
|
||||
|
||||
function metadataFailure(error) {
|
||||
if (error?.code === 'ENOENT') return 'installed package metadata is missing'
|
||||
if (error instanceof SyntaxError) return 'installed package metadata is not valid JSON'
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
|
||||
async function inspectDependency(profileRoot, name, spec, enabledBundles, disabled, reservedNames) {
|
||||
const path = packagePath(profileRoot, name)
|
||||
let metadata
|
||||
let metadataError = null
|
||||
if (path === undefined) {
|
||||
metadataError = 'dependency name is invalid'
|
||||
} else {
|
||||
try {
|
||||
metadata = JSON.parse(await readFile(path, 'utf8'))
|
||||
if (metadata === null || typeof metadata !== 'object' || Array.isArray(metadata)) {
|
||||
throw new Error('installed package metadata must be an object')
|
||||
}
|
||||
if (metadata.name !== name) throw new Error(`installed package name is ${String(metadata.name)}`)
|
||||
} catch (error) {
|
||||
metadataError = metadataFailure(error)
|
||||
}
|
||||
}
|
||||
if (metadataError === null && metadata.version !== undefined
|
||||
&& (typeof metadata.version !== 'string' || metadata.version.length === 0)) {
|
||||
metadataError = 'installed package version is invalid'
|
||||
}
|
||||
const bundleDeclaration = metadata?.dsh?.bundle
|
||||
const patch = bundleDeclaration?.patch
|
||||
const isBundle = typeof patch === 'string' && patch.length > 0
|
||||
const reservedNameConflict = reservedNames.has(name)
|
||||
if (metadataError === null && !isBundle
|
||||
&& (bundleDeclaration !== undefined || enabledBundles.has(name))) {
|
||||
metadataError = 'installed package Bundle metadata is invalid'
|
||||
}
|
||||
if (!isBundle && metadataError === null && !reservedNameConflict) return undefined
|
||||
return Object.freeze({
|
||||
name,
|
||||
spec,
|
||||
source: sourceForSpec(spec),
|
||||
version: typeof metadata?.version === 'string' ? metadata.version : null,
|
||||
description: typeof metadata?.description === 'string' && metadata.description.trim() !== '' ? metadata.description.trim() : null,
|
||||
enabled: !reservedNameConflict && enabledBundles.has(name),
|
||||
previousIndex: disabled.get(name) ?? null,
|
||||
damaged: metadataError !== null,
|
||||
metadataError,
|
||||
reservedNameConflict,
|
||||
})
|
||||
}
|
||||
|
||||
function loadedIdentity(plugin) {
|
||||
return JSON.stringify([
|
||||
plugin.name, plugin.spec, plugin.version, plugin.enabled, plugin.damaged,
|
||||
plugin.metadataError, plugin.reservedNameConflict,
|
||||
])
|
||||
}
|
||||
|
||||
export function markUserPluginRestartState(current, loaded) {
|
||||
if (loaded === undefined) return current
|
||||
const loadedPlugins = new Map(loaded.plugins.map(plugin => [plugin.name, loadedIdentity(plugin)]))
|
||||
const plugins = current.plugins.map(plugin => Object.freeze({
|
||||
...plugin,
|
||||
pendingRestart: loadedPlugins.get(plugin.name) !== loadedIdentity(plugin),
|
||||
}))
|
||||
const currentNames = new Set(current.plugins.map(plugin => plugin.name))
|
||||
const removedLoadedPlugin = loaded.plugins.some(plugin => !currentNames.has(plugin.name))
|
||||
return Object.freeze({
|
||||
...current,
|
||||
plugins: Object.freeze(plugins),
|
||||
restartRequired: removedLoadedPlugin || plugins.some(plugin => plugin.pendingRestart),
|
||||
})
|
||||
}
|
||||
|
||||
export class UserPluginInventory {
|
||||
constructor({ dshHome = '/data/dsh', profile = 'web', selectionPath, systemPluginNames = async () => [] } = {}) {
|
||||
if (profile !== 'web') throw new Error('Only the web DSH Profile is supported')
|
||||
this.profile = profile
|
||||
this.profileRoot = join(resolve(dshHome), 'profiles', profile)
|
||||
this.selectionPath = selectionPath === undefined
|
||||
? join(resolve(dshHome), '..', 'platform', 'state', 'management', 'user-plugins.json')
|
||||
: resolve(selectionPath)
|
||||
this.systemPluginNames = systemPluginNames
|
||||
}
|
||||
|
||||
async read() {
|
||||
const manifestPath = join(this.profileRoot, 'package.json')
|
||||
const lockfilePath = join(this.profileRoot, 'pnpm-lock.yaml')
|
||||
const [manifestBytes, lockfile, selectionFile, names] = await Promise.all([
|
||||
readFile(manifestPath),
|
||||
optionalBytes(lockfilePath),
|
||||
optionalBytes(this.selectionPath),
|
||||
this.systemPluginNames(),
|
||||
])
|
||||
const manifest = parseObject(manifestBytes, 'Web Profile package.json')
|
||||
const { dependencies, bundles } = profileFields(manifest)
|
||||
const selection = !selectionFile.present
|
||||
? undefined
|
||||
: parseObject(selectionFile.bytes, 'User Plugin selection state')
|
||||
const disabled = disabledPositions(selection)
|
||||
const reservedNames = new Set(names)
|
||||
if ([...reservedNames].some(name => typeof name !== 'string' || !PACKAGE_NAME_PATTERN.test(name))) {
|
||||
throw new Error('Verified System Plugin names are invalid')
|
||||
}
|
||||
const enabledBundles = new Set(bundles)
|
||||
const inspected = await Promise.all(Object.entries(dependencies).map(([name, spec]) => (
|
||||
inspectDependency(this.profileRoot, name, spec, enabledBundles, disabled, reservedNames)
|
||||
)))
|
||||
const revisionHash = createHash('sha256')
|
||||
updateRevision(revisionHash, 'manifest', { present: true, bytes: manifestBytes })
|
||||
updateRevision(revisionHash, 'lockfile', lockfile)
|
||||
updateRevision(revisionHash, 'selection', selectionFile)
|
||||
const revision = revisionHash.digest('hex')
|
||||
return Object.freeze({
|
||||
schema: 1,
|
||||
profile: this.profile,
|
||||
revision: `sha256:${revision}`,
|
||||
plugins: Object.freeze(inspected.filter(Boolean)),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const userPluginInternals = Object.freeze({ PACKAGE_NAME_PATTERN, sourceForSpec, loadedIdentity })
|
||||
Reference in new issue
Block a user