superpowers-manager 0.1.4 → 0.2.0
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.
- package/README.md +180 -82
- package/bin/superpowers-manager.js +85 -30
- package/package.json +2 -1
- package/scripts/adapters/codex/adapter +603 -0
- package/scripts/adapters/codex/lib.sh +449 -0
- package/scripts/{validate-generated-plugin.py → adapters/codex/validate-generated-plugin.py} +3 -3
- package/scripts/core/adapter.sh +103 -0
- package/scripts/core/common.sh +59 -0
- package/scripts/core/lifecycle.sh +141 -0
- package/scripts/core/provenance.sh +135 -0
- package/scripts/core/selection-state.py +414 -0
- package/scripts/core/selection.sh +168 -0
- package/scripts/core/status.sh +27 -0
- package/scripts/core/upstream.sh +280 -0
- package/scripts/core/validate-adapter-response.py +284 -0
- package/scripts/install +30 -29
- package/scripts/pin +51 -0
- package/scripts/prepare +60 -79
- package/scripts/probe +62 -30
- package/scripts/track-latest +30 -0
- package/scripts/uninstall +21 -47
- package/scripts/unpin +41 -0
- package/scripts/update +8 -10
- package/scripts/lib.sh +0 -651
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# Sourced module; callers own set -eu.
|
|
3
|
+
|
|
4
|
+
# Replace the live tree with a single atomic rename, keeping a backup so a
|
|
5
|
+
# failed swap restores the previous working generated tree. candidate and
|
|
6
|
+
# live_root must be same-filesystem siblings for atomic renames.
|
|
7
|
+
spw_replace_generated_tree() {
|
|
8
|
+
candidate="$1"
|
|
9
|
+
live_root="$2"
|
|
10
|
+
parent=$(dirname "$live_root")
|
|
11
|
+
backup="$parent/.superpowers.bak.$$"
|
|
12
|
+
|
|
13
|
+
rm -rf "$backup"
|
|
14
|
+
if [ -e "$live_root" ]; then
|
|
15
|
+
mv "$live_root" "$backup"
|
|
16
|
+
fi
|
|
17
|
+
if mv "$candidate" "$live_root"; then
|
|
18
|
+
rm -rf "$backup"
|
|
19
|
+
else
|
|
20
|
+
if [ -e "$backup" ]; then
|
|
21
|
+
mv "$backup" "$live_root"
|
|
22
|
+
fi
|
|
23
|
+
rm -rf "$candidate"
|
|
24
|
+
spw_die "failed to install generated tree into $live_root; previous tree restored"
|
|
25
|
+
fi
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
spw_generated_metadata_path() (
|
|
29
|
+
generated_root="$1"
|
|
30
|
+
printf '%s\n' "$generated_root/plugins/superpowers/.superpowers-upstream.json"
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
spw_generated_commit_or_empty() (
|
|
34
|
+
generated_root="$1"
|
|
35
|
+
generated_metadata=$(spw_generated_metadata_path "$generated_root")
|
|
36
|
+
spw_metadata_commit_lenient_or_empty "$generated_metadata"
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
spw_probe_field() {
|
|
40
|
+
printf '%s\n' "$1" | awk -F= -v key="$2" '$1 == key { print $2 }'
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
spw_require_no_legacy_state() {
|
|
44
|
+
identity_state="$1"
|
|
45
|
+
case "$identity_state" in
|
|
46
|
+
neither|manager)
|
|
47
|
+
return 0
|
|
48
|
+
;;
|
|
49
|
+
legacy|both)
|
|
50
|
+
printf '%s\n' \
|
|
51
|
+
'Legacy superpowers-wrapper Codex state is installed.' \
|
|
52
|
+
'Run: npx superpowers-wrapper@0.1.1 uninstall' \
|
|
53
|
+
'Then run: npx superpowers-manager install' >&2
|
|
54
|
+
return 1
|
|
55
|
+
;;
|
|
56
|
+
*)
|
|
57
|
+
spw_die "unknown adapter identity state: $identity_state"
|
|
58
|
+
;;
|
|
59
|
+
esac
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
spw_require_managed_update_control() {
|
|
63
|
+
case "$1" in
|
|
64
|
+
managed) return 0 ;;
|
|
65
|
+
unsupported)
|
|
66
|
+
spw_die "adapter cannot guarantee manager-controlled updates"
|
|
67
|
+
;;
|
|
68
|
+
*) spw_die "unknown adapter update-control capability: $1" ;;
|
|
69
|
+
esac
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
spw_report_legacy_state() {
|
|
73
|
+
case "$1" in
|
|
74
|
+
legacy|both)
|
|
75
|
+
printf '%s\n' \
|
|
76
|
+
'Legacy superpowers-wrapper Codex state remains installed.' \
|
|
77
|
+
'Run: npx superpowers-wrapper@0.1.1 uninstall'
|
|
78
|
+
;;
|
|
79
|
+
neither|manager)
|
|
80
|
+
;;
|
|
81
|
+
*)
|
|
82
|
+
spw_die "unknown adapter identity state: $1"
|
|
83
|
+
;;
|
|
84
|
+
esac
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
spw_verify_installed_fingerprint() {
|
|
88
|
+
desired_commit="$1"
|
|
89
|
+
install_result="$2"
|
|
90
|
+
inspect_result="$3"
|
|
91
|
+
if ! spw_inspect_fingerprint "$inspect_result"; then
|
|
92
|
+
echo "error: installed manager fingerprint inspection failed after install." >&2
|
|
93
|
+
return 1
|
|
94
|
+
fi
|
|
95
|
+
if ! installed_commit=$(spw_adapter_result_get "$inspect_result" "fingerprint"); then
|
|
96
|
+
echo "error: cannot parse installed manager fingerprint inspection result after install." >&2
|
|
97
|
+
return 1
|
|
98
|
+
fi
|
|
99
|
+
printf 'desired_commit=%s\n' "$desired_commit"
|
|
100
|
+
printf 'installed_commit=%s\n' "$installed_commit"
|
|
101
|
+
if [ -n "$installed_commit" ] && spw_commit_matches "$desired_commit" "$installed_commit"; then
|
|
102
|
+
echo "manager updated"
|
|
103
|
+
return 0
|
|
104
|
+
fi
|
|
105
|
+
|
|
106
|
+
hint=""
|
|
107
|
+
if [ -f "$install_result" ]; then
|
|
108
|
+
if [ -n "$installed_commit" ]; then
|
|
109
|
+
hint=$(spw_adapter_result_get "$install_result" "verification_hints.mismatch" || true)
|
|
110
|
+
else
|
|
111
|
+
hint=$(spw_adapter_result_get "$install_result" "verification_hints.missing" || true)
|
|
112
|
+
fi
|
|
113
|
+
fi
|
|
114
|
+
|
|
115
|
+
if [ -n "$installed_commit" ]; then
|
|
116
|
+
echo "error: installed manager fingerprint does not match the prepared plugin after install." >&2
|
|
117
|
+
else
|
|
118
|
+
echo "error: installed manager fingerprint is not detectable after install." >&2
|
|
119
|
+
fi
|
|
120
|
+
if [ -n "$hint" ]; then
|
|
121
|
+
echo "hint: $hint" >&2
|
|
122
|
+
fi
|
|
123
|
+
return 1
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
spw_verify_uninstalled_resources() {
|
|
127
|
+
inspect_result="$1"
|
|
128
|
+
|
|
129
|
+
if ! plugin_present=$(spw_adapter_result_boolean "$inspect_result" "resources.plugin"); then
|
|
130
|
+
return 1
|
|
131
|
+
fi
|
|
132
|
+
if ! marketplace_present=$(spw_adapter_result_boolean "$inspect_result" "resources.marketplace"); then
|
|
133
|
+
return 1
|
|
134
|
+
fi
|
|
135
|
+
if [ "$plugin_present" = true ]; then
|
|
136
|
+
spw_die "owned plugin resource is still installed after removal"
|
|
137
|
+
fi
|
|
138
|
+
if [ "$marketplace_present" = true ]; then
|
|
139
|
+
spw_die "owned marketplace resource is still registered after removal"
|
|
140
|
+
fi
|
|
141
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# Sourced module; callers own set -eu.
|
|
3
|
+
|
|
4
|
+
spw_json_get() {
|
|
5
|
+
file="$1"
|
|
6
|
+
key="$2"
|
|
7
|
+
if ! value=$(
|
|
8
|
+
python3 - "$file" "$key" 2>&1 <<'PY'
|
|
9
|
+
import json
|
|
10
|
+
import sys
|
|
11
|
+
|
|
12
|
+
path, dotted_key = sys.argv[1:]
|
|
13
|
+
MAX_JSON_NESTING = 256
|
|
14
|
+
|
|
15
|
+
def reject_constant(constant):
|
|
16
|
+
raise ValueError(f"non-standard numeric constant: {constant}")
|
|
17
|
+
|
|
18
|
+
def nesting_exceeds_limit(value):
|
|
19
|
+
stack = [(value, 0)]
|
|
20
|
+
while stack:
|
|
21
|
+
current, depth = stack.pop()
|
|
22
|
+
if isinstance(current, dict):
|
|
23
|
+
next_depth = depth + 1
|
|
24
|
+
if next_depth > MAX_JSON_NESTING:
|
|
25
|
+
return True
|
|
26
|
+
stack.extend((child, next_depth) for child in current.values())
|
|
27
|
+
elif isinstance(current, list):
|
|
28
|
+
next_depth = depth + 1
|
|
29
|
+
if next_depth > MAX_JSON_NESTING:
|
|
30
|
+
return True
|
|
31
|
+
stack.extend((child, next_depth) for child in current)
|
|
32
|
+
return False
|
|
33
|
+
|
|
34
|
+
try:
|
|
35
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
36
|
+
data = json.load(f, parse_constant=reject_constant)
|
|
37
|
+
except RecursionError:
|
|
38
|
+
sys.exit(f"JSON nesting exceeds limit in {path}")
|
|
39
|
+
except json.JSONDecodeError as exc:
|
|
40
|
+
sys.exit(
|
|
41
|
+
f"invalid JSON in {path}: "
|
|
42
|
+
f"line {exc.lineno} column {exc.colno}: {exc.msg}"
|
|
43
|
+
)
|
|
44
|
+
except (OSError, UnicodeError) as exc:
|
|
45
|
+
sys.exit(f"cannot read JSON in {path}: {exc}")
|
|
46
|
+
except ValueError as exc:
|
|
47
|
+
sys.exit(f"invalid JSON in {path}: {exc}")
|
|
48
|
+
|
|
49
|
+
if nesting_exceeds_limit(data):
|
|
50
|
+
sys.exit(f"JSON nesting exceeds limit in {path}")
|
|
51
|
+
|
|
52
|
+
if not isinstance(data, dict):
|
|
53
|
+
sys.exit(f"JSON value must be an object in {path}")
|
|
54
|
+
|
|
55
|
+
value = data
|
|
56
|
+
for part in dotted_key.split("."):
|
|
57
|
+
if not isinstance(value, dict):
|
|
58
|
+
value = ""
|
|
59
|
+
break
|
|
60
|
+
value = value.get(part, "")
|
|
61
|
+
try:
|
|
62
|
+
print(value if value is not None else "")
|
|
63
|
+
except (OSError, UnicodeError) as exc:
|
|
64
|
+
sys.exit(f"cannot output JSON value from {path}: {exc}")
|
|
65
|
+
PY
|
|
66
|
+
); then
|
|
67
|
+
spw_die "$value"
|
|
68
|
+
fi
|
|
69
|
+
printf '%s\n' "$value"
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
spw_write_metadata_json() {
|
|
73
|
+
file="$1"
|
|
74
|
+
source="$2"
|
|
75
|
+
requested_ref="$3"
|
|
76
|
+
resolved_ref="$4"
|
|
77
|
+
commit="$5"
|
|
78
|
+
upstream_manifest_version="$6"
|
|
79
|
+
python3 - "$file" "$source" "$requested_ref" "$resolved_ref" "$commit" "$upstream_manifest_version" <<'PY'
|
|
80
|
+
import json, sys
|
|
81
|
+
path, source, requested, resolved, commit, version = sys.argv[1:]
|
|
82
|
+
data = {
|
|
83
|
+
"source": source,
|
|
84
|
+
"requested_ref": requested,
|
|
85
|
+
"resolved_ref": resolved,
|
|
86
|
+
"commit": commit,
|
|
87
|
+
"upstream_manifest_version": version,
|
|
88
|
+
}
|
|
89
|
+
try:
|
|
90
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
91
|
+
json.dump(data, f, indent=2, allow_nan=False)
|
|
92
|
+
f.write("\n")
|
|
93
|
+
except RecursionError as exc:
|
|
94
|
+
sys.exit(f"JSON nesting exceeds limit while writing {path}: {exc}")
|
|
95
|
+
except (OSError, UnicodeError, ValueError) as exc:
|
|
96
|
+
sys.exit(f"cannot write JSON to {path}: {exc}")
|
|
97
|
+
PY
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
spw_metadata_commit_or_empty() {
|
|
101
|
+
file="$1"
|
|
102
|
+
if [ -f "$file" ]; then
|
|
103
|
+
spw_json_get "$file" "commit"
|
|
104
|
+
fi
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
spw_metadata_commit_lenient_or_empty() {
|
|
108
|
+
file="$1"
|
|
109
|
+
[ -f "$file" ] || return 0
|
|
110
|
+
|
|
111
|
+
if value=$(
|
|
112
|
+
python3 -S - "$file" 2>/dev/null <<'PY'
|
|
113
|
+
import json
|
|
114
|
+
import re
|
|
115
|
+
import sys
|
|
116
|
+
|
|
117
|
+
path = sys.argv[1]
|
|
118
|
+
|
|
119
|
+
def reject_constant(constant):
|
|
120
|
+
raise ValueError(f"non-standard numeric constant: {constant}")
|
|
121
|
+
|
|
122
|
+
try:
|
|
123
|
+
with open(path, "r", encoding="utf-8") as handle:
|
|
124
|
+
data = json.load(handle, parse_constant=reject_constant)
|
|
125
|
+
except (OSError, UnicodeError, ValueError, RecursionError):
|
|
126
|
+
raise SystemExit(1)
|
|
127
|
+
|
|
128
|
+
commit = data.get("commit") if isinstance(data, dict) else None
|
|
129
|
+
if isinstance(commit, str) and re.fullmatch(r"[0-9a-fA-F]{40}", commit):
|
|
130
|
+
print(commit)
|
|
131
|
+
PY
|
|
132
|
+
); then
|
|
133
|
+
printf '%s\n' "$value"
|
|
134
|
+
fi
|
|
135
|
+
}
|
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import errno
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
import re
|
|
10
|
+
import secrets
|
|
11
|
+
import stat
|
|
12
|
+
import sys
|
|
13
|
+
from urllib.parse import urlsplit
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
PINNED_KEYS = {
|
|
17
|
+
"schema_version", "mode", "source", "requested_ref", "resolved_ref", "commit"
|
|
18
|
+
}
|
|
19
|
+
TRACK_LATEST_KEYS = {"schema_version", "mode", "source"}
|
|
20
|
+
TAG_RE = re.compile(
|
|
21
|
+
r"v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)"
|
|
22
|
+
r"(?:-((?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)"
|
|
23
|
+
r"(?:\.(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*))?\Z"
|
|
24
|
+
)
|
|
25
|
+
COMMIT_RE = re.compile(r"[0-9a-f]{40}\Z")
|
|
26
|
+
COMMIT_INPUT_RE = re.compile(r"[0-9a-fA-F]{40}\Z")
|
|
27
|
+
MAX_JSON_NESTING = 256
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class SelectionError(Exception):
|
|
31
|
+
"""A controlled selection-state validation or persistence failure."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def require_object(raw: object, label: str) -> dict[str, object]:
|
|
35
|
+
if not isinstance(raw, dict):
|
|
36
|
+
raise SelectionError(f"{label} must be a JSON object")
|
|
37
|
+
return raw
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def require_exact_keys(record: dict[str, object], expected: set[str]) -> None:
|
|
41
|
+
actual = set(record)
|
|
42
|
+
if actual != expected:
|
|
43
|
+
missing = sorted(expected - actual)
|
|
44
|
+
unknown = sorted(actual - expected)
|
|
45
|
+
details = []
|
|
46
|
+
if missing:
|
|
47
|
+
details.append("missing " + ", ".join(missing))
|
|
48
|
+
if unknown:
|
|
49
|
+
details.append("unknown " + ", ".join(unknown))
|
|
50
|
+
raise SelectionError("selection state keys are invalid: " + "; ".join(details))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def require_single_line_string(value: object, label: str) -> str:
|
|
54
|
+
if not isinstance(value, str) or not value or any(char in value for char in "\r\n\0"):
|
|
55
|
+
raise SelectionError(f"{label} must be a non-empty single-line string")
|
|
56
|
+
return value
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def validate_source(raw: object) -> str:
|
|
60
|
+
source = require_single_line_string(raw, "source")
|
|
61
|
+
try:
|
|
62
|
+
parsed = urlsplit(source)
|
|
63
|
+
except (UnicodeError, ValueError) as exc:
|
|
64
|
+
raise SelectionError("source URL is malformed") from exc
|
|
65
|
+
if parsed.scheme.lower() in ("http", "https") and parsed.username is not None:
|
|
66
|
+
raise SelectionError("HTTP(S) source must not include userinfo")
|
|
67
|
+
return source
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def validate_pinned_record(
|
|
71
|
+
record: dict[str, object], source: str
|
|
72
|
+
) -> dict[str, object]:
|
|
73
|
+
requested_ref = require_single_line_string(record["requested_ref"], "requested_ref")
|
|
74
|
+
resolved_ref = require_single_line_string(record["resolved_ref"], "resolved_ref")
|
|
75
|
+
commit = require_single_line_string(record["commit"], "commit")
|
|
76
|
+
if not COMMIT_RE.fullmatch(commit):
|
|
77
|
+
raise SelectionError("commit must be a lowercase 40-hex value")
|
|
78
|
+
if TAG_RE.fullmatch(requested_ref):
|
|
79
|
+
if resolved_ref != requested_ref:
|
|
80
|
+
raise SelectionError("tag resolved_ref must equal requested_ref")
|
|
81
|
+
elif COMMIT_RE.fullmatch(requested_ref):
|
|
82
|
+
if resolved_ref != requested_ref or commit != requested_ref:
|
|
83
|
+
raise SelectionError(
|
|
84
|
+
"raw commit requested_ref, resolved_ref, and commit must be equal"
|
|
85
|
+
)
|
|
86
|
+
else:
|
|
87
|
+
raise SelectionError("requested_ref must be an exact tag or full commit")
|
|
88
|
+
return {
|
|
89
|
+
"schema_version": 1,
|
|
90
|
+
"mode": "pinned",
|
|
91
|
+
"source": source,
|
|
92
|
+
"requested_ref": requested_ref,
|
|
93
|
+
"resolved_ref": resolved_ref,
|
|
94
|
+
"commit": commit,
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def validate_record(raw: object) -> dict[str, object]:
|
|
99
|
+
record = require_object(raw, "selection state")
|
|
100
|
+
version = record.get("schema_version")
|
|
101
|
+
if type(version) is not int or version != 1:
|
|
102
|
+
raise SelectionError("schema_version must equal integer 1")
|
|
103
|
+
mode = record.get("mode")
|
|
104
|
+
expected = (
|
|
105
|
+
PINNED_KEYS if mode == "pinned"
|
|
106
|
+
else TRACK_LATEST_KEYS if mode == "track-latest"
|
|
107
|
+
else None
|
|
108
|
+
)
|
|
109
|
+
if expected is None:
|
|
110
|
+
raise SelectionError("mode must be pinned or track-latest")
|
|
111
|
+
require_exact_keys(record, expected)
|
|
112
|
+
source = validate_source(record["source"])
|
|
113
|
+
if mode == "track-latest":
|
|
114
|
+
return {"schema_version": 1, "mode": mode, "source": source}
|
|
115
|
+
return validate_pinned_record(record, source)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def normalize(record: dict[str, object] | None) -> dict[str, str]:
|
|
119
|
+
if record is None:
|
|
120
|
+
return {
|
|
121
|
+
"saved_mode": "none",
|
|
122
|
+
"saved_source": "",
|
|
123
|
+
"saved_requested_ref": "",
|
|
124
|
+
"saved_resolved_ref": "",
|
|
125
|
+
"saved_commit": "",
|
|
126
|
+
}
|
|
127
|
+
return {
|
|
128
|
+
"saved_mode": str(record["mode"]),
|
|
129
|
+
"saved_source": str(record["source"]),
|
|
130
|
+
"saved_requested_ref": str(record.get("requested_ref", "")),
|
|
131
|
+
"saved_resolved_ref": str(record.get("resolved_ref", "")),
|
|
132
|
+
"saved_commit": str(record.get("commit", "")),
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def reject_duplicate_keys(pairs: list[tuple[str, object]]) -> dict[str, object]:
|
|
137
|
+
result: dict[str, object] = {}
|
|
138
|
+
for key, value in pairs:
|
|
139
|
+
if key in result:
|
|
140
|
+
raise SelectionError(f"duplicate JSON key: {key}")
|
|
141
|
+
result[key] = value
|
|
142
|
+
return result
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def reject_constant(constant: str) -> None:
|
|
146
|
+
raise SelectionError(f"non-standard numeric constant: {constant}")
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def nesting_exceeds_limit(value: object) -> bool:
|
|
150
|
+
stack = [(value, 0)]
|
|
151
|
+
while stack:
|
|
152
|
+
current, depth = stack.pop()
|
|
153
|
+
if isinstance(current, dict):
|
|
154
|
+
next_depth = depth + 1
|
|
155
|
+
if next_depth > MAX_JSON_NESTING:
|
|
156
|
+
return True
|
|
157
|
+
stack.extend((child, next_depth) for child in current.values())
|
|
158
|
+
elif isinstance(current, list):
|
|
159
|
+
next_depth = depth + 1
|
|
160
|
+
if next_depth > MAX_JSON_NESTING:
|
|
161
|
+
return True
|
|
162
|
+
stack.extend((child, next_depth) for child in current)
|
|
163
|
+
return False
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def describe_path_type(path: Path, label: str) -> os.stat_result | None:
|
|
167
|
+
try:
|
|
168
|
+
info = path.lstat()
|
|
169
|
+
except FileNotFoundError:
|
|
170
|
+
return None
|
|
171
|
+
except OSError as exc:
|
|
172
|
+
raise SelectionError(f"cannot inspect {label} {path}: {exc}") from exc
|
|
173
|
+
if stat.S_ISLNK(info.st_mode):
|
|
174
|
+
raise SelectionError(f"{label} must not be a symlink: {path}")
|
|
175
|
+
return info
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def load_json(path: Path) -> object:
|
|
179
|
+
flags = os.O_RDONLY
|
|
180
|
+
if hasattr(os, "O_NOFOLLOW"):
|
|
181
|
+
flags |= os.O_NOFOLLOW
|
|
182
|
+
try:
|
|
183
|
+
fd = os.open(path, flags)
|
|
184
|
+
except OSError as exc:
|
|
185
|
+
raise SelectionError(f"cannot read selection state {path}: {exc}") from exc
|
|
186
|
+
try:
|
|
187
|
+
info = os.fstat(fd)
|
|
188
|
+
if not stat.S_ISREG(info.st_mode):
|
|
189
|
+
raise SelectionError(f"selection state must be a regular file: {path}")
|
|
190
|
+
with os.fdopen(fd, "r", encoding="utf-8") as handle:
|
|
191
|
+
fd = -1
|
|
192
|
+
raw = json.load(
|
|
193
|
+
handle,
|
|
194
|
+
object_pairs_hook=reject_duplicate_keys,
|
|
195
|
+
parse_constant=reject_constant,
|
|
196
|
+
)
|
|
197
|
+
except RecursionError as exc:
|
|
198
|
+
raise SelectionError(f"JSON nesting exceeds limit in {path}") from exc
|
|
199
|
+
except json.JSONDecodeError as exc:
|
|
200
|
+
raise SelectionError(
|
|
201
|
+
f"invalid JSON in {path}: line {exc.lineno} column {exc.colno}: {exc.msg}"
|
|
202
|
+
) from exc
|
|
203
|
+
except ValueError as exc:
|
|
204
|
+
raise SelectionError(f"invalid JSON in {path}: {exc}") from exc
|
|
205
|
+
except (OSError, UnicodeError) as exc:
|
|
206
|
+
raise SelectionError(f"cannot read selection state {path}: {exc}") from exc
|
|
207
|
+
finally:
|
|
208
|
+
if fd >= 0:
|
|
209
|
+
os.close(fd)
|
|
210
|
+
if nesting_exceeds_limit(raw):
|
|
211
|
+
raise SelectionError(f"JSON nesting exceeds limit in {path}")
|
|
212
|
+
return raw
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def load_record(path: Path) -> dict[str, object] | None:
|
|
216
|
+
parent_info = describe_path_type(path.parent, "selection state directory")
|
|
217
|
+
if parent_info is not None and not stat.S_ISDIR(parent_info.st_mode):
|
|
218
|
+
raise SelectionError(
|
|
219
|
+
f"selection state directory must be a directory: {path.parent}"
|
|
220
|
+
)
|
|
221
|
+
info = describe_path_type(path, "selection state")
|
|
222
|
+
if info is None:
|
|
223
|
+
return None
|
|
224
|
+
if not stat.S_ISREG(info.st_mode):
|
|
225
|
+
raise SelectionError(f"selection state must be a regular file: {path}")
|
|
226
|
+
return validate_record(load_json(path))
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def write_json_output(path: Path, value: object) -> None:
|
|
230
|
+
try:
|
|
231
|
+
with path.open("w", encoding="utf-8") as handle:
|
|
232
|
+
json.dump(value, handle, indent=2, allow_nan=False)
|
|
233
|
+
handle.write("\n")
|
|
234
|
+
except (OSError, UnicodeError, ValueError) as exc:
|
|
235
|
+
raise SelectionError(f"cannot write normalized selection output {path}: {exc}") from exc
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def ensure_state_directory(path: Path) -> None:
|
|
239
|
+
info = describe_path_type(path, "selection state directory")
|
|
240
|
+
if info is not None:
|
|
241
|
+
if not stat.S_ISDIR(info.st_mode):
|
|
242
|
+
raise SelectionError(f"selection state directory must be a directory: {path}")
|
|
243
|
+
return
|
|
244
|
+
old_umask = os.umask(0o077)
|
|
245
|
+
try:
|
|
246
|
+
path.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
247
|
+
except OSError as exc:
|
|
248
|
+
raise SelectionError(f"cannot create selection state directory {path}: {exc}") from exc
|
|
249
|
+
finally:
|
|
250
|
+
os.umask(old_umask)
|
|
251
|
+
info = describe_path_type(path, "selection state directory")
|
|
252
|
+
if info is None or not stat.S_ISDIR(info.st_mode):
|
|
253
|
+
raise SelectionError(f"selection state directory is not usable: {path}")
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def create_temp_file(path: Path) -> tuple[int, Path]:
|
|
257
|
+
for _ in range(100):
|
|
258
|
+
candidate = path.parent / f".{path.name}.tmp.{os.getpid()}.{secrets.token_hex(8)}"
|
|
259
|
+
try:
|
|
260
|
+
fd = os.open(candidate, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
|
261
|
+
os.fchmod(fd, 0o600)
|
|
262
|
+
return fd, candidate
|
|
263
|
+
except FileExistsError:
|
|
264
|
+
continue
|
|
265
|
+
except OSError as exc:
|
|
266
|
+
raise SelectionError(f"cannot create selection state temporary file: {exc}") from exc
|
|
267
|
+
raise SelectionError("cannot create unique selection state temporary file")
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def fsync_directory_best_effort(directory: Path) -> None:
|
|
271
|
+
flags = os.O_RDONLY
|
|
272
|
+
if hasattr(os, "O_DIRECTORY"):
|
|
273
|
+
flags |= os.O_DIRECTORY
|
|
274
|
+
try:
|
|
275
|
+
fd = os.open(directory, flags)
|
|
276
|
+
except OSError:
|
|
277
|
+
return
|
|
278
|
+
try:
|
|
279
|
+
try:
|
|
280
|
+
os.fsync(fd)
|
|
281
|
+
except OSError as exc:
|
|
282
|
+
if exc.errno not in (errno.EINVAL, errno.ENOTSUP, errno.EBADF):
|
|
283
|
+
return
|
|
284
|
+
finally:
|
|
285
|
+
os.close(fd)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def final_state_diagnostic(path: Path) -> str:
|
|
289
|
+
try:
|
|
290
|
+
record = load_record(path)
|
|
291
|
+
except SelectionError as exc:
|
|
292
|
+
return f"final selection state cannot be validated: {exc}"
|
|
293
|
+
if record is None:
|
|
294
|
+
return "selection state is now absent"
|
|
295
|
+
return f"selection state is now {record['mode']}"
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def write_record(path: Path, proposed: dict[str, object]) -> None:
|
|
299
|
+
ensure_state_directory(path.parent)
|
|
300
|
+
load_record(path)
|
|
301
|
+
record = validate_record(proposed)
|
|
302
|
+
fd = -1
|
|
303
|
+
temporary: Path | None = None
|
|
304
|
+
replacement_started = False
|
|
305
|
+
try:
|
|
306
|
+
fd, temporary = create_temp_file(path)
|
|
307
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
308
|
+
fd = -1
|
|
309
|
+
json.dump(record, handle, indent=2, allow_nan=False)
|
|
310
|
+
handle.write("\n")
|
|
311
|
+
handle.flush()
|
|
312
|
+
os.fsync(handle.fileno())
|
|
313
|
+
validate_record(load_json(temporary))
|
|
314
|
+
replacement_started = True
|
|
315
|
+
os.replace(temporary, path)
|
|
316
|
+
temporary = None
|
|
317
|
+
fsync_directory_best_effort(path.parent)
|
|
318
|
+
except SelectionError:
|
|
319
|
+
raise
|
|
320
|
+
except (OSError, UnicodeError, ValueError) as exc:
|
|
321
|
+
if replacement_started:
|
|
322
|
+
raise SelectionError(
|
|
323
|
+
f"cannot complete selection state write: {exc}; {final_state_diagnostic(path)}"
|
|
324
|
+
) from exc
|
|
325
|
+
raise SelectionError(f"cannot write selection state: {exc}") from exc
|
|
326
|
+
finally:
|
|
327
|
+
if fd >= 0:
|
|
328
|
+
os.close(fd)
|
|
329
|
+
if temporary is not None:
|
|
330
|
+
try:
|
|
331
|
+
temporary.unlink()
|
|
332
|
+
except FileNotFoundError:
|
|
333
|
+
pass
|
|
334
|
+
except OSError:
|
|
335
|
+
pass
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def normalized_pinned_arguments(arguments: argparse.Namespace) -> dict[str, object]:
|
|
339
|
+
requested_ref = arguments.requested_ref
|
|
340
|
+
resolved_ref = arguments.resolved_ref
|
|
341
|
+
commit = arguments.commit.lower()
|
|
342
|
+
if COMMIT_INPUT_RE.fullmatch(requested_ref):
|
|
343
|
+
requested_ref = requested_ref.lower()
|
|
344
|
+
if COMMIT_INPUT_RE.fullmatch(resolved_ref):
|
|
345
|
+
resolved_ref = resolved_ref.lower()
|
|
346
|
+
return {
|
|
347
|
+
"schema_version": 1,
|
|
348
|
+
"mode": "pinned",
|
|
349
|
+
"source": arguments.source,
|
|
350
|
+
"requested_ref": requested_ref,
|
|
351
|
+
"resolved_ref": resolved_ref,
|
|
352
|
+
"commit": commit,
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def parser() -> argparse.ArgumentParser:
|
|
357
|
+
result = argparse.ArgumentParser(description="Strict persistent selection-state helper")
|
|
358
|
+
subparsers = result.add_subparsers(dest="command", required=True)
|
|
359
|
+
|
|
360
|
+
read = subparsers.add_parser("read")
|
|
361
|
+
read.add_argument("--path", required=True)
|
|
362
|
+
read.add_argument("--output", required=True)
|
|
363
|
+
|
|
364
|
+
pinned = subparsers.add_parser("write-pinned")
|
|
365
|
+
pinned.add_argument("--path", required=True)
|
|
366
|
+
pinned.add_argument("--source", required=True)
|
|
367
|
+
pinned.add_argument("--requested-ref", required=True)
|
|
368
|
+
pinned.add_argument("--resolved-ref", required=True)
|
|
369
|
+
pinned.add_argument("--commit", required=True)
|
|
370
|
+
|
|
371
|
+
latest = subparsers.add_parser("write-track-latest")
|
|
372
|
+
latest.add_argument("--path", required=True)
|
|
373
|
+
latest.add_argument("--source", required=True)
|
|
374
|
+
|
|
375
|
+
validate = subparsers.add_parser("validate-source")
|
|
376
|
+
validate.add_argument("--source", required=True)
|
|
377
|
+
|
|
378
|
+
display = subparsers.add_parser("display-source")
|
|
379
|
+
display.add_argument("--source", required=True)
|
|
380
|
+
return result
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def dispatch(arguments: argparse.Namespace) -> None:
|
|
384
|
+
if arguments.command == "read":
|
|
385
|
+
write_json_output(Path(arguments.output), normalize(load_record(Path(arguments.path))))
|
|
386
|
+
elif arguments.command == "write-pinned":
|
|
387
|
+
write_record(Path(arguments.path), normalized_pinned_arguments(arguments))
|
|
388
|
+
elif arguments.command == "write-track-latest":
|
|
389
|
+
write_record(Path(arguments.path), {
|
|
390
|
+
"schema_version": 1,
|
|
391
|
+
"mode": "track-latest",
|
|
392
|
+
"source": arguments.source,
|
|
393
|
+
})
|
|
394
|
+
elif arguments.command == "validate-source":
|
|
395
|
+
validate_source(arguments.source)
|
|
396
|
+
elif arguments.command == "display-source":
|
|
397
|
+
try:
|
|
398
|
+
print(validate_source(arguments.source))
|
|
399
|
+
except SelectionError:
|
|
400
|
+
print("<redacted-source>")
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def main() -> int:
|
|
404
|
+
arguments = parser().parse_args()
|
|
405
|
+
try:
|
|
406
|
+
dispatch(arguments)
|
|
407
|
+
except SelectionError as exc:
|
|
408
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
409
|
+
return 1
|
|
410
|
+
return 0
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
if __name__ == "__main__":
|
|
414
|
+
raise SystemExit(main())
|