feat: integrate learning module
This commit is contained in:
@@ -2,6 +2,13 @@ const { execFileSync } = require('node:child_process');
|
||||
const { join } = require('node:path');
|
||||
|
||||
exports.default = async function beforePack() {
|
||||
execFileSync(process.execPath, [
|
||||
join(__dirname, 'verify-learning-player-artifact.mjs'),
|
||||
join(__dirname, '..', 'build', 'learning-player'),
|
||||
], {
|
||||
cwd: join(__dirname, '..'),
|
||||
stdio: 'inherit',
|
||||
});
|
||||
execFileSync(process.execPath, [join(__dirname, 'prepare-publish-runtime.mjs')], {
|
||||
cwd: join(__dirname, '..'),
|
||||
stdio: 'inherit',
|
||||
|
||||
62
scripts/download-learning-player-artifact.mjs
Normal file
62
scripts/download-learning-player-artifact.mjs
Normal file
@@ -0,0 +1,62 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { appendFile, cp, mkdir, readdir, rm } from 'node:fs/promises';
|
||||
import { resolve } from 'node:path';
|
||||
import AdmZip from 'adm-zip';
|
||||
import { validateLearningPlayerArtifact } from './learning-player-artifact.mjs';
|
||||
|
||||
const artifactUrl = process.env.LEARNING_PLAYER_ARTIFACT_URL?.trim();
|
||||
const expectedSha256 = process.env.LEARNING_PLAYER_ARTIFACT_SHA256?.trim().toLowerCase();
|
||||
if (!artifactUrl || !/^https:\/\//i.test(artifactUrl)) {
|
||||
throw new Error('LEARNING_PLAYER_ARTIFACT_URL must be a versioned HTTPS artifact URL');
|
||||
}
|
||||
if (!expectedSha256 || !/^[0-9a-f]{64}$/.test(expectedSha256)) {
|
||||
throw new Error('LEARNING_PLAYER_ARTIFACT_SHA256 must be the pinned artifact digest');
|
||||
}
|
||||
|
||||
const response = await fetch(artifactUrl, { redirect: 'follow' });
|
||||
if (!response.ok) throw new Error(`Learning player download failed (HTTP ${response.status})`);
|
||||
const archive = Buffer.from(await response.arrayBuffer());
|
||||
const actualSha256 = createHash('sha256').update(archive).digest('hex');
|
||||
if (actualSha256 !== expectedSha256) {
|
||||
throw new Error(`Learning player archive digest mismatch: expected ${expectedSha256}, got ${actualSha256}`);
|
||||
}
|
||||
|
||||
const root = resolve(import.meta.dirname, '..');
|
||||
const extractionRoot = resolve(root, 'build', '.learning-player-download');
|
||||
const outputRoot = resolve(root, 'build', 'learning-player-source');
|
||||
await Promise.all([
|
||||
rm(extractionRoot, { recursive: true, force: true }),
|
||||
rm(outputRoot, { recursive: true, force: true }),
|
||||
]);
|
||||
await mkdir(extractionRoot, { recursive: true });
|
||||
const zip = new AdmZip(archive);
|
||||
for (const entry of zip.getEntries()) {
|
||||
const normalized = entry.entryName.replace(/\\/g, '/');
|
||||
const parts = normalized.split('/');
|
||||
if (normalized.startsWith('/') || /^[A-Za-z]:\//.test(normalized) || parts.includes('..')) {
|
||||
throw new Error('Learning player archive contains an unsafe path');
|
||||
}
|
||||
}
|
||||
zip.extractAllTo(extractionRoot, true);
|
||||
|
||||
async function findArtifactRoots(directory, depth = 0) {
|
||||
const entries = await readdir(directory, { withFileTypes: true });
|
||||
if (entries.some((entry) => entry.isFile() && entry.name === 'artifact.json')) return [directory];
|
||||
if (depth >= 2) return [];
|
||||
const nested = await Promise.all(entries
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => findArtifactRoots(resolve(directory, entry.name), depth + 1)));
|
||||
return nested.flat();
|
||||
}
|
||||
|
||||
const candidates = await findArtifactRoots(extractionRoot);
|
||||
if (candidates.length !== 1) throw new Error('Learning player archive must contain exactly one artifact.json root');
|
||||
await validateLearningPlayerArtifact(candidates[0]);
|
||||
await cp(candidates[0], outputRoot, { recursive: true });
|
||||
await validateLearningPlayerArtifact(outputRoot);
|
||||
await rm(extractionRoot, { recursive: true, force: true });
|
||||
|
||||
if (process.env.GITHUB_ENV) {
|
||||
await appendFile(process.env.GITHUB_ENV, `MAKELORE_LEARNING_PLAYER_ARTIFACT=${outputRoot}\n`);
|
||||
}
|
||||
process.stdout.write(`Downloaded verified learning player ${actualSha256} to ${outputRoot}\n`);
|
||||
46
scripts/learning-player-artifact.mjs
Normal file
46
scripts/learning-player-artifact.mjs
Normal file
@@ -0,0 +1,46 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { readdir, readFile, stat } from 'node:fs/promises';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
async function hasFiles(directory) {
|
||||
const entries = await readdir(directory, { withFileTypes: true }).catch(() => []);
|
||||
for (const entry of entries) {
|
||||
if (entry.isFile()) return true;
|
||||
if (entry.isDirectory() && await hasFiles(resolve(directory, entry.name))) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function validateLearningPlayerArtifact(directory) {
|
||||
const root = resolve(directory);
|
||||
const [artifactText, html] = await Promise.all([
|
||||
readFile(resolve(root, 'artifact.json'), 'utf8'),
|
||||
readFile(resolve(root, 'index.html')),
|
||||
]).catch((error) => {
|
||||
throw new Error(`Invalid learning player artifact at ${root}: ${error.message}`);
|
||||
});
|
||||
const artifact = JSON.parse(artifactText);
|
||||
if (artifact.schemaVersion !== 1
|
||||
|| artifact.entrypoint !== 'index.html'
|
||||
|| typeof artifact.htmlSha256 !== 'string'
|
||||
|| !/^[0-9a-f]{64}$/.test(artifact.htmlSha256)) {
|
||||
throw new Error(`Invalid learning player artifact manifest at ${root}`);
|
||||
}
|
||||
const htmlSha256 = createHash('sha256').update(html).digest('hex');
|
||||
if (htmlSha256 !== artifact.htmlSha256) {
|
||||
throw new Error(`Learning player index hash mismatch at ${root}`);
|
||||
}
|
||||
if (!(await stat(resolve(root, '_next', 'static')).then((entry) => entry.isDirectory()).catch(() => false))
|
||||
|| !await hasFiles(resolve(root, '_next', 'static'))) {
|
||||
throw new Error(`Learning player static assets are missing at ${root}`);
|
||||
}
|
||||
if (!(await stat(resolve(root, 'avatars')).then((entry) => entry.isDirectory()).catch(() => false))
|
||||
|| !await hasFiles(resolve(root, 'avatars'))) {
|
||||
throw new Error(`Learning player avatars are missing at ${root}`);
|
||||
}
|
||||
if (!(await stat(resolve(root, 'public')).then((entry) => entry.isDirectory()).catch(() => false))
|
||||
|| !await hasFiles(resolve(root, 'public'))) {
|
||||
throw new Error(`Learning player public assets are missing at ${root}`);
|
||||
}
|
||||
return { root, artifact, htmlSha256 };
|
||||
}
|
||||
18
scripts/learning-player-build-manifest.json
Normal file
18
scripts/learning-player-build-manifest.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"artifact": {
|
||||
"manifest": "artifact.json",
|
||||
"entrypoint": "index.html",
|
||||
"requiredDirectories": ["_next/static", "avatars", "public"]
|
||||
},
|
||||
"localSource": {
|
||||
"environmentVariable": "MAKELORE_LEARNING_SOURCE",
|
||||
"defaultRelativePath": "../麦洛学习/OpenMAIC",
|
||||
"requiredScripts": ["build", "build:makelore-player"]
|
||||
},
|
||||
"release": {
|
||||
"artifactEnvironmentVariable": "MAKELORE_LEARNING_PLAYER_ARTIFACT",
|
||||
"downloadUrlSecret": "LEARNING_PLAYER_ARTIFACT_URL",
|
||||
"sha256Secret": "LEARNING_PLAYER_ARTIFACT_SHA256"
|
||||
}
|
||||
}
|
||||
66
scripts/sync-learning-player.mjs
Normal file
66
scripts/sync-learning-player.mjs
Normal file
@@ -0,0 +1,66 @@
|
||||
import { cp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { resolve } from 'node:path';
|
||||
import { validateLearningPlayerArtifact } from './learning-player-artifact.mjs';
|
||||
|
||||
const makeloreRoot = resolve(import.meta.dirname, '..');
|
||||
const outputRoot = resolve(makeloreRoot, 'build', 'learning-player');
|
||||
const buildManifest = JSON.parse(await readFile(
|
||||
resolve(import.meta.dirname, 'learning-player-build-manifest.json'),
|
||||
'utf8',
|
||||
));
|
||||
|
||||
function run(command, args, cwd) {
|
||||
return new Promise((resolveRun, rejectRun) => {
|
||||
const child = spawn(command, args, { cwd, stdio: 'inherit', shell: process.platform === 'win32' });
|
||||
child.once('error', rejectRun);
|
||||
child.once('exit', (code, signal) => {
|
||||
if (code === 0) resolveRun();
|
||||
else rejectRun(new Error(`${command} ${args.join(' ')} failed (${signal || code})`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function buildLocalFallback() {
|
||||
if (process.env.CI || process.env.MAKELORE_RELEASE_BUILD === '1') {
|
||||
throw new Error(
|
||||
'Release builds require MAKELORE_LEARNING_PLAYER_ARTIFACT. Download and verify the versioned OpenMAIC player before packaging.',
|
||||
);
|
||||
}
|
||||
const learningRoot = resolve(
|
||||
process.env.MAKELORE_LEARNING_SOURCE
|
||||
|| resolve(makeloreRoot, buildManifest.localSource.defaultRelativePath),
|
||||
);
|
||||
const packageJson = JSON.parse(await readFile(resolve(learningRoot, 'package.json'), 'utf8').catch(() => {
|
||||
throw new Error(`OpenMAIC source checkout is unavailable at ${learningRoot}`);
|
||||
}));
|
||||
for (const script of buildManifest.localSource.requiredScripts) {
|
||||
if (typeof packageJson.scripts?.[script] !== 'string') {
|
||||
throw new Error(`OpenMAIC source is missing required script: ${script}`);
|
||||
}
|
||||
}
|
||||
const temporaryOutput = resolve(makeloreRoot, 'build', '.learning-player-local');
|
||||
await rm(temporaryOutput, { recursive: true, force: true });
|
||||
await run('pnpm', ['run', 'build'], learningRoot);
|
||||
await run('pnpm', ['run', 'build:makelore-player', '--', temporaryOutput], learningRoot);
|
||||
return temporaryOutput;
|
||||
}
|
||||
|
||||
const explicitArtifact = process.env.MAKELORE_LEARNING_PLAYER_ARTIFACT?.trim();
|
||||
const sourceRoot = explicitArtifact ? resolve(explicitArtifact) : await buildLocalFallback();
|
||||
if (sourceRoot === outputRoot) {
|
||||
throw new Error('MAKELORE_LEARNING_PLAYER_ARTIFACT must not point at build/learning-player');
|
||||
}
|
||||
const verified = await validateLearningPlayerArtifact(sourceRoot);
|
||||
|
||||
await rm(outputRoot, { recursive: true, force: true });
|
||||
await mkdir(resolve(outputRoot, '..'), { recursive: true });
|
||||
await cp(verified.root, outputRoot, { recursive: true });
|
||||
await writeFile(resolve(outputRoot, 'makelore-build.json'), JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
sourceKind: explicitArtifact ? 'versioned-artifact' : 'local-source-build',
|
||||
htmlSha256: verified.htmlSha256,
|
||||
}, null, 2));
|
||||
await validateLearningPlayerArtifact(outputRoot);
|
||||
|
||||
process.stdout.write(`Synced verified OpenMAIC player to ${outputRoot}\n`);
|
||||
6
scripts/verify-learning-player-artifact.mjs
Normal file
6
scripts/verify-learning-player-artifact.mjs
Normal file
@@ -0,0 +1,6 @@
|
||||
import { resolve } from 'node:path';
|
||||
import { validateLearningPlayerArtifact } from './learning-player-artifact.mjs';
|
||||
|
||||
const root = resolve(process.argv[2] || 'build/learning-player');
|
||||
const result = await validateLearningPlayerArtifact(root);
|
||||
process.stdout.write(`Verified Makelore learning player ${result.htmlSha256} at ${result.root}\n`);
|
||||
Reference in New Issue
Block a user