663 lines
29 KiB
JavaScript
663 lines
29 KiB
JavaScript
#!/usr/bin/env zx
|
|
|
|
import 'zx/globals';
|
|
import { readFileSync, existsSync, mkdirSync, rmSync, cpSync, writeFileSync, readdirSync, statSync } from 'node:fs';
|
|
import { join, dirname, basename } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
const ROOT = join(__dirname, '..');
|
|
const MANIFEST_PATH = join(ROOT, 'resources', 'skills', 'preinstalled-manifest.json');
|
|
const OUTPUT_ROOT = join(ROOT, 'build', 'preinstalled-skills');
|
|
const TMP_ROOT = join(ROOT, 'build', '.tmp-preinstalled-skills');
|
|
|
|
function loadManifest() {
|
|
if (!existsSync(MANIFEST_PATH)) {
|
|
throw new Error(`Missing manifest: ${MANIFEST_PATH}`);
|
|
}
|
|
const raw = readFileSync(MANIFEST_PATH, 'utf8');
|
|
const parsed = JSON.parse(raw);
|
|
if (!parsed || !Array.isArray(parsed.skills)) {
|
|
throw new Error('Invalid preinstalled-skills manifest format');
|
|
}
|
|
for (const item of parsed.skills) {
|
|
const hasRemoteSource = Boolean(item.repo && item.repoPath);
|
|
const hasLocalSource = Boolean(item.localPath);
|
|
if (!item.slug || (hasRemoteSource === hasLocalSource)) {
|
|
throw new Error(`Invalid manifest entry: ${JSON.stringify(item)}`);
|
|
}
|
|
}
|
|
return parsed.skills;
|
|
}
|
|
|
|
function groupByRepoRef(entries) {
|
|
const grouped = new Map();
|
|
for (const entry of entries) {
|
|
if (entry.localPath) continue;
|
|
const ref = entry.ref || 'main';
|
|
const key = `${entry.repo}#${ref}`;
|
|
if (!grouped.has(key)) grouped.set(key, { repo: entry.repo, ref, entries: [] });
|
|
grouped.get(key).entries.push(entry);
|
|
}
|
|
return [...grouped.values()];
|
|
}
|
|
|
|
function createRepoDirName(repo, ref) {
|
|
return `${repo.replace(/[\\/]/g, '__')}__${ref.replace(/[^a-zA-Z0-9._-]/g, '_')}`;
|
|
}
|
|
|
|
function toGitPath(inputPath) {
|
|
if (process.platform !== 'win32') return inputPath;
|
|
// Git on Windows accepts forward slashes and avoids backslash escape quirks.
|
|
return inputPath.replace(/\\/g, '/');
|
|
}
|
|
|
|
function normalizeRepoPath(repoPath) {
|
|
return repoPath.replace(/\\/g, '/').replace(/^\/+/, '').replace(/\/+$/, '');
|
|
}
|
|
|
|
function shouldCopySkillFile(srcPath) {
|
|
const base = basename(srcPath);
|
|
if (base === '.git') return false;
|
|
if (base === '.subset.tar') return false;
|
|
return true;
|
|
}
|
|
|
|
function toYamlString(value) {
|
|
if (/^[a-zA-Z0-9_-]+$/.test(value)) return value;
|
|
return JSON.stringify(value);
|
|
}
|
|
|
|
function rewriteSkillName(skillManifest, name) {
|
|
const raw = readFileSync(skillManifest, 'utf8');
|
|
const frontmatterMatch = raw.match(/^---\n([\s\S]*?)\n---/);
|
|
if (!frontmatterMatch) return;
|
|
|
|
const body = frontmatterMatch[1];
|
|
const nextBody = /^name:\s*.*$/m.test(body)
|
|
? body.replace(/^name:\s*.*$/m, `name: ${toYamlString(name)}`)
|
|
: `name: ${toYamlString(name)}\n${body}`;
|
|
const next = raw.replace(/^---\n[\s\S]*?\n---/, `---\n${nextBody}\n---`);
|
|
if (next !== raw) writeFileSync(skillManifest, next, 'utf8');
|
|
}
|
|
|
|
const runtimePolicies = new Map([
|
|
['docx', `## Zhinian Runtime Policy
|
|
|
|
- Do not ask the user to install any desktop office suite or GUI converter.
|
|
- For normal .docx creation/editing, use the OpenXML/.NET workflow in this skill.
|
|
- If the .NET runtime is unavailable, run the skill setup/repair path silently where possible; report a clear environment limitation only if document generation is blocked.
|
|
- If optional .doc conversion or visual preview requires an unavailable desktop converter, skip that optional step and continue. Mention the limitation only when it directly blocks the requested output.
|
|
`],
|
|
['xlsx', `## Zhinian Runtime Policy
|
|
|
|
- Do not ask the user to install any desktop office suite or GUI converter.
|
|
- Use the XML/static validation workflow as the default path.
|
|
- When running Python scripts, prefer \`uv run python\`. If a Python dependency is missing, use \`uv pip install\` instead of asking the user to install it manually.
|
|
- Do not perform desktop-engine formula recalculation in the Zhinian bundled runtime; use static validation with \`formula_check.py\`.
|
|
`],
|
|
['pptx', `## Zhinian Runtime Policy
|
|
|
|
- Do not ask the user to install any desktop office suite or GUI converter.
|
|
- Use the PptxGenJS/XML workflows in this skill as the default path.
|
|
- Use the project-bundled Node/npm modules when available. Do not ask the user to run global npm installs for \`pptxgenjs\`, \`react-icons\`, \`react\`, \`react-dom\`, or \`sharp\`.
|
|
- If optional preview/conversion tools are unavailable, skip those optional steps and continue. Mention the limitation only when it directly blocks the requested output.
|
|
`],
|
|
['pdf', `## Zhinian Runtime Policy
|
|
|
|
- Do not ask the user to install desktop office suites or unrelated external GUI tools.
|
|
- Use the Python/HTML/PDF workflows in this skill and keep outputs in project-accessible local paths.
|
|
- When running Python scripts, prefer \`uv run python\`. If a Python dependency is missing, use \`uv pip install\` instead of asking the user to install it manually.
|
|
- If an optional renderer is unavailable, choose another supported path or report the limitation only when it directly blocks the requested output.
|
|
`],
|
|
]);
|
|
|
|
function insertAfterFrontmatter(raw, content) {
|
|
const frontmatterMatch = raw.match(/^---\n[\s\S]*?\n---\n?/);
|
|
if (!frontmatterMatch) {
|
|
return `${content}\n${raw}`;
|
|
}
|
|
const index = frontmatterMatch[0].length;
|
|
return `${raw.slice(0, index)}${content}\n${raw.slice(index)}`;
|
|
}
|
|
|
|
function applyRuntimePolicy(skillManifest, slug) {
|
|
const policy = runtimePolicies.get(slug);
|
|
if (!policy) return;
|
|
|
|
const raw = readFileSync(skillManifest, 'utf8');
|
|
if (raw.includes('## Zhinian Runtime Policy')) return;
|
|
writeFileSync(skillManifest, insertAfterFrontmatter(raw, policy), 'utf8');
|
|
}
|
|
|
|
function replaceFileText(filePath, replacements) {
|
|
if (!existsSync(filePath)) return;
|
|
|
|
let next = readFileSync(filePath, 'utf8');
|
|
const original = next;
|
|
for (const [pattern, replacement] of replacements) {
|
|
next = next.replace(pattern, replacement);
|
|
}
|
|
if (next !== original) writeFileSync(filePath, next, 'utf8');
|
|
}
|
|
|
|
function listTextFiles(rootDir) {
|
|
if (!existsSync(rootDir)) return [];
|
|
const files = [];
|
|
const visit = (dir) => {
|
|
for (const entry of readdirSync(dir)) {
|
|
const fullPath = join(dir, entry);
|
|
const stats = statSync(fullPath);
|
|
if (stats.isDirectory()) {
|
|
visit(fullPath);
|
|
} else if (stats.isFile()) {
|
|
files.push(fullPath);
|
|
}
|
|
}
|
|
};
|
|
visit(rootDir);
|
|
return files;
|
|
}
|
|
|
|
function findMacAppLikeDirectories(rootDir) {
|
|
if (!existsSync(rootDir)) return [];
|
|
const hits = [];
|
|
const visit = (dir) => {
|
|
for (const entry of readdirSync(dir)) {
|
|
const fullPath = join(dir, entry);
|
|
const stats = statSync(fullPath);
|
|
if (!stats.isDirectory()) continue;
|
|
if (entry.endsWith('.app')) {
|
|
hits.push(fullPath);
|
|
}
|
|
visit(fullPath);
|
|
}
|
|
};
|
|
visit(rootDir);
|
|
return hits;
|
|
}
|
|
|
|
function assertNoMacAppLikeDirectories(rootDir) {
|
|
const hits = findMacAppLikeDirectories(rootDir);
|
|
if (!hits.length) return;
|
|
const formatted = hits
|
|
.map((hit) => ` - ${hit.replace(`${ROOT}/`, '')}`)
|
|
.join('\n');
|
|
throw new Error(`Preinstalled skills must not contain .app directories because macOS codesign treats them as app bundles:\n${formatted}`);
|
|
}
|
|
|
|
function sanitizeDesktopOfficeEngineTerms(targetDir, slug) {
|
|
if (!['docx', 'xlsx', 'pptx', 'pdf'].includes(slug)) return;
|
|
|
|
if (slug === 'xlsx') {
|
|
rmSync(join(targetDir, 'scripts', 'libreoffice_recalc.py'), { force: true });
|
|
rmSync(join(targetDir, 'recalc.py'), { force: true });
|
|
mkdirSync(join(targetDir, 'references'), { recursive: true });
|
|
writeFileSync(join(targetDir, 'references', 'validate.md'), `# XLSX Formula Validation
|
|
|
|
Use static validation only in the Zhinian bundled runtime.
|
|
|
|
## Standard Path
|
|
|
|
1. Run \`formula_check.py\` on the workbook.
|
|
2. Fix malformed formulas, missing references, circular references, or packaging errors reported by the script.
|
|
3. Repack the workbook when XML edits are needed.
|
|
4. Run \`formula_check.py\` again and deliver only after it exits successfully.
|
|
|
|
Do not ask the user to install desktop office suites or GUI converters. Do not run desktop-engine recalculation in the bundled runtime.
|
|
`, 'utf8');
|
|
writeFileSync(join(targetDir, 'formula_check.py'), `#!/usr/bin/env python3
|
|
import json
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
from openpyxl import load_workbook
|
|
|
|
EXCEL_ERRORS = ["#VALUE!", "#DIV/0!", "#REF!", "#NAME?", "#NULL!", "#NUM!", "#N/A"]
|
|
|
|
|
|
def scan_workbook(filename):
|
|
path = Path(filename)
|
|
if not path.exists():
|
|
return {"error": f"File {filename} does not exist"}
|
|
|
|
result = {
|
|
"status": "success",
|
|
"total_errors": 0,
|
|
"total_formulas": 0,
|
|
"error_summary": {},
|
|
}
|
|
|
|
wb_formulas = load_workbook(path, data_only=False)
|
|
try:
|
|
for sheet_name in wb_formulas.sheetnames:
|
|
sheet = wb_formulas[sheet_name]
|
|
for row in sheet.iter_rows():
|
|
for cell in row:
|
|
value = cell.value
|
|
if isinstance(value, str) and value.startswith("="):
|
|
result["total_formulas"] += 1
|
|
for error in EXCEL_ERRORS:
|
|
if error in value:
|
|
result["error_summary"].setdefault(error, {"count": 0, "locations": []})
|
|
result["error_summary"][error]["count"] += 1
|
|
result["error_summary"][error]["locations"].append(f"{sheet_name}!{cell.coordinate}")
|
|
result["total_errors"] += 1
|
|
break
|
|
finally:
|
|
wb_formulas.close()
|
|
|
|
wb_values = load_workbook(path, data_only=True)
|
|
try:
|
|
for sheet_name in wb_values.sheetnames:
|
|
sheet = wb_values[sheet_name]
|
|
for row in sheet.iter_rows():
|
|
for cell in row:
|
|
value = cell.value
|
|
if not isinstance(value, str):
|
|
continue
|
|
for error in EXCEL_ERRORS:
|
|
if error in value:
|
|
result["error_summary"].setdefault(error, {"count": 0, "locations": []})
|
|
result["error_summary"][error]["count"] += 1
|
|
result["error_summary"][error]["locations"].append(f"{sheet_name}!{cell.coordinate}")
|
|
result["total_errors"] += 1
|
|
break
|
|
finally:
|
|
wb_values.close()
|
|
|
|
if result["total_errors"] > 0:
|
|
result["status"] = "errors_found"
|
|
return result
|
|
|
|
|
|
def main():
|
|
if len(sys.argv) < 2:
|
|
print("Usage: python formula_check.py <excel_file>")
|
|
sys.exit(1)
|
|
print(json.dumps(scan_workbook(sys.argv[1]), ensure_ascii=False, indent=2))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|
|
`, 'utf8');
|
|
}
|
|
if (slug === 'docx') {
|
|
rmSync(join(targetDir, 'scripts', 'doc_to_docx.sh'), { force: true });
|
|
}
|
|
|
|
for (const filePath of listTextFiles(targetDir)) {
|
|
let raw;
|
|
try {
|
|
raw = readFileSync(filePath, 'utf8');
|
|
} catch {
|
|
continue;
|
|
}
|
|
if (raw.includes('\u0000')) continue;
|
|
|
|
let next = raw
|
|
.replace(/libreoffice_recalc\.py/g, 'formula_check.py')
|
|
.replace(/\brecalc\.py\b/g, 'formula_check.py')
|
|
.replace(/scripts\/doc_to_docx\.sh\s+input\.doc\s+output_dir\/?/g, 'legacy .doc conversion is not bundled; request or create a .docx file instead')
|
|
.replace(/Convert `?\.doc`?\s*→\s*`?\.docx`? if needed:[^\n]*/g, 'Legacy .doc conversion is not bundled. Ask for a .docx source or create a new .docx output.')
|
|
.replace(/Excel\/LibreOffice/g, 'Excel-compatible')
|
|
.replace(/Word\/LibreOffice/g, 'Word-compatible')
|
|
.replace(/LibreOffice/g, 'desktop office engine')
|
|
.replace(/libreoffice/g, 'desktop-office-engine')
|
|
.replace(/\bsoffice\b/g, 'desktop office converter')
|
|
.replace(/\bLibra\b/g, 'desktop office engine')
|
|
.replace(/\blibra\b/g, 'desktop-office-engine')
|
|
.replace(/desktop office engine/g, 'compatible viewer')
|
|
.replace(/desktop-office-engine/g, 'compatible-viewer')
|
|
.replace(/desktop office converter/g, 'legacy document converter')
|
|
.replace(/dynamic recalculation/g, 'runtime formula execution')
|
|
.replace(/headless recalculation/g, 'static validation')
|
|
.replace(/`sudo apt-get install pandoc`/g, 'use bundled text extraction when available')
|
|
.replace(/`sudo apt-get install poppler-utils`/g, 'use bundled PDF/image tooling when available')
|
|
.replace(/`sudo apt-get install compatible-viewer`/g, 'skip optional PDF conversion if unavailable')
|
|
.replace(/`npm install -g docx`/g, 'use the project-bundled `docx` module when available')
|
|
.replace(/`npm install -g pptxgenjs`/g, 'use the project-bundled `pptxgenjs` module')
|
|
.replace(/`npm install -g playwright`/g, 'use the project-bundled Playwright runtime when available')
|
|
.replace(/`npm install -g react-icons react react-dom`/g, 'use project-bundled `react-icons`, `react`, and `react-dom`')
|
|
.replace(/`npm install -g sharp`/g, 'use the project-bundled `sharp` module when available')
|
|
.replace(/`pip install defusedxml`/g, '`uv pip install defusedxml`')
|
|
.replace(/`pip install "markitdown\[pptx\]"`/g, '`uv pip install "markitdown[pptx]"`')
|
|
.replace(/# Requires: pip install ([^\n]+)/g, '# Requires: uv pip install $1')
|
|
.replace(/Assumes docx is already installed globally\s*If not installed: use the project-bundled `docx` module when available/g, 'Use the project-bundled `docx` module when available')
|
|
.replace(/Required dependencies \(install if not available\):/g, 'Runtime dependencies are managed by Zhinian when available:')
|
|
.replace(/Required dependencies \(should already be installed\):/g, 'Runtime dependencies are managed by Zhinian when available:');
|
|
|
|
if (slug === 'xlsx') {
|
|
next = next
|
|
.replace(/\*\*compatible viewer Required for Formula Recalculation\*\*:[^\n]*/g, '**Static formula validation is available in the bundled runtime**: Use `formula_check.py` to scan formulas and cached error values. Do not ask the user to install external office software.')
|
|
.replace(/Recalculating formulas/g, 'Static formula validation')
|
|
.replace(/Recalculate formulas/g, 'Validate formulas')
|
|
.replace(/Recalculates all formulas/g, 'Checks formulas')
|
|
.replace(/recalculate formulas/g, 'validate formulas')
|
|
.replace(/recalculation/g, 'static validation')
|
|
.replace(/recalculate again/g, 'validate again')
|
|
.replace(/Use `formula_check\.py` for dynamic recalculation when available\./g, 'Use `formula_check.py` for static validation.')
|
|
.replace(/Dynamic Validation \(desktop office engine Headless\)/g, 'Static Formula Validation')
|
|
.replace(/Tier 2 dynamic validation/g, 'Optional dynamic validation')
|
|
.replace(/Tier 2 — Dynamic Validation/g, 'Optional Dynamic Validation')
|
|
.replace(/Run `formula_check\.py` for static validation\. Use `formula_check\.py` for dynamic recalculation when available\./g, 'Run `formula_check.py` for static formula validation. Do not run desktop-engine recalculation in the bundled runtime.');
|
|
}
|
|
|
|
if (slug === 'docx') {
|
|
next = next
|
|
.replace(/install_soffice/g, 'skip_optional_doc_converter')
|
|
.replace(/soffice_ver/g, 'converter_ver')
|
|
.replace(/soffice_found/g, 'converter_found')
|
|
.replace(/\s*"\$SCRIPT_DIR\/doc_to_docx\.sh"\n/g, '')
|
|
.replace(/^\s*echo " desktop office converter:[^\n]*\n/gm, ' echo " legacy .doc conversion: not bundled (use .docx)"\n')
|
|
.replace(
|
|
/# --- Optional: desktop office engine ---[\s\S]*?(?=\n# --- Optional: zip\/unzip ---)/,
|
|
`# --- Legacy DOC Conversion ---
|
|
printf "[WARN] %-14s not bundled — use .docx files\\n" "legacy .doc conversion"
|
|
WARNINGS=$((WARNINGS + 1))
|
|
`,
|
|
);
|
|
}
|
|
|
|
if (next !== raw) writeFileSync(filePath, next, 'utf8');
|
|
}
|
|
}
|
|
|
|
function applyNoExternalOfficePrompts(targetDir, slug) {
|
|
if (slug === 'docx') {
|
|
replaceFileText(join(targetDir, 'scripts', 'setup.sh'), [
|
|
[
|
|
/# --- LibreOffice Installation \(Optional\) ---[\s\S]*?\n# --- zip\/unzip ---/,
|
|
`# --- Legacy DOC Conversion (Optional) ---
|
|
skip_optional_doc_converter() {
|
|
step "Checking legacy .doc conversion support"
|
|
warn "Legacy .doc conversion is not bundled; use .docx files in Zhinian runtime"
|
|
}
|
|
|
|
# --- zip/unzip ---`,
|
|
],
|
|
[/echo " --minimal Only install critical dependencies \(skip pandoc, soffice, fonts\)"/g, 'echo " --minimal Only install critical dependencies (skip optional tools)"'],
|
|
]);
|
|
replaceFileText(join(targetDir, 'scripts', 'setup.ps1'), [
|
|
[
|
|
/# --- LibreOffice \(Optional\) ---[\s\S]*?\n# --- NuGet Configuration ---/,
|
|
`# --- Legacy DOC Conversion (Optional) ---
|
|
if (-not $Minimal) {
|
|
Step "Checking legacy .doc conversion support"
|
|
Warn "Legacy .doc conversion is not bundled; use .docx files in Zhinian runtime"
|
|
}
|
|
|
|
# --- NuGet Configuration ---`,
|
|
],
|
|
[/-Minimal Only install critical dependencies \(skip pandoc, soffice, fonts\)/g, '-Minimal Only install critical dependencies (skip optional tools)'],
|
|
]);
|
|
replaceFileText(join(targetDir, 'scripts', 'env_check.sh'), [
|
|
[/echo " Install: brew install --cask libreoffice"/g, 'echo " Optional .doc conversion skipped in Zhinian runtime"'],
|
|
[/echo " Install: sudo apt-get install libreoffice-core"/g, 'echo " Optional .doc conversion skipped in Zhinian runtime"'],
|
|
[/echo " Install: winget install TheDocumentFoundation\.LibreOffice"/g, 'echo " Optional .doc conversion skipped in Zhinian runtime"'],
|
|
]);
|
|
replaceFileText(join(targetDir, 'scripts', 'doc_to_docx.sh'), [
|
|
[/echo "Install LibreOffice: brew install --cask libreoffice"/g, 'echo "Optional .doc conversion unavailable in Zhinian runtime; please provide a .docx file."'],
|
|
]);
|
|
}
|
|
|
|
if (slug === 'xlsx') {
|
|
replaceFileText(join(targetDir, 'scripts', 'xlsx_reader.py'), [
|
|
[/Run: pip install pandas openpyxl/g, 'Run: uv pip install pandas openpyxl'],
|
|
]);
|
|
replaceFileText(join(targetDir, 'references', 'validate.md'), [
|
|
[
|
|
/### Install LibreOffice \(if permitted in the environment\)[\s\S]*?\n### Run headless recalculation/,
|
|
`### Optional dynamic recalculation
|
|
|
|
Do not ask the user to install LibreOffice. If LibreOffice is not already available, record "Tier 2: SKIPPED — LibreOffice not available" and continue with Tier 1 static validation.
|
|
|
|
### Run headless recalculation`,
|
|
],
|
|
[
|
|
/\*\*If LibreOffice is not installed:\*\*[\s\S]*?\n\*\*If the script times out/,
|
|
`**If LibreOffice is not installed:**
|
|
|
|
Record "Tier 2: SKIPPED — LibreOffice not available" and continue. Do not provide installation commands to the user.
|
|
|
|
**If the script times out`,
|
|
],
|
|
]);
|
|
replaceFileText(join(targetDir, 'scripts', 'libreoffice_recalc.py'), [
|
|
[
|
|
/"LibreOffice not found\. Tier 2 validation is unavailable in this environment\. "\s*\n\s*"Install LibreOffice to enable dynamic formula recalculation\.\\n"\s*\n\s*" macOS: brew install --cask libreoffice\\n"\s*\n\s*" Linux: sudo apt-get install -y libreoffice"/,
|
|
`"LibreOffice not found. Tier 2 validation is skipped in Zhinian runtime. "`,
|
|
],
|
|
[/print\(" macOS: brew install --cask libreoffice"\)\n\s*print\(" Linux: sudo apt-get install -y libreoffice"\)/g, 'print("Tier 2 dynamic validation skipped; continue with static validation.")'],
|
|
]);
|
|
}
|
|
|
|
if (slug === 'pptx') {
|
|
replaceFileText(join(targetDir, 'SKILL.md'), [
|
|
[/`pip install "markitdown\[pptx\]"` — text extraction/g, '`uv pip install "markitdown[pptx]"` — text extraction when needed'],
|
|
[/`npm install -g pptxgenjs` — creating from scratch/g, 'Use the project-bundled `pptxgenjs` module for creating from scratch'],
|
|
[/`npm install -g react-icons react react-dom sharp` — icons \(optional\)/g, 'Use project-bundled `react-icons`, `react`, `react-dom`, and `sharp` when icons are needed'],
|
|
]);
|
|
replaceFileText(join(targetDir, 'references', 'pptxgenjs.md'), [
|
|
[/Install: `npm install -g react-icons react react-dom sharp`/g, 'Use project-bundled `react-icons`, `react`, `react-dom`, and `sharp`; do not ask the user to install them globally.'],
|
|
]);
|
|
}
|
|
|
|
if (slug === 'pdf') {
|
|
replaceFileText(join(targetDir, 'SKILL.md'), [
|
|
[/`pip install reportlab`/g, '`uv pip install reportlab`'],
|
|
[/`pip install pypdf`/g, '`uv pip install pypdf`'],
|
|
[/`npm install -g playwright && npx playwright install chromium`/g, 'use the project-bundled Playwright runtime when available'],
|
|
]);
|
|
replaceFileText(join(targetDir, 'README.md'), [
|
|
[/`pip install reportlab`/g, '`uv pip install reportlab`'],
|
|
[/`pip install pypdf`/g, '`uv pip install pypdf`'],
|
|
[/`npm install -g playwright && npx playwright install chromium`/g, 'optional; use project-bundled Playwright runtime when available'],
|
|
]);
|
|
replaceFileText(join(targetDir, 'scripts', 'make.sh'), [
|
|
[/pip install failed — try: pip install reportlab pypdf matplotlib/g, 'dependency install failed — try: uv pip install reportlab pypdf matplotlib'],
|
|
[/python3 -m pip install/g, 'uv pip install'],
|
|
[` # Playwright
|
|
if node -e "require('playwright')" 2>/dev/null || \\
|
|
node -e "require(require('child_process').execSync('npm root -g').toString().trim()+'/playwright')" 2>/dev/null; then
|
|
green " ✓ playwright"
|
|
else
|
|
yellow " ⚠ playwright not found (run: make.sh fix)"
|
|
ok=false
|
|
fi`, ` # Playwright is optional in Zhinian runtime.
|
|
if node -e "require('playwright')" 2>/dev/null || \\
|
|
node -e "require('playwright-core')" 2>/dev/null; then
|
|
green " ✓ playwright"
|
|
else
|
|
yellow " ⚠ playwright not found (optional cover rendering skipped)"
|
|
fi`],
|
|
[` # Playwright
|
|
if command -v npm &>/dev/null; then
|
|
npm install -g playwright --silent 2>/dev/null && \\
|
|
npx playwright install chromium --silent 2>/dev/null && \\
|
|
green " ✓ Playwright + Chromium installed" || \\
|
|
{ yellow " playwright install failed — try manually"; rc=3; }
|
|
else
|
|
yellow " npm not found — cannot install Playwright automatically"
|
|
rc=2
|
|
fi`, ` # Playwright is optional in Zhinian runtime. Do not install global npm packages.
|
|
yellow " Playwright cover rendering is optional; skipping global install"`],
|
|
]);
|
|
replaceFileText(join(targetDir, 'scripts', 'render_cover.js'), [
|
|
[
|
|
/\/\/ ── Playwright loader \(tolerates global npm installs\) ─────────────────────────[\s\S]*?\/\/ ── Main ───────────────────────────────────────────────────────────────────────/,
|
|
`// ── Playwright loader (Zhinian runtime) ─────────────────────────────────────
|
|
function loadPlaywright() {
|
|
try { return require("playwright"); } catch (_) {}
|
|
try { return require("playwright-core"); } catch (_) {}
|
|
console.error(JSON.stringify({
|
|
status: "error",
|
|
error: "playwright runtime not available",
|
|
hint: "Playwright cover rendering is optional in Zhinian runtime. Continue without the HTML cover or contact an administrator."
|
|
}));
|
|
process.exit(2);
|
|
}
|
|
|
|
// ── Main ───────────────────────────────────────────────────────────────────────`,
|
|
],
|
|
[
|
|
/ let browser;\n try {\n browser = await chromium\.launch\(\);\n } catch \(e\) {[\s\S]*? browser = await chromium\.launch\(\);\n }\n/,
|
|
` let browser;
|
|
try {
|
|
browser = await chromium.launch();
|
|
} catch (e) {
|
|
console.error(JSON.stringify({
|
|
status: "error",
|
|
error: "Chromium is not available for Playwright cover rendering",
|
|
hint: "Cover rendering is optional in Zhinian runtime. Continue without the HTML cover or contact an administrator."
|
|
}));
|
|
process.exit(2);
|
|
}
|
|
`,
|
|
],
|
|
]);
|
|
}
|
|
}
|
|
|
|
async function extractArchive(archiveFileName, cwd) {
|
|
const prevCwd = $.cwd;
|
|
$.cwd = cwd;
|
|
try {
|
|
try {
|
|
await $`tar -xf ${archiveFileName}`;
|
|
return;
|
|
} catch (tarError) {
|
|
if (process.platform === 'win32') {
|
|
// Some Windows images expose bsdtar instead of tar.
|
|
await $`bsdtar -xf ${archiveFileName}`;
|
|
return;
|
|
}
|
|
throw tarError;
|
|
}
|
|
} finally {
|
|
$.cwd = prevCwd;
|
|
}
|
|
}
|
|
|
|
async function fetchSparseRepo(repo, ref, paths, checkoutDir) {
|
|
const remote = `https://github.com/${repo}.git`;
|
|
mkdirSync(checkoutDir, { recursive: true });
|
|
const gitCheckoutDir = toGitPath(checkoutDir);
|
|
const archiveFileName = '.subset.tar';
|
|
const archivePath = join(checkoutDir, archiveFileName);
|
|
const archivePaths = [...new Set(paths.map(normalizeRepoPath))];
|
|
|
|
await $`git init ${gitCheckoutDir}`;
|
|
await $`git -C ${gitCheckoutDir} remote add origin ${remote}`;
|
|
await $`git -C ${gitCheckoutDir} fetch --depth 1 origin ${ref}`;
|
|
// Do not checkout working tree on Windows: upstream repos may contain
|
|
// Windows-invalid paths. Export only requested directories via git archive.
|
|
await $`git -C ${gitCheckoutDir} archive --format=tar --output ${archiveFileName} FETCH_HEAD ${archivePaths}`;
|
|
await extractArchive(archiveFileName, checkoutDir);
|
|
rmSync(archivePath, { force: true });
|
|
|
|
const commit = (await $`git -C ${gitCheckoutDir} rev-parse FETCH_HEAD`).stdout.trim();
|
|
return commit;
|
|
}
|
|
|
|
echo`Bundling preinstalled skills...`;
|
|
|
|
if (process.env.SKIP_PREINSTALLED_SKILLS === '1') {
|
|
echo`⏭ SKIP_PREINSTALLED_SKILLS=1 set, skipping skills fetch.`;
|
|
process.exit(0);
|
|
}
|
|
|
|
const manifestSkills = loadManifest();
|
|
|
|
rmSync(OUTPUT_ROOT, { recursive: true, force: true });
|
|
mkdirSync(OUTPUT_ROOT, { recursive: true });
|
|
rmSync(TMP_ROOT, { recursive: true, force: true });
|
|
mkdirSync(TMP_ROOT, { recursive: true });
|
|
|
|
const lock = {
|
|
generatedAt: new Date().toISOString(),
|
|
skills: [],
|
|
};
|
|
|
|
const groups = groupByRepoRef(manifestSkills);
|
|
for (const group of groups) {
|
|
const repoDir = join(TMP_ROOT, createRepoDirName(group.repo, group.ref));
|
|
const sparsePaths = [...new Set(group.entries.map((entry) => entry.repoPath))];
|
|
|
|
echo`Fetching ${group.repo} @ ${group.ref}`;
|
|
const commit = await fetchSparseRepo(group.repo, group.ref, sparsePaths, repoDir);
|
|
echo` commit ${commit}`;
|
|
|
|
for (const entry of group.entries) {
|
|
const sourceDir = join(repoDir, entry.repoPath);
|
|
const targetDir = join(OUTPUT_ROOT, entry.slug);
|
|
|
|
if (!existsSync(sourceDir)) {
|
|
throw new Error(`Missing source path in repo checkout: ${entry.repoPath}`);
|
|
}
|
|
|
|
rmSync(targetDir, { recursive: true, force: true });
|
|
cpSync(sourceDir, targetDir, { recursive: true, dereference: true, filter: shouldCopySkillFile });
|
|
|
|
const skillManifest = join(targetDir, 'SKILL.md');
|
|
if (!existsSync(skillManifest)) {
|
|
throw new Error(`Skill ${entry.slug} is missing SKILL.md after copy`);
|
|
}
|
|
rewriteSkillName(skillManifest, entry.skillName || entry.slug);
|
|
applyRuntimePolicy(skillManifest, entry.slug);
|
|
applyNoExternalOfficePrompts(targetDir, entry.slug);
|
|
sanitizeDesktopOfficeEngineTerms(targetDir, entry.slug);
|
|
|
|
const requestedVersion = (entry.version || '').trim();
|
|
const resolvedVersion = !requestedVersion || requestedVersion === 'main'
|
|
? commit
|
|
: requestedVersion;
|
|
lock.skills.push({
|
|
slug: entry.slug,
|
|
version: resolvedVersion,
|
|
repo: entry.repo,
|
|
repoPath: entry.repoPath,
|
|
ref: group.ref,
|
|
commit,
|
|
});
|
|
|
|
echo` OK ${entry.slug}`;
|
|
}
|
|
}
|
|
|
|
for (const entry of manifestSkills.filter((item) => item.localPath)) {
|
|
const sourceDir = join(ROOT, entry.localPath);
|
|
const targetDir = join(OUTPUT_ROOT, entry.slug);
|
|
|
|
if (!existsSync(sourceDir)) {
|
|
throw new Error(`Missing local skill source: ${entry.localPath}`);
|
|
}
|
|
if (!existsSync(join(sourceDir, 'SKILL.md'))) {
|
|
throw new Error(`Local skill ${entry.slug} is missing SKILL.md: ${entry.localPath}`);
|
|
}
|
|
|
|
rmSync(targetDir, { recursive: true, force: true });
|
|
cpSync(sourceDir, targetDir, { recursive: true, dereference: true, filter: shouldCopySkillFile });
|
|
const skillManifest = join(targetDir, 'SKILL.md');
|
|
rewriteSkillName(skillManifest, entry.skillName || entry.slug);
|
|
applyRuntimePolicy(skillManifest, entry.slug);
|
|
applyNoExternalOfficePrompts(targetDir, entry.slug);
|
|
sanitizeDesktopOfficeEngineTerms(targetDir, entry.slug);
|
|
|
|
const resolvedVersion = (entry.version || 'local').trim() || 'local';
|
|
lock.skills.push({
|
|
slug: entry.slug,
|
|
version: resolvedVersion,
|
|
localPath: entry.localPath,
|
|
ref: 'local',
|
|
commit: resolvedVersion,
|
|
});
|
|
|
|
echo` OK ${entry.slug} (local)`;
|
|
}
|
|
|
|
writeFileSync(join(OUTPUT_ROOT, '.preinstalled-lock.json'), `${JSON.stringify(lock, null, 2)}\n`, 'utf8');
|
|
assertNoMacAppLikeDirectories(OUTPUT_ROOT);
|
|
rmSync(TMP_ROOT, { recursive: true, force: true });
|
|
echo`Preinstalled skills ready: ${OUTPUT_ROOT}`;
|