feat: 完善服务端工程化、发布流程和自动化测试

This commit is contained in:
2026-07-16 08:36:12 +00:00
parent 0b71e8d024
commit 5d7aa560e5
29 changed files with 822 additions and 7987 deletions
+5 -7
View File
@@ -1,20 +1,18 @@
FROM node:20-alpine as build-stage
FROM node:22-alpine as build-stage
WORKDIR /app
RUN corepack enable
RUN corepack prepare pnpm@latest --activate
RUN npm config set registry https://registry.npmmirror.com
COPY .npmrc package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
COPY .npmrc package.json package-lock.json ./
RUN npm ci
COPY . .
RUN pnpm build
RUN npm run build
FROM nginx:stable-alpine as production-stage
COPY --from=build-stage /app/dist /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
CMD ["nginx", "-g", "daemon off;"]
-39
View File
@@ -1,39 +0,0 @@
<h1>vue-pure-admin Lite Editionno i18n version</h1>
[![license](https://img.shields.io/github/license/pure-admin/vue-pure-admin.svg)](LICENSE)
**English** | [中文](./README.md)
## Introduce
The simplified version is based on the shelf extracted from [vue-pure-admin](https://github.com/pure-admin/vue-pure-admin), which contains main functions and is more suitable for actual project development. The packaged size is introduced globally [element-plus](https://element-plus.org) is still below `2.3MB`, and the full version of the code will be permanently synchronized. After enabling `brotli` compression and `cdn` to replace the local library mode, the package size is less than `350kb`
## Supporting video
[Click me to view UI design](https://www.bilibili.com/video/BV17g411T7rq)
[Click me to view the rapid development tutorial](https://www.bilibili.com/video/BV1kg411v7QT)
## Nanny-level documents
[Click me to view vue-pure-admin documentation](https://pure-admin.cn/)
[Click me to view @pureadmin/utils documentation](https://pure-admin-utils.netlify.app)
## Premium service
[Click me to view details](https://pure-admin.cn/pages/service/)
## Preview
[Click me to view the preview station](https://pure-admin-thin.netlify.app/#/login)
## Maintainer
[xiaoxian521](https://github.com/xiaoxian521)
## ⚠️ Attention
The Lite version does not accept any issues and prs. If you have any questions, please go to the full version [issues](https://github.com/pure-admin/vue-pure-admin/issues/new/choose) to mention, thank you!
## License
[MIT © 2020-present, pure-admin](./LICENSE)
+22 -31
View File
@@ -1,43 +1,34 @@
<h1>vue-pure-admin精简版(非国际化版本)</h1>
# SimCAE 管理后台
[![license](https://img.shields.io/github/license/pure-admin/vue-pure-admin.svg)](LICENSE)
这个目录是服务端管理后台前端,基于 `pure-admin-thin`、Vue 3、Vite、Element Plus 和 TypeScript 改造。
**中文** | [English](./README.en-US.md)
## 主要功能
## 介绍
- 应用、渠道、版本发布和更新策略管理。
- License 授权、设备、升级日志、下载日志和审计日志管理。
- 崩溃报告接口联调信息展示。
- 管理员登录、JWT 会话和角色权限基础能力。
精简版是基于 [vue-pure-admin](https://github.com/pure-admin/vue-pure-admin) 提炼出的架子,包含主体功能,更适合实际项目开发,打包后的大小在全局引入 [element-plus](https://element-plus.org) 的情况下仍然低于 `2.3MB`,并且会永久同步完整版的代码。开启 `brotli` 压缩和 `cdn` 替换本地库模式后,打包大小低于 `350kb`
## 本地开发
## 版本选择
```bash
npm ci
npm run dev
```
当前是非国际化版本,如果您需要国际化版本 [请点击](https://github.com/pure-admin/pure-admin-thin/tree/i18n)
## 构建
## 配套视频
```bash
npm ci
npm run build
```
[点我查看 UI 设计](https://www.bilibili.com/video/BV17g411T7rq)
[点我查看快速开发教程](https://www.bilibili.com/video/BV1kg411v7QT)
构建产物输出到 `dist/`。服务端 Dockerfile 会自动构建并复制 `dist/`,通常不需要手动复制。
## 配套保姆级文档
## 包管理约定
[点我查看 vue-pure-admin 文档](https://pure-admin.cn/)
[点我查看 @pureadmin/utils 文档](https://pure-admin-utils.netlify.app)
本项目统一使用 `npm``package-lock.json`。不要再提交 `pnpm-lock.yaml``yarn.lock`,避免不同包管理器解析出不同依赖版本。
## 高级服务
## 模板来源
[点我查看详情](https://pure-admin.cn/pages/service/)
## 预览
[查看预览](https://pure-admin-thin.netlify.app/#/login)
## 维护者
[xiaoxian521](https://github.com/xiaoxian521)
## ⚠️ 注意
精简版不接受任何 `issues``pr`,如果有问题请到完整版 [issues](https://github.com/pure-admin/vue-pure-admin/issues/new/choose) 去提,谢谢!
## 许可证
[MIT © 2020-present, pure-admin](./LICENSE)
初始工程来自开源后台模板 `pure-admin-thin`。当前仓库只保留 SimCAE 后台需要的页面和运行能力,示例页面不作为业务交付内容。
-7387
View File
File diff suppressed because it is too large Load Diff
+13 -1
View File
@@ -1,4 +1,4 @@
import { formatToken, getToken } from "@/utils/auth";
import { formatToken, getToken, removeToken } from "@/utils/auth";
export class AdminApiError extends Error {
status: number;
@@ -70,6 +70,12 @@ export async function adminRequest<T = any>(
}
if (!response.ok) {
if (response.status === 401) {
removeToken();
if (window.location.pathname !== "/login") {
window.location.href = "/login";
}
}
throw new AdminApiError(
`HTTP ${response.status}: ${text || response.statusText}`,
response.status,
@@ -122,6 +128,12 @@ export async function downloadAdminFile(
});
if (!response.ok) {
const text = await response.text();
if (response.status === 401) {
removeToken();
if (window.location.pathname !== "/login") {
window.location.href = "/login";
}
}
throw new AdminApiError(
`HTTP ${response.status}: ${text || response.statusText}`,
response.status,
+3 -1
View File
@@ -33,6 +33,7 @@ import {
import {
type DataInfo,
userKey,
getToken,
removeToken,
multipleTabsKey
} from "@/utils/auth";
@@ -147,7 +148,8 @@ router.beforeEach((to: ToRouteType, _from, next) => {
function toCorrectRoute() {
whiteList.includes(to.fullPath) ? next(_from.fullPath) : next();
}
if (Cookies.get(multipleTabsKey) && userInfo) {
const accessToken = getToken()?.accessToken;
if (Cookies.get(multipleTabsKey) && userInfo && accessToken) {
// 无权限跳转403页面
if (to.meta?.roles && !isOneOfArray(to.meta?.roles, userInfo?.roles)) {
next({ path: "/error/403" });
-59
View File
@@ -1,59 +0,0 @@
import { removeToken, setToken, type DataInfo } from "./auth";
import { subBefore, getQueryMap } from "@pureadmin/utils";
/**
* 简版前端单点登录,根据实际业务自行编写,平台启动后本地可以跳后面这个链接进行测试 http://localhost:8848/#/permission/page/index?username=sso&roles=admin&accessToken=eyJhbGciOiJIUzUxMiJ9.admin
* 划重点:
* 判断是否为单点登录,不为则直接返回不再进行任何逻辑处理,下面是单点登录后的逻辑处理
* 1.清空本地旧信息;
* 2.获取url中的重要参数信息,然后通过 setToken 保存在本地;
* 3.删除不需要显示在 url 的参数
* 4.使用 window.location.replace 跳转正确页面
*/
(function () {
// 获取 url 中的参数
const params = getQueryMap(location.href) as DataInfo<Date>;
const must = ["username", "roles", "accessToken"];
const mustLength = must.length;
if (Object.keys(params).length !== mustLength) return;
// url 参数满足 must 里的全部值,才判定为单点登录,避免非单点登录时刷新页面无限循环
let sso = [];
let start = 0;
while (start < mustLength) {
if (Object.keys(params).includes(must[start]) && sso.length <= mustLength) {
sso.push(must[start]);
} else {
sso = [];
}
start++;
}
if (sso.length === mustLength) {
// 判定为单点登录
// 清空本地旧信息
removeToken();
// 保存新信息到本地
setToken(params);
// 删除不需要显示在 url 的参数
delete params.roles;
delete params.accessToken;
const newUrl = `${location.origin}${location.pathname}${subBefore(
location.hash,
"?"
)}?${JSON.stringify(params)
.replace(/["{}]/g, "")
.replace(/:/g, "=")
.replace(/,/g, "&")}`;
// 替换历史记录项
window.location.replace(newUrl);
} else {
return;
}
})();
@@ -1,99 +0,0 @@
<script setup lang="ts">
import { hasAuth, getAuths } from "@/router/utils";
defineOptions({
name: "PermissionButtonRouter"
});
</script>
<template>
<div>
<p class="mb-2!">当前拥有的code列表{{ getAuths() }}</p>
<el-card shadow="never" class="mb-2">
<template #header>
<div class="card-header">组件方式判断权限</div>
</template>
<el-space wrap>
<Auth value="permission:btn:add">
<el-button plain type="warning">
拥有code'permission:btn:add' 权限可见
</el-button>
</Auth>
<Auth :value="['permission:btn:edit']">
<el-button plain type="primary">
拥有code['permission:btn:edit'] 权限可见
</el-button>
</Auth>
<Auth
:value="[
'permission:btn:add',
'permission:btn:edit',
'permission:btn:delete'
]"
>
<el-button plain type="danger">
拥有code['permission:btn:add', 'permission:btn:edit',
'permission:btn:delete'] 权限可见
</el-button>
</Auth>
</el-space>
</el-card>
<el-card shadow="never" class="mb-2">
<template #header>
<div class="card-header">函数方式判断权限</div>
</template>
<el-space wrap>
<el-button v-if="hasAuth('permission:btn:add')" plain type="warning">
拥有code'permission:btn:add' 权限可见
</el-button>
<el-button v-if="hasAuth(['permission:btn:edit'])" plain type="primary">
拥有code['permission:btn:edit'] 权限可见
</el-button>
<el-button
v-if="
hasAuth([
'permission:btn:add',
'permission:btn:edit',
'permission:btn:delete'
])
"
plain
type="danger"
>
拥有code['permission:btn:add', 'permission:btn:edit',
'permission:btn:delete'] 权限可见
</el-button>
</el-space>
</el-card>
<el-card shadow="never">
<template #header>
<div class="card-header">
指令方式判断权限(该方式不能动态修改权限)
</div>
</template>
<el-space wrap>
<el-button v-auth="'permission:btn:add'" plain type="warning">
拥有code'permission:btn:add' 权限可见
</el-button>
<el-button v-auth="['permission:btn:edit']" plain type="primary">
拥有code['permission:btn:edit'] 权限可见
</el-button>
<el-button
v-auth="[
'permission:btn:add',
'permission:btn:edit',
'permission:btn:delete'
]"
plain
type="danger"
>
拥有code['permission:btn:add', 'permission:btn:edit',
'permission:btn:delete'] 权限可见
</el-button>
</el-space>
</el-card>
</div>
</template>
@@ -1,109 +0,0 @@
<script setup lang="ts">
import { hasPerms } from "@/utils/auth";
import { useUserStoreHook } from "@/store/modules/user";
const { permissions } = useUserStoreHook();
defineOptions({
name: "PermissionButtonLogin"
});
</script>
<template>
<div>
<p class="mb-2!">当前拥有的code列表{{ permissions }}</p>
<p v-show="permissions?.[0] === '*:*:*'" class="mb-2!">
*:*:* 代表拥有全部按钮级别权限
</p>
<el-card shadow="never" class="mb-2">
<template #header>
<div class="card-header">组件方式判断权限</div>
</template>
<el-space wrap>
<Perms value="permission:btn:add">
<el-button plain type="warning">
拥有code'permission:btn:add' 权限可见
</el-button>
</Perms>
<Perms :value="['permission:btn:edit']">
<el-button plain type="primary">
拥有code['permission:btn:edit'] 权限可见
</el-button>
</Perms>
<Perms
:value="[
'permission:btn:add',
'permission:btn:edit',
'permission:btn:delete'
]"
>
<el-button plain type="danger">
拥有code['permission:btn:add', 'permission:btn:edit',
'permission:btn:delete'] 权限可见
</el-button>
</Perms>
</el-space>
</el-card>
<el-card shadow="never" class="mb-2">
<template #header>
<div class="card-header">函数方式判断权限</div>
</template>
<el-space wrap>
<el-button v-if="hasPerms('permission:btn:add')" plain type="warning">
拥有code'permission:btn:add' 权限可见
</el-button>
<el-button
v-if="hasPerms(['permission:btn:edit'])"
plain
type="primary"
>
拥有code['permission:btn:edit'] 权限可见
</el-button>
<el-button
v-if="
hasPerms([
'permission:btn:add',
'permission:btn:edit',
'permission:btn:delete'
])
"
plain
type="danger"
>
拥有code['permission:btn:add', 'permission:btn:edit',
'permission:btn:delete'] 权限可见
</el-button>
</el-space>
</el-card>
<el-card shadow="never">
<template #header>
<div class="card-header">
指令方式判断权限(该方式不能动态修改权限)
</div>
</template>
<el-space wrap>
<el-button v-perms="'permission:btn:add'" plain type="warning">
拥有code'permission:btn:add' 权限可见
</el-button>
<el-button v-perms="['permission:btn:edit']" plain type="primary">
拥有code['permission:btn:edit'] 权限可见
</el-button>
<el-button
v-perms="[
'permission:btn:add',
'permission:btn:edit',
'permission:btn:delete'
]"
plain
type="danger"
>
拥有code['permission:btn:add', 'permission:btn:edit',
'permission:btn:delete'] 权限可见
</el-button>
</el-space>
</el-card>
</div>
</template>
@@ -1,66 +0,0 @@
<script setup lang="ts">
import { initRouter } from "@/router/utils";
import { storageLocal } from "@pureadmin/utils";
import { type CSSProperties, ref, computed } from "vue";
import { useUserStoreHook } from "@/store/modules/user";
import { usePermissionStoreHook } from "@/store/modules/permission";
defineOptions({
name: "PermissionPage"
});
const elStyle = computed((): CSSProperties => {
return {
width: "85vw",
justifyContent: "start"
};
});
const username = ref(useUserStoreHook()?.username);
const options = [
{
value: "admin",
label: "管理员角色"
},
{
value: "common",
label: "普通角色"
}
];
function onChange() {
useUserStoreHook()
.loginByUsername({ username: username.value, password: "admin123" })
.then(res => {
if (res.success) {
storageLocal().removeItem("async-routes");
usePermissionStoreHook().clearAllCachePage();
initRouter();
}
});
}
</script>
<template>
<div>
<p class="mb-2!">
模拟后台根据不同角色返回对应路由观察左侧菜单变化管理员角色可查看系统管理菜单普通角色不可查看系统管理菜单
</p>
<el-card shadow="never" :style="elStyle">
<template #header>
<div class="card-header">
<span>当前角色{{ username }}</span>
</div>
</template>
<el-select v-model="username" class="w-[160px]!" @change="onChange">
<el-option
v-for="item in options"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-card>
</div>
</template>
+50 -185
View File
@@ -8,24 +8,30 @@ import {
friendlyError
} from "@/api/simcae";
import { removeToken } from "@/utils/auth";
import {
RELEASE_MANIFEST_NAMES,
type UploadEntry,
dirname,
detectSelectedRoot,
executableLeafName,
executableNameCandidates,
findExecutablePath,
isReleaseManifestFile,
isSafePublishPath,
manifestRuleMatches,
parseReleaseManifest,
relativePathForFile,
releaseMainExecutableForInstall
} from "./publishManifest";
defineOptions({
name: "SimCAEConsole"
});
type UploadEntry = { file: File; path: string };
type PublishManifestRule = {
line: number;
raw: string;
pattern: string;
exclude: boolean;
};
const RELEASE_MANIFEST_NAMES = ["发布清单.txt", "release_manifest.txt"];
const activeTab = ref("overview");
const loading = ref(false);
const publishing = ref(false);
const publishJobMessage = ref("");
const apps = ref<any[]>([]);
const channels = ref<any[]>([]);
const versions = ref<any[]>([]);
@@ -204,168 +210,6 @@ const expectedReleaseMainExecutable = computed(() =>
releaseMainExecutableForInstall(configForm.install_root, configForm.main_executable)
);
function rawPathForFile(file: File) {
const raw = ((file as any).webkitRelativePath || file.name) as string;
return raw.replaceAll("\\", "/");
}
function relativePathForFile(file: File) {
const raw = rawPathForFile(file);
const parts = raw.replaceAll("\\", "/").split("/").filter(Boolean);
if ((file as any).webkitRelativePath && parts.length > 1) parts.shift();
return parts.join("/");
}
function detectSelectedRoot(files: File[]) {
const first = files[0];
if (!first || !(first as any).webkitRelativePath) return "";
const parts = rawPathForFile(first).split("/").filter(Boolean);
return parts.length > 1 ? parts[0] : "";
}
function normalizePublishPath(value: string) {
let normalized = value.replaceAll("\\", "/").trim();
while (normalized.startsWith("./")) normalized = normalized.slice(2);
normalized = normalized.replace(/^\/+/, "").replace(/\/+$/, "");
return normalized;
}
function normalizeManifestPattern(value: string, rootName: string) {
let pattern = normalizePublishPath(value);
const root = normalizePublishPath(rootName);
if (root) {
if (pattern === root) return "*";
if (pattern.startsWith(`${root}/`)) {
pattern = pattern.slice(root.length + 1);
}
}
return pattern || "*";
}
function parseReleaseManifest(text: string, rootName: string): PublishManifestRule[] {
return text
.split(/\r?\n/)
.map((raw, index) => ({ raw, line: index + 1, trimmed: raw.trim() }))
.filter(item => item.trimmed && !item.trimmed.startsWith("#"))
.map(item => {
const exclude = item.trimmed.startsWith("!");
const body = exclude ? item.trimmed.slice(1).trim() : item.trimmed;
return {
line: item.line,
raw: item.raw,
pattern: normalizeManifestPattern(body, rootName),
exclude
};
})
.filter(rule => rule.pattern);
}
function escapeRegExp(value: string) {
return value.replace(/[|\\{}()[\]^$+?.]/g, "\\$&");
}
function manifestRuleMatches(pattern: string, filePath: string) {
const normalizedPattern = normalizePublishPath(pattern);
const normalizedPath = normalizePublishPath(filePath);
if (normalizedPattern === "*") return true;
if (normalizedPattern.endsWith("/*")) {
const prefix = normalizedPattern.slice(0, -2);
return normalizedPath.startsWith(`${prefix}/`);
}
if (!normalizedPattern.includes("*")) {
return normalizedPath === normalizedPattern;
}
const regex = new RegExp(
`^${normalizedPattern.split("*").map(escapeRegExp).join(".*")}$`
);
return regex.test(normalizedPath);
}
function isReleaseManifestFile(path: string) {
const normalized = normalizePublishPath(path).toLowerCase();
return RELEASE_MANIFEST_NAMES.some(name => normalized === name.toLowerCase());
}
function isSafePublishPath(itemPath: string) {
const protectedFiles = new Set([
"client.ini",
"bootstrap.exe",
"config/app_config.json",
"config/local_state.json",
"config/client_identity.dat",
"config/version_policy.dat"
]);
const path = normalizePublishPath(itemPath).toLowerCase();
const parts = path.split("/");
const blockedDirectory = parts
.slice(0, -1)
.some(
part =>
part === ".git" ||
part === ".vs" ||
part === "debug" ||
part === "update" ||
part === "update_temp" ||
part === "cmakefiles" ||
part.startsWith("build") ||
part.endsWith("_autogen")
);
const runtimeFile =
protectedFiles.has(path) ||
(path.startsWith("bin/") && protectedFiles.has(path.slice(4)));
return (
!blockedDirectory &&
!runtimeFile &&
!path.endsWith(".pdb") &&
!path.endsWith(".ilk") &&
!path.endsWith(".obj")
);
}
function normalizeExecutableName(value: string) {
const normalized = normalizePublishPath(value || "SimCAE.exe");
if (!normalized) return "SimCAE.exe";
const parts = normalized.split("/");
const leaf = parts[parts.length - 1];
if (!leaf.includes(".")) {
parts[parts.length - 1] = `${leaf}.exe`;
}
return parts.join("/");
}
function releaseMainExecutableForInstall(installRoot: string, mainExecutable: string) {
const root = (installRoot || "..").trim();
const main = normalizeExecutableName(mainExecutable);
if (root === ".") return main;
if (main.toLowerCase().startsWith("bin/")) return main;
return `bin/${main}`;
}
function executableNameCandidates(value: string, fallback: string) {
const leaf =
normalizePublishPath(value || fallback)
.split("/")
.filter(Boolean)
.pop() || fallback;
const lower = leaf.toLowerCase();
const candidates = new Set([lower]);
if (lower.endsWith(".exe")) {
candidates.add(lower.slice(0, -4));
} else {
candidates.add(`${lower}.exe`);
}
return candidates;
}
function executableLeafName(value: string, fallback: string) {
return (
normalizePublishPath(value || fallback)
.split("/")
.filter(Boolean)
.pop() || fallback
);
}
function launcherDisplayName() {
return executableLeafName(
runtime?.default_client_config?.launcher_executable,
@@ -373,18 +217,6 @@ function launcherDisplayName() {
);
}
function dirname(path: string) {
const parts = normalizePublishPath(path).split("/").filter(Boolean);
parts.pop();
return parts.join("/");
}
function findExecutablePath(entries: UploadEntry[], candidates: Set<string>) {
return entries
.map(item => normalizePublishPath(item.path))
.find(path => candidates.has(path.split("/").pop()?.toLowerCase() || ""));
}
function installRootDescription() {
const launcher = launcherDisplayName();
return configForm.install_root === "."
@@ -465,6 +297,10 @@ function handleError(prefix: string, error: unknown) {
ElMessage.error(`${prefix}${friendlyError(error)}`);
}
function sleep(ms: number) {
return new Promise(resolve => window.setTimeout(resolve, ms));
}
function roleLabel(role: string) {
return adminRoleOptions.value.find(item => item.value === role)?.label || role;
}
@@ -637,17 +473,34 @@ async function publishVersion() {
});
}
publishing.value = true;
publishJobMessage.value = "正在上传发布文件...";
try {
const result = await adminRequest<any>("/admin/publish", {
const job = await adminRequest<any>("/admin/publish/job", {
method: "POST",
body: formData
});
publishJobMessage.value = job?.message || "发布文件已接收,正在后台处理...";
const result = await waitPublishJob(job?.job_id);
ElMessage.success(result?.msg || `发布成功:${publishForm.version}`);
await loadVersions();
} catch (error) {
handleError("发布失败", error);
} finally {
publishing.value = false;
publishJobMessage.value = "";
}
}
async function waitPublishJob(jobId: string) {
if (!jobId) throw new Error("服务端没有返回发布任务 ID");
while (true) {
await sleep(1500);
const job = await adminRequest<any>(`/admin/publish/job/${encodeURIComponent(jobId)}`);
publishJobMessage.value = job?.message || "正在后台发布版本...";
if (job?.status === "success") return job.result || { msg: job.message };
if (job?.status === "error") {
throw new Error(job?.message || "发布任务失败");
}
}
}
@@ -1303,6 +1156,14 @@ test_file/*
<input v-else type="file" accept=".zip,.tar.gz,.tgz,.tar.bz2,.tbz2,.rar" @change="onArchiveFile" />
<span>{{ publishSummary }}</span>
</div>
<el-alert
v-if="publishJobMessage"
class="publish-job-alert"
:title="publishJobMessage"
type="info"
show-icon
:closable="false"
/>
<el-button type="primary" size="large" :loading="publishing" @click="publishVersion">发布版本</el-button>
</el-card>
</el-tab-pane>
@@ -1799,6 +1660,10 @@ test_file/*
border-radius: 8px;
}
.publish-job-alert {
margin: 12px 0;
}
.install-root-options {
margin-bottom: 10px;
}
@@ -0,0 +1,187 @@
export type UploadEntry = { file: File; path: string };
export type PublishManifestRule = {
line: number;
raw: string;
pattern: string;
exclude: boolean;
};
export const RELEASE_MANIFEST_NAMES = ["发布清单.txt", "release_manifest.txt"];
export function rawPathForFile(file: File) {
const raw = ((file as any).webkitRelativePath || file.name) as string;
return raw.replaceAll("\\", "/");
}
export function relativePathForFile(file: File) {
const raw = rawPathForFile(file);
const parts = raw.replaceAll("\\", "/").split("/").filter(Boolean);
if ((file as any).webkitRelativePath && parts.length > 1) parts.shift();
return parts.join("/");
}
export function detectSelectedRoot(files: File[]) {
const first = files[0];
if (!first || !(first as any).webkitRelativePath) return "";
const parts = rawPathForFile(first).split("/").filter(Boolean);
return parts.length > 1 ? parts[0] : "";
}
function normalizePublishPath(value: string) {
let normalized = value.replaceAll("\\", "/").trim();
while (normalized.startsWith("./")) normalized = normalized.slice(2);
normalized = normalized.replace(/^\/+/, "").replace(/\/+$/, "");
return normalized;
}
function normalizeManifestPattern(value: string, rootName: string) {
let pattern = normalizePublishPath(value);
const root = normalizePublishPath(rootName);
if (root) {
if (pattern === root) return "*";
if (pattern.startsWith(`${root}/`)) {
pattern = pattern.slice(root.length + 1);
}
}
return pattern || "*";
}
export function parseReleaseManifest(text: string, rootName: string): PublishManifestRule[] {
return text
.split(/\r?\n/)
.map((raw, index) => ({ raw, line: index + 1, trimmed: raw.trim() }))
.filter(item => item.trimmed && !item.trimmed.startsWith("#"))
.map(item => {
const exclude = item.trimmed.startsWith("!");
const body = exclude ? item.trimmed.slice(1).trim() : item.trimmed;
return {
line: item.line,
raw: item.raw,
pattern: normalizeManifestPattern(body, rootName),
exclude
};
})
.filter(rule => rule.pattern);
}
function escapeRegExp(value: string) {
return value.replace(/[|\\{}()[\]^$+?.]/g, "\\$&");
}
export function manifestRuleMatches(pattern: string, filePath: string) {
const normalizedPattern = normalizePublishPath(pattern);
const normalizedPath = normalizePublishPath(filePath);
if (normalizedPattern === "*") return true;
if (normalizedPattern.endsWith("/*")) {
const prefix = normalizedPattern.slice(0, -2);
return normalizedPath.startsWith(`${prefix}/`);
}
if (!normalizedPattern.includes("*")) {
return normalizedPath === normalizedPattern;
}
const regex = new RegExp(
`^${normalizedPattern.split("*").map(escapeRegExp).join(".*")}$`
);
return regex.test(normalizedPath);
}
export function isReleaseManifestFile(path: string) {
const normalized = normalizePublishPath(path).toLowerCase();
return RELEASE_MANIFEST_NAMES.some(name => normalized === name.toLowerCase());
}
export function isSafePublishPath(itemPath: string) {
const protectedFiles = new Set([
"client.ini",
"bootstrap.exe",
"config/app_config.json",
"config/local_state.json",
"config/client_identity.dat",
"config/version_policy.dat"
]);
const path = normalizePublishPath(itemPath).toLowerCase();
const parts = path.split("/");
const blockedDirectory = parts
.slice(0, -1)
.some(
part =>
part === ".git" ||
part === ".vs" ||
part === "debug" ||
part === "update" ||
part === "update_temp" ||
part === "cmakefiles" ||
part.startsWith("build") ||
part.endsWith("_autogen")
);
const runtimeFile =
protectedFiles.has(path) ||
(path.startsWith("bin/") && protectedFiles.has(path.slice(4)));
return (
!blockedDirectory &&
!runtimeFile &&
!path.endsWith(".pdb") &&
!path.endsWith(".ilk") &&
!path.endsWith(".obj")
);
}
export function normalizeExecutableName(value: string) {
const normalized = normalizePublishPath(value || "SimCAE.exe");
if (!normalized) return "SimCAE.exe";
const parts = normalized.split("/");
const leaf = parts[parts.length - 1];
if (!leaf.includes(".")) {
parts[parts.length - 1] = `${leaf}.exe`;
}
return parts.join("/");
}
export function releaseMainExecutableForInstall(
installRoot: string,
mainExecutable: string
) {
const root = (installRoot || "..").trim();
const main = normalizeExecutableName(mainExecutable);
if (root === ".") return main;
if (main.toLowerCase().startsWith("bin/")) return main;
return `bin/${main}`;
}
export function executableNameCandidates(value: string, fallback: string) {
const leaf =
normalizePublishPath(value || fallback)
.split("/")
.filter(Boolean)
.pop() || fallback;
const lower = leaf.toLowerCase();
const candidates = new Set([lower]);
if (lower.endsWith(".exe")) {
candidates.add(lower.slice(0, -4));
} else {
candidates.add(`${lower}.exe`);
}
return candidates;
}
export function executableLeafName(value: string, fallback: string) {
return (
normalizePublishPath(value || fallback)
.split("/")
.filter(Boolean)
.pop() || fallback
);
}
export function dirname(path: string) {
const parts = normalizePublishPath(path).split("/").filter(Boolean);
parts.pop();
return parts.join("/");
}
export function findExecutablePath(entries: UploadEntry[], candidates: Set<string>) {
return entries
.map(item => normalizePublishPath(item.path))
.find(path => candidates.has(path.split("/").pop()?.toLowerCase() || ""));
}