code-server-sync 0.0.0-stage → 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/cs_sync/cli.py ADDED
@@ -0,0 +1,650 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import os
6
+ import posixpath
7
+ import urllib.error
8
+ import urllib.parse
9
+ import urllib.request
10
+
11
+ from cs_sync.client import Client, check_ready, explain_http, origin_of, ssl_context
12
+ from cs_sync.config import (
13
+ compose,
14
+ ensure_config,
15
+ load_host,
16
+ load_json,
17
+ next_port,
18
+ project_filename,
19
+ project_files,
20
+ project_paths,
21
+ projects_dir,
22
+ save_config,
23
+ save_host,
24
+ valid_name,
25
+ )
26
+ from cs_sync.files import file_mode, remove_file, write_file
27
+ from cs_sync.git import StageError, collect_changes, stage_changes, transfer_line_changes
28
+ from cs_sync.ignore import DEFAULT_IGNORE_DIRS, ignore_dirs, load_path_ignore, should_skip
29
+ from cs_sync.protocol import DEFAULT_PORT, eprint
30
+ from cs_sync.server import serve
31
+ from cs_sync.sync import (
32
+ copy_cloud_to_local,
33
+ copy_local_to_cloud,
34
+ is_dry_run,
35
+ load_remote_base,
36
+ preview_copy_cloud_to_local,
37
+ preview_copy_local_to_cloud,
38
+ preview_line_changes,
39
+ preview_sync_divergence,
40
+ print_dry_run_banner,
41
+ watch,
42
+ )
43
+
44
+ def command_text(config: dict, command: str) -> str:
45
+ text = "cs-sync %s" % command
46
+ if config.get("name"):
47
+ text += " --name %s" % config["name"]
48
+ return text
49
+
50
+
51
+ def shell_quote(value: str) -> str:
52
+ return "'" + str(value).replace("'", "'\"'\"'") + "'"
53
+
54
+
55
+ def normalize_remote_path(path: str) -> str:
56
+ text = str(path or "").strip().replace("\\", "/")
57
+ if not text.startswith("/") or text.startswith("//"):
58
+ raise SystemExit("云端路径必须是绝对路径。")
59
+ text = posixpath.normpath(text)
60
+ if not text.startswith("/"):
61
+ raise SystemExit("云端路径必须是绝对路径。")
62
+ return text
63
+
64
+
65
+ def recv_command(config: dict) -> str:
66
+ return "cs-sync recv --root %s --token %s --port %d" % (
67
+ shell_quote(config["remoteRoot"]),
68
+ shell_quote(config["token"]),
69
+ int(config["port"]),
70
+ )
71
+
72
+
73
+ def ensure_host_base(base: str = "") -> dict:
74
+ host = load_host()
75
+ if base:
76
+ parts = urllib.parse.urlsplit(base)
77
+ if parts.scheme not in ("http", "https") or not parts.netloc:
78
+ raise SystemExit("--base 需要写成 https://主机:端口")
79
+ host["baseUrl"] = urllib.parse.urlunsplit((parts.scheme, parts.netloc, "", "", ""))
80
+ host["insecure"] = host.get("insecure", parts.scheme == "https")
81
+ save_host(host)
82
+ return host
83
+ if host.get("baseUrl"):
84
+ return host
85
+ for path in project_files().values():
86
+ endpoint = str(load_json(path).get("endpoint") or "")
87
+ if not endpoint:
88
+ continue
89
+ host["baseUrl"] = origin_of(endpoint)
90
+ host["insecure"] = endpoint.startswith("https://")
91
+ save_host(host)
92
+ return host
93
+ raise SystemExit("还没有 code-server 地址。加上 --base https://主机:端口")
94
+
95
+
96
+ def selected(args: argparse.Namespace) -> dict:
97
+ return ensure_config(getattr(args, "name", "") or "", getattr(args, "config", "") or "")
98
+
99
+
100
+ def cmd_init(args: argparse.Namespace) -> None:
101
+ import secrets
102
+
103
+ if not args.local or not args.remote:
104
+ raise SystemExit("需要 --local 本地目录 和 --remote 云端绝对路径")
105
+ local = os.path.abspath(os.path.expanduser(args.local))
106
+ if not os.path.isdir(local):
107
+ raise SystemExit("本地目录不存在: %s" % local)
108
+ remote = normalize_remote_path(args.remote)
109
+ name = args.name or os.path.basename(local.rstrip(os.sep))
110
+ if not valid_name(name):
111
+ raise SystemExit("项目名只能包含字母、数字、点、下划线和中划线,并用 --name 指定")
112
+ files = project_files()
113
+ if name in files and not args.force:
114
+ raise SystemExit("项目 %s 已存在。换一个 --name,或加上 --force 覆盖" % name)
115
+ ensure_host_base(args.base or "")
116
+ port = args.port or next_port()
117
+ project = {
118
+ "name": name,
119
+ "localRoot": local,
120
+ "remoteRoot": remote,
121
+ "port": port,
122
+ "token": secrets.token_urlsafe(32),
123
+ "ignoreDirs": sorted(DEFAULT_IGNORE_DIRS),
124
+ }
125
+ path = os.path.join(projects_dir(), project_filename(name))
126
+ save_config(path, project)
127
+ if args.force:
128
+ for old in project_paths().get(name, []):
129
+ if os.path.abspath(old) != os.path.abspath(path):
130
+ os.remove(old)
131
+ config = compose(load_host(), project)
132
+ print("已添加项目 %s" % name)
133
+ print("本地: %s" % local)
134
+ print("云端: %s" % remote)
135
+ print("在云主机的 code-server 终端执行:")
136
+ print(recv_command(config))
137
+
138
+
139
+ def cmd_remove(args: argparse.Namespace) -> None:
140
+ name = args.name
141
+ if not name:
142
+ raise SystemExit("请用 --name 指定要删除的项目。先运行 list 查看名称。")
143
+ paths = project_paths()
144
+ if name not in paths:
145
+ known = "、".join(sorted(paths)) or "(无)"
146
+ raise SystemExit("没有名为 %s 的项目。已有: %s" % (name, known))
147
+ for path in paths[name]:
148
+ os.remove(path)
149
+ print("已删除映射 %s" % path)
150
+ print("项目 %s 已从列表移除。本地目录和云端代码都还在。" % name)
151
+
152
+
153
+ def cmd_list(_args: argparse.Namespace) -> None:
154
+ files = project_files()
155
+ if not files:
156
+ print("还没有项目。")
157
+ return
158
+ host = load_host()
159
+ base = host.get("baseUrl") or ""
160
+ if not base:
161
+ for path in files.values():
162
+ endpoint = str(load_json(path).get("endpoint") or "")
163
+ if endpoint:
164
+ base = origin_of(endpoint)
165
+ break
166
+ print("code-server: %s" % (base or "(未设置)"))
167
+ print("登录: %s" % ("这台 code-server 不校验登录" if host.get("auth") == "none" else ("已保存" if host.get("cookie") else "未登录")))
168
+ for name in sorted(files):
169
+ config = compose(host, load_json(files[name]))
170
+ print("%s 端口 %s 本地 %s 云端 %s" % (name, config.get("port"), config.get("localRoot"), config.get("remoteRoot")))
171
+
172
+
173
+ def cmd_token(args: argparse.Namespace) -> None:
174
+ config = selected(args)
175
+ if not config.get("token"):
176
+ raise SystemExit("token 为空")
177
+ print(recv_command(config))
178
+
179
+
180
+ def cmd_login(args: argparse.Namespace) -> None:
181
+ import getpass
182
+ import http.cookiejar
183
+
184
+ host = ensure_host_base(getattr(args, "base", "") or "")
185
+ password = str(host.get("password") or "")
186
+ if password:
187
+ print("使用已保存的密码登录。")
188
+ else:
189
+ password = getpass.getpass("code-server 登录密码: ")
190
+ origin = host["baseUrl"].rstrip("/")
191
+ data = urllib.parse.urlencode({"password": password, "base": ""}).encode()
192
+ request = urllib.request.Request(origin + "/login", data=data, method="POST")
193
+ jar = http.cookiejar.CookieJar()
194
+ context = ssl_context(bool(host.get("insecure", True)))
195
+ handlers = [
196
+ urllib.request.HTTPCookieProcessor(jar),
197
+ urllib.request.HTTPSHandler(context=context),
198
+ ]
199
+ opener = urllib.request.build_opener(*handlers)
200
+ try:
201
+ with opener.open(request, timeout=30) as response:
202
+ body = response.read()
203
+ final_status = response.status
204
+ except urllib.error.HTTPError as exc:
205
+ body = exc.read()
206
+ final_status = exc.code
207
+ except urllib.error.URLError as exc:
208
+ raise SystemExit("连不上 code-server:%s" % exc.reason)
209
+ session = ""
210
+ for cookie in jar:
211
+ if cookie.name == "code-server-session":
212
+ session = "code-server-session=%s" % cookie.value
213
+ break
214
+ if session:
215
+ host["cookie"] = session
216
+ host["auth"] = "password"
217
+ save_host(host)
218
+ print("登录凭证已保存,之后每个项目共用。")
219
+ return
220
+ try:
221
+ with urllib.request.urlopen(urllib.request.Request(origin + "/"), context=context, timeout=30) as response:
222
+ page = response.read(4000)
223
+ open_status = response.status
224
+ except urllib.error.URLError as exc:
225
+ raise SystemExit("连不上 code-server:%s" % exc.reason)
226
+ if open_status == 200 and b"vscode-workbench-web-configuration" in page:
227
+ host["auth"] = "none"
228
+ host["cookie"] = ""
229
+ save_host(host)
230
+ print("这台 code-server 不校验登录,直接运行 doctor 或 sync 即可。")
231
+ return
232
+ preview = body.decode("utf-8", errors="replace")[:160].replace("\n", " ")
233
+ raise SystemExit("登录没有拿到 code-server-session(状态 %d)。%s" % (final_status, preview))
234
+
235
+
236
+ def cmd_cookie(args: argparse.Namespace) -> None:
237
+ host = ensure_host_base(getattr(args, "base", "") or "")
238
+ value = args.value.strip()
239
+ if not value.startswith("code-server-session="):
240
+ value = "code-server-session=" + value
241
+ host["cookie"] = value
242
+ save_host(host)
243
+ print("登录凭证已保存,之后每个项目共用。")
244
+
245
+
246
+ def cmd_doctor(args: argparse.Namespace) -> None:
247
+ config = selected(args)
248
+ client = Client(config)
249
+ label = config.get("name") or config.get("localRoot")
250
+ print("项目: %s" % label)
251
+ print("端点: %s" % client.endpoint)
252
+ print("cookie: %s" % ("已填写" if client.cookie else "空"))
253
+ check_ready(client)
254
+ print("通道和令牌都可用。")
255
+
256
+
257
+ def report_copy(action: str, done: int, skipped: int, failed: int, leftover: str) -> None:
258
+ print("完成:%s %d 个,相同跳过 %d 个。" % (action, done, skipped), flush=True)
259
+ print(leftover, flush=True)
260
+ if failed:
261
+ raise SystemExit("有 %d 个文件没有同步过去。" % failed)
262
+
263
+
264
+ def stage_local_side(local_root: str, ignored: set) -> None:
265
+ try:
266
+ stage_changes(local_root, ignored)
267
+ except StageError as exc:
268
+ raise SystemExit(str(exc))
269
+
270
+
271
+ def stage_cloud_side(client: Client, config: dict, command: str) -> None:
272
+ stage_status, stage_info = client.stage()
273
+ if stage_status == 404:
274
+ raise SystemExit("云端接收程序不支持暂存。请在云端升级 code-server-sync 并重启 cs-sync recv,然后再运行 %s。" % command_text(config, command))
275
+ if stage_status != 200 or not stage_info:
276
+ raise SystemExit("云端暂存失败:%s" % explain_http(stage_status, b""))
277
+ staged = [str(item) for item in stage_info.get("staged") or []]
278
+ if not stage_info.get("repos"):
279
+ print("云端项目里没有 git 仓库,跳过暂存。", flush=True)
280
+ elif staged:
281
+ for rel in staged:
282
+ print("云端暂存 %s" % rel, flush=True)
283
+ else:
284
+ print("云端没有需要暂存的修改。", flush=True)
285
+
286
+
287
+ def cmd_to_cloud(args: argparse.Namespace) -> None:
288
+ config = selected(args)
289
+ label = config.get("name") or config.get("localRoot")
290
+ dry = is_dry_run(args)
291
+ if dry:
292
+ print_dry_run_banner("to-cloud")
293
+ print("把本地内容同步到云主机:%s" % label, flush=True)
294
+ local_root = config["localRoot"]
295
+ if not os.path.isdir(local_root):
296
+ raise SystemExit("本地目录不存在: %s" % local_root)
297
+ client = Client(config)
298
+ check_ready(client)
299
+ ignored = ignore_dirs(config)
300
+ path_ignore = load_path_ignore(local_root)
301
+ print("已连上 %s" % client.endpoint, flush=True)
302
+ if dry:
303
+ ignore_status = 200
304
+ print("演练模式跳过:推送忽略规则、云端暂存。", flush=True)
305
+ else:
306
+ ignore_status, _ignore_body = client.push_ignore(ignored)
307
+ stage_cloud_side(client, config, "to-cloud")
308
+ remote, enabled = load_remote_base(client, ignored, ignore_status, path_ignore)
309
+ if not enabled:
310
+ print("云端还是旧的接收程序,将把全部未忽略文件同步到云端。", flush=True)
311
+ if dry:
312
+ uploaded, skipped, failed = preview_copy_local_to_cloud(local_root, ignored, remote if enabled else None)
313
+ if failed:
314
+ raise SystemExit("演练发现 %d 个无法处理的文件。" % failed)
315
+ return
316
+ uploaded, skipped, failed = copy_local_to_cloud(client, local_root, ignored, remote if enabled else None)
317
+ report_copy("本地改动", uploaded, skipped, failed, "只在云端、本地没有的文件保持原样。")
318
+
319
+
320
+ def cmd_to_local(args: argparse.Namespace) -> None:
321
+ config = selected(args)
322
+ label = config.get("name") or config.get("localRoot")
323
+ dry = is_dry_run(args)
324
+ if dry:
325
+ print_dry_run_banner("to-local")
326
+ print("把云主机内容同步到本地:%s" % label, flush=True)
327
+ local_root = config["localRoot"]
328
+ if not os.path.isdir(local_root):
329
+ raise SystemExit("本地目录不存在: %s" % local_root)
330
+ client = Client(config)
331
+ check_ready(client)
332
+ ignored = ignore_dirs(config)
333
+ path_ignore = load_path_ignore(local_root)
334
+ print("已连上 %s" % client.endpoint, flush=True)
335
+ if dry:
336
+ ignore_status = 200
337
+ print("演练模式跳过:推送忽略规则、本地暂存。", flush=True)
338
+ else:
339
+ ignore_status, _ignore_body = client.push_ignore(ignored)
340
+ remote, enabled = load_remote_base(client, ignored, ignore_status, path_ignore)
341
+ if not enabled:
342
+ raise SystemExit("云端接收程序不支持拉取。请在云端升级 code-server-sync 并重启 cs-sync recv,然后再运行 %s。" % command_text(config, "to-local"))
343
+ if dry:
344
+ downloaded, skipped, failed = preview_copy_cloud_to_local(local_root, remote)
345
+ if failed:
346
+ raise SystemExit("演练发现 %d 个无法处理的文件。" % failed)
347
+ return
348
+ stage_local_side(local_root, ignored)
349
+ downloaded, skipped, failed = copy_cloud_to_local(client, local_root, remote)
350
+ report_copy("本地写入", downloaded, skipped, failed, "只在本地、云端没有的文件保持原样。")
351
+
352
+
353
+ def report_lines(applied: int, failed: int) -> None:
354
+ print("完成:已同步 %d 处 git diff,未应用 %d 处。" % (applied, failed), flush=True)
355
+ if failed:
356
+ raise SystemExit("有 %d 处 git diff 没有同步过去。" % failed)
357
+
358
+
359
+ def local_change_reader(local_root: str):
360
+ def read_dest(rel: str):
361
+ full = os.path.join(local_root, *rel.split("/"))
362
+ if os.path.islink(full) or not os.path.isfile(full):
363
+ return False, None, "644"
364
+ with open(full, "rb") as handle:
365
+ return True, handle.read(), file_mode(full)
366
+ return read_dest
367
+
368
+
369
+ def local_change_writer(local_root: str):
370
+ def write_dest(rel: str, payload: bytes, mode: str) -> bool:
371
+ try:
372
+ write_file(local_root, rel, payload, 0o755 if mode == "755" else 0o644)
373
+ except (ValueError, OSError) as exc:
374
+ eprint("本地写入失败 %s :%s" % (rel, exc))
375
+ return False
376
+ return True
377
+ return write_dest
378
+
379
+
380
+ def local_change_deleter(local_root: str):
381
+ def delete_dest(rel: str) -> bool:
382
+ try:
383
+ remove_file(local_root, rel)
384
+ except (ValueError, OSError) as exc:
385
+ eprint("本地写入(删除)失败 %s :%s" % (rel, exc))
386
+ return False
387
+ return True
388
+ return delete_dest
389
+
390
+
391
+ def cloud_change_reader(client: Client):
392
+ def read_dest(rel: str):
393
+ status, payload, mode = client.pull_file(rel)
394
+ if status != 200:
395
+ return False, None, "644"
396
+ return True, payload, "755" if mode == 0o755 else "644"
397
+ return read_dest
398
+
399
+
400
+ def cloud_change_writer(client: Client):
401
+ def write_dest(rel: str, payload: bytes, mode: str) -> bool:
402
+ status, body = client.put_file(rel, payload, mode)
403
+ if status != 200 or body.strip() != b"ok":
404
+ eprint("未同步到云端 %s :%s" % (rel, explain_http(status, body)))
405
+ return False
406
+ return True
407
+ return write_dest
408
+
409
+
410
+ def cloud_change_deleter(client: Client):
411
+ def delete_dest(rel: str) -> bool:
412
+ status, body = client.delete_file(rel)
413
+ if status != 200 or body.strip() != b"ok":
414
+ eprint("未同步到云端(删除) %s :%s" % (rel, explain_http(status, body)))
415
+ return False
416
+ return True
417
+ return delete_dest
418
+
419
+
420
+ def cmd_diff_to_cloud(args: argparse.Namespace) -> None:
421
+ config = selected(args)
422
+ label = config.get("name") or config.get("localRoot")
423
+ dry = is_dry_run(args)
424
+ if dry:
425
+ print_dry_run_banner("diff-to-cloud")
426
+ print("把本次 git diff 同步到云主机:%s" % label, flush=True)
427
+ local_root = config["localRoot"]
428
+ if not os.path.isdir(local_root):
429
+ raise SystemExit("本地目录不存在: %s" % local_root)
430
+ client = Client(config)
431
+ check_ready(client)
432
+ ignored = ignore_dirs(config)
433
+ path_ignore = load_path_ignore(local_root)
434
+ print("已连上 %s" % client.endpoint, flush=True)
435
+ if dry:
436
+ print("演练模式跳过:推送忽略规则、云端暂存。", flush=True)
437
+ else:
438
+ client.push_ignore(ignored)
439
+ stage_cloud_side(client, config, "diff-to-cloud")
440
+ try:
441
+ changes, repo_count = collect_changes(local_root, ignored)
442
+ except StageError as exc:
443
+ raise SystemExit(str(exc))
444
+ if repo_count == 0:
445
+ print("项目里没有 git 仓库,没有可同步的 git diff。", flush=True)
446
+ return
447
+ if not changes:
448
+ print("没有需要同步的 git diff。", flush=True)
449
+ return
450
+ if dry:
451
+ would, failed = preview_line_changes(changes, cloud_change_reader(client))
452
+ if failed:
453
+ raise SystemExit("演练发现 %d 处 git diff 不能直接应用。" % failed)
454
+ print("去掉 --dry-run 才会真正同步这 %d 处。" % would, flush=True)
455
+ return
456
+ applied, failed = transfer_line_changes(
457
+ changes,
458
+ cloud_change_reader(client),
459
+ cloud_change_writer(client),
460
+ cloud_change_deleter(client),
461
+ success_verb="本地改动",
462
+ )
463
+ report_lines(applied, failed)
464
+
465
+
466
+ def cmd_diff_to_local(args: argparse.Namespace) -> None:
467
+ config = selected(args)
468
+ label = config.get("name") or config.get("localRoot")
469
+ dry = is_dry_run(args)
470
+ if dry:
471
+ print_dry_run_banner("diff-to-local")
472
+ print("把云端本次 git diff 同步到本地:%s" % label, flush=True)
473
+ local_root = config["localRoot"]
474
+ if not os.path.isdir(local_root):
475
+ raise SystemExit("本地目录不存在: %s" % local_root)
476
+ client = Client(config)
477
+ check_ready(client)
478
+ ignored = ignore_dirs(config)
479
+ path_ignore = load_path_ignore(local_root)
480
+ print("已连上 %s" % client.endpoint, flush=True)
481
+ if dry:
482
+ print("演练模式跳过:推送忽略规则、本地暂存。", flush=True)
483
+ else:
484
+ client.push_ignore(ignored)
485
+ status, info = client.changes()
486
+ if status == 404:
487
+ raise SystemExit("云端接收程序不支持读取 git diff。请在云端升级 code-server-sync 并重启 cs-sync recv,然后再运行 %s。" % command_text(config, "diff-to-local"))
488
+ if status != 200 or not info:
489
+ raise SystemExit("读取云端 git diff 失败:%s" % explain_http(status, b""))
490
+ if not dry:
491
+ stage_local_side(local_root, ignored)
492
+ if not info.get("repos"):
493
+ print("云端项目里没有 git 仓库,没有可同步的 git diff。", flush=True)
494
+ return
495
+ changes = info.get("changes") or []
496
+ if not changes:
497
+ print("云端没有需要同步的 git diff。", flush=True)
498
+ return
499
+ try:
500
+ filtered = [change for change in changes if not should_skip(str(change.get("path") or ""), ignored, path_ignore)]
501
+ except TypeError:
502
+ filtered = list(changes)
503
+ if dry:
504
+ would, failed = preview_line_changes(filtered, local_change_reader(local_root))
505
+ if failed:
506
+ raise SystemExit("演练发现 %d 处 git diff 不能直接应用。" % failed)
507
+ print("去掉 --dry-run 才会真正同步这 %d 处。" % would, flush=True)
508
+ return
509
+ applied, failed = transfer_line_changes(
510
+ filtered,
511
+ local_change_reader(local_root),
512
+ local_change_writer(local_root),
513
+ local_change_deleter(local_root),
514
+ success_verb="本地写入",
515
+ )
516
+ report_lines(applied, failed)
517
+
518
+
519
+ def cmd_sync(args: argparse.Namespace) -> None:
520
+ config = selected(args)
521
+ label = config.get("name") or config.get("localRoot")
522
+ dry = is_dry_run(args)
523
+ if dry:
524
+ print_dry_run_banner("sync")
525
+ print("同步项目 %s(仅预览当前差异,不进入监视)" % label, flush=True)
526
+ local_root = config["localRoot"]
527
+ if not os.path.isdir(local_root):
528
+ raise SystemExit("本地目录不存在: %s" % local_root)
529
+ client = Client(config)
530
+ check_ready(client)
531
+ ignored = ignore_dirs(config)
532
+ path_ignore = load_path_ignore(local_root)
533
+ print("已连上 %s" % client.endpoint, flush=True)
534
+ print("演练模式跳过:推送忽略规则。", flush=True)
535
+ remote, pull_enabled = load_remote_base(client, ignored, 200, path_ignore)
536
+ if not pull_enabled:
537
+ print("云端还是旧的接收程序,差异预览只能看到本地侧扫描结果与有限清单。", flush=True)
538
+ remote = {}
539
+ preview_sync_divergence(local_root, ignored, remote, pull_enabled)
540
+ return
541
+ print("同步项目 %s" % label, flush=True)
542
+ try:
543
+ watch(config)
544
+ except KeyboardInterrupt:
545
+ print("\n已停止监视")
546
+
547
+
548
+ def cmd_recv(args: argparse.Namespace) -> None:
549
+ if not args.root or not args.token:
550
+ raise SystemExit("用法: cs-sync recv --root 云端绝对路径 --token 令牌 [--port %d]" % DEFAULT_PORT)
551
+ serve(args.root, args.token, args.port or DEFAULT_PORT)
552
+
553
+
554
+ def cmd_self_test_entry(_args) -> None:
555
+ from cs_sync.self_test import cmd_self_test
556
+ cmd_self_test(_args)
557
+
558
+
559
+ def package_version() -> str:
560
+ path = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "package.json")
561
+ with open(path, encoding="utf-8") as handle:
562
+ return json.load(handle)["version"]
563
+
564
+
565
+ def build_parser() -> argparse.ArgumentParser:
566
+ parser = argparse.ArgumentParser(
567
+ prog="cs-sync",
568
+ description="经 code-server 已登录通道,在本机与云主机之间同步项目代码",
569
+ )
570
+ parser.add_argument("--version", action="version", version="cs-sync %s" % package_version())
571
+ parser.add_argument("--config", default="", help="直接指定某个项目配置文件")
572
+ sub = parser.add_subparsers(dest="command", required=True)
573
+
574
+ init = sub.add_parser("init", help="登记一个项目")
575
+ init.add_argument("--name", default="", help="项目简称;省略时用本地目录名")
576
+ init.add_argument("--local", required=True, help="本机项目目录")
577
+ init.add_argument("--remote", required=True, help="云主机上的绝对路径")
578
+ init.add_argument("--base", default="", help="code-server 地址,例如 https://10.248.148.67:18888")
579
+ init.add_argument("--port", type=int, default=0, help="云端接收端口,省略时自动递增")
580
+ init.add_argument("--force", action="store_true", help="覆盖同名项目")
581
+ init.set_defaults(func=cmd_init)
582
+
583
+ listing = sub.add_parser("list", help="列出已登记的项目")
584
+ listing.set_defaults(func=cmd_list)
585
+
586
+ remove = sub.add_parser("remove", help="删除一个项目映射,不删除代码")
587
+ remove.add_argument("--name", required=True, help="要删除的项目简称")
588
+ remove.set_defaults(func=cmd_remove)
589
+
590
+ token = sub.add_parser("token", help="打印这个项目在云端的启动命令")
591
+ token.add_argument("--name", default="")
592
+ token.set_defaults(func=cmd_token)
593
+
594
+ login = sub.add_parser("login", help="用 code-server 密码登录,所有项目共用")
595
+ login.add_argument("--base", default="")
596
+ login.set_defaults(func=cmd_login)
597
+
598
+ cookie = sub.add_parser("cookie", help="写入浏览器里的 code-server-session,所有项目共用")
599
+ cookie.add_argument("value")
600
+ cookie.add_argument("--base", default="")
601
+ cookie.set_defaults(func=cmd_cookie)
602
+
603
+ doctor = sub.add_parser("doctor", help="检查某个项目的代理和接收程序")
604
+ doctor.add_argument("--name", default="")
605
+ doctor.set_defaults(func=cmd_doctor)
606
+
607
+ to_cloud = sub.add_parser("to-cloud", help="先暂存云端已有修改,再把本地内容同步到云主机,覆盖云端同名文件")
608
+ to_cloud.add_argument("--name", default="", help="项目简称;在该项目目录内可省略")
609
+ to_cloud.add_argument("--dry-run", action="store_true", help="只列出将本地改动/跳过的文件,不暂存、不写入")
610
+ to_cloud.set_defaults(func=cmd_to_cloud)
611
+
612
+ to_local = sub.add_parser("to-local", help="先暂存本地已有修改,再把云主机内容同步到本地,覆盖本地同名文件")
613
+ to_local.add_argument("--name", default="", help="项目简称;在该项目目录内可省略")
614
+ to_local.add_argument("--dry-run", action="store_true", help="只列出将本地写入/跳过的文件,不暂存、不写入")
615
+ to_local.set_defaults(func=cmd_to_local)
616
+
617
+ diff_to_cloud = sub.add_parser("diff-to-cloud", help="先暂存云端已有修改,再只把本地本次 git diff 同步到云主机")
618
+ diff_to_cloud.add_argument("--name", default="", help="项目简称;在该项目目录内可省略")
619
+ diff_to_cloud.add_argument("--dry-run", action="store_true", help="只列出将应用的 git diff,不暂存、不写入")
620
+ diff_to_cloud.set_defaults(func=cmd_diff_to_cloud)
621
+
622
+ diff_to_local = sub.add_parser("diff-to-local", help="先暂存本地已有修改,再只把云端本次 git diff 同步到本地")
623
+ diff_to_local.add_argument("--name", default="", help="项目简称;在该项目目录内可省略")
624
+ diff_to_local.add_argument("--dry-run", action="store_true", help="只列出将应用的 git diff,不暂存、不写入")
625
+ diff_to_local.set_defaults(func=cmd_diff_to_local)
626
+
627
+ sync_cmd = sub.add_parser("sync", help="双向监视:启动后的保存按 git 行级 diff 同步")
628
+ sync_cmd.add_argument("--name", default="", help="项目简称;在该项目目录内可省略")
629
+ sync_cmd.add_argument("--dry-run", action="store_true", help="只预览当前本地/云端差异,不进入监视、不写入")
630
+ sync_cmd.set_defaults(func=cmd_sync)
631
+
632
+ recv = sub.add_parser("recv", help="在云主机上接收文件,监听 0.0.0.0")
633
+ recv.add_argument("--root", default="")
634
+ recv.add_argument("--token", default="")
635
+ recv.add_argument("--port", type=int, default=0)
636
+ recv.set_defaults(func=cmd_recv)
637
+
638
+ test = sub.add_parser("self-test", help="在本机临时目录跑一遍协议")
639
+ test.set_defaults(func=cmd_self_test_entry)
640
+ return parser
641
+
642
+
643
+ def main() -> None:
644
+ parser = build_parser()
645
+ args = parser.parse_args()
646
+ args.func(args)
647
+
648
+
649
+ if __name__ == "__main__":
650
+ main()