@honor-claw/yoyo 0.0.1-beta.3 → 0.0.1-beta.5
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/index.ts +5 -1
- package/package.json +1 -1
- package/skills/yoyo-control/SKILL.md +39 -30
- package/skills/yoyo-control/references/hot-spot.md +19 -0
- package/skills/yoyo-control/references/no-disturb.md +19 -0
- package/skills/yoyo-control/references/phone-call.md +17 -104
- package/skills/yoyo-control/references/volume.md +1 -1
- package/src/agent/copy-templates.ts +56 -0
- package/src/agent/index.ts +3 -0
- package/src/commands/index.ts +3 -1
- package/src/modules/claw-configs/config-manager.ts +13 -10
- package/src/utils/fs-safe.ts +544 -0
- package/skills/yoyo-control/scripts/README.md +0 -103
- package/skills/yoyo-control/scripts/invoke.js +0 -119
- package/skills/yoyo-control/scripts/volume-up.json +0 -7
package/index.ts
CHANGED
|
@@ -4,16 +4,20 @@ import { registerCommands } from "./src/commands/index.js";
|
|
|
4
4
|
import { YoyoPluginConfigSchema } from "./src/schemas.js";
|
|
5
5
|
import { ClawConnectionService } from "./src/services/connection/index.js";
|
|
6
6
|
import { setClawLogger } from "./src/utils/logger.js";
|
|
7
|
+
import { copyTemplateToWorkspace } from "./src/agent/index.js";
|
|
7
8
|
|
|
8
9
|
const plugin = {
|
|
9
10
|
id: "yoyo",
|
|
10
11
|
name: "YOYOClaw",
|
|
11
12
|
description: "OpenClaw Honor Yoyo connection plugin",
|
|
12
13
|
configSchema: YoyoPluginConfigSchema,
|
|
13
|
-
register(api: OpenClawPluginApi) {
|
|
14
|
+
async register(api: OpenClawPluginApi) {
|
|
14
15
|
setYoyoRuntime(api.runtime);
|
|
15
16
|
setClawLogger(api.logger);
|
|
16
17
|
|
|
18
|
+
// 复制智能体模板到工作目录
|
|
19
|
+
await copyTemplateToWorkspace(api)
|
|
20
|
+
|
|
17
21
|
// 利用服务来管理核心连接任务进行~
|
|
18
22
|
api.registerService(ClawConnectionService);
|
|
19
23
|
|
package/package.json
CHANGED
|
@@ -5,7 +5,7 @@ description: Whenever a task requires **operating or controlling the phone**, eg
|
|
|
5
5
|
metadata: { "openclaw": { "emoji": "📱", "always": true } }
|
|
6
6
|
---
|
|
7
7
|
|
|
8
|
-
# Control
|
|
8
|
+
# YOYO Control
|
|
9
9
|
|
|
10
10
|
Follow the workflow to control phone or pad with YOYO, Don't guess the commands.
|
|
11
11
|
|
|
@@ -13,9 +13,9 @@ Follow the workflow to control phone or pad with YOYO, Don't guess the commands.
|
|
|
13
13
|
|
|
14
14
|
Follow this **4-step workflow** when using the skill:
|
|
15
15
|
|
|
16
|
-
1. Query the
|
|
17
|
-
2.
|
|
18
|
-
3.
|
|
16
|
+
1. Query the nodes status to identify available device, and determine the target device.
|
|
17
|
+
2. Plan the tool to use, and extract command & parameters according to the tool reference.
|
|
18
|
+
3. Invoke node with correct nodeId, command and parameters.
|
|
19
19
|
4. Present the result and confirm whether the operation was successful.
|
|
20
20
|
|
|
21
21
|
### Step 1. Discover Nodes
|
|
@@ -26,9 +26,11 @@ Follow this **4-step workflow** when using the skill:
|
|
|
26
26
|
|
|
27
27
|
**Execution result**:
|
|
28
28
|
|
|
29
|
+
```markdown
|
|
29
30
|
| Node | ID | IP | Detail | Status | Caps |
|
|
30
31
|
| ---------- | -------- | -------- | -------- | -------- | ------ |
|
|
31
32
|
| <nodeName> | <nodeId> | <nodeIP> | <detail> | <status> | <caps> |
|
|
33
|
+
```
|
|
32
34
|
|
|
33
35
|
#### Capability Matching Logic
|
|
34
36
|
|
|
@@ -74,17 +76,19 @@ Before executing any device control operation, you **MUST** consult the correspo
|
|
|
74
76
|
|
|
75
77
|
### Available Tool References
|
|
76
78
|
|
|
77
|
-
| Reference File |
|
|
78
|
-
| ---------------------------------- |
|
|
79
|
-
| `references/volume.md` |
|
|
80
|
-
| `references/phone-call.md` |
|
|
81
|
-
| `references/screen-recorder.md` |
|
|
82
|
-
| `references/capture-screenshot.md` |
|
|
83
|
-
| `references/schedule.md` |
|
|
84
|
-
| `references/search-contact.md` |
|
|
85
|
-
| `references/open-app.md` |
|
|
86
|
-
| `references/send-message.md` |
|
|
87
|
-
| `references/local-search.md` |
|
|
79
|
+
| Reference File | Tool Name | Description | Required Node Caps |
|
|
80
|
+
| ---------------------------------- | ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------ |
|
|
81
|
+
| `references/volume.md` | volume | Adjust, set, increase, decrease, mute/unmute device volume; query current volume status; fine-grained control by stream type (media, call, ringtone, notification) | `volume.operate` |
|
|
82
|
+
| `references/phone-call.md` | phone_call | Initiate and manage phone calls; unified control for call operations | `phone.call` |
|
|
83
|
+
| `references/screen-recorder.md` | screen_recorder | Start/stop screen recording; configure recording parameters; query recording status; app-specific recording control | `screen.record` |
|
|
84
|
+
| `references/capture-screenshot.md` | capture_screenshot | Capture current screen content as image; support standard and scrolling screenshot modes | `capture.shot` |
|
|
85
|
+
| `references/schedule.md` | Schedschedule_createule | Provides the ability to create schedules | `schedule.create` |
|
|
86
|
+
| `references/search-contact.md` | search_contact | Provides the ability to search contacts | `search.contact ` |
|
|
87
|
+
| `references/open-app.md` | open_app | Used to help users open a specified app without any specific app internals. | `app.open` |
|
|
88
|
+
| `references/send-message.md` | send_message | Provide SMS sending services, users can send SMS content to specified numbers by providing phone numbers or contact information. | `send.message` |
|
|
89
|
+
| `references/local-search.md` | local_search | Search native data for documents, notes, calendars, galleries, yoyo memories, wallets, and more for direct answers to user questions or generate at-a-glance content | `loacal.search` |
|
|
90
|
+
| `references/hot-spot.md` | hot_spot | 手机热点控制能力 | `hot.spot` |
|
|
91
|
+
| `references/no-disturb.md` | hot_spot | 手机勿扰模式控制力 | `no.disturb` |
|
|
88
92
|
|
|
89
93
|
### Parameter Construction Workflow
|
|
90
94
|
|
|
@@ -181,17 +185,32 @@ Execute the following steps **in strict order** for each operation:
|
|
|
181
185
|
|
|
182
186
|
### Parameter Format Requirements
|
|
183
187
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
|
187
|
-
|
|
|
188
|
-
| **
|
|
188
|
+
**Platform-specific JSON parameter format:**
|
|
189
|
+
|
|
190
|
+
| Platform | Shell | Quote Style | Example |
|
|
191
|
+
| ----------- | ----- | ------------------------------- | ------------------------------- |
|
|
192
|
+
| **Windows** | CMD | Double quotes, escape inner `"` | `"{\"actionType\":\"打电话\"}"` |
|
|
193
|
+
| **Linux** | Bash | Single quotes | `'{"actionType":"打电话"}'` |
|
|
194
|
+
| **macOS** | Bash | Single quotes | `'{"actionType":"打电话"}'` |
|
|
195
|
+
|
|
196
|
+
**Windows CMD Execution:**
|
|
197
|
+
|
|
198
|
+
```cmd
|
|
199
|
+
openclaw nodes invoke <nodeId> --command phone.call --params "{\"actionType\":\"打电话\",\"phoneNumber\":\"10086\"}"
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
**Linux/macOS Bash Execution:**
|
|
203
|
+
|
|
204
|
+
```bash
|
|
205
|
+
openclaw nodes invoke <nodeId> --command phone.call --params '{"actionType":"打电话","phoneNumber":"10086"}'
|
|
206
|
+
```
|
|
189
207
|
|
|
190
208
|
**Additional Requirements**:
|
|
191
209
|
|
|
192
210
|
- Parameters must be valid JSON
|
|
193
211
|
- Enum values must match documentation exactly (case-sensitive)
|
|
194
212
|
- Numeric values must be within defined ranges
|
|
213
|
+
- On Windows, always use CMD (not PowerShell) to avoid quote parsing issues
|
|
195
214
|
|
|
196
215
|
### Examples
|
|
197
216
|
|
|
@@ -334,13 +353,3 @@ Would you like to check other nodes or retry later?"
|
|
|
334
353
|
- ❌ **DO NOT** expose raw error codes to users
|
|
335
354
|
- ❌ **DO NOT** assume user knows technical details
|
|
336
355
|
- ❌ **DO NOT** leave user without next steps on failure
|
|
337
|
-
|
|
338
|
-
## 快速参考命令
|
|
339
|
-
|
|
340
|
-
```bash
|
|
341
|
-
# 获取所有节点状态
|
|
342
|
-
openclaw nodes status
|
|
343
|
-
|
|
344
|
-
# 调用工具
|
|
345
|
-
openclaw nodes invoke --node <id|name|ip> --command <command> --params <json>
|
|
346
|
-
```
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# hot_spot 手机热点控制工具使用说明
|
|
2
|
+
|
|
3
|
+
## Tool Command
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
hot.spot
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
## Tool Parameters and Examples
|
|
10
|
+
|
|
11
|
+
```json
|
|
12
|
+
{
|
|
13
|
+
"name": "hot_spot",
|
|
14
|
+
"description": "该工具提供对设备热点控制",
|
|
15
|
+
"parameters": {},
|
|
16
|
+
"required": [],
|
|
17
|
+
"examples": []
|
|
18
|
+
}
|
|
19
|
+
```
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# no_disturb 勿扰模式工具使用说明
|
|
2
|
+
|
|
3
|
+
## Tool Command
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
no.disturb
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
## Tool Parameters and Examples
|
|
10
|
+
|
|
11
|
+
```json
|
|
12
|
+
{
|
|
13
|
+
"name": "no_disturb",
|
|
14
|
+
"description": "手机勿扰模式",
|
|
15
|
+
"parameters": {},
|
|
16
|
+
"required": [],
|
|
17
|
+
"examples": []
|
|
18
|
+
}
|
|
19
|
+
```
|
|
@@ -65,7 +65,7 @@ phone.call
|
|
|
65
65
|
```json
|
|
66
66
|
{
|
|
67
67
|
"name": "phone_call",
|
|
68
|
-
"description": "
|
|
68
|
+
"description": "提供打电话的功能,支持指定姓名、电话号码;支持选择打电话的卡;支持重拨、回拨已接听来电、回拨未接听来电、回拨电话",
|
|
69
69
|
"parameters": {
|
|
70
70
|
"actionType": {
|
|
71
71
|
"type": "string",
|
|
@@ -91,125 +91,38 @@ phone.call
|
|
|
91
91
|
},
|
|
92
92
|
"contact": {
|
|
93
93
|
"type": "string",
|
|
94
|
-
"description": "
|
|
94
|
+
"description": "泛化的“联系人”概念,可为:自然人名或昵称(如:张三、王总)、非自然实体(机构、部门、服务名称,如:招生办、物业客服)"
|
|
95
95
|
}
|
|
96
96
|
},
|
|
97
97
|
"examples": [
|
|
98
98
|
{
|
|
99
|
-
"
|
|
100
|
-
"query": "打电话给15651621029",
|
|
101
|
-
"workflow": "Direct call (no contact search needed)",
|
|
99
|
+
"query": "拨打电话",
|
|
102
100
|
"arguments": {
|
|
103
|
-
"actionType": "打电话"
|
|
104
|
-
"phoneNumber": "15651621029"
|
|
105
|
-
}
|
|
106
|
-
},
|
|
107
|
-
{
|
|
108
|
-
"scenario": "User provides contact name only",
|
|
109
|
-
"query": "打电话给妈妈",
|
|
110
|
-
"workflow": "search_contact → phone.call",
|
|
111
|
-
"step1": {
|
|
112
|
-
"tool": "search_contact",
|
|
113
|
-
"arguments": {
|
|
114
|
-
"contact": "妈妈"
|
|
115
|
-
},
|
|
116
|
-
"result": {
|
|
117
|
-
"name": "妈妈",
|
|
118
|
-
"phoneNumber": "13900139000"
|
|
119
|
-
}
|
|
120
|
-
},
|
|
121
|
-
"step2": {
|
|
122
|
-
"tool": "phone.call",
|
|
123
|
-
"arguments": {
|
|
124
|
-
"actionType": "打电话",
|
|
125
|
-
"phoneNumber": "13900139000"
|
|
126
|
-
}
|
|
101
|
+
"actionType": "打电话"
|
|
127
102
|
}
|
|
128
103
|
},
|
|
129
104
|
{
|
|
130
|
-
"
|
|
131
|
-
"query": "用移动卡打电话给张三",
|
|
132
|
-
"workflow": "search_contact → phone.call with phoneCard",
|
|
133
|
-
"step1": {
|
|
134
|
-
"tool": "search_contact",
|
|
135
|
-
"arguments": {
|
|
136
|
-
"contact": "张三"
|
|
137
|
-
},
|
|
138
|
-
"result": {
|
|
139
|
-
"name": "张三",
|
|
140
|
-
"phoneNumber": "15651621029"
|
|
141
|
-
}
|
|
142
|
-
},
|
|
143
|
-
"step2": {
|
|
144
|
-
"tool": "phone.call",
|
|
145
|
-
"arguments": {
|
|
146
|
-
"actionType": "打电话",
|
|
147
|
-
"phoneNumber": "15651621029",
|
|
148
|
-
"phoneCard": "移动"
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
},
|
|
152
|
-
{
|
|
153
|
-
"scenario": "User requests redial",
|
|
154
|
-
"query": "重拨",
|
|
155
|
-
"workflow": "Direct redial (no contact search needed)",
|
|
105
|
+
"query": "打电话给15651621029",
|
|
156
106
|
"arguments": {
|
|
157
|
-
"actionType": "
|
|
107
|
+
"actionType": "打电话",
|
|
108
|
+
"phoneNumber": "15651621029"
|
|
158
109
|
}
|
|
159
110
|
},
|
|
160
111
|
{
|
|
161
|
-
"
|
|
162
|
-
"query": "回拨未接来电",
|
|
163
|
-
"workflow": "Direct callback (no contact search needed)",
|
|
112
|
+
"query": "打电话给张三",
|
|
164
113
|
"arguments": {
|
|
165
|
-
"actionType": "
|
|
166
|
-
"
|
|
167
|
-
|
|
168
|
-
},
|
|
169
|
-
{
|
|
170
|
-
"scenario": "User provides contact with multiple matches",
|
|
171
|
-
"query": "打电话给李四",
|
|
172
|
-
"workflow": "search_contact → user selects → phone.call",
|
|
173
|
-
"step1": {
|
|
174
|
-
"tool": "search_contact",
|
|
175
|
-
"arguments": {
|
|
176
|
-
"contact": "李四"
|
|
177
|
-
},
|
|
178
|
-
"result": {
|
|
179
|
-
"matches": [
|
|
180
|
-
{ "name": "李四(公司)", "phoneNumber": "13800138001" },
|
|
181
|
-
{ "name": "李四(家里)", "phoneNumber": "13800138002" }
|
|
182
|
-
]
|
|
183
|
-
}
|
|
184
|
-
},
|
|
185
|
-
"step2": {
|
|
186
|
-
"action": "Ask user to select",
|
|
187
|
-
"prompt": "找到多个李四,请选择:1. 李四(公司) 2. 李四(家里)"
|
|
188
|
-
},
|
|
189
|
-
"step3": {
|
|
190
|
-
"tool": "phone.call",
|
|
191
|
-
"arguments": {
|
|
192
|
-
"actionType": "打电话",
|
|
193
|
-
"phoneNumber": "13800138001"
|
|
194
|
-
}
|
|
114
|
+
"actionType": "打电话",
|
|
115
|
+
"contact": "张三",
|
|
116
|
+
"phoneNumber": "15651621029"
|
|
195
117
|
}
|
|
196
118
|
},
|
|
197
119
|
{
|
|
198
|
-
"
|
|
199
|
-
"
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
"
|
|
203
|
-
"
|
|
204
|
-
"contact": "王五"
|
|
205
|
-
},
|
|
206
|
-
"result": {
|
|
207
|
-
"matches": []
|
|
208
|
-
}
|
|
209
|
-
},
|
|
210
|
-
"step2": {
|
|
211
|
-
"action": "Ask user for phoneNumber",
|
|
212
|
-
"prompt": "未找到联系人王五,请提供电话号码"
|
|
120
|
+
"query": "用移动手机卡打电话给于洋",
|
|
121
|
+
"arguments": {
|
|
122
|
+
"actionType": "打电话",
|
|
123
|
+
"contact": "张三",
|
|
124
|
+
"phoneNumber": "15651621029",
|
|
125
|
+
"phoneCard": "移动手机卡"
|
|
213
126
|
}
|
|
214
127
|
}
|
|
215
128
|
]
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
4
|
+
import { safeWriteFile, SafeFsError, type WriteMode } from "../utils/fs-safe.js";
|
|
5
|
+
|
|
6
|
+
type TemplateEntry = {
|
|
7
|
+
file: string;
|
|
8
|
+
mode: WriteMode;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
const TEMPLATE_FILE_LIST: TemplateEntry[] = [
|
|
12
|
+
{ file: "AGENTS.md", mode: "overwrite" },
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
function resolveWorkspaceDir(api: OpenClawPluginApi): string {
|
|
16
|
+
const configuredWorkspace = api.config.agents?.defaults?.workspace?.trim();
|
|
17
|
+
if (configuredWorkspace) {
|
|
18
|
+
return api.resolvePath(configuredWorkspace);
|
|
19
|
+
}
|
|
20
|
+
return api.resolvePath("~/.openclaw/workspace");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function copyTemplateToWorkspace(api: OpenClawPluginApi): Promise<void> {
|
|
24
|
+
const pluginDir = path.dirname(api.source);
|
|
25
|
+
const templateDir = path.join(pluginDir, "src", "agent", "templates");
|
|
26
|
+
const workspaceDir = resolveWorkspaceDir(api);
|
|
27
|
+
|
|
28
|
+
for (const entry of TEMPLATE_FILE_LIST) {
|
|
29
|
+
const sourcePath = path.join(templateDir, entry.file);
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
const content = await fs.readFile(sourcePath, "utf-8");
|
|
33
|
+
|
|
34
|
+
await safeWriteFile({
|
|
35
|
+
rootDir: workspaceDir,
|
|
36
|
+
relativePath: entry.file,
|
|
37
|
+
data: content,
|
|
38
|
+
mode: entry.mode,
|
|
39
|
+
mkdir: true,
|
|
40
|
+
});
|
|
41
|
+
api.logger.info(`Successfully wrote ${entry.file} to workspace (${entry.mode} mode)`);
|
|
42
|
+
} catch (error) {
|
|
43
|
+
if (error instanceof SafeFsError) {
|
|
44
|
+
if (error.code === "not-found") {
|
|
45
|
+
api.logger.warn(`Source file does not exist: ${sourcePath}`);
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
api.logger.error(`Failed to write ${entry.file}: ${error.message}`);
|
|
49
|
+
} else {
|
|
50
|
+
api.logger.error(
|
|
51
|
+
`Failed to write ${entry.file}: ${error instanceof Error ? error.message : String(error)}`
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
package/src/commands/index.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { type OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
2
2
|
import { registerLoginCommand } from "./login/index.js";
|
|
3
3
|
import { registerStatusCommand } from "./status/index.js";
|
|
4
|
-
import { registerLogoutCommand } from "./logout/index.js";
|
|
4
|
+
// import { registerLogoutCommand } from "./logout/index.js";
|
|
5
5
|
|
|
6
6
|
export function registerCommands(api: OpenClawPluginApi) {
|
|
7
7
|
api.registerCli(
|
|
@@ -10,7 +10,9 @@ export function registerCommands(api: OpenClawPluginApi) {
|
|
|
10
10
|
.command("honor")
|
|
11
11
|
.description("Commands for honor yoyoclaw");
|
|
12
12
|
|
|
13
|
+
// @ts-ignore
|
|
13
14
|
registerLoginCommand(api, rootCommand);
|
|
15
|
+
// @ts-ignore
|
|
14
16
|
registerStatusCommand(api, rootCommand);
|
|
15
17
|
// registerLogoutCommand(api, rootCommand);
|
|
16
18
|
},
|
|
@@ -21,8 +21,7 @@ export class ConfigManager {
|
|
|
21
21
|
return runtime.config.loadConfig();
|
|
22
22
|
} catch (error) {
|
|
23
23
|
throw new Error(
|
|
24
|
-
`Failed to load config: ${
|
|
25
|
-
error instanceof Error ? error.message : String(error)
|
|
24
|
+
`Failed to load config: ${error instanceof Error ? error.message : String(error)
|
|
26
25
|
}`
|
|
27
26
|
);
|
|
28
27
|
}
|
|
@@ -37,8 +36,7 @@ export class ConfigManager {
|
|
|
37
36
|
await runtime.config.writeConfigFile(config);
|
|
38
37
|
} catch (error) {
|
|
39
38
|
throw new Error(
|
|
40
|
-
`Failed to save config: ${
|
|
41
|
-
error instanceof Error ? error.message : String(error)
|
|
39
|
+
`Failed to save config: ${error instanceof Error ? error.message : String(error)
|
|
42
40
|
}`
|
|
43
41
|
);
|
|
44
42
|
}
|
|
@@ -138,8 +136,7 @@ export class ConfigManager {
|
|
|
138
136
|
await this.saveConfig(updatedConfig);
|
|
139
137
|
} catch (error) {
|
|
140
138
|
throw new Error(
|
|
141
|
-
`Failed to update user config: ${
|
|
142
|
-
error instanceof Error ? error.message : String(error)
|
|
139
|
+
`Failed to update user config: ${error instanceof Error ? error.message : String(error)
|
|
143
140
|
}`
|
|
144
141
|
);
|
|
145
142
|
}
|
|
@@ -171,8 +168,7 @@ export class ConfigManager {
|
|
|
171
168
|
await this.saveConfig(updatedConfig);
|
|
172
169
|
} catch (error) {
|
|
173
170
|
throw new Error(
|
|
174
|
-
`Failed to clear user config: ${
|
|
175
|
-
error instanceof Error ? error.message : String(error)
|
|
171
|
+
`Failed to clear user config: ${error instanceof Error ? error.message : String(error)
|
|
176
172
|
}`
|
|
177
173
|
);
|
|
178
174
|
}
|
|
@@ -212,6 +208,14 @@ export class ConfigManager {
|
|
|
212
208
|
"schedule.update",
|
|
213
209
|
"schedule.delete",
|
|
214
210
|
"schedule.search",
|
|
211
|
+
"mobile.data",
|
|
212
|
+
"hot.spot",
|
|
213
|
+
"no.disturb",
|
|
214
|
+
"quiet.mode",
|
|
215
|
+
"sound.vibration",
|
|
216
|
+
"ringing.mode",
|
|
217
|
+
"vibration.mode",
|
|
218
|
+
"close.app"
|
|
215
219
|
];
|
|
216
220
|
|
|
217
221
|
const currentAllowCommands =
|
|
@@ -239,8 +243,7 @@ export class ConfigManager {
|
|
|
239
243
|
await this.saveConfig(updatedConfig);
|
|
240
244
|
} catch (error) {
|
|
241
245
|
throw new Error(
|
|
242
|
-
`failed to initialize plugin config: ${
|
|
243
|
-
error instanceof Error ? error.message : String(error)
|
|
246
|
+
`failed to initialize plugin config: ${error instanceof Error ? error.message : String(error)
|
|
244
247
|
}`
|
|
245
248
|
);
|
|
246
249
|
}
|
|
@@ -0,0 +1,544 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import type { Stats } from "node:fs";
|
|
3
|
+
import { constants as fsConstants } from "node:fs";
|
|
4
|
+
import type { FileHandle } from "node:fs/promises";
|
|
5
|
+
import fs from "node:fs/promises";
|
|
6
|
+
import os from "node:os";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
|
|
9
|
+
export type SafeFsErrorCode =
|
|
10
|
+
| "invalid-path"
|
|
11
|
+
| "not-found"
|
|
12
|
+
| "outside-root"
|
|
13
|
+
| "symlink"
|
|
14
|
+
| "not-file"
|
|
15
|
+
| "path-mismatch"
|
|
16
|
+
| "too-large"
|
|
17
|
+
| "write-failed";
|
|
18
|
+
|
|
19
|
+
export class SafeFsError extends Error {
|
|
20
|
+
code: SafeFsErrorCode;
|
|
21
|
+
|
|
22
|
+
constructor(code: SafeFsErrorCode, message: string, options?: ErrorOptions) {
|
|
23
|
+
super(message, options);
|
|
24
|
+
this.code = code;
|
|
25
|
+
this.name = "SafeFsError";
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type WriteMode = "overwrite" | "append";
|
|
30
|
+
|
|
31
|
+
export type SafeWriteOptions = {
|
|
32
|
+
rootDir: string;
|
|
33
|
+
relativePath: string;
|
|
34
|
+
data: string | Buffer;
|
|
35
|
+
encoding?: BufferEncoding;
|
|
36
|
+
mode?: WriteMode;
|
|
37
|
+
mkdir?: boolean;
|
|
38
|
+
maxBytes?: number;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export type SafeReadResult = {
|
|
42
|
+
buffer: Buffer;
|
|
43
|
+
realPath: string;
|
|
44
|
+
stat: Stats;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export type SafeWriteResult = {
|
|
48
|
+
realPath: string;
|
|
49
|
+
bytesWritten: number;
|
|
50
|
+
created: boolean;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const NOT_FOUND_CODES = new Set(["ENOENT", "ENOTDIR"]);
|
|
54
|
+
const SYMLINK_OPEN_CODES = new Set(["ELOOP", "EINVAL", "ENOTSUP"]);
|
|
55
|
+
|
|
56
|
+
function isNodeError(value: unknown): value is NodeJS.ErrnoException {
|
|
57
|
+
return Boolean(
|
|
58
|
+
value && typeof value === "object" && "code" in (value as Record<string, unknown>)
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function isNotFoundPathError(value: unknown): boolean {
|
|
63
|
+
return isNodeError(value) && typeof value.code === "string" && NOT_FOUND_CODES.has(value.code);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function isSymlinkOpenError(value: unknown): boolean {
|
|
67
|
+
return isNodeError(value) && typeof value.code === "string" && SYMLINK_OPEN_CODES.has(value.code);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function normalizeWindowsPathForComparison(input: string): string {
|
|
71
|
+
let normalized = path.win32.normalize(input);
|
|
72
|
+
if (normalized.startsWith("\\\\?\\")) {
|
|
73
|
+
normalized = normalized.slice(4);
|
|
74
|
+
if (normalized.toUpperCase().startsWith("UNC\\")) {
|
|
75
|
+
normalized = `\\\\${normalized.slice(4)}`;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return normalized.replaceAll("/", "\\").toLowerCase();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function isPathInside(root: string, target: string): boolean {
|
|
82
|
+
const resolvedRoot = path.resolve(root);
|
|
83
|
+
const resolvedTarget = path.resolve(target);
|
|
84
|
+
|
|
85
|
+
if (process.platform === "win32") {
|
|
86
|
+
const rootForCompare = normalizeWindowsPathForComparison(resolvedRoot);
|
|
87
|
+
const targetForCompare = normalizeWindowsPathForComparison(resolvedTarget);
|
|
88
|
+
const relative = path.win32.relative(rootForCompare, targetForCompare);
|
|
89
|
+
return relative === "" || (!relative.startsWith("..") && !path.win32.isAbsolute(relative));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const relative = path.relative(resolvedRoot, resolvedTarget);
|
|
93
|
+
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
type FileIdentityStat = {
|
|
97
|
+
dev: number | bigint;
|
|
98
|
+
ino: number | bigint;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
function isZero(value: number | bigint): boolean {
|
|
102
|
+
return value === 0 || value === 0n;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function sameFileIdentity(
|
|
106
|
+
left: FileIdentityStat,
|
|
107
|
+
right: FileIdentityStat,
|
|
108
|
+
platform: NodeJS.Platform = process.platform
|
|
109
|
+
): boolean {
|
|
110
|
+
if (left.ino !== right.ino) {
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
if (left.dev === right.dev) {
|
|
114
|
+
return true;
|
|
115
|
+
}
|
|
116
|
+
return platform === "win32" && (isZero(left.dev) || isZero(right.dev));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const SUPPORTS_NOFOLLOW = process.platform !== "win32" && "O_NOFOLLOW" in fsConstants;
|
|
120
|
+
const OPEN_READ_FLAGS = fsConstants.O_RDONLY | (SUPPORTS_NOFOLLOW ? fsConstants.O_NOFOLLOW : 0);
|
|
121
|
+
const OPEN_WRITE_CREATE_FLAGS =
|
|
122
|
+
fsConstants.O_WRONLY |
|
|
123
|
+
fsConstants.O_CREAT |
|
|
124
|
+
fsConstants.O_EXCL |
|
|
125
|
+
(SUPPORTS_NOFOLLOW ? fsConstants.O_NOFOLLOW : 0);
|
|
126
|
+
const OPEN_APPEND_FLAGS =
|
|
127
|
+
fsConstants.O_WRONLY | fsConstants.O_APPEND | (SUPPORTS_NOFOLLOW ? fsConstants.O_NOFOLLOW : 0);
|
|
128
|
+
|
|
129
|
+
const ensureTrailingSep = (value: string) => (value.endsWith(path.sep) ? value : value + path.sep);
|
|
130
|
+
|
|
131
|
+
function expandHomePrefix(input: string, home?: string): string {
|
|
132
|
+
if (!input.startsWith("~")) {
|
|
133
|
+
return input;
|
|
134
|
+
}
|
|
135
|
+
const resolvedHome = home ?? process.env.HOME ?? process.env.USERPROFILE ?? os.homedir();
|
|
136
|
+
if (!resolvedHome) {
|
|
137
|
+
return input;
|
|
138
|
+
}
|
|
139
|
+
return input.replace(/^~(?=$|[\\/])/, resolvedHome);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function resolvePathWithinRoot(params: {
|
|
143
|
+
rootDir: string;
|
|
144
|
+
relativePath: string;
|
|
145
|
+
}): Promise<{ rootReal: string; rootWithSep: string; resolved: string }> {
|
|
146
|
+
let rootReal: string;
|
|
147
|
+
try {
|
|
148
|
+
rootReal = await fs.realpath(params.rootDir);
|
|
149
|
+
} catch (err) {
|
|
150
|
+
if (isNotFoundPathError(err)) {
|
|
151
|
+
throw new SafeFsError("not-found", "root dir not found");
|
|
152
|
+
}
|
|
153
|
+
throw err;
|
|
154
|
+
}
|
|
155
|
+
const rootWithSep = ensureTrailingSep(rootReal);
|
|
156
|
+
const expanded = expandHomePrefix(params.relativePath);
|
|
157
|
+
const resolved = path.resolve(rootWithSep, expanded);
|
|
158
|
+
if (!isPathInside(rootWithSep, resolved)) {
|
|
159
|
+
throw new SafeFsError("outside-root", "file is outside workspace root");
|
|
160
|
+
}
|
|
161
|
+
return { rootReal, rootWithSep, resolved };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function openVerifiedLocalFile(
|
|
165
|
+
filePath: string,
|
|
166
|
+
options?: { rejectHardlinks?: boolean }
|
|
167
|
+
): Promise<{ handle: FileHandle; realPath: string; stat: Stats }> {
|
|
168
|
+
try {
|
|
169
|
+
const preStat = await fs.lstat(filePath);
|
|
170
|
+
if (preStat.isDirectory()) {
|
|
171
|
+
throw new SafeFsError("not-file", "not a file");
|
|
172
|
+
}
|
|
173
|
+
} catch (err) {
|
|
174
|
+
if (err instanceof SafeFsError) {
|
|
175
|
+
throw err;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
let handle: FileHandle;
|
|
180
|
+
try {
|
|
181
|
+
handle = await fs.open(filePath, OPEN_READ_FLAGS);
|
|
182
|
+
} catch (err) {
|
|
183
|
+
if (isNotFoundPathError(err)) {
|
|
184
|
+
throw new SafeFsError("not-found", "file not found");
|
|
185
|
+
}
|
|
186
|
+
if (isSymlinkOpenError(err)) {
|
|
187
|
+
throw new SafeFsError("symlink", "symlink open blocked", { cause: err });
|
|
188
|
+
}
|
|
189
|
+
if (isNodeError(err) && err.code === "EISDIR") {
|
|
190
|
+
throw new SafeFsError("not-file", "not a file");
|
|
191
|
+
}
|
|
192
|
+
throw err;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
try {
|
|
196
|
+
const [stat, lstat] = await Promise.all([handle.stat(), fs.lstat(filePath)]);
|
|
197
|
+
if (lstat.isSymbolicLink()) {
|
|
198
|
+
throw new SafeFsError("symlink", "symlink not allowed");
|
|
199
|
+
}
|
|
200
|
+
if (!stat.isFile()) {
|
|
201
|
+
throw new SafeFsError("not-file", "not a file");
|
|
202
|
+
}
|
|
203
|
+
if (options?.rejectHardlinks && stat.nlink > 1) {
|
|
204
|
+
throw new SafeFsError("invalid-path", "hardlinked path not allowed");
|
|
205
|
+
}
|
|
206
|
+
if (!sameFileIdentity(stat, lstat)) {
|
|
207
|
+
throw new SafeFsError("path-mismatch", "path changed during read");
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const realPath = await fs.realpath(filePath);
|
|
211
|
+
const realStat = await fs.stat(realPath);
|
|
212
|
+
if (options?.rejectHardlinks && realStat.nlink > 1) {
|
|
213
|
+
throw new SafeFsError("invalid-path", "hardlinked path not allowed");
|
|
214
|
+
}
|
|
215
|
+
if (!sameFileIdentity(stat, realStat)) {
|
|
216
|
+
throw new SafeFsError("path-mismatch", "path mismatch");
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return { handle, realPath, stat };
|
|
220
|
+
} catch (err) {
|
|
221
|
+
await handle.close().catch(() => { });
|
|
222
|
+
if (err instanceof SafeFsError) {
|
|
223
|
+
throw err;
|
|
224
|
+
}
|
|
225
|
+
if (isNotFoundPathError(err)) {
|
|
226
|
+
throw new SafeFsError("not-found", "file not found");
|
|
227
|
+
}
|
|
228
|
+
throw err;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export async function safeReadFile(params: {
|
|
233
|
+
rootDir: string;
|
|
234
|
+
relativePath: string;
|
|
235
|
+
maxBytes?: number;
|
|
236
|
+
}): Promise<SafeReadResult> {
|
|
237
|
+
const { rootWithSep, resolved } = await resolvePathWithinRoot(params);
|
|
238
|
+
|
|
239
|
+
const opened = await openVerifiedLocalFile(resolved);
|
|
240
|
+
|
|
241
|
+
if (!isPathInside(rootWithSep, opened.realPath)) {
|
|
242
|
+
await opened.handle.close().catch(() => { });
|
|
243
|
+
throw new SafeFsError("outside-root", "file is outside workspace root");
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
try {
|
|
247
|
+
if (params.maxBytes !== undefined && opened.stat.size > params.maxBytes) {
|
|
248
|
+
throw new SafeFsError(
|
|
249
|
+
"too-large",
|
|
250
|
+
`file exceeds limit of ${params.maxBytes} bytes (got ${opened.stat.size})`
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
const buffer = await opened.handle.readFile();
|
|
254
|
+
return {
|
|
255
|
+
buffer,
|
|
256
|
+
realPath: opened.realPath,
|
|
257
|
+
stat: opened.stat,
|
|
258
|
+
};
|
|
259
|
+
} finally {
|
|
260
|
+
await opened.handle.close().catch(() => { });
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function buildAtomicWriteTempPath(targetPath: string): string {
|
|
265
|
+
const dir = path.dirname(targetPath);
|
|
266
|
+
const base = path.basename(targetPath);
|
|
267
|
+
return path.join(dir, `.${base}.${process.pid}.${randomUUID()}.tmp`);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async function writeTempFileForAtomicReplace(params: {
|
|
271
|
+
tempPath: string;
|
|
272
|
+
data: string | Buffer;
|
|
273
|
+
encoding?: BufferEncoding;
|
|
274
|
+
mode: number;
|
|
275
|
+
}): Promise<Stats> {
|
|
276
|
+
const tempHandle = await fs.open(params.tempPath, OPEN_WRITE_CREATE_FLAGS, params.mode);
|
|
277
|
+
try {
|
|
278
|
+
if (typeof params.data === "string") {
|
|
279
|
+
await tempHandle.writeFile(params.data, params.encoding ?? "utf8");
|
|
280
|
+
} else {
|
|
281
|
+
await tempHandle.writeFile(params.data);
|
|
282
|
+
}
|
|
283
|
+
return await tempHandle.stat();
|
|
284
|
+
} finally {
|
|
285
|
+
await tempHandle.close().catch(() => { });
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
async function verifyAtomicWriteResult(params: {
|
|
290
|
+
rootDir: string;
|
|
291
|
+
targetPath: string;
|
|
292
|
+
expectedStat: Stats;
|
|
293
|
+
}): Promise<void> {
|
|
294
|
+
const rootReal = await fs.realpath(params.rootDir);
|
|
295
|
+
const rootWithSep = ensureTrailingSep(rootReal);
|
|
296
|
+
const opened = await openVerifiedLocalFile(params.targetPath, { rejectHardlinks: true });
|
|
297
|
+
try {
|
|
298
|
+
if (!sameFileIdentity(opened.stat, params.expectedStat)) {
|
|
299
|
+
throw new SafeFsError("path-mismatch", "path changed during write");
|
|
300
|
+
}
|
|
301
|
+
if (!isPathInside(rootWithSep, opened.realPath)) {
|
|
302
|
+
throw new SafeFsError("outside-root", "file is outside workspace root");
|
|
303
|
+
}
|
|
304
|
+
} finally {
|
|
305
|
+
await opened.handle.close().catch(() => { });
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
export async function safeWriteFile(params: SafeWriteOptions): Promise<SafeWriteResult> {
|
|
310
|
+
const { rootWithSep, resolved } = await resolvePathWithinRoot(params);
|
|
311
|
+
|
|
312
|
+
if (params.mkdir !== false) {
|
|
313
|
+
await fs.mkdir(path.dirname(resolved), { recursive: true });
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
let ioPath = resolved;
|
|
317
|
+
let existingFile = false;
|
|
318
|
+
|
|
319
|
+
try {
|
|
320
|
+
const resolvedRealPath = await fs.realpath(resolved);
|
|
321
|
+
if (!isPathInside(rootWithSep, resolvedRealPath)) {
|
|
322
|
+
throw new SafeFsError("outside-root", "file is outside workspace root");
|
|
323
|
+
}
|
|
324
|
+
ioPath = resolvedRealPath;
|
|
325
|
+
existingFile = true;
|
|
326
|
+
} catch (err) {
|
|
327
|
+
if (err instanceof SafeFsError) {
|
|
328
|
+
throw err;
|
|
329
|
+
}
|
|
330
|
+
if (!isNotFoundPathError(err)) {
|
|
331
|
+
throw err;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const fileMode = 0o600;
|
|
336
|
+
const isAppend = params.mode === "append";
|
|
337
|
+
|
|
338
|
+
if (isAppend && !existingFile) {
|
|
339
|
+
throw new SafeFsError("not-found", "cannot append to non-existent file");
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
if (isAppend) {
|
|
343
|
+
return await appendToFile({
|
|
344
|
+
ioPath,
|
|
345
|
+
rootWithSep,
|
|
346
|
+
data: params.data,
|
|
347
|
+
encoding: params.encoding,
|
|
348
|
+
maxBytes: params.maxBytes,
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
return await overwriteFile({
|
|
353
|
+
ioPath,
|
|
354
|
+
rootWithSep,
|
|
355
|
+
rootDir: params.rootDir,
|
|
356
|
+
data: params.data,
|
|
357
|
+
encoding: params.encoding,
|
|
358
|
+
existingFile,
|
|
359
|
+
fileMode,
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
async function appendToFile(params: {
|
|
364
|
+
ioPath: string;
|
|
365
|
+
rootWithSep: string;
|
|
366
|
+
data: string | Buffer;
|
|
367
|
+
encoding?: BufferEncoding;
|
|
368
|
+
maxBytes?: number;
|
|
369
|
+
}): Promise<SafeWriteResult> {
|
|
370
|
+
let handle: FileHandle;
|
|
371
|
+
try {
|
|
372
|
+
handle = await fs.open(params.ioPath, OPEN_APPEND_FLAGS);
|
|
373
|
+
} catch (err) {
|
|
374
|
+
if (isNotFoundPathError(err)) {
|
|
375
|
+
throw new SafeFsError("not-found", "file not found");
|
|
376
|
+
}
|
|
377
|
+
if (isSymlinkOpenError(err)) {
|
|
378
|
+
throw new SafeFsError("symlink", "symlink open blocked", { cause: err });
|
|
379
|
+
}
|
|
380
|
+
throw err;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
try {
|
|
384
|
+
const stat = await handle.stat();
|
|
385
|
+
if (!stat.isFile()) {
|
|
386
|
+
throw new SafeFsError("not-file", "path is not a regular file");
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const lstat = await fs.lstat(params.ioPath);
|
|
390
|
+
if (lstat.isSymbolicLink()) {
|
|
391
|
+
throw new SafeFsError("symlink", "path is a symlink");
|
|
392
|
+
}
|
|
393
|
+
if (!sameFileIdentity(stat, lstat)) {
|
|
394
|
+
throw new SafeFsError("path-mismatch", "path changed during write");
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const realPath = await fs.realpath(params.ioPath);
|
|
398
|
+
if (!isPathInside(params.rootWithSep, realPath)) {
|
|
399
|
+
throw new SafeFsError("outside-root", "file is outside workspace root");
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const dataSize = typeof params.data === "string"
|
|
403
|
+
? Buffer.byteLength(params.data, params.encoding ?? "utf8")
|
|
404
|
+
: params.data.length;
|
|
405
|
+
|
|
406
|
+
if (params.maxBytes !== undefined && stat.size + dataSize > params.maxBytes) {
|
|
407
|
+
throw new SafeFsError(
|
|
408
|
+
"too-large",
|
|
409
|
+
`file would exceed limit of ${params.maxBytes} bytes`
|
|
410
|
+
);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
if (typeof params.data === "string") {
|
|
414
|
+
await handle.writeFile(params.data, params.encoding ?? "utf8");
|
|
415
|
+
} else {
|
|
416
|
+
await handle.writeFile(params.data);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
return {
|
|
420
|
+
realPath,
|
|
421
|
+
bytesWritten: dataSize,
|
|
422
|
+
created: false,
|
|
423
|
+
};
|
|
424
|
+
} finally {
|
|
425
|
+
await handle.close().catch(() => { });
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
async function overwriteFile(params: {
|
|
430
|
+
ioPath: string;
|
|
431
|
+
rootWithSep: string;
|
|
432
|
+
rootDir: string;
|
|
433
|
+
data: string | Buffer;
|
|
434
|
+
encoding?: BufferEncoding;
|
|
435
|
+
existingFile: boolean;
|
|
436
|
+
fileMode: number;
|
|
437
|
+
}): Promise<SafeWriteResult> {
|
|
438
|
+
let tempPath: string | null = null;
|
|
439
|
+
const destinationPath = params.ioPath;
|
|
440
|
+
|
|
441
|
+
try {
|
|
442
|
+
tempPath = buildAtomicWriteTempPath(destinationPath);
|
|
443
|
+
const writtenStat = await writeTempFileForAtomicReplace({
|
|
444
|
+
tempPath,
|
|
445
|
+
data: params.data,
|
|
446
|
+
encoding: params.encoding,
|
|
447
|
+
mode: params.fileMode,
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
await fs.rename(tempPath, destinationPath);
|
|
451
|
+
tempPath = null;
|
|
452
|
+
|
|
453
|
+
try {
|
|
454
|
+
await verifyAtomicWriteResult({
|
|
455
|
+
rootDir: params.rootDir,
|
|
456
|
+
targetPath: destinationPath,
|
|
457
|
+
expectedStat: writtenStat,
|
|
458
|
+
});
|
|
459
|
+
} catch (err) {
|
|
460
|
+
// 修复:重新抛出错误
|
|
461
|
+
throw new SafeFsError("write-failed", `${JSON.stringify(err)}`);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
const dataSize = typeof params.data === "string"
|
|
465
|
+
? Buffer.byteLength(params.data, params.encoding ?? "utf8")
|
|
466
|
+
: params.data.length;
|
|
467
|
+
|
|
468
|
+
return {
|
|
469
|
+
realPath: destinationPath,
|
|
470
|
+
bytesWritten: dataSize,
|
|
471
|
+
created: !params.existingFile,
|
|
472
|
+
};
|
|
473
|
+
} finally {
|
|
474
|
+
if (tempPath) {
|
|
475
|
+
await fs.rm(tempPath, { force: true }).catch(() => { });
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
export async function safeCopyFile(params: {
|
|
481
|
+
sourcePath: string;
|
|
482
|
+
rootDir: string;
|
|
483
|
+
relativePath: string;
|
|
484
|
+
maxBytes?: number;
|
|
485
|
+
mkdir?: boolean;
|
|
486
|
+
}): Promise<void> {
|
|
487
|
+
const source = await openVerifiedLocalFile(params.sourcePath, { rejectHardlinks: true });
|
|
488
|
+
|
|
489
|
+
if (params.maxBytes !== undefined && source.stat.size > params.maxBytes) {
|
|
490
|
+
await source.handle.close().catch(() => { });
|
|
491
|
+
throw new SafeFsError(
|
|
492
|
+
"too-large",
|
|
493
|
+
`file exceeds limit of ${params.maxBytes} bytes (got ${source.stat.size})`
|
|
494
|
+
);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
const { resolved } = await resolvePathWithinRoot({
|
|
498
|
+
rootDir: params.rootDir,
|
|
499
|
+
relativePath: params.relativePath,
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
if (params.mkdir !== false) {
|
|
503
|
+
await fs.mkdir(path.dirname(resolved), { recursive: true });
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
let tempPath: string | null = null;
|
|
507
|
+
let tempHandle: FileHandle | null = null;
|
|
508
|
+
|
|
509
|
+
try {
|
|
510
|
+
tempPath = buildAtomicWriteTempPath(resolved);
|
|
511
|
+
tempHandle = await fs.open(tempPath, OPEN_WRITE_CREATE_FLAGS, 0o600);
|
|
512
|
+
|
|
513
|
+
const sourceStream = source.handle.createReadStream();
|
|
514
|
+
const targetStream = tempHandle.createWriteStream();
|
|
515
|
+
|
|
516
|
+
await new Promise<void>((resolve, reject) => {
|
|
517
|
+
sourceStream.pipe(targetStream);
|
|
518
|
+
sourceStream.on("end", resolve);
|
|
519
|
+
sourceStream.on("error", reject);
|
|
520
|
+
targetStream.on("error", reject);
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
await tempHandle.close().catch(() => { });
|
|
524
|
+
tempHandle = null;
|
|
525
|
+
|
|
526
|
+
const writtenStat = await fs.stat(tempPath);
|
|
527
|
+
await fs.rename(tempPath, resolved);
|
|
528
|
+
tempPath = null;
|
|
529
|
+
|
|
530
|
+
await verifyAtomicWriteResult({
|
|
531
|
+
rootDir: params.rootDir,
|
|
532
|
+
targetPath: resolved,
|
|
533
|
+
expectedStat: writtenStat,
|
|
534
|
+
});
|
|
535
|
+
} finally {
|
|
536
|
+
if (tempPath) {
|
|
537
|
+
await fs.rm(tempPath, { force: true }).catch(() => { });
|
|
538
|
+
}
|
|
539
|
+
if (tempHandle) {
|
|
540
|
+
await tempHandle.close().catch(() => { });
|
|
541
|
+
}
|
|
542
|
+
await source.handle.close().catch(() => { });
|
|
543
|
+
}
|
|
544
|
+
}
|
|
@@ -1,103 +0,0 @@
|
|
|
1
|
-
# 设备控制脚本使用说明
|
|
2
|
-
|
|
3
|
-
本脚本提供统一的设备控制接口,避免 Windows PowerShell 对 JSON 参数的引号处理问题。
|
|
4
|
-
|
|
5
|
-
## 快速开始
|
|
6
|
-
|
|
7
|
-
### 1. 获取设备ID
|
|
8
|
-
|
|
9
|
-
```bash
|
|
10
|
-
npx openclaw nodes status
|
|
11
|
-
```
|
|
12
|
-
|
|
13
|
-
### 2. 创建配置文件
|
|
14
|
-
|
|
15
|
-
复制并修改示例配置文件,将 `YOUR_DEVICE_ID` 替换为实际的设备ID。
|
|
16
|
-
|
|
17
|
-
```json
|
|
18
|
-
{
|
|
19
|
-
"nodeId": "f5f8916028aa52091fd9e97bdb77b294b73dcef339541e33d0b51a3b8d595b6c",
|
|
20
|
-
"command": "volume.operate",
|
|
21
|
-
"params": {
|
|
22
|
-
"actionType": "关闭"
|
|
23
|
-
}
|
|
24
|
-
}
|
|
25
|
-
```
|
|
26
|
-
|
|
27
|
-
### 3. 执行命令
|
|
28
|
-
|
|
29
|
-
```bash
|
|
30
|
-
cd claw-yoyo-ext
|
|
31
|
-
node skills/devices-control/scripts/invoke.js skills/devices-control/scripts/volume-close.json
|
|
32
|
-
```
|
|
33
|
-
|
|
34
|
-
## 使用示例
|
|
35
|
-
|
|
36
|
-
### 音量控制
|
|
37
|
-
|
|
38
|
-
```bash
|
|
39
|
-
# 关闭音量
|
|
40
|
-
node skills/devices-control/scripts/invoke.js skills/devices-control/scripts/volume-close.json
|
|
41
|
-
|
|
42
|
-
# 调大音量
|
|
43
|
-
node skills/devices-control/scripts/invoke.js skills/devices-control/scripts/volume-up.json
|
|
44
|
-
|
|
45
|
-
# 设置音量为50
|
|
46
|
-
node skills/devices-control/scripts/invoke.js skills/devices-control/scripts/volume-set-50.json
|
|
47
|
-
```
|
|
48
|
-
|
|
49
|
-
### 电话拨打
|
|
50
|
-
|
|
51
|
-
```bash
|
|
52
|
-
# 拨打电话
|
|
53
|
-
node skills/devices-control/scripts/invoke.js skills/devices-control/scripts/phone-call.json
|
|
54
|
-
```
|
|
55
|
-
|
|
56
|
-
### 屏幕录制
|
|
57
|
-
|
|
58
|
-
```bash
|
|
59
|
-
# 开始录制
|
|
60
|
-
node skills/devices-control/scripts/invoke.js skills/devices-control/scripts/screen-record-start.json
|
|
61
|
-
```
|
|
62
|
-
|
|
63
|
-
## 调试模式
|
|
64
|
-
|
|
65
|
-
启用调试模式查看详细执行信息:
|
|
66
|
-
|
|
67
|
-
```bash
|
|
68
|
-
node skills/devices-control/scripts/invoke.js config.json --debug
|
|
69
|
-
```
|
|
70
|
-
|
|
71
|
-
## 配置文件格式
|
|
72
|
-
|
|
73
|
-
```json
|
|
74
|
-
{
|
|
75
|
-
"nodeId": "设备ID(必填)",
|
|
76
|
-
"command": "命令标识(必填)",
|
|
77
|
-
"params": {
|
|
78
|
-
// 命令参数(可选)
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
```
|
|
82
|
-
|
|
83
|
-
## 常用命令标识
|
|
84
|
-
|
|
85
|
-
| 功能 | 命令标识 |
|
|
86
|
-
|------|----------|
|
|
87
|
-
| 音量控制 | `volume.operate` |
|
|
88
|
-
| 电话拨打 | `phone.call` |
|
|
89
|
-
| 屏幕录制 | `screen_recorder.control` |
|
|
90
|
-
|
|
91
|
-
## 故障排查
|
|
92
|
-
|
|
93
|
-
### 问题:找不到 openclaw 命令
|
|
94
|
-
|
|
95
|
-
确保在 `claw-yoyo-ext` 目录下执行命令,或确保 `openclaw` 已全局安装。
|
|
96
|
-
|
|
97
|
-
### 问题:设备ID无效
|
|
98
|
-
|
|
99
|
-
使用 `npx openclaw nodes status` 查看有效的设备ID。
|
|
100
|
-
|
|
101
|
-
### 问题:参数格式错误
|
|
102
|
-
|
|
103
|
-
参考 `examples.json` 查看完整的参数示例。
|
|
@@ -1,119 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
* Device Control Invoke Script
|
|
4
|
-
*
|
|
5
|
-
* 封装 openclaw nodes invoke 命令,提供统一的设备控制接口
|
|
6
|
-
* 使用配置文件方式避免 Windows PowerShell 对 JSON 参数的引号处理问题
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import { exec } from "child_process";
|
|
10
|
-
import { readFileSync } from "fs";
|
|
11
|
-
import { resolve } from "path";
|
|
12
|
-
import { promisify } from "util";
|
|
13
|
-
|
|
14
|
-
const execAsync = promisify(exec);
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* 显示使用说明
|
|
18
|
-
*/
|
|
19
|
-
function usage() {
|
|
20
|
-
console.error("用法: node invoke.js <config_file> [--debug]");
|
|
21
|
-
console.error("");
|
|
22
|
-
console.error("参数:");
|
|
23
|
-
console.error(" config_file - JSON 配置文件路径");
|
|
24
|
-
console.error(" --debug - 启用调试模式");
|
|
25
|
-
console.error("");
|
|
26
|
-
console.error("配置文件格式:");
|
|
27
|
-
console.error("{");
|
|
28
|
-
console.error(' "nodeId": "设备ID",');
|
|
29
|
-
console.error(' "command": "命令标识",');
|
|
30
|
-
console.error(' "params": { ...参数对象 }');
|
|
31
|
-
console.error("}");
|
|
32
|
-
console.error("");
|
|
33
|
-
console.error("示例:");
|
|
34
|
-
console.error(" node invoke.js config.json");
|
|
35
|
-
console.error(" node invoke.js config.json --debug");
|
|
36
|
-
process.exit(2);
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* 执行节点命令
|
|
41
|
-
*/
|
|
42
|
-
async function invokeNode(nodeId, command, params, timeout = 15000, debug = false) {
|
|
43
|
-
const paramsStr = JSON.stringify(params);
|
|
44
|
-
const cmd = `npx openclaw nodes invoke --node ${nodeId} --command ${command} --params "${paramsStr.replace(/"/g, '\\"')}"`;
|
|
45
|
-
|
|
46
|
-
if (debug) {
|
|
47
|
-
console.log("=== 调试信息 ===");
|
|
48
|
-
console.log("执行命令:", cmd);
|
|
49
|
-
console.log("设备 ID:", nodeId);
|
|
50
|
-
console.log("命令标识:", command);
|
|
51
|
-
console.log("参数对象:", JSON.stringify(params, null, 2));
|
|
52
|
-
console.log("================");
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
try {
|
|
56
|
-
const { stdout, stderr } = await execAsync(cmd, {
|
|
57
|
-
timeout,
|
|
58
|
-
env: { ...process.env },
|
|
59
|
-
cwd: process.cwd()
|
|
60
|
-
});
|
|
61
|
-
|
|
62
|
-
if (debug) {
|
|
63
|
-
console.log("=== 执行结果 ===");
|
|
64
|
-
console.log("标准输出:", stdout.trim());
|
|
65
|
-
console.log("错误输出:", stderr.trim());
|
|
66
|
-
console.log("================");
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
return {
|
|
70
|
-
success: true,
|
|
71
|
-
data: stdout.trim(),
|
|
72
|
-
error: stderr.trim() || null
|
|
73
|
-
};
|
|
74
|
-
} catch (error) {
|
|
75
|
-
if (debug) {
|
|
76
|
-
console.log("=== 执行失败 ===");
|
|
77
|
-
console.log("错误信息:", error.message);
|
|
78
|
-
console.log("标准输出:", error.stdout?.trim() || "");
|
|
79
|
-
console.log("错误输出:", error.stderr?.trim() || "");
|
|
80
|
-
console.log("================");
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
return {
|
|
84
|
-
success: false,
|
|
85
|
-
data: error.stdout?.trim() || "",
|
|
86
|
-
error: error.stderr?.trim() || error.message
|
|
87
|
-
};
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
// 主程序
|
|
92
|
-
const args = process.argv.slice(2);
|
|
93
|
-
const debugIndex = args.indexOf("--debug");
|
|
94
|
-
const debug = debugIndex !== -1;
|
|
95
|
-
if (debug) args.splice(debugIndex, 1);
|
|
96
|
-
|
|
97
|
-
if (args.length === 0 || args[0] === "-h" || args[0] === "--help") {
|
|
98
|
-
usage();
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
const configPath = resolve(args[0]);
|
|
102
|
-
|
|
103
|
-
try {
|
|
104
|
-
const configContent = readFileSync(configPath, "utf-8");
|
|
105
|
-
const config = JSON.parse(configContent);
|
|
106
|
-
const { nodeId, command, params } = config;
|
|
107
|
-
|
|
108
|
-
if (!nodeId || !command) {
|
|
109
|
-
console.error("错误: 配置文件缺少必要字段: nodeId 或 command");
|
|
110
|
-
process.exit(1);
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
const result = await invokeNode(nodeId, command, params || {}, 15000, debug);
|
|
114
|
-
console.log(JSON.stringify(result, null, 2));
|
|
115
|
-
process.exit(result.success ? 0 : 1);
|
|
116
|
-
} catch (error) {
|
|
117
|
-
console.error(`错误: ${error.message}`);
|
|
118
|
-
process.exit(1);
|
|
119
|
-
}
|