This commit is contained in:
parent
b46cddc7cf
commit
e05ce19eac
47
kit/.vscode/launch.json
vendored
Normal file
47
kit/.vscode/launch.json
vendored
Normal file
@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
BIN
kit/bun.lockb
BIN
kit/bun.lockb
Binary file not shown.
365
kit/doc/kit remote 仓库格式设计.md
Normal file
365
kit/doc/kit remote 仓库格式设计.md
Normal file
@ -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/` 结构,便于在不同平台间迁移或共享缓存。
|
||||||
|
|
||||||
27
kit/doc/远程模块管理命令.md
Normal file
27
kit/doc/远程模块管理命令.md
Normal file
@ -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
|
||||||
|
```
|
||||||
@ -30,7 +30,11 @@ module.exports = class SystemConfig {
|
|||||||
static getLocalKitPath(){
|
static getLocalKitPath(){
|
||||||
let path = "./"
|
let path = "./"
|
||||||
if ( this.isWindows() ) {
|
if ( this.isWindows() ) {
|
||||||
|
if( require && require.main && require.main.filename ){
|
||||||
path = require('path').dirname(require.main.filename)
|
path = require('path').dirname(require.main.filename)
|
||||||
|
} else {
|
||||||
|
path = process.cwd()
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
path = require('path').dirname(process.execPath);
|
path = require('path').dirname(process.execPath);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -35,16 +35,16 @@ module.exports = {
|
|||||||
|
|
||||||
let file = cli.getParamValue("file");
|
let file = cli.getParamValue("file");
|
||||||
if (!file) {
|
if (!file) {
|
||||||
file = "kit"
|
file = "kitx"
|
||||||
}
|
}
|
||||||
|
|
||||||
const scriptFile = `./${file}.js`;
|
const scriptFile = `./${file}.js`;
|
||||||
const stat = fs.existsSync(scriptFile)
|
const stat = fs.existsSync(scriptFile)
|
||||||
if (!stat) {
|
if (!stat) {
|
||||||
if (!cli.getParamValue("file")) {
|
if (!cli.getParamValue("file")) {
|
||||||
fs.writeFileSync("./kit.js", `
|
fs.writeFileSync("./kitx.js", `
|
||||||
async function main(){
|
async function main(){
|
||||||
console.log("hello kit!")
|
console.log("hello kit!");
|
||||||
}
|
}
|
||||||
`);
|
`);
|
||||||
} else {
|
} else {
|
||||||
@ -82,7 +82,7 @@ async function main(){
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
parser.addCmdLine("remote", "验证远程仓库;", async function () {
|
parser.addCmdLine("remote", "验证远程仓库可达;", async function () {
|
||||||
let remote = await $context.get("remote")
|
let remote = await $context.get("remote")
|
||||||
if (!(await remote.check())) {
|
if (!(await remote.check())) {
|
||||||
$logger.error("远程仓库不可用")
|
$logger.error("远程仓库不可用")
|
||||||
|
|||||||
@ -329,7 +329,11 @@ function stringifyLogValue(value) {
|
|||||||
function printCallLog(name, args, result) {
|
function printCallLog(name, args, result) {
|
||||||
console.log(`调用 ${name} 功能`);
|
console.log(`调用 ${name} 功能`);
|
||||||
console.log(`入参 ${args.map(stringifyLogValue).join(" ")}`);
|
console.log(`入参 ${args.map(stringifyLogValue).join(" ")}`);
|
||||||
console.log(`结果 ${stringifyLogValue(result)}`);
|
if (result === undefined) {
|
||||||
|
console.log("执行结束");
|
||||||
|
} else {
|
||||||
|
console.log(`执行结果 ${stringifyLogValue(result)}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function withCallLog(name, fn) {
|
function withCallLog(name, fn) {
|
||||||
|
|||||||
@ -1,239 +1,366 @@
|
|||||||
/**
|
const fs = require("fs");
|
||||||
* remote 模块用来处理远程仓库相关事务
|
const os = require("os");
|
||||||
*/
|
const path = require("path");
|
||||||
const path = require("path")
|
const { pipeline } = require("stream/promises");
|
||||||
const fs = require("fs")
|
const JSON5 = require("json5");
|
||||||
const JSON5 = require('json5')
|
|
||||||
const { pipeline, Transform} = require("stream/promises");
|
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 {
|
module.exports = class Remote {
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
// this.__init()
|
this.func_publish = require("./publish");
|
||||||
this.func_publish = require("./publish")
|
this.func_install = require("./install");
|
||||||
this.func_install = require("./install")
|
this.root = "/";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
async init() {
|
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");
|
const uConfig = await $context.get("uConfig");
|
||||||
if ( uConfig.get("remoteServer") ){
|
|
||||||
let server = uConfig.get("remoteServer");
|
let server = uConfig.get("remoteServer");
|
||||||
$logger.info("server " + server)
|
if (!server) {
|
||||||
if ( server.startsWith("webdav") ) {
|
return;
|
||||||
server = "http" + server.substring(6)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if ( uConfig.get("remoteAuth") ) {
|
if (server.startsWith("webdav")) {
|
||||||
let up = uConfig.get("remoteAuth");
|
server = "http" + server.substring(6);
|
||||||
$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
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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() {
|
async check() {
|
||||||
|
|
||||||
if ( ! this.webdav ) {
|
|
||||||
$logger.info("webdav unable")
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!this.client) {
|
if (!this.client) {
|
||||||
$logger.info("client unable")
|
$logger.info("remote client unavailable");
|
||||||
return false
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
await this.ensureDir("/");
|
||||||
// Get directory contents
|
await this.client.getDirectoryContents("/");
|
||||||
const directoryItems = await this.client.getDirectoryContents("/");
|
return true;
|
||||||
$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) {
|
} catch (e) {
|
||||||
$logger.error("remote check 过程中出错", e)
|
$logger.error("remote check 过程中出错 " + e.message);
|
||||||
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 ) {
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
for ( let item of items ) {
|
|
||||||
if ( item.basename === name ) {
|
|
||||||
return item;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async publish(localFile, remotePath) {
|
async publish(localFile, remotePath) {
|
||||||
return await this.func_publish( this , localFile, remotePath )
|
return await this.func_publish(this, localFile, remotePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
async install(id) {
|
async install(id) {
|
||||||
return this.func_install( this, id )
|
return await this.func_install(this, id);
|
||||||
}
|
}
|
||||||
|
|
||||||
async wUpload(from, to, debug){
|
async list(projectId) {
|
||||||
$logger.info(`from=${from} to=${to}`)
|
const state = this.getInstallState(projectId);
|
||||||
const totalSize = fs.statSync(from).size;
|
for (const version of state.installed_versions) {
|
||||||
debug ? console.log("totalSize", totalSize) : null
|
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 rs = fs.createReadStream(from);
|
||||||
let wSize = 0;
|
const ws = this.client.createWriteStream(normalizeRemotePath(to));
|
||||||
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);
|
await pipeline(rs, ws);
|
||||||
debug ? console.log("finish") : null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async wDownload(from, to) {
|
async wDownload(from, to) {
|
||||||
$logger.info("download", from, to )
|
fs.mkdirSync(path.dirname(to), { recursive: true });
|
||||||
const stat = await this.client.stat(from);
|
const rs = this.client.createReadStream(normalizeRemotePath(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);
|
const ws = fs.createWriteStream(to);
|
||||||
ws.on("data", (r)=>{
|
await pipeline(rs, ws);
|
||||||
wSize += r.length;
|
|
||||||
// console.log("w", wSize * 100 / totalSize)
|
|
||||||
})
|
|
||||||
return await pipeline(
|
|
||||||
rs, ws
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async wMkdir(folder) {
|
async wMkdir(folder) {
|
||||||
|
return this.ensureDir(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 ) ) {
|
async wGetModuleInfo(projectId) {
|
||||||
return
|
return this.getProjectMetadata(projectId);
|
||||||
} else {
|
}
|
||||||
await this.client.createDirectory( folder )
|
|
||||||
|
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 [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async wGetModuleInfo(id){
|
|
||||||
let meta = await this.client.getFileContents("/modules/" + id + "/meta.json", { format: "text" });
|
|
||||||
let metaJson = JSON5.parse( meta );
|
|
||||||
return metaJson
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|||||||
@ -1,53 +1,83 @@
|
|||||||
const os = require('os');
|
const fs = require("fs");
|
||||||
const fs = require("fs");
|
const path = require("path");
|
||||||
const osutil = require("../util/osutil")
|
const { execFileSync } = require("child_process");
|
||||||
|
|
||||||
function matchDistName(cur, dists){
|
function now() {
|
||||||
const list = []
|
return new Date().toISOString();
|
||||||
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 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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function unpackArtifact(artifact, localPath, versionDir) {
|
||||||
|
if (artifact.type !== "archive" || !artifact.unpack) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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" });
|
||||||
|
}
|
||||||
|
|
||||||
|
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, "/"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = async function install(remote, id) {
|
module.exports = async function install(remote, id) {
|
||||||
const cur = osutil.cur();
|
const parsed = remote.parseProjectVersion(id || "kit");
|
||||||
|
const projectId = parsed.projectId;
|
||||||
if ( ! id ) {
|
const project = await remote.getProjectMetadata(projectId);
|
||||||
id = "kit"
|
const version = parsed.version || project.latest_version;
|
||||||
|
if (!version) {
|
||||||
|
throw new Error(`远程模块没有可安装版本 ${projectId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取本地版本
|
const versionMeta = await remote.getVersionMetadata(projectId, version);
|
||||||
const localInfo = osutil.choise([
|
const artifacts = remote.getMatchingArtifacts(versionMeta);
|
||||||
require("../windows/local").getModuleInfo,
|
if (artifacts.length < 1) {
|
||||||
require("../linux/local").getModuleInfo,
|
throw new Error(`没有匹配当前平台的文件 ${projectId}@${version}`);
|
||||||
])( id )
|
|
||||||
|
|
||||||
$logger.info("local version", localInfo.version)
|
|
||||||
|
|
||||||
// 确认远程版本文件是否存在
|
|
||||||
const remoteInfo = await remote.wGetModuleInfo(id)
|
|
||||||
$logger.info("remote latest version", remoteInfo.latest.version)
|
|
||||||
|
|
||||||
const dists = matchDistName(cur, remoteInfo.latest.dist)
|
|
||||||
const dist = dists.pop()
|
|
||||||
|
|
||||||
await remote.wDownload(
|
|
||||||
"/modules/" + id + "/" + remoteInfo.latest.version + "/" + cur.curOS + "/" + cur.curPlatform + "/" + dist.filename,
|
|
||||||
localInfo.homeDir + "/" + dist.filename,
|
|
||||||
)
|
|
||||||
|
|
||||||
fs.writeFileSync( localInfo.homeDir + "/version", remoteInfo.latest.version );
|
|
||||||
|
|
||||||
$logger.info("当前版本", remoteInfo.latest.version )
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const root = remote.getLocalModuleRoot(projectId);
|
||||||
|
const versionDir = path.resolve(root, "versions", version);
|
||||||
|
fs.mkdirSync(versionDir, { recursive: true });
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
const localVersionMeta = Object.assign({}, versionMeta, { installed_at: now(), artifacts });
|
||||||
|
fs.writeFileSync(path.resolve(versionDir, ".version.json"), JSON.stringify(localVersionMeta, null, 4), "utf-8");
|
||||||
|
|
||||||
|
const state = remote.getInstallState(projectId);
|
||||||
|
const action = state.current_version ? "switch" : "install";
|
||||||
|
await remote.activate(projectId, version, action);
|
||||||
|
$logger.info(`当前版本 ${projectId}@${version}`);
|
||||||
|
return version;
|
||||||
|
};
|
||||||
|
|||||||
@ -1,107 +1,176 @@
|
|||||||
const path = require("path")
|
const crypto = require("crypto");
|
||||||
const fs = require("fs")
|
const fs = require("fs");
|
||||||
const JSON5 = require('json5')
|
const path = require("path");
|
||||||
const { pipeline } = require('stream/promises');
|
const JSON5 = require("json5");
|
||||||
const objutil = require("../util/objutil")
|
|
||||||
const datautil = require("../util/datautil")
|
|
||||||
|
|
||||||
|
function now() {
|
||||||
|
return new Date().toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = async function publish(remote, localFile, remotePath) {
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
if( remotePath ) {
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
// 如果有 meta
|
function sha256(filePath) {
|
||||||
|
return crypto.createHash("sha256").update(fs.readFileSync(filePath)).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
// 如果没有 meta
|
function guessArtifactType(filePath) {
|
||||||
$logger.info("远程路径没有 meta, 按照默认规则上传");
|
const ext = path.extname(filePath).toLowerCase();
|
||||||
|
if ([".zip", ".tgz", ".gz", ".xz", ".tar"].includes(ext)) {
|
||||||
|
return "archive";
|
||||||
|
}
|
||||||
|
return "executable";
|
||||||
|
}
|
||||||
|
|
||||||
// 确认目录
|
function archiveFormat(filePath) {
|
||||||
let folder = path.posix.resolve("/" + remotePath + "/data/" + datautil.now() );
|
const name = path.basename(filePath).toLowerCase();
|
||||||
if ( ! await remote.client.exists( folder ) ) {
|
if (name.endsWith(".tar.gz") || name.endsWith(".tgz")) {
|
||||||
// 创建文件夹
|
return "tar.gz";
|
||||||
await remote.client.createDirectory(folder, {
|
}
|
||||||
recursive: true
|
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;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let filename = path.basename(localFile);
|
module.exports = async function publish(remote, localFile, remotePath) {
|
||||||
|
if (remotePath) {
|
||||||
// 上传文件
|
const target = path.posix.resolve("/", remotePath.replace(/\\/g, "/"));
|
||||||
await remote.wUpload( localFile , path.posix.resolve(folder , filename ));
|
await remote.wUpload(localFile, path.posix.join(target, path.basename(localFile)));
|
||||||
|
$logger.info("文件上传成功");
|
||||||
} else {
|
return;
|
||||||
|
|
||||||
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 = readLocalMeta(localFile);
|
||||||
|
const projectId = localMeta.project_id || localMeta.id || localMeta.name;
|
||||||
const localMeta = JSON5.parse( metaFile );
|
if (!projectId) {
|
||||||
|
throw new Error("meta.json 缺少 project_id/id/name");
|
||||||
// 更新版本
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 先更新文件
|
const version = buildVersion(localMeta);
|
||||||
for (let dist of localMeta.dist ) {
|
const artifacts = buildArtifacts(localMeta);
|
||||||
|
await remote.ensureDir(`/${projectId}/${version}`);
|
||||||
|
|
||||||
const folder = "/modules/" + localMeta.id + "/" + localMeta.version + "/" + dist.os + "/" + dist.platform
|
const versionMeta = {
|
||||||
await remote.wMkdir( folder );
|
version,
|
||||||
|
release_date: localMeta.release_date || now(),
|
||||||
$logger.info("upload dist", dist.path, fs.existsSync(dist.path))
|
official_download_url: localMeta.official_download_url || "",
|
||||||
|
artifacts: artifacts.map(({ localPath, ...artifact }) => artifact),
|
||||||
await remote.wUpload( dist.path , folder + "/" + dist.filename );
|
checksums: Object.fromEntries(artifacts.map((artifact) => [artifact.path, artifact.checksum]))
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const artifact of artifacts) {
|
||||||
|
await remote.wUpload(artifact.localPath, `/${projectId}/${version}/${artifact.path}`);
|
||||||
}
|
}
|
||||||
|
await remote.putJson(`/${projectId}/${version}/version.json`, versionMeta);
|
||||||
|
|
||||||
// remote meta
|
const metadata = await remote.getJson(`/${projectId}/metadata.json`, {
|
||||||
let remoteMeta = {
|
project_id: projectId,
|
||||||
"versions" : []
|
official_url: localMeta.official_url || localMeta.vcs || "",
|
||||||
}
|
description: localMeta.description || localMeta.desc || "",
|
||||||
try {
|
latest_version: version,
|
||||||
const remoteMetaStr = await remote.client.getFileContents("/modules/" + localMeta.id + "/meta.json", { format: "text" });
|
versions: [],
|
||||||
remoteMeta = JSON5.parse( remoteMetaStr )
|
fetch_rules: localMeta.fetch_rules || [],
|
||||||
} catch (e) {
|
extra: {}
|
||||||
|
});
|
||||||
|
|
||||||
}
|
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 || {});
|
||||||
|
|
||||||
remoteMeta.id = localMeta.id;
|
await remote.putJson(`/${projectId}/metadata.json`, metadata);
|
||||||
remoteMeta.name = localMeta.name;
|
$logger.info(`文件上传成功 ${projectId}@${version}`);
|
||||||
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("文件上传成功")
|
|
||||||
}
|
|
||||||
|
|||||||
BIN
kit/test/kit
BIN
kit/test/kit
Binary file not shown.
13
kit/test/remote_debug.js
Normal file
13
kit/test/remote_debug.js
Normal file
@ -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()
|
||||||
100
kit/test/remote_test.js
Normal file
100
kit/test/remote_test.js
Normal file
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -105,10 +105,10 @@ async function main(){
|
|||||||
// }
|
// }
|
||||||
// );
|
// );
|
||||||
const client = webdav.createClient(
|
const client = webdav.createClient(
|
||||||
"http://baishe.fullstack.club:5244/dav",
|
"http://openlist.honor3.com/dav",
|
||||||
{
|
{
|
||||||
username: "kit",
|
username: "sunyard",
|
||||||
password: "kitkit"
|
password: "sunyard"
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -136,9 +136,12 @@ async function main(){
|
|||||||
|
|
||||||
// console.log("end")
|
// 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" )
|
// await wUpload( client, "../dist/windows/kit.exe", "/modules/kit/0.1.20240227094429/Windows/X86_64/kit.exe" )
|
||||||
console.log("over")
|
console.log("over")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
//kit3 config remoteAuth sunyard:sunyard
|
||||||
|
//kit3 config remoteServer http://openlist.honor3.com/dav
|
||||||
main()
|
main()
|
||||||
Loading…
Reference in New Issue
Block a user