shipcli 0.0.1__py3-none-any.whl

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.
shipcli/scaffold.py ADDED
@@ -0,0 +1,411 @@
1
+ """shipcli 项目脚手架生成器。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ from pathlib import Path
8
+
9
+
10
+ def default_package_name(app_name: str) -> str:
11
+ """把应用名规范化为可用的 Python 包名。"""
12
+ package = app_name.strip().lower().replace("-", "_").replace(" ", "_")
13
+ package = re.sub(r"[^a-z0-9_]", "", package)
14
+ package = re.sub(r"_+", "_", package).strip("_")
15
+ return package or "mycli"
16
+
17
+
18
+ def create_project(
19
+ target_dir: Path,
20
+ app_name: str,
21
+ package_name: str,
22
+ force: bool = False,
23
+ ) -> None:
24
+ """创建一个新的 CLI 项目骨架。"""
25
+ if target_dir.exists() and any(target_dir.iterdir()) and not force:
26
+ raise FileExistsError(f"目标目录非空:{target_dir}")
27
+
28
+ package_dir = target_dir / package_name
29
+ commands_dir = package_dir / "commands"
30
+ tests_dir = target_dir / "tests"
31
+ target_dir.mkdir(parents=True, exist_ok=True)
32
+ commands_dir.mkdir(parents=True, exist_ok=True)
33
+ tests_dir.mkdir(parents=True, exist_ok=True)
34
+
35
+ config = {
36
+ "app_name": app_name,
37
+ "entry": f"{package_name}/__main__.py",
38
+ "build_root": ".build",
39
+ "version": "0.1.0",
40
+ "build": 0,
41
+ }
42
+ (target_dir / "build.config.json").write_text(
43
+ json.dumps(config, ensure_ascii=False, indent=2) + "\n",
44
+ encoding="utf-8",
45
+ )
46
+ (target_dir / "pyproject.toml").write_text(
47
+ _pyproject(app_name, package_name),
48
+ encoding="utf-8",
49
+ )
50
+ (target_dir / "README.md").write_text(
51
+ _readme(app_name, package_name),
52
+ encoding="utf-8",
53
+ )
54
+ (package_dir / "__init__.py").write_text(
55
+ '__all__ = ["__version__"]\n__version__ = "0.1.0"\n',
56
+ encoding="utf-8",
57
+ )
58
+ (package_dir / "__main__.py").write_text(
59
+ _main_py(package_name),
60
+ encoding="utf-8",
61
+ )
62
+ (package_dir / "cli.py").write_text(
63
+ _cli_py(app_name, package_name),
64
+ encoding="utf-8",
65
+ )
66
+ (commands_dir / "__init__.py").write_text("", encoding="utf-8")
67
+ (commands_dir / "help.py").write_text(_help_py(), encoding="utf-8")
68
+ (commands_dir / "version.py").write_text(
69
+ _version_py(app_name, package_name),
70
+ encoding="utf-8",
71
+ )
72
+ (commands_dir / "upgrade.py").write_text(
73
+ _upgrade_py(app_name),
74
+ encoding="utf-8",
75
+ )
76
+ (commands_dir / "uninstall.py").write_text(
77
+ _uninstall_py(app_name),
78
+ encoding="utf-8",
79
+ )
80
+ (tests_dir / "test_cli.py").write_text(
81
+ _test_cli(package_name),
82
+ encoding="utf-8",
83
+ )
84
+
85
+
86
+ def _pyproject(app_name: str, package_name: str) -> str:
87
+ """生成项目 pyproject.toml。"""
88
+ return f"""[build-system]
89
+ requires = ["setuptools>=69", "wheel"]
90
+ build-backend = "setuptools.build_meta"
91
+
92
+ [project]
93
+ name = "{app_name}"
94
+ version = "0.1.0"
95
+ description = "A Python CLI initialized by shipcli."
96
+ requires-python = ">=3.10"
97
+ license = "MIT"
98
+ authors = [
99
+ {{ name = "your-name" }}
100
+ ]
101
+
102
+ [project.optional-dependencies]
103
+ build = [
104
+ "build>=1.2.2"
105
+ ]
106
+ publish = [
107
+ "twine>=5.1.0"
108
+ ]
109
+
110
+ [project.scripts]
111
+ {app_name} = "{package_name}.cli:main"
112
+
113
+ [tool.setuptools.packages.find]
114
+ include = ["{package_name}*"]
115
+ """
116
+
117
+ def _readme(app_name: str, package_name: str) -> str:
118
+ """生成项目 README。"""
119
+ return f"""# {app_name}
120
+
121
+ 一个由 `shipcli` 初始化的 Python CLI 项目。
122
+
123
+ ## 运行
124
+
125
+ ```bash
126
+ python3 -m {package_name} --help
127
+ python3 -m {package_name} help
128
+ python3 -m {package_name} version
129
+ python3 -m {package_name} upgrade
130
+ python3 -m {package_name} uninstall
131
+ ```
132
+ """
133
+
134
+
135
+ def _main_py(package_name: str) -> str:
136
+ """生成模块入口文件。"""
137
+ return f'''"""{package_name} 模块入口。"""
138
+
139
+ from __future__ import annotations
140
+
141
+ import sys
142
+
143
+ from {package_name}.cli import main
144
+
145
+
146
+ if __name__ == "__main__":
147
+ sys.exit(main())
148
+ '''
149
+
150
+
151
+ def _cli_py(app_name: str, package_name: str) -> str:
152
+ """生成最小 CLI 分发器。"""
153
+ return f'''"""{package_name} CLI 主入口。"""
154
+
155
+ from __future__ import annotations
156
+
157
+ import argparse
158
+ import sys
159
+
160
+ from {package_name}.commands.help import run_help
161
+ from {package_name}.commands.uninstall import run_uninstall
162
+ from {package_name}.commands.upgrade import run_upgrade
163
+ from {package_name}.commands.version import run_version
164
+
165
+
166
+ def build_parser() -> argparse.ArgumentParser:
167
+ """构造顶层参数解析器。"""
168
+ parser = argparse.ArgumentParser(prog="{app_name}", description="{app_name} CLI")
169
+ parser.add_argument("--version", action="store_true", help="显示版本号")
170
+ sub = parser.add_subparsers(dest="command", metavar="<command>")
171
+ sub.add_parser("help", help="显示帮助信息")
172
+ sub.add_parser("version", help="输出当前版本")
173
+ upgrade = sub.add_parser("upgrade", help="升级当前 CLI")
174
+ upgrade.add_argument("--version", default=None, help="升级到指定版本")
175
+ upgrade.add_argument("--local", default=None, help="从本地项目路径的 build 产物升级")
176
+ sub.add_parser("uninstall", help="卸载当前 CLI")
177
+ return parser
178
+
179
+
180
+ def main(argv: list[str] | None = None) -> int:
181
+ """解析命令行并分发子命令。"""
182
+ parser = build_parser()
183
+ args = parser.parse_args(argv)
184
+ if args.version:
185
+ return run_version(args)
186
+ if args.command == "help":
187
+ return run_help(parser)
188
+ if args.command == "version":
189
+ return run_version(args)
190
+ if args.command == "upgrade":
191
+ return run_upgrade(args)
192
+ if args.command == "uninstall":
193
+ return run_uninstall(args)
194
+ parser.print_help()
195
+ return 1
196
+
197
+
198
+ if __name__ == "__main__":
199
+ sys.exit(main())
200
+ '''
201
+
202
+
203
+ def _help_py() -> str:
204
+ """生成示例 help 命令。"""
205
+ return '''"""示例 help 命令。"""
206
+
207
+ from __future__ import annotations
208
+
209
+ import argparse
210
+
211
+
212
+ def run_help(parser: argparse.ArgumentParser) -> int:
213
+ """输出当前 CLI 的帮助信息。"""
214
+ parser.print_help()
215
+ return 0
216
+ '''
217
+
218
+
219
+ def _version_py(app_name: str, package_name: str) -> str:
220
+ """生成示例 version 命令。"""
221
+ return f'''"""示例 version 命令。"""
222
+
223
+ from __future__ import annotations
224
+
225
+ from typing import Any
226
+
227
+ from {package_name} import __version__
228
+
229
+
230
+ def run_version(args: Any) -> int:
231
+ """输出当前 CLI 的版本号。"""
232
+ print("{app_name} " + __version__)
233
+ return 0
234
+ '''
235
+
236
+
237
+ def _uninstall_py(app_name: str) -> str:
238
+ """生成示例 uninstall 命令:pip 卸载 + 清理本地痕迹。"""
239
+ return f'''"""示例 uninstall 命令。"""
240
+
241
+ from __future__ import annotations
242
+
243
+ import shutil
244
+ import subprocess
245
+ import sys
246
+ from pathlib import Path
247
+ from typing import Any
248
+
249
+ APP_NAME = "{app_name}"
250
+
251
+
252
+ def run_uninstall(args: Any) -> int:
253
+ """卸载当前 CLI:pip 卸载包,并清理本地安装目录与符号链接。"""
254
+ subprocess.run(
255
+ [sys.executable, "-m", "pip", "uninstall", "-y", APP_NAME, "--disable-pip-version-check"],
256
+ check=False,
257
+ )
258
+ install_dir = Path.home() / f".{{APP_NAME}}-cli"
259
+ for link_dir in (Path("/usr/local/bin"), Path.home() / ".local" / "bin"):
260
+ link_path = link_dir / APP_NAME
261
+ if link_path.exists() or link_path.is_symlink():
262
+ link_path.unlink(missing_ok=True)
263
+ if install_dir.exists():
264
+ shutil.rmtree(install_dir)
265
+ print(f"已卸载:{{APP_NAME}}")
266
+ return 0
267
+ '''
268
+
269
+
270
+ def _upgrade_py(app_name: str) -> str:
271
+ """生成示例 upgrade 命令:默认 PyPI,--local 走本地 build 产物。"""
272
+ return f'''"""示例 upgrade 命令。"""
273
+
274
+ from __future__ import annotations
275
+
276
+ import json
277
+ import subprocess
278
+ import sys
279
+ from pathlib import Path
280
+ from typing import Any
281
+
282
+ APP_NAME = "{app_name}"
283
+
284
+
285
+ def run_upgrade(args: Any) -> int:
286
+ """升级当前 CLI:默认从 PyPI 拉发布版,--local 从本地 build 产物装。"""
287
+ try:
288
+ if getattr(args, "local", None):
289
+ version = _upgrade_from_local(Path(args.local).resolve(), args.version)
290
+ else:
291
+ version = _upgrade_from_pypi(args.version)
292
+ except Exception as exc: # noqa: BLE001
293
+ print(f"错误: {{exc}}")
294
+ return 1
295
+ print(f"升级完成:{{version}}")
296
+ return 0
297
+
298
+
299
+ def _upgrade_from_pypi(version: str | None) -> str:
300
+ """从 PyPI 升级到最新或指定发布版本。"""
301
+ spec = [APP_NAME, "--upgrade"] if version is None else [f"{{APP_NAME}}=={{version}}"]
302
+ subprocess.run(
303
+ [sys.executable, "-m", "pip", "install", *spec, "--disable-pip-version-check"],
304
+ check=True,
305
+ )
306
+ return version or "latest"
307
+
308
+
309
+ def _upgrade_from_local(project_root: Path, version: str | None) -> str:
310
+ """从本地项目的 build 产物升级;version 缺省取最新版本。"""
311
+ resolved = version or _latest_product_version(project_root)
312
+ wheel = _find_local_wheel(project_root, resolved)
313
+ subprocess.run(
314
+ [sys.executable, "-m", "pip", "install", "--force-reinstall", str(wheel), "--disable-pip-version-check"],
315
+ check=True,
316
+ )
317
+ return resolved
318
+
319
+
320
+ def _find_local_wheel(project_root: Path, version: str) -> Path:
321
+ """定位本地某版本 build 产物中的 wheel 文件。"""
322
+ config_path = project_root / "build.config.json"
323
+ config = json.loads(config_path.read_text(encoding="utf-8"))
324
+ build_root = config.get("build_root", ".build")
325
+ dist_dir = project_root / build_root / "dist" / version / "dist"
326
+ wheels = sorted(dist_dir.glob("*.whl")) if dist_dir.is_dir() else []
327
+ if not wheels:
328
+ raise FileNotFoundError(
329
+ f"未找到本地 {{version}} 的 wheel:{{dist_dir}},请先执行 build 构建"
330
+ )
331
+ return wheels[0]
332
+
333
+
334
+ def _latest_product_version(project_root: Path) -> str:
335
+ """返回本地 build 产物目录中最新的版本。"""
336
+ config_path = project_root / "build.config.json"
337
+ config = json.loads(config_path.read_text(encoding="utf-8"))
338
+ build_root = config.get("build_root", ".build")
339
+ dist = project_root / build_root / "dist"
340
+ if not dist.is_dir():
341
+ raise FileNotFoundError(f"构建产物目录不存在:{{dist}}")
342
+ candidates = []
343
+ for sub in dist.iterdir():
344
+ if sub.is_dir() and (sub / "dist").is_dir() and any((sub / "dist").glob("*.whl")):
345
+ candidates.append(sub.name)
346
+ if not candidates:
347
+ raise FileNotFoundError(f"未找到可升级产物:{{dist}}")
348
+ candidates.sort(key=_version_sort_key)
349
+ return candidates[-1]
350
+
351
+
352
+ def _version_sort_key(version_str: str) -> tuple:
353
+ """给 release/dev 版本字符串生成排序键(兼容 .devN 与 -devN)。"""
354
+ base = version_str
355
+ dev = 0
356
+ for sep in (".dev", "-dev"):
357
+ if sep in base:
358
+ base, dev_part = base.split(sep, 1)
359
+ dev = int(dev_part)
360
+ break
361
+ segments = base.split(".")
362
+ if len(segments) != 3 or not all(seg.isdigit() for seg in segments):
363
+ return (0, 0, 0, 0, 0)
364
+ major, minor, patch = (int(seg) for seg in segments)
365
+ is_release = 0 if (".dev" in version_str or "-dev" in version_str) else 1
366
+ return (major, minor, patch, is_release, dev)
367
+ '''
368
+
369
+
370
+ def _test_cli(package_name: str) -> str:
371
+ """生成最小命令测试。"""
372
+ return f'''import subprocess
373
+
374
+
375
+ def test_help_runs():
376
+ out = subprocess.run(
377
+ ["python3", "-m", "{package_name}", "--help"],
378
+ capture_output=True,
379
+ text=True,
380
+ check=True,
381
+ )
382
+ text = out.stdout + out.stderr
383
+ assert "help" in text
384
+ assert "version" in text
385
+ assert "upgrade" in text
386
+ assert "uninstall" in text
387
+
388
+
389
+ def test_help_command_runs():
390
+ out = subprocess.run(
391
+ ["python3", "-m", "{package_name}", "help"],
392
+ capture_output=True,
393
+ text=True,
394
+ check=True,
395
+ )
396
+ text = out.stdout + out.stderr
397
+ assert "help" in text
398
+ assert "version" in text
399
+ assert "upgrade" in text
400
+ assert "uninstall" in text
401
+
402
+
403
+ def test_version_runs():
404
+ out = subprocess.run(
405
+ ["python3", "-m", "{package_name}", "version"],
406
+ capture_output=True,
407
+ text=True,
408
+ check=True,
409
+ )
410
+ assert "0.1.0" in out.stdout
411
+ '''
shipcli/version.py ADDED
@@ -0,0 +1,95 @@
1
+ """shipcli 版本展示辅助。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ import shipcli
9
+
10
+
11
+ def display_version() -> str:
12
+ """返回打包时写入的 CLI 展示版本。"""
13
+ return shipcli.__version__
14
+
15
+
16
+ def install_mode() -> str:
17
+ """判断当前 shipcli 的安装形态:binary / editable / installed。
18
+
19
+ - binary: PyInstaller 打包的可执行文件
20
+ - editable: pip editable 安装,命令直接跑源码目录
21
+ - installed: pip 正式安装,命令跑 site-packages 里的打包快照
22
+ """
23
+ if getattr(sys, "frozen", False):
24
+ return "binary"
25
+
26
+ package_file = getattr(shipcli, "__file__", None)
27
+ if not package_file:
28
+ return "installed"
29
+
30
+ package_path = Path(package_file).resolve()
31
+ site_packages = _site_packages()
32
+ in_site = any(_is_relative_to(package_path, sp) for sp in site_packages)
33
+
34
+ if not in_site:
35
+ # 源码目录(不在 site-packages)→ editable 开发安装。
36
+ return "editable"
37
+
38
+ # 在 site-packages 内:再区分 editable 与正式安装。
39
+ if _has_editable_marker(package_path):
40
+ return "editable"
41
+ return "installed"
42
+
43
+
44
+ def display_version_with_mode() -> str:
45
+ """返回带安装形态标记的版本字符串。"""
46
+ return f"{display_version()} ({install_mode()})"
47
+
48
+
49
+ def _site_packages() -> list[Path]:
50
+ """收集当前解释器的 site-packages 目录。"""
51
+ candidates: list[Path] = []
52
+ try:
53
+ import site
54
+
55
+ candidates.extend(Path(p).resolve() for p in site.getsitepackages())
56
+ candidates.append(Path(site.getusersitepackages()).resolve())
57
+ except Exception: # noqa: BLE001 - 退化时按 sys.path 兜底
58
+ pass
59
+ candidates.extend(Path(p).resolve() for p in sys.path if p)
60
+ return candidates
61
+
62
+
63
+ def _is_relative_to(path: Path, base: Path) -> bool:
64
+ """兼容旧 Python 的相对路径判断。"""
65
+ try:
66
+ path.relative_to(base)
67
+ return True
68
+ except ValueError:
69
+ return False
70
+
71
+
72
+ def _has_editable_marker(package_path: Path) -> bool:
73
+ """判断 site-packages 内的包是否为 editable 安装。
74
+
75
+ editable 安装会留下 `__editable__.<name>-<ver>.pth` 或
76
+ `<name>-<ver>.dist-info/direct_url.json`(含 "dir" 字段)。
77
+ """
78
+ parent = package_path.parent
79
+ for entry in parent.iterdir():
80
+ name = entry.name
81
+ if name.startswith("__editable__") and name.endswith(".pth"):
82
+ return True
83
+ if entry.is_dir() and entry.name.endswith(".dist-info"):
84
+ direct_url = entry / "direct_url.json"
85
+ if direct_url.is_file():
86
+ try:
87
+ import json
88
+
89
+ data = json.loads(direct_url.read_text(encoding="utf-8"))
90
+ if "dir" in data:
91
+ return True
92
+ except Exception: # noqa: BLE001 - 解析失败按非 editable 处理
93
+ continue
94
+ return False
95
+
@@ -0,0 +1,182 @@
1
+ Metadata-Version: 2.4
2
+ Name: shipcli
3
+ Version: 0.0.1
4
+ Summary: Pure Python CLI scaffold and delivery tool.
5
+ Author: CaffeineOddity
6
+ License-Expression: MIT
7
+ Keywords: cli,scaffold,pyinstaller,release,tooling
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Environment :: Console
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Operating System :: MacOS
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Topic :: Software Development :: Build Tools
15
+ Classifier: Topic :: Utilities
16
+ Requires-Python: >=3.10
17
+ Description-Content-Type: text/markdown
18
+ License-File: LICENSE
19
+ Provides-Extra: build
20
+ Requires-Dist: pyinstaller>=6.0; extra == "build"
21
+ Provides-Extra: publish
22
+ Requires-Dist: twine>=5.1.0; extra == "publish"
23
+ Provides-Extra: dev
24
+ Requires-Dist: build>=1.2.2; extra == "dev"
25
+ Requires-Dist: pytest>=9.0.0; extra == "dev"
26
+ Dynamic: license-file
27
+
28
+ # shipcli
29
+
30
+ `shipcli` 是一个纯 Python 的 CLI 脚手架与交付工具,用来初始化、构建、发布、安装和升级命令行项目。
31
+
32
+ ## 特性
33
+
34
+ - 用 `shipcli init <path>` 快速生成新的 Python CLI 项目
35
+ - 用 `shipcli build` / `shipcli build --release` 生成 dev 或 release 版的 wheel/sdist 分发文件
36
+ - 用 `shipcli publish` 把 release 版本发布到 GitHub Release 或 PyPI
37
+ - `shipcli upgrade` / `uninstall` 只管 shipcli 自身;项目 CLI(如 demo-cli)用其自带命令管理
38
+ - 默认以当前目录作为项目目录,也支持 `--project <path>` 跨目录操作
39
+ - 初始化后的项目默认包含 `help`、`version`、`upgrade`、`uninstall` 命令示例
40
+
41
+ ## 命令作用范围
42
+
43
+ | 命令 | 作用对象 |
44
+ |------|---------|
45
+ | `init` / `build` / `install` / `publish` | 目标项目(当前目录或 `--project`,如 demo-cli) |
46
+ | `upgrade` / `uninstall` | shipcli 自身 |
47
+
48
+ 若要升级或卸载某个项目 CLI(如 demo-cli),应先 `install` 安装它,再用其自带命令:`demo-cli upgrade` / `demo-cli uninstall`。
49
+
50
+ ## 安装
51
+
52
+ ### 首次安装(开发)
53
+
54
+ shipcli 未发布到 PyPI 前,从本地仓库安装:
55
+
56
+ ```bash
57
+ git clone <your-repo-url>
58
+ cd shipcli
59
+ python3 -m pip install -e . # editable 安装,改代码即生效
60
+ ```
61
+
62
+ 安装完成后:
63
+
64
+ ```bash
65
+ shipcli --help
66
+ shipcli --version # 形如:shipcli 0.0.1 (editable)
67
+ ```
68
+
69
+ `--version` / `--help` 会标注当前安装形态:`editable`(editable 安装)、`installed`(pip 正式安装)、`binary`(PyInstaller 二进制)。
70
+
71
+ ### 安装 build 产物
72
+
73
+ `shipcli build` 产出 wheel/sdist 后,可直接装本地 wheel(模拟用户从 PyPI 安装):
74
+
75
+ ```bash
76
+ shipcli build --release
77
+ python3 -m pip install .build/dist/0.0.1/dist/*.whl
78
+ ```
79
+
80
+ ## 快速开始
81
+
82
+ ```bash
83
+ shipcli init demo-cli
84
+
85
+ cd demo-cli
86
+ shipcli build # dev 构建,产出 <version>.devN
87
+ shipcli build --release # release 构建,产出发布版 wheel/sdist
88
+ shipcli publish --github --pypi # 发布到 GitHub Release + PyPI
89
+ ```
90
+
91
+ 也支持不切目录,直接指定目标项目:
92
+
93
+ ```bash
94
+ shipcli build --project ./demo-cli
95
+ shipcli publish --project ./demo-cli --github --github-repo owner/demo-cli
96
+ ```
97
+
98
+ ## 构建
99
+
100
+ `shipcli build` 产出标准的 Python 分发文件(wheel/sdist),不再依赖 PyInstaller:
101
+
102
+ - **dev 构建**(`shipcli build`):版本号 `<base>.devN`(如 `0.0.1.dev1`),build 号自动 +1。用于本地验证。
103
+ - **release 构建**(`shipcli build --release`):版本号取配置中的 `version`(如 `0.0.1`)。
104
+ - **版本递增**(`--increase <major|minor|patch>`):构建前把版本号指定位 +1(低位归零)并重置 build,可与 `--release` 同用。
105
+
106
+ 版本号唯一真源是 `build.config.json` 的 `version`;release 构建会自动同步到 `pyproject.toml`,构建时会写入包 `__init__.py` 的 `__version__`。
107
+
108
+ ```bash
109
+ shipcli build # dev 构建
110
+ shipcli build --release # release 构建
111
+ shipcli build --increase patch # patch+1 后 dev 构建
112
+ shipcli build --increase minor --release # minor+1 后 release 构建
113
+ ```
114
+
115
+ 产物目录结构:
116
+
117
+ ```text
118
+ .build/dist/<version>/dist/
119
+ ├── <name>-<version>-py3-none-any.whl
120
+ └── <name>-<version>.tar.gz
121
+ ```
122
+
123
+ ## 升级 shipcli 自身
124
+
125
+ ```bash
126
+ shipcli upgrade # 从 PyPI 升级到最新发布版
127
+ shipcli upgrade --version 0.0.1 # 从 PyPI 升级到指定发布版
128
+ shipcli upgrade --local . # 从本地项目 build 产物升级(取最新版本)
129
+ shipcli upgrade --local . --version 0.0.1.dev1 # 指定本地 build 产物版本
130
+ shipcli uninstall # 卸载 shipcli(pip 卸载 + 清理本地痕迹)
131
+ ```
132
+
133
+ dev 版不上 PyPI,仅本地安装/升级:先 `shipcli build` 产出 dev wheel,再用 `shipcli upgrade --local .` 装上。
134
+
135
+ ## 发布
136
+
137
+ - `shipcli publish` 只做发布,不负责构建
138
+ - 发布前先执行 `shipcli build --release`
139
+ - 发布到 GitHub Release:上传 wheel/sdist 及各自的 `.sha256` 校验文件
140
+ - 发布到 PyPI:上传 wheel/sdist
141
+ - GitHub 凭证只从环境变量读取:`SHIPCLI_GITHUB_TOKEN` 或 `GITHUB_TOKEN`
142
+ - GitHub 仓库可通过 `--github-repo` 传入,或设置 `SHIPCLI_GITHUB_REPO` / `GITHUB_REPOSITORY`
143
+ - PyPI 凭证只从环境变量读取:`SHIPCLI_PYPI_TOKEN`,也兼容现有 `TWINE_PASSWORD`
144
+ - `publish` 只支持 release 版本,不接受 dev 版本
145
+
146
+ ```bash
147
+ cd demo-cli
148
+
149
+ # 发布本地已构建的 release 分发文件到 GitHub Release + PyPI
150
+ shipcli build --release
151
+ SHIPCLI_GITHUB_TOKEN=... SHIPCLI_PYPI_TOKEN=... \
152
+ shipcli publish --github --pypi --github-repo owner/demo-cli
153
+ ```
154
+
155
+ ## 初始化后的目录结构
156
+
157
+ 执行 `shipcli init demo-cli` 后,默认会生成:
158
+
159
+ ```text
160
+ demo-cli/
161
+ ├── build.config.json
162
+ ├── README.md
163
+ ├── demo_cli/
164
+ │ ├── __init__.py
165
+ │ ├── __main__.py
166
+ │ ├── cli.py
167
+ │ └── commands/
168
+ │ ├── __init__.py
169
+ │ ├── help.py
170
+ │ ├── version.py
171
+ │ ├── upgrade.py
172
+ │ └── uninstall.py
173
+ └── tests/
174
+ └── test_cli.py
175
+ ```
176
+
177
+ ## 开发验证
178
+
179
+ ```bash
180
+ python3 -m pytest
181
+ shipcli build --release
182
+ ```