fix: fully clean project adoption artifacts
This commit is contained in:
@@ -66,14 +66,15 @@ release tooling. Source repository vẫn giữ tests để kiểm chứng chính
|
|||||||
cd <project-root>
|
cd <project-root>
|
||||||
|
|
||||||
# Chỉ thêm governance config/hooks; không thêm domain-pack hoặc CI template
|
# 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 doctor
|
||||||
casan verify-harness
|
casan verify-harness
|
||||||
```
|
```
|
||||||
|
|
||||||
`casan init` có menu chọn client khi chạy tương tác. Trong automation nên chỉ
|
`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
|
```bash
|
||||||
casan init --level core --client claude
|
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 claude,codex
|
||||||
casan init --level core --client vscode-copilot --vscode-install yes
|
casan init --level core --client vscode-copilot --vscode-install yes
|
||||||
casan init --level core --client all
|
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.
|
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
|
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
|
`uninstall` xóa workflow CASAN trong `.gitea`, xóa các scaffold file CASAN còn
|
||||||
source code của project, không tự xóa `.casan-bak`, và không mặc định gỡ VS Code
|
nguyên checksum và tự dọn thư mục cha khi đã rỗng. Workflow/file của project,
|
||||||
extension dùng chung cho các project khác.
|
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:
|
Khi nâng cấp CASAN:
|
||||||
|
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ casan — CASAN governance harness CLI ($(version))
|
|||||||
Usage: casan <command> [args]
|
Usage: casan <command> [args]
|
||||||
|
|
||||||
Commands:
|
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)
|
uninstall [--purge] Remove CASAN from this project (preserves user config)
|
||||||
doctor [--client ...] Verify configured hooks, pin, adapters, and VS Code route
|
doctor [--client ...] Verify configured hooks, pin, adapters, and VS Code route
|
||||||
level <show|set 1..4> Show / change the project's packaging level
|
level <show|set 1..4> Show / change the project's packaging level
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ cục bộ), `CASAN_DIST_URL` (tải tarball), `CASAN_NO_PATH_LINK=1` (không t
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd <dự-án-của-bạn>
|
cd <dự-án-của-bạn>
|
||||||
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:
|
# hoặc chọn level áp dụng cho project:
|
||||||
casan init --level 1 --project my-app --client claude
|
casan init --level 1 --project my-app --client claude
|
||||||
casan init --level 2 --project my-app --client claude,codex
|
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.
|
**Á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.
|
- `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`
|
- 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
|
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 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).
|
`--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):
|
`casan init` chỉ ghi **config per-project** (không copy harness):
|
||||||
|
|
||||||
| File | Vai trò |
|
| File | Vai trò |
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
@@ -359,6 +360,33 @@ def _load_json_or(path, default):
|
|||||||
return 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):
|
def _backup_once(path, backups):
|
||||||
if backups is None:
|
if backups is None:
|
||||||
return
|
return
|
||||||
@@ -814,14 +842,35 @@ def cmd_init(args):
|
|||||||
for path in previous_manifest.get("owned_files", [])
|
for path in previous_manifest.get("owned_files", [])
|
||||||
if isinstance(path, str)
|
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):
|
def created_add(p):
|
||||||
created.append(os.path.relpath(p, target))
|
created.append(os.path.relpath(p, target))
|
||||||
|
|
||||||
def mark_owned_if_absent(p):
|
def mark_owned_if_absent(p):
|
||||||
if not os.path.exists(p):
|
if not os.path.exists(p):
|
||||||
owned_files.add(
|
relative = os.path.relpath(p, target).replace(os.sep, "/")
|
||||||
os.path.relpath(p, target).replace(os.sep, "/"))
|
owned_files.add(relative)
|
||||||
|
newly_owned_files.add(relative)
|
||||||
|
|
||||||
# ── .casan/config.json ──
|
# ── .casan/config.json ──
|
||||||
cfg_dir = os.path.join(target, ".casan")
|
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_src = os.path.join(dk, "templates", "gitea-workflow", "ci.yml")
|
||||||
ci_dst = os.path.join(target, ".gitea", "workflows", "casan-ci.yml")
|
ci_dst = os.path.join(target, ".gitea", "workflows", "casan-ci.yml")
|
||||||
if _copy_if_absent(ci_src, ci_dst, None, target, created):
|
if _copy_if_absent(ci_src, ci_dst, None, target, created):
|
||||||
owned_files.add(os.path.relpath(
|
relative = os.path.relpath(
|
||||||
ci_dst, target).replace(os.sep, "/"))
|
ci_dst, target).replace(os.sep, "/")
|
||||||
|
owned_files.add(relative)
|
||||||
|
newly_owned_files.add(relative)
|
||||||
level_extras.append(".gitea/workflows/casan-ci.yml")
|
level_extras.append(".gitea/workflows/casan-ci.yml")
|
||||||
dom_src = os.path.join(dk, "templates", "domain-pack")
|
dom_src = os.path.join(dk, "templates", "domain-pack")
|
||||||
dom_dst = os.path.join(target, "apps", project, "domain")
|
dom_dst = os.path.join(target, "apps", project, "domain")
|
||||||
created_before_domain = len(created)
|
created_before_domain = len(created)
|
||||||
n = _copy_tree_missing(dom_src, dom_dst, target, created)
|
n = _copy_tree_missing(dom_src, dom_dst, target, created)
|
||||||
if n:
|
if n:
|
||||||
owned_files.update(
|
domain_files = {
|
||||||
path.replace(os.sep, "/")
|
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))
|
level_extras.append("apps/%s/domain (%d files)" % (project, n))
|
||||||
|
|
||||||
# ── manifest (so uninstall/verify know what init created) ──
|
# ── 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 = {
|
||||||
|
"manifest_version": 2,
|
||||||
"created": created,
|
"created": created,
|
||||||
"backups": [os.path.relpath(b, target) for b in backups],
|
"backups": [os.path.relpath(b, target) for b in backups],
|
||||||
"owned_files": sorted(owned_files),
|
"owned_files": sorted(owned_files),
|
||||||
|
"owned_file_hashes": dict(sorted(owned_file_hashes.items())),
|
||||||
"project_id": project,
|
"project_id": project,
|
||||||
"at": now_iso(),
|
"at": now_iso(),
|
||||||
}
|
}
|
||||||
@@ -1008,6 +1074,8 @@ def cmd_init(args):
|
|||||||
"vscode_extension": vscode_install,
|
"vscode_extension": vscode_install,
|
||||||
"legacy_migration": legacy_migration,
|
"legacy_migration": legacy_migration,
|
||||||
"level_extras": level_extras,
|
"level_extras": level_extras,
|
||||||
|
"level_removed": level_removed,
|
||||||
|
"level_retained": level_retained,
|
||||||
"note": ("harness NOT copied into repo (hybrid model); selected client hooks "
|
"note": ("harness NOT copied into repo (hybrid model); selected client hooks "
|
||||||
"MERGED and unselected CASAN hooks removed; run `casan doctor`"),
|
"MERGED and unselected CASAN hooks removed; run `casan doctor`"),
|
||||||
}
|
}
|
||||||
@@ -1300,6 +1368,54 @@ def _prune_empty_directory(path):
|
|||||||
pass
|
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):
|
def _uninstall_vscode_extension(enabled):
|
||||||
if not enabled:
|
if not enabled:
|
||||||
return {
|
return {
|
||||||
@@ -1349,6 +1465,11 @@ def cmd_uninstall(args):
|
|||||||
for path in manifest.get("owned_files", [])
|
for path in manifest.get("owned_files", [])
|
||||||
if isinstance(path, str)
|
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 = {}
|
hook_changes = {}
|
||||||
integration_files = (
|
integration_files = (
|
||||||
(".claude/settings.json", "claude_hook.py", "casan-hook.py"),
|
(".claude/settings.json", "claude_hook.py", "casan-hook.py"),
|
||||||
@@ -1379,7 +1500,9 @@ def cmd_uninstall(args):
|
|||||||
vscode_path, "_casan_unused", "",
|
vscode_path, "_casan_unused", "",
|
||||||
".vscode/extensions.json" in owned_files)
|
".vscode/extensions.json" in owned_files)
|
||||||
|
|
||||||
removed = []
|
removed, retained = _remove_owned_project_artifacts(
|
||||||
|
target, owned_files, owned_file_hashes)
|
||||||
|
|
||||||
for relative in (
|
for relative in (
|
||||||
".casan/config.json",
|
".casan/config.json",
|
||||||
".casan/version.lock",
|
".casan/version.lock",
|
||||||
@@ -1403,14 +1526,8 @@ def cmd_uninstall(args):
|
|||||||
".casan", ".claude", ".codex", ".vscode", ".specify"):
|
".casan", ".claude", ".codex", ".vscode", ".specify"):
|
||||||
_prune_empty_directory(os.path.join(target, relative))
|
_prune_empty_directory(os.path.join(target, relative))
|
||||||
|
|
||||||
retained = []
|
|
||||||
if not args.purge and os.path.isdir(os.path.join(target, ".specify")):
|
if not args.purge and os.path.isdir(os.path.join(target, ".specify")):
|
||||||
retained.append(".specify/ runtime evidence (use --purge to remove)")
|
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", [])
|
backups = manifest.get("backups", [])
|
||||||
if backups:
|
if backups:
|
||||||
retained.append("%d .casan-bak backup(s)" % len(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",
|
"--vscode-install", choices=["auto", "yes", "no"], default="auto",
|
||||||
help=("install the local CASAN @casan VSIX when vscode-copilot is selected "
|
help=("install the local CASAN @casan VSIX when vscode-copilot is selected "
|
||||||
"(default auto: install when `code` is available)"))
|
"(default auto: install when `code` is available)"))
|
||||||
pi.add_argument("--level", default="devkit",
|
pi.add_argument("--level", default="core",
|
||||||
help="packaging level to adopt: 1|core, 2|devkit (default), 3|platform (preview), 4|enterprise (refused)")
|
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",
|
pi.add_argument("--mode", choices=["observe", "enforce"], default="enforce",
|
||||||
help="agentic policy mode (default: enforce; use observe for a telemetry-only pilot)")
|
help="agentic policy mode (default: enforce; use observe for a telemetry-only pilot)")
|
||||||
pi.add_argument("--integration-mode", dest="integration_mode",
|
pi.add_argument("--integration-mode", dest="integration_mode",
|
||||||
|
|||||||
@@ -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)"
|
[[ "$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=$(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)"
|
[[ "$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) ====="
|
echo "===== ⑦ init MERGES into an existing shell (agents/skills/hooks preserved) ====="
|
||||||
EXP="$WORK/existing"; mkdir -p "$EXP/.claude/agents" "$EXP/.claude/skills" "$EXP/.codex"
|
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)"
|
[ "$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=$( ( 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)"
|
[ "$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 ====="
|
echo "===== ⑨ SAFETY: init refuses to adopt a CASAN source hub into itself ====="
|
||||||
HUB="$WORK/fakehub"
|
HUB="$WORK/fakehub"
|
||||||
@@ -353,7 +366,7 @@ mkdir -p "$UN/.claude"
|
|||||||
cat > "$UN/.claude/settings.json" <<'EOF'
|
cat > "$UN/.claude/settings.json" <<'EOF'
|
||||||
{"model":"claude-opus-4-8","hooks":{"PreToolUse":[{"matcher":"*","hooks":[{"type":"command","command":"my-user-hook.sh"}]}]}}
|
{"model":"claude-opus-4-8","hooks":{"PreToolUse":[{"matcher":"*","hooks":[{"type":"command","command":"my-user-hook.sh"}]}]}}
|
||||||
EOF
|
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" \
|
echo "$INIT_OUT" | grep -q "CASAN initialized" \
|
||||||
&& pass "init defaults to concise human-readable output" \
|
&& pass "init defaults to concise human-readable output" \
|
||||||
|| fail "init human output missing ($INIT_OUT)"
|
|| fail "init human output missing ($INIT_OUT)"
|
||||||
@@ -366,6 +379,7 @@ fi
|
|||||||
'import json,sys; d=json.load(sys.stdin); assert d["status"] == "ready"' \
|
'import json,sys; d=json.load(sys.stdin); assert d["status"] == "ready"' \
|
||||||
&& pass "doctor --json preserves the machine-readable contract" \
|
&& pass "doctor --json preserves the machine-readable contract" \
|
||||||
|| fail "doctor --json is not valid/ready"
|
|| 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)
|
UN_OUT=$(cd "$UN" && "$DKC" uninstall)
|
||||||
echo "$UN_OUT" | grep -q "CASAN removed from project" \
|
echo "$UN_OUT" | grep -q "CASAN removed from project" \
|
||||||
&& pass "uninstall emits a concise completion summary" \
|
&& pass "uninstall emits a concise completion summary" \
|
||||||
@@ -381,6 +395,14 @@ if py_check "$UN/.claude/settings.json" "casan-hook.py"; then
|
|||||||
else
|
else
|
||||||
pass "uninstall removes only the CASAN hook"
|
pass "uninstall removes only the CASAN hook"
|
||||||
fi
|
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" ] \
|
[ -d "$UN/.specify" ] \
|
||||||
&& pass "uninstall retains runtime evidence unless --purge is explicit" \
|
&& pass "uninstall retains runtime evidence unless --purge is explicit" \
|
||||||
|| fail "uninstall removed evidence without --purge"
|
|| fail "uninstall removed evidence without --purge"
|
||||||
@@ -389,6 +411,27 @@ fi
|
|||||||
&& pass "a follow-up uninstall --purge removes retained evidence" \
|
&& pass "a follow-up uninstall --purge removes retained evidence" \
|
||||||
|| fail "uninstall --purge did not remove 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 ""
|
||||||
echo "===== HYBRID INSTALL SUMMARY: PASS=$PASS FAIL=$FAIL ====="
|
echo "===== HYBRID INSTALL SUMMARY: PASS=$PASS FAIL=$FAIL ====="
|
||||||
[[ "$FAIL" -eq 0 ]] || exit 1
|
[[ "$FAIL" -eq 0 ]] || exit 1
|
||||||
|
|||||||
Reference in New Issue
Block a user