diff --git a/README.md b/README.md index b528308..9442437 100644 --- a/README.md +++ b/README.md @@ -66,14 +66,15 @@ release tooling. Source repository vẫn giữ tests để kiểm chứng chính cd # Chỉ thêm governance config/hooks; không thêm domain-pack hoặc CI template -casan init --level core --client claude,codex --mode enforce +casan init --client claude,codex --mode enforce casan doctor casan verify-harness ``` `casan init` có menu chọn client khi chạy tương tác. Trong automation nên chỉ -định rõ `--client`: +định rõ `--client`. Project hiện hữu mặc định dùng Level 1 (`core`); chỉ truyền +`--level devkit` khi muốn CASAN bổ sung CI template và domain-pack: ```bash casan init --level core --client claude @@ -81,6 +82,7 @@ casan init --level core --client codex casan init --level core --client claude,codex casan init --level core --client vscode-copilot --vscode-install yes casan init --level core --client all +casan init --level devkit --client claude,codex ``` Với Codex, sau init phải mở `/hooks`, kiểm tra và trust đúng project hook hash. @@ -278,9 +280,10 @@ casan uninstall --purge casan uninstall --remove-vscode-extension ``` -`uninstall` không tự xóa CI/domain template vì các file này có thể đã trở thành -source code của project, không tự xóa `.casan-bak`, và không mặc định gỡ VS Code -extension dùng chung cho các project khác. +`uninstall` xóa workflow CASAN trong `.gitea`, xóa các scaffold file CASAN còn +nguyên checksum và tự dọn thư mục cha khi đã rỗng. Workflow/file của project, +scaffold file đã chỉnh sửa và `.casan-bak` được giữ lại để tránh mất dữ liệu. +Lệnh cũng không mặc định gỡ VS Code extension dùng chung cho các project khác. Khi nâng cấp CASAN: diff --git a/bin/casan b/bin/casan index 2429007..e5c6edd 100755 --- a/bin/casan +++ b/bin/casan @@ -41,7 +41,7 @@ casan — CASAN governance harness CLI ($(version)) Usage: casan [args] Commands: - init [--client ...] [--project id] Adopt CASAN and select Claude/Codex/VS Code integrations + init [--level core|devkit] [...] Adopt CASAN (project default: core) uninstall [--purge] Remove CASAN from this project (preserves user config) doctor [--client ...] Verify configured hooks, pin, adapters, and VS Code route level Show / change the project's packaging level diff --git a/docs/casan/CASAN_INSTALL_HYBRID.md b/docs/casan/CASAN_INSTALL_HYBRID.md index 187261f..31623d1 100644 --- a/docs/casan/CASAN_INSTALL_HYBRID.md +++ b/docs/casan/CASAN_INSTALL_HYBRID.md @@ -58,7 +58,7 @@ cục bộ), `CASAN_DIST_URL` (tải tarball), `CASAN_NO_PATH_LINK=1` (không t ```bash cd -casan init # interactive: chọn Claude, Codex, VS Code/Copilot +casan init # mặc định project Level 1/core; interactive chọn client # hoặc chọn level áp dụng cho project: casan init --level 1 --project my-app --client claude casan init --level 2 --project my-app --client claude,codex @@ -68,6 +68,9 @@ casan level set 2 # đổi level project (không cần ini ``` **Áp dụng cho dự án ĐÃ có vỏ (agents/skills/hook sẵn):** an toàn. +- Mặc định `casan init` áp dụng **Level 1/core**: governance config + hooks, + không thêm `.gitea` workflow hoặc domain-pack. Level cài global vẫn phải là + DevKit vì lệnh adoption nằm trong DevKit. - `init` **KHÔNG index/parse code, KHÔNG sửa source, KHÔNG dựng lại vỏ** — chỉ thêm config. - Hook được **MERGE** idempotent vào `.claude/settings.json` / `.codex/hooks.json` hiện có (giữ nguyên hook/agents/skills/khóa khác của bạn), không ghi đè. Chạy @@ -81,6 +84,11 @@ casan level set 2 # đổi level project (không cần ini `--level 3` (platform) chỉ áp base L2 + nhắc rằng platform là service deploy riêng; `--level 4` (enterprise) bị từ chối (chưa ship). +`casan uninstall` xóa workflow CASAN trong `.gitea` và các scaffold file còn +nguyên checksum, sau đó prune thư mục rỗng. Workflow của project và scaffold +file đã chỉnh sửa được giữ lại. Thêm `--purge` để xóa cả runtime evidence +`.specify/logs` và `.specify/state`. + `casan init` chỉ ghi **config per-project** (không copy harness): | File | Vai trò | diff --git a/packages/casan-devkit/casan-init.py b/packages/casan-devkit/casan-init.py index ac27884..2a568dc 100755 --- a/packages/casan-devkit/casan-init.py +++ b/packages/casan-devkit/casan-init.py @@ -27,6 +27,7 @@ from __future__ import annotations import argparse from contextlib import contextmanager +import hashlib import json import os import re @@ -359,6 +360,33 @@ def _load_json_or(path, default): return default +def _safe_project_path(target, relative): + """Resolve a manifest-owned relative path without allowing target escape.""" + normalized = str(relative).replace("\\", "/").strip("/") + if not normalized or os.path.isabs(str(relative)): + return None + candidate = os.path.abspath(os.path.join(target, *normalized.split("/"))) + try: + if os.path.commonpath((target, candidate)) != target: + return None + except ValueError: + return None + return candidate + + +def _file_sha256(path): + if not os.path.isfile(path) or os.path.islink(path): + return None + digest = hashlib.sha256() + try: + with open(path, "rb") as fh: + for chunk in iter(lambda: fh.read(1024 * 1024), b""): + digest.update(chunk) + except OSError: + return None + return digest.hexdigest() + + def _backup_once(path, backups): if backups is None: return @@ -814,14 +842,35 @@ def cmd_init(args): for path in previous_manifest.get("owned_files", []) if isinstance(path, str) } + previous_owned_hashes = { + str(path).replace("\\", "/"): digest + for path, digest in previous_manifest.get( + "owned_file_hashes", {}).items() + if isinstance(path, str) and isinstance(digest, str) + } + newly_owned_files = set() + level_removed = [] + level_retained = [] + if not apply_devkit: + devkit_owned_files = { + path for path in owned_files + if (path.startswith(".gitea/workflows/casan-") or + path.startswith("apps/")) + } + level_removed, level_retained = _remove_owned_project_artifacts( + target, devkit_owned_files, previous_owned_hashes) + for relative in level_removed: + owned_files.discard(relative) + previous_owned_hashes.pop(relative, None) def created_add(p): created.append(os.path.relpath(p, target)) def mark_owned_if_absent(p): if not os.path.exists(p): - owned_files.add( - os.path.relpath(p, target).replace(os.sep, "/")) + relative = os.path.relpath(p, target).replace(os.sep, "/") + owned_files.add(relative) + newly_owned_files.add(relative) # ── .casan/config.json ── cfg_dir = os.path.join(target, ".casan") @@ -969,24 +1018,41 @@ def cmd_init(args): ci_src = os.path.join(dk, "templates", "gitea-workflow", "ci.yml") ci_dst = os.path.join(target, ".gitea", "workflows", "casan-ci.yml") if _copy_if_absent(ci_src, ci_dst, None, target, created): - owned_files.add(os.path.relpath( - ci_dst, target).replace(os.sep, "/")) + relative = os.path.relpath( + ci_dst, target).replace(os.sep, "/") + owned_files.add(relative) + newly_owned_files.add(relative) level_extras.append(".gitea/workflows/casan-ci.yml") dom_src = os.path.join(dk, "templates", "domain-pack") dom_dst = os.path.join(target, "apps", project, "domain") created_before_domain = len(created) n = _copy_tree_missing(dom_src, dom_dst, target, created) if n: - owned_files.update( + domain_files = { path.replace(os.sep, "/") - for path in created[created_before_domain:]) + for path in created[created_before_domain:] + } + owned_files.update(domain_files) + newly_owned_files.update(domain_files) level_extras.append("apps/%s/domain (%d files)" % (project, n)) # ── manifest (so uninstall/verify know what init created) ── + owned_file_hashes = { + path: digest + for path, digest in previous_owned_hashes.items() + if path in owned_files + } + for relative in newly_owned_files: + owned_path = _safe_project_path(target, relative) + digest = _file_sha256(owned_path) if owned_path else None + if digest: + owned_file_hashes[relative] = digest manifest = { + "manifest_version": 2, "created": created, "backups": [os.path.relpath(b, target) for b in backups], "owned_files": sorted(owned_files), + "owned_file_hashes": dict(sorted(owned_file_hashes.items())), "project_id": project, "at": now_iso(), } @@ -1008,6 +1074,8 @@ def cmd_init(args): "vscode_extension": vscode_install, "legacy_migration": legacy_migration, "level_extras": level_extras, + "level_removed": level_removed, + "level_retained": level_retained, "note": ("harness NOT copied into repo (hybrid model); selected client hooks " "MERGED and unselected CASAN hooks removed; run `casan doctor`"), } @@ -1300,6 +1368,54 @@ def _prune_empty_directory(path): pass +def _prune_empty_parents(path, target): + parent = os.path.dirname(path) + while parent and parent != target: + try: + os.rmdir(parent) + except OSError: + break + parent = os.path.dirname(parent) + + +def _remove_owned_project_artifacts(target, owned_files, owned_file_hashes): + """Remove safe standalone artifacts while preserving merged/user content.""" + removed = [] + retained = [] + integration_owned_files = { + ".claude/settings.json", + ".codex/hooks.json", + ".codex/config.toml", + ".vscode/extensions.json", + ".specify/.gitignore", + } + for relative in sorted( + owned_files, key=lambda item: (item.count("/"), item), + reverse=True): + if (relative in integration_owned_files or + relative.startswith(".casan/")): + continue + path = _safe_project_path(target, relative) + if not path: + retained.append("%s (invalid ownership path)" % relative) + continue + if not os.path.isfile(path) and not os.path.islink(path): + continue + expected_hash = owned_file_hashes.get(relative) + current_hash = _file_sha256(path) + casan_namespaced_workflow = ( + relative.startswith(".gitea/workflows/casan-") and + relative.endswith((".yml", ".yaml"))) + if casan_namespaced_workflow or ( + expected_hash and current_hash == expected_hash): + _remove_owned_file(path, removed, target) + _prune_empty_parents(path, target) + else: + retained.append( + "%s (modified or legacy file; preserved)" % relative) + return removed, retained + + def _uninstall_vscode_extension(enabled): if not enabled: return { @@ -1349,6 +1465,11 @@ def cmd_uninstall(args): for path in manifest.get("owned_files", []) if isinstance(path, str) } + owned_file_hashes = { + str(path).replace("\\", "/"): digest + for path, digest in manifest.get("owned_file_hashes", {}).items() + if isinstance(path, str) and isinstance(digest, str) + } hook_changes = {} integration_files = ( (".claude/settings.json", "claude_hook.py", "casan-hook.py"), @@ -1379,7 +1500,9 @@ def cmd_uninstall(args): vscode_path, "_casan_unused", "", ".vscode/extensions.json" in owned_files) - removed = [] + removed, retained = _remove_owned_project_artifacts( + target, owned_files, owned_file_hashes) + for relative in ( ".casan/config.json", ".casan/version.lock", @@ -1403,14 +1526,8 @@ def cmd_uninstall(args): ".casan", ".claude", ".codex", ".vscode", ".specify"): _prune_empty_directory(os.path.join(target, relative)) - retained = [] if not args.purge and os.path.isdir(os.path.join(target, ".specify")): retained.append(".specify/ runtime evidence (use --purge to remove)") - for item in manifest.get("created", []): - normalized = str(item).replace("\\", "/") - if (normalized.startswith(".gitea/") or - normalized.startswith("apps/")): - retained.append(normalized + " (may contain project changes)") backups = manifest.get("backups", []) if backups: retained.append("%d .casan-bak backup(s)" % len(backups)) @@ -1448,8 +1565,8 @@ def main(argv=None): "--vscode-install", choices=["auto", "yes", "no"], default="auto", help=("install the local CASAN @casan VSIX when vscode-copilot is selected " "(default auto: install when `code` is available)")) - pi.add_argument("--level", default="devkit", - help="packaging level to adopt: 1|core, 2|devkit (default), 3|platform (preview), 4|enterprise (refused)") + pi.add_argument("--level", default="core", + help="packaging level to adopt: 1|core (default), 2|devkit, 3|platform (preview), 4|enterprise (refused)") pi.add_argument("--mode", choices=["observe", "enforce"], default="enforce", help="agentic policy mode (default: enforce; use observe for a telemetry-only pilot)") pi.add_argument("--integration-mode", dest="integration_mode", diff --git a/packages/casan-devkit/tests/hybrid-install-tests.sh b/packages/casan-devkit/tests/hybrid-install-tests.sh index 8297448..89af608 100755 --- a/packages/casan-devkit/tests/hybrid-install-tests.sh +++ b/packages/casan-devkit/tests/hybrid-install-tests.sh @@ -180,6 +180,12 @@ PID=$(python3 -c 'import json;print(json.load(open("'"$PROJ2"'/.casan/config.jso [[ "$PID" =~ ^[a-z][a-z0-9-]{1,62}$ ]] && pass "defaulted project id is sanitized ($PID)" || fail "bad default project id ($PID)" MODE=$(python3 -c 'import json;print(json.load(open("'"$PROJ2"'/.casan/config.json"))["enforcement_mode"])' 2>/dev/null) [[ "$MODE" == "enforce" ]] && pass "production init defaults to enforce mode" || fail "default mode is not enforce ($MODE)" +DEFAULT_LEVEL=$(python3 -c 'import json;print(json.load(open("'"$PROJ2"'/.casan/config.json"))["target_level"])' 2>/dev/null) +[[ "$DEFAULT_LEVEL" == "1" ]] \ + && [ ! -d "$PROJ2/.gitea" ] \ + && [ ! -d "$PROJ2/apps" ] \ + && pass "existing-project init defaults to core without CI/domain scaffold" \ + || fail "default init did not stay at core (level=$DEFAULT_LEVEL)" echo "===== ⑦ init MERGES into an existing shell (agents/skills/hooks preserved) =====" EXP="$WORK/existing"; mkdir -p "$EXP/.claude/agents" "$EXP/.claude/skills" "$EXP/.codex" @@ -240,6 +246,13 @@ L4RC=0; L4="$WORK/l4"; mkdir -p "$L4"; ( cd "$L4" && "$DKC" init --level 4 --pro [ "$L4RC" -eq 3 ] && pass "init --level 4 (enterprise) refused (rc=3)" || fail "L4 init not refused (rc=$L4RC)" LVL=$( ( cd "$L2" && "$DKC" level show --json ) | python3 -c 'import json,sys;print(json.load(sys.stdin)["project_target_level"])' 2>/dev/null) [ "$LVL" = "2" ] && pass "casan level show reports project target level" || fail "level show wrong ($LVL)" +( cd "$L2" && "$DKC" init --project l2 --client none --non-interactive >/dev/null ) +LVL_DOWN=$( ( cd "$L2" && "$DKC" level show --json ) | python3 -c 'import json,sys;print(json.load(sys.stdin)["project_target_level"])' 2>/dev/null) +[ "$LVL_DOWN" = "1" ] \ + && [ ! -d "$L2/.gitea" ] \ + && [ ! -d "$L2/apps" ] \ + && pass "default init downgrades existing DevKit adoption to clean core" \ + || fail "default init left Level 2 artifacts after core downgrade" echo "===== ⑨ SAFETY: init refuses to adopt a CASAN source hub into itself =====" HUB="$WORK/fakehub" @@ -353,7 +366,7 @@ mkdir -p "$UN/.claude" cat > "$UN/.claude/settings.json" <<'EOF' {"model":"claude-opus-4-8","hooks":{"PreToolUse":[{"matcher":"*","hooks":[{"type":"command","command":"my-user-hook.sh"}]}]}} EOF -INIT_OUT=$(cd "$UN" && "$DKC" init --project uninstall-project --client claude --non-interactive) +INIT_OUT=$(cd "$UN" && "$DKC" init --level devkit --project uninstall-project --client claude --non-interactive) echo "$INIT_OUT" | grep -q "CASAN initialized" \ && pass "init defaults to concise human-readable output" \ || fail "init human output missing ($INIT_OUT)" @@ -366,6 +379,7 @@ fi 'import json,sys; d=json.load(sys.stdin); assert d["status"] == "ready"' \ && pass "doctor --json preserves the machine-readable contract" \ || fail "doctor --json is not valid/ready" +echo "project-owned requirement" > "$UN/apps/uninstall-project/domain/input/requirement.md" UN_OUT=$(cd "$UN" && "$DKC" uninstall) echo "$UN_OUT" | grep -q "CASAN removed from project" \ && pass "uninstall emits a concise completion summary" \ @@ -381,6 +395,14 @@ if py_check "$UN/.claude/settings.json" "casan-hook.py"; then else pass "uninstall removes only the CASAN hook" fi +[ ! -e "$UN/.gitea/workflows/casan-ci.yml" ] \ + && [ ! -d "$UN/.gitea" ] \ + && pass "uninstall removes CASAN Gitea workflow and prunes empty .gitea" \ + || fail "uninstall left CASAN-owned .gitea content" +[ -f "$UN/apps/uninstall-project/domain/input/requirement.md" ] \ + && [ ! -f "$UN/apps/uninstall-project/domain/input/architecture.md" ] \ + && pass "uninstall removes unchanged scaffold files but preserves modified project content" \ + || fail "uninstall ownership checksum handling is unsafe" [ -d "$UN/.specify" ] \ && pass "uninstall retains runtime evidence unless --purge is explicit" \ || fail "uninstall removed evidence without --purge" @@ -389,6 +411,27 @@ fi && pass "a follow-up uninstall --purge removes retained evidence" \ || fail "uninstall --purge did not remove retained evidence" +UN_KEEP="$WORK/uninstall-user-workflow" +mkdir -p "$UN_KEEP/.gitea/workflows" +echo "name: user-workflow" > "$UN_KEEP/.gitea/workflows/user-ci.yml" +( cd "$UN_KEEP" && "$DKC" init --level devkit --project uninstall-user-workflow --client none --non-interactive >/dev/null ) +python3 - "$UN_KEEP/.casan/init-manifest.json" <<'PY' +import json +import sys +path = sys.argv[1] +data = json.load(open(path, encoding="utf-8")) +data.pop("manifest_version", None) +data.pop("owned_file_hashes", None) +with open(path, "w", encoding="utf-8") as handle: + json.dump(data, handle, indent=2) + handle.write("\n") +PY +( cd "$UN_KEEP" && "$DKC" uninstall >/dev/null ) +[ -f "$UN_KEEP/.gitea/workflows/user-ci.yml" ] \ + && [ ! -f "$UN_KEEP/.gitea/workflows/casan-ci.yml" ] \ + && pass "legacy uninstall preserves user Gitea workflow and removes CASAN workflow" \ + || fail "uninstall removed a user workflow or retained the CASAN workflow" + echo "" echo "===== HYBRID INSTALL SUMMARY: PASS=$PASS FAIL=$FAIL =====" [[ "$FAIL" -eq 0 ]] || exit 1