134 lines
5.8 KiB
JavaScript
134 lines
5.8 KiB
JavaScript
import { lstatSync, readFileSync, writeFileSync, openSync, closeSync, fsyncSync, renameSync, unlinkSync } from 'node:fs';
|
|
import { dirname, join, resolve, basename } from 'node:path';
|
|
import { randomUUID } from 'node:crypto';
|
|
|
|
export const BUILTIN_DEPENDENCIES = Object.freeze({
|
|
'dsh-preview-download': 'file:/usr/local/lib/dsh-mgr/dsh-preview-download-1.0.1.tgz',
|
|
'dsh-chat-manager': 'file:/usr/local/lib/dsh-mgr/dsh-chat-manager-1.5.16-dsh-mgr.1.tgz',
|
|
'dsh-mgr-workspace': 'file:/usr/local/lib/dsh-mgr/dsh-mgr-workspace-1.0.0.tgz',
|
|
'dsh-web-mobile': 'file:/usr/local/lib/dsh-mgr/dsh-web-mobile-3.1.0.tgz',
|
|
'dsh-free-search': 'file:/usr/local/lib/dsh-mgr/dsh-free-search-0.9.2.tgz',
|
|
});
|
|
export const BUILTIN_OVERRIDES = Object.freeze({
|
|
'dsh-chat-manager>clsx': 'file:/usr/local/lib/dsh-mgr/clsx-2.1.1.tgz',
|
|
'dsh-free-search>@deepseek-ai/schemastery': 'file:/usr/local/lib/dsh-mgr/schemastery-3.18.4.tgz',
|
|
'@deepseek-ai/schemastery>@deepseek-ai/cosmokit': 'file:/usr/local/lib/dsh-mgr/cosmokit-1.8.5.tgz',
|
|
'@deepseek-ai/schemastery>@standard-schema/spec': 'file:/usr/local/lib/dsh-mgr/spec-1.1.0.tgz',
|
|
});
|
|
const sections = ['dependencies', 'devDependencies', 'optionalDependencies'];
|
|
export const LOCKFILE_PENDING = '.dsh-lockfile-pending';
|
|
|
|
export function markLockfilePending(profile) {
|
|
assertSafeProfile(profile);
|
|
const fd = openSync(join(profile, LOCKFILE_PENDING), 'w', 0o600);
|
|
try { writeFileSync(fd, 'builtin profile migration\n'); fsyncSync(fd); }
|
|
finally { closeSync(fd); }
|
|
const directoryFd = openSync(profile, 'r');
|
|
try { fsyncSync(directoryFd); } finally { closeSync(directoryFd); }
|
|
}
|
|
const overrideOwners = ['dsh-chat-manager', 'dsh-free-search', 'dsh-free-search', 'dsh-free-search'];
|
|
const semver = '(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)(?:-(?:0|[1-9]\\d*|\\d*[A-Za-z-][0-9A-Za-z-]*)(?:\\.(?:0|[1-9]\\d*|\\d*[A-Za-z-][0-9A-Za-z-]*))*)?(?:\\+[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?';
|
|
|
|
function object(value, label) {
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error(`invalid ${label}`);
|
|
}
|
|
|
|
export function reconcileManifest(input) {
|
|
object(input, 'manifest');
|
|
const manifest = structuredClone(input);
|
|
const active = new Set();
|
|
const archives = new Set();
|
|
let changed = false;
|
|
for (const section of sections) {
|
|
if (manifest[section] === undefined) continue;
|
|
object(manifest[section], section);
|
|
for (const [name, target] of Object.entries(BUILTIN_DEPENDENCIES)) {
|
|
const current = manifest[section][name];
|
|
if (typeof current !== 'string' || !new RegExp(`^file:/usr/local/lib/dsh-mgr/${name}-${semver}\\.tgz$`).test(current)) continue;
|
|
active.add(name);
|
|
archives.add(target);
|
|
if (current !== target) {
|
|
manifest[section][name] = target;
|
|
changed = true;
|
|
}
|
|
}
|
|
}
|
|
if (manifest.pnpm !== undefined) {
|
|
object(manifest.pnpm, 'pnpm');
|
|
if (manifest.pnpm.overrides !== undefined) object(manifest.pnpm.overrides, 'pnpm.overrides');
|
|
}
|
|
Object.entries(BUILTIN_OVERRIDES).forEach(([key, target], index) => {
|
|
if (!active.has(overrideOwners[index])) return;
|
|
manifest.pnpm ??= {};
|
|
manifest.pnpm.overrides ??= {};
|
|
if (!Object.hasOwn(manifest.pnpm.overrides, key)) {
|
|
manifest.pnpm.overrides[key] = target;
|
|
changed = true;
|
|
}
|
|
if (manifest.pnpm.overrides[key] === target) archives.add(target);
|
|
});
|
|
return { manifest, changed, archives: [...archives] };
|
|
}
|
|
|
|
function statIfPresent(path) {
|
|
try { return lstatSync(path); } catch (error) {
|
|
if (error.code !== 'ENOENT') throw error;
|
|
}
|
|
}
|
|
|
|
export function assertSafeProfile(profile) {
|
|
for (let dir = resolve(profile); ; dir = dirname(dir)) {
|
|
const stat = statIfPresent(dir);
|
|
if (stat && (!stat.isDirectory() || stat.isSymbolicLink())) throw new Error(`unsafe profile directory: ${dir}`);
|
|
if (dirname(dir) === dir) break;
|
|
}
|
|
for (const name of ['package.json', 'pnpm-workspace.yaml', 'pnpm-lock.yaml', LOCKFILE_PENDING]) {
|
|
const path = join(profile, name);
|
|
const stat = statIfPresent(path);
|
|
if (stat && (!stat.isFile() || stat.isSymbolicLink())) throw new Error(`unsafe profile file: ${path}`);
|
|
}
|
|
}
|
|
|
|
export function reconcileProfile(profile, { archiveDirectory = '/usr/local/lib/dsh-mgr' } = {}) {
|
|
assertSafeProfile(profile);
|
|
const path = join(profile, 'package.json');
|
|
if (!statIfPresent(path)) return { changed: false };
|
|
const original = readFileSync(path);
|
|
const result = reconcileManifest(JSON.parse(original.toString('utf8')));
|
|
for (const reference of result.archives) {
|
|
const archive = join(archiveDirectory, basename(reference.slice(5)));
|
|
const stat = lstatSync(archive);
|
|
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error(`unsafe builtin archive: ${archive}`);
|
|
}
|
|
if (!result.changed) return { changed: false };
|
|
const backupPath = `${path}.backup-${randomUUID()}`;
|
|
const temporaryPath = `${path}.tmp-${randomUUID()}`;
|
|
let temporaryCreated = false;
|
|
try {
|
|
const backupFd = openSync(backupPath, 'wx', 0o600);
|
|
try { writeFileSync(backupFd, original); fsyncSync(backupFd); }
|
|
finally { closeSync(backupFd); }
|
|
const syncDirectory = () => {
|
|
const directoryFd = openSync(profile, 'r');
|
|
try { fsyncSync(directoryFd); } finally { closeSync(directoryFd); }
|
|
};
|
|
// Make the recovery copy durable before replacing the active manifest.
|
|
syncDirectory();
|
|
const fd = openSync(temporaryPath, 'wx', 0o600);
|
|
temporaryCreated = true;
|
|
try {
|
|
writeFileSync(fd, JSON.stringify(result.manifest, null, 2) + '\n');
|
|
fsyncSync(fd);
|
|
} finally { closeSync(fd); }
|
|
assertSafeProfile(profile);
|
|
// Persist before changing the manifest, including across failed installs/restarts.
|
|
markLockfilePending(profile);
|
|
renameSync(temporaryPath, path);
|
|
temporaryCreated = false;
|
|
syncDirectory();
|
|
} finally {
|
|
if (temporaryCreated) unlinkSync(temporaryPath);
|
|
}
|
|
return { changed: true, backupPath };
|
|
}
|