fix: fully clean project adoption artifacts
This commit is contained in:
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user