superpowers-manager 0.1.4 → 0.1.6
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 +121 -78
- package/bin/superpowers-manager.js +22 -13
- package/package.json +2 -1
- package/scripts/adapters/codex/adapter +598 -0
- package/scripts/adapters/codex/lib.sh +391 -0
- package/scripts/{validate-generated-plugin.py → adapters/codex/validate-generated-plugin.py} +3 -3
- package/scripts/core/adapter.sh +92 -0
- package/scripts/core/common.sh +35 -0
- package/scripts/core/lifecycle.sh +127 -0
- package/scripts/core/provenance.sh +135 -0
- package/scripts/core/status.sh +27 -0
- package/scripts/core/upstream.sh +146 -0
- package/scripts/core/validate-adapter-response.py +245 -0
- package/scripts/install +29 -27
- package/scripts/prepare +33 -43
- package/scripts/probe +29 -18
- package/scripts/uninstall +33 -50
- package/scripts/update +3 -8
- package/scripts/lib.sh +0 -651
|
@@ -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,27 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# Sourced module; callers own set -eu.
|
|
3
|
+
|
|
4
|
+
spw_commit_matches() {
|
|
5
|
+
desired="$1"
|
|
6
|
+
observed="$2"
|
|
7
|
+
short=$(printf '%s' "$desired" | cut -c 1-7)
|
|
8
|
+
[ -n "$observed" ] && { [ "$observed" = "$desired" ] || [ "$observed" = "$short" ]; }
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
spw_status_for_commits() {
|
|
12
|
+
desired="$1"
|
|
13
|
+
generated="$2"
|
|
14
|
+
installed="$3"
|
|
15
|
+
|
|
16
|
+
if [ -z "$generated" ]; then
|
|
17
|
+
printf '%s\n' "needs prepare"
|
|
18
|
+
elif ! spw_commit_matches "$desired" "$generated"; then
|
|
19
|
+
printf '%s\n' "needs prepare"
|
|
20
|
+
elif [ -z "$installed" ]; then
|
|
21
|
+
printf '%s\n' "needs install"
|
|
22
|
+
elif ! spw_commit_matches "$desired" "$installed"; then
|
|
23
|
+
printf '%s\n' "needs install"
|
|
24
|
+
else
|
|
25
|
+
printf '%s\n' "current"
|
|
26
|
+
fi
|
|
27
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# Sourced module; callers own set -eu.
|
|
3
|
+
|
|
4
|
+
SPW_UPSTREAM_URL_DEFAULT="https://github.com/obra/superpowers"
|
|
5
|
+
|
|
6
|
+
spw_config_ref() (
|
|
7
|
+
config_root="$1"
|
|
8
|
+
if [ -n "${SUPERPOWERS_REF:-}" ]; then
|
|
9
|
+
printf '%s\n' "$SUPERPOWERS_REF"
|
|
10
|
+
return
|
|
11
|
+
fi
|
|
12
|
+
sed -n '1{s/[[:space:]]*$//;p;}' "$config_root/config/upstream-ref"
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
spw_short_commit() {
|
|
16
|
+
commit="$1"
|
|
17
|
+
printf '%s' "$commit" | cut -c 1-7
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
spw_is_semver_base() {
|
|
21
|
+
version="$1"
|
|
22
|
+
printf '%s' "$version" | grep -Eq '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-((0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(\.(0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*))?$'
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
spw_sanitize_ref_for_version() {
|
|
26
|
+
ref="$1"
|
|
27
|
+
sanitized=$(printf '%s' "$ref" | sed 's/[^0-9A-Za-z-][^0-9A-Za-z-]*/-/g; s/^-*//; s/-*$//')
|
|
28
|
+
sanitized=$(printf '%s' "$sanitized" | cut -c 1-48 | sed 's/^-*//; s/-*$//')
|
|
29
|
+
if [ -z "$sanitized" ]; then
|
|
30
|
+
sanitized="unknown"
|
|
31
|
+
fi
|
|
32
|
+
printf '%s' "$sanitized"
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
spw_manifest_version_for_ref() {
|
|
36
|
+
requested_ref="$1"
|
|
37
|
+
resolution_kind="$2"
|
|
38
|
+
resolved_ref="$3"
|
|
39
|
+
commit="$4"
|
|
40
|
+
short=$(spw_short_commit "$commit")
|
|
41
|
+
|
|
42
|
+
case "$resolution_kind" in
|
|
43
|
+
latest-release|tag)
|
|
44
|
+
base=$(printf '%s' "$resolved_ref" | sed -n 's/^v//p')
|
|
45
|
+
if [ -n "$base" ] && spw_is_semver_base "$base"; then
|
|
46
|
+
printf '%s+manager.%s\n' "$base" "$short"
|
|
47
|
+
return
|
|
48
|
+
fi
|
|
49
|
+
;;
|
|
50
|
+
ref)
|
|
51
|
+
if [ "$requested_ref" = "main" ]; then
|
|
52
|
+
printf '0.0.0-main+manager.%s\n' "$short"
|
|
53
|
+
return
|
|
54
|
+
fi
|
|
55
|
+
sanitized=$(spw_sanitize_ref_for_version "$requested_ref")
|
|
56
|
+
printf '0.0.0-ref-%s+manager.%s\n' "$sanitized" "$short"
|
|
57
|
+
return
|
|
58
|
+
;;
|
|
59
|
+
raw-commit)
|
|
60
|
+
;;
|
|
61
|
+
esac
|
|
62
|
+
|
|
63
|
+
printf '0.0.0+manager.%s\n' "$short"
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
spw_manifest_version_for_commit() {
|
|
67
|
+
commit="$1"
|
|
68
|
+
spw_manifest_version_for_ref "$commit" "raw-commit" "$commit" "$commit"
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
spw_select_latest_release_from_ls_remote() {
|
|
72
|
+
awk '
|
|
73
|
+
$2 ~ /^refs\/tags\/v[0-9]+\.[0-9]+\.[0-9]+(\^\{\})?$/ {
|
|
74
|
+
ref = $2
|
|
75
|
+
peeled = 0
|
|
76
|
+
if (ref ~ /\^\{\}$/) {
|
|
77
|
+
peeled = 1
|
|
78
|
+
sub(/\^\{\}$/, "", ref)
|
|
79
|
+
}
|
|
80
|
+
tag = ref
|
|
81
|
+
sub(/^refs\/tags\//, "", tag)
|
|
82
|
+
split(substr(tag, 2), parts, ".")
|
|
83
|
+
key = sprintf("%010d.%010d.%010d", parts[1], parts[2], parts[3])
|
|
84
|
+
tag_by_key[key] = tag
|
|
85
|
+
if (peeled || !(tag in sha_by_tag)) {
|
|
86
|
+
sha_by_tag[tag] = $1
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
END {
|
|
90
|
+
for (key in tag_by_key) {
|
|
91
|
+
tag = tag_by_key[key]
|
|
92
|
+
print key, tag, sha_by_tag[tag]
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
' | sort | tail -n 1 | awk '
|
|
96
|
+
NF == 3 { print $2 " " $3; found = 1 }
|
|
97
|
+
END { if (!found) exit 1 }
|
|
98
|
+
'
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
spw_resolve_ref() {
|
|
102
|
+
upstream_url="$1"
|
|
103
|
+
requested_ref="$2"
|
|
104
|
+
spw_require_command git
|
|
105
|
+
|
|
106
|
+
if [ "$requested_ref" = "latest-release" ]; then
|
|
107
|
+
if ! output=$(git ls-remote --tags "$upstream_url" 'refs/tags/v*' 2>&1); then
|
|
108
|
+
spw_die "cannot query upstream tags from $upstream_url: $output"
|
|
109
|
+
fi
|
|
110
|
+
if ! selected=$(printf '%s\n' "$output" | spw_select_latest_release_from_ls_remote); then
|
|
111
|
+
spw_die "no stable semver tag found for latest-release"
|
|
112
|
+
fi
|
|
113
|
+
printf 'latest-release %s\n' "$selected"
|
|
114
|
+
return
|
|
115
|
+
fi
|
|
116
|
+
|
|
117
|
+
if printf '%s' "$requested_ref" | grep -Eq '^[0-9a-fA-F]{40}$'; then
|
|
118
|
+
printf 'raw-commit %s %s\n' "$requested_ref" "$requested_ref"
|
|
119
|
+
return
|
|
120
|
+
fi
|
|
121
|
+
|
|
122
|
+
if ! tag_output=$(git ls-remote --tags "$upstream_url" "refs/tags/$requested_ref" "refs/tags/$requested_ref^{}" 2>&1); then
|
|
123
|
+
spw_die "cannot query upstream tag $requested_ref from $upstream_url: $tag_output"
|
|
124
|
+
fi
|
|
125
|
+
resolved=$(printf '%s\n' "$tag_output" | awk -v tag_ref="refs/tags/$requested_ref" '
|
|
126
|
+
NF >= 2 && ($2 == tag_ref || $2 == tag_ref "^{}") {
|
|
127
|
+
sha = $1
|
|
128
|
+
}
|
|
129
|
+
END { if (sha != "") print sha }
|
|
130
|
+
')
|
|
131
|
+
if [ -n "$resolved" ]; then
|
|
132
|
+
printf 'tag %s %s\n' "$requested_ref" "$resolved"
|
|
133
|
+
return
|
|
134
|
+
fi
|
|
135
|
+
|
|
136
|
+
if ! ref_output=$(git ls-remote "$upstream_url" "$requested_ref" 2>&1); then
|
|
137
|
+
spw_die "cannot query upstream ref $requested_ref from $upstream_url: $ref_output"
|
|
138
|
+
fi
|
|
139
|
+
resolved=$(printf '%s\n' "$ref_output" | awk 'NF >= 2 { print $1; exit }')
|
|
140
|
+
if [ -n "$resolved" ]; then
|
|
141
|
+
printf 'ref %s %s\n' "$requested_ref" "$resolved"
|
|
142
|
+
return
|
|
143
|
+
fi
|
|
144
|
+
|
|
145
|
+
spw_die "cannot resolve upstream ref: $requested_ref"
|
|
146
|
+
}
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
ENVELOPE_KEYS = {"protocol", "operation", "ok", "messages", "result", "error"}
|
|
11
|
+
MESSAGE_KEYS = {"channel", "text"}
|
|
12
|
+
ERROR_KEYS = {"code", "message", "hints"}
|
|
13
|
+
OWNERSHIP_KEYS = {"plugin", "marketplace"}
|
|
14
|
+
VERIFICATION_HINT_KEYS = {"mismatch", "missing"}
|
|
15
|
+
OPERATIONS = {"build", "inspect", "install", "uninstall"}
|
|
16
|
+
FINGERPRINT_RE = re.compile(r"(?:[0-9a-fA-F]{7}|[0-9a-fA-F]{40})\Z")
|
|
17
|
+
MAX_NESTING = 64
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ProtocolError(ValueError):
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def reject_constant(value: str) -> None:
|
|
25
|
+
raise ProtocolError(f"non-standard JSON constant: {value}")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def nesting_exceeds_limit(value: object) -> bool:
|
|
29
|
+
stack = [(value, 0)]
|
|
30
|
+
while stack:
|
|
31
|
+
current, depth = stack.pop()
|
|
32
|
+
if isinstance(current, dict):
|
|
33
|
+
next_depth = depth + 1
|
|
34
|
+
if next_depth > MAX_NESTING:
|
|
35
|
+
return True
|
|
36
|
+
stack.extend((child, next_depth) for child in current.values())
|
|
37
|
+
elif isinstance(current, list):
|
|
38
|
+
next_depth = depth + 1
|
|
39
|
+
if next_depth > MAX_NESTING:
|
|
40
|
+
return True
|
|
41
|
+
stack.extend((child, next_depth) for child in current)
|
|
42
|
+
return False
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def require_object(value: object, label: str) -> dict[str, object]:
|
|
46
|
+
if not isinstance(value, dict):
|
|
47
|
+
raise ProtocolError(f"{label} must be an object")
|
|
48
|
+
return value
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def require_exact_keys(value: dict[str, object], expected: set[str], label: str) -> None:
|
|
52
|
+
actual = set(value)
|
|
53
|
+
if actual != expected:
|
|
54
|
+
raise ProtocolError(
|
|
55
|
+
f"{label} keys must be {sorted(expected)}, got {sorted(actual)}"
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def contains_terminal_control(value: str) -> bool:
|
|
60
|
+
return any(
|
|
61
|
+
ord(character) < 0x20
|
|
62
|
+
or 0x7F <= ord(character) <= 0x9F
|
|
63
|
+
or 0xD800 <= ord(character) <= 0xDFFF
|
|
64
|
+
for character in value
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def require_non_empty_string(value: object, label: str) -> str:
|
|
69
|
+
if not isinstance(value, str) or not value or "\n" in value or "\r" in value:
|
|
70
|
+
raise ProtocolError(f"{label} must be a non-empty single-line string")
|
|
71
|
+
if contains_terminal_control(value):
|
|
72
|
+
raise ProtocolError(f"{label} must not contain terminal control characters")
|
|
73
|
+
return value
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def validate_messages(value: object) -> list[dict[str, str]]:
|
|
77
|
+
if not isinstance(value, list):
|
|
78
|
+
raise ProtocolError("messages must be an array")
|
|
79
|
+
messages: list[dict[str, str]] = []
|
|
80
|
+
for index, item in enumerate(value):
|
|
81
|
+
message = require_object(item, f"messages[{index}]")
|
|
82
|
+
require_exact_keys(message, MESSAGE_KEYS, f"messages[{index}]")
|
|
83
|
+
channel = message.get("channel")
|
|
84
|
+
if channel not in {"stdout", "stderr"}:
|
|
85
|
+
raise ProtocolError(f"messages[{index}].channel is invalid")
|
|
86
|
+
text = require_non_empty_string(message.get("text"), f"messages[{index}].text")
|
|
87
|
+
messages.append({"channel": str(channel), "text": text})
|
|
88
|
+
return messages
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def validate_error(value: object) -> dict[str, object]:
|
|
92
|
+
error = require_object(value, "error")
|
|
93
|
+
require_exact_keys(error, ERROR_KEYS, "error")
|
|
94
|
+
code = require_non_empty_string(error.get("code"), "error.code")
|
|
95
|
+
message = require_non_empty_string(error.get("message"), "error.message")
|
|
96
|
+
hints_value = error.get("hints")
|
|
97
|
+
if not isinstance(hints_value, list):
|
|
98
|
+
raise ProtocolError("error.hints must be an array")
|
|
99
|
+
hints = [
|
|
100
|
+
require_non_empty_string(hint, f"error.hints[{index}]")
|
|
101
|
+
for index, hint in enumerate(hints_value)
|
|
102
|
+
]
|
|
103
|
+
return {"code": code, "message": message, "hints": hints}
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def validate_result(
|
|
107
|
+
operation: str, value: object, inspect_view: str | None
|
|
108
|
+
) -> dict[str, object]:
|
|
109
|
+
result = require_object(value, "result")
|
|
110
|
+
if operation in {"build", "uninstall"}:
|
|
111
|
+
require_exact_keys(result, set(), f"{operation} result")
|
|
112
|
+
return {}
|
|
113
|
+
if operation == "inspect":
|
|
114
|
+
if inspect_view == "fingerprint":
|
|
115
|
+
require_exact_keys(result, {"view", "fingerprint"}, "inspect result")
|
|
116
|
+
if result.get("view") != "fingerprint":
|
|
117
|
+
raise ProtocolError("inspect result view must be fingerprint")
|
|
118
|
+
fingerprint = result.get("fingerprint")
|
|
119
|
+
if fingerprint is not None and (
|
|
120
|
+
not isinstance(fingerprint, str)
|
|
121
|
+
or FINGERPRINT_RE.fullmatch(fingerprint) is None
|
|
122
|
+
):
|
|
123
|
+
raise ProtocolError("fingerprint must be null, 7 hex, or 40 hex")
|
|
124
|
+
return {"view": "fingerprint", "fingerprint": fingerprint}
|
|
125
|
+
if inspect_view == "ownership":
|
|
126
|
+
require_exact_keys(
|
|
127
|
+
result,
|
|
128
|
+
{"view", "resources", "legacy_resources", "identity_state"},
|
|
129
|
+
"inspect result",
|
|
130
|
+
)
|
|
131
|
+
if result.get("view") != "ownership":
|
|
132
|
+
raise ProtocolError("inspect result view must be ownership")
|
|
133
|
+
resources = require_object(result.get("resources"), "resources")
|
|
134
|
+
legacy_resources = require_object(
|
|
135
|
+
result.get("legacy_resources"), "legacy_resources"
|
|
136
|
+
)
|
|
137
|
+
for label, group in (
|
|
138
|
+
("resources", resources),
|
|
139
|
+
("legacy_resources", legacy_resources),
|
|
140
|
+
):
|
|
141
|
+
require_exact_keys(group, OWNERSHIP_KEYS, label)
|
|
142
|
+
if not all(isinstance(group[key], bool) for key in OWNERSHIP_KEYS):
|
|
143
|
+
raise ProtocolError(f"{label} values must be Boolean")
|
|
144
|
+
manager_present = any(resources.values())
|
|
145
|
+
legacy_present = any(legacy_resources.values())
|
|
146
|
+
expected_state = {
|
|
147
|
+
(False, False): "neither",
|
|
148
|
+
(True, False): "manager",
|
|
149
|
+
(False, True): "legacy",
|
|
150
|
+
(True, True): "both",
|
|
151
|
+
}[(manager_present, legacy_present)]
|
|
152
|
+
identity_state = result.get("identity_state")
|
|
153
|
+
if identity_state != expected_state:
|
|
154
|
+
raise ProtocolError(
|
|
155
|
+
f"identity_state must be {expected_state} for the reported resources"
|
|
156
|
+
)
|
|
157
|
+
return {
|
|
158
|
+
"view": "ownership",
|
|
159
|
+
"resources": resources,
|
|
160
|
+
"legacy_resources": legacy_resources,
|
|
161
|
+
"identity_state": identity_state,
|
|
162
|
+
}
|
|
163
|
+
raise ProtocolError("inspect view must be fingerprint or ownership")
|
|
164
|
+
if operation == "install":
|
|
165
|
+
require_exact_keys(result, {"verification_hints"}, "install result")
|
|
166
|
+
hints = require_object(result.get("verification_hints"), "verification_hints")
|
|
167
|
+
unknown = set(hints) - VERIFICATION_HINT_KEYS
|
|
168
|
+
if unknown:
|
|
169
|
+
raise ProtocolError(f"unknown verification hint keys: {sorted(unknown)}")
|
|
170
|
+
normalized = {
|
|
171
|
+
key: require_non_empty_string(value, f"verification_hints.{key}")
|
|
172
|
+
for key, value in hints.items()
|
|
173
|
+
}
|
|
174
|
+
return {"verification_hints": normalized}
|
|
175
|
+
raise ProtocolError(f"unsupported operation: {operation}")
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def validate_envelope(
|
|
179
|
+
raw: object, operation: str, adapter_exit: int, inspect_view: str | None
|
|
180
|
+
) -> tuple[list[dict[str, str]], dict[str, object] | None, dict[str, object] | None]:
|
|
181
|
+
envelope = require_object(raw, "response")
|
|
182
|
+
require_exact_keys(envelope, ENVELOPE_KEYS, "response")
|
|
183
|
+
protocol = envelope.get("protocol")
|
|
184
|
+
if type(protocol) is not int or protocol != 1:
|
|
185
|
+
raise ProtocolError("protocol must equal integer 1")
|
|
186
|
+
if envelope.get("operation") != operation:
|
|
187
|
+
raise ProtocolError("response operation does not match invocation")
|
|
188
|
+
ok = envelope.get("ok")
|
|
189
|
+
if not isinstance(ok, bool):
|
|
190
|
+
raise ProtocolError("ok must be Boolean")
|
|
191
|
+
messages = validate_messages(envelope.get("messages"))
|
|
192
|
+
if ok:
|
|
193
|
+
if adapter_exit != 0:
|
|
194
|
+
raise ProtocolError("successful response requires adapter exit 0")
|
|
195
|
+
if envelope.get("error") is not None:
|
|
196
|
+
raise ProtocolError("successful response error must be null")
|
|
197
|
+
result = validate_result(operation, envelope.get("result"), inspect_view)
|
|
198
|
+
return messages, result, None
|
|
199
|
+
if adapter_exit == 0:
|
|
200
|
+
raise ProtocolError("failure response requires nonzero adapter exit")
|
|
201
|
+
if envelope.get("result") is not None:
|
|
202
|
+
raise ProtocolError("failure response result must be null")
|
|
203
|
+
error = validate_error(envelope.get("error"))
|
|
204
|
+
return messages, None, error
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def replay(messages: list[dict[str, str]]) -> None:
|
|
208
|
+
for message in messages:
|
|
209
|
+
stream = sys.stdout if message["channel"] == "stdout" else sys.stderr
|
|
210
|
+
print(message["text"], file=stream)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def main() -> int:
|
|
214
|
+
parser = argparse.ArgumentParser()
|
|
215
|
+
parser.add_argument("--operation", choices=sorted(OPERATIONS), required=True)
|
|
216
|
+
parser.add_argument("--adapter-exit", type=int, required=True)
|
|
217
|
+
parser.add_argument("--response", type=Path, required=True)
|
|
218
|
+
parser.add_argument("--result", type=Path, required=True)
|
|
219
|
+
parser.add_argument("--inspect-view", choices=("fingerprint", "ownership"))
|
|
220
|
+
args = parser.parse_args()
|
|
221
|
+
try:
|
|
222
|
+
with args.response.open(encoding="utf-8") as handle:
|
|
223
|
+
raw = json.load(handle, parse_constant=reject_constant)
|
|
224
|
+
if nesting_exceeds_limit(raw):
|
|
225
|
+
raise ProtocolError("response JSON nesting exceeds limit")
|
|
226
|
+
messages, result, error = validate_envelope(
|
|
227
|
+
raw, args.operation, args.adapter_exit, args.inspect_view
|
|
228
|
+
)
|
|
229
|
+
replay(messages)
|
|
230
|
+
if error is not None:
|
|
231
|
+
print(f"error: {error['message']}", file=sys.stderr)
|
|
232
|
+
for hint in error["hints"]:
|
|
233
|
+
print(f"hint: {hint}", file=sys.stderr)
|
|
234
|
+
return 1
|
|
235
|
+
with args.result.open("w", encoding="utf-8") as handle:
|
|
236
|
+
json.dump(result, handle, allow_nan=False, separators=(",", ":"))
|
|
237
|
+
handle.write("\n")
|
|
238
|
+
return 0
|
|
239
|
+
except (OSError, UnicodeError, json.JSONDecodeError, ProtocolError, RecursionError) as exc:
|
|
240
|
+
print(f"error: invalid adapter response: {exc}", file=sys.stderr)
|
|
241
|
+
return 2
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
if __name__ == "__main__":
|
|
245
|
+
raise SystemExit(main())
|
package/scripts/install
CHANGED
|
@@ -2,27 +2,22 @@
|
|
|
2
2
|
set -eu
|
|
3
3
|
|
|
4
4
|
root=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)
|
|
5
|
-
. "$root/scripts/
|
|
5
|
+
. "$root/scripts/core/common.sh"
|
|
6
|
+
. "$root/scripts/core/provenance.sh"
|
|
7
|
+
. "$root/scripts/core/status.sh"
|
|
8
|
+
. "$root/scripts/core/lifecycle.sh"
|
|
9
|
+
. "$root/scripts/core/adapter.sh"
|
|
6
10
|
|
|
7
|
-
codex_bin="${SUPERPOWERS_CODEX:-codex}"
|
|
8
|
-
refresh_mode="${SUPERPOWERS_INSTALL_REFRESH_MODE:-add-only}"
|
|
9
|
-
|
|
10
|
-
spw_require_command "$codex_bin"
|
|
11
11
|
spw_require_command python3
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
add-only|remove-add) ;;
|
|
15
|
-
*) spw_die "unsupported SUPERPOWERS_INSTALL_REFRESH_MODE: $refresh_mode" ;;
|
|
16
|
-
esac
|
|
17
|
-
|
|
18
|
-
echo "Note: remove or disable conflicting Superpowers providers yourself before relying on manager-provided skills."
|
|
13
|
+
echo "Note: remove or disable conflicting Superpowers providers yourself before relying on manager skills."
|
|
19
14
|
|
|
20
15
|
# Prepare-before-mutate: probe, and (re)generate the plugin tree if it is
|
|
21
16
|
# missing or stale — including validation — entirely before any Codex
|
|
22
17
|
# marketplace or plugin mutation. A failed prepare leaves Codex untouched.
|
|
23
18
|
probe_output=$(sh "$root/scripts/probe" --porcelain)
|
|
24
19
|
identity_state=$(printf '%s\n' "$probe_output" | awk -F= '$1 == "identity_state" { print $2 }')
|
|
25
|
-
[ -n "$identity_state" ] || spw_die "probe did not report
|
|
20
|
+
[ -n "$identity_state" ] || spw_die "probe did not report adapter identity state"
|
|
26
21
|
spw_require_no_legacy_state "$identity_state"
|
|
27
22
|
status=$(printf '%s\n' "$probe_output" | awk -F= '$1 == "status" { print $2 }')
|
|
28
23
|
case "$status" in
|
|
@@ -40,19 +35,26 @@ esac
|
|
|
40
35
|
desired_commit=$(spw_metadata_commit_or_empty "$root/plugins/superpowers/.superpowers-upstream.json" || true)
|
|
41
36
|
[ -n "$desired_commit" ] || spw_die "generated metadata missing desired commit after prepare"
|
|
42
37
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
38
|
+
tmp_parent="${TMPDIR:-/tmp}"
|
|
39
|
+
if ! install_workspace=$(mktemp -d "$tmp_parent/superpowers-manager.install.XXXXXX"); then
|
|
40
|
+
spw_die "cannot create install workspace under $tmp_parent"
|
|
41
|
+
fi
|
|
42
|
+
cleanup_install_workspace() {
|
|
43
|
+
status=$?
|
|
44
|
+
trap - 0 HUP INT TERM
|
|
45
|
+
rm -rf "$install_workspace" || :
|
|
46
|
+
exit "$status"
|
|
47
|
+
}
|
|
48
|
+
trap cleanup_install_workspace 0
|
|
49
|
+
trap 'exit 129' HUP
|
|
50
|
+
trap 'exit 130' INT
|
|
51
|
+
trap 'exit 143' TERM
|
|
52
|
+
|
|
53
|
+
TMPDIR="$install_workspace"
|
|
54
|
+
export TMPDIR
|
|
55
|
+
install_result="$install_workspace/install.json"
|
|
56
|
+
inspect_result="$install_workspace/inspect.json"
|
|
57
|
+
spw_adapter_install "$install_result" "$root"
|
|
58
|
+
if ! spw_verify_installed_fingerprint "$desired_commit" "$install_result" "$inspect_result"; then
|
|
59
|
+
exit 1
|
|
56
60
|
fi
|
|
57
|
-
|
|
58
|
-
spw_verify_refresh "$desired_commit"
|