diff --git a/kit/.vscode/launch.json b/kit/.vscode/launch.json new file mode 100644 index 0000000..59635c7 --- /dev/null +++ b/kit/.vscode/launch.json @@ -0,0 +1,47 @@ +{ + // 使用 IntelliSense 了解相关属性。 + // 悬停以查看现有属性的描述。 + // 欲了解更多信息,请访问: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "type": "node", + "request": "launch", + "name": "remote_test", + "runtimeExecutable": "bun", + "runtimeArgs": ["test", "./test/remote_test.js", "-t", "exposes documented public methods", "--inspect-brk"], + "cwd": "${workspaceFolder}", + "console": "integratedTerminal", + "sourceMaps": true, + "outFiles": ["${workspaceFolder}/**/*.js"] +}, + { + "type": "bun", + "internalConsoleOptions": "neverOpen", + "request": "launch", + "name": "Debug File", + "program": "${file}", + "cwd": "${workspaceFolder}", + "stopOnEntry": false, + "watchMode": false + }, + { + "type": "bun", + "internalConsoleOptions": "neverOpen", + "request": "launch", + "name": "Run File", + "program": "${file}", + "cwd": "${workspaceFolder}", + "noDebug": true, + "watchMode": false + }, + { + "type": "bun", + "internalConsoleOptions": "neverOpen", + "request": "attach", + "name": "Attach Bun", + "url": "ws://localhost:6499/e4xu5fgl1a", + "stopOnEntry": false + } + ] +} \ No newline at end of file diff --git a/kit/bun.lockb b/kit/bun.lockb deleted file mode 100644 index 49526a7..0000000 Binary files a/kit/bun.lockb and /dev/null differ diff --git a/kit/doc/kit remote 仓库格式设计.md b/kit/doc/kit remote 仓库格式设计.md new file mode 100644 index 0000000..d964b1b --- /dev/null +++ b/kit/doc/kit remote 仓库格式设计.md @@ -0,0 +1,365 @@ +- 基于 webdav 协议访问。包括**远程仓库存储方案**(基于 WebDAV 协议)与**本地客户端存储方案**。 +- 支持存储多种操作系统(Linux、Windows)、多种架构(X86、aarch64) +- 支持记录官网地址,方便定期从官网拉取新版本存储。 + +--- +## Part A:远程仓库存储方案(基于 WebDAV) + +### 1. 概述 +本方案定义了一种基于 WebDAV 协议访问的远程仓库存储格式,用于存储开源项目的目标代码(二进制分发文件)。仓库支持: + +- 多种操作系统(Linux、Windows、macOS、FreeBSD 等) +- 多种硬件架构(x86_64、aarch64、armv7、i386 等) +- 平台无关的二进制文件(如 Java JAR、Python 轮包、纯 JS 文件等) +- 单文件可执行程序与多文件压缩包(zip、tar.gz 等) +- 版本管理(语义化版本)与元数据记录 + +仓库通过 WebDAV 的目录和文件操作进行访问,支持枚举、下载、上传、删除等操作。 + +### 2. 仓库根目录结构 + +``` +/repo/ # WebDAV 服务暴露的根路径 + └─ {project-id}/ # 项目目录(唯一标识) + ├─ metadata.json # 项目级元数据文件 + └─ {semver}/ # 版本目录(如 1.2.3 或 v1.2.3) + ├─ version.json # 版本级元数据文件(推荐) + ├─ any/any/ # 平台无关文件目录(保留字) + │ └─ ... # 单文件或压缩包 + ├─ {os}/ # 操作系统(如 linux, windows, darwin) + │ └─ {arch}/ # 架构(如 x86_64, aarch64) + │ └─ ... # 具体二进制文件或压缩包 + └─ ... +``` + +#### 2.1 命名规则 +- `project-id`:小写字母、数字、连字符(-),长度 ≤ 64。 +- `semver`:符合 [Semantic Versioning 2.0]( 的版本号,推荐不含 `v` 前缀。 +- `os` 与 `arch` 使用标准化名称(见下表),`any` 为保留字,表示任意平台。 + +| 操作系统 | 标准名称 | 架构 | 标准名称 | +| ------- | --------- | ------- | --------- | +| Linux | `linux` | x86_64 | `x86_64` | +| Windows | `windows` | aarch64 | `aarch64` | +| macOS | `darwin` | armv7 | `armv7` | +| FreeBSD | `freebsd` | i386 | `i386` | +| 任意平台 | `any` | 任意架构 | `any` | + +#### 2.2 目录说明 +- **`any/any/`**:存放与操作系统和架构均无关的文件(如 JAR、纯 Python 脚本、WASM 模块)。客户端在任何平台均可直接使用此目录下的文件。 +- **`{os}/{arch}/`**:存放特定平台、特定架构的二进制文件或压缩包。 +- 同一版本下可同时包含 `any/any/` 和具体平台目录。 + +### 3. 元数据文件规范 + +#### 3.1 项目级元数据:`metadata.json` +位于每个项目根目录,UTF-8 编码,JSON 格式。 + +**字段说明**: + +| 字段名 | 类型 | 必选 | 说明 | +| ------------------ | ----------------- | --- | ----------------- | +| `project_id` | string | 是 | 项目唯一标识,与目录名相同 | +| `official_url` | string | 是 | 项目官网地址(用于定期拉取新版本) | +| `description` | string | 否 | 项目简述 | +| `latest_version` | string | 是 | 仓库中已存储的最新版本号 | +| `versions` | array of strings | 是 | 仓库中已存储的所有版本号列表 | +| `last_fetch_check` | string (ISO 8601) | 否 | 上次检查官网更新的时间戳 | +| `fetch_rules` | array of objects | 否 | 自动拉取时的匹配规则(见 3.3) | +| `extra` | object | 否 | 扩展字段 | + +**示例**: +```json +{ + "project_id": "myapp", + "official_url": "", + "description": "An example tool", + "latest_version": "1.2.1", + "versions": ["1.0.0", "1.2.0", "1.2.1"], + "last_fetch_check": "2026-06-15T12:00:00Z", + "fetch_rules": [...], + "extra": { "license": "Apache-2.0" } +} +``` + +#### 3.2 版本级元数据:`version.json` +位于每个版本目录下,包含该版本的详细信息和文件清单。 + +**字段说明**: + +| 字段名 | 类型 | 必选 | 说明 | +| ----------------------- | ------------------ | ---- | ---------------------------------------------- | +| `version` | string | 是 | 版本号,与目录名一致 | +| `release_date` | string (ISO 8601) | 否 | 官方发布日期 | +| `official_download_url` | string | 否 | 官网的直接下载页或 API 地址 | +| `artifacts` | array of objects | 是 | 该版本包含的所有文件条目(见 3.2.1) | +| `checksums` | object | 否 | 文件路径到哈希值的映射(可选,与 artifacts 可共存) | +| `signatures` | object | 否 | 文件路径到数字签名的映射 | + +##### 3.2.1 `artifacts` 数组中每个对象的字段 + +| 字段名 | 类型 | 必选 | 说明 | +| ----------------- | --------- | ---- | ------------------------------------------------------------ | +| `path` | string | 是 | 相对于版本目录的文件路径,如 `linux/x86_64/myapp` 或 `any/any/app.zip` | +| `type` | string | 是 | 文件类型:`executable`(单文件可执行)、`archive`(压缩包)、`library`(库文件)、`data`(资源数据) | +| `archive_format` | string | 否 | 当 `type="archive"` 时,取值为 `zip`、`tar.gz`、`tar.xz` 等 | +| `entry_point` | string | 否 | 当 `type="archive"` 时,指定压缩包内的可执行文件相对路径(如 `bin/app`) | +| `unpack` | boolean | 否 | 是否建议客户端自动解压,默认 `false`。若为 `true`,客户端解压后应运行 `entry_point` | +| `checksum` | string | 否 | 该文件的哈希值(格式 `算法:值`,如 `sha256:abc...`) | + +**示例**: + +- **单文件可执行(Linux)** +```json +{ + "version": "1.0.0", + "artifacts": [ + { + "path": "linux/x86_64/myapp", + "type": "executable" + } + ], + "checksums": { + "linux/x86_64/myapp": "sha256:e3b0c442..." + } +} +``` + +- **多文件压缩包(Windows)** +```json +{ + "version": "2.0.0", + "artifacts": [ + { + "path": "windows/x86_64/myapp.zip", + "type": "archive", + "archive_format": "zip", + "entry_point": "myapp/bin/myapp.exe", + "unpack": true + } + ] +} +``` + +- **平台无关 JAR 文件** +```json +{ + "version": "3.0.0", + "artifacts": [ + { + "path": "any/any/myapp.jar", + "type": "executable" + } + ] +} +``` + +#### 3.3 自动拉取规则(`fetch_rules`) +在 `metadata.json` 中可定义 `fetch_rules` 数组,用于指导自动化工具从官网下载并分类存储。 + +每个规则对象包含: + +| 字段名 | 类型 | 说明 | +| ----------------- | --------- | ------------------------------------------------------------ | +| `url_pattern` | string | 正则表达式,匹配下载 URL | +| `os` | string | 目标操作系统(`linux`、`windows`、`darwin`、`any` 等) | +| `arch` | string | 目标架构(`x86_64`、`aarch64`、`any` 等) | +| `type` | string | 文件类型(`executable` 或 `archive`) | +| `archive_format` | string | 可选,当 `type="archive"` 时指定 | +| `entry_point` | string | 可选,压缩包内的入口点 | +| `unpack` | boolean | 可选,是否自动解压 | + +**示例**: +```json +"fetch_rules": [ + { + "url_pattern": ".*-linux-amd64\\.tar\\.gz$", + "os": "linux", + "arch": "x86_64", + "type": "archive", + "archive_format": "tar.gz", + "entry_point": "myapp/bin/myapp", + "unpack": true + }, + { + "url_pattern": ".*\\.jar$", + "os": "any", + "arch": "any", + "type": "executable" + } +] +``` + +### 4. 客户端访问方式 +基于 WebDAV 协议,客户端可通过以下 HTTP 方法操作: + +- `PROPFIND`:列举项目、版本、os/arch 目录。 +- `GET`:下载二进制文件或元数据文件。 +- `PUT` / `MKCOL`:上传新版本或新文件(用于手动或自动填充)。 +- `DELETE`:删除过期版本。 + +推荐 WebDAV 服务器配置:启用目录列表、支持 CORS、支持 HTTPS。 + +### 5. 自动化拉取新版本流程 +外部工具(如 cron 作业)按以下步骤更新仓库: + +1. 遍历仓库根目录下每个项目的 `metadata.json`。 +2. 访问 `official_url`,获取所有可用版本列表及最新版本。 +3. 对比已存储版本,找出缺失的新版本。 +4. 对于每个新版本,根据 `fetch_rules`(或内置逻辑)匹配下载 URL,下载对应的二进制/压缩包。 +5. 按 `os` / `arch` / `any` 规则存放到 `{project-id}/{version}/{os}/{arch}/` 下。 +6. 计算文件哈希,生成或更新该版本的 `version.json`。 +7. 更新项目级 `metadata.json`(`versions`、`latest_version`、`last_fetch_check`)。 + +--- + +## Part B:本地客户端存储方案(用于版本管理与回退) + +### 1. 概述 +客户端从远程 WebDAV 仓库下载项目的目标代码后,需要在本地持久化存储,并支持: + +- 多版本共存 +- 当前激活版本的快速切换(符号链接) +- 版本回退(无需重新下载) +- 压缩包自动解压与入口点管理 +- 元数据记录(安装历史、校验信息) +- 可配置的旧版本清理策略 + +### 2. 本地根目录结构 + +客户端约定一个根目录(如 `~/.cache/artifact-repo/` 或 `~/.local/share/artifact-repo/`),其下按项目组织: + +``` +~/.local/share/artifact-repo/ + └─ {project-id}/ # 项目目录(与远程 project-id 一致) + ├─ .meta/ # 元数据目录(隐藏) + │ ├─ install.json # 本地安装记录 + │ └─ history/ # 可选,操作历史 JSON 文件 + ├─ versions/ # 所有已下载的版本 + │ ├─ {semver}/ # 版本目录(如 1.2.0) + │ │ ├─ .version.json # 从远程复制的 version.json,附加本地信息 + │ │ └─ {os}/{arch}/ # 与远程结构一致的实际文件 + │ │ └─ ... + │ └─ {semver}/... + └─ current -> versions/{semver} # 符号链接,指向当前激活的版本 +``` + +> **注意**:符号链接可放在项目根目录下,也可放在 `.meta/current`。推荐项目根目录下的 `current`,便于外部脚本直接引用(如 `$REPO_HOME/myapp/current/bin/myapp`)。 + +### 3. 元数据文件 `install.json` + +位于 `.meta/install.json`,记录项目的本地安装状态。 + +**字段说明**: + +| 字段名 | 类型 | 说明 | +| -------------------- | ------------------ | ------------------------------------------------------------ | +| `project_id` | string | 项目标识 | +| `installed_versions` | array of strings | 已下载到本地的所有版本号 | +| `current_version` | string | 当前激活的版本号(应与 `current` 符号链接一致) | +| `last_updated` | string (ISO 8601) | 最后一次变更时间(安装、切换、删除) | +| `history` | array of objects | 操作历史记录,每个元素包含 `timestamp`, `action`, `version`, `from`(可选) | +| `options` | object | 客户端配置,如 `auto_cleanup`, `max_versions` 等 | + +**示例**: +```json +{ + "project_id": "myapp", + "installed_versions": ["1.0.0", "1.2.0", "1.2.1"], + "current_version": "1.2.1", + "last_updated": "2026-06-15T14:30:00Z", + "history": [ + { + "timestamp": "2026-06-10T09:00:00Z", + "action": "install", + "version": "1.0.0" + }, + { + "timestamp": "2026-06-15T14:30:00Z", + "action": "switch", + "version": "1.2.1", + "from": "1.2.0" + } + ], + "options": { + "auto_cleanup": true, + "max_versions": 5 + } +} +``` + +### 4. 版本目录中的 `.version.json` + +从远程仓库的 `version.json` 复制而来,并增加本地特有字段: + +```json +{ + "version": "1.2.1", + "installed_at": "2026-06-15T14:25:00Z", + "local_checksums": { + "linux/x86_64/myapp": "sha256:..." + }, + ... // 原始远程字段(artifacts, release_date 等) +} +``` + +### 5. 核心操作流程 + +#### 5.1 安装新版本(更新) + +1. 从远程 WebDAV 仓库下载指定版本的所有相关文件到 `versions/{new_version}/`,保持远程目录结构(包括 `any/any/` 和具体平台目录)。 +2. 同时下载 `version.json` 并保存为 `.version.json`,添加 `installed_at` 时间戳。 +3. 更新 `.meta/install.json`: + - 将 `new_version` 加入 `installed_versions`(若未存在)。 + - 设置 `current_version` 为 `new_version`(若需要立即激活)。 + - 添加历史记录(`action: "install"` 或 `"switch"`)。 +4. 更新项目根目录下的 `current` 符号链接指向 `versions/{new_version}`。 +5. 如果 `artifacts` 中有 `type="archive"` 且 `unpack=true` 的条目,则解压到版本目录下的特定子目录(如 `extracted/`),并在 `.version.json` 中记录解压后的入口点路径。 +6. 根据 `options` 中的清理策略删除旧版本(保留最近 N 个版本或保留指定天数)。 + +#### 5.2 回退到已安装的旧版本 + +1. 确认目标旧版本存在于 `installed_versions` 中。 +2. 修改 `current` 符号链接指向 `versions/{old_version}`。 +3. 更新 `.meta/install.json`: + - 将 `current_version` 改为 `old_version`。 + - 添加历史记录(`action: "rollback"` 或 `"switch"`,可记录 `from` 原版本)。 +4. 无需重新下载任何文件。 + +#### 5.3 卸载特定版本 + +1. 如果卸载的是当前激活版本,先切换到另一个已安装版本(或提示用户)。 +2. 删除 `versions/{version}` 整个目录。 +3. 从 `install.json` 的 `installed_versions` 数组中移除该版本。 +4. 添加历史记录(`action: "uninstall"`)。 + +#### 5.4 清理旧版本(自动或手动) + +清理策略示例: +- 保留最近 `max_versions` 个版本(不计当前版本)。 +- 或删除 `keep_recent_days` 天之前安装且不是当前版本的版本。 +- 每次安装新版本后自动触发清理。 + +### 6. 处理压缩包(archive)的特别说明 + +当远程 `artifact` 的 `unpack=true` 时: + +- 客户端下载压缩包到 `versions/{version}/{path}`。 +- 立即解压到同目录下的 `_extracted/` 子目录(例如 `versions/1.2.1/linux/x86_64/_extracted/`)。 +- 根据 `entry_point` 在 `.version.json` 中记录解压后的可执行文件绝对路径(或相对于版本目录的路径)。 +- 符号链接 `current` 可以直接指向解压后的入口点文件,而不是整个版本目录。实现方式: + ``` + current/bin/myapp -> ../versions/1.2.1/_extracted/bin/myapp + ``` + +这样切换版本时只需更新符号链接,无需重新解压。 + +### 7. 多平台兼容性 + +客户端在下载时,应根据当前运行的操作系统和架构,仅从远程仓库获取匹配的文件: +- 优先尝试 `{os}/{arch}/` +- 若不存在,回退到 `any/any/` +- 下载后保持远程的相对路径结构,但客户端实际运行时只使用与当前平台相关的文件。 + +在本地存储中,所有版本目录仍保留完整的 `{os}/{arch}/` 和 `any/any/` 结构,便于在不同平台间迁移或共享缓存。 + diff --git a/kit/doc/远程模块管理命令.md b/kit/doc/远程模块管理命令.md new file mode 100644 index 0000000..e7b755b --- /dev/null +++ b/kit/doc/远程模块管理命令.md @@ -0,0 +1,27 @@ + +```bash +# 验证远程仓库可达 +check + +# 发行某个模块 eg. myapp +publish myapp + +# 列出已安装版本 eg. myapp +list myapp +# 输出: +# 1.0.0 +# * 1.2.0 (current) +# 1.2.1 + +# 安装某个模块 eg. myapp +install myapp + +# 切换到版本 1.2.0 +use myapp@1.2.0 + +# 回滚到上一个版本 +rollback myapp + +# 删除模块 +remove myapp +``` diff --git a/kit/src/config/SystemConfig.js b/kit/src/config/SystemConfig.js index e0ac131..b2daf2e 100644 --- a/kit/src/config/SystemConfig.js +++ b/kit/src/config/SystemConfig.js @@ -30,7 +30,11 @@ module.exports = class SystemConfig { static getLocalKitPath(){ let path = "./" if ( this.isWindows() ) { - path = require('path').dirname(require.main.filename) + if( require && require.main && require.main.filename ){ + path = require('path').dirname(require.main.filename) + } else { + path = process.cwd() + } } else { path = require('path').dirname(process.execPath); } diff --git a/kit/src/kitcommand.js b/kit/src/kitcommand.js index 714fbd7..f39ca3c 100644 --- a/kit/src/kitcommand.js +++ b/kit/src/kitcommand.js @@ -35,16 +35,16 @@ module.exports = { let file = cli.getParamValue("file"); if (!file) { - file = "kit" + file = "kitx" } const scriptFile = `./${file}.js`; const stat = fs.existsSync(scriptFile) if (!stat) { if (!cli.getParamValue("file")) { - fs.writeFileSync("./kit.js", ` + fs.writeFileSync("./kitx.js", ` async function main(){ - console.log("hello kit!") + console.log("hello kit!"); } `); } else { @@ -82,7 +82,7 @@ async function main(){ } }); - parser.addCmdLine("remote", "验证远程仓库;", async function () { + parser.addCmdLine("remote", "验证远程仓库可达;", async function () { let remote = await $context.get("remote") if (!(await remote.check())) { $logger.error("远程仓库不可用") diff --git a/kit/src/kitfunction.js b/kit/src/kitfunction.js index ccadfc8..0ddac34 100644 --- a/kit/src/kitfunction.js +++ b/kit/src/kitfunction.js @@ -329,7 +329,11 @@ function stringifyLogValue(value) { function printCallLog(name, args, result) { console.log(`调用 ${name} 功能`); console.log(`入参 ${args.map(stringifyLogValue).join(" ")}`); - console.log(`结果 ${stringifyLogValue(result)}`); + if (result === undefined) { + console.log("执行结束"); + } else { + console.log(`执行结果 ${stringifyLogValue(result)}`); + } } function withCallLog(name, fn) { diff --git a/kit/src/remote/index.js b/kit/src/remote/index.js index 779d9a6..c081668 100644 --- a/kit/src/remote/index.js +++ b/kit/src/remote/index.js @@ -1,239 +1,366 @@ -/** - * remote 模块用来处理远程仓库相关事务 - */ -const path = require("path") -const fs = require("fs") -const JSON5 = require('json5') -const { pipeline, Transform} = require("stream/promises"); +const fs = require("fs"); +const os = require("os"); +const path = require("path"); +const { pipeline } = require("stream/promises"); +const JSON5 = require("json5"); + +function now() { + return new Date().toISOString(); +} + +function normalizeRemotePath(value) { + if (!value || value === "/") { + return "/"; + } + return path.posix.normalize("/" + String(value).replace(/\\/g, "/")).replace(/\/+$|^$/g, "") || "/"; +} + +function normalizeOs(value) { + const name = String(value || os.platform()).toLowerCase(); + if (["windows_nt", "win32", "windows"].includes(name)) { + return "windows"; + } + if (["macos", "mac", "darwin"].includes(name)) { + return "darwin"; + } + if (["linux", "alpine"].includes(name)) { + return "linux"; + } + return name; +} + +function normalizeArch(value) { + const name = String(value || os.arch()).toLowerCase(); + if (["x64", "x86-64", "x86_64", "amd64"].includes(name)) { + return "x86_64"; + } + if (["arm64", "aarch64"].includes(name)) { + return "aarch64"; + } + if (["ia32", "i386", "x86"].includes(name)) { + return "i386"; + } + return name; +} + +function localModuleRoot(projectId) { + return path.resolve($sConfig.getLocalStorePath(), "modules", projectId); +} + +function readJsonFile(filePath, fallback) { + try { + if (fs.existsSync(filePath)) { + return JSON5.parse(fs.readFileSync(filePath, "utf-8")); + } + } catch (e) { + $logger.warn(`读取 JSON 失败 ${filePath}: ${e.message}`); + } + return fallback; +} + +function writeJsonFile(filePath, data) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, JSON.stringify(data, null, 4), "utf-8"); +} + +function safeRemove(target) { + fs.rmSync(target, { recursive: true, force: true }); +} + +function copyRecursive(from, to) { + const stat = fs.statSync(from); + if (stat.isDirectory()) { + fs.mkdirSync(to, { recursive: true }); + for (const item of fs.readdirSync(from)) { + copyRecursive(path.join(from, item), path.join(to, item)); + } + } else { + fs.mkdirSync(path.dirname(to), { recursive: true }); + fs.copyFileSync(from, to); + } +} module.exports = class Remote { - constructor() { - // this.__init() - this.func_publish = require("./publish") - this.func_install = require("./install") + this.func_publish = require("./publish"); + this.func_install = require("./install"); + this.root = "/"; } - - async init() { - this.webdav = await import("webdav"); + const webdav = await import("webdav"); + this.webdav = webdav.createClient ? webdav : webdav.default; const uConfig = await $context.get("uConfig"); - if ( uConfig.get("remoteServer") ){ - let server = uConfig.get("remoteServer"); - $logger.info("server " + server) - if ( server.startsWith("webdav") ) { - server = "http" + server.substring(6) - } - - if ( uConfig.get("remoteAuth") ) { - let up = uConfig.get("remoteAuth"); - $logger.info("remoteAuth " + up) - up = up.split(":", 2) - this.client = this.webdav.createClient( - server, - { - username: up[0], - password: up[1] - } - ); - } else { - this.client = this.webdav.createClient( - server - ); - } + let server = uConfig.get("remoteServer"); + if (!server) { + return; } + + if (server.startsWith("webdav")) { + server = "http" + server.substring(6); + } + + const options = {}; + const auth = uConfig.get("remoteAuth"); + if (auth) { + const [username, password] = auth.split(":", 2); + options.username = username; + options.password = password; + } + + this.client = this.webdav.createClient(server, options); } async check() { - - if ( ! this.webdav ) { - $logger.info("webdav unable") - return false - } - - if ( ! this.client ) { - $logger.info("client unable") - return false + if (!this.client) { + $logger.info("remote client unavailable"); + return false; } try { - - // Get directory contents - const directoryItems = await this.client.getDirectoryContents("/"); - $logger.info("directoryItems", directoryItems) - // 是否包含 index.json ? - // 不包含尝试创建 - // 包含了尝试下载 - const indexJsonFile = this.getIndexJson( directoryItems ); - $logger.info("indexJsonFile", indexJsonFile) - if ( indexJsonFile ) { - // 包含了尝试下载 - // 下载有缓存 - this.download(indexJsonFile) - $logger.info("download index") - - } else { - // 不包含尝试创建 - await this.affireRemoteStore() - $logger.info("affireRemoteStore") - } - - return true - }catch ( e ) { - $logger.error("remote check 过程中出错", e) - console.error(e) - return false - } - } - - /** - * 先对比时间和长度,有不同才会真正下载,否则使用本地缓存。 - * 本地缓存路径即本地仓库路径 - * @param rFile - */ - download(rFile){ - const lFilePath = path.resolve($sConfig.getLocalStorePath(), rFile.filename); - if ( fs.existsSync( lFilePath ) ) { - const lFileInfo = fs.statSync(lFilePath); - if ( lFileInfo.size === rFile.length - && lFileInfo.mtime === rFile.lastmod - ) { - // 使用缓存 - $logger.debug("use cache") - return true - } - } - - // 下载并覆盖 - // 不知道是同步还是异步 - this.wDownload( rFile.filename, lFilePath ); - - - } - - async affireRemoteStore(){ - // Get directory contents - const directoryItems = await this.client.getDirectoryContents("/"); - // 是否包含 index.json ? - // 不包含尝试创建 - // 包含了尝试下载 - const indexJsonFile = this.getIndexJson( directoryItems ); - if ( ! indexJsonFile ) { - // 创建 index - await this.client.putFileContents("/index.json", JSON5.stringify({ - modules : [], - tmpls : [], - lists : [] - }, null, 4) , { overwrite: false }); - } - - const modulesFolder = this.getFileInfoByName(directoryItems, "modules") - if ( ! modulesFolder ) { - // 创建文件夹 - await this.client.createDirectory("/modules"); - } - - const tmplsFolder = this.getFileInfoByName(directoryItems, "tmpls") - if ( ! tmplsFolder ) { - // 创建文件夹 - await this.client.createDirectory("/tmpls"); - } - - const listsFolder = this.getFileInfoByName(directoryItems, "lists") - if ( ! listsFolder ) { - // 创建文件夹 - await this.client.createDirectory("/lists"); - } - - } - - getIndexJson(items){ - return this.getFileInfoByName( items, "index.json" ); - } - getFileInfoByName(items, name){ - if ( ! items || items.length < 1 ) { + await this.ensureDir("/"); + await this.client.getDirectoryContents("/"); + return true; + } catch (e) { + $logger.error("remote check 过程中出错 " + e.message); return false; } - for ( let item of items ) { - if ( item.basename === name ) { - return item; + } + + async publish(localFile, remotePath) { + return await this.func_publish(this, localFile, remotePath); + } + + async install(id) { + return await this.func_install(this, id); + } + + async list(projectId) { + const state = this.getInstallState(projectId); + for (const version of state.installed_versions) { + const current = version === state.current_version ? "*" : " "; + $logger.info(`${current} ${version}${current === "*" ? " (current)" : ""}`); + } + return state.installed_versions; + } + + async use(projectVersion) { + const { projectId, version } = this.parseProjectVersion(projectVersion); + return this.activate(projectId, version, "switch"); + } + + async rollback(projectId) { + const state = this.getInstallState(projectId); + const history = [...state.history].reverse(); + const current = state.current_version; + const previous = history.find((item) => item.version && item.version !== current && state.installed_versions.includes(item.version)); + if (!previous) { + throw new Error(`没有可回滚版本 ${projectId}`); + } + return this.activate(projectId, previous.version, "rollback"); + } + + async remove(projectVersion) { + const { projectId, version } = this.parseProjectVersion(projectVersion); + const root = localModuleRoot(projectId); + const state = this.getInstallState(projectId); + const targetVersion = version || state.current_version; + if (!targetVersion) { + return false; + } + + if (state.current_version === targetVersion) { + throw new Error(`不能删除当前激活版本 ${projectId}@${targetVersion}`); + } + + safeRemove(path.resolve(root, "versions", targetVersion)); + state.installed_versions = state.installed_versions.filter((item) => item !== targetVersion); + this.pushHistory(state, "uninstall", targetVersion); + this.saveInstallState(projectId, state); + return true; + } + + parseProjectVersion(value) { + const text = String(value || "kit"); + const index = text.lastIndexOf("@"); + if (index <= 0) { + return { projectId: text, version: undefined }; + } + return { projectId: text.substring(0, index), version: text.substring(index + 1) }; + } + + getLocalModuleRoot(projectId) { + const root = localModuleRoot(projectId); + fs.mkdirSync(root, { recursive: true }); + return root; + } + + getInstallState(projectId) { + const statePath = path.resolve(this.getLocalModuleRoot(projectId), ".meta", "install.json"); + const state = readJsonFile(statePath, { + project_id: projectId, + installed_versions: [], + current_version: undefined, + last_updated: undefined, + history: [], + options: {} + }); + state.project_id = state.project_id || projectId; + state.installed_versions = state.installed_versions || []; + state.history = state.history || []; + state.options = state.options || {}; + return state; + } + + saveInstallState(projectId, state) { + state.last_updated = now(); + writeJsonFile(path.resolve(this.getLocalModuleRoot(projectId), ".meta", "install.json"), state); + } + + pushHistory(state, action, version, from) { + state.history.push({ timestamp: now(), action, version, from }); + } + + async activate(projectId, version, action = "switch") { + const root = this.getLocalModuleRoot(projectId); + const versionDir = path.resolve(root, "versions", version); + if (!fs.existsSync(versionDir)) { + throw new Error(`本地版本不存在 ${projectId}@${version}`); + } + + const current = path.resolve(root, "current"); + safeRemove(current); + try { + fs.symlinkSync(path.relative(root, versionDir), current, "junction"); + } catch (e) { + copyRecursive(versionDir, current); + } + + const state = this.getInstallState(projectId); + const from = state.current_version; + state.current_version = version; + if (!state.installed_versions.includes(version)) { + state.installed_versions.push(version); + } + this.pushHistory(state, action, version, from); + this.saveInstallState(projectId, state); + fs.writeFileSync(path.resolve(root, "version"), version, "utf-8"); + return version; + } + + getPlatformCandidates() { + return [ + { os: normalizeOs(os.platform()), arch: normalizeArch(os.arch()) }, + { os: "any", arch: "any" } + ]; + } + + async ensureDir(remotePath) { + const target = normalizeRemotePath(remotePath); + if (target === "/") { + return; + } + if (await this.client.exists(target)) { + return; + } + await this.ensureDir(path.posix.dirname(target)); + await this.client.createDirectory(target); + } + + async putJson(remotePath, data) { + await this.ensureDir(path.posix.dirname(remotePath)); + await this.client.putFileContents(normalizeRemotePath(remotePath), JSON.stringify(data, null, 4), { overwrite: true }); + } + + async getJson(remotePath, fallback) { + try { + const text = await this.client.getFileContents(normalizeRemotePath(remotePath), { format: "text" }); + return JSON5.parse(text); + } catch (e) { + return fallback; + } + } + + async wUpload(from, to) { + await this.ensureDir(path.posix.dirname(to)); + const rs = fs.createReadStream(from); + const ws = this.client.createWriteStream(normalizeRemotePath(to)); + await pipeline(rs, ws); + } + + async wDownload(from, to) { + fs.mkdirSync(path.dirname(to), { recursive: true }); + const rs = this.client.createReadStream(normalizeRemotePath(from)); + const ws = fs.createWriteStream(to); + await pipeline(rs, ws); + } + + async wMkdir(folder) { + return this.ensureDir(folder); + } + + async wGetModuleInfo(projectId) { + return this.getProjectMetadata(projectId); + } + + async getProjectMetadata(projectId) { + const metadata = await this.getJson(`/${projectId}/metadata.json`, undefined); + if (metadata) { + return metadata; + } + + const legacy = await this.getJson(`/modules/${projectId}/meta.json`, undefined); + if (legacy) { + return { + project_id: legacy.id || projectId, + description: legacy.desc || legacy.name || "", + latest_version: legacy.latest && legacy.latest.version, + versions: (legacy.versions || []).map((item) => item.version || item), + extra: legacy + }; + } + throw new Error(`远程模块不存在 ${projectId}`); + } + + async getVersionMetadata(projectId, version) { + const metadata = await this.getJson(`/${projectId}/${version}/version.json`, undefined); + if (metadata) { + return metadata; + } + + const project = await this.getProjectMetadata(projectId); + const legacyVersion = project.extra && project.extra.versions && project.extra.versions.find((item) => (item.version || item) === version); + if (legacyVersion && legacyVersion.dist) { + return { + version, + artifacts: legacyVersion.dist.map((dist) => ({ + path: `${normalizeOs(dist.os)}/${normalizeArch(dist.platform)}/${dist.filename}`, + type: "executable" + })) + }; + } + throw new Error(`远程版本不存在 ${projectId}@${version}`); + } + + async downloadArtifact(projectId, version, artifact, versionDir) { + const remotePath = `/${projectId}/${version}/${artifact.path}`; + const localPath = path.resolve(versionDir, artifact.path); + await this.wDownload(remotePath, localPath); + return localPath; + } + + getMatchingArtifacts(versionMetadata) { + const artifacts = versionMetadata.artifacts || []; + const candidates = this.getPlatformCandidates(); + for (const candidate of candidates) { + const prefix = `${candidate.os}/${candidate.arch}/`; + const matched = artifacts.filter((item) => String(item.path).replace(/\\/g, "/").startsWith(prefix)); + if (matched.length > 0) { + return matched; } } - return false; + return []; } - - async publish(localFile, remotePath){ - return await this.func_publish( this , localFile, remotePath ) - } - - async install(id){ - return this.func_install( this, id ) - } - - async wUpload(from, to, debug){ - $logger.info(`from=${from} to=${to}`) - const totalSize = fs.statSync(from).size; - debug ? console.log("totalSize", totalSize) : null - const rs = fs.createReadStream( from ); - let wSize = 0; - let rSize = 0; - rs.on("data", (r)=>{ - rSize += r.length; - debug ? console.log("r", rSize * 100 / totalSize + "%") : null - }) - const ws = this.client.createWriteStream(to) - ws.on("data", (r)=>{ - wSize += r.length; - debug ? console.log("w ", wSize * 100 / totalSize + "%") : null - }) - await pipeline( rs, ws); - debug ? console.log("finish") : null - } - - async wDownload(from, to){ - $logger.info("download", from, to ) - const stat = await this.client.stat(from); - const totalSize = stat.size; - let wSize = 0; - let rSize = 0; - - const rs = this.client.createReadStream(from); - rs.on("data", (r)=>{ - rSize += r.length; - // console.log("r", rSize * 100 / totalSize) - }) - - const ws = fs.createWriteStream(to); - ws.on("data", (r)=>{ - wSize += r.length; - // console.log("w", wSize * 100 / totalSize) - }) - return await pipeline( - rs, ws - ) - } - - async wMkdir(folder){ - - const folderfather = path.dirname( folder ); - if ( ! await this.client.exists( folderfather ) ) { - // await remote.client.createDirectory( folderfather ) - await this.wMkdir( folderfather ) - } - - if ( await this.client.exists( folder ) ) { - return - } else { - await this.client.createDirectory( folder ) - } - } - - async wGetModuleInfo(id){ - let meta = await this.client.getFileContents("/modules/" + id + "/meta.json", { format: "text" }); - let metaJson = JSON5.parse( meta ); - return metaJson - } - } + diff --git a/kit/src/remote/install.js b/kit/src/remote/install.js index 6f69cf1..c8b0b07 100644 --- a/kit/src/remote/install.js +++ b/kit/src/remote/install.js @@ -1,53 +1,83 @@ -const os = require('os'); -const fs = require("fs"); -const osutil = require("../util/osutil") +const fs = require("fs"); +const path = require("path"); +const { execFileSync } = require("child_process"); -function matchDistName(cur, dists){ - const list = [] - for (let dist of dists) { - if ( cur.curOS === dist.os && cur.curPlatform === dist.platform) { - list.push( dist ) - } - else if ( cur.curOS === dist.os && "cross" === dist.platform) { - list.unshift( dist ) - } - else if ( "cross" === dist.os && cur.curPlatform === dist.platform) { - list.unshift( dist ) - } - } - return list +function now() { + return new Date().toISOString(); } +function verifyChecksum(filePath, checksum) { + if (!checksum) { + return; + } + const [alg, expected] = checksum.split(":", 2); + if (!alg || !expected) { + return; + } + const crypto = require("crypto"); + const actual = crypto.createHash(alg).update(fs.readFileSync(filePath)).digest("hex"); + if (actual !== expected) { + throw new Error(`文件校验失败 ${filePath}`); + } +} -module.exports = async function install(remote, id){ - const cur = osutil.cur(); - - if ( ! id ) { - id = "kit" +function unpackArtifact(artifact, localPath, versionDir) { + if (artifact.type !== "archive" || !artifact.unpack) { + return; } - // 获取本地版本 - const localInfo = osutil.choise([ - require("../windows/local").getModuleInfo, - require("../linux/local").getModuleInfo, - ])( id ) + const extractDir = path.resolve(path.dirname(localPath), "_extracted"); + fs.mkdirSync(extractDir, { recursive: true }); + const format = artifact.archive_format || "zip"; + if (format === "zip") { + if (process.platform === "win32") { + const source = localPath.replace(/'/g, "''"); + const target = extractDir.replace(/'/g, "''"); + execFileSync("powershell.exe", ["-NoProfile", "-Command", `Expand-Archive -Path '${source}' -DestinationPath '${target}' -Force`], { stdio: "inherit" }); + } else { + execFileSync("unzip", ["-o", localPath, "-d", extractDir], { stdio: "inherit" }); + } + } else if (["tar", "tar.gz", "tgz", "tar.xz"].includes(format)) { + execFileSync("tar", ["-xf", localPath, "-C", extractDir], { stdio: "inherit" }); + } - $logger.info("local version", localInfo.version) + artifact.extracted_path = path.relative(versionDir, extractDir).replace(/\\/g, "/"); + if (artifact.entry_point) { + artifact.extracted_entry_point = path.posix.join(artifact.extracted_path, artifact.entry_point.replace(/\\/g, "/")); + } +} - // 确认远程版本文件是否存在 - const remoteInfo = await remote.wGetModuleInfo(id) - $logger.info("remote latest version", remoteInfo.latest.version) +module.exports = async function install(remote, id) { + const parsed = remote.parseProjectVersion(id || "kit"); + const projectId = parsed.projectId; + const project = await remote.getProjectMetadata(projectId); + const version = parsed.version || project.latest_version; + if (!version) { + throw new Error(`远程模块没有可安装版本 ${projectId}`); + } - const dists = matchDistName(cur, remoteInfo.latest.dist) - const dist = dists.pop() + const versionMeta = await remote.getVersionMetadata(projectId, version); + const artifacts = remote.getMatchingArtifacts(versionMeta); + if (artifacts.length < 1) { + throw new Error(`没有匹配当前平台的文件 ${projectId}@${version}`); + } - await remote.wDownload( - "/modules/" + id + "/" + remoteInfo.latest.version + "/" + cur.curOS + "/" + cur.curPlatform + "/" + dist.filename, - localInfo.homeDir + "/" + dist.filename, - ) + const root = remote.getLocalModuleRoot(projectId); + const versionDir = path.resolve(root, "versions", version); + fs.mkdirSync(versionDir, { recursive: true }); - fs.writeFileSync( localInfo.homeDir + "/version", remoteInfo.latest.version ); + for (const artifact of artifacts) { + const localPath = await remote.downloadArtifact(projectId, version, artifact, versionDir); + verifyChecksum(localPath, artifact.checksum || (versionMeta.checksums && versionMeta.checksums[artifact.path])); + unpackArtifact(artifact, localPath, versionDir); + } - $logger.info("当前版本", remoteInfo.latest.version ) + const localVersionMeta = Object.assign({}, versionMeta, { installed_at: now(), artifacts }); + fs.writeFileSync(path.resolve(versionDir, ".version.json"), JSON.stringify(localVersionMeta, null, 4), "utf-8"); -} \ No newline at end of file + const state = remote.getInstallState(projectId); + const action = state.current_version ? "switch" : "install"; + await remote.activate(projectId, version, action); + $logger.info(`当前版本 ${projectId}@${version}`); + return version; +}; diff --git a/kit/src/remote/publish.js b/kit/src/remote/publish.js index c753002..fb4f947 100644 --- a/kit/src/remote/publish.js +++ b/kit/src/remote/publish.js @@ -1,107 +1,176 @@ -const path = require("path") -const fs = require("fs") -const JSON5 = require('json5') -const { pipeline } = require('stream/promises'); -const objutil = require("../util/objutil") -const datautil = require("../util/datautil") +const crypto = require("crypto"); +const fs = require("fs"); +const path = require("path"); +const JSON5 = require("json5"); +function now() { + return new Date().toISOString(); +} + +function normalizeOs(value) { + const name = String(value || "any").toLowerCase(); + if (["windows_nt", "win32", "windows"].includes(name)) { + return "windows"; + } + if (["macos", "mac", "darwin"].includes(name)) { + return "darwin"; + } + if (["linux", "alpine"].includes(name)) { + return "linux"; + } + if (["cross", "all", "any"].includes(name)) { + return "any"; + } + return name; +} + +function normalizeArch(value) { + const name = String(value || "any").toLowerCase(); + if (["x64", "x86-64", "x86_64", "amd64"].includes(name)) { + return "x86_64"; + } + if (["arm64", "aarch64"].includes(name)) { + return "aarch64"; + } + if (["ia32", "i386", "x86"].includes(name)) { + return "i386"; + } + if (["cross", "all", "any"].includes(name)) { + return "any"; + } + return name; +} + +function sha256(filePath) { + return crypto.createHash("sha256").update(fs.readFileSync(filePath)).digest("hex"); +} + +function guessArtifactType(filePath) { + const ext = path.extname(filePath).toLowerCase(); + if ([".zip", ".tgz", ".gz", ".xz", ".tar"].includes(ext)) { + return "archive"; + } + return "executable"; +} + +function archiveFormat(filePath) { + const name = path.basename(filePath).toLowerCase(); + if (name.endsWith(".tar.gz") || name.endsWith(".tgz")) { + return "tar.gz"; + } + if (name.endsWith(".tar.xz")) { + return "tar.xz"; + } + if (name.endsWith(".zip")) { + return "zip"; + } + if (name.endsWith(".tar")) { + return "tar"; + } + return undefined; +} + +function buildVersion(localMeta) { + if (localMeta.release_version) { + return localMeta.release_version; + } + if (localMeta.version) { + return localMeta.version; + } + return new Date().toISOString().replace(/[-:TZ.]/g, "").slice(0, 14); +} + +function readLocalMeta(localFile) { + const base = localFile ? path.resolve(localFile) : process.cwd(); + const metaFile = fs.statSync(base).isDirectory() ? path.resolve(base, "meta.json") : path.resolve(process.cwd(), "meta.json"); + if (fs.existsSync(metaFile)) { + return JSON5.parse(fs.readFileSync(metaFile, "utf-8")); + } + + if (!localFile) { + throw new Error("当前路径不包含 meta.json"); + } + + return { + id: path.basename(localFile, path.extname(localFile)).toLowerCase(), + version: buildVersion({}), + desc: "", + dist: [{ path: localFile, os: "any", platform: "any", filename: path.basename(localFile) }] + }; +} + +function buildArtifacts(localMeta) { + const dists = localMeta.dist || localMeta.artifacts || []; + return dists.map((dist) => { + const localPath = path.resolve(dist.path || dist.localPath || dist.file || dist.filename); + const osName = normalizeOs(dist.os); + const archName = normalizeArch(dist.arch || dist.platform); + const filename = dist.filename || path.basename(localPath); + const artifactPath = dist.remotePath || `${osName}/${archName}/${filename}`; + const type = dist.type || guessArtifactType(localPath); + const artifact = { + path: artifactPath.replace(/\\/g, "/"), + type, + checksum: `sha256:${sha256(localPath)}`, + localPath + }; + if (type === "archive") { + artifact.archive_format = dist.archive_format || archiveFormat(localPath); + artifact.entry_point = dist.entry_point; + artifact.unpack = Boolean(dist.unpack); + } + return artifact; + }); +} module.exports = async function publish(remote, localFile, remotePath) { + if (remotePath) { + const target = path.posix.resolve("/", remotePath.replace(/\\/g, "/")); + await remote.wUpload(localFile, path.posix.join(target, path.basename(localFile))); + $logger.info("文件上传成功"); + return; + } - if( remotePath ) { + const localMeta = readLocalMeta(localFile); + const projectId = localMeta.project_id || localMeta.id || localMeta.name; + if (!projectId) { + throw new Error("meta.json 缺少 project_id/id/name"); + } - // 如果有 meta + const version = buildVersion(localMeta); + const artifacts = buildArtifacts(localMeta); + await remote.ensureDir(`/${projectId}/${version}`); - // 如果没有 meta - $logger.info("远程路径没有 meta, 按照默认规则上传"); + const versionMeta = { + version, + release_date: localMeta.release_date || now(), + official_download_url: localMeta.official_download_url || "", + artifacts: artifacts.map(({ localPath, ...artifact }) => artifact), + checksums: Object.fromEntries(artifacts.map((artifact) => [artifact.path, artifact.checksum])) + }; - // 确认目录 - let folder = path.posix.resolve("/" + remotePath + "/data/" + datautil.now() ); - if ( ! await remote.client.exists( folder ) ) { - // 创建文件夹 - await remote.client.createDirectory(folder, { - recursive: true - }); - } + for (const artifact of artifacts) { + await remote.wUpload(artifact.localPath, `/${projectId}/${version}/${artifact.path}`); + } + await remote.putJson(`/${projectId}/${version}/version.json`, versionMeta); - let filename = path.basename(localFile); + const metadata = await remote.getJson(`/${projectId}/metadata.json`, { + project_id: projectId, + official_url: localMeta.official_url || localMeta.vcs || "", + description: localMeta.description || localMeta.desc || "", + latest_version: version, + versions: [], + fetch_rules: localMeta.fetch_rules || [], + extra: {} + }); - // 上传文件 - await remote.wUpload( localFile , path.posix.resolve(folder , filename )); + metadata.project_id = projectId; + metadata.official_url = metadata.official_url || localMeta.official_url || localMeta.vcs || ""; + metadata.description = localMeta.description || localMeta.desc || metadata.description || ""; + metadata.latest_version = version; + metadata.versions = Array.from(new Set([...(metadata.versions || []), version])); + metadata.extra = Object.assign({}, metadata.extra || {}, localMeta.extra || {}); - } else { - - let curPath = process.cwd(); - const metaFilePath = path.resolve( curPath, "meta.json" ); - if ( ! fs.existsSync( metaFilePath ) ) { - $logger.error("当前路径不包含 meta.json") - return - } - - const metaFile = fs.readFileSync(metaFilePath); - - const localMeta = JSON5.parse( metaFile ); - - // 更新版本 - const date = new Date(); - localMeta.version = `${localMeta.version}.${date.getFullYear()}${(date.getMonth() + 1).toString().padStart(2, '0')}${date.getDate().toString().padStart(2, '0')}${date.getHours().toString().padStart(2, '0')}${date.getMinutes().toString().padStart(2, '0')}${date.getSeconds().toString().padStart(2, '0')}`; - - - // 确认目录 - // const directoryItems = await remote.getDirectoryContents("/modules"); - // const modulesFolder = remote.getFileInfoByName(directoryItems, localMeta.id) - if ( ! await remote.client.exists("/modules/" + localMeta.id) ) { - // 创建文件夹 - await remote.client.createDirectory("/modules/" + localMeta.id); - } - - // 先更新文件 - for (let dist of localMeta.dist ) { - - const folder = "/modules/" + localMeta.id + "/" + localMeta.version + "/" + dist.os + "/" + dist.platform - await remote.wMkdir( folder ); - - $logger.info("upload dist", dist.path, fs.existsSync(dist.path)) - - await remote.wUpload( dist.path , folder + "/" + dist.filename ); - - } - - // remote meta - let remoteMeta = { - "versions" : [] - } - try { - const remoteMetaStr = await remote.client.getFileContents("/modules/" + localMeta.id + "/meta.json", { format: "text" }); - remoteMeta = JSON5.parse( remoteMetaStr ) - } catch (e) { - - } - - remoteMeta.id = localMeta.id; - remoteMeta.name = localMeta.name; - remoteMeta.desc = localMeta.desc; - remoteMeta.type = localMeta.type; - remoteMeta.vcs = localMeta.vcs; - // 废弃 - // remoteMeta.platform = "cross" === localMeta.platform ? ["X86_64", "ARM64"] : localMeta.platform; - // remoteMeta.os = "cross" === localMeta.os ? ["Windows", "Linux", "Alpine"] : localMeta.os; - const dist = objutil.copy(localMeta.dist) - for (let d of dist) { - delete dist.path - } - remoteMeta.latest = { - version : localMeta.version, - dist - } - remoteMeta.versions.push(remoteMeta.latest) - if ( remoteMeta.versions.length >= 20 ) { - remoteMeta.versions = remoteMeta.versions.slice(0, 20); - } - - await remote.client.putFileContents("/modules/" + localMeta.id + "/meta.json", JSON5.stringify(remoteMeta, null, 4) , { overwrite: true }); - - } - - $logger.info("文件上传成功") -} \ No newline at end of file + await remote.putJson(`/${projectId}/metadata.json`, metadata); + $logger.info(`文件上传成功 ${projectId}@${version}`); +}; diff --git a/kit/test/kit b/kit/test/kit deleted file mode 100644 index ecb085f..0000000 Binary files a/kit/test/kit and /dev/null differ diff --git a/kit/test/remote_debug.js b/kit/test/remote_debug.js new file mode 100644 index 0000000..9a21fd8 --- /dev/null +++ b/kit/test/remote_debug.js @@ -0,0 +1,13 @@ +require("../src/init") +const Remote = require("../src/remote") + + +async function main(){ + const remote = new Remote(); + await remote.init() + + console.log(await remote.check()) +} + + +main() diff --git a/kit/test/remote_test.js b/kit/test/remote_test.js new file mode 100644 index 0000000..ebbffc4 --- /dev/null +++ b/kit/test/remote_test.js @@ -0,0 +1,100 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import fs from "fs"; +import os from "os"; +import path from "path"; + +require("../src/init") +async function createRemote() { + const Remote = require("../src/remote") + const remote = new Remote(); + await remote.init() + return remote +} + +let tempDir; +let localStore; + +beforeEach(() => { + tempDir = fs.mkdtempSync(path.resolve(os.tmpdir(), "kit-remote-test")); + localStore = $sConfig.getLocalStorePath(); +}); + +afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); +}); + +describe("remote module public API", () => { + test("exposes documented public methods", async() => { + const remote = await createRemote(); + expect(typeof remote.check).toBe("function"); + expect(typeof remote.publish).toBe("function"); + expect(typeof remote.install).toBe("function"); + expect(typeof remote.list).toBe("function"); + expect(typeof remote.use).toBe("function"); + expect(typeof remote.rollback).toBe("function"); + expect(typeof remote.remove).toBe("function"); + }); + + test("check validates an available webdav client", async () => { + const remote = await createRemote(); + expect(await remote.check()).toBe(true); + }); + + test("publish stores metadata and version artifacts in webdav layout", async () => { + const remote = await createRemote(); + const projectDir = writeFixtureProject(tempDir, "1.0.0", "hello v1"); + + await remote.publish(projectDir); + + const metadata = JSON.parse(await remote.client.getFileContents("/myapp/metadata.json", { format: "text" })); + const version = JSON.parse(await remote.client.getFileContents("/myapp/1.0.0/version.json", { format: "text" })); + const artifact = await remote.client.getFileContents("/myapp/1.0.0/any/any/myapp.txt", { format: "text" }); + + expect(metadata.project_id).toBe("myapp"); + expect(metadata.latest_version).toBe("1.0.0"); + expect(metadata.versions).toContain("1.0.0"); + expect(version.artifacts[0].path).toBe("any/any/myapp.txt"); + expect(artifact).toBe("hello v1"); + }); + + test("install downloads latest matching artifact and records local state", async () => { + const remote = await createRemote(); + const projectDir = writeFixtureProject(tempDir, "1.0.0", "hello v1"); + await remote.publish(projectDir); + + const installed = await remote.install("myapp"); + + const moduleRoot = path.resolve(localStore, "modules", "myapp"); + const installJson = JSON.parse(fs.readFileSync(path.resolve(moduleRoot, ".meta", "install.json"), "utf-8")); + const installedFile = fs.readFileSync(path.resolve(moduleRoot, "versions", "1.0.0", "any", "any", "myapp.txt"), "utf-8"); + + expect(installed).toBe("1.0.0"); + expect(installedFile).toBe("hello v1"); + expect(installJson.current_version).toBe("1.0.0"); + expect(installJson.installed_versions).toContain("1.0.0"); + expect(fs.existsSync(path.resolve(moduleRoot, "current"))).toBe(true); + }); + + test("list use rollback and remove manage installed versions", async () => { + const remote = await createRemote(); + await remote.publish(writeFixtureProject(tempDir, "1.0.0", "hello v1")); + await remote.install("myapp"); + await remote.publish(writeFixtureProject(tempDir, "2.0.0", "hello v2")); + await remote.install("myapp"); + + expect(await remote.list("myapp")).toEqual(["1.0.0", "2.0.0"]); + + await remote.rollback("myapp"); + let installJson = JSON.parse(fs.readFileSync(path.resolve(localStore, "modules", "myapp", ".meta", "install.json"), "utf-8")); + expect(installJson.current_version).toBe("1.0.0"); + + await remote.use("myapp@2.0.0"); + installJson = JSON.parse(fs.readFileSync(path.resolve(localStore, "modules", "myapp", ".meta", "install.json"), "utf-8")); + expect(installJson.current_version).toBe("2.0.0"); + + await remote.remove("myapp@1.0.0"); + installJson = JSON.parse(fs.readFileSync(path.resolve(localStore, "modules", "myapp", ".meta", "install.json"), "utf-8")); + expect(installJson.installed_versions).toEqual(["2.0.0"]); + expect(fs.existsSync(path.resolve(localStore, "modules", "myapp", "versions", "1.0.0"))).toBe(false); + }); +}); diff --git a/kit/test/webdav_test.js b/kit/test/webdav_test.js index 4ffc408..5b917b4 100644 --- a/kit/test/webdav_test.js +++ b/kit/test/webdav_test.js @@ -105,10 +105,10 @@ async function main(){ // } // ); const client = webdav.createClient( - "http://baishe.fullstack.club:5244/dav", + "http://openlist.honor3.com/dav", { - username: "kit", - password: "kitkit" + username: "sunyard", + password: "sunyard" } ); @@ -136,9 +136,12 @@ async function main(){ // console.log("end") - await wUpload( client, "D:\\fullstack\\TDevOps\\Readme.md", "/test/case1/data/20250313111818/Readme.md2" ) + // await wUpload( client, "D:\\fullstack\\TDevOps\\Readme.md", "/test/case1/data/20250313111818/Readme.md2" ) // await wUpload( client, "../dist/windows/kit.exe", "/modules/kit/0.1.20240227094429/Windows/X86_64/kit.exe" ) console.log("over") } + +//kit3 config remoteAuth sunyard:sunyard +//kit3 config remoteServer http://openlist.honor3.com/dav main() \ No newline at end of file