@kyan-du/agent-wechat-cli 0.12.0-next.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.
@@ -0,0 +1,353 @@
1
+ #!/usr/bin/env python3
2
+ """Canonical host-side device identity. CLI and Compose both use this file."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import fcntl
7
+ import json
8
+ import os
9
+ import re
10
+ import shlex
11
+ import stat
12
+ import sys
13
+ import tempfile
14
+ import time
15
+ import uuid
16
+ from typing import Callable, Optional
17
+
18
+ PREFIXES = ("lenovo-pc", "honor-pc", "xiaomi-pc", "asus-pc", "dell-pc", "hp-pc", "thinkpad")
19
+ MACHINE_ID_RE = re.compile(r"^[0-9a-f]{32}$")
20
+ HOSTNAME_RE = re.compile(r"^[a-z0-9]([a-z0-9-]*[a-z0-9])?$")
21
+ MAC_RE = re.compile(r"^[0-9a-f]{2}(:[0-9a-f]{2}){5}$")
22
+ ENV_NAME = "device-identity.env"
23
+ JSON_NAME = "device-identity.json"
24
+ LOCK_NAME = ".device-identity.lock"
25
+ ENV_TEMP_RE = re.compile(r"^device-identity\.env\.(?:\d+\.[0-9a-f]{8}|[A-Za-z0-9_-]+)$")
26
+
27
+
28
+ def die(message: str) -> None:
29
+ print(f"[identity] {message}", file=sys.stderr)
30
+ raise SystemExit(1)
31
+
32
+
33
+ def valid_machine_id(value: str) -> bool:
34
+ return "\n" not in value and "\r" not in value and bool(MACHINE_ID_RE.fullmatch(value))
35
+
36
+
37
+ def valid_hostname(value: str) -> bool:
38
+ return (
39
+ "\n" not in value
40
+ and "\r" not in value
41
+ and 1 <= len(value) <= 63
42
+ and bool(HOSTNAME_RE.fullmatch(value))
43
+ )
44
+
45
+
46
+ def valid_mac(value: str) -> bool:
47
+ if "\n" in value or "\r" in value or not MAC_RE.fullmatch(value):
48
+ return False
49
+ return int(value.split(":", 1)[0], 16) % 2 == 0
50
+
51
+
52
+ def validate_override(name: str, value: Optional[str], checker: Callable[[str], bool]) -> Optional[str]:
53
+ if not value:
54
+ return None
55
+ if not checker(value):
56
+ die(f"invalid {name} override")
57
+ return value
58
+
59
+
60
+ def path_kind(path: str) -> str:
61
+ try:
62
+ info = os.lstat(path)
63
+ except FileNotFoundError:
64
+ return "absent"
65
+ if stat.S_ISLNK(info.st_mode):
66
+ return "symlink"
67
+ if stat.S_ISREG(info.st_mode):
68
+ return "file"
69
+ if stat.S_ISDIR(info.st_mode):
70
+ return "dir"
71
+ return "other"
72
+
73
+
74
+ def require_absent_or_regular(path: str) -> None:
75
+ kind = path_kind(path)
76
+ if kind == "symlink":
77
+ die(f"{path} is a symlink")
78
+ if kind == "other":
79
+ die(f"{path} is not a regular file")
80
+
81
+
82
+ def parse_env_identity(path: str) -> tuple[str, str, str]:
83
+ require_absent_or_regular(path)
84
+ data = open(path, "rb").read()
85
+ if b"\0" in data:
86
+ die(f"NUL in {path}")
87
+ try:
88
+ text = data.decode("ascii")
89
+ except UnicodeDecodeError:
90
+ die(f"non-ascii identity in {path}")
91
+ mid = hn = mac = None
92
+ for line in text.splitlines():
93
+ if line.startswith("AGENT_WECHAT_MACHINE_ID="):
94
+ if mid is not None:
95
+ die(f"duplicate AGENT_WECHAT_MACHINE_ID in {path}")
96
+ value = line[len("AGENT_WECHAT_MACHINE_ID=") :]
97
+ if not valid_machine_id(value):
98
+ die(f"invalid AGENT_WECHAT_MACHINE_ID in {path}")
99
+ mid = value
100
+ elif line.startswith("AGENT_WECHAT_HOSTNAME="):
101
+ if hn is not None:
102
+ die(f"duplicate AGENT_WECHAT_HOSTNAME in {path}")
103
+ value = line[len("AGENT_WECHAT_HOSTNAME=") :]
104
+ if not valid_hostname(value):
105
+ die(f"invalid AGENT_WECHAT_HOSTNAME in {path}")
106
+ hn = value
107
+ elif line.startswith("AGENT_WECHAT_MAC="):
108
+ if mac is not None:
109
+ die(f"duplicate AGENT_WECHAT_MAC in {path}")
110
+ value = line[len("AGENT_WECHAT_MAC=") :]
111
+ if not valid_mac(value):
112
+ die(f"invalid AGENT_WECHAT_MAC in {path}")
113
+ mac = value
114
+ else:
115
+ die(f"unexpected line in {path}")
116
+ if not (mid and hn and mac):
117
+ die(f"incomplete identity in {path}")
118
+ return mid, hn, mac
119
+
120
+
121
+ def parse_json_identity(path: str) -> tuple[str, str, str]:
122
+ require_absent_or_regular(path)
123
+ raw = open(path, "rb").read()
124
+ if b"\0" in raw:
125
+ die(f"NUL in {path}")
126
+ try:
127
+ data = json.loads(raw.decode("utf-8"))
128
+ except (UnicodeDecodeError, json.JSONDecodeError):
129
+ die(f"invalid JSON identity in {path}")
130
+ if not isinstance(data, dict):
131
+ die(f"invalid JSON identity in {path}")
132
+ mid = data.get("machineId")
133
+ hn = data.get("hostname")
134
+ mac = data.get("mac")
135
+ if not isinstance(mid, str) or not valid_machine_id(mid):
136
+ die(f"invalid machineId in {path}")
137
+ if not isinstance(hn, str) or not valid_hostname(hn):
138
+ die(f"invalid hostname in {path}")
139
+ if not isinstance(mac, str) or not valid_mac(mac):
140
+ die(f"invalid mac in {path}")
141
+ return mid, hn, mac
142
+
143
+
144
+ def derive_hostname(mid: str) -> str:
145
+ idx = int(mid[0:2], 16) % len(PREFIXES)
146
+ num = int(mid[2:6], 16) % 900 + 100
147
+ return f"{PREFIXES[idx]}-{num}"
148
+
149
+
150
+ def derive_mac(mid: str) -> str:
151
+ return f"00:1b:21:{mid[6:8]}:{mid[8:10]}:{mid[10:12]}"
152
+
153
+
154
+ def exclusive_publish(
155
+ env_file: str,
156
+ mid: str,
157
+ hn: str,
158
+ mac: str,
159
+ after_link: Optional[Callable[[str], None]] = None,
160
+ ) -> bool:
161
+ directory = os.path.dirname(env_file)
162
+ require_absent_or_regular(env_file)
163
+ fd, tmp = tempfile.mkstemp(prefix="device-identity.env.", dir=directory)
164
+ try:
165
+ os.fchmod(fd, 0o600)
166
+ payload = (
167
+ f"AGENT_WECHAT_MACHINE_ID={mid}\n"
168
+ f"AGENT_WECHAT_HOSTNAME={hn}\n"
169
+ f"AGENT_WECHAT_MAC={mac}\n"
170
+ ).encode("ascii")
171
+ os.write(fd, payload)
172
+ os.fsync(fd)
173
+ finally:
174
+ os.close(fd)
175
+ try:
176
+ os.link(tmp, env_file)
177
+ except FileExistsError:
178
+ try:
179
+ os.unlink(tmp)
180
+ except FileNotFoundError:
181
+ pass
182
+ return False
183
+ if after_link is not None:
184
+ after_link(tmp)
185
+ try:
186
+ os.unlink(tmp)
187
+ except FileNotFoundError:
188
+ winner = parse_env_identity(env_file)
189
+ if winner != (mid, hn, mac):
190
+ raise
191
+ os.chmod(env_file, 0o600)
192
+ return True
193
+
194
+
195
+ def harden_regular(path: str) -> None:
196
+ require_absent_or_regular(path)
197
+ flags = os.O_RDONLY
198
+ if hasattr(os, "O_NOFOLLOW"):
199
+ flags |= os.O_NOFOLLOW
200
+ fd = os.open(path, flags)
201
+ try:
202
+ info = os.fstat(fd)
203
+ if info.st_nlink > 1:
204
+ cleanup_owned_temp_links(path, info)
205
+ info = os.fstat(fd)
206
+ if info.st_nlink != 1:
207
+ die(f"{path} has unexpected link count {info.st_nlink}")
208
+ if info.st_uid != os.getuid() and os.getuid() != 0:
209
+ die(f"{path} is not owned by the current user")
210
+ os.fchmod(fd, 0o600)
211
+ finally:
212
+ os.close(fd)
213
+
214
+
215
+ def cleanup_owned_temp_links(path: str, target_info: os.stat_result) -> None:
216
+ directory = os.path.dirname(path)
217
+ base = os.path.basename(path)
218
+ dev, ino = target_info.st_dev, target_info.st_ino
219
+ progressed = True
220
+ while progressed:
221
+ progressed = False
222
+ for name in os.listdir(directory):
223
+ if name == base or not ENV_TEMP_RE.fullmatch(name):
224
+ continue
225
+ candidate = os.path.join(directory, name)
226
+ try:
227
+ info = os.lstat(candidate)
228
+ except FileNotFoundError:
229
+ continue
230
+ if stat.S_ISREG(info.st_mode) and info.st_dev == dev and info.st_ino == ino:
231
+ os.unlink(candidate)
232
+ progressed = True
233
+
234
+
235
+ def acquire_lock(lock_path: str) -> int:
236
+ require_absent_or_regular(lock_path)
237
+ flags = os.O_RDWR | os.O_CREAT
238
+ if hasattr(os, "O_NOFOLLOW"):
239
+ flags |= os.O_NOFOLLOW
240
+ try:
241
+ fd = os.open(lock_path, flags, 0o600)
242
+ except OSError as exc:
243
+ die(f"cannot open lock {lock_path}: {exc}")
244
+ try:
245
+ info = os.fstat(fd)
246
+ if not stat.S_ISREG(info.st_mode):
247
+ die(f"{lock_path} is not a regular file")
248
+ fcntl.flock(fd, fcntl.LOCK_EX)
249
+ except Exception:
250
+ os.close(fd)
251
+ raise
252
+ return fd
253
+
254
+
255
+ def prepare_ident_dir(ident_dir: str) -> None:
256
+ kind = path_kind(ident_dir)
257
+ if kind == "symlink":
258
+ die(f"{ident_dir} is a symlink")
259
+ if kind == "file":
260
+ die(f"{ident_dir} is not a directory")
261
+ if kind == "absent":
262
+ os.makedirs(ident_dir, mode=0o700, exist_ok=True)
263
+ elif kind != "dir":
264
+ die(f"{ident_dir} is not a directory")
265
+ try:
266
+ os.chmod(ident_dir, 0o700)
267
+ except OSError:
268
+ pass
269
+
270
+
271
+ def load_or_create(ident_dir: str) -> tuple[str, str, str]:
272
+ prepare_ident_dir(ident_dir)
273
+ env_file = os.path.join(ident_dir, ENV_NAME)
274
+ json_file = os.path.join(ident_dir, JSON_NAME)
275
+ require_absent_or_regular(env_file)
276
+ require_absent_or_regular(json_file)
277
+
278
+ env_id = parse_env_identity(env_file) if path_kind(env_file) == "file" else None
279
+ json_id = parse_json_identity(json_file) if path_kind(json_file) == "file" else None
280
+ if env_id and json_id and env_id != json_id:
281
+ die(f"conflicting {ENV_NAME} and {JSON_NAME} in {ident_dir}")
282
+ if env_id:
283
+ harden_regular(env_file)
284
+ return env_id
285
+ if json_id:
286
+ if exclusive_publish(env_file, *json_id):
287
+ return parse_env_identity(env_file)
288
+ env_id = parse_env_identity(env_file)
289
+ if env_id != json_id:
290
+ die(f"conflicting {ENV_NAME} and {JSON_NAME} in {ident_dir}")
291
+ return env_id
292
+
293
+ mid = hn = mac = None
294
+ if os.environ.get("AGENT_WECHAT_IDENTITY_FROM_ENV") == "1":
295
+ mid = validate_override(
296
+ "AGENT_WECHAT_MACHINE_ID",
297
+ os.environ.get("AGENT_WECHAT_MACHINE_ID"),
298
+ valid_machine_id,
299
+ )
300
+ hn = validate_override(
301
+ "AGENT_WECHAT_HOSTNAME",
302
+ os.environ.get("AGENT_WECHAT_HOSTNAME"),
303
+ valid_hostname,
304
+ )
305
+ mac = validate_override(
306
+ "AGENT_WECHAT_MAC",
307
+ os.environ.get("AGENT_WECHAT_MAC"),
308
+ valid_mac,
309
+ )
310
+ if mid is None:
311
+ mid = uuid.uuid4().hex
312
+ if not valid_machine_id(mid):
313
+ die("generated machine-id is invalid")
314
+ if hn is None:
315
+ hn = derive_hostname(mid)
316
+ if mac is None:
317
+ mac = derive_mac(mid)
318
+ if not valid_hostname(hn):
319
+ die("generated hostname is invalid")
320
+ if not valid_mac(mac):
321
+ die("generated MAC is invalid")
322
+ if exclusive_publish(env_file, mid, hn, mac):
323
+ return parse_env_identity(env_file)
324
+ return parse_env_identity(env_file)
325
+
326
+
327
+ def main() -> None:
328
+ ident_dir = (
329
+ sys.argv[1]
330
+ if len(sys.argv) > 1
331
+ else os.environ.get("AGENT_WECHAT_IDENTITY_DIR")
332
+ or os.path.join(os.path.expanduser("~"), ".config", "agent-wechat")
333
+ )
334
+ prepare_ident_dir(ident_dir)
335
+ lock_path = os.path.join(ident_dir, LOCK_NAME)
336
+ lock_fd = acquire_lock(lock_path)
337
+ try:
338
+ hold_ms = int(os.environ.get("AGENT_WECHAT_IDENTITY_HOLD_MS") or "0")
339
+ except ValueError:
340
+ hold_ms = 0
341
+ try:
342
+ mid, hn, mac = load_or_create(ident_dir)
343
+ if hold_ms > 0:
344
+ time.sleep(hold_ms / 1000.0)
345
+ finally:
346
+ os.close(lock_fd)
347
+ print(f"export AGENT_WECHAT_MACHINE_ID={shlex.quote(mid)}")
348
+ print(f"export AGENT_WECHAT_HOSTNAME={shlex.quote(hn)}")
349
+ print(f"export AGENT_WECHAT_MAC={shlex.quote(mac)}")
350
+
351
+
352
+ if __name__ == "__main__":
353
+ main()
@@ -0,0 +1,12 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "cliVersion": "0.12.0-next.0",
4
+ "repository": "ghcr.io/kyan-du/agent-wechat",
5
+ "apiVersion": 1,
6
+ "allowedReferences": [
7
+ "local-build",
8
+ "exact-semver-tag",
9
+ "sha256-digest"
10
+ ],
11
+ "floatingTagsAllowed": false
12
+ }
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@kyan-du/agent-wechat-cli",
3
+ "version": "0.12.0-next.0",
4
+ "type": "module",
5
+ "bin": {
6
+ "wx": "./dist/cli.js"
7
+ },
8
+ "files": [
9
+ "dist",
10
+ "!dist/.release-metafile.json"
11
+ ],
12
+ "scripts": {
13
+ "build": "node build.js",
14
+ "typecheck": "tsc --noEmit",
15
+ "test": "node --test --experimental-strip-types src/*.test.ts"
16
+ },
17
+ "dependencies": {
18
+ "qrcode-terminal": "^0.12.0"
19
+ },
20
+ "devDependencies": {
21
+ "@kyan-du/agent-wechat-shared": "workspace:*",
22
+ "commander": "^14.0.3",
23
+ "esbuild": "^0.25.0",
24
+ "@types/node": "^22",
25
+ "@types/qrcode-terminal": "^0.12.2",
26
+ "typescript": "^5.4.5"
27
+ },
28
+ "publishConfig": {
29
+ "access": "public",
30
+ "provenance": true
31
+ },
32
+ "repository": {
33
+ "type": "git",
34
+ "url": "git+https://github.com/kyan-du/agent-wechat.git",
35
+ "directory": "packages/cli"
36
+ }
37
+ }