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.
@@ -0,0 +1,449 @@
1
+ #!/bin/sh
2
+ # Sourced module; callers own set -eu.
3
+
4
+ SPW_PLUGIN_ID="superpowers@superpowers-manager"
5
+ SPW_MARKETPLACE_NAME="superpowers-manager"
6
+ SPW_LEGACY_PLUGIN_ID="superpowers@superpowers-wrapper"
7
+ SPW_LEGACY_MARKETPLACE_NAME="superpowers-wrapper"
8
+
9
+ # Given a JSON document as the FIRST ARGUMENT (a string), print "present" if any
10
+ # element of the top-level array <array_key> is an object whose <field> equals
11
+ # <value>, else print "absent" (exit 0). On unparseable JSON or invalid schema,
12
+ # print nothing and exit 2 so callers can fail closed rather than treat an
13
+ # unreadable listing as "absent". Every item in the inspected array must be an
14
+ # object with a non-empty string <field>; otherwise ownership is unprovable and
15
+ # the helper exits 2 instead of treating the malformed entry as unrelated.
16
+ # The JSON is passed as an argument (exactly as spw_json_get takes a file path),
17
+ # NOT on stdin: the here-doc below is Python's stdin (its program source), so a
18
+ # json.load(sys.stdin) here would read the program, not the caller's JSON.
19
+ spw_json_array_has() {
20
+ json="$1"
21
+ array_key="$2"
22
+ field="$3"
23
+ value="$4"
24
+ python3 - "$json" "$array_key" "$field" "$value" <<'PY'
25
+ import json, sys
26
+ raw, array_key, field, value = sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4]
27
+ try:
28
+ data = json.loads(raw)
29
+ except (json.JSONDecodeError, RecursionError):
30
+ sys.exit(2)
31
+ if not isinstance(data, dict):
32
+ sys.exit(2)
33
+ items = data.get(array_key)
34
+ if not isinstance(items, list):
35
+ sys.exit(2)
36
+ for item in items:
37
+ if not isinstance(item, dict):
38
+ sys.exit(2)
39
+ field_value = item.get(field)
40
+ if not isinstance(field_value, str) or not field_value:
41
+ sys.exit(2)
42
+ found = any(item[field] == value for item in items)
43
+ print("present" if found else "absent")
44
+ PY
45
+ }
46
+
47
+ # Print the registered root of <marketplace_name> from a marketplace-list JSON
48
+ # document (as a string argument, like spw_json_array_has), or nothing if the
49
+ # marketplace is absent. Exit 2 on unparseable JSON, a non-object item, or an
50
+ # item without a non-empty string name: such an item cannot be proven unrelated
51
+ # and must not be mistaken for an absent manager marketplace. Validate root only
52
+ # on the matching item; unrelated marketplace roots are never read. A matching
53
+ # item without a non-empty string root also exits 2. Empty output is unambiguous:
54
+ # a valid registered root is always a non-empty path.
55
+ spw_marketplace_root_from_json() {
56
+ json="$1"
57
+ marketplace_name="$2"
58
+ python3 - "$json" "$marketplace_name" <<'PY'
59
+ import json, sys
60
+ raw, name = sys.argv[1], sys.argv[2]
61
+ try:
62
+ data = json.loads(raw)
63
+ except (json.JSONDecodeError, RecursionError):
64
+ sys.exit(2)
65
+ if not isinstance(data, dict):
66
+ sys.exit(2)
67
+ items = data.get("marketplaces")
68
+ if not isinstance(items, list):
69
+ sys.exit(2)
70
+ for item in items:
71
+ if not isinstance(item, dict):
72
+ sys.exit(2)
73
+ item_name = item.get("name")
74
+ if not isinstance(item_name, str) or not item_name:
75
+ sys.exit(2)
76
+ for item in items:
77
+ if item["name"] == name:
78
+ item_root = item.get("root")
79
+ if not isinstance(item_root, str) or not item_root:
80
+ sys.exit(2)
81
+ print(item_root)
82
+ sys.exit(0)
83
+ PY
84
+ }
85
+
86
+ # Print "same" if the two paths refer to the same physical location, else
87
+ # "different". Physical-path normalization via os.path.realpath on both
88
+ # sides (handles /var vs /private/var and other symlinked roots); if
89
+ # realpath fails, fall back to comparing the original strings.
90
+ spw_paths_equal() {
91
+ python3 - "$1" "$2" <<'PY'
92
+ import os, sys
93
+ a, b = sys.argv[1], sys.argv[2]
94
+ try:
95
+ na, nb = os.path.realpath(a), os.path.realpath(b)
96
+ except OSError:
97
+ na, nb = a, b
98
+ print("same" if na == nb else "different")
99
+ PY
100
+ }
101
+
102
+ spw_active_plugin_version_from_json() {
103
+ json="$1"
104
+ plugin_id="$2"
105
+ python3 - "$json" "$plugin_id" <<'PY'
106
+ import json
107
+ import sys
108
+
109
+ raw, plugin_id = sys.argv[1:]
110
+
111
+ def reject_constant(value):
112
+ raise ValueError(f"non-standard JSON constant: {value}")
113
+
114
+ def has_terminal_control(value):
115
+ return any(
116
+ ord(character) < 0x20
117
+ or 0x7F <= ord(character) <= 0x9F
118
+ or 0xD800 <= ord(character) <= 0xDFFF
119
+ for character in value
120
+ )
121
+
122
+ try:
123
+ data = json.loads(raw, parse_constant=reject_constant)
124
+ except (json.JSONDecodeError, RecursionError, ValueError):
125
+ sys.exit(2)
126
+
127
+ if not isinstance(data, dict):
128
+ sys.exit(2)
129
+ items = data.get("installed")
130
+ if not isinstance(items, list):
131
+ sys.exit(2)
132
+
133
+ matches = []
134
+ for item in items:
135
+ if not isinstance(item, dict):
136
+ sys.exit(2)
137
+ item_plugin_id = item.get("pluginId")
138
+ if not isinstance(item_plugin_id, str) or not item_plugin_id:
139
+ sys.exit(2)
140
+ if item_plugin_id == plugin_id:
141
+ matches.append(item)
142
+
143
+ if len(matches) > 1:
144
+ sys.exit(2)
145
+ if not matches:
146
+ sys.exit(0)
147
+
148
+ version = matches[0].get("version")
149
+ if (
150
+ not isinstance(version, str)
151
+ or not version
152
+ or version in {".", ".."}
153
+ or "/" in version
154
+ or "\\" in version
155
+ or has_terminal_control(version)
156
+ ):
157
+ sys.exit(2)
158
+ print(version)
159
+ PY
160
+ }
161
+
162
+ spw_installed_root_for_version() {
163
+ search_root="$1"
164
+ marketplace="$2"
165
+ plugin="$3"
166
+ version="$4"
167
+ case "$search_root" in
168
+ /) search_root= ;;
169
+ */) search_root=${search_root%/} ;;
170
+ esac
171
+ printf '%s/plugins/cache/%s/%s/%s\n' \
172
+ "$search_root" "$marketplace" "$plugin" "$version"
173
+ }
174
+
175
+ spw_codex_json_get_or_empty() {
176
+ file="$1"
177
+ key="$2"
178
+ [ -f "$file" ] || return 1
179
+ python3 - "$file" "$key" <<'PY'
180
+ import json
181
+ import sys
182
+
183
+ path, dotted_key = sys.argv[1:]
184
+ MAX_JSON_NESTING = 256
185
+
186
+ def reject_constant(constant):
187
+ raise ValueError(f"non-standard numeric constant: {constant}")
188
+
189
+ def nesting_exceeds_limit(value):
190
+ stack = [(value, 0)]
191
+ while stack:
192
+ current, depth = stack.pop()
193
+ if isinstance(current, dict):
194
+ next_depth = depth + 1
195
+ if next_depth > MAX_JSON_NESTING:
196
+ return True
197
+ stack.extend((child, next_depth) for child in current.values())
198
+ elif isinstance(current, list):
199
+ next_depth = depth + 1
200
+ if next_depth > MAX_JSON_NESTING:
201
+ return True
202
+ stack.extend((child, next_depth) for child in current)
203
+ return False
204
+
205
+ try:
206
+ with open(path, "r", encoding="utf-8") as handle:
207
+ data = json.load(handle, parse_constant=reject_constant)
208
+ except (OSError, UnicodeError, json.JSONDecodeError, RecursionError, ValueError):
209
+ sys.exit(1)
210
+
211
+ if nesting_exceeds_limit(data) or not isinstance(data, dict):
212
+ sys.exit(1)
213
+
214
+ value = data
215
+ for part in dotted_key.split("."):
216
+ if not isinstance(value, dict):
217
+ value = ""
218
+ break
219
+ value = value.get(part, "")
220
+
221
+ if value is None:
222
+ value = ""
223
+ if not isinstance(value, str):
224
+ sys.exit(1)
225
+ print(value)
226
+ PY
227
+ }
228
+
229
+ spw_codex_metadata_commit_or_empty() {
230
+ file="$1"
231
+ if ! commit=$(spw_codex_json_get_or_empty "$file" "commit"); then
232
+ return 1
233
+ fi
234
+ case "$commit" in
235
+ ""|*[!0-9a-fA-F]*) return 1 ;;
236
+ esac
237
+ case "${#commit}" in
238
+ 7|40) printf '%s\n' "$commit" ;;
239
+ *) return 1 ;;
240
+ esac
241
+ }
242
+
243
+ spw_apply_manifest_overlay() {
244
+ manifest="$1"
245
+ version="$2"
246
+ python3 - "$manifest" "$version" <<'PY'
247
+ import json
248
+ import sys
249
+
250
+ path, version = sys.argv[1:]
251
+ MAX_JSON_NESTING = 256
252
+
253
+ def reject_constant(constant):
254
+ raise ValueError(f"non-standard numeric constant: {constant}")
255
+
256
+ def nesting_exceeds_limit(value):
257
+ stack = [(value, 0)]
258
+ while stack:
259
+ current, depth = stack.pop()
260
+ if isinstance(current, dict):
261
+ next_depth = depth + 1
262
+ if next_depth > MAX_JSON_NESTING:
263
+ return True
264
+ stack.extend((child, next_depth) for child in current.values())
265
+ elif isinstance(current, list):
266
+ next_depth = depth + 1
267
+ if next_depth > MAX_JSON_NESTING:
268
+ return True
269
+ stack.extend((child, next_depth) for child in current)
270
+ return False
271
+
272
+ try:
273
+ with open(path, "r", encoding="utf-8") as f:
274
+ data = json.load(f, parse_constant=reject_constant)
275
+ except RecursionError:
276
+ sys.exit(f"JSON nesting exceeds limit in {path}")
277
+ except json.JSONDecodeError as exc:
278
+ sys.exit(
279
+ f"invalid manifest JSON in {path}: "
280
+ f"line {exc.lineno} column {exc.colno}: {exc.msg}"
281
+ )
282
+ except (OSError, UnicodeError) as exc:
283
+ sys.exit(f"cannot read manifest JSON in {path}: {exc}")
284
+ except ValueError as exc:
285
+ sys.exit(f"invalid manifest JSON in {path}: {exc}")
286
+
287
+ if nesting_exceeds_limit(data):
288
+ sys.exit(f"JSON nesting exceeds limit in {path}")
289
+
290
+ if not isinstance(data, dict):
291
+ sys.exit(f"manifest must be a JSON object: {path}")
292
+
293
+ data["version"] = version
294
+ data["skills"] = "./skills/"
295
+ data.pop("hooks", None)
296
+
297
+ try:
298
+ with open(path, "w", encoding="utf-8") as f:
299
+ json.dump(data, f, indent=2, allow_nan=False)
300
+ f.write("\n")
301
+ except RecursionError as exc:
302
+ sys.exit(f"manifest JSON nesting exceeds limit while writing {path}: {exc}")
303
+ except (OSError, UnicodeError, ValueError) as exc:
304
+ sys.exit(f"cannot write manifest JSON in {path}: {exc}")
305
+ PY
306
+ }
307
+
308
+ spw_manifest_short_sha_or_empty() {
309
+ file="$1"
310
+ if ! version=$(spw_codex_json_get_or_empty "$file" "version"); then
311
+ return 1
312
+ fi
313
+ case "$version" in
314
+ *+manager.*)
315
+ short="${version##*.}"
316
+ case "$short" in
317
+ [0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F])
318
+ printf '%s\n' "$short"
319
+ ;;
320
+ esac
321
+ ;;
322
+ esac
323
+ }
324
+
325
+ spw_installed_commit_from_root_or_empty() {
326
+ active_root="$1"
327
+ installed_metadata="$active_root/.superpowers-upstream.json"
328
+ installed_manifest="$active_root/.codex-plugin/plugin.json"
329
+ installed_commit=""
330
+ if [ -f "$installed_metadata" ]; then
331
+ installed_commit=$(spw_codex_metadata_commit_or_empty "$installed_metadata" || true)
332
+ fi
333
+ if [ -z "$installed_commit" ] && [ -f "$installed_manifest" ]; then
334
+ installed_commit=$(spw_manifest_short_sha_or_empty "$installed_manifest" || true)
335
+ fi
336
+ printf '%s\n' "$installed_commit"
337
+ }
338
+
339
+ spw_codex_emit() {
340
+ operation="$1"
341
+ ok="$2"
342
+ result_json="$3"
343
+ code="$4"
344
+ error_message="$5"
345
+ hints_file="$6"
346
+ messages_file="$7"
347
+ python3 - "$operation" "$ok" "$result_json" "$code" "$error_message" "$hints_file" "$messages_file" <<'PY'
348
+ import json
349
+ import sys
350
+
351
+ operation, ok_text, result_text, code, error_message, hints_path, messages_path = sys.argv[1:]
352
+
353
+ def has_terminal_control(value):
354
+ return any(
355
+ ord(char) < 0x20
356
+ or 0x7f <= ord(char) <= 0x9f
357
+ or 0xd800 <= ord(char) <= 0xdfff
358
+ for char in value
359
+ )
360
+
361
+ if any(has_terminal_control(value) for value in (operation, code, error_message)):
362
+ raise SystemExit("protocol strings must not contain terminal control characters")
363
+
364
+ messages = []
365
+ if messages_path:
366
+ with open(messages_path, encoding="utf-8") as handle:
367
+ for number, raw in enumerate(handle, 1):
368
+ line = raw.rstrip("\n")
369
+ if "\t" not in line:
370
+ raise SystemExit(f"invalid message record at line {number}")
371
+ channel, text = line.split("\t", 1)
372
+ if (
373
+ channel not in {"stdout", "stderr"}
374
+ or not text
375
+ or "\t" in text
376
+ or "\r" in text
377
+ or has_terminal_control(text)
378
+ ):
379
+ raise SystemExit(f"invalid message record at line {number}")
380
+ messages.append({"channel": channel, "text": text})
381
+
382
+ ok = ok_text == "true"
383
+ if ok:
384
+ envelope = {
385
+ "protocol": 1,
386
+ "operation": operation,
387
+ "ok": True,
388
+ "messages": messages,
389
+ "result": json.loads(result_text),
390
+ "error": None,
391
+ }
392
+ else:
393
+ hints = []
394
+ if hints_path:
395
+ with open(hints_path, encoding="utf-8") as handle:
396
+ hints = [line.rstrip("\n") for line in handle if line.rstrip("\n")]
397
+ if any(has_terminal_control(hint) for hint in hints):
398
+ raise SystemExit("protocol hints must not contain terminal control characters")
399
+ envelope = {
400
+ "protocol": 1,
401
+ "operation": operation,
402
+ "ok": False,
403
+ "messages": messages,
404
+ "result": None,
405
+ "error": {"code": code, "message": error_message, "hints": hints},
406
+ }
407
+ json.dump(envelope, sys.stdout, allow_nan=False, separators=(",", ":"))
408
+ sys.stdout.write("\n")
409
+ PY
410
+ }
411
+
412
+ spw_codex_success() {
413
+ spw_codex_emit "$1" true "$2" "" "" "" "$3"
414
+ }
415
+
416
+ spw_codex_failure() {
417
+ spw_codex_emit "$1" false '{}' "$2" "$3" "$4" "$5"
418
+ exit 1
419
+ }
420
+
421
+ # Append command output as structured message records while keeping the
422
+ # tab-delimited spool format unambiguous. Each physical command-output line is
423
+ # escaped with Python's unicode_escape codec so literal backslashes remain
424
+ # distinguishable from rendered control characters like \t and \r.
425
+ spw_codex_append_messages() {
426
+ messages_file="$1"
427
+ channel="$2"
428
+ input_file="$3"
429
+ [ -f "$input_file" ] || return 0
430
+ python3 - "$messages_file" "$channel" "$input_file" <<'PY'
431
+ from pathlib import Path
432
+ import sys
433
+
434
+ messages_path = Path(sys.argv[1])
435
+ channel = sys.argv[2]
436
+ input_path = Path(sys.argv[3])
437
+
438
+ chunks = input_path.read_bytes().split(b"\n")
439
+ with messages_path.open("a", encoding="utf-8") as handle:
440
+ for chunk in chunks:
441
+ if not chunk:
442
+ continue
443
+ text = chunk.decode("utf-8", errors="backslashreplace")
444
+ escaped = text.encode("unicode_escape").decode("ascii")
445
+ if not escaped:
446
+ continue
447
+ handle.write(f"{channel}\t{escaped}\n")
448
+ PY
449
+ }
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env python3
2
- """Validate the wrapper-owned generated Superpowers plugin contract."""
2
+ """Validate the manager-owned generated Superpowers plugin contract."""
3
3
 
4
4
  from __future__ import annotations
5
5
 
@@ -53,7 +53,7 @@ def json_nesting_exceeds_limit(value: Any) -> bool:
53
53
 
54
54
  def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
55
55
  parser = argparse.ArgumentParser(
56
- description="Validate a generated Superpowers wrapper candidate."
56
+ description="Validate a generated Superpowers manager candidate."
57
57
  )
58
58
  parser.add_argument("--plugin-root", required=True)
59
59
  parser.add_argument("--source", required=True)
@@ -270,7 +270,7 @@ def validate_provenance(args: argparse.Namespace, plugin_root: Path, errors: lis
270
270
  if provenance is None:
271
271
  return
272
272
  if set(provenance) != PROVENANCE_KEYS:
273
- errors.append("provenance keys do not match the wrapper-owned contract")
273
+ errors.append("provenance keys do not match the manager-owned contract")
274
274
  expected = {
275
275
  "source": args.source,
276
276
  "requested_ref": args.requested_ref,
@@ -0,0 +1,103 @@
1
+ #!/bin/sh
2
+ # Sourced module; callers own set -eu.
3
+
4
+ spw_adapter_package_root() {
5
+ if [ -n "${SPW_PACKAGE_ROOT:-}" ]; then
6
+ printf '%s\n' "$SPW_PACKAGE_ROOT"
7
+ elif [ -n "${root:-}" ]; then
8
+ printf '%s\n' "$root"
9
+ else
10
+ spw_root
11
+ fi
12
+ }
13
+
14
+ SPW_ADAPTER_ROOT=$(spw_adapter_package_root)
15
+ SPW_ADAPTER="${SPW_ADAPTER:-$SPW_ADAPTER_ROOT/scripts/adapters/codex/adapter}"
16
+ SPW_ADAPTER_RESPONSE_VALIDATOR="${SPW_ADAPTER_RESPONSE_VALIDATOR:-$SPW_ADAPTER_ROOT/scripts/core/validate-adapter-response.py}"
17
+
18
+ spw_invoke_adapter() {
19
+ operation="$1"
20
+ result_file="$2"
21
+ inspect_view="$3"
22
+ shift 3
23
+ [ "${1:-}" = "--" ] || spw_die "internal adapter invocation missing --"
24
+ shift
25
+
26
+ response_file="${result_file}.response"
27
+ rm -f "$result_file"
28
+ adapter_exit=0
29
+ "$SPW_ADAPTER" "$operation" "$@" > "$response_file" || adapter_exit=$?
30
+
31
+ args=""
32
+ if [ -n "$inspect_view" ]; then
33
+ args="$inspect_view"
34
+ fi
35
+ validator_exit=0
36
+ if [ -n "$args" ]; then
37
+ python3 -S "$SPW_ADAPTER_RESPONSE_VALIDATOR" \
38
+ --operation "$operation" --adapter-exit "$adapter_exit" \
39
+ --response "$response_file" --result "$result_file" \
40
+ --inspect-view "$inspect_view" || validator_exit=$?
41
+ else
42
+ python3 -S "$SPW_ADAPTER_RESPONSE_VALIDATOR" \
43
+ --operation "$operation" --adapter-exit "$adapter_exit" \
44
+ --response "$response_file" --result "$result_file" || validator_exit=$?
45
+ fi
46
+ if [ "$validator_exit" -ne 0 ]; then
47
+ rm -f "$result_file"
48
+ return 1
49
+ fi
50
+ }
51
+
52
+ spw_adapter_result_get() {
53
+ result_file="$1"
54
+ dotted_key="$2"
55
+ spw_json_get "$result_file" "$dotted_key"
56
+ }
57
+
58
+ spw_adapter_result_boolean() {
59
+ result_file="$1"
60
+ dotted_key="$2"
61
+ value=$(spw_adapter_result_get "$result_file" "$dotted_key")
62
+ case "$value" in
63
+ true|True)
64
+ printf 'true\n'
65
+ ;;
66
+ false|False)
67
+ printf 'false\n'
68
+ ;;
69
+ *)
70
+ spw_die "expected Boolean adapter result at $dotted_key in $result_file"
71
+ ;;
72
+ esac
73
+ }
74
+
75
+ spw_inspect_fingerprint() {
76
+ result_file="$1"
77
+ spw_invoke_adapter inspect "$result_file" fingerprint -- --view fingerprint
78
+ }
79
+
80
+ spw_inspect_ownership() {
81
+ result_file="$1"
82
+ spw_invoke_adapter inspect "$result_file" ownership -- --view ownership
83
+ }
84
+
85
+ spw_inspect_update_control() {
86
+ result_file="$1"
87
+ spw_invoke_adapter inspect "$result_file" update-control -- --view update-control
88
+ }
89
+
90
+ spw_adapter_install() {
91
+ result_file="$1"
92
+ package_root="$2"
93
+ spw_invoke_adapter install "$result_file" "" -- --package-root "$package_root"
94
+ }
95
+
96
+ spw_adapter_uninstall() {
97
+ result_file="$1"
98
+ plugin_present="$2"
99
+ marketplace_present="$3"
100
+ spw_invoke_adapter uninstall "$result_file" "" -- \
101
+ --plugin-present "$plugin_present" \
102
+ --marketplace-present "$marketplace_present"
103
+ }
@@ -0,0 +1,59 @@
1
+ #!/bin/sh
2
+ # Sourced module; callers own set -eu.
3
+
4
+ spw_die() {
5
+ echo "error: $*" >&2
6
+ exit 1
7
+ }
8
+
9
+ spw_usage_error() {
10
+ echo "error: $*" >&2
11
+ exit 2
12
+ }
13
+
14
+ spw_require_command() {
15
+ command_name="$1"
16
+ if ! command -v "$command_name" >/dev/null 2>&1; then
17
+ spw_die "required command not found: $command_name"
18
+ fi
19
+ }
20
+
21
+ spw_make_workspace() {
22
+ mktemp -d "$1/$2.XXXXXX"
23
+ }
24
+
25
+ spw_cleanup_workspace_trap() {
26
+ _spw_workspace_trap_status=$?
27
+ trap - 0 HUP INT TERM
28
+ rm -rf "$_spw_workspace_trap_path" || :
29
+ exit "$_spw_workspace_trap_status"
30
+ }
31
+
32
+ spw_install_workspace_trap() {
33
+ _spw_workspace_trap_path="$1"
34
+ trap spw_cleanup_workspace_trap 0
35
+ trap 'exit 129' HUP
36
+ trap 'exit 130' INT
37
+ trap 'exit 143' TERM
38
+ }
39
+
40
+ spw_root() {
41
+ CDPATH= cd -- "$(dirname "$0")/.." && pwd
42
+ }
43
+
44
+ spw_copy_path_if_present() {
45
+ src="$1"
46
+ dst="$2"
47
+ if [ -e "$src" ]; then
48
+ rm -rf "$dst"
49
+ cp -R "$src" "$dst"
50
+ fi
51
+ }
52
+
53
+ spw_require_upstream_path() {
54
+ path="$1"
55
+ label="$2"
56
+ if [ ! -e "$path" ]; then
57
+ spw_die "required upstream path missing: $label"
58
+ fi
59
+ }