feat: publish HoloLake model-native living system source
This commit is contained in:
parent
6ad10edde1
commit
c395dd3a99
2467 changed files with 615073 additions and 0 deletions
|
|
@ -0,0 +1,329 @@
|
|||
import { spawnSync } from 'node:child_process'
|
||||
import { error as logError, log } from 'node:console'
|
||||
import { existsSync } from 'node:fs'
|
||||
import {
|
||||
chmod,
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
rename,
|
||||
rm,
|
||||
writeFile,
|
||||
} from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import process from 'node:process'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
export const BROKEN_LINUXDEPLOY_APPRUN_DIR_LINE =
|
||||
'this_dir="$(readlink -f "$(dirname "$0")")"'
|
||||
export const FIXED_LINUXDEPLOY_APPRUN_DIR_LINE =
|
||||
'this_dir="$(dirname "$(readlink -f "$0")")"'
|
||||
export const APPIMAGE_PLUGIN_WRAPPER_NAME = 'linuxdeploy-plugin-appimage.AppImage'
|
||||
export const REAL_APPIMAGE_PLUGIN_NAME =
|
||||
'tolaria-real-linuxdeploy-plugin-appimage/linuxdeploy-plugin-appimage.AppImage'
|
||||
export const APPIMAGE_FCITX_GTK3_IM_MODULE_PATH =
|
||||
'usr/lib/x86_64-linux-gnu/gtk-3.0/3.0.0/immodules/im-fcitx5.so'
|
||||
export const APPIMAGE_FCITX_GCLIENT_LIBRARY_PATH =
|
||||
'usr/lib/x86_64-linux-gnu/libFcitx5GClient.so.2'
|
||||
export const DEFAULT_APPIMAGE_PLUGIN_URL =
|
||||
'https://github.com/linuxdeploy/linuxdeploy-plugin-appimage/releases/download/continuous/linuxdeploy-plugin-appimage-x86_64.AppImage'
|
||||
|
||||
const WRAPPER_MARKER = 'Tolaria AppImage symlink launcher shim'
|
||||
const REQUIRED_APPIMAGE_PATHS = [
|
||||
'AppRun',
|
||||
APPIMAGE_FCITX_GTK3_IM_MODULE_PATH,
|
||||
APPIMAGE_FCITX_GCLIENT_LIBRARY_PATH,
|
||||
]
|
||||
|
||||
export function tauriToolsCacheDir(env = process.env) {
|
||||
if (env.TOLARIA_TAURI_TOOLS_DIR) {
|
||||
return resolve(env.TOLARIA_TAURI_TOOLS_DIR)
|
||||
}
|
||||
|
||||
if (env.XDG_CACHE_HOME) {
|
||||
return resolve(env.XDG_CACHE_HOME, 'tauri')
|
||||
}
|
||||
|
||||
if (!env.HOME) {
|
||||
throw new Error('HOME or XDG_CACHE_HOME is required to locate the Tauri tools cache')
|
||||
}
|
||||
|
||||
return resolve(env.HOME, '.cache', 'tauri')
|
||||
}
|
||||
|
||||
export function patchAppRunText(text) {
|
||||
if (text.includes(BROKEN_LINUXDEPLOY_APPRUN_DIR_LINE)) {
|
||||
return {
|
||||
changed: true,
|
||||
text: text.replaceAll(
|
||||
BROKEN_LINUXDEPLOY_APPRUN_DIR_LINE,
|
||||
FIXED_LINUXDEPLOY_APPRUN_DIR_LINE,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
return { changed: false, text }
|
||||
}
|
||||
|
||||
export function assertSymlinkSafeAppRunText(text, label = 'AppRun') {
|
||||
if (text.includes(BROKEN_LINUXDEPLOY_APPRUN_DIR_LINE)) {
|
||||
throw new Error(`${label} still resolves dirname before following AppRun symlinks`)
|
||||
}
|
||||
|
||||
if (!text.includes(FIXED_LINUXDEPLOY_APPRUN_DIR_LINE)) {
|
||||
throw new Error(`${label} is missing the symlink-safe AppRun directory resolver`)
|
||||
}
|
||||
}
|
||||
|
||||
export function appImagePluginWrapperSource({
|
||||
pluginUrl = DEFAULT_APPIMAGE_PLUGIN_URL,
|
||||
} = {}) {
|
||||
return `#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# ${WRAPPER_MARKER}
|
||||
PLUGIN_URL="\${TOLARIA_APPIMAGE_PLUGIN_URL:-${pluginUrl}}"
|
||||
SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
|
||||
REAL_PLUGIN="\${TOLARIA_APPIMAGE_REAL_PLUGIN:-"$SCRIPT_DIR/${REAL_APPIMAGE_PLUGIN_NAME}"}"
|
||||
FCITX_GTK3_IM_MODULE="\${TOLARIA_FCITX_GTK3_IM_MODULE:-/usr/lib/x86_64-linux-gnu/gtk-3.0/3.0.0/immodules/im-fcitx5.so}"
|
||||
FCITX_LIBRARY_DIR="\${TOLARIA_FCITX_LIBRARY_DIR:-/usr/lib/x86_64-linux-gnu}"
|
||||
|
||||
appdir_from_args() {
|
||||
local previous=""
|
||||
|
||||
for arg in "$@"; do
|
||||
if [ "$previous" = "--appdir" ]; then
|
||||
printf '%s\\n' "$arg"
|
||||
return 0
|
||||
fi
|
||||
|
||||
case "$arg" in
|
||||
--appdir=*)
|
||||
printf '%s\\n' "\${arg#--appdir=}"
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
|
||||
previous="$arg"
|
||||
done
|
||||
}
|
||||
|
||||
download_real_plugin() {
|
||||
if [ -x "$REAL_PLUGIN" ]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
local tmp_plugin="$REAL_PLUGIN.tmp.$$"
|
||||
rm -f "$tmp_plugin"
|
||||
mkdir -p "$(dirname -- "$REAL_PLUGIN")"
|
||||
|
||||
if command -v curl >/dev/null 2>&1; then
|
||||
curl -fsSL -o "$tmp_plugin" "$PLUGIN_URL"
|
||||
elif command -v wget >/dev/null 2>&1; then
|
||||
wget -q -O "$tmp_plugin" "$PLUGIN_URL"
|
||||
else
|
||||
echo "curl or wget is required to fetch the real linuxdeploy AppImage output plugin" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
chmod +x "$tmp_plugin"
|
||||
mv "$tmp_plugin" "$REAL_PLUGIN"
|
||||
}
|
||||
|
||||
patch_apprun() {
|
||||
local appdir="\${APPDIR:-}"
|
||||
|
||||
if [ -z "$appdir" ]; then
|
||||
appdir="$(appdir_from_args "$@" || true)"
|
||||
fi
|
||||
|
||||
if [ -z "$appdir" ] || [ ! -f "$appdir/AppRun" ]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
python3 - "$appdir/AppRun" <<'PY'
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
path = Path(sys.argv[1])
|
||||
broken = 'this_dir="$(readlink -f "$(dirname "$0")")"'
|
||||
fixed = 'this_dir="$(dirname "$(readlink -f "$0")")"'
|
||||
text = path.read_text(encoding="utf-8")
|
||||
|
||||
if broken in text:
|
||||
path.write_text(text.replace(broken, fixed), encoding="utf-8")
|
||||
print(f"Patched linuxdeploy AppRun symlink resolution in {path}", file=sys.stderr)
|
||||
elif fixed in text:
|
||||
pass
|
||||
elif "autogenerated by linuxdeploy" in text and "AppRun.wrapped" in text:
|
||||
raise SystemExit(f"{path} is a linuxdeploy wrapper but does not contain the expected AppRun resolver")
|
||||
PY
|
||||
}
|
||||
|
||||
bundle_fcitx_gtk3_module() {
|
||||
local appdir="\${APPDIR:-}"
|
||||
|
||||
if [ -z "$appdir" ]; then
|
||||
appdir="$(appdir_from_args "$@" || true)"
|
||||
fi
|
||||
|
||||
if [ -z "$appdir" ] || [ ! -d "$appdir" ] || [ ! -f "$FCITX_GTK3_IM_MODULE" ]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
local module_dest="$appdir/${APPIMAGE_FCITX_GTK3_IM_MODULE_PATH}"
|
||||
local library_dest_dir="$appdir/usr/lib/x86_64-linux-gnu"
|
||||
|
||||
mkdir -p "$(dirname -- "$module_dest")" "$library_dest_dir"
|
||||
cp -a "$FCITX_GTK3_IM_MODULE" "$module_dest"
|
||||
|
||||
local copied_library=0
|
||||
shopt -s nullglob
|
||||
for lib in "$FCITX_LIBRARY_DIR"/libFcitx5GClient.so* "$FCITX_LIBRARY_DIR"/libFcitx5Utils.so*; do
|
||||
cp -a "$lib" "$library_dest_dir/"
|
||||
copied_library=1
|
||||
done
|
||||
shopt -u nullglob
|
||||
|
||||
if [ "$copied_library" -eq 0 ]; then
|
||||
echo "No fcitx GTK client libraries found in $FCITX_LIBRARY_DIR" >&2
|
||||
fi
|
||||
}
|
||||
|
||||
download_real_plugin
|
||||
patch_apprun "$@"
|
||||
bundle_fcitx_gtk3_module "$@"
|
||||
exec "$REAL_PLUGIN" "$@"
|
||||
`
|
||||
}
|
||||
|
||||
export async function preparePluginWrapper({
|
||||
env = process.env,
|
||||
toolsDir = tauriToolsCacheDir(env),
|
||||
} = {}) {
|
||||
await mkdir(toolsDir, { recursive: true })
|
||||
|
||||
const wrapperPath = join(toolsDir, APPIMAGE_PLUGIN_WRAPPER_NAME)
|
||||
const realPluginPath = join(toolsDir, REAL_APPIMAGE_PLUGIN_NAME)
|
||||
|
||||
if (existsSync(wrapperPath) && !existsSync(realPluginPath)) {
|
||||
const existing = await readFile(wrapperPath, 'utf8').catch(() => '')
|
||||
if (!existing.includes(WRAPPER_MARKER)) {
|
||||
await mkdir(dirname(realPluginPath), { recursive: true })
|
||||
await rename(wrapperPath, realPluginPath)
|
||||
}
|
||||
}
|
||||
|
||||
await writeFile(wrapperPath, appImagePluginWrapperSource(), 'utf8')
|
||||
await chmod(wrapperPath, 0o755)
|
||||
|
||||
return { realPluginPath, wrapperPath }
|
||||
}
|
||||
|
||||
export async function validateAppRunFile(path) {
|
||||
const text = await readFile(path, 'utf8')
|
||||
assertSymlinkSafeAppRunText(text, path)
|
||||
}
|
||||
|
||||
function extractAppImagePath(appImage, requiredPath, tempDir) {
|
||||
const result = spawnSync(appImage, ['--appimage-extract', requiredPath], {
|
||||
cwd: tempDir,
|
||||
encoding: 'utf8',
|
||||
})
|
||||
|
||||
if (result.status === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
[
|
||||
`Failed to extract ${requiredPath} from ${appImage}`,
|
||||
result.stdout.trim(),
|
||||
result.stderr.trim(),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n'),
|
||||
)
|
||||
}
|
||||
|
||||
function assertAppImagePathsExtracted(appImage, tempDir, requiredPaths) {
|
||||
for (const requiredPath of requiredPaths) {
|
||||
const extractedPath = join(tempDir, 'squashfs-root', requiredPath)
|
||||
if (!existsSync(extractedPath)) {
|
||||
throw new Error(`${appImage} is missing ${requiredPath}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function validateExtractedAppImage(appImage, tempDir) {
|
||||
for (const requiredPath of REQUIRED_APPIMAGE_PATHS) {
|
||||
extractAppImagePath(appImage, requiredPath, tempDir)
|
||||
}
|
||||
|
||||
await validateAppRunFile(join(tempDir, 'squashfs-root', 'AppRun'))
|
||||
assertAppImagePathsExtracted(appImage, tempDir, REQUIRED_APPIMAGE_PATHS.slice(1))
|
||||
}
|
||||
|
||||
export async function validateAppImages(paths) {
|
||||
if (paths.length === 0) {
|
||||
throw new Error('At least one AppImage path is required for launcher validation')
|
||||
}
|
||||
|
||||
for (const appImage of paths.map((path) => resolve(path))) {
|
||||
const tempDir = await mkdtemp(join(tmpdir(), 'tolaria-appimage-'))
|
||||
try {
|
||||
await validateExtractedAppImage(appImage, tempDir)
|
||||
} finally {
|
||||
await rm(tempDir, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function preparePluginCommand() {
|
||||
const { wrapperPath, realPluginPath } = await preparePluginWrapper()
|
||||
log(`Prepared ${wrapperPath}`)
|
||||
log(`Real plugin cache: ${realPluginPath}`)
|
||||
}
|
||||
|
||||
async function validateAppRunFilesCommand(paths) {
|
||||
for (const path of paths) {
|
||||
await validateAppRunFile(path)
|
||||
log(`Validated ${path}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function validateAppImagesCommand(paths) {
|
||||
await validateAppImages(paths)
|
||||
for (const path of paths) {
|
||||
log(`Validated AppImage launcher in ${path}`)
|
||||
}
|
||||
}
|
||||
|
||||
const COMMANDS = new Map([
|
||||
['prepare-plugin', preparePluginCommand],
|
||||
['validate-apprun-file', validateAppRunFilesCommand],
|
||||
['validate-appimages', validateAppImagesCommand],
|
||||
])
|
||||
|
||||
function usage() {
|
||||
return 'Usage: node scripts/appimage-launcher-tools.mjs prepare-plugin | validate-apprun-file <AppRun...> | validate-appimages <AppImage...>'
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const [command, ...args] = process.argv.slice(2)
|
||||
const handler = COMMANDS.get(command)
|
||||
|
||||
if (!handler) {
|
||||
throw new Error(usage())
|
||||
}
|
||||
|
||||
await handler(args)
|
||||
}
|
||||
|
||||
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
main().catch((error) => {
|
||||
logError(error.message)
|
||||
process.exit(1)
|
||||
})
|
||||
}
|
||||
|
|
@ -0,0 +1,182 @@
|
|||
import assert from 'node:assert/strict'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { existsSync, realpathSync } from 'node:fs'
|
||||
import {
|
||||
chmod,
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
symlink,
|
||||
writeFile,
|
||||
} from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { basename, dirname, join } from 'node:path'
|
||||
import process from 'node:process'
|
||||
import test from 'node:test'
|
||||
|
||||
import {
|
||||
BROKEN_LINUXDEPLOY_APPRUN_DIR_LINE,
|
||||
FIXED_LINUXDEPLOY_APPRUN_DIR_LINE,
|
||||
REAL_APPIMAGE_PLUGIN_NAME,
|
||||
appImagePluginWrapperSource,
|
||||
patchAppRunText,
|
||||
preparePluginWrapper,
|
||||
} from './appimage-launcher-tools.mjs'
|
||||
|
||||
function brokenResolverDir(invokedPath) {
|
||||
return realpathSync(dirname(invokedPath))
|
||||
}
|
||||
|
||||
function fixedResolverDir(invokedPath) {
|
||||
return dirname(realpathSync(invokedPath))
|
||||
}
|
||||
|
||||
test('patches linuxdeploy AppRun wrapper to resolve the invoked path before dirname', () => {
|
||||
const original = [
|
||||
'#! /usr/bin/env bash',
|
||||
'# autogenerated by linuxdeploy',
|
||||
BROKEN_LINUXDEPLOY_APPRUN_DIR_LINE,
|
||||
'exec "$this_dir"/AppRun.wrapped "$@"',
|
||||
].join('\n')
|
||||
|
||||
const patched = patchAppRunText(original)
|
||||
|
||||
assert.equal(patched.changed, true)
|
||||
assert.equal(patched.text.includes(BROKEN_LINUXDEPLOY_APPRUN_DIR_LINE), false)
|
||||
assert.equal(patched.text.includes(FIXED_LINUXDEPLOY_APPRUN_DIR_LINE), true)
|
||||
})
|
||||
|
||||
test('fixed resolver follows absolute and relative symlinks before choosing AppDir', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'tolaria-apprun-resolver-'))
|
||||
const appDir = join(root, 'Tolaria.AppDir')
|
||||
const binDir = join(root, 'bin')
|
||||
const relativeDir = join(root, 'relative-bin')
|
||||
const appRun = join(appDir, 'AppRun')
|
||||
|
||||
await mkdir(appDir)
|
||||
await mkdir(binDir)
|
||||
await mkdir(relativeDir)
|
||||
await writeFile(appRun, '#! /usr/bin/env bash\n', 'utf8')
|
||||
|
||||
const absoluteSymlink = join(binDir, 'tolaria')
|
||||
const relativeSymlink = join(relativeDir, 'tolaria')
|
||||
|
||||
await symlink(appRun, absoluteSymlink)
|
||||
await symlink(`../${basename(appDir)}/AppRun`, relativeSymlink)
|
||||
|
||||
assert.equal(brokenResolverDir(absoluteSymlink), realpathSync(binDir))
|
||||
assert.equal(fixedResolverDir(absoluteSymlink), realpathSync(appDir))
|
||||
assert.equal(brokenResolverDir(relativeSymlink), realpathSync(relativeDir))
|
||||
assert.equal(fixedResolverDir(relativeSymlink), realpathSync(appDir))
|
||||
})
|
||||
|
||||
test('plugin wrapper patches AppRun before delegating to the real output plugin', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'tolaria-appimage-plugin-'))
|
||||
const appDir = join(root, 'Tolaria.AppDir')
|
||||
const appRun = join(appDir, 'AppRun')
|
||||
const wrapper = join(root, 'linuxdeploy-plugin-appimage.AppImage')
|
||||
const realPlugin = join(root, 'linuxdeploy-plugin-appimage.real.AppImage')
|
||||
const pluginMarker = join(root, 'plugin-ran')
|
||||
|
||||
await mkdir(appDir)
|
||||
await writeFile(
|
||||
appRun,
|
||||
[
|
||||
'#! /usr/bin/env bash',
|
||||
'# autogenerated by linuxdeploy',
|
||||
BROKEN_LINUXDEPLOY_APPRUN_DIR_LINE,
|
||||
'exec "$this_dir"/AppRun.wrapped "$@"',
|
||||
].join('\n'),
|
||||
'utf8',
|
||||
)
|
||||
await writeFile(wrapper, appImagePluginWrapperSource(), 'utf8')
|
||||
await chmod(wrapper, 0o755)
|
||||
await writeFile(
|
||||
realPlugin,
|
||||
`#!/usr/bin/env bash\nset -euo pipefail\ntouch "${pluginMarker}"\n`,
|
||||
'utf8',
|
||||
)
|
||||
await chmod(realPlugin, 0o755)
|
||||
|
||||
const result = spawnSync(wrapper, [], {
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
APPDIR: appDir,
|
||||
TOLARIA_APPIMAGE_REAL_PLUGIN: realPlugin,
|
||||
},
|
||||
})
|
||||
|
||||
assert.equal(result.status, 0, result.stderr)
|
||||
assert.equal(existsSync(pluginMarker), true)
|
||||
|
||||
const patched = await readFile(appRun, 'utf8')
|
||||
assert.equal(patched.includes(BROKEN_LINUXDEPLOY_APPRUN_DIR_LINE), false)
|
||||
assert.equal(patched.includes(FIXED_LINUXDEPLOY_APPRUN_DIR_LINE), true)
|
||||
})
|
||||
|
||||
test('plugin wrapper keeps the delegated appimage plugin basename canonical', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'tolaria-appimage-tools-'))
|
||||
const { realPluginPath, wrapperPath } = await preparePluginWrapper({
|
||||
toolsDir: root,
|
||||
})
|
||||
|
||||
assert.equal(basename(wrapperPath), 'linuxdeploy-plugin-appimage.AppImage')
|
||||
assert.equal(
|
||||
realPluginPath,
|
||||
join(root, 'tolaria-real-linuxdeploy-plugin-appimage', 'linuxdeploy-plugin-appimage.AppImage'),
|
||||
)
|
||||
assert.equal(REAL_APPIMAGE_PLUGIN_NAME.endsWith('/linuxdeploy-plugin-appimage.AppImage'), true)
|
||||
|
||||
const wrapper = await readFile(wrapperPath, 'utf8')
|
||||
assert.equal(wrapper.includes('linuxdeploy-plugin-appimage.real.AppImage'), false)
|
||||
})
|
||||
|
||||
test('plugin wrapper bundles fcitx GTK3 input module before sealing AppImage', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'tolaria-appimage-fcitx-'))
|
||||
const appDir = join(root, 'Tolaria.AppDir')
|
||||
const wrapper = join(root, 'linuxdeploy-plugin-appimage.AppImage')
|
||||
const realPlugin = join(root, 'linuxdeploy-plugin-appimage.real.AppImage')
|
||||
const pluginMarker = join(root, 'plugin-ran')
|
||||
const hostModule = join(root, 'host', 'im-fcitx5.so')
|
||||
const hostLibraryDir = join(root, 'host-lib')
|
||||
const hostLibrary = join(hostLibraryDir, 'libFcitx5GClient.so.2')
|
||||
|
||||
await mkdir(appDir)
|
||||
await mkdir(dirname(hostModule), { recursive: true })
|
||||
await mkdir(hostLibraryDir)
|
||||
await writeFile(hostModule, 'fake fcitx gtk module', 'utf8')
|
||||
await writeFile(hostLibrary, 'fake fcitx client library', 'utf8')
|
||||
await writeFile(wrapper, appImagePluginWrapperSource(), 'utf8')
|
||||
await chmod(wrapper, 0o755)
|
||||
await writeFile(
|
||||
realPlugin,
|
||||
`#!/usr/bin/env bash\nset -euo pipefail\ntouch "${pluginMarker}"\n`,
|
||||
'utf8',
|
||||
)
|
||||
await chmod(realPlugin, 0o755)
|
||||
|
||||
const result = spawnSync(wrapper, [], {
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
APPDIR: appDir,
|
||||
TOLARIA_APPIMAGE_REAL_PLUGIN: realPlugin,
|
||||
TOLARIA_FCITX_GTK3_IM_MODULE: hostModule,
|
||||
TOLARIA_FCITX_LIBRARY_DIR: hostLibraryDir,
|
||||
},
|
||||
})
|
||||
|
||||
assert.equal(result.status, 0, result.stderr)
|
||||
assert.equal(existsSync(pluginMarker), true)
|
||||
assert.equal(
|
||||
existsSync(
|
||||
join(
|
||||
appDir,
|
||||
'usr/lib/x86_64-linux-gnu/gtk-3.0/3.0.0/immodules/im-fcitx5.so',
|
||||
),
|
||||
),
|
||||
true,
|
||||
)
|
||||
assert.equal(existsSync(join(appDir, 'usr/lib/x86_64-linux-gnu/libFcitx5GClient.so.2')), true)
|
||||
})
|
||||
197
product-source/hololake-platform/scripts/build-agent-docs.mjs
Normal file
197
product-source/hololake-platform/scripts/build-agent-docs.mjs
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
import { mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
|
||||
const repoRoot = path.resolve(import.meta.dirname, '..')
|
||||
const siteRoot = path.join(repoRoot, 'site')
|
||||
const outputRoot = path.join(repoRoot, 'src-tauri', 'resources', 'agent-docs')
|
||||
|
||||
const sectionOrder = ['start', 'concepts', 'guides', 'templates', 'reference', 'troubleshooting', 'download', 'releases']
|
||||
const ignoredDirs = new Set(['.vitepress', 'public', 'node_modules', '.DS_Store'])
|
||||
|
||||
function titleFromSlug(slug) {
|
||||
return slug
|
||||
.replace(/[-_]+/g, ' ')
|
||||
.replace(/\b\w/g, (letter) => letter.toUpperCase())
|
||||
}
|
||||
|
||||
function stripFrontmatter(markdown) {
|
||||
return markdown.replace(/^---\n[\s\S]*?\n---\n/, '')
|
||||
}
|
||||
|
||||
function firstHeading(markdown, fallback) {
|
||||
const match = markdown.match(/^#\s+(.+)$/m)
|
||||
return match?.[1]?.trim() || fallback
|
||||
}
|
||||
|
||||
export function normalizeDocPath(relativePath) {
|
||||
return relativePath.replaceAll(path.win32.sep, '/')
|
||||
}
|
||||
|
||||
export function sectionForFile(relativePath) {
|
||||
const [firstPart] = relativePath.split('/')
|
||||
if (firstPart === 'index.md') return 'home'
|
||||
return firstPart.replace(/\.md$/, '')
|
||||
}
|
||||
|
||||
async function listMarkdownFiles(dir, base = dir) {
|
||||
const entries = await readdir(dir, { withFileTypes: true })
|
||||
const files = []
|
||||
|
||||
for (const entry of entries) {
|
||||
if (ignoredDirs.has(entry.name)) continue
|
||||
|
||||
const fullPath = path.join(dir, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
files.push(...await listMarkdownFiles(fullPath, base))
|
||||
} else if (entry.isFile() && entry.name.endsWith('.md')) {
|
||||
files.push(normalizeDocPath(path.relative(base, fullPath)))
|
||||
}
|
||||
}
|
||||
|
||||
return files
|
||||
}
|
||||
|
||||
function sortDocs(files) {
|
||||
return files.sort((a, b) => {
|
||||
const sectionDiff = sectionOrder.indexOf(sectionForFile(a)) - sectionOrder.indexOf(sectionForFile(b))
|
||||
if (sectionDiff !== 0) return sectionDiff
|
||||
return a.localeCompare(b)
|
||||
})
|
||||
}
|
||||
|
||||
function docUrl(relativePath) {
|
||||
const withoutExt = relativePath.replace(/(^|\/)index\.md$/, '$1').replace(/\.md$/, '')
|
||||
return `/${withoutExt}`.replace(/\/$/, '/') || '/'
|
||||
}
|
||||
|
||||
function formatDoc(doc) {
|
||||
return `# ${doc.title}\n\nSource: ${doc.path}\nURL: ${doc.url}\n\n${doc.content}`
|
||||
}
|
||||
|
||||
function groupDocsBySection(docs) {
|
||||
const bySection = new Map()
|
||||
|
||||
for (const doc of docs) {
|
||||
const docsInSection = bySection.get(doc.section) ?? []
|
||||
docsInSection.push(doc)
|
||||
bySection.set(doc.section, docsInSection)
|
||||
}
|
||||
|
||||
return bySection
|
||||
}
|
||||
|
||||
function buildIndex(docs) {
|
||||
const bySection = groupDocsBySection(docs)
|
||||
const lines = [
|
||||
'# HoloLake Era Agent Docs',
|
||||
'',
|
||||
'These docs are generated from the public HoloLake Era documentation for local AI agent lookup.',
|
||||
'',
|
||||
'Start here, then use `rg` over this folder for specific HoloLake Era concepts and workflows.',
|
||||
'',
|
||||
'When users ask how to improve a knowledge base, structure notes, choose types, model relationships, or make a vault easier for humans and agents to use, treat Portent as HoloLake Era\'s default best-practice model. Read `pages/templates/portent.md` and combine it with the HoloLake Era concepts for types, relationships, properties, Inbox, archive, and custom views.',
|
||||
'',
|
||||
]
|
||||
|
||||
for (const section of ['home', ...sectionOrder]) {
|
||||
const docsInSection = bySection.get(section)
|
||||
if (!docsInSection?.length) continue
|
||||
|
||||
lines.push(`## ${titleFromSlug(section)}`, '')
|
||||
for (const doc of docsInSection) {
|
||||
lines.push(`- [${doc.title}](pages/${doc.path})`)
|
||||
}
|
||||
lines.push('')
|
||||
}
|
||||
|
||||
lines.push('## Generated Files', '')
|
||||
lines.push('- `all.md`: all public docs concatenated for fast full-context reads.')
|
||||
lines.push('- `search-index.json`: title, heading, section, path, and URL metadata for quick routing.')
|
||||
lines.push('- `<section>.md`: one compact bundle per docs section.')
|
||||
lines.push('- `pages/`: one generated Markdown file per public docs page.')
|
||||
lines.push('')
|
||||
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
function buildAgentInstructions() {
|
||||
return `# AGENTS.md - HoloLake Era Docs Bundle
|
||||
|
||||
This folder contains local, generated HoloLake Era product docs for AI agents.
|
||||
|
||||
Use these docs when a user asks how HoloLake Era works, when you need product behavior, or before making HoloLake Era-specific assumptions.
|
||||
|
||||
Portent is the default best-practice model for structuring HoloLake Era knowledge bases. When a user asks how to improve a knowledge base, make it better organized, choose better types, model relationships, or make the vault easier for humans and agents to use, read \`pages/templates/portent.md\` and combine it with HoloLake Era's docs for types, relationships, properties, Inbox, archive, and custom views.
|
||||
|
||||
Recommended lookup flow:
|
||||
|
||||
1. Read the active vault's AGENTS.md for vault-specific conventions.
|
||||
2. Read this folder's index.md for the docs map.
|
||||
3. Use \`rg\` over this folder for advanced concepts, workflows, shortcuts, Git, AutoGit, AI, Portent, types, properties, relationships, and troubleshooting.
|
||||
|
||||
Vault-specific AGENTS.md wins for local conventions. These bundled docs win for HoloLake Era product behavior.
|
||||
`
|
||||
}
|
||||
|
||||
function searchIndexFor(doc) {
|
||||
const headings = [...doc.content.matchAll(/^#{2,3}\s+(.+)$/gm)].map((match) => match[1].trim())
|
||||
return {
|
||||
title: doc.title,
|
||||
path: `pages/${doc.path}`,
|
||||
url: doc.url,
|
||||
section: doc.section,
|
||||
headings,
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const files = sortDocs(await listMarkdownFiles(siteRoot))
|
||||
const docs = []
|
||||
|
||||
for (const relativePath of files) {
|
||||
const raw = await readFile(path.join(siteRoot, relativePath), 'utf8')
|
||||
const content = stripFrontmatter(raw).trim()
|
||||
const fallbackTitle = titleFromSlug(path.basename(relativePath, '.md'))
|
||||
docs.push({
|
||||
content,
|
||||
path: relativePath,
|
||||
section: sectionForFile(relativePath),
|
||||
title: firstHeading(content, fallbackTitle),
|
||||
url: docUrl(relativePath),
|
||||
})
|
||||
}
|
||||
|
||||
await rm(outputRoot, { force: true, recursive: true })
|
||||
await mkdir(outputRoot, { recursive: true })
|
||||
|
||||
await writeFile(path.join(outputRoot, 'AGENTS.md'), buildAgentInstructions())
|
||||
await writeFile(path.join(outputRoot, 'index.md'), buildIndex(docs))
|
||||
await writeFile(path.join(outputRoot, 'all.md'), docs.map(formatDoc).join('\n\n---\n\n'))
|
||||
await writeFile(path.join(outputRoot, 'search-index.json'), `${JSON.stringify(docs.map(searchIndexFor), null, 2)}\n`)
|
||||
|
||||
for (const doc of docs) {
|
||||
const outputPath = path.join(outputRoot, 'pages', doc.path)
|
||||
await mkdir(path.dirname(outputPath), { recursive: true })
|
||||
await writeFile(outputPath, formatDoc(doc))
|
||||
}
|
||||
|
||||
const bySection = groupDocsBySection(docs)
|
||||
for (const [section, docsInSection] of bySection) {
|
||||
await writeFile(
|
||||
path.join(outputRoot, `${section}.md`),
|
||||
docsInSection.map(formatDoc).join('\n\n---\n\n'),
|
||||
)
|
||||
}
|
||||
|
||||
console.log(`Generated ${docs.length} agent docs in ${path.relative(repoRoot, outputRoot)}`)
|
||||
}
|
||||
|
||||
const entrypointUrl = process.argv[1] ? pathToFileURL(process.argv[1]).href : ''
|
||||
|
||||
if (import.meta.url === entrypointUrl) {
|
||||
main().catch((error) => {
|
||||
console.error(error)
|
||||
process.exit(1)
|
||||
})
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { normalizeDocPath, sectionForFile } from './build-agent-docs.mjs'
|
||||
|
||||
test('normalizes Windows doc paths before section grouping', () => {
|
||||
const docPath = normalizeDocPath('concepts\\ai.md')
|
||||
|
||||
assert.equal(docPath, 'concepts/ai.md')
|
||||
assert.equal(sectionForFile(docPath), 'concepts')
|
||||
})
|
||||
18
product-source/hololake-platform/scripts/build-internal-release.sh
Executable file
18
product-source/hololake-platform/scripts/build-internal-release.sh
Executable file
|
|
@ -0,0 +1,18 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
platform="${1:-}"
|
||||
|
||||
case "$platform" in
|
||||
macos)
|
||||
exec "$repo_root/scripts/build-macos-internal.sh"
|
||||
;;
|
||||
windows)
|
||||
exec "$repo_root/scripts/build-windows-jd-cross.sh"
|
||||
;;
|
||||
*)
|
||||
echo "Usage: ./scripts/build-internal-release.sh <macos|windows>" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
49
product-source/hololake-platform/scripts/build-macos-internal.sh
Executable file
49
product-source/hololake-platform/scripts/build-macos-internal.sh
Executable file
|
|
@ -0,0 +1,49 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$repo_root"
|
||||
|
||||
[[ "$(uname -s)" == "Darwin" ]] || { echo "macOS internal packages must be built on macOS." >&2; exit 2; }
|
||||
|
||||
target="${HOLOLAKE_MAC_TARGET:-aarch64-apple-darwin}"
|
||||
version="$(node scripts/internal-release-version.mjs src-tauri/tauri.conf.json "${HOLOLAKE_VERSION:-}")"
|
||||
product_name="$(node -e "const fs=require('node:fs'); const value=JSON.parse(fs.readFileSync('src-tauri/tauri.conf.json','utf8')).productName; if(!value) process.exit(1); process.stdout.write(value)")"
|
||||
output_dir="${HOLOLAKE_OUTPUT_DIR:-$repo_root/artifacts/internal/$version}"
|
||||
target_root="${CARGO_TARGET_DIR:-$repo_root/src-tauri/target}"
|
||||
bundle_dir="$target_root/$target/release/bundle/macos"
|
||||
app="$bundle_dir/${HOLOLAKE_APP_NAME:-$product_name}.app"
|
||||
installer="$output_dir/HoloLake-Era-$version-Mac-internal-aarch64.dmg"
|
||||
tauri_config="${HOLOLAKE_TAURI_CONFIG:-}"
|
||||
if [[ -n "${HOLOLAKE_INSTALLER_BASENAME:-}" ]]; then
|
||||
installer_basename="${HOLOLAKE_INSTALLER_BASENAME//\{version\}/$version}"
|
||||
installer="$output_dir/${installer_basename}.dmg"
|
||||
fi
|
||||
|
||||
if command -v pnpm >/dev/null 2>&1; then
|
||||
pnpm_cmd=(pnpm)
|
||||
elif command -v corepack >/dev/null 2>&1; then
|
||||
pnpm_cmd=(corepack pnpm)
|
||||
else
|
||||
echo "pnpm or corepack is required." >&2
|
||||
exit 2
|
||||
fi
|
||||
command -v hdiutil >/dev/null || { echo "hdiutil is required." >&2; exit 2; }
|
||||
mkdir -p "$output_dir"
|
||||
|
||||
if [[ "${HOLOLAKE_SKIP_INSTALL:-0}" != "1" ]]; then
|
||||
"${pnpm_cmd[@]}" install --frozen-lockfile
|
||||
fi
|
||||
[[ -x "$repo_root/node_modules/.bin/tauri" ]] || { echo "Local Tauri CLI is missing; install dependencies first." >&2; exit 2; }
|
||||
tauri_args=(build --target "$target" --bundles app)
|
||||
if [[ -n "$tauri_config" ]]; then tauri_args+=(--config "$tauri_config"); fi
|
||||
"$repo_root/node_modules/.bin/tauri" "${tauri_args[@]}"
|
||||
[[ -d "$app" ]] || { echo "No macOS application bundle was produced: $app" >&2; exit 1; }
|
||||
|
||||
node scripts/verify-internal-package-content.mjs "$app"
|
||||
codesign --force --deep --sign - "$app"
|
||||
codesign --verify --deep --strict "$app"
|
||||
hdiutil create -volname "HoloLake Era $version Internal" -srcfolder "$app" -ov -format UDZO "$installer"
|
||||
hdiutil verify "$installer"
|
||||
shasum -a 256 "$installer" | sed "s# .*# $(basename "$installer")#" > "$installer.sha256"
|
||||
echo "$installer"
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
|
||||
import {
|
||||
buildStableDownloadRedirectPage,
|
||||
resolveStableDownloadTargets,
|
||||
} from '../src/utils/releaseDownloadPage'
|
||||
|
||||
function getArg(flag: string): string {
|
||||
const index = process.argv.indexOf(flag)
|
||||
const value = index >= 0 ? process.argv[index + 1] : null
|
||||
|
||||
if (!value) {
|
||||
throw new Error(`Missing required argument: ${flag}`)
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
function readLatestReleasePayload(filePath: string): unknown {
|
||||
try {
|
||||
return JSON.parse(readFileSync(filePath, 'utf8'))
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
const latestJsonPath = resolve(getArg('--latest-json'))
|
||||
const releasesJsonPath = resolve(getArg('--releases-json'))
|
||||
const outputFilePath = resolve(getArg('--output-file'))
|
||||
const latestPayload = readLatestReleasePayload(latestJsonPath)
|
||||
const releasesPayload = readLatestReleasePayload(releasesJsonPath)
|
||||
const downloads = resolveStableDownloadTargets(latestPayload, releasesPayload)
|
||||
const html = buildStableDownloadRedirectPage(downloads)
|
||||
|
||||
mkdirSync(dirname(outputFilePath), { recursive: true })
|
||||
writeFileSync(outputFilePath, html)
|
||||
|
||||
console.log(`Stable download page written to ${outputFilePath}`)
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
|
||||
import { buildReleaseHistoryPage } from '../src/utils/releaseHistoryPage'
|
||||
|
||||
function getArg(flag: string): string {
|
||||
const index = process.argv.indexOf(flag)
|
||||
const value = index >= 0 ? process.argv[index + 1] : null
|
||||
|
||||
if (!value) {
|
||||
throw new Error(`Missing required argument: ${flag}`)
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
function readReleasePayload(filePath: string): unknown {
|
||||
try {
|
||||
return JSON.parse(readFileSync(filePath, 'utf8'))
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
const releasesJsonPath = resolve(getArg('--releases-json'))
|
||||
const outputFilePath = resolve(getArg('--output-file'))
|
||||
const releasesPayload = readReleasePayload(releasesJsonPath)
|
||||
const html = buildReleaseHistoryPage(releasesPayload)
|
||||
|
||||
mkdirSync(dirname(outputFilePath), { recursive: true })
|
||||
writeFileSync(outputFilePath, html)
|
||||
|
||||
console.log(`Release history page written to ${outputFilePath}`)
|
||||
82
product-source/hololake-platform/scripts/build-windows-jd-cross.sh
Executable file
82
product-source/hololake-platform/scripts/build-windows-jd-cross.sh
Executable file
|
|
@ -0,0 +1,82 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
target="x86_64-pc-windows-msvc"
|
||||
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$repo_root"
|
||||
|
||||
if [[ "$(uname -s)" != "Linux" ]]; then
|
||||
echo "This cross-build entrypoint is intended for the JD Linux build node." >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if command -v apt-get >/dev/null 2>&1; then
|
||||
apt_prefix=()
|
||||
if [[ "$(id -u)" -ne 0 ]]; then apt_prefix=(sudo); fi
|
||||
"${apt_prefix[@]}" apt-get update
|
||||
"${apt_prefix[@]}" apt-get install -y clang curl lld llvm nsis build-essential pkg-config libssl-dev
|
||||
fi
|
||||
|
||||
command -v node >/dev/null || { echo "Node.js is required." >&2; exit 2; }
|
||||
version="$(node scripts/internal-release-version.mjs src-tauri/tauri.conf.json "${HOLOLAKE_VERSION:-}")"
|
||||
tauri_config="${HOLOLAKE_TAURI_CONFIG:-}"
|
||||
export PATH="$HOME/.cargo/bin:$PATH"
|
||||
|
||||
if ! command -v cargo >/dev/null 2>&1; then
|
||||
command -v curl >/dev/null || { echo "curl is required to install Rust." >&2; exit 2; }
|
||||
export RUSTUP_DIST_SERVER="${RUSTUP_DIST_SERVER:-https://rsproxy.cn}"
|
||||
export RUSTUP_UPDATE_ROOT="${RUSTUP_UPDATE_ROOT:-https://rsproxy.cn/rustup}"
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal
|
||||
fi
|
||||
export CARGO_HOME="${CARGO_HOME:-$HOME/.cargo-jd}"
|
||||
mkdir -p "$CARGO_HOME"
|
||||
cp scripts/cargo-config-jd.toml "$CARGO_HOME/config.toml"
|
||||
export PATH="$CARGO_HOME/bin:$HOME/.cargo/bin:$PATH"
|
||||
|
||||
if ! command -v corepack >/dev/null 2>&1; then
|
||||
npm install --global corepack
|
||||
fi
|
||||
|
||||
corepack enable 2>/dev/null || true
|
||||
corepack prepare pnpm@10.25.0 --activate
|
||||
rustup target add "$target"
|
||||
command -v cargo-xwin >/dev/null || cargo install cargo-xwin --locked
|
||||
|
||||
pnpm install --frozen-lockfile
|
||||
# Tauri's Linux CLI intentionally only exposes Linux bundle targets. Compile the
|
||||
# Windows application with cargo-xwin, then hand the resulting payload to NSIS.
|
||||
tauri_args=(build --runner cargo-xwin --target "$target" --no-bundle)
|
||||
if [[ -n "$tauri_config" ]]; then tauri_args+=(--config "$tauri_config"); fi
|
||||
pnpm tauri "${tauri_args[@]}"
|
||||
|
||||
target_root="${CARGO_TARGET_DIR:-$repo_root/src-tauri/target}"
|
||||
release_dir="$target_root/$target/release"
|
||||
app_exe="$release_dir/tolaria.exe"
|
||||
[[ -f "$app_exe" ]] || { echo "No Windows executable was produced: $app_exe" >&2; exit 1; }
|
||||
|
||||
bundle_dir="$release_dir/bundle/nsis"
|
||||
stage_dir="$bundle_dir/stage"
|
||||
installer_basename="${HOLOLAKE_INSTALLER_BASENAME:-HoloLake-Era_${version}_x64-internal-setup}"
|
||||
installer_basename="${installer_basename//\{version\}/$version}"
|
||||
installer="$bundle_dir/${installer_basename}.exe"
|
||||
rm -rf "$stage_dir"
|
||||
mkdir -p "$stage_dir/resources" "$bundle_dir"
|
||||
cp "$app_exe" "$stage_dir/HoloLake Era.exe"
|
||||
cp src-tauri/icons/icon.ico "$stage_dir/icon.ico"
|
||||
cp -R src-tauri/resources/mcp-server "$stage_dir/resources/"
|
||||
cp -R src-tauri/resources/agent-docs "$stage_dir/resources/"
|
||||
cp -R src-tauri/resources/public-architecture "$stage_dir/resources/"
|
||||
|
||||
makensis \
|
||||
-DAPP_VERSION="$version" \
|
||||
-DSTAGE_DIR="$stage_dir" \
|
||||
-DOUTPUT_FILE="$installer" \
|
||||
scripts/windows-installer.nsi
|
||||
|
||||
[[ -f "$installer" ]] || { echo "No NSIS installer was produced." >&2; exit 1; }
|
||||
[[ "$(basename "$installer")" == *"$version"* ]] || { echo "Installer filename does not contain version $version: $installer" >&2; exit 1; }
|
||||
|
||||
node scripts/verify-internal-package-content.mjs dist
|
||||
node scripts/verify-internal-package-content.mjs "$stage_dir"
|
||||
sha256sum "$installer" > "$installer.sha256"
|
||||
echo "$installer"
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
/**
|
||||
* Bundle the mcp-server Node.js files into self-contained CJS bundles
|
||||
* that can be shipped as Tauri resources inside the .app bundle.
|
||||
*
|
||||
* Output: src-tauri/resources/mcp-server/{index.js,ws-bridge.js}
|
||||
*/
|
||||
import { build } from 'esbuild'
|
||||
import { fileURLToPath } from 'url'
|
||||
import { dirname, join } from 'path'
|
||||
import { mkdirSync, writeFileSync } from 'fs'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const ROOT = join(__dirname, '..')
|
||||
const SRC = join(ROOT, 'mcp-server')
|
||||
const OUT = join(ROOT, 'src-tauri', 'resources', 'mcp-server')
|
||||
|
||||
mkdirSync(OUT, { recursive: true })
|
||||
|
||||
// Tell Node.js that this directory contains CJS bundles, even if the
|
||||
// root package.json declares "type": "module".
|
||||
writeFileSync(join(OUT, 'package.json'), JSON.stringify({ type: 'commonjs' }))
|
||||
|
||||
const shared = {
|
||||
platform: 'node',
|
||||
bundle: true,
|
||||
format: 'cjs',
|
||||
target: 'node18',
|
||||
// Mark optional native bindings as external — ws works fine without them
|
||||
external: ['bufferutil', 'utf-8-validate'],
|
||||
logLevel: 'warning',
|
||||
}
|
||||
|
||||
await build({
|
||||
...shared,
|
||||
entryPoints: [join(SRC, 'index.js')],
|
||||
outfile: join(OUT, 'index.js'),
|
||||
})
|
||||
|
||||
await build({
|
||||
...shared,
|
||||
entryPoints: [join(SRC, 'ws-bridge.js')],
|
||||
outfile: join(OUT, 'ws-bridge.js'),
|
||||
})
|
||||
|
||||
console.log('mcp-server bundled → src-tauri/resources/mcp-server/')
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
[source.crates-io]
|
||||
replace-with = "rsproxy-sparse"
|
||||
|
||||
[source.rsproxy-sparse]
|
||||
registry = "sparse+https://rsproxy.cn/index/"
|
||||
|
||||
[net]
|
||||
git-fetch-with-cli = true
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import { readFile } from 'node:fs/promises'
|
||||
|
||||
export function validateDeploymentSource(policy, context) {
|
||||
const route = policy.routes?.find(candidate => candidate.distribution === context.distribution)
|
||||
if (!route || route.deployment_enabled !== true) {
|
||||
return { ok: false, reason: 'deployment_source_route_inactive' }
|
||||
}
|
||||
if (route.repository_id !== context.repositoryId || route.channel_id !== context.channelId) {
|
||||
return { ok: false, reason: 'deployment_source_binding_mismatch' }
|
||||
}
|
||||
if (route.source_owner_id !== context.sourceOwnerId) {
|
||||
return { ok: false, reason: 'deployment_source_owner_mismatch' }
|
||||
}
|
||||
if (!route.allowed_authorizers?.includes(context.authorizerId)) {
|
||||
return { ok: false, reason: 'deployment_authorizer_not_allowed' }
|
||||
}
|
||||
if (!route.allowed_personas?.includes(context.personaId)) {
|
||||
return { ok: false, reason: 'deployment_persona_not_allowed' }
|
||||
}
|
||||
if (!route.allowed_execution_runtimes?.includes(context.executionRuntimeId)) {
|
||||
return { ok: false, reason: 'deployment_execution_runtime_not_allowed' }
|
||||
}
|
||||
if (!route.allowed_targets?.includes(context.target)) {
|
||||
return { ok: false, reason: 'deployment_target_not_allowed' }
|
||||
}
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const policyFile = process.env.HOLOLAKE_SOURCE_POLICY || 'research/source-route-policy.json'
|
||||
const policy = JSON.parse(await readFile(policyFile, 'utf8'))
|
||||
const context = {
|
||||
distribution: process.env.HOLOLAKE_DISTRIBUTION || '',
|
||||
repositoryId: process.env.HOLOLAKE_SOURCE_REPOSITORY_ID || '',
|
||||
channelId: process.env.HOLOLAKE_SOURCE_CHANNEL_ID || '',
|
||||
sourceOwnerId: process.env.HOLOLAKE_SOURCE_OWNER_ID || '',
|
||||
authorizerId: process.env.HOLOLAKE_HUMAN_AUTHORIZER_ID || '',
|
||||
personaId: process.env.HOLOLAKE_PERSONA_ID || '',
|
||||
executionRuntimeId: process.env.HOLOLAKE_EXECUTION_RUNTIME_ID || '',
|
||||
target: process.env.HOLOLAKE_DEPLOY_TARGET || '',
|
||||
}
|
||||
const result = validateDeploymentSource(policy, context)
|
||||
if (!result.ok) {
|
||||
console.error(`HoloLake deployment source rejected: ${result.reason}`)
|
||||
process.exitCode = 77
|
||||
return
|
||||
}
|
||||
console.log(`HoloLake deployment source verified: ${context.distribution} · ${context.channelId} · ${context.target}`)
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
await run()
|
||||
}
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import { validateDeploymentSource } from './deployment-source-guard.mjs'
|
||||
|
||||
const policy = {
|
||||
schema: 'hololake.source-route-policy/v1',
|
||||
routes: [
|
||||
{
|
||||
distribution: 'personal',
|
||||
repository_id: 'REPO-008',
|
||||
channel_id: 'HLP-CHANNEL-0001',
|
||||
source_node: 'JD-FD-PRIMARY',
|
||||
source_owner_id: 'ICE-GL∞',
|
||||
allowed_authorizers: ['ICE-GL∞'],
|
||||
allowed_personas: ['ICE-GL-ZY001'],
|
||||
allowed_execution_runtimes: ['SYS-GLW-ZY-EXEC-0001'],
|
||||
allowed_targets: ['JD-FD-PRIMARY'],
|
||||
deployment_enabled: true,
|
||||
},
|
||||
{
|
||||
distribution: 'team',
|
||||
repository_id: null,
|
||||
channel_id: null,
|
||||
source_node: 'AW-GZ-001',
|
||||
allowed_personas: [],
|
||||
allowed_targets: ['AW-GZ-001'],
|
||||
deployment_enabled: false,
|
||||
},
|
||||
{
|
||||
distribution: 'public-module',
|
||||
repository_id: null,
|
||||
channel_id: null,
|
||||
source_node: null,
|
||||
allowed_personas: [],
|
||||
allowed_targets: [],
|
||||
deployment_enabled: false,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
test('allows Ice Shuo authorization plus Zhuyuan main control to package the personal distribution', () => {
|
||||
assert.deepEqual(validateDeploymentSource(policy, {
|
||||
distribution: 'personal',
|
||||
repositoryId: 'REPO-008',
|
||||
channelId: 'HLP-CHANNEL-0001',
|
||||
sourceOwnerId: 'ICE-GL∞',
|
||||
authorizerId: 'ICE-GL∞',
|
||||
personaId: 'ICE-GL-ZY001',
|
||||
executionRuntimeId: 'SYS-GLW-ZY-EXEC-0001',
|
||||
target: 'JD-FD-PRIMARY',
|
||||
}), { ok: true })
|
||||
})
|
||||
|
||||
test('rejects a team persona using Ice Shuo personal source', () => {
|
||||
assert.deepEqual(validateDeploymentSource(policy, {
|
||||
distribution: 'personal',
|
||||
repositoryId: 'REPO-008',
|
||||
channelId: 'HLP-CHANNEL-0001',
|
||||
sourceOwnerId: 'ICE-GL∞',
|
||||
authorizerId: 'ICE-GL∞',
|
||||
personaId: 'AGE-TEAM-001',
|
||||
executionRuntimeId: 'SYS-GLW-ZY-EXEC-0001',
|
||||
target: 'JD-FD-PRIMARY',
|
||||
}), { ok: false, reason: 'deployment_persona_not_allowed' })
|
||||
})
|
||||
|
||||
test('blocks team deployment until its enterprise repository route is registered', () => {
|
||||
assert.deepEqual(validateDeploymentSource(policy, {
|
||||
distribution: 'team',
|
||||
repositoryId: 'REPO-008',
|
||||
channelId: 'HLP-CHANNEL-0001',
|
||||
sourceOwnerId: 'ICE-GL∞',
|
||||
authorizerId: 'ICE-GL∞',
|
||||
personaId: 'AGE-TEAM-001',
|
||||
executionRuntimeId: 'SYS-GLW-ZY-EXEC-0001',
|
||||
target: 'AW-GZ-001',
|
||||
}), { ok: false, reason: 'deployment_source_route_inactive' })
|
||||
})
|
||||
|
||||
test('public modules are reusable source and never an application deployment target', () => {
|
||||
assert.deepEqual(validateDeploymentSource(policy, {
|
||||
distribution: 'public-module',
|
||||
repositoryId: 'REPO-008',
|
||||
channelId: 'HLP-CHANNEL-0001',
|
||||
sourceOwnerId: 'ICE-GL∞',
|
||||
authorizerId: 'ICE-GL∞',
|
||||
personaId: 'ICE-GL-ZY001',
|
||||
executionRuntimeId: 'SYS-GLW-ZY-EXEC-0001',
|
||||
target: 'JD-FD-PRIMARY',
|
||||
}), { ok: false, reason: 'deployment_source_route_inactive' })
|
||||
})
|
||||
|
||||
test('rejects a human identity presented as the executing persona', () => {
|
||||
assert.deepEqual(validateDeploymentSource(policy, {
|
||||
distribution: 'personal',
|
||||
repositoryId: 'REPO-008',
|
||||
channelId: 'HLP-CHANNEL-0001',
|
||||
sourceOwnerId: 'ICE-GL∞',
|
||||
authorizerId: 'ICE-GL∞',
|
||||
personaId: 'ICE-GL∞',
|
||||
executionRuntimeId: 'SYS-GLW-ZY-EXEC-0001',
|
||||
target: 'JD-FD-PRIMARY',
|
||||
}), { ok: false, reason: 'deployment_persona_not_allowed' })
|
||||
})
|
||||
|
|
@ -0,0 +1,526 @@
|
|||
#!/usr/bin/env node
|
||||
/* global document, fetch, HTMLElement, performance, requestAnimationFrame, Response, setTimeout, URL, window */
|
||||
|
||||
import { spawn } from 'node:child_process'
|
||||
import console from 'node:console'
|
||||
import { rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import process from 'node:process'
|
||||
import { resolve } from 'node:path'
|
||||
import { chromium } from '@playwright/test'
|
||||
import {
|
||||
printSummary,
|
||||
printThresholdFailures,
|
||||
readThresholds,
|
||||
thresholdFailures,
|
||||
updateThresholds,
|
||||
writeThresholds,
|
||||
} from './editor-performance-thresholds.mjs'
|
||||
|
||||
const rootDir = process.cwd()
|
||||
const defaultThresholdsPath = resolve(rootDir, '.editor-performance-thresholds.json')
|
||||
const defaultPort = '41742'
|
||||
const scenarios = {
|
||||
small: { sectionCount: 5, title: 'Perf Small Note' },
|
||||
large: { sectionCount: 460, title: 'Perf Large Note' },
|
||||
}
|
||||
const defaultScenarioNames = Object.keys(scenarios)
|
||||
const metricLabels = {
|
||||
blockApplyMs: 'block apply',
|
||||
blockResolveMs: 'block resolve',
|
||||
editFrameMs: 'edit frame',
|
||||
editorVisibleMs: 'editor visible',
|
||||
firstContentMs: 'first content rendered',
|
||||
fullAppliedMs: 'full note applied',
|
||||
noteOpenEditorSwapMs: 'note open editor swap',
|
||||
noteOpenTotalMs: 'note open total',
|
||||
}
|
||||
|
||||
function defaultOptions() {
|
||||
return {
|
||||
baseUrl: process.env.BASE_URL ?? '',
|
||||
headful: false,
|
||||
iterations: positiveInteger(process.env.EDITOR_PERF_ITERATIONS ?? '5', 'EDITOR_PERF_ITERATIONS'),
|
||||
port: process.env.EDITOR_PERF_PORT ?? defaultPort,
|
||||
scenarioNames: defaultScenarioNames,
|
||||
thresholdsPath: defaultThresholdsPath,
|
||||
update: false,
|
||||
}
|
||||
}
|
||||
|
||||
const flagOptions = {
|
||||
'--headful': parsed => {
|
||||
parsed.headful = true
|
||||
},
|
||||
'--update': parsed => {
|
||||
parsed.update = true
|
||||
},
|
||||
}
|
||||
|
||||
const valueOptions = {
|
||||
'--base-url': (parsed, value) => {
|
||||
parsed.baseUrl = value
|
||||
},
|
||||
'--iterations': (parsed, value) => {
|
||||
parsed.iterations = positiveInteger(value, '--iterations')
|
||||
},
|
||||
'--port': (parsed, value) => {
|
||||
parsed.port = value
|
||||
},
|
||||
'--scenario': (parsed, value) => {
|
||||
parsed.scenarioNames = value.split(',').filter(Boolean)
|
||||
},
|
||||
'--thresholds': (parsed, value) => {
|
||||
parsed.thresholdsPath = value
|
||||
},
|
||||
}
|
||||
|
||||
function parseArgs(args) {
|
||||
const parsed = defaultOptions()
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
index = parseArg(parsed, args, index)
|
||||
}
|
||||
|
||||
validateScenarioNames(parsed.scenarioNames)
|
||||
return parsed
|
||||
}
|
||||
|
||||
function parseArg(parsed, args, index) {
|
||||
const arg = args[index]
|
||||
if (arg === '--') return index
|
||||
if (arg === '--help' || arg === '-h') exitWithHelp(0)
|
||||
if (flagOptions[arg]) {
|
||||
flagOptions[arg](parsed)
|
||||
return index
|
||||
}
|
||||
if (valueOptions[arg]) {
|
||||
valueOptions[arg](parsed, requiredValue(args, index, arg))
|
||||
return index + 1
|
||||
}
|
||||
console.error(`Unknown argument: ${arg}`)
|
||||
exitWithHelp(2)
|
||||
return index
|
||||
}
|
||||
|
||||
function validateScenarioNames(scenarioNames) {
|
||||
for (const scenarioName of scenarioNames) {
|
||||
if (!(scenarioName in scenarios)) {
|
||||
console.error(`Unknown scenario: ${scenarioName}`)
|
||||
process.exit(2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function exitWithHelp(code) {
|
||||
printHelp()
|
||||
process.exit(code)
|
||||
}
|
||||
|
||||
const options = parseArgs(process.argv.slice(2))
|
||||
const thresholdsPath = resolve(rootDir, options.thresholdsPath)
|
||||
let devServer = null
|
||||
let stoppingDevServer = false
|
||||
|
||||
function requiredValue(args, index, name) {
|
||||
const value = args[index + 1]
|
||||
if (!value || value.startsWith('--')) {
|
||||
console.error(`${name} requires a value`)
|
||||
process.exit(2)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function positiveInteger(value, name) {
|
||||
if (/^[1-9][0-9]*$/.test(String(value))) return Number(value)
|
||||
console.error(`${name} must be a positive integer`)
|
||||
process.exit(2)
|
||||
}
|
||||
|
||||
function printHelp() {
|
||||
console.log(`Usage: pnpm perf:editor [options]
|
||||
|
||||
Options:
|
||||
--base-url <url> Reuse an existing dev server instead of starting Vite.
|
||||
--iterations <count> Runs per scenario. Default: 5.
|
||||
--scenario <names> Comma-separated scenarios: small,large. Default: both.
|
||||
--thresholds <path> Threshold JSON path. Default: .editor-performance-thresholds.json.
|
||||
--update Ratchet stored baselines and thresholds from the current run.
|
||||
--headful Run Chromium headed for debugging.
|
||||
`)
|
||||
}
|
||||
|
||||
function median(values) {
|
||||
const numeric = values.filter(value => typeof value === 'number' && Number.isFinite(value))
|
||||
if (numeric.length === 0) return null
|
||||
const sorted = [...numeric].sort((a, b) => a - b)
|
||||
const middle = Math.floor(sorted.length / 2)
|
||||
return sorted.length % 2 === 0
|
||||
? (sorted[middle - 1] + sorted[middle]) / 2
|
||||
: sorted[middle]
|
||||
}
|
||||
|
||||
function round(value) {
|
||||
return value === null ? null : Math.round(value * 10) / 10
|
||||
}
|
||||
|
||||
function largeMarkdown(sectionCount, title) {
|
||||
const paragraphs = Array.from({ length: sectionCount }, (_, index) => {
|
||||
const ordinal = index + 1
|
||||
return [
|
||||
`## Section ${ordinal}`,
|
||||
'',
|
||||
`Paragraph ${ordinal} keeps the large editor path realistic with **bold text**, *italic text*, `,
|
||||
`a wikilink to [[Build Laputa App]], and a [reference link](https://example.com/${ordinal}). `,
|
||||
'The text is intentionally long enough to push the source past the worker-backed parser threshold.',
|
||||
].join('')
|
||||
})
|
||||
|
||||
return [
|
||||
'---',
|
||||
`title: ${title}`,
|
||||
'type: Note',
|
||||
'---',
|
||||
'',
|
||||
`# ${title}`,
|
||||
'',
|
||||
...paragraphs,
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
function syntheticEntry({ markdown, title }) {
|
||||
return {
|
||||
aliases: [],
|
||||
archived: false,
|
||||
belongsTo: [],
|
||||
color: null,
|
||||
createdAt: Math.floor(Date.now() / 1000) - 60,
|
||||
favorite: false,
|
||||
favoriteIndex: null,
|
||||
fileSize: markdown.length,
|
||||
filename: `${title.toLowerCase().replace(/\s+/g, '-')}.md`,
|
||||
hasH1: true,
|
||||
icon: null,
|
||||
isA: 'Note',
|
||||
listPropertiesDisplay: [],
|
||||
modifiedAt: Math.floor(Date.now() / 1000) + 60,
|
||||
order: null,
|
||||
organized: false,
|
||||
outgoingLinks: ['build-laputa-app'],
|
||||
path: `/Users/luca/Laputa/${title.toLowerCase().replace(/\s+/g, '-')}.md`,
|
||||
properties: {},
|
||||
relationships: {},
|
||||
relatedTo: [],
|
||||
sidebarLabel: null,
|
||||
snippet: 'Synthetic note for editor performance benchmarking.',
|
||||
sort: null,
|
||||
status: null,
|
||||
template: null,
|
||||
title,
|
||||
view: null,
|
||||
visible: null,
|
||||
wordCount: 10 * Math.max(1, Math.floor(markdown.length / 280)),
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForServer(url) {
|
||||
const deadline = Date.now() + 30_000
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const response = await fetch(url)
|
||||
if (response.ok) return
|
||||
} catch (error) {
|
||||
void error
|
||||
}
|
||||
await new Promise(resolveWait => setTimeout(resolveWait, 250))
|
||||
}
|
||||
throw new Error(`Timed out waiting for dev server: ${url}`)
|
||||
}
|
||||
|
||||
async function startDevServer() {
|
||||
if (options.baseUrl) return options.baseUrl
|
||||
|
||||
const baseUrl = `http://127.0.0.1:${options.port}`
|
||||
const viteCacheDir = resolve(tmpdir(), `tolaria-editor-perf-vite-${options.port}`)
|
||||
devServer = spawn(
|
||||
'pnpm',
|
||||
['dev', '--host', '127.0.0.1', '--port', options.port, '--strictPort'],
|
||||
{
|
||||
cwd: rootDir,
|
||||
env: { ...process.env, TOLARIA_VITE_CACHE_DIR: viteCacheDir },
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
},
|
||||
)
|
||||
devServer.stdout?.on('data', chunk => process.stdout.write(`[perf-server] ${chunk}`))
|
||||
devServer.stderr?.on('data', chunk => process.stderr.write(`[perf-server] ${chunk}`))
|
||||
devServer.on('exit', (code, signal) => {
|
||||
if (stoppingDevServer) return
|
||||
if (signal || code === 0) return
|
||||
console.error(`[perf-server] exited with status ${code}`)
|
||||
})
|
||||
|
||||
await waitForServer(baseUrl)
|
||||
return baseUrl
|
||||
}
|
||||
|
||||
function stopDevServer() {
|
||||
if (!devServer || devServer.killed) return
|
||||
stoppingDevServer = true
|
||||
devServer.stdout?.removeAllListeners('data')
|
||||
devServer.stderr?.removeAllListeners('data')
|
||||
devServer.kill('SIGTERM')
|
||||
}
|
||||
|
||||
async function installSyntheticVault(page, entry, markdown) {
|
||||
await page.addInitScript(({ syntheticEntryValue, syntheticMarkdown }) => {
|
||||
const jsonResponse = value => new Response(JSON.stringify(value), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
status: 200,
|
||||
})
|
||||
const requestPath = (input) => {
|
||||
const rawUrl = typeof input === 'string'
|
||||
? input
|
||||
: input && typeof input === 'object' && 'url' in input
|
||||
? input.url
|
||||
: String(input)
|
||||
return new URL(rawUrl, window.location.href).pathname
|
||||
}
|
||||
const originalFetch = window.fetch.bind(window)
|
||||
const syntheticResponses = {
|
||||
'/api/vault/all-content': () => jsonResponse([{ content: syntheticMarkdown, path: syntheticEntryValue.path }]),
|
||||
'/api/vault/content': () => jsonResponse({ content: syntheticMarkdown }),
|
||||
'/api/vault/entry': () => jsonResponse(syntheticEntryValue),
|
||||
'/api/vault/list': () => jsonResponse([syntheticEntryValue]),
|
||||
'/api/vault/ping': () => new Response('ok', { status: 200 }),
|
||||
'/api/vault/search': () => jsonResponse([syntheticEntryValue]),
|
||||
}
|
||||
window.fetch = async (input, init) => {
|
||||
const responseFactory = syntheticResponses[requestPath(input)]
|
||||
if (responseFactory) return responseFactory()
|
||||
return originalFetch(input, init)
|
||||
}
|
||||
|
||||
const withSyntheticEntry = (result) => {
|
||||
const entries = Array.isArray(result) ? result : []
|
||||
return [
|
||||
syntheticEntryValue,
|
||||
...entries.filter(candidate => candidate.path !== syntheticEntryValue.path),
|
||||
]
|
||||
}
|
||||
const matchesSyntheticPath = args => args?.path === syntheticEntryValue.path
|
||||
const handlerPatches = {
|
||||
get_note_content: original => args => (
|
||||
matchesSyntheticPath(args) ? syntheticMarkdown : original?.(args) ?? ''
|
||||
),
|
||||
list_vault: original => args => withSyntheticEntry(original?.(args)),
|
||||
reload_vault: original => args => withSyntheticEntry(original?.(args)),
|
||||
reload_vault_entry: original => args => (
|
||||
matchesSyntheticPath(args) ? syntheticEntryValue : original?.(args)
|
||||
),
|
||||
validate_note_content: original => args => (
|
||||
matchesSyntheticPath(args)
|
||||
? args.content === syntheticMarkdown
|
||||
: Boolean(original?.(args))
|
||||
),
|
||||
}
|
||||
|
||||
const patchHandlers = (handlers) => {
|
||||
if (!handlers || handlers.__editorPerformancePatched) return handlers ?? null
|
||||
for (const [name, createHandler] of Object.entries(handlerPatches)) {
|
||||
handlers[name] = createHandler(handlers[name])
|
||||
}
|
||||
handlers.__editorPerformancePatched = true
|
||||
return handlers
|
||||
}
|
||||
|
||||
let handlersRef = patchHandlers(window.__mockHandlers)
|
||||
Object.defineProperty(window, '__mockHandlers', {
|
||||
configurable: true,
|
||||
get() {
|
||||
return handlersRef ?? undefined
|
||||
},
|
||||
set(value) {
|
||||
handlersRef = patchHandlers(value)
|
||||
},
|
||||
})
|
||||
}, { syntheticEntryValue: entry, syntheticMarkdown: markdown })
|
||||
}
|
||||
|
||||
async function measureEditFrame(page) {
|
||||
return await page.evaluate(async () => {
|
||||
const root = document.querySelector('.bn-editor')
|
||||
const editable = root?.querySelector('[contenteditable="true"]') ?? root
|
||||
if (!root || !(editable instanceof HTMLElement)) return null
|
||||
|
||||
editable.focus()
|
||||
const startedAt = performance.now()
|
||||
document.execCommand('insertText', false, 'x')
|
||||
await new Promise(resolveFrame => requestAnimationFrame(() => resolveFrame()))
|
||||
return performance.now() - startedAt
|
||||
})
|
||||
}
|
||||
|
||||
function durationFromLog(logs, pattern) {
|
||||
for (const line of logs) {
|
||||
const match = line.match(pattern)
|
||||
if (match?.[1]) return Number(match[1])
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function parsePerfMetrics(perfLogs) {
|
||||
return {
|
||||
blockApplyMs: durationFromLog(perfLogs, /editorBlockApply .* duration=([\d.]+)ms/),
|
||||
blockResolveMs: durationFromLog(perfLogs, /editorBlockResolve .* duration=([\d.]+)ms/),
|
||||
noteOpenEditorSwapMs: durationFromLog(perfLogs, /noteOpen .* editorSwap=([\d.]+)ms/),
|
||||
noteOpenTotalMs: durationFromLog(perfLogs, /noteOpen .* total=([\d.]+)ms/),
|
||||
}
|
||||
}
|
||||
|
||||
async function runIteration({ baseUrl, browser, index, scenario, scenarioName }) {
|
||||
const markdown = largeMarkdown(scenario.sectionCount, scenario.title)
|
||||
const entry = syntheticEntry({ markdown, title: scenario.title })
|
||||
const context = await browser.newContext()
|
||||
const page = await context.newPage()
|
||||
const perfLogs = []
|
||||
page.on('console', (message) => {
|
||||
const text = message.text()
|
||||
if (text.includes('[perf]')) perfLogs.push(text)
|
||||
})
|
||||
|
||||
await installSyntheticVault(page, entry, markdown)
|
||||
await page.goto(baseUrl)
|
||||
await page.waitForLoadState('domcontentloaded')
|
||||
await page.getByText('Set up later', { exact: true }).click({ timeout: 12_000 }).catch(() => {})
|
||||
|
||||
const title = page.getByText(scenario.title, { exact: true }).first()
|
||||
await title.waitFor({ state: 'visible', timeout: 30_000 })
|
||||
|
||||
const startedAt = await page.evaluate(() => performance.now())
|
||||
await title.click()
|
||||
|
||||
await page.locator('.editor__blocknote-container').waitFor({ state: 'visible', timeout: 30_000 })
|
||||
await page.locator('.bn-editor').waitFor({ state: 'visible', timeout: 30_000 })
|
||||
const editorVisibleAt = await page.evaluate(() => performance.now())
|
||||
|
||||
await page.waitForFunction(() => {
|
||||
const editor = document.querySelector('.bn-editor')
|
||||
return editor?.textContent?.includes('Section 1') === true
|
||||
}, undefined, { timeout: 30_000 })
|
||||
const firstContentAt = await page.evaluate(() => performance.now())
|
||||
|
||||
await page.waitForFunction((expectedSectionCount) => {
|
||||
const editor = document.querySelector('.bn-editor')
|
||||
return editor?.textContent?.includes(`Section ${expectedSectionCount}`) === true
|
||||
}, scenario.sectionCount, { timeout: 30_000 })
|
||||
const fullAppliedAt = await page.evaluate(() => performance.now())
|
||||
|
||||
await page.locator('.bn-editor').click({ timeout: 10_000 })
|
||||
const editFrameMs = []
|
||||
for (let sample = 0; sample < 8; sample += 1) {
|
||||
const value = await measureEditFrame(page)
|
||||
if (typeof value === 'number') editFrameMs.push(value)
|
||||
await page.waitForTimeout(80)
|
||||
}
|
||||
|
||||
await context.close()
|
||||
return {
|
||||
...parsePerfMetrics(perfLogs),
|
||||
editFrameMs,
|
||||
editorVisibleMs: editorVisibleAt - startedAt,
|
||||
firstContentMs: firstContentAt - startedAt,
|
||||
fullAppliedMs: fullAppliedAt - startedAt,
|
||||
index,
|
||||
perfLogs,
|
||||
scenario: scenarioName,
|
||||
}
|
||||
}
|
||||
|
||||
function summarizeScenario(scenarioName, scenario, runs) {
|
||||
const editFrameSamples = runs.flatMap(run => run.editFrameMs)
|
||||
const medians = {
|
||||
blockApplyMs: round(median(runs.map(run => run.blockApplyMs))),
|
||||
blockResolveMs: round(median(runs.map(run => run.blockResolveMs))),
|
||||
editFrameMs: round(median(editFrameSamples)),
|
||||
editorVisibleMs: round(median(runs.map(run => run.editorVisibleMs))),
|
||||
firstContentMs: round(median(runs.map(run => run.firstContentMs))),
|
||||
fullAppliedMs: round(median(runs.map(run => run.fullAppliedMs))),
|
||||
noteOpenEditorSwapMs: round(median(runs.map(run => run.noteOpenEditorSwapMs))),
|
||||
noteOpenTotalMs: round(median(runs.map(run => run.noteOpenTotalMs))),
|
||||
}
|
||||
|
||||
return {
|
||||
contentBytes: largeMarkdown(scenario.sectionCount, scenario.title).length,
|
||||
medians,
|
||||
runs: runs.map(run => ({
|
||||
...run,
|
||||
blockApplyMs: round(run.blockApplyMs),
|
||||
blockResolveMs: round(run.blockResolveMs),
|
||||
editFrameMs: run.editFrameMs.map(round),
|
||||
editorVisibleMs: round(run.editorVisibleMs),
|
||||
firstContentMs: round(run.firstContentMs),
|
||||
fullAppliedMs: round(run.fullAppliedMs),
|
||||
noteOpenEditorSwapMs: round(run.noteOpenEditorSwapMs),
|
||||
noteOpenTotalMs: round(run.noteOpenTotalMs),
|
||||
})),
|
||||
scenario: scenarioName,
|
||||
sectionCount: scenario.sectionCount,
|
||||
}
|
||||
}
|
||||
|
||||
async function runBenchmarks(baseUrl) {
|
||||
const browser = await chromium.launch({
|
||||
headless: !options.headful,
|
||||
...(process.env.PLAYWRIGHT_EXECUTABLE_PATH
|
||||
? { executablePath: process.env.PLAYWRIGHT_EXECUTABLE_PATH }
|
||||
: {}),
|
||||
})
|
||||
const summaries = {}
|
||||
try {
|
||||
for (const scenarioName of options.scenarioNames) {
|
||||
const scenario = scenarios[scenarioName]
|
||||
console.log(`[perf] scenario=${scenarioName} sections=${scenario.sectionCount}`)
|
||||
const runs = []
|
||||
for (let index = 1; index <= options.iterations; index += 1) {
|
||||
const run = await runIteration({ baseUrl, browser, index, scenario, scenarioName })
|
||||
runs.push(run)
|
||||
console.log(
|
||||
`[perf] ${scenarioName} run=${index} `
|
||||
+ `visible=${round(run.editorVisibleMs)}ms `
|
||||
+ `first=${round(run.firstContentMs)}ms `
|
||||
+ `full=${round(run.fullAppliedMs)}ms `
|
||||
+ `edit=${round(median(run.editFrameMs))}ms`,
|
||||
)
|
||||
}
|
||||
summaries[scenarioName] = summarizeScenario(scenarioName, scenario, runs)
|
||||
}
|
||||
} finally {
|
||||
await browser.close()
|
||||
}
|
||||
return summaries
|
||||
}
|
||||
|
||||
const startedBaseUrl = await startDevServer()
|
||||
try {
|
||||
const summaries = await runBenchmarks(startedBaseUrl)
|
||||
const thresholds = await readThresholds(thresholdsPath)
|
||||
const activeThresholds = options.update ? updateThresholds(thresholds, summaries) : thresholds
|
||||
|
||||
printSummary({ metricLabels, summaries, thresholds: activeThresholds })
|
||||
|
||||
if (options.update) {
|
||||
await writeThresholds(thresholdsPath, activeThresholds)
|
||||
console.log(`\nUpdated ${thresholdsPath}`)
|
||||
}
|
||||
|
||||
const failures = thresholdFailures(activeThresholds, summaries)
|
||||
let exitCode = 0
|
||||
if (failures.length > 0) {
|
||||
printThresholdFailures({ failures, metricLabels })
|
||||
exitCode = 1
|
||||
}
|
||||
|
||||
await rm(resolve(rootDir, 'test-results'), { recursive: true, force: true })
|
||||
if (exitCode !== 0) process.exit(exitCode)
|
||||
} finally {
|
||||
stopDevServer()
|
||||
}
|
||||
|
|
@ -0,0 +1,113 @@
|
|||
import console from 'node:console'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { readFile, writeFile } from 'node:fs/promises'
|
||||
|
||||
const thresholdDescription = 'Ratcheted editor performance budgets for synthetic small and large note opens. Lower is better; maxMs values should only move down unless intentionally rebaselined.'
|
||||
|
||||
export async function readThresholds(thresholdsPath) {
|
||||
if (!existsSync(thresholdsPath)) {
|
||||
return { scenarios: {}, version: 1 }
|
||||
}
|
||||
return JSON.parse(await readFile(thresholdsPath, 'utf8'))
|
||||
}
|
||||
|
||||
export async function writeThresholds(thresholdsPath, thresholds) {
|
||||
await writeFile(thresholdsPath, `${JSON.stringify(thresholds, null, 2)}\n`)
|
||||
}
|
||||
|
||||
export function updateThresholds(thresholds, summaries) {
|
||||
const next = {
|
||||
...thresholds,
|
||||
description: thresholdDescription,
|
||||
scenarios: { ...thresholds.scenarios },
|
||||
updatedAt: new Date().toISOString(),
|
||||
version: 1,
|
||||
}
|
||||
|
||||
for (const [scenarioName, summary] of Object.entries(summaries)) {
|
||||
next.scenarios[scenarioName] = updatedScenarioThreshold(thresholds, scenarioName, summary)
|
||||
}
|
||||
|
||||
return next
|
||||
}
|
||||
|
||||
export function thresholdFailures(thresholds, summaries) {
|
||||
return Object.entries(summaries).flatMap(([scenarioName, summary]) => (
|
||||
scenarioThresholdFailures(thresholds, scenarioName, summary)
|
||||
))
|
||||
}
|
||||
|
||||
export function printSummary({ metricLabels, summaries, thresholds, writeLine = console.log }) {
|
||||
for (const [scenarioName, summary] of Object.entries(summaries)) {
|
||||
writeLine(`\n${scenarioName} (${summary.contentBytes} bytes, ${summary.sectionCount} sections)`)
|
||||
for (const [metricName, value] of currentMetricEntries(summary)) {
|
||||
writeLine(summaryMetricLine({
|
||||
label: metricLabels[metricName] ?? metricName,
|
||||
maxMs: thresholds.scenarios?.[scenarioName]?.metrics?.[metricName]?.maxMs,
|
||||
value,
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function printThresholdFailures({ failures, metricLabels, writeLine = console.error }) {
|
||||
writeLine('\nEditor performance thresholds failed:')
|
||||
for (const failure of failures) {
|
||||
const label = metricLabels[failure.metricName] ?? failure.metricName
|
||||
writeLine(` ${failure.scenarioName} ${label}: ${failure.value}ms > ${failure.maxMs}ms`)
|
||||
}
|
||||
}
|
||||
|
||||
function updatedScenarioThreshold(thresholds, scenarioName, summary) {
|
||||
const previousScenario = thresholds.scenarios?.[scenarioName] ?? {}
|
||||
return {
|
||||
contentBytes: summary.contentBytes,
|
||||
metrics: updatedMetricThresholds(previousScenario.metrics ?? {}, summary),
|
||||
sectionCount: summary.sectionCount,
|
||||
}
|
||||
}
|
||||
|
||||
function updatedMetricThresholds(previousMetrics, summary) {
|
||||
return Object.fromEntries(currentMetricEntries(summary).map(([metricName, value]) => [
|
||||
metricName,
|
||||
{
|
||||
baselineMs: value,
|
||||
maxMs: ratchetedMax(metricName, previousMetrics[metricName], value),
|
||||
},
|
||||
]))
|
||||
}
|
||||
|
||||
function currentMetricEntries(summary) {
|
||||
return Object.entries(summary.medians)
|
||||
.filter(([, value]) => typeof value === 'number' && Number.isFinite(value))
|
||||
}
|
||||
|
||||
function ratchetedMax(metricName, existingMetric, value) {
|
||||
const observedBudget = metricName === 'editFrameMs'
|
||||
? Math.ceil(Math.max(value * 2.5, value + 8, 16))
|
||||
: Math.ceil(Math.max(value * 1.35, value + 25))
|
||||
if (!existingMetric?.maxMs) return observedBudget
|
||||
return Math.min(existingMetric.maxMs, observedBudget)
|
||||
}
|
||||
|
||||
function scenarioThresholdFailures(thresholds, scenarioName, summary) {
|
||||
return currentMetricEntries(summary)
|
||||
.map(([metricName, value]) => metricFailure(thresholds, scenarioName, metricName, value))
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
function metricFailure(thresholds, scenarioName, metricName, value) {
|
||||
const maxMs = thresholds.scenarios?.[scenarioName]?.metrics?.[metricName]?.maxMs
|
||||
if (typeof maxMs !== 'number' || value <= maxMs) return null
|
||||
return {
|
||||
maxMs,
|
||||
metricName,
|
||||
scenarioName,
|
||||
value,
|
||||
}
|
||||
}
|
||||
|
||||
function summaryMetricLine({ label, maxMs, value }) {
|
||||
const suffix = typeof maxMs === 'number' ? ` / max ${maxMs}ms` : ''
|
||||
return ` ${label.padEnd(24)} ${String(value).padStart(6)}ms${suffix}`
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
import { rename } from 'node:fs/promises'
|
||||
|
||||
await rename('dist/team-foundation.html', 'dist/index.html')
|
||||
1197
product-source/hololake-platform/scripts/generate_demo_vault.py
Normal file
1197
product-source/hololake-platform/scripts/generate_demo_vault.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,37 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
|
||||
const SEMVER = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/
|
||||
|
||||
export async function resolveInternalReleaseVersion(configPath, explicitVersion = '') {
|
||||
const config = JSON.parse(await readFile(configPath, 'utf8'))
|
||||
const configuredVersion = String(config.version ?? '').trim()
|
||||
|
||||
if (!SEMVER.test(configuredVersion)) {
|
||||
throw new Error(`tauri.conf.json must contain a valid semantic version; received: ${configuredVersion || '<empty>'}`)
|
||||
}
|
||||
|
||||
const requestedVersion = explicitVersion.trim()
|
||||
if (requestedVersion && requestedVersion !== configuredVersion) {
|
||||
throw new Error(`Requested version ${requestedVersion} does not match configured version ${configuredVersion}`)
|
||||
}
|
||||
|
||||
return configuredVersion
|
||||
}
|
||||
|
||||
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
const configPath = process.argv[2]
|
||||
if (!configPath) {
|
||||
console.error('Usage: node scripts/internal-release-version.mjs <tauri.conf.json> [expected-version]')
|
||||
process.exit(2)
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(await resolveInternalReleaseVersion(configPath, process.argv[3] ?? ''))
|
||||
} catch (error) {
|
||||
console.error(error instanceof Error ? error.message : String(error))
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
import assert from 'node:assert/strict'
|
||||
import { mkdtemp, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import test from 'node:test'
|
||||
|
||||
import { resolveInternalReleaseVersion } from './internal-release-version.mjs'
|
||||
|
||||
test('reads the version from tauri.conf.json', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'hololake-release-version-'))
|
||||
await writeFile(join(root, 'tauri.conf.json'), JSON.stringify({ version: '1.2.3' }))
|
||||
|
||||
assert.equal(await resolveInternalReleaseVersion(join(root, 'tauri.conf.json')), '1.2.3')
|
||||
})
|
||||
|
||||
test('accepts an explicit matching version', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'hololake-release-version-'))
|
||||
await writeFile(join(root, 'tauri.conf.json'), JSON.stringify({ version: '1.2.3' }))
|
||||
|
||||
assert.equal(await resolveInternalReleaseVersion(join(root, 'tauri.conf.json'), '1.2.3'), '1.2.3')
|
||||
})
|
||||
|
||||
test('rejects a version that differs from the application configuration', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'hololake-release-version-'))
|
||||
await writeFile(join(root, 'tauri.conf.json'), JSON.stringify({ version: '1.2.3' }))
|
||||
|
||||
await assert.rejects(
|
||||
resolveInternalReleaseVersion(join(root, 'tauri.conf.json'), '1.2.4'),
|
||||
/does not match configured version 1\.2\.3/,
|
||||
)
|
||||
})
|
||||
|
||||
test('rejects malformed configured versions', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'hololake-release-version-'))
|
||||
await writeFile(join(root, 'tauri.conf.json'), JSON.stringify({ version: 'tomorrow' }))
|
||||
|
||||
await assert.rejects(
|
||||
resolveInternalReleaseVersion(join(root, 'tauri.conf.json')),
|
||||
/valid semantic version/,
|
||||
)
|
||||
})
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import { spawn } from 'node:child_process'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const port = process.argv[2] ?? process.env.PORT ?? '41741'
|
||||
const viteCacheDir = process.env.TOLARIA_VITE_CACHE_DIR ?? join(tmpdir(), `tolaria-vite-smoke-${port}`)
|
||||
|
||||
const child = spawn(
|
||||
'pnpm',
|
||||
['dev', '--host', '127.0.0.1', '--port', port, '--strictPort'],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
env: {
|
||||
...process.env,
|
||||
TOLARIA_VITE_CACHE_DIR: viteCacheDir,
|
||||
},
|
||||
stdio: ['pipe', 'inherit', 'inherit'],
|
||||
},
|
||||
)
|
||||
|
||||
function forwardSignal(signal) {
|
||||
if (child.killed) return
|
||||
child.kill(signal)
|
||||
}
|
||||
|
||||
process.on('SIGINT', () => forwardSignal('SIGINT'))
|
||||
process.on('SIGTERM', () => forwardSignal('SIGTERM'))
|
||||
|
||||
child.on('exit', (code, signal) => {
|
||||
if (signal) {
|
||||
process.kill(process.pid, signal)
|
||||
return
|
||||
}
|
||||
|
||||
process.exit(code ?? 1)
|
||||
})
|
||||
|
|
@ -0,0 +1,200 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import { spawn } from 'node:child_process'
|
||||
import console from 'node:console'
|
||||
import { readFile, rm } from 'node:fs/promises'
|
||||
import { createRequire } from 'node:module'
|
||||
import os from 'node:os'
|
||||
import process from 'node:process'
|
||||
import { resolve } from 'node:path'
|
||||
|
||||
const rootDir = process.cwd()
|
||||
const forwardedArgs = process.argv.slice(2)
|
||||
const totalShards = positiveInteger(process.env.FRONTEND_COVERAGE_SHARDS ?? '2', 'FRONTEND_COVERAGE_SHARDS')
|
||||
const concurrency = positiveInteger(
|
||||
process.env.FRONTEND_COVERAGE_CONCURRENCY ?? String(totalShards),
|
||||
'FRONTEND_COVERAGE_CONCURRENCY',
|
||||
)
|
||||
const runId = `${Date.now()}-${process.pid}`
|
||||
const shardRoot = resolve(os.tmpdir(), 'tolaria-vitest-coverage-shards', runId)
|
||||
const finalCoverageDir = resolve(rootDir, 'coverage')
|
||||
const coverageRequire = createCoverageRequire()
|
||||
const { createCoverageMap } = coverageRequire('istanbul-lib-coverage')
|
||||
const libReport = coverageRequire('istanbul-lib-report')
|
||||
const reports = coverageRequire('istanbul-reports')
|
||||
const thresholdPercent = Number(process.env.VITEST_COVERAGE_THRESHOLD ?? '70')
|
||||
const thresholds = {
|
||||
lines: metricThreshold('LINES'),
|
||||
functions: metricThreshold('FUNCTIONS'),
|
||||
branches: metricThreshold('BRANCHES'),
|
||||
statements: metricThreshold('STATEMENTS'),
|
||||
}
|
||||
|
||||
function positiveInteger(value, name) {
|
||||
if (/^[1-9][0-9]*$/.test(value)) {
|
||||
return Number(value)
|
||||
}
|
||||
|
||||
console.error(`${name} must be a positive integer`)
|
||||
process.exit(2)
|
||||
}
|
||||
|
||||
function metricThreshold(metricName) {
|
||||
const value = process.env[`VITEST_COVERAGE_${metricName}_THRESHOLD`]
|
||||
return value === undefined ? thresholdPercent : Number(value)
|
||||
}
|
||||
|
||||
function createCoverageRequire() {
|
||||
const require = createRequire(import.meta.url)
|
||||
const coveragePackagePath = require.resolve('@vitest/coverage-v8/package.json')
|
||||
return createRequire(coveragePackagePath)
|
||||
}
|
||||
|
||||
function shardLabel(shardIndex) {
|
||||
return `${shardIndex}/${totalShards}`
|
||||
}
|
||||
|
||||
function shardCoverageDir(shardIndex) {
|
||||
return resolve(shardRoot, `shard-${shardIndex}`)
|
||||
}
|
||||
|
||||
async function clearVitestCache() {
|
||||
const exitCode = await spawnCommand('clear-cache', 'pnpm', ['exec', 'vitest', '--clearCache'], process.env)
|
||||
if (exitCode !== 0) {
|
||||
throw new Error(`Vitest cache clear failed with exit code ${exitCode}`)
|
||||
}
|
||||
}
|
||||
|
||||
function runShard(shardIndex) {
|
||||
const env = {
|
||||
...process.env,
|
||||
VITEST_COVERAGE_FINAL_DIR: shardCoverageDir(shardIndex),
|
||||
VITEST_COVERAGE_SHARD: shardLabel(shardIndex),
|
||||
VITEST_COVERAGE_SKIP_CLEAR_CACHE: '1',
|
||||
VITEST_COVERAGE_SKIP_THRESHOLDS: '1',
|
||||
}
|
||||
|
||||
return spawnCommand(
|
||||
`coverage-${shardIndex}`,
|
||||
process.execPath,
|
||||
['scripts/run-vitest-coverage.mjs', '--coverage.reporter=json', ...forwardedArgs],
|
||||
env,
|
||||
)
|
||||
}
|
||||
|
||||
function spawnCommand(name, command, args, env) {
|
||||
return new Promise((resolveExit, rejectExit) => {
|
||||
console.log(`[${name}] started`)
|
||||
const child = spawn(command, args, {
|
||||
cwd: rootDir,
|
||||
env,
|
||||
stdio: ['inherit', 'pipe', 'pipe'],
|
||||
})
|
||||
|
||||
child.stdout?.on('data', (chunk) => process.stdout.write(`[${name}] ${chunk}`))
|
||||
child.stderr?.on('data', (chunk) => process.stderr.write(`[${name}] ${chunk}`))
|
||||
child.on('error', rejectExit)
|
||||
child.on('exit', (code, signal) => {
|
||||
if (signal) {
|
||||
rejectExit(new Error(`${name} exited via signal: ${signal}`))
|
||||
return
|
||||
}
|
||||
|
||||
const exitCode = code ?? 1
|
||||
console.log(`[${name}] exited with status ${exitCode}`)
|
||||
resolveExit(exitCode)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function runShardBatch(firstShard) {
|
||||
const shardIndexes = []
|
||||
for (
|
||||
let shardIndex = firstShard;
|
||||
shardIndex <= totalShards && shardIndexes.length < concurrency;
|
||||
shardIndex += 1
|
||||
) {
|
||||
shardIndexes.push(shardIndex)
|
||||
}
|
||||
|
||||
const results = await Promise.all(shardIndexes.map((shardIndex) => runShard(shardIndex)))
|
||||
return results.every((exitCode) => exitCode === 0)
|
||||
}
|
||||
|
||||
async function runShards() {
|
||||
let firstShard = 1
|
||||
|
||||
while (firstShard <= totalShards) {
|
||||
if (!(await runShardBatch(firstShard))) {
|
||||
console.error(`Coverage shard artifacts preserved at ${shardRoot}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
firstShard += concurrency
|
||||
}
|
||||
}
|
||||
|
||||
async function readShardCoverage(shardIndex) {
|
||||
const coveragePath = resolve(shardCoverageDir(shardIndex), 'coverage-final.json')
|
||||
return JSON.parse(await readFile(coveragePath, 'utf8'))
|
||||
}
|
||||
|
||||
async function mergeCoverage() {
|
||||
const coverageMap = createCoverageMap({})
|
||||
|
||||
for (let shardIndex = 1; shardIndex <= totalShards; shardIndex += 1) {
|
||||
coverageMap.merge(await readShardCoverage(shardIndex))
|
||||
}
|
||||
|
||||
return coverageMap
|
||||
}
|
||||
|
||||
async function writeCoverageReports(coverageMap) {
|
||||
await rm(finalCoverageDir, { recursive: true, force: true })
|
||||
const context = libReport.createContext({
|
||||
coverageMap,
|
||||
dir: finalCoverageDir,
|
||||
})
|
||||
|
||||
for (const reportName of ['text', 'json', 'html', 'lcov']) {
|
||||
reports.create(reportName).execute(context)
|
||||
}
|
||||
}
|
||||
|
||||
function printCoverageSummary(summary) {
|
||||
for (const metric of ['lines', 'functions', 'branches', 'statements']) {
|
||||
const item = summary[metric]
|
||||
console.log(
|
||||
`${metric.padEnd(10)} ${String(item.pct).padStart(6)}% `
|
||||
+ `(${item.covered}/${item.total}, threshold ${thresholds[metric]}%)`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function checkCoverageThresholds(coverageMap) {
|
||||
const summary = coverageMap.getCoverageSummary().toJSON()
|
||||
printCoverageSummary(summary)
|
||||
|
||||
const failures = Object.entries(thresholds)
|
||||
.filter(([metric, threshold]) => summary[metric].pct < threshold)
|
||||
|
||||
if (failures.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
for (const [metric, threshold] of failures) {
|
||||
console.error(
|
||||
`Coverage for ${metric} (${summary[metric].pct}%) does not meet threshold ${threshold}%`,
|
||||
)
|
||||
}
|
||||
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
await clearVitestCache()
|
||||
await runShards()
|
||||
|
||||
const coverageMap = await mergeCoverage()
|
||||
await writeCoverageReports(coverageMap)
|
||||
checkCoverageThresholds(coverageMap)
|
||||
await rm(shardRoot, { recursive: true, force: true })
|
||||
203
product-source/hololake-platform/scripts/run-vitest-coverage.mjs
Normal file
203
product-source/hololake-platform/scripts/run-vitest-coverage.mjs
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import { cp, mkdir, rm } from 'node:fs/promises'
|
||||
import console from 'node:console'
|
||||
import os from 'node:os'
|
||||
import process from 'node:process'
|
||||
import { resolve } from 'node:path'
|
||||
import { spawn } from 'node:child_process'
|
||||
|
||||
const rootDir = process.cwd()
|
||||
const finalCoverageDir = resolve(rootDir, process.env.VITEST_COVERAGE_FINAL_DIR ?? 'coverage')
|
||||
const coverageRunRoot = resolve(os.tmpdir(), 'tolaria-vitest-coverage-runs')
|
||||
const forwardedArgs = process.argv.slice(2)
|
||||
const hasFileParallelismOverride = forwardedArgs.some((arg) =>
|
||||
arg === '--fileParallelism' || arg === '--no-file-parallelism'
|
||||
)
|
||||
const hasMaxWorkersOverride = forwardedArgs.some((arg) =>
|
||||
arg === '--maxWorkers' || arg.startsWith('--maxWorkers=')
|
||||
)
|
||||
const hasShardOverride = forwardedArgs.some((arg) =>
|
||||
arg === '--shard' || arg.startsWith('--shard=')
|
||||
)
|
||||
const defaultMaxWorkers = resolveDefaultMaxWorkers()
|
||||
const coverageShard = process.env.VITEST_COVERAGE_SHARD?.trim() ?? ''
|
||||
const skipCoverageThresholds = process.env.VITEST_COVERAGE_SKIP_THRESHOLDS === '1'
|
||||
const skipClearCache = process.env.VITEST_COVERAGE_SKIP_CLEAR_CACHE === '1'
|
||||
const maxAttempts = 2
|
||||
|
||||
// Standalone pnpm installs ship a native binary, so npm_execpath points at
|
||||
// a Mach-O/ELF executable. Only reuse process.execPath (node) when the path
|
||||
// is something node can actually load as a module.
|
||||
const packageManagerExec = process.env.npm_execpath
|
||||
const isJsExecpath = packageManagerExec && /\.[mc]?js$/i.test(packageManagerExec)
|
||||
const command = isJsExecpath ? process.execPath : 'pnpm'
|
||||
const baseCommandArgs = isJsExecpath
|
||||
? [packageManagerExec, 'exec', 'vitest', 'run', '--coverage']
|
||||
: ['exec', 'vitest', 'run', '--coverage']
|
||||
const clearCacheCommandArgs = isJsExecpath
|
||||
? [packageManagerExec, 'exec', 'vitest', '--clearCache']
|
||||
: ['exec', 'vitest', '--clearCache']
|
||||
|
||||
function isKnownVitestInternalStateFlake(output) {
|
||||
return output.includes('Vitest failed to access its internal state.')
|
||||
&& /Test Files\s+\d+\s+passed\s+\(\d+\)/.test(output)
|
||||
&& /Tests\s+\d+\s+passed\s+\(\d+\)/.test(output)
|
||||
}
|
||||
|
||||
function appendCapturedOutput(output, chunk) {
|
||||
const nextOutput = output + chunk
|
||||
return nextOutput.length > 200_000 ? nextOutput.slice(-200_000) : nextOutput
|
||||
}
|
||||
|
||||
function resolveDefaultMaxWorkers() {
|
||||
const value = process.env.VITEST_COVERAGE_MAX_WORKERS?.trim()
|
||||
|
||||
if (!value) {
|
||||
return '4'
|
||||
}
|
||||
|
||||
if (/^[1-9][0-9]*%?$/.test(value)) {
|
||||
return value
|
||||
}
|
||||
|
||||
console.warn(`Ignoring invalid VITEST_COVERAGE_MAX_WORKERS=${JSON.stringify(value)}; using 4`)
|
||||
return '4'
|
||||
}
|
||||
|
||||
function isValidCoverageShard(value) {
|
||||
return /^[1-9][0-9]*\/[1-9][0-9]*$/.test(value)
|
||||
}
|
||||
|
||||
function coverageThresholdOverrideArgs() {
|
||||
return [
|
||||
'--coverage.thresholds.lines=0',
|
||||
'--coverage.thresholds.functions=0',
|
||||
'--coverage.thresholds.branches=0',
|
||||
'--coverage.thresholds.statements=0',
|
||||
]
|
||||
}
|
||||
|
||||
async function runCoverageAttempt(attempt) {
|
||||
const runId = `${Date.now()}-${process.pid}-${attempt}`
|
||||
const runCoverageDir = resolve(coverageRunRoot, runId)
|
||||
const runCoverageTempDir = resolve(runCoverageDir, '.tmp')
|
||||
|
||||
await mkdir(runCoverageDir, { recursive: true })
|
||||
// Vitest writes per-worker coverage shards under reportsDirectory/.tmp.
|
||||
await mkdir(runCoverageTempDir, { recursive: true })
|
||||
if (!skipClearCache) {
|
||||
await clearVitestCache()
|
||||
}
|
||||
|
||||
const commandArgs = [
|
||||
...baseCommandArgs,
|
||||
// Keep coverage fast enough for CI while avoiding the unbounded worker
|
||||
// contention that makes a few DOM-heavy suites time out under full
|
||||
// file parallelism. Callers can still opt into serial or wider runs.
|
||||
...(hasFileParallelismOverride ? [] : ['--fileParallelism']),
|
||||
...(hasMaxWorkersOverride ? [] : [`--maxWorkers=${defaultMaxWorkers}`]),
|
||||
...(coverageShard && !hasShardOverride ? [`--shard=${coverageShard}`] : []),
|
||||
...(skipCoverageThresholds ? coverageThresholdOverrideArgs() : []),
|
||||
`--coverage.reportsDirectory=${runCoverageDir}`,
|
||||
...forwardedArgs,
|
||||
]
|
||||
let output = ''
|
||||
|
||||
const exitCode = await new Promise((resolveExit, rejectExit) => {
|
||||
const child = spawn(command, commandArgs, {
|
||||
cwd: rootDir,
|
||||
env: {
|
||||
...process.env,
|
||||
VITEST_COVERAGE_DIR: runCoverageDir,
|
||||
},
|
||||
stdio: ['inherit', 'pipe', 'pipe'],
|
||||
})
|
||||
|
||||
const handleOutput = (stream, target) => {
|
||||
if (!stream) return
|
||||
stream.setEncoding('utf8')
|
||||
stream.on('data', (chunk) => {
|
||||
target.write(chunk)
|
||||
output = appendCapturedOutput(output, chunk)
|
||||
})
|
||||
}
|
||||
|
||||
handleOutput(child.stdout, process.stdout)
|
||||
handleOutput(child.stderr, process.stderr)
|
||||
|
||||
child.on('error', rejectExit)
|
||||
child.on('exit', (code, signal) => {
|
||||
if (signal) {
|
||||
rejectExit(new Error(`Vitest coverage exited via signal: ${signal}`))
|
||||
return
|
||||
}
|
||||
|
||||
resolveExit(code ?? 1)
|
||||
})
|
||||
})
|
||||
|
||||
return {
|
||||
exitCode,
|
||||
output,
|
||||
runCoverageDir,
|
||||
}
|
||||
}
|
||||
|
||||
async function clearVitestCache() {
|
||||
const exitCode = await new Promise((resolveExit, rejectExit) => {
|
||||
const child = spawn(command, clearCacheCommandArgs, {
|
||||
cwd: rootDir,
|
||||
env: process.env,
|
||||
stdio: 'inherit',
|
||||
})
|
||||
|
||||
child.on('error', rejectExit)
|
||||
child.on('exit', (code, signal) => {
|
||||
if (signal) {
|
||||
rejectExit(new Error(`Vitest cache clear exited via signal: ${signal}`))
|
||||
return
|
||||
}
|
||||
|
||||
resolveExit(code ?? 1)
|
||||
})
|
||||
})
|
||||
|
||||
if (exitCode !== 0) {
|
||||
throw new Error(`Vitest cache clear failed with exit code ${exitCode}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (coverageShard && !isValidCoverageShard(coverageShard)) {
|
||||
console.error(`Invalid VITEST_COVERAGE_SHARD=${JSON.stringify(coverageShard)}; expected index/total`)
|
||||
process.exit(2)
|
||||
}
|
||||
|
||||
let finalRun = null
|
||||
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
||||
const run = await runCoverageAttempt(attempt)
|
||||
finalRun = run
|
||||
|
||||
if (run.exitCode === 0) {
|
||||
await rm(finalCoverageDir, { recursive: true, force: true })
|
||||
await cp(run.runCoverageDir, finalCoverageDir, {
|
||||
force: true,
|
||||
recursive: true,
|
||||
})
|
||||
await rm(run.runCoverageDir, { recursive: true, force: true })
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
// Retry once when Vitest itself flakes after a fully passing suite.
|
||||
if (attempt < maxAttempts && isKnownVitestInternalStateFlake(run.output)) {
|
||||
console.error(`Vitest hit a known internal-state teardown flake on attempt ${attempt}; retrying once...`)
|
||||
await rm(run.runCoverageDir, { recursive: true, force: true })
|
||||
continue
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
console.error(`Vitest coverage artifacts preserved at ${finalRun.runCoverageDir}`)
|
||||
process.exit(finalRun.exitCode)
|
||||
284
product-source/hololake-platform/scripts/serve-demo.mjs
Normal file
284
product-source/hololake-platform/scripts/serve-demo.mjs
Normal file
|
|
@ -0,0 +1,284 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* Production static server for Laputa App demo.
|
||||
* Serves dist/ + handles /api/vault/* routes for browser testing.
|
||||
*/
|
||||
|
||||
import http from 'http'
|
||||
import { log } from 'console'
|
||||
import {
|
||||
closeSync,
|
||||
createReadStream,
|
||||
fstatSync,
|
||||
openSync,
|
||||
opendirSync,
|
||||
readFileSync,
|
||||
} from 'fs'
|
||||
import path from 'path'
|
||||
import { fileURLToPath, URL } from 'url'
|
||||
import matter from 'gray-matter'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const DIST_DIR = path.join(__dirname, '..', 'dist')
|
||||
const REPO_DIR = path.resolve(__dirname, '..')
|
||||
const PORT = 5173
|
||||
const DEDICATED_FRONTMATTER_KEYS = new Set([
|
||||
'aliases',
|
||||
'Is A',
|
||||
'Belongs to',
|
||||
'Related to',
|
||||
'Status',
|
||||
'Owner',
|
||||
'Cadence',
|
||||
'Created at',
|
||||
])
|
||||
|
||||
function isAllowedPath(p) {
|
||||
return isInsideRelativePath(path.relative(REPO_DIR, p))
|
||||
}
|
||||
|
||||
function isInsideRelativePath(relative) {
|
||||
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative))
|
||||
}
|
||||
|
||||
function resolveInside(root, target) {
|
||||
const normalizedTarget = path.normalize(target)
|
||||
if (path.isAbsolute(normalizedTarget)) return null
|
||||
const candidate = path.normalize(`${root}${path.sep}${normalizedTarget}`)
|
||||
return isInsideRelativePath(path.relative(root, candidate)) ? candidate : null
|
||||
}
|
||||
|
||||
function readUtf8File(filePath) {
|
||||
const fd = openSync(filePath, 'r')
|
||||
try {
|
||||
return readFileSync(fd, 'utf-8')
|
||||
} finally {
|
||||
closeSync(fd)
|
||||
}
|
||||
}
|
||||
|
||||
function pathStats(filePath) {
|
||||
const fd = openSync(filePath, 'r')
|
||||
try {
|
||||
return fstatSync(fd)
|
||||
} finally {
|
||||
closeSync(fd)
|
||||
}
|
||||
}
|
||||
|
||||
function pathExists(filePath) {
|
||||
try {
|
||||
pathStats(filePath)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function directoryEntries(dir) {
|
||||
const directory = opendirSync(dir)
|
||||
try {
|
||||
const entries = []
|
||||
let entry = directory.readSync()
|
||||
while (entry) {
|
||||
entries.push(entry)
|
||||
entry = directory.readSync()
|
||||
}
|
||||
return entries
|
||||
} finally {
|
||||
directory.closeSync()
|
||||
}
|
||||
}
|
||||
|
||||
function streamFile(filePath) {
|
||||
const fd = openSync(filePath, 'r')
|
||||
return createReadStream(null, { fd, autoClose: true })
|
||||
}
|
||||
|
||||
function staticAssetPath(url) {
|
||||
const pathname = new URL(url, 'http://localhost').pathname
|
||||
const requested = pathname === '/' ? 'index.html' : decodeURIComponent(pathname).replace(/^\/+/, '')
|
||||
return resolveInside(DIST_DIR, requested) ?? path.normalize(`${DIST_DIR}${path.sep}index.html`)
|
||||
}
|
||||
|
||||
const MIME = {
|
||||
'.html': 'text/html',
|
||||
'.js': 'application/javascript',
|
||||
'.css': 'text/css',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.png': 'image/png',
|
||||
'.ico': 'image/x-icon',
|
||||
'.woff2':'font/woff2',
|
||||
'.json': 'application/json',
|
||||
}
|
||||
|
||||
function findMarkdownFiles(dir) {
|
||||
const results = []
|
||||
try {
|
||||
for (const entry of directoryEntries(dir)) {
|
||||
const full = resolveInside(dir, entry.name)
|
||||
if (!full) continue
|
||||
if (entry.isDirectory()) results.push(...findMarkdownFiles(full))
|
||||
else if (entry.name.endsWith('.md')) results.push(full)
|
||||
}
|
||||
} catch {}
|
||||
return results
|
||||
}
|
||||
|
||||
function extractWikiLinks(value) {
|
||||
if (!value) return []
|
||||
const str = Array.isArray(value) ? value.join(' ') : String(value)
|
||||
return [...str.matchAll(/\[\[([^\]]+)\]\]/g)].map(m => `[[${m[1]}]]`)
|
||||
}
|
||||
|
||||
function frontmatterRelationships(frontmatter) {
|
||||
const relationships = {}
|
||||
for (const [key, value] of Object.entries(frontmatter)) {
|
||||
if (DEDICATED_FRONTMATTER_KEYS.has(key)) continue
|
||||
const links = extractWikiLinks(value)
|
||||
if (links.length) relationships[key] = links
|
||||
}
|
||||
return relationships
|
||||
}
|
||||
|
||||
function aliasesFrom(frontmatter) {
|
||||
if (Array.isArray(frontmatter.aliases)) return frontmatter.aliases
|
||||
return frontmatter.aliases ? [frontmatter.aliases] : []
|
||||
}
|
||||
|
||||
function markdownBodyText(content) {
|
||||
return content.replace(/---[\s\S]*?---/, '').trim()
|
||||
}
|
||||
|
||||
function markdownTitle(bodyText, aliases, filePath) {
|
||||
const h1 = bodyText.match(/^#\s+(.+)/m)?.[1]
|
||||
return h1 || aliases[0] || path.basename(filePath, '.md')
|
||||
}
|
||||
|
||||
function createdAtMillis(frontmatter) {
|
||||
return frontmatter['Created at'] ? new Date(frontmatter['Created at']).getTime() : null
|
||||
}
|
||||
|
||||
function snippetFrom(bodyText) {
|
||||
return bodyText
|
||||
.replace(/^#+\s+.+/gm, '')
|
||||
.replace(/\n+/g, ' ')
|
||||
.trim()
|
||||
.slice(0, 200)
|
||||
}
|
||||
|
||||
function parseMarkdownFile(filePath) {
|
||||
try {
|
||||
const raw = readUtf8File(filePath)
|
||||
const { data: fm, content } = matter(raw)
|
||||
const stat = pathStats(filePath)
|
||||
const bodyText = markdownBodyText(content)
|
||||
const aliases = aliasesFrom(fm)
|
||||
|
||||
return {
|
||||
path: filePath,
|
||||
filename: path.basename(filePath),
|
||||
title: markdownTitle(bodyText, aliases, filePath),
|
||||
isA: fm['Is A'] ?? null,
|
||||
aliases,
|
||||
belongsTo: extractWikiLinks(fm['Belongs to']),
|
||||
relatedTo: extractWikiLinks(fm['Related to']),
|
||||
status: fm['Status'] ?? null,
|
||||
owner: fm['Owner'] ?? null,
|
||||
cadence: fm['Cadence'] ?? null,
|
||||
modifiedAt: stat.mtimeMs,
|
||||
createdAt: createdAtMillis(fm),
|
||||
fileSize: stat.size,
|
||||
snippet: snippetFrom(bodyText),
|
||||
relationships: frontmatterRelationships(fm),
|
||||
}
|
||||
} catch { return null }
|
||||
}
|
||||
|
||||
function sendJson(res, statusCode, payload) {
|
||||
res.writeHead(statusCode, { 'Content-Type': 'application/json' })
|
||||
res.end(JSON.stringify(payload))
|
||||
}
|
||||
|
||||
function badPath(res) {
|
||||
sendJson(res, 400, { error: 'bad path' })
|
||||
}
|
||||
|
||||
function existingAllowedPath(params) {
|
||||
const requestedPath = params.searchParams.get('path')
|
||||
return requestedPath && isAllowedPath(requestedPath) && pathExists(requestedPath)
|
||||
? requestedPath
|
||||
: null
|
||||
}
|
||||
|
||||
function handleVaultPing(_params, res) {
|
||||
sendJson(res, 200, { ok: true })
|
||||
}
|
||||
|
||||
function handleVaultList(params, res) {
|
||||
const dir = existingAllowedPath(params)
|
||||
if (!dir) return badPath(res)
|
||||
const entries = findMarkdownFiles(dir).map(parseMarkdownFile).filter(Boolean)
|
||||
sendJson(res, 200, entries)
|
||||
}
|
||||
|
||||
function handleVaultContent(params, res) {
|
||||
const file = existingAllowedPath(params)
|
||||
if (!file) return badPath(res)
|
||||
sendJson(res, 200, { content: readUtf8File(file) })
|
||||
}
|
||||
|
||||
function allVaultContent(dir) {
|
||||
const map = {}
|
||||
for (const filePath of findMarkdownFiles(dir)) {
|
||||
try { map[filePath] = readUtf8File(filePath) } catch {}
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
function handleVaultAllContent(params, res) {
|
||||
const dir = existingAllowedPath(params)
|
||||
if (!dir) return badPath(res)
|
||||
sendJson(res, 200, allVaultContent(dir))
|
||||
}
|
||||
|
||||
const VAULT_API_ROUTES = new Map([
|
||||
['/api/vault/ping', handleVaultPing],
|
||||
['/api/vault/list', handleVaultList],
|
||||
['/api/vault/content', handleVaultContent],
|
||||
['/api/vault/all-content', handleVaultAllContent],
|
||||
])
|
||||
|
||||
function serveVaultApi(url, res) {
|
||||
const params = new URL(url, 'http://localhost')
|
||||
const handler = VAULT_API_ROUTES.get(params.pathname)
|
||||
if (!handler) return false
|
||||
handler(params, res)
|
||||
return true
|
||||
}
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
const url = req.url ?? '/'
|
||||
|
||||
// API routes
|
||||
if (url.startsWith('/api/vault/')) {
|
||||
if (!serveVaultApi(url, res)) {
|
||||
res.writeHead(404); res.end()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Static files
|
||||
let filePath = staticAssetPath(url)
|
||||
if (!pathExists(filePath) || pathStats(filePath).isDirectory()) {
|
||||
filePath = path.normalize(`${DIST_DIR}${path.sep}index.html`) // SPA fallback
|
||||
}
|
||||
const ext = path.extname(filePath)
|
||||
res.writeHead(200, { 'Content-Type': MIME[ext] ?? 'application/octet-stream' })
|
||||
streamFile(filePath).pipe(res)
|
||||
})
|
||||
|
||||
server.listen(PORT, '0.0.0.0', () => {
|
||||
log(`✅ Laputa demo server running on http://0.0.0.0:${PORT}`)
|
||||
log(` Tailscale: https://mac-mini.tail7cbc15.ts.net`)
|
||||
})
|
||||
149
product-source/hololake-platform/scripts/validate-locales.mjs
Normal file
149
product-source/hololake-platform/scripts/validate-locales.mjs
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
import {
|
||||
closeSync, fstatSync, openSync, opendirSync, readFileSync,
|
||||
} from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const localesDir = path.join(root, 'src/lib/locales')
|
||||
const sourcePath = path.join(localesDir, 'en.json')
|
||||
|
||||
function readCatalog(filePath) {
|
||||
return JSON.parse(readUtf8File(filePath))
|
||||
}
|
||||
|
||||
function readUtf8File(filePath) {
|
||||
const fd = openSync(filePath, 'r')
|
||||
try {
|
||||
return readFileSync(fd, 'utf8')
|
||||
} finally {
|
||||
closeSync(fd)
|
||||
}
|
||||
}
|
||||
|
||||
function directoryFiles(dirPath) {
|
||||
const dir = opendirSync(dirPath)
|
||||
try {
|
||||
const files = []
|
||||
let entry = dir.readSync()
|
||||
while (entry) {
|
||||
if (entry.isFile()) files.push(entry.name)
|
||||
entry = dir.readSync()
|
||||
}
|
||||
return files
|
||||
} finally {
|
||||
dir.closeSync()
|
||||
}
|
||||
}
|
||||
|
||||
function ensureDirectory(dirPath) {
|
||||
const fd = openSync(dirPath, 'r')
|
||||
try {
|
||||
if (!fstatSync(fd).isDirectory()) {
|
||||
throw new Error(`${dirPath} is not a directory`)
|
||||
}
|
||||
} finally {
|
||||
closeSync(fd)
|
||||
}
|
||||
}
|
||||
|
||||
function isFlatObject(value) {
|
||||
if (!value) return false
|
||||
if (typeof value !== 'object') return false
|
||||
return !Array.isArray(value)
|
||||
}
|
||||
|
||||
function assertFlatStringCatalog(locale, catalog) {
|
||||
if (!isFlatObject(catalog)) {
|
||||
throw new Error(`${locale}: expected a flat object of translation keys`)
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(catalog)) {
|
||||
if (typeof value !== 'string') {
|
||||
throw new Error(`${locale}: key "${key}" must map to a string`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function missingKeys(sourceKeys, localeKeys) {
|
||||
const localeKeySet = new Set(localeKeys)
|
||||
return sourceKeys.filter((key) => !localeKeySet.has(key))
|
||||
}
|
||||
|
||||
function extraKeys(sourceKeys, localeKeys) {
|
||||
const sourceKeySet = new Set(sourceKeys)
|
||||
return localeKeys.filter((key) => !sourceKeySet.has(key))
|
||||
}
|
||||
|
||||
function placeholders(value) {
|
||||
return Array.from(value.matchAll(/\{(\w+)\}/g), (match) => match[1]).sort()
|
||||
}
|
||||
|
||||
function sameValues(left, right) {
|
||||
if (left.length !== right.length) return false
|
||||
return left.every((value, index) => value === right[index])
|
||||
}
|
||||
|
||||
function formatValues(values) {
|
||||
return values.length === 0 ? 'none' : values.join(', ')
|
||||
}
|
||||
|
||||
function placeholderIssues(locale, sourceCatalog, catalog) {
|
||||
const issues = []
|
||||
|
||||
for (const [key, sourceValue] of Object.entries(sourceCatalog)) {
|
||||
if (!(key in catalog)) continue
|
||||
|
||||
const sourcePlaceholders = placeholders(sourceValue)
|
||||
const localePlaceholders = placeholders(catalog[key])
|
||||
if (sameValues(sourcePlaceholders, localePlaceholders)) continue
|
||||
|
||||
issues.push(
|
||||
`${locale}: key "${key}" placeholders differ ` +
|
||||
`(expected ${formatValues(sourcePlaceholders)}, found ${formatValues(localePlaceholders)})`,
|
||||
)
|
||||
}
|
||||
|
||||
return issues
|
||||
}
|
||||
|
||||
const sourceCatalog = readCatalog(sourcePath)
|
||||
assertFlatStringCatalog('en', sourceCatalog)
|
||||
|
||||
const sourceKeys = Object.keys(sourceCatalog).sort()
|
||||
ensureDirectory(localesDir)
|
||||
const localeFiles = directoryFiles(localesDir).filter((file) => file.endsWith('.json'))
|
||||
const issues = []
|
||||
|
||||
for (const file of localeFiles) {
|
||||
const locale = file.replace(/\.json$/, '')
|
||||
const filePath = path.join(localesDir, file)
|
||||
const catalog = readCatalog(filePath)
|
||||
|
||||
assertFlatStringCatalog(locale, catalog)
|
||||
|
||||
if (locale === 'en') continue
|
||||
|
||||
const keys = Object.keys(catalog).sort()
|
||||
const missing = missingKeys(sourceKeys, keys)
|
||||
const extra = extraKeys(sourceKeys, keys)
|
||||
|
||||
if (missing.length > 0) {
|
||||
issues.push(`${locale}: missing ${missing.length} key(s)`)
|
||||
}
|
||||
if (extra.length > 0) {
|
||||
issues.push(`${locale}: extra ${extra.length} key(s)`)
|
||||
}
|
||||
|
||||
issues.push(...placeholderIssues(locale, sourceCatalog, catalog))
|
||||
}
|
||||
|
||||
if (issues.length > 0) {
|
||||
console.error('Locale validation failed:')
|
||||
for (const issue of issues) {
|
||||
console.error(`- ${issue}`)
|
||||
}
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log(`Validated ${localeFiles.length} locale catalog(s) against ${sourceKeys.length} English keys.`)
|
||||
54
product-source/hololake-platform/scripts/verify-internal-package-content.mjs
Executable file
54
product-source/hololake-platform/scripts/verify-internal-package-content.mjs
Executable file
|
|
@ -0,0 +1,54 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import { readFile, stat } from 'node:fs/promises'
|
||||
import { basename } from 'node:path'
|
||||
|
||||
const FORBIDDEN = [
|
||||
'guanghubingshuo.com',
|
||||
]
|
||||
|
||||
const REQUIRED = [
|
||||
'guanghulab.com/fifth-domain/bingshuo/hololake-platform',
|
||||
'GLS-SYS-ARCH-001',
|
||||
]
|
||||
|
||||
const targets = process.argv.slice(2)
|
||||
if (targets.length === 0) {
|
||||
console.error('Usage: pnpm verify:internal-package <unpacked-app-file-or-directory> [...]')
|
||||
process.exit(2)
|
||||
}
|
||||
|
||||
async function collectFiles(target) {
|
||||
const info = await stat(target)
|
||||
if (info.isFile()) return [target]
|
||||
const { readdir } = await import('node:fs/promises')
|
||||
const entries = await readdir(target, { withFileTypes: true })
|
||||
return (await Promise.all(entries.map((entry) =>
|
||||
collectFiles(`${target}/${entry.name}`),
|
||||
))).flat()
|
||||
}
|
||||
|
||||
const matches = new Map(REQUIRED.map((needle) => [needle, false]))
|
||||
for (const target of targets) {
|
||||
for (const file of await collectFiles(target)) {
|
||||
const contents = await readFile(file)
|
||||
const text = contents.toString('latin1')
|
||||
for (const forbidden of FORBIDDEN) {
|
||||
if (text.includes(forbidden)) {
|
||||
console.error(`Forbidden private route found in ${file}: ${forbidden}`)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
for (const required of REQUIRED) {
|
||||
if (text.includes(required)) matches.set(required, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const missing = [...matches].filter(([, found]) => !found).map(([needle]) => needle)
|
||||
if (missing.length > 0) {
|
||||
console.error(`Required public architecture markers missing from ${targets.map((target) => basename(target)).join(', ')}: ${missing.join(', ')}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log(`Internal package content verified: ${targets.map((target) => basename(target)).join(', ')}`)
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
Unicode true
|
||||
RequestExecutionLevel user
|
||||
SetCompressor /SOLID lzma
|
||||
|
||||
!ifndef APP_VERSION
|
||||
!error "APP_VERSION is required"
|
||||
!endif
|
||||
!ifndef STAGE_DIR
|
||||
!error "STAGE_DIR is required"
|
||||
!endif
|
||||
!ifndef OUTPUT_FILE
|
||||
!error "OUTPUT_FILE is required"
|
||||
!endif
|
||||
|
||||
Name "HoloLake Era"
|
||||
OutFile "${OUTPUT_FILE}"
|
||||
InstallDir "$LOCALAPPDATA\Programs\HoloLake Era"
|
||||
InstallDirRegKey HKCU "Software\Guanghu\HoloLake Era" "InstallDir"
|
||||
Icon "${STAGE_DIR}/icon.ico"
|
||||
UninstallIcon "${STAGE_DIR}/icon.ico"
|
||||
|
||||
VIProductVersion "${APP_VERSION}.0"
|
||||
VIAddVersionKey "ProductName" "HoloLake Era"
|
||||
VIAddVersionKey "ProductVersion" "${APP_VERSION}"
|
||||
VIAddVersionKey "FileVersion" "${APP_VERSION}"
|
||||
VIAddVersionKey "CompanyName" "Guanghu Language World"
|
||||
VIAddVersionKey "FileDescription" "HoloLake Era internal installer"
|
||||
VIAddVersionKey "LegalCopyright" "Guanghu Language World"
|
||||
|
||||
Page directory
|
||||
Page instfiles
|
||||
UninstPage uninstConfirm
|
||||
UninstPage instfiles
|
||||
|
||||
Section "HoloLake Era" SEC_MAIN
|
||||
SetOutPath "$INSTDIR"
|
||||
File "${STAGE_DIR}/HoloLake Era.exe"
|
||||
File "${STAGE_DIR}/icon.ico"
|
||||
File /r "${STAGE_DIR}/resources"
|
||||
|
||||
WriteUninstaller "$INSTDIR\Uninstall.exe"
|
||||
WriteRegStr HKCU "Software\Guanghu\HoloLake Era" "InstallDir" "$INSTDIR"
|
||||
WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\HoloLakeEra" "DisplayName" "HoloLake Era"
|
||||
WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\HoloLakeEra" "DisplayVersion" "${APP_VERSION}"
|
||||
WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\HoloLakeEra" "DisplayIcon" "$INSTDIR\icon.ico"
|
||||
WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\HoloLakeEra" "UninstallString" '"$INSTDIR\Uninstall.exe"'
|
||||
|
||||
CreateDirectory "$SMPROGRAMS\HoloLake Era"
|
||||
CreateShortcut "$SMPROGRAMS\HoloLake Era\HoloLake Era.lnk" "$INSTDIR\HoloLake Era.exe" "" "$INSTDIR\icon.ico"
|
||||
CreateShortcut "$DESKTOP\HoloLake Era.lnk" "$INSTDIR\HoloLake Era.exe" "" "$INSTDIR\icon.ico"
|
||||
|
||||
WriteRegStr HKCU "Software\Classes\tolaria" "" "URL:Tolaria Protocol"
|
||||
WriteRegStr HKCU "Software\Classes\tolaria" "URL Protocol" ""
|
||||
WriteRegStr HKCU "Software\Classes\tolaria\shell\open\command" "" '"$INSTDIR\HoloLake Era.exe" "%1"'
|
||||
WriteRegStr HKCU "Software\Classes\guanghu" "" "URL:Guanghu Protocol"
|
||||
WriteRegStr HKCU "Software\Classes\guanghu" "URL Protocol" ""
|
||||
WriteRegStr HKCU "Software\Classes\guanghu\shell\open\command" "" '"$INSTDIR\HoloLake Era.exe" "%1"'
|
||||
SectionEnd
|
||||
|
||||
Section "Uninstall"
|
||||
Delete "$DESKTOP\HoloLake Era.lnk"
|
||||
RMDir /r "$SMPROGRAMS\HoloLake Era"
|
||||
DeleteRegKey HKCU "Software\Classes\tolaria"
|
||||
DeleteRegKey HKCU "Software\Classes\guanghu"
|
||||
DeleteRegKey HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\HoloLakeEra"
|
||||
DeleteRegKey HKCU "Software\Guanghu\HoloLake Era"
|
||||
RMDir /r "$INSTDIR"
|
||||
SectionEnd
|
||||
Loading…
Reference in a new issue