@easytwin/devkit 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +72 -0
- package/dist/bin.js +586 -0
- package/dist/bin.js.map +1 -0
- package/dist/index.d.ts +181 -0
- package/dist/index.js +551 -0
- package/dist/index.js.map +1 -0
- package/package.json +45 -0
- package/skills/easytwin-bootstrap/SKILL.md +26 -0
- package/skills/easytwin-core/SKILL.md +25 -0
- package/skills/easytwin-core/references/base-classes.md +32 -0
- package/skills/easytwin-core/references/camera.md +33 -0
- package/skills/easytwin-core/references/engine.md +88 -0
- package/skills/easytwin-core/references/physics.md +30 -0
- package/skills/easytwin-core/references/virtual-components.md +169 -0
- package/skills/easytwin-develop/SKILL.md +25 -0
- package/skills/easytwin-render/SKILL.md +42 -0
- package/skills/easytwin-render/references/intro.md +81 -0
- package/skills/easytwin-render/references/lifecycle-events.md +79 -0
- package/skills/easytwin-render/references/rendering-apis.md +116 -0
- package/skills/easytwin-render/references/scene-and-assets.md +94 -0
- package/skills/easytwin-scene/SKILL.md +26 -0
- package/skills/easytwin-upload/SKILL.md +21 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 EasyTwin DevKit contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# @easytwin/devkit
|
|
2
|
+
|
|
3
|
+
EasyTwin 第三方应用开发工具集的核心包:lib(函数库)与 bin(`easytwin` 命令)双导出,供人类与 AI agent(如 Cursor / Claude Code / Codex)共同使用。设计蓝图见仓库根 `AGENTS.md`。
|
|
4
|
+
|
|
5
|
+
## 安装
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm i -g @easytwin/devkit # 全局安装 CLI
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
也可作为库安装:`npm i @easytwin/devkit`。
|
|
12
|
+
|
|
13
|
+
## 命令
|
|
14
|
+
|
|
15
|
+
| 命令 | 行为 |
|
|
16
|
+
| --- | --- |
|
|
17
|
+
| `easytwin init` | 交互式生成 `easytwin.config.json`,并追加进 `.gitignore`(无则创建) |
|
|
18
|
+
| `easytwin scene list` | 列出凭据可见的场景 |
|
|
19
|
+
| `easytwin scene pull <id> [--out <path>]` | 拉取场景 JSON 到本地 |
|
|
20
|
+
| `easytwin upload <dir>` | 全量覆盖上传目录 |
|
|
21
|
+
| `easytwin skills sync [--target cursor\|claude\|codex\|all]` | 同步 skills 到用户项目,缺省 all |
|
|
22
|
+
|
|
23
|
+
## 配置
|
|
24
|
+
|
|
25
|
+
项目根 `easytwin.config.json`:
|
|
26
|
+
|
|
27
|
+
```json
|
|
28
|
+
{
|
|
29
|
+
"appId": "必填",
|
|
30
|
+
"appSecret": "必填",
|
|
31
|
+
"baseUrl": "可选,缺省官方固定域名"
|
|
32
|
+
}
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
环境变量 `EASYTWIN_APP_ID` / `EASYTWIN_APP_SECRET` / `EASYTWIN_BASE_URL` 优先于文件。文件含密钥,永不入库(`init` 会处理 gitignore)。
|
|
36
|
+
|
|
37
|
+
## skills
|
|
38
|
+
|
|
39
|
+
六件套(`easytwin-render` / `-core` / `-develop` / `-bootstrap` / `-scene` / `-upload`)随包分发,通过 `easytwin skills sync` 显式写入 Cursor(`.cursor/skills/`)、Claude Code(`.claude/skills/`)与 Codex(`AGENTS.md` 标记段),重复执行幂等。
|
|
40
|
+
|
|
41
|
+
## 发布到 npm
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
# 1. 登录(写入 ~/.npmrc 的 npmjs token;CI 用 NPM_TOKEN 环境变量)
|
|
45
|
+
npm login --registry https://registry.npmjs.org/
|
|
46
|
+
|
|
47
|
+
# 2. 确认发布产物应包含 dist/ 与 skills/
|
|
48
|
+
npm pack --dry-run
|
|
49
|
+
|
|
50
|
+
# 3. 升版本
|
|
51
|
+
npm version patch|minor|major
|
|
52
|
+
|
|
53
|
+
# 4. 发布(发布前自动 build + test;publishConfig 已固定 public + npmjs)
|
|
54
|
+
pnpm publish
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
- scoped 包需 publishConfig.access 为 public,已配置;
|
|
58
|
+
- 本机默认 registry 是 npmmirror,publishConfig.registry 已固定为官方 npmjs;
|
|
59
|
+
- 包名是否被占用可用 npm view @easytwin/devkit 确认;
|
|
60
|
+
- repository 目前指向内网 GitLab,公网发布前建议改为公开地址(可选)。
|
|
61
|
+
|
|
62
|
+
## 开发
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
pnpm install
|
|
66
|
+
pnpm -r build
|
|
67
|
+
pnpm -r test
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## 已知外部输入(TODO)
|
|
71
|
+
|
|
72
|
+
内部 swagger(API 契约)、渲染 API 文档与 d.ts、场景 JSON 格式、官方缺省域名——均未到位,代码中以 `TODO(swagger)` / `TODO(外部输入)` 标记,不臆造。
|
package/dist/bin.js
ADDED
|
@@ -0,0 +1,586 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/bin.ts
|
|
4
|
+
import { Command } from "commander";
|
|
5
|
+
import { realpathSync } from "fs";
|
|
6
|
+
import { readFile } from "fs/promises";
|
|
7
|
+
import { createInterface } from "readline/promises";
|
|
8
|
+
import { stdin, stdout } from "process";
|
|
9
|
+
import { pathToFileURL } from "url";
|
|
10
|
+
import path5 from "path";
|
|
11
|
+
|
|
12
|
+
// src/config.ts
|
|
13
|
+
import { promises as fs } from "fs";
|
|
14
|
+
import path from "path";
|
|
15
|
+
var CONFIG_FILE_NAME = "easytwin.config.json";
|
|
16
|
+
var DEFAULT_BASE_URL = "https://api.easytwin.example.com";
|
|
17
|
+
var ConfigError = class extends Error {
|
|
18
|
+
constructor(message) {
|
|
19
|
+
super(message);
|
|
20
|
+
this.name = "ConfigError";
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
function configFilePath(cwd) {
|
|
24
|
+
return path.join(cwd, CONFIG_FILE_NAME);
|
|
25
|
+
}
|
|
26
|
+
function parseConfig(raw) {
|
|
27
|
+
let parsed;
|
|
28
|
+
try {
|
|
29
|
+
parsed = JSON.parse(raw);
|
|
30
|
+
} catch {
|
|
31
|
+
throw new ConfigError(`\u914D\u7F6E\u6587\u4EF6\u4E0D\u662F\u5408\u6CD5 JSON:${CONFIG_FILE_NAME}`);
|
|
32
|
+
}
|
|
33
|
+
return validateConfigShape(parsed);
|
|
34
|
+
}
|
|
35
|
+
function validateConfigShape(value) {
|
|
36
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
37
|
+
throw new ConfigError("\u914D\u7F6E\u5FC5\u987B\u662F JSON \u5BF9\u8C61");
|
|
38
|
+
}
|
|
39
|
+
const v = value;
|
|
40
|
+
if (typeof v.appId !== "string" || v.appId.length === 0) {
|
|
41
|
+
throw new ConfigError("\u914D\u7F6E\u7F3A\u5C11\u5FC5\u586B\u5B57\u6BB5 appId");
|
|
42
|
+
}
|
|
43
|
+
if (typeof v.appSecret !== "string" || v.appSecret.length === 0) {
|
|
44
|
+
throw new ConfigError("\u914D\u7F6E\u7F3A\u5C11\u5FC5\u586B\u5B57\u6BB5 appSecret");
|
|
45
|
+
}
|
|
46
|
+
if (v.baseUrl !== void 0 && (typeof v.baseUrl !== "string" || v.baseUrl.length === 0)) {
|
|
47
|
+
throw new ConfigError("baseUrl \u5FC5\u987B\u662F\u975E\u7A7A\u5B57\u7B26\u4E32");
|
|
48
|
+
}
|
|
49
|
+
const config = { appId: v.appId, appSecret: v.appSecret };
|
|
50
|
+
if (typeof v.baseUrl === "string") config.baseUrl = v.baseUrl;
|
|
51
|
+
return config;
|
|
52
|
+
}
|
|
53
|
+
async function readConfigFile(cwd) {
|
|
54
|
+
const file = configFilePath(cwd);
|
|
55
|
+
let raw;
|
|
56
|
+
try {
|
|
57
|
+
raw = await fs.readFile(file, "utf8");
|
|
58
|
+
} catch {
|
|
59
|
+
throw new ConfigError(`\u672A\u627E\u5230\u914D\u7F6E\u6587\u4EF6 ${file},\u8BF7\u5148\u8FD0\u884C \`easytwin init\``);
|
|
60
|
+
}
|
|
61
|
+
return parseConfig(raw);
|
|
62
|
+
}
|
|
63
|
+
function resolveConfig(file, env = process.env) {
|
|
64
|
+
const appId = env.EASYTWIN_APP_ID ?? file.appId;
|
|
65
|
+
const appSecret = env.EASYTWIN_APP_SECRET ?? file.appSecret;
|
|
66
|
+
const baseUrl = env.EASYTWIN_BASE_URL ?? file.baseUrl ?? DEFAULT_BASE_URL;
|
|
67
|
+
if (!appId) throw new ConfigError("\u7F3A\u5C11 appId:\u914D\u7F6E\u6587\u4EF6\u4E2D\u672A\u63D0\u4F9B,\u4E14\u672A\u8BBE\u7F6E\u73AF\u5883\u53D8\u91CF EASYTWIN_APP_ID");
|
|
68
|
+
if (!appSecret) throw new ConfigError("\u7F3A\u5C11 appSecret:\u914D\u7F6E\u6587\u4EF6\u4E2D\u672A\u63D0\u4F9B,\u4E14\u672A\u8BBE\u7F6E\u73AF\u5883\u53D8\u91CF EASYTWIN_APP_SECRET");
|
|
69
|
+
const source = env.EASYTWIN_APP_ID !== void 0 || env.EASYTWIN_APP_SECRET !== void 0 ? "env" : "file";
|
|
70
|
+
return { appId, appSecret, baseUrl, source };
|
|
71
|
+
}
|
|
72
|
+
async function loadConfig(cwd, env = process.env) {
|
|
73
|
+
return resolveConfig(await readConfigFile(cwd), env);
|
|
74
|
+
}
|
|
75
|
+
async function writeConfigFile(cwd, config) {
|
|
76
|
+
const file = configFilePath(cwd);
|
|
77
|
+
const body = { appId: config.appId, appSecret: config.appSecret };
|
|
78
|
+
if (config.baseUrl) body.baseUrl = config.baseUrl;
|
|
79
|
+
await fs.mkdir(cwd, { recursive: true });
|
|
80
|
+
await fs.writeFile(file, JSON.stringify(body, null, 2) + "\n", "utf8");
|
|
81
|
+
return file;
|
|
82
|
+
}
|
|
83
|
+
var GITIGNORE_FILE_NAME = ".gitignore";
|
|
84
|
+
var GITIGNORE_ENTRY = "easytwin.config.json";
|
|
85
|
+
async function appendGitignore(cwd) {
|
|
86
|
+
const file = path.join(cwd, GITIGNORE_FILE_NAME);
|
|
87
|
+
let content = "";
|
|
88
|
+
let created = false;
|
|
89
|
+
try {
|
|
90
|
+
content = await fs.readFile(file, "utf8");
|
|
91
|
+
} catch {
|
|
92
|
+
created = true;
|
|
93
|
+
}
|
|
94
|
+
const lines = content.split(/\r?\n/);
|
|
95
|
+
if (lines.includes(GITIGNORE_ENTRY)) return { created, added: false };
|
|
96
|
+
const prefix = content.length > 0 && !content.endsWith("\n") ? "\n" : "";
|
|
97
|
+
await fs.writeFile(file, content + prefix + GITIGNORE_ENTRY + "\n", "utf8");
|
|
98
|
+
return { created, added: true };
|
|
99
|
+
}
|
|
100
|
+
async function initConfig(input, cwd) {
|
|
101
|
+
const validated = validateConfigShape(input);
|
|
102
|
+
const configFile = await writeConfigFile(cwd, validated);
|
|
103
|
+
const gitignore = await appendGitignore(cwd);
|
|
104
|
+
return { configFile, gitignore };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// src/client.ts
|
|
108
|
+
import http from "http";
|
|
109
|
+
import https from "https";
|
|
110
|
+
import { URL as URL2 } from "url";
|
|
111
|
+
var AUTH_HEADER = "Authorization";
|
|
112
|
+
var BEARER_PREFIX = "Bearer";
|
|
113
|
+
var APP_ID_HEADER = "X-Easytwin-App-Id";
|
|
114
|
+
var ENDPOINTS = {
|
|
115
|
+
scenes: "/api/scenes",
|
|
116
|
+
scene: (id) => `/api/scenes/${encodeURIComponent(id)}`,
|
|
117
|
+
upload: "/api/upload"
|
|
118
|
+
};
|
|
119
|
+
var EasyTwinApiError = class extends Error {
|
|
120
|
+
status;
|
|
121
|
+
body;
|
|
122
|
+
constructor(status, message, body) {
|
|
123
|
+
super(message);
|
|
124
|
+
this.name = "EasyTwinApiError";
|
|
125
|
+
this.status = status;
|
|
126
|
+
this.body = body;
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
function messageFromBody(body) {
|
|
130
|
+
if (typeof body === "object" && body !== null) {
|
|
131
|
+
const m = body.message;
|
|
132
|
+
if (typeof m === "string") return m;
|
|
133
|
+
}
|
|
134
|
+
return void 0;
|
|
135
|
+
}
|
|
136
|
+
function parseResponseBody(buffer) {
|
|
137
|
+
const text = buffer.toString("utf8");
|
|
138
|
+
if (text.length === 0) return void 0;
|
|
139
|
+
try {
|
|
140
|
+
return JSON.parse(text);
|
|
141
|
+
} catch {
|
|
142
|
+
return text;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
var EasyTwinClient = class {
|
|
146
|
+
baseUrl;
|
|
147
|
+
appId;
|
|
148
|
+
appSecret;
|
|
149
|
+
constructor(config) {
|
|
150
|
+
this.baseUrl = config.baseUrl.replace(/\/+$/, "");
|
|
151
|
+
this.appId = config.appId;
|
|
152
|
+
this.appSecret = config.appSecret;
|
|
153
|
+
}
|
|
154
|
+
/** 全仓唯一认证头注入点。 */
|
|
155
|
+
authHeaders() {
|
|
156
|
+
return {
|
|
157
|
+
[AUTH_HEADER]: `${BEARER_PREFIX} ${this.appSecret}`,
|
|
158
|
+
[APP_ID_HEADER]: this.appId
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
/** JSON 请求(原生 fetch)。 */
|
|
162
|
+
async request(path6, options = {}) {
|
|
163
|
+
const url = `${this.baseUrl}${path6}`;
|
|
164
|
+
const headers = { ...this.authHeaders(), ...options.headers };
|
|
165
|
+
if (options.body !== void 0 && options.body !== null && !("Content-Type" in headers)) {
|
|
166
|
+
headers["Content-Type"] = "application/json";
|
|
167
|
+
}
|
|
168
|
+
let res;
|
|
169
|
+
try {
|
|
170
|
+
res = await fetch(url, {
|
|
171
|
+
method: options.method ?? "GET",
|
|
172
|
+
headers,
|
|
173
|
+
body: options.body,
|
|
174
|
+
signal: options.signal
|
|
175
|
+
});
|
|
176
|
+
} catch (err) {
|
|
177
|
+
throw new EasyTwinApiError(0, `\u8BF7\u6C42\u5931\u8D25:${err instanceof Error ? err.message : String(err)}`);
|
|
178
|
+
}
|
|
179
|
+
return this.handleStatus(res.status, Buffer.from(await res.arrayBuffer()));
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* multipart 流式上传(手写 http 请求以支持逐字节进度回调)。
|
|
183
|
+
* 这是 fetch 上传的替代路径:原生 fetch 无法上报上传进度。
|
|
184
|
+
*/
|
|
185
|
+
async upload(path6, options) {
|
|
186
|
+
const url = new URL2(`${this.baseUrl}${path6}`);
|
|
187
|
+
const mod = url.protocol === "https:" ? https : http;
|
|
188
|
+
const headers = {
|
|
189
|
+
...this.authHeaders(),
|
|
190
|
+
"Content-Type": `multipart/form-data; boundary=${options.boundary}`,
|
|
191
|
+
"Content-Length": String(options.body.length)
|
|
192
|
+
};
|
|
193
|
+
const raw = await new Promise((resolve, reject) => {
|
|
194
|
+
const req = mod.request(url, { method: "POST", headers }, (res) => {
|
|
195
|
+
const chunks = [];
|
|
196
|
+
res.on("data", (c) => chunks.push(c));
|
|
197
|
+
res.on("end", () => resolve({ status: res.statusCode ?? 0, body: Buffer.concat(chunks) }));
|
|
198
|
+
});
|
|
199
|
+
req.on("error", reject);
|
|
200
|
+
const CHUNK = 64 * 1024;
|
|
201
|
+
let offset = 0;
|
|
202
|
+
const sendNext = () => {
|
|
203
|
+
if (offset >= options.body.length) {
|
|
204
|
+
req.end();
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
const chunk = options.body.subarray(offset, offset + CHUNK);
|
|
208
|
+
offset += chunk.length;
|
|
209
|
+
req.write(chunk, () => {
|
|
210
|
+
options.onBytes?.(chunk.length);
|
|
211
|
+
sendNext();
|
|
212
|
+
});
|
|
213
|
+
};
|
|
214
|
+
sendNext();
|
|
215
|
+
});
|
|
216
|
+
return this.handleStatus(raw.status, raw.body);
|
|
217
|
+
}
|
|
218
|
+
handleStatus(status, body) {
|
|
219
|
+
if (status >= 200 && status < 300) return parseResponseBody(body);
|
|
220
|
+
const parsed = parseResponseBody(body);
|
|
221
|
+
throw new EasyTwinApiError(status, messageFromBody(parsed) ?? `HTTP ${status}`, parsed);
|
|
222
|
+
}
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
// src/scene.ts
|
|
226
|
+
import { promises as fs2 } from "fs";
|
|
227
|
+
import path2 from "path";
|
|
228
|
+
function normalizeSceneList(data) {
|
|
229
|
+
const list = Array.isArray(data) ? data : data?.list;
|
|
230
|
+
if (!Array.isArray(list)) throw new Error("scene list \u54CD\u5E94\u7ED3\u6784\u65E0\u6CD5\u8BC6\u522B");
|
|
231
|
+
return list.map((item) => {
|
|
232
|
+
const it = item ?? {};
|
|
233
|
+
return { id: String(it.id ?? ""), name: String(it.name ?? it.id ?? "") };
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
function normalizeSceneDetail(data) {
|
|
237
|
+
const it = data ?? {};
|
|
238
|
+
return { id: String(it.id ?? ""), name: String(it.name ?? ""), payload: data };
|
|
239
|
+
}
|
|
240
|
+
async function listScenes(client) {
|
|
241
|
+
const data = await client.request(ENDPOINTS.scenes, { method: "GET" });
|
|
242
|
+
return normalizeSceneList(data);
|
|
243
|
+
}
|
|
244
|
+
async function pullScene(client, id) {
|
|
245
|
+
const data = await client.request(ENDPOINTS.scene(id), { method: "GET" });
|
|
246
|
+
return normalizeSceneDetail(data);
|
|
247
|
+
}
|
|
248
|
+
async function saveSceneJson(scene, out) {
|
|
249
|
+
await fs2.mkdir(path2.dirname(out), { recursive: true });
|
|
250
|
+
await fs2.writeFile(out, JSON.stringify(scene.payload ?? scene, null, 2) + "\n", "utf8");
|
|
251
|
+
return out;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// src/upload.ts
|
|
255
|
+
import { promises as fs3 } from "fs";
|
|
256
|
+
import path3 from "path";
|
|
257
|
+
var DEFAULT_IGNORED_DIRS = /* @__PURE__ */ new Set([".git"]);
|
|
258
|
+
async function collectFiles(dir, ignore = () => false) {
|
|
259
|
+
const files = [];
|
|
260
|
+
async function walk(current, rel) {
|
|
261
|
+
const entries = await fs3.readdir(current, { withFileTypes: true });
|
|
262
|
+
for (const entry of entries) {
|
|
263
|
+
const relPath = path3.posix.join(rel, entry.name);
|
|
264
|
+
if (ignore(relPath)) continue;
|
|
265
|
+
if (entry.isDirectory()) {
|
|
266
|
+
if (DEFAULT_IGNORED_DIRS.has(entry.name)) continue;
|
|
267
|
+
await walk(path3.join(current, entry.name), relPath);
|
|
268
|
+
} else if (entry.isFile()) {
|
|
269
|
+
const absPath = path3.join(current, entry.name);
|
|
270
|
+
const stat = await fs3.stat(absPath);
|
|
271
|
+
files.push({ relPath, absPath, size: stat.size });
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
await walk(dir, "");
|
|
276
|
+
files.sort((a, b) => a.relPath.localeCompare(b.relPath));
|
|
277
|
+
return files;
|
|
278
|
+
}
|
|
279
|
+
function buildMultipartBody(boundary, fields, file) {
|
|
280
|
+
const parts = [];
|
|
281
|
+
for (const [key, value] of Object.entries(fields)) {
|
|
282
|
+
parts.push(
|
|
283
|
+
Buffer.from(`--${boundary}\r
|
|
284
|
+
Content-Disposition: form-data; name="${key}"\r
|
|
285
|
+
\r
|
|
286
|
+
${value}\r
|
|
287
|
+
`, "utf8")
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
parts.push(
|
|
291
|
+
Buffer.from(
|
|
292
|
+
`--${boundary}\r
|
|
293
|
+
Content-Disposition: form-data; name="file"; filename="${file.name}"\r
|
|
294
|
+
Content-Type: application/octet-stream\r
|
|
295
|
+
\r
|
|
296
|
+
`,
|
|
297
|
+
"utf8"
|
|
298
|
+
)
|
|
299
|
+
);
|
|
300
|
+
parts.push(file.content);
|
|
301
|
+
parts.push(Buffer.from(`\r
|
|
302
|
+
--${boundary}--\r
|
|
303
|
+
`, "utf8"));
|
|
304
|
+
return Buffer.concat(parts);
|
|
305
|
+
}
|
|
306
|
+
async function uploadDirectory(client, dir, options = {}) {
|
|
307
|
+
const ignore = options.ignore ?? (() => false);
|
|
308
|
+
const files = await collectFiles(dir, ignore);
|
|
309
|
+
const total = files.reduce((sum, f) => sum + f.size, 0);
|
|
310
|
+
options.onProgress?.({ phase: "collect", current: total, total });
|
|
311
|
+
let sent = 0;
|
|
312
|
+
for (const file of files) {
|
|
313
|
+
const content = await fs3.readFile(file.absPath);
|
|
314
|
+
const boundary = `----easytwin${Date.now().toString(36)}${Math.random().toString(36).slice(2)}`;
|
|
315
|
+
const body = buildMultipartBody(
|
|
316
|
+
boundary,
|
|
317
|
+
// TODO(swagger): multipart 字段名/结构待定,当前为占位约定。
|
|
318
|
+
{ path: file.relPath },
|
|
319
|
+
{ name: path3.basename(file.relPath), content }
|
|
320
|
+
);
|
|
321
|
+
await client.upload(ENDPOINTS.upload, { boundary, body });
|
|
322
|
+
sent += file.size;
|
|
323
|
+
options.onProgress?.({ phase: "upload", current: sent, total, file: file.relPath });
|
|
324
|
+
}
|
|
325
|
+
return { fileCount: files.length, byteCount: sent };
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// src/skills.ts
|
|
329
|
+
import { promises as fs4 } from "fs";
|
|
330
|
+
import path4 from "path";
|
|
331
|
+
import { fileURLToPath } from "url";
|
|
332
|
+
var SKILL_NAMES = [
|
|
333
|
+
"easytwin-render",
|
|
334
|
+
"easytwin-core",
|
|
335
|
+
"easytwin-develop",
|
|
336
|
+
"easytwin-bootstrap",
|
|
337
|
+
"easytwin-scene",
|
|
338
|
+
"easytwin-upload"
|
|
339
|
+
];
|
|
340
|
+
var CODEX_MARKER_BEGIN = "<!-- EASYTWIN-SKILLS:BEGIN -->";
|
|
341
|
+
var CODEX_MARKER_END = "<!-- EASYTWIN-SKILLS:END -->";
|
|
342
|
+
var META_FILE_NAME = ".easytwin-meta.json";
|
|
343
|
+
function normalizeTargets(target = "all") {
|
|
344
|
+
if (target === "all") return ["cursor", "claude", "codex"];
|
|
345
|
+
if (Array.isArray(target)) return [...new Set(target)];
|
|
346
|
+
return [target];
|
|
347
|
+
}
|
|
348
|
+
function resolveSkillsSourceDir() {
|
|
349
|
+
if (typeof import.meta.url !== "string" || import.meta.url.length === 0) {
|
|
350
|
+
throw new Error("\u65E0\u6CD5\u5B9A\u4F4D skills \u6E90\u76EE\u5F55:\u63D2\u4EF6(bundle/CJS)\u573A\u666F\u5FC5\u987B\u663E\u5F0F\u4F20\u5165 sourceDir");
|
|
351
|
+
}
|
|
352
|
+
const here = path4.dirname(fileURLToPath(import.meta.url));
|
|
353
|
+
return path4.resolve(here, "..", "skills");
|
|
354
|
+
}
|
|
355
|
+
async function readJson(file) {
|
|
356
|
+
return JSON.parse(await fs4.readFile(file, "utf8"));
|
|
357
|
+
}
|
|
358
|
+
async function readDevkitVersion(skillsDir = resolveSkillsSourceDir()) {
|
|
359
|
+
const marker = path4.join(skillsDir, ".easytwin-source.json");
|
|
360
|
+
try {
|
|
361
|
+
const meta = await readJson(marker);
|
|
362
|
+
if (typeof meta.version === "string") return meta.version;
|
|
363
|
+
} catch {
|
|
364
|
+
}
|
|
365
|
+
try {
|
|
366
|
+
const pkg = await readJson(path4.resolve(skillsDir, "..", "package.json"));
|
|
367
|
+
return pkg.version;
|
|
368
|
+
} catch {
|
|
369
|
+
return "0.0.0";
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
async function copyDir(src, dest) {
|
|
373
|
+
await fs4.mkdir(dest, { recursive: true });
|
|
374
|
+
const entries = await fs4.readdir(src, { withFileTypes: true });
|
|
375
|
+
for (const entry of entries) {
|
|
376
|
+
const s = path4.join(src, entry.name);
|
|
377
|
+
const d = path4.join(dest, entry.name);
|
|
378
|
+
if (entry.isDirectory()) await copyDir(s, d);
|
|
379
|
+
else if (entry.isFile()) await fs4.copyFile(s, d);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
async function dirMatches(src, dest) {
|
|
383
|
+
let sourceEntries;
|
|
384
|
+
try {
|
|
385
|
+
sourceEntries = await fs4.readdir(src);
|
|
386
|
+
} catch {
|
|
387
|
+
return false;
|
|
388
|
+
}
|
|
389
|
+
for (const name of sourceEntries) {
|
|
390
|
+
const s = path4.join(src, name);
|
|
391
|
+
const d = path4.join(dest, name);
|
|
392
|
+
const sStat = await fs4.stat(s);
|
|
393
|
+
if (sStat.isDirectory()) {
|
|
394
|
+
if (!await dirMatches(s, d)) return false;
|
|
395
|
+
} else {
|
|
396
|
+
let dStat;
|
|
397
|
+
try {
|
|
398
|
+
dStat = await fs4.stat(d);
|
|
399
|
+
} catch {
|
|
400
|
+
return false;
|
|
401
|
+
}
|
|
402
|
+
if (!sStat.isFile() || !dStat.isFile() || sStat.size !== dStat.size) return false;
|
|
403
|
+
if (!(await fs4.readFile(s)).equals(await fs4.readFile(d))) return false;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
return true;
|
|
407
|
+
}
|
|
408
|
+
function actionFor(exists, matches) {
|
|
409
|
+
if (!exists) return "created";
|
|
410
|
+
return matches ? "unchanged" : "updated";
|
|
411
|
+
}
|
|
412
|
+
async function writeMeta(destDir, version) {
|
|
413
|
+
const meta = { name: "@easytwin/devkit", version };
|
|
414
|
+
await fs4.writeFile(path4.join(destDir, META_FILE_NAME), JSON.stringify(meta, null, 2) + "\n", "utf8");
|
|
415
|
+
}
|
|
416
|
+
async function syncToDir(target, sourceRoot, targetRoot, version) {
|
|
417
|
+
const entries = [];
|
|
418
|
+
for (const name of SKILL_NAMES) {
|
|
419
|
+
const src = path4.join(sourceRoot, name);
|
|
420
|
+
const dest = path4.join(targetRoot, name);
|
|
421
|
+
const exists = await fs4.stat(dest).then(() => true).catch(() => false);
|
|
422
|
+
const matches = await dirMatches(src, dest);
|
|
423
|
+
const action = actionFor(exists, matches);
|
|
424
|
+
if (action !== "unchanged") {
|
|
425
|
+
await fs4.rm(dest, { recursive: true, force: true });
|
|
426
|
+
await copyDir(src, dest);
|
|
427
|
+
}
|
|
428
|
+
entries.push({ name, action });
|
|
429
|
+
}
|
|
430
|
+
await writeMeta(targetRoot, version);
|
|
431
|
+
return { target, entries };
|
|
432
|
+
}
|
|
433
|
+
function buildCodexSegment(version) {
|
|
434
|
+
return [
|
|
435
|
+
CODEX_MARKER_BEGIN,
|
|
436
|
+
"<!-- \u672C\u6BB5\u7531 `easytwin skills sync` \u7BA1\u7406,\u53EF\u6574\u6BB5\u66FF\u6362;\u624B\u52A8\u4FEE\u6539\u4F1A\u88AB\u4E0B\u6B21\u540C\u6B65\u8986\u76D6\u3002 -->",
|
|
437
|
+
`EasyTwin \u5F00\u53D1\u6280\u80FD(\u7531 @easytwin/devkit v${version} \u540C\u6B65):`,
|
|
438
|
+
"",
|
|
439
|
+
"- `easytwin-develop`:\u5DE5\u4F5C\u6D41\u603B\u7EB2,\u5F00\u59CB EasyTwin \u5F00\u53D1\u524D\u5FC5\u8BFB\u3002",
|
|
440
|
+
"- `easytwin-bootstrap`:\u521D\u59CB\u5316\u5E94\u7528\u7EA7\u51ED\u8BC1(easytwin.config.json)\u3002",
|
|
441
|
+
"- `easytwin-scene`:\u5217\u51FA\u573A\u666F\u3001\u62C9\u53D6\u573A\u666F JSON\u3002",
|
|
442
|
+
"- `easytwin-render`:\u7528 twin runtime \u5F00\u53D1\u6E32\u67D3\u529F\u80FD(\u4E3B\u529B)\u3002",
|
|
443
|
+
"- `easytwin-core`:\u5F15\u64CE\u5185\u6838 API(RuntimeEngine/\u57FA\u7C7B/Time/\u76F8\u673A/\u7269\u7406)\u3002",
|
|
444
|
+
"- `easytwin-upload`:\u5168\u91CF\u4E0A\u4F20\u5F00\u53D1\u4EA7\u7269\u76EE\u5F55(\u8986\u76D6\u4E0D\u53EF\u9006)\u3002",
|
|
445
|
+
"",
|
|
446
|
+
"\u8BE6\u7EC6\u7528\u6CD5\u89C1\u5404\u6280\u80FD;\u91CD\u65B0\u540C\u6B65\u8FD0\u884C `easytwin skills sync --target codex`\u3002",
|
|
447
|
+
CODEX_MARKER_END
|
|
448
|
+
].join("\n");
|
|
449
|
+
}
|
|
450
|
+
async function syncToCodex(sourceRoot, cwd, version) {
|
|
451
|
+
const agentsFile = path4.join(cwd, "AGENTS.md");
|
|
452
|
+
const segment = buildCodexSegment(version);
|
|
453
|
+
let content = "";
|
|
454
|
+
let exists = true;
|
|
455
|
+
try {
|
|
456
|
+
content = await fs4.readFile(agentsFile, "utf8");
|
|
457
|
+
} catch {
|
|
458
|
+
exists = false;
|
|
459
|
+
}
|
|
460
|
+
const start = content.indexOf(CODEX_MARKER_BEGIN);
|
|
461
|
+
const end = content.indexOf(CODEX_MARKER_END);
|
|
462
|
+
let action;
|
|
463
|
+
let next;
|
|
464
|
+
if (start === -1 || end === -1 || end < start) {
|
|
465
|
+
action = exists ? "updated" : "created";
|
|
466
|
+
next = content.length > 0 && !content.endsWith("\n") ? content + "\n\n" : content + (content.length > 0 ? "\n" : "");
|
|
467
|
+
next += segment + "\n";
|
|
468
|
+
} else {
|
|
469
|
+
const current = content.slice(start, end + CODEX_MARKER_END.length);
|
|
470
|
+
action = current === segment ? "unchanged" : "updated";
|
|
471
|
+
next = content.slice(0, start) + segment + content.slice(end + CODEX_MARKER_END.length);
|
|
472
|
+
}
|
|
473
|
+
if (action !== "unchanged") {
|
|
474
|
+
await fs4.mkdir(cwd, { recursive: true });
|
|
475
|
+
await fs4.writeFile(agentsFile, next, "utf8");
|
|
476
|
+
}
|
|
477
|
+
return { target: "codex", entries: [], codex: { name: "AGENTS.md", action } };
|
|
478
|
+
}
|
|
479
|
+
async function syncSkills(options) {
|
|
480
|
+
const targets = normalizeTargets(options.targets ?? "all");
|
|
481
|
+
const sourceRoot = options.sourceDir ?? resolveSkillsSourceDir();
|
|
482
|
+
const version = options.version ?? await readDevkitVersion(sourceRoot);
|
|
483
|
+
const summaries = [];
|
|
484
|
+
for (const target of targets) {
|
|
485
|
+
if (target === "cursor") summaries.push(await syncToDir("cursor", sourceRoot, path4.join(options.cwd, ".cursor", "skills"), version));
|
|
486
|
+
else if (target === "claude") summaries.push(await syncToDir("claude", sourceRoot, path4.join(options.cwd, ".claude", "skills"), version));
|
|
487
|
+
else summaries.push(await syncToCodex(sourceRoot, options.cwd, version));
|
|
488
|
+
}
|
|
489
|
+
return summaries;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// src/bin.ts
|
|
493
|
+
async function readVersion() {
|
|
494
|
+
try {
|
|
495
|
+
const pkg = JSON.parse(await readFile(new URL("../package.json", import.meta.url), "utf8"));
|
|
496
|
+
return pkg.version;
|
|
497
|
+
} catch {
|
|
498
|
+
return "0.0.0";
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
async function prompt(question) {
|
|
502
|
+
const rl = createInterface({ input: stdin, output: stdout });
|
|
503
|
+
try {
|
|
504
|
+
return (await rl.question(question)).trim();
|
|
505
|
+
} finally {
|
|
506
|
+
rl.close();
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
async function runInit(cwd, flags) {
|
|
510
|
+
const appId = flags.appId ?? await prompt("App ID: ");
|
|
511
|
+
const appSecret = flags.appSecret ?? await prompt("App Secret: ");
|
|
512
|
+
const baseUrlInput = flags.baseUrl ?? await prompt(`Base URL(\u7F3A\u7701 ${DEFAULT_BASE_URL}): `);
|
|
513
|
+
const input = { appId, appSecret };
|
|
514
|
+
if (baseUrlInput.trim().length > 0) input.baseUrl = baseUrlInput.trim();
|
|
515
|
+
const result = await initConfig(input, cwd);
|
|
516
|
+
console.log(`\u5DF2\u751F\u6210 ${result.configFile}`);
|
|
517
|
+
console.log(
|
|
518
|
+
result.gitignore.added ? `\u5DF2\u628A ${CONFIG_FILE_NAME} \u8FFD\u52A0\u8FDB .gitignore(\u9632\u6B62\u5BC6\u94A5\u5165\u5E93)` : `.gitignore \u5DF2\u5305\u542B ${CONFIG_FILE_NAME}`
|
|
519
|
+
);
|
|
520
|
+
}
|
|
521
|
+
function buildProgram(cwd, version = "0.0.0") {
|
|
522
|
+
const program = new Command();
|
|
523
|
+
program.name("easytwin").description("EasyTwin DevKit \u547D\u4EE4\u884C\u5DE5\u5177").version(version, "-V, --version");
|
|
524
|
+
program.command("init").description("\u4EA4\u4E92\u5F0F\u751F\u6210 easytwin.config.json,\u5E76\u8FFD\u52A0\u8FDB .gitignore").option("--app-id <id>", "App ID(\u8DF3\u8FC7\u4EA4\u4E92)").option("--app-secret <secret>", "App Secret(\u8DF3\u8FC7\u4EA4\u4E92)").option("--base-url <url>", "Base URL(\u8DF3\u8FC7\u4EA4\u4E92)").action((opts) => runInit(cwd, opts));
|
|
525
|
+
const scene = program.command("scene").description("\u573A\u666F\u7BA1\u7406");
|
|
526
|
+
scene.command("list").description("\u5217\u51FA\u51ED\u636E\u53EF\u89C1\u7684\u573A\u666F").action(async () => {
|
|
527
|
+
const config = await loadConfig(cwd);
|
|
528
|
+
const client = new EasyTwinClient(config);
|
|
529
|
+
const scenes = await listScenes(client);
|
|
530
|
+
if (scenes.length === 0) {
|
|
531
|
+
console.log("(\u65E0\u573A\u666F)");
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
for (const s of scenes) console.log(`${s.id} ${s.name}`);
|
|
535
|
+
});
|
|
536
|
+
scene.command("pull <id>").description("\u62C9\u53D6\u573A\u666F JSON \u5230\u672C\u5730").option("-o, --out <path>", "\u8F93\u51FA\u6587\u4EF6\u8DEF\u5F84(\u7F3A\u7701 ./<id>.scene.json)").action(async (id, opts) => {
|
|
537
|
+
const config = await loadConfig(cwd);
|
|
538
|
+
const client = new EasyTwinClient(config);
|
|
539
|
+
const scene2 = await pullScene(client, id);
|
|
540
|
+
const out = opts.out ?? path5.join(cwd, `${id}.scene.json`);
|
|
541
|
+
const file = await saveSceneJson(scene2, out);
|
|
542
|
+
console.log(`\u5DF2\u4FDD\u5B58\u5230 ${file}`);
|
|
543
|
+
});
|
|
544
|
+
program.command("upload <dir>").description("\u5168\u91CF\u8986\u76D6\u4E0A\u4F20\u76EE\u5F55(\u4E0D\u53EF\u9006)").action(async (dir) => {
|
|
545
|
+
const config = await loadConfig(cwd);
|
|
546
|
+
const client = new EasyTwinClient(config);
|
|
547
|
+
await uploadDirectory(client, dir, {
|
|
548
|
+
onProgress: (p) => {
|
|
549
|
+
if (p.phase === "upload") {
|
|
550
|
+
process.stderr.write(`\r\u5DF2\u4E0A\u4F20 ${p.current}/${p.total} bytes${p.file ? ` (${p.file})` : ""}`);
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
});
|
|
554
|
+
process.stderr.write("\n");
|
|
555
|
+
});
|
|
556
|
+
const skills = program.command("skills").description("skills \u540C\u6B65\u5230\u7528\u6237\u9879\u76EE");
|
|
557
|
+
skills.command("sync").description("\u540C\u6B65 skills \u5230\u7528\u6237\u9879\u76EE(cursor/claude/codex),\u7F3A\u7701 all").option("--target <target>", "cursor|claude|codex|all", "all").action(async (opts) => {
|
|
558
|
+
const summaries = await syncSkills({ cwd, targets: opts.target });
|
|
559
|
+
for (const s of summaries) {
|
|
560
|
+
if (s.codex) {
|
|
561
|
+
console.log(`[codex] AGENTS.md: ${s.codex.action}`);
|
|
562
|
+
continue;
|
|
563
|
+
}
|
|
564
|
+
for (const e of s.entries) console.log(`[${s.target}] ${e.name}: ${e.action}`);
|
|
565
|
+
}
|
|
566
|
+
});
|
|
567
|
+
return program;
|
|
568
|
+
}
|
|
569
|
+
async function main(argv, cwd = process.cwd()) {
|
|
570
|
+
const version = await readVersion();
|
|
571
|
+
const program = buildProgram(cwd, version);
|
|
572
|
+
await program.parseAsync(argv, { from: "user" });
|
|
573
|
+
}
|
|
574
|
+
var isMain = process.argv[1] !== void 0 && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href;
|
|
575
|
+
if (isMain) {
|
|
576
|
+
main(process.argv.slice(2)).catch((err) => {
|
|
577
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
578
|
+
process.exitCode = 1;
|
|
579
|
+
});
|
|
580
|
+
}
|
|
581
|
+
export {
|
|
582
|
+
buildProgram,
|
|
583
|
+
main,
|
|
584
|
+
runInit
|
|
585
|
+
};
|
|
586
|
+
//# sourceMappingURL=bin.js.map
|