oil-ui-pro 0.15.4 → 0.22.2(上游 ui.oiloil.org 正式包)+ 记 P0-98 跨盘 rename 坑

- oil-ui-pro 升到 0.22.2:references 13→28 份(新增 16、删 imagery.md)、assets 增 cards/(22 组)与 fonts/、SKILL.md 顶层五步结构未改名。
- 升级路径:官方 `npx github:oil-oil/oil-cli update` 稳定失败(error=replace、报「检查目录权限」)。
  真身为 EXDEV —— 解压在 os.tmpdir()(C 盘)、技能目录在 E 盘,Windows 上 rename 不能跨盘;
  CLI 的 catch 把非 CliError 原生异常一律兜底成 replaceFailed,真错误一个字不吐。
  做法=借其 prepare() 只做下载+校验,再 cp 到同盘 stage、rename 交换;官方命令复验 up_to_date。
- 回归:product-planning 按名引用的 visual-review.md/tools.md/design-direction.md/style-explorer.md 全部健在;命名闸门 2061|0|27。
- session-mechanism/references/pitfalls.md 新增 P0-98(含通则:更新器把原生错误兜底成指错方向的人话)。
This commit is contained in:
admin committed 2026-10-14 10:21:46 +08:00
1 parent 053a6a6723
commit d9445b5888
126 files changed
+10345 -1401

No files matched your search

+51 -36
View File
@@ -1,19 +1,21 @@
#!/usr/bin/env python3
"""检查本 Skill 有没有新版本,有就自动更新。
"""检查本 Skill 有没有新版本;只有显式传入 --update 才更新。
由使用 Skill 触发,最多每 10 分钟联网检查一次,网络失败后一小时可重试。
只读取 ui.oiloil.org 公开的版本列表,不上传项目内容。
发现新版本时用 npx github:oil-oil/oil-cli 原地更新本目录:
发现新版本时默认提示;用户要求更新后,用固定提交的 CLI 原地更新本目录:
成功打印一行“已自动更新”,不能自动更新时打印一行提示,条件不变时每天最多提示一次;
没有新版本、网络失败或在开发目录(含 .git)里运行时什么也不输出。
设置环境变量 OIL_NO_UPDATE_CHECK=1 关闭检查,OIL_NO_AUTO_UPDATE=1 只提示不自动更新。
设置环境变量 OIL_NO_UPDATE_CHECK=1 关闭检查,OIL_NO_AUTO_UPDATE=1 强制只提示。
"""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import re
import shlex
import shutil
import subprocess
import sys
@@ -24,7 +26,13 @@ from typing import NamedTuple
ROOT = Path(__file__).resolve().parent.parent
API = os.environ.get("OIL_API", "https://ui.oiloil.org").rstrip("/")
CLI = "github:oil-oil/oil-cli"
# 固定经过检查的 CLI:无 npm 依赖,下载包必须通过 SHA-256 与归档路径校验。
CLI = "github:oil-oil/oil-cli#b6fb811d42a72d327e5060518347b125d9a098ca"
UPDATE_ENV_KEYS = {
"PATH", "HOME", "USERPROFILE", "APPDATA", "LOCALAPPDATA", "XDG_CONFIG_HOME", "XDG_STATE_HOME",
"SYSTEMROOT", "WINDIR", "TMPDIR", "TMP", "TEMP", "LANG", "LC_ALL", "LC_MESSAGES",
"OIL_API", "OIL_TOKEN",
}
DAY = 24 * 3600
# 发版后用户下一次使用就能更新;版本列表有服务端缓存,频繁检查的成本很低
CHECK_INTERVAL = 10 * 60
@@ -104,18 +112,8 @@ def fetch(name: str) -> dict | None:
entry = json.load(response).get("skills", {}).get(name)
if not isinstance(entry, dict) or not re.fullmatch(r"\d+\.\d+\.\d+", str(entry.get("latest", ""))):
return None
history = entry.get("history") or []
notes = next((h.get("notes", "") for h in history if h.get("version") == entry["latest"]), "")
# 免费版的版本信息带公开下载地址,付费版没有
return {"latest": entry["latest"], "notes": notes, "free": bool(entry.get("download_url"))}
def headline(notes: str) -> str:
for line in notes.splitlines():
line = re.sub(r"^[#>*\-\s]+", "", line).strip().rstrip("。.;;,,")
if line:
return line if len(line) <= 60 else line[:59] + "…"
return ""
return {"latest": entry["latest"], "free": bool(entry.get("download_url"))}
def file_signature(path: str | Path | None) -> list:
@@ -128,14 +126,19 @@ def file_signature(path: str | Path | None) -> list:
return [str(path)]
def update_context() -> tuple[str, str | None, bool]:
def update_environment() -> dict[str, str]:
"""只给已获准运行的 CLI 传必要路径、语言与其自身授权,不传无关密钥或注入选项。"""
return {**{k: v for k, v in os.environ.items() if k.upper() in UPDATE_ENV_KEYS}, "CI": "1"}
def update_context(approved: bool = False) -> tuple[str, str | None, bool]:
"""只存指纹;登录配置变化或补齐依赖时不沿用旧冷却。"""
npx = shutil.which("npx")
node = shutil.which("node")
node_version = ""
if node:
if node and approved:
try:
result = subprocess.run([node, "--version"], capture_output=True, text=True, timeout=3)
result = subprocess.run([node, "--version"], env=update_environment(), capture_output=True, text=True, timeout=3, encoding="utf-8")
if result.returncode == 0:
node_version = result.stdout.strip()
except (OSError, subprocess.SubprocessError):
@@ -144,7 +147,7 @@ def update_context() -> tuple[str, str | None, bool]:
ready = bool(npx and version and int(version.group(1)) >= 18)
context = [API, file_signature(config_path()), os.environ.get("OIL_TOKEN", ""),
file_signature(npx), file_signature(node), node_version,
bool(os.environ.get("OIL_NO_AUTO_UPDATE"))]
bool(os.environ.get("OIL_NO_AUTO_UPDATE")), approved, CLI]
fingerprint = hashlib.sha256(json.dumps(context).encode()).hexdigest()
return fingerprint, npx, ready
@@ -166,19 +169,19 @@ def cli_error(stdout: str, stderr: str) -> str:
return ""
def auto_update(name: str, latest: str, free: bool, npx: str | None, ready: bool) -> UpdateResult:
if os.environ.get("OIL_NO_AUTO_UPDATE"):
def auto_update(name: str, latest: str, free: bool, npx: str | None, ready: bool, approved: bool = False) -> UpdateResult:
if not approved or os.environ.get("OIL_NO_AUTO_UPDATE"):
return UpdateResult(False, "manual")
if not (free or logged_in()):
return UpdateResult(False, "unauthorized")
if not ready:
return UpdateResult(False, "dependencies")
# CI=1 让命令行在没登录时直接失败,不会停下来等浏览器确认
env = {**os.environ, "CI": "1"}
env = update_environment()
try:
result = subprocess.run([npx, "-y", CLI, "update", name, "--path", str(ROOT), "--json"], env=env,
stdin=subprocess.DEVNULL, capture_output=True, text=True,
timeout=UPDATE_TIMEOUT, check=False)
timeout=UPDATE_TIMEOUT, check=False, encoding="utf-8", errors="replace")
except FileNotFoundError:
return UpdateResult(False, "dependencies")
except subprocess.TimeoutExpired:
@@ -200,15 +203,14 @@ def auto_update(name: str, latest: str, free: bool, npx: str | None, ready: bool
def english() -> bool:
return (os.environ.get("LC_ALL") or os.environ.get("LC_MESSAGES") or os.environ.get("LANG", "")).lower().startswith("en")
# 只有中文环境用中文,其他语言和未设置时都用英文;宿主会再按对话语言转述。
return not (os.environ.get("LC_ALL") or os.environ.get("LC_MESSAGES") or os.environ.get("LANG", "")).lower().startswith("zh")
def update_command(name: str) -> str:
# 双引号方便直接复制,也保护 POSIX shell 中的变量与命令替换。
path = str(ROOT)
if os.name != "nt":
path = re.sub(r'([\\"$`])', r'\\\1', path)
return f'npx {CLI} update {name} --path "{path}"'
# 从同一入口执行,保留固定 CLI 和环境白名单;不让宿主自行拼接 npx 命令。
args = [sys.executable, str(ROOT / "scripts" / "check_update.py"), "--update"]
return subprocess.list2cmdline(args) if os.name == "nt" else shlex.join(args)
def product_name(name: str) -> str:
@@ -232,7 +234,7 @@ def notice(name: str, current: str, latest: str, detail: str, reason: str) -> st
if reason == "unauthorized":
return intro + f"Authorization has expired or is missing. Run npx {CLI} login, then run {command}."
if reason == "inactive":
return intro + f"This Skill has no purchase record. Purchase it at https://ui.oiloil.org/pro/ to get updates, then run {command}."
return intro + f"This Skill has no purchase record. Purchase it at https://ui.oiloil.org/en/pro/ to get updates, then run {command}."
if reason == "dependencies":
return intro + f"Automatic updates need Node.js 18 or later. Install it, then run {command}."
return intro + f"To update, run {command}."
@@ -246,7 +248,18 @@ def notice(name: str, current: str, latest: str, detail: str, reason: str) -> st
return intro + f"在终端运行 {command} 即可更新。"
def utf8_output() -> None:
"""管道另一头的 Agent 按 UTF-8 读;控制台的代码页可能连中文都没有。"""
for stream in (sys.stdout, sys.stderr):
if hasattr(stream, "reconfigure") and not stream.isatty():
stream.reconfigure(encoding="utf-8")
def main() -> int:
utf8_output()
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--update", action="store_true", help="Explicitly authorize updating this installation")
approved = parser.parse_args().update
if os.environ.get("OIL_NO_UPDATE_CHECK") or (ROOT / ".git").exists():
return 0
skill = read_skill()
@@ -255,8 +268,11 @@ def main() -> int:
name, current = skill
path = state_path(name)
# 原来的共享文件可能包含失败与提示记录,迁移时只保留公开缓存。
cache = {key: value for key, value in load(path).items()
if key in {"checked_at", "latest", "notes", "free", "fetch_failed_at"}}
previous_cache = load(path)
cache = {key: value for key, value in previous_cache.items()
if key in {"checked_at", "latest", "free", "fetch_failed_at"}}
if cache != previous_cache:
save(path, cache)
install_path = installation_state_path(name)
state = load(install_path)
now = time.time()
@@ -281,15 +297,14 @@ def main() -> int:
latest = cache.get("latest")
if not isinstance(latest, str) or not re.fullmatch(r"\d+\.\d+\.\d+", latest) or parse(latest) <= parse(current):
return 0
summary = headline(cache.get("notes", ""))
detail = (f": {summary}" if english() else f":{summary}") if summary else ""
context, npx, ready = update_context()
detail = ""
context, npx, ready = update_context(approved)
reason = state.get("auto_failed_reason", "failed")
cooldown = RETRY_AFTER_FAILURE if reason == "network" else DAY
if (state.get("auto_failed_version") != latest or state.get("auto_failed_context") != context
or now - float(state.get("auto_failed_at", 0)) >= cooldown):
result = auto_update(name, latest, bool(cache.get("free")), npx, ready)
result = auto_update(name, latest, bool(cache.get("free")), npx, ready, approved)
reason = result.reason
if reason == "updated":
save(install_path, {})