sci-protocol 0.2.0__py3-none-any.whl

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.
Files changed (80) hide show
  1. pysci/__init__.py +77 -0
  2. pysci/__main__.py +8 -0
  3. pysci/_cli/__init__.py +5 -0
  4. pysci/_cli/checks.py +173 -0
  5. pysci/_cli/clients/__init__.py +0 -0
  6. pysci/_cli/clients/archive.py +456 -0
  7. pysci/_cli/clients/credentials.py +54 -0
  8. pysci/_cli/clients/github.py +734 -0
  9. pysci/_cli/clients/hosting.py +238 -0
  10. pysci/_cli/clients/http.py +481 -0
  11. pysci/_cli/clients/huggingface.py +492 -0
  12. pysci/_cli/clients/index.py +435 -0
  13. pysci/_cli/clients/tsa.py +223 -0
  14. pysci/_cli/commands/__init__.py +44 -0
  15. pysci/_cli/commands/check.py +115 -0
  16. pysci/_cli/commands/claim.py +179 -0
  17. pysci/_cli/commands/common.py +91 -0
  18. pysci/_cli/commands/folders.py +83 -0
  19. pysci/_cli/commands/index.py +51 -0
  20. pysci/_cli/commands/keygen.py +58 -0
  21. pysci/_cli/commands/license.py +75 -0
  22. pysci/_cli/commands/protocol.py +72 -0
  23. pysci/_cli/commands/publish.py +282 -0
  24. pysci/_cli/commands/pull.py +337 -0
  25. pysci/_cli/commands/read.py +364 -0
  26. pysci/_cli/commands/revoke.py +207 -0
  27. pysci/_cli/commands/search.py +400 -0
  28. pysci/_cli/commands/sign.py +284 -0
  29. pysci/_cli/commands/submit.py +172 -0
  30. pysci/_cli/commands/timestamp.py +196 -0
  31. pysci/_cli/commands/verify.py +163 -0
  32. pysci/_cli/config.py +233 -0
  33. pysci/_cli/context.py +79 -0
  34. pysci/_cli/destinations.py +163 -0
  35. pysci/_cli/errors.py +168 -0
  36. pysci/_cli/main.py +229 -0
  37. pysci/_cli/output.py +80 -0
  38. pysci/_cli/retrieval.py +51 -0
  39. pysci/_cli/spec.py +113 -0
  40. pysci/_cli/storage/__init__.py +0 -0
  41. pysci/_cli/storage/catalog.py +371 -0
  42. pysci/_cli/storage/files.py +252 -0
  43. pysci/_cli/storage/inputs.py +205 -0
  44. pysci/_cli/storage/keys.py +132 -0
  45. pysci/_cli/storage/local_publish.py +88 -0
  46. pysci/accounts.py +135 -0
  47. pysci/baseline.py +142 -0
  48. pysci/bundle.py +878 -0
  49. pysci/citations.py +233 -0
  50. pysci/data/freetsa-cacert.pem +45 -0
  51. pysci/data/sci-baseline-LICENSE.txt +457 -0
  52. pysci/data/sci-protocol.md +613 -0
  53. pysci/der.py +146 -0
  54. pysci/discovery.py +461 -0
  55. pysci/draft.py +374 -0
  56. pysci/ed25519.py +153 -0
  57. pysci/errors.py +204 -0
  58. pysci/ids.py +61 -0
  59. pysci/jsonstrict.py +225 -0
  60. pysci/keys.py +247 -0
  61. pysci/manifest.py +302 -0
  62. pysci/metadata.py +406 -0
  63. pysci/paths.py +136 -0
  64. pysci/png.py +217 -0
  65. pysci/py.typed +0 -0
  66. pysci/records.py +384 -0
  67. pysci/rfc3339.py +66 -0
  68. pysci/schemas.py +75 -0
  69. pysci/source.py +166 -0
  70. pysci/specification.py +58 -0
  71. pysci/submission.py +225 -0
  72. pysci/templates.py +129 -0
  73. pysci/text.py +226 -0
  74. pysci/timestamps.py +867 -0
  75. pysci/zipformat.py +400 -0
  76. sci_protocol-0.2.0.dist-info/METADATA +112 -0
  77. sci_protocol-0.2.0.dist-info/RECORD +80 -0
  78. sci_protocol-0.2.0.dist-info/WHEEL +4 -0
  79. sci_protocol-0.2.0.dist-info/entry_points.txt +2 -0
  80. sci_protocol-0.2.0.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,115 @@
1
+ """``sci check``: validate an unsigned draft without signing it."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ from typing import Any
7
+
8
+ from pysci.draft import DraftReport, check_draft
9
+
10
+ from ..checks import issue_lines
11
+ from ..context import Context
12
+ from ..errors import CliError, Exit, exit_for_outcome, issue_json, issue_record
13
+ from ..output import Result
14
+ from ..spec import Command, arg
15
+ from ..storage.files import temp_names
16
+
17
+
18
+ def draft_report_json(report: DraftReport) -> dict[str, Any]:
19
+ baseline: dict[str, Any]
20
+ if report.baseline is None:
21
+ baseline = {"status": "not_performed", "reason": "planned sizes could not be computed"}
22
+ else:
23
+ baseline = {
24
+ "status": "eligible" if report.baseline.eligible else "not_eligible",
25
+ "reasons": [issue_json(r) for r in report.baseline.reasons],
26
+ }
27
+ return {
28
+ "valid": report.valid,
29
+ "outcome": str(report.outcome),
30
+ "files": len(report.files),
31
+ "planned_manifest_bytes": report.manifest_bytes,
32
+ "planned_zip_bytes": report.zip_bytes,
33
+ "metadata": None
34
+ if report.metadata is None
35
+ else {"title": report.metadata.title, "display": [d.path for d in report.metadata.display]},
36
+ "baseline": baseline,
37
+ "signed_files_ignored": report.signed,
38
+ "issues": [issue_json(i) for i in report.issues],
39
+ }
40
+
41
+
42
+ def describe(result: Result, report: DraftReport) -> None:
43
+ data = draft_report_json(report)
44
+ result.say(f"files: {data['files']}")
45
+ result.say(f"planned manifest: {data['planned_manifest_bytes']} bytes")
46
+ result.say(f"planned ZIP: {data['planned_zip_bytes']} bytes")
47
+ if report.valid:
48
+ result.say("protocol checks: passed (§§2-4)")
49
+ else:
50
+ result.say(f"protocol checks: {len(report.issues)} problem(s)")
51
+ result.lines.extend(issue_lines(data["issues"], " "))
52
+ baseline = data["baseline"]
53
+ result.say(f"baseline: {baseline['status']}")
54
+ result.lines.extend(issue_lines(baseline.get("reasons", []), " "))
55
+ if report.signed:
56
+ result.say("note: manifest.json/manifest.json.sig present and ignored")
57
+
58
+
59
+ def warn_display(result: Result, report: DraftReport) -> None:
60
+ """Report display images that may not render; they do not change the exit code."""
61
+ for issue in report.display_warnings:
62
+ result.warn(f"{issue.file}: display image does not decode: {issue.observed}")
63
+
64
+
65
+ def run(args: argparse.Namespace, ctx: Context) -> Result:
66
+ draft = ctx.resolve(args.dir)
67
+ if not draft.is_dir():
68
+ raise CliError("draft_missing", f"{draft} is not a directory", Exit.USAGE, file=str(draft))
69
+ report = check_draft(draft, ignore=temp_names(draft))
70
+ result = Result("check")
71
+ result.data = {"path": str(draft), **draft_report_json(report)}
72
+ describe(result, report)
73
+ warn_display(result, report)
74
+ if report.issues:
75
+ result.exit_code = exit_for_outcome(report.outcome)
76
+ result.errors.extend(issue_record(i) for i in report.issues)
77
+ elif (
78
+ report.baseline is not None and not report.baseline.eligible and not args.allow_nonbaseline
79
+ ):
80
+ result.exit_code = Exit.REFUSED
81
+ result.errors.extend(issue_record(i) for i in report.baseline.reasons)
82
+ return result
83
+
84
+
85
+ COMMAND = Command(
86
+ name="check",
87
+ usage_hint="check DIR [--allow-nonbaseline] [--json]",
88
+ summary="Check an unsigned draft without signing it.",
89
+ description="""
90
+ Validates a draft under SCI protocol §§2-4: file paths and conflicts,
91
+ required files, sci.json fields, README/LICENSE text rules, and absence of
92
+ private-key material. Calculates the planned manifest and ZIP sizes with the
93
+ signing serializer and evaluates baseline eligibility (§2.1). All
94
+ independent problems are reported together, each with its file or JSON
95
+ field, requirement, and observed value.
96
+ """,
97
+ args=(
98
+ arg("dir", metavar="DIR", help="draft directory (sci.json, README.md, LICENSE, files/)"),
99
+ arg(
100
+ "--allow-nonbaseline",
101
+ action="store_true",
102
+ help="exit 0 for a protocol-valid draft that is outside the baseline",
103
+ ),
104
+ ),
105
+ outputs="Planned sizes, protocol problems, and baseline eligibility with reasons.",
106
+ files="None; reads no private key.",
107
+ network="None.",
108
+ exit_codes={
109
+ 3: "the draft fails protocol checks",
110
+ 5: "files could not be read or a local limit was exceeded",
111
+ 6: "the draft is valid but not baseline (signing would refuse without override)",
112
+ },
113
+ examples=("sci check ./draft",),
114
+ run=run,
115
+ )
@@ -0,0 +1,179 @@
1
+ """``sci claim``: create a signed account claim (§5.2)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import urllib.parse
7
+ from datetime import UTC, datetime
8
+
9
+ from pysci.accounts import AccountType, claim_urls, normalize_account
10
+ from pysci.errors import SciError
11
+ from pysci.keys import public_key_of
12
+ from pysci.records import DEFAULT_CLAIM_VALIDITY, create_claim, verify_claim
13
+ from pysci.rfc3339 import format_rfc3339, parse_rfc3339
14
+
15
+ from ..clients.http import Deadline, HttpClient
16
+ from ..context import Context
17
+ from ..errors import CliError, Exit
18
+ from ..output import Result
19
+ from ..spec import Command, arg
20
+ from ..storage.files import atomic_write_bytes
21
+ from ..storage.keys import require_outside
22
+ from .common import KEY_ARG_HELP, signing_key
23
+
24
+ HF_API = "https://huggingface.co/api"
25
+
26
+
27
+ def huggingface_spelling(http: HttpClient, name: str, deadline: Deadline) -> str:
28
+ """The user or organization name as spelled by the Hub API."""
29
+ quoted = urllib.parse.quote(name, safe="")
30
+ for kind, field in (("users", "user"), ("organizations", "name")):
31
+ response = http.get(f"{HF_API}/{kind}/{quoted}/overview", deadline=deadline)
32
+ if response.status == 404:
33
+ continue
34
+ if response.status != 200:
35
+ raise CliError(
36
+ "lookup_failed",
37
+ f"Hugging Face account lookup returned HTTP {response.status}",
38
+ Exit.INCOMPLETE,
39
+ retryable=True,
40
+ )
41
+ record = response.json_object("Hugging Face account overview")
42
+ value = record.get(field)
43
+ if isinstance(value, str) and value.lower() == name.lower():
44
+ return value
45
+ raise CliError(
46
+ "invalid_response", "Hugging Face overview has no matching name", Exit.INVALID
47
+ )
48
+ raise CliError(
49
+ "account_not_found",
50
+ f"Hugging Face account {name!r} was not found",
51
+ Exit.INVALID,
52
+ observed=name,
53
+ )
54
+
55
+
56
+ def run(args: argparse.Namespace, ctx: Context) -> Result:
57
+ output = ctx.resolve(args.output)
58
+ key_path, key, warnings = signing_key(ctx, args)
59
+ require_outside(key_path, output)
60
+ try:
61
+ account = normalize_account(args.type, args.account)
62
+ except ValueError as exc:
63
+ raise CliError(
64
+ "invalid_account",
65
+ f"{args.type} account {args.account!r}: {exc}",
66
+ Exit.USAGE,
67
+ field="/account/id",
68
+ observed=args.account,
69
+ ) from None
70
+ result = Result("claim")
71
+ result.warnings.extend(warnings)
72
+ lookup = "not_required"
73
+ if account.type == AccountType.HUGGINGFACE:
74
+ if args.no_lookup:
75
+ lookup = "skipped"
76
+ result.warn("Hugging Face spelling not checked (--no-lookup)")
77
+ else:
78
+ spelled = huggingface_spelling(ctx.http, account.id, ctx.deadline(args.timeout))
79
+ account = normalize_account(account.type, spelled)
80
+ lookup = "huggingface_api"
81
+ now = datetime.now(UTC)
82
+ if args.expires:
83
+ try:
84
+ expires = parse_rfc3339(args.expires)
85
+ except ValueError as exc:
86
+ raise CliError(
87
+ "invalid_expires", f"--expires: {exc}", Exit.USAGE, observed=args.expires
88
+ ) from None
89
+ if expires <= now:
90
+ raise CliError("invalid_expires", "--expires must be in the future", Exit.USAGE)
91
+ else:
92
+ expires = now + DEFAULT_CLAIM_VALIDITY
93
+ if expires - now > DEFAULT_CLAIM_VALIDITY:
94
+ result.warn("the protocol recommends claims expire within 90 days of signing")
95
+ try:
96
+ record = create_claim(key, account, expires=expires, note=args.note, name=args.name)
97
+ except (ValueError, SciError) as exc:
98
+ raise CliError("invalid_claim", str(exc), Exit.USAGE) from None
99
+ public = public_key_of(key)
100
+ check = verify_claim(
101
+ record.record, record.signature, expected_public_key=public, requested_account=account
102
+ )
103
+ if not check.ok: # pragma: no cover - library guarantees validity
104
+ raise CliError("invalid_claim", str(check.issues[0]), Exit.INVALID)
105
+ output.mkdir(parents=True, exist_ok=True)
106
+ claim_path = output / f"{public.x}.json"
107
+ signature_path = output / f"{public.x}.json.sig"
108
+ renewed = claim_path.exists()
109
+ atomic_write_bytes(signature_path, record.signature)
110
+ atomic_write_bytes(claim_path, record.record)
111
+ locations = [{"claim": c, "signature": s} for c, s in claim_urls(account, public.x)]
112
+ result.data = {
113
+ "account": account.to_json(),
114
+ "public_key": public.to_jwk(),
115
+ "expires": format_rfc3339(expires),
116
+ "files": {"claim": str(claim_path), "signature": str(signature_path)},
117
+ "status": "renewed" if renewed else "created",
118
+ "lookup": lookup,
119
+ "hosting_locations": locations,
120
+ }
121
+ result.say(f"claim: {claim_path}")
122
+ result.say(f"signature: {signature_path}")
123
+ result.say(f"account: {account.type}:{account.id} (expires {format_rfc3339(expires)})")
124
+ result.say("Host both files at" + (" one of:" if len(locations) > 1 else ":"))
125
+ for location in locations:
126
+ result.say(f" {location['claim']}")
127
+ result.say(f" {location['signature']}")
128
+ result.say("These files were not uploaded. Renew before expiry; publications are unchanged.")
129
+ return result
130
+
131
+
132
+ COMMAND = Command(
133
+ name="claim",
134
+ usage_hint="claim TYPE ACCOUNT --output DIR [--expires RFC3339] [--key PATH] [--json]",
135
+ summary="Create a signed account claim.",
136
+ description="""
137
+ Creates an SCI key claim (protocol §5.2) linking your public key to a domain,
138
+ GitHub account, or Hugging Face user or organization. The account is
139
+ normalized (domains to lowercase IDNA A-labels, GitHub logins to lowercase);
140
+ Hugging Face names take the spelling returned by the Hub API. Writes
141
+ <x>.json and <x>.json.sig and prints where to host them; nothing is
142
+ uploaded. Renewal replaces the claim files, never publication bytes.
143
+ """,
144
+ args=(
145
+ arg(
146
+ "type",
147
+ metavar="TYPE",
148
+ choices=("domain", "github", "huggingface"),
149
+ help="account method: domain, github, or huggingface",
150
+ ),
151
+ arg("account", metavar="ACCOUNT", help="domain name, GitHub login, or Hugging Face name"),
152
+ arg(
153
+ "--output", metavar="DIR", required=True, help="directory for <x>.json and <x>.json.sig"
154
+ ),
155
+ arg("--expires", metavar="RFC3339", help="claim expiry (default: 90 days from now)"),
156
+ arg(
157
+ "--name",
158
+ metavar="TEXT",
159
+ help="publisher display name, 1-80 Unicode code points",
160
+ ),
161
+ arg("--note", metavar="TEXT", help="optional note stored in the signed claim"),
162
+ arg("--key", metavar="PATH", help=KEY_ARG_HELP),
163
+ arg(
164
+ "--no-lookup",
165
+ action="store_true",
166
+ help="skip the Hugging Face spelling lookup (the given spelling must be exact)",
167
+ ),
168
+ ),
169
+ outputs="Account, expiry, written files, and the required hosting URLs.",
170
+ files="Writes DIR/<x>.json and DIR/<x>.json.sig (replacing an earlier claim for the key).",
171
+ network="Hugging Face accounts: one Hub API lookup unless --no-lookup. Otherwise none.",
172
+ exit_codes={
173
+ 3: "the Hugging Face account does not exist",
174
+ 5: "the account lookup failed temporarily",
175
+ },
176
+ examples=("sci claim domain lab.example --output ./claims",),
177
+ run=run,
178
+ timeout=True,
179
+ )
@@ -0,0 +1,91 @@
1
+ """Helpers shared by several commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from pysci.errors import SciError
10
+ from pysci.ids import parse_publication_id
11
+ from pysci.timestamps import TrustConfig
12
+
13
+ from ..clients.http import Deadline
14
+ from ..clients.index import IndexClient
15
+ from ..clients.tsa import build_trust
16
+ from ..config import normalize_origin
17
+ from ..context import Context
18
+ from ..destinations import resolve_archive
19
+ from ..errors import CliError, Exit
20
+ from ..storage.keys import key_permission_warning, load_signing_key, resolve_key_path
21
+
22
+
23
+ def looks_like_id(value: str) -> bool:
24
+ return value.startswith(("sha256:", "sci:sha256:"))
25
+
26
+
27
+ def parse_id(value: str) -> str:
28
+ """Accept ``sha256:<digest>`` or ``sci:sha256:<digest>``; return the bare digest."""
29
+ try:
30
+ return parse_publication_id(value)
31
+ except ValueError as exc:
32
+ raise CliError(
33
+ "invalid_id",
34
+ f"{exc}",
35
+ Exit.USAGE,
36
+ observed=value,
37
+ requirement="sha256:<64 lowercase hex> or sci:sha256:<64 lowercase hex>",
38
+ ) from None
39
+
40
+
41
+ def index_origin(ctx: Context, explicit: str | None, *, option: str = "--to") -> str:
42
+ if explicit:
43
+ return normalize_origin(explicit)
44
+ if ctx.config.index:
45
+ return ctx.config.index
46
+ raise CliError(
47
+ "missing_index",
48
+ f"no index configured; pass {option} URL or save one with `sci index URL`",
49
+ Exit.USAGE,
50
+ )
51
+
52
+
53
+ def index_client(ctx: Context, explicit: str | None, *, option: str = "--to") -> IndexClient:
54
+ return IndexClient(ctx.http, index_origin(ctx, explicit, option=option))
55
+
56
+
57
+ def archive_urls(values: list[str] | None) -> list[str]:
58
+ urls: list[str] = []
59
+ for value in values or []:
60
+ url = resolve_archive(value)
61
+ if url not in urls:
62
+ urls.append(url)
63
+ return urls
64
+
65
+
66
+ def trust(ctx: Context, *, online: bool = False, deadline: Deadline | None = None) -> TrustConfig:
67
+ config, warnings = build_trust(
68
+ ctx.config.timestamp_trust, online=online, http=ctx.http, deadline=deadline
69
+ )
70
+ del warnings
71
+ return config
72
+
73
+
74
+ def trust_with_warnings(
75
+ ctx: Context, *, online: bool = False, deadline: Deadline | None = None
76
+ ) -> tuple[TrustConfig, list[str]]:
77
+ return build_trust(ctx.config.timestamp_trust, online=online, http=ctx.http, deadline=deadline)
78
+
79
+
80
+ def signing_key(ctx: Context, args: argparse.Namespace) -> tuple[Path, Any, list[str]]:
81
+ path = resolve_key_path(getattr(args, "key", None), ctx.config.key, ctx.home)
82
+ key = load_signing_key(path)
83
+ warnings = [w for w in [key_permission_warning(path)] if w]
84
+ return path, key, warnings
85
+
86
+
87
+ def sci_failure(exc: SciError, message: str) -> CliError:
88
+ return CliError.from_sci(exc, message)
89
+
90
+
91
+ KEY_ARG_HELP = "private key (PKCS#8 PEM); default: configured key, else <state>/key.pem"
@@ -0,0 +1,83 @@
1
+ """``sci folders``: manage the folders selected by ``@local``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import contextlib
7
+ from pathlib import Path
8
+
9
+ from ..context import Context
10
+ from ..errors import CliError, Exit
11
+ from ..output import Result
12
+ from ..spec import Command, arg
13
+
14
+
15
+ def run(args: argparse.Namespace, ctx: Context) -> Result:
16
+ config = ctx.config
17
+ result = Result("folders")
18
+ if args.action is None:
19
+ if args.dir is not None:
20
+ raise CliError("invalid_arguments", "use `sci folders add DIR` or `remove DIR`")
21
+ folders = [{"path": f, "available": Path(f).is_dir()} for f in config.folders]
22
+ result.data = {"folders": folders}
23
+ if not folders:
24
+ result.say("no known folders")
25
+ for entry in folders:
26
+ state = "available" if entry["available"] else "unavailable"
27
+ result.say(f"{entry['path']} ({state})")
28
+ return result
29
+ if args.dir is None:
30
+ raise CliError("invalid_arguments", f"`sci folders {args.action}` requires DIR")
31
+ if args.action == "add":
32
+ folder = ctx.resolve(args.dir)
33
+ if not folder.is_dir():
34
+ raise CliError("folder_missing", f"{folder} is not an existing directory", Exit.USAGE)
35
+ canonical = str(folder.resolve())
36
+ changed = canonical not in config.folders
37
+ if changed:
38
+ config.folders.append(canonical)
39
+ ctx.save_config()
40
+ result.data = {"path": canonical, "status": "added" if changed else "unchanged"}
41
+ result.say(f"{canonical}: {result.data['status']}")
42
+ return result
43
+ raw = ctx.resolve(args.dir)
44
+ candidates = {str(raw.absolute())}
45
+ with contextlib.suppress(OSError):
46
+ candidates.add(str(raw.resolve()))
47
+ removed = [f for f in config.folders if f in candidates]
48
+ if removed:
49
+ config.folders = [f for f in config.folders if f not in candidates]
50
+ ctx.save_config()
51
+ result.data = {
52
+ "path": removed[0] if removed else str(raw),
53
+ "status": "removed" if removed else "unknown",
54
+ }
55
+ result.say(
56
+ f"{result.data['path']}: "
57
+ + ("forgotten (files were not deleted)" if removed else "not a known folder")
58
+ )
59
+ return result
60
+
61
+
62
+ COMMAND = Command(
63
+ name="folders",
64
+ usage_hint="folders [add DIR | remove DIR] [--json]",
65
+ summary="Manage the folders selected by @local.",
66
+ description="""
67
+ Without arguments, lists known folders and whether each is available. `add`
68
+ saves an existing folder's canonical absolute path; `remove` forgets a path
69
+ without deleting any files. Folders are also remembered after an explicit
70
+ local search (`search --in DIR`) and after bundle writes by `sign --output`,
71
+ `pull --zip`, and `publish`. The computer is never scanned for campaigns.
72
+ """,
73
+ args=(
74
+ arg("action", metavar="ACTION", nargs="?", choices=("add", "remove"), help="add or remove"),
75
+ arg("dir", metavar="DIR", nargs="?", help="folder path"),
76
+ ),
77
+ outputs="The known folders and their availability, or the change made.",
78
+ files="`add` and `remove` update config.json; no bundle files are touched.",
79
+ network="None.",
80
+ exit_codes={2: "DIR is missing or not a directory"},
81
+ examples=("sci folders add ./campaign", "sci folders"),
82
+ run=run,
83
+ )
@@ -0,0 +1,51 @@
1
+ """``sci index``: show or save the default index origin."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+
7
+ from ..config import CONFIG_FILE, normalize_origin
8
+ from ..context import Context
9
+ from ..output import Result
10
+ from ..spec import Command, arg
11
+
12
+
13
+ def run(args: argparse.Namespace, ctx: Context) -> Result:
14
+ result = Result("index")
15
+ config = ctx.config
16
+ if args.url is None:
17
+ result.data = {"index": config.index, "config": str(ctx.home / CONFIG_FILE)}
18
+ result.say(config.index or "no index configured (set one with `sci index URL`)")
19
+ return result
20
+ origin = normalize_origin(args.url)
21
+ changed = config.index != origin
22
+ config.index = origin
23
+ if changed:
24
+ ctx.save_config()
25
+ result.data = {
26
+ "index": origin,
27
+ "status": "saved" if changed else "unchanged",
28
+ "config": str(ctx.home / CONFIG_FILE),
29
+ }
30
+ result.say(f"index: {origin} ({'saved' if changed else 'unchanged'})")
31
+ return result
32
+
33
+
34
+ COMMAND = Command(
35
+ name="index",
36
+ usage_hint="index [URL] [--json]",
37
+ summary="Show or save the default index origin.",
38
+ description="""
39
+ Without URL, shows the configured index. With URL, saves that HTTPS origin
40
+ (for example https://sciscape.io) as the default for `pull`, `read`,
41
+ `submit`, `timestamp --submit`, `revoke --submit`, and `search --in @index`.
42
+ The origin is not contacted. No public index is selected by default.
43
+ """,
44
+ args=(arg("url", metavar="URL", nargs="?", help="HTTPS index origin to save"),),
45
+ outputs="The configured index origin.",
46
+ files="With URL, updates config.json in the state directory (~/.sci or $SCI_HOME).",
47
+ network="None.",
48
+ exit_codes={2: "URL is not an HTTPS origin, or config.json is invalid"},
49
+ examples=("sci index https://sciscape.io",),
50
+ run=run,
51
+ )
@@ -0,0 +1,58 @@
1
+ """``sci keygen``: create a signing key pair."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+
7
+ from ..context import Context
8
+ from ..output import Result
9
+ from ..spec import Command, arg
10
+ from ..storage.keys import create_key_pair, resolve_key_path
11
+
12
+
13
+ def run(args: argparse.Namespace, ctx: Context) -> Result:
14
+ path = resolve_key_path(args.key, None, ctx.home)
15
+ if args.key is None:
16
+ ctx.home.mkdir(mode=0o700, parents=True, exist_ok=True)
17
+ public, public_path = create_key_pair(path)
18
+ result = Result("keygen")
19
+ result.data = {
20
+ "private_key": str(path),
21
+ "public_key_file": str(public_path),
22
+ "public_key": public.to_jwk(),
23
+ "x": public.x,
24
+ }
25
+ result.say(f"private key: {path} (owner-only access)")
26
+ result.say(f"public key: {public_path}")
27
+ result.say(f"x: {public.x}")
28
+ result.say("Keep the private key outside publication folders and back it up securely.")
29
+ return result
30
+
31
+
32
+ COMMAND = Command(
33
+ name="keygen",
34
+ usage_hint="keygen [--key PATH] [--json]",
35
+ summary="Create an Ed25519 signing key pair.",
36
+ description="""
37
+ Creates a PKCS#8 private key and a SubjectPublicKeyInfo public key (SCI
38
+ protocol §5.1). The private key file is readable only by its owner. Existing
39
+ files are never overwritten. The public key file name replaces a final .pem
40
+ with .pub.pem, or appends .pub.pem. --key also selects the key for `sign`,
41
+ `claim`, and `revoke`; to make a custom path the default, set "key" in
42
+ config.json.
43
+ """,
44
+ args=(
45
+ arg(
46
+ "--key",
47
+ metavar="PATH",
48
+ help="private key path to create (default: <state>/key.pem, state is ~/.sci or "
49
+ "$SCI_HOME)",
50
+ ),
51
+ ),
52
+ outputs="The public JWK and its x value, and both file paths.",
53
+ files="Creates PATH (mode 0600) and its .pub.pem companion; rejects existing outputs.",
54
+ network="None.",
55
+ exit_codes={2: "an output file already exists or the location is not writable"},
56
+ examples=("sci keygen", "sci keygen --key ~/keys/lab.pem --json"),
57
+ run=run,
58
+ )
@@ -0,0 +1,75 @@
1
+ """``sci license``: install the approved baseline license in a draft."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import hashlib
7
+
8
+ from pysci.baseline import BASELINE_LICENSE_SHA256, baseline_license_bytes
9
+ from pysci.manifest import LICENSE_PATH, MANIFEST_PATH, SIGNATURE_PATH
10
+
11
+ from ..context import Context
12
+ from ..errors import CliError, Exit
13
+ from ..output import Result
14
+ from ..spec import Command, arg
15
+ from ..storage.files import store_bytes
16
+
17
+
18
+ def run(args: argparse.Namespace, ctx: Context) -> Result:
19
+ draft = ctx.resolve(args.dir)
20
+ if not draft.is_dir():
21
+ raise CliError("draft_missing", f"{draft} is not a directory", Exit.USAGE, file=str(draft))
22
+ for generated in (MANIFEST_PATH, SIGNATURE_PATH):
23
+ if (draft / generated).exists():
24
+ raise CliError(
25
+ "signed_directory",
26
+ f"{draft} is signed ({generated} exists); signed publication bytes cannot change",
27
+ Exit.USAGE,
28
+ file=str(draft / generated),
29
+ )
30
+ target = draft / LICENSE_PATH
31
+ data = baseline_license_bytes()
32
+ if target.is_symlink():
33
+ raise CliError("license_conflict", f"{target} is a symbolic link", Exit.INVALID)
34
+ stored = store_bytes(target, data)
35
+ if stored.outcome == "conflict":
36
+ raise CliError(
37
+ "license_conflict",
38
+ f"{target} exists with different terms; it was left unchanged",
39
+ Exit.INVALID,
40
+ file=str(target),
41
+ requirement=f"sha256 {BASELINE_LICENSE_SHA256} (SCI baseline license 0.1)",
42
+ observed=f"sha256 {stored.existing_sha256}",
43
+ )
44
+ result = Result("license")
45
+ result.data = {
46
+ "path": str(target),
47
+ "status": str(stored.outcome),
48
+ "license": "SCI baseline license 0.1",
49
+ "sha256": hashlib.sha256(data).hexdigest(),
50
+ }
51
+ verb = "installed" if stored.outcome == "written" else "already present"
52
+ result.say(f"SCI baseline license 0.1 {verb}: {target}")
53
+ result.say("Terms: CC BY 4.0 for text, figures, and data; MIT for code.")
54
+ return result
55
+
56
+
57
+ COMMAND = Command(
58
+ name="license",
59
+ usage_hint="license DIR [--json]",
60
+ summary="Install the SCI baseline license in a draft.",
61
+ description="""
62
+ Copies the exact approved baseline license (SCI protocol §2.1: CC BY 4.0 for
63
+ text, figures, and data; MIT for code) to DIR/LICENSE. Running this command
64
+ selects those terms explicitly; signing never inserts a license. An identical
65
+ LICENSE is kept; a different one is rejected and left unchanged. Signed
66
+ directories are rejected.
67
+ """,
68
+ args=(arg("dir", metavar="DIR", help="unsigned draft directory"),),
69
+ outputs="The license path, whether it was written or already present, and its SHA-256.",
70
+ files="Creates DIR/LICENSE if absent.",
71
+ network="None.",
72
+ exit_codes={3: "DIR/LICENSE exists with different bytes"},
73
+ examples=("sci license ./draft",),
74
+ run=run,
75
+ )