scripticus-server 0.1.1__tar.gz → 0.3.0__tar.gz

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,117 @@
1
+ Metadata-Version: 2.4
2
+ Name: scripticus-server
3
+ Version: 0.3.0
4
+ Summary: A package manager and registry for scripts — index service
5
+ License-Expression: MIT
6
+ Requires-Dist: fastapi>=0.115
7
+ Requires-Dist: uvicorn>=0.30
8
+ Requires-Dist: sqlalchemy>=2.0
9
+ Requires-Dist: httpx>=0.27
10
+ Requires-Dist: python-multipart>=0.0.9
11
+ Requires-Dist: scripticus-schema>=0.1.2,<0.2.0
12
+ Requires-Python: >=3.11
13
+ Description-Content-Type: text/markdown
14
+
15
+ # Scripticus server
16
+
17
+ The index service for [Scripticus](https://github.com/kevinchannon/scripticus),
18
+ a package manager and registry for scripts. The server provides
19
+ manifest-aware search, version and dependency resolution, and the publish
20
+ path for a Scripticus registry. Installing this package provides the
21
+ `scripticus-svr` command.
22
+
23
+ ## Running the server
24
+
25
+ `scripticus-svr` starts the service, printing its version and address on
26
+ start-up:
27
+
28
+ ```console
29
+ $ scripticus-svr --host 0.0.0.0 --port 8000
30
+ scripticus-svr 0.1.0 — serving on http://0.0.0.0:8000 (interactive API docs at http://0.0.0.0:8000/docs)
31
+ ```
32
+
33
+ Both options are optional; the default is `127.0.0.1:8000`. The API is
34
+ self-describing: interactive docs are served at `/docs` and the OpenAPI
35
+ spec at `/openapi.json`.
36
+
37
+ ### Health check
38
+
39
+ `GET /health` returns `200` with `{"status": "ok"}` while the service is
40
+ up. It is deliberately unauthenticated — it's a liveness probe for load
41
+ balancers and container orchestrators.
42
+
43
+ ### Version
44
+
45
+ `GET /version` returns the running server's version, e.g.
46
+ `{"version": "0.1.1"}`.
47
+
48
+ ### Package index (read API)
49
+
50
+ - `GET /packages/{namespace}/{name}` — a package's version listing, newest
51
+ first by semver precedence. Yanked versions are included and marked
52
+ (`"yanked": true`) so pinned lookups can still see them; unknown packages
53
+ return `404`.
54
+ - `GET /search?q=<substring>&platform=<os>&language=<lang>` — packages whose
55
+ name contains `q` (all parameters optional), with each result's latest
56
+ non-yanked version. Yanked versions are invisible to search; `platform`
57
+ and `language` filter on the artifacts a version actually provides.
58
+
59
+ The index database defaults to a local SQLite file
60
+ (`scripticus-index.db`); set `SCRIPTICUS_INDEX_DB` to any SQLAlchemy URL
61
+ to point elsewhere. Tables are created automatically on first use.
62
+
63
+ ### Publishing
64
+
65
+ `POST /packages` publishes a package: a multipart upload of one archive
66
+ (as produced by `scripticus pack`) with your Gitea token in the
67
+ `Authorization` header:
68
+
69
+ ```console
70
+ $ curl -X POST http://localhost:8000/packages \
71
+ -H "Authorization: token <your-gitea-token>" \
72
+ -F archive=@my_tool-1.0.0-linux.macos-bash.tar.gz
73
+ ```
74
+
75
+ The server trusts nothing about the upload: it re-validates the manifest
76
+ and package tree, computes the content hash, checks with Gitea (live)
77
+ that your token may publish to the manifest's namespace — your own
78
+ username, or an organisation you belong to — stores the blob in Gitea's
79
+ generic package registry, and only then commits the index record.
80
+ Versions are immutable; the one addition an existing version accepts is
81
+ an artifact in a new archive format carrying the identical content hash.
82
+ Declared package dependencies must be fully namespaced and already
83
+ present in the index, and a publish that would create a dependency cycle
84
+ is rejected. The `library` namespace is reserved. The Gitea instance is
85
+ configured with `SCRIPTICUS_GITEA_URL` (default `http://localhost:3000`).
86
+
87
+ ### Docker
88
+
89
+ Server releases publish a Docker image to
90
+ [`kevinchannon/scripticus-server`](https://hub.docker.com/r/kevinchannon/scripticus-server)
91
+ (tagged with the release version and `latest`). The repository's
92
+ `docker-compose.yml` is the full registry bundle — the index service plus
93
+ the Gitea instance that provides storage, authentication, and namespace
94
+ ownership — and needs no checkout:
95
+
96
+ ```console
97
+ $ curl -LO https://raw.githubusercontent.com/kevinchannon/scripticus/main/docker-compose.yml
98
+ $ docker compose up -d
99
+ $ curl http://localhost:8000/health
100
+ {"status":"ok"}
101
+ ```
102
+
103
+ First-run Gitea setup: accounts and organisations are managed in Gitea
104
+ (http://localhost:3000), and a Scripticus namespace *is* a Gitea user or
105
+ organisation, claimed first-come-first-served, with publish rights
106
+ following Gitea's own membership and ACLs. So, once the bundle is up:
107
+
108
+ 1. Register your user in the Gitea web UI (the first registered user is
109
+ the instance admin), and create an organisation for any shared
110
+ namespace you want.
111
+ 2. Generate a token under *Settings → Applications → Manage Access
112
+ Tokens* with package write and user read scopes.
113
+ 3. Publish with that token (see above).
114
+
115
+ ## Licence
116
+
117
+ MIT
@@ -0,0 +1,103 @@
1
+ # Scripticus server
2
+
3
+ The index service for [Scripticus](https://github.com/kevinchannon/scripticus),
4
+ a package manager and registry for scripts. The server provides
5
+ manifest-aware search, version and dependency resolution, and the publish
6
+ path for a Scripticus registry. Installing this package provides the
7
+ `scripticus-svr` command.
8
+
9
+ ## Running the server
10
+
11
+ `scripticus-svr` starts the service, printing its version and address on
12
+ start-up:
13
+
14
+ ```console
15
+ $ scripticus-svr --host 0.0.0.0 --port 8000
16
+ scripticus-svr 0.1.0 — serving on http://0.0.0.0:8000 (interactive API docs at http://0.0.0.0:8000/docs)
17
+ ```
18
+
19
+ Both options are optional; the default is `127.0.0.1:8000`. The API is
20
+ self-describing: interactive docs are served at `/docs` and the OpenAPI
21
+ spec at `/openapi.json`.
22
+
23
+ ### Health check
24
+
25
+ `GET /health` returns `200` with `{"status": "ok"}` while the service is
26
+ up. It is deliberately unauthenticated — it's a liveness probe for load
27
+ balancers and container orchestrators.
28
+
29
+ ### Version
30
+
31
+ `GET /version` returns the running server's version, e.g.
32
+ `{"version": "0.1.1"}`.
33
+
34
+ ### Package index (read API)
35
+
36
+ - `GET /packages/{namespace}/{name}` — a package's version listing, newest
37
+ first by semver precedence. Yanked versions are included and marked
38
+ (`"yanked": true`) so pinned lookups can still see them; unknown packages
39
+ return `404`.
40
+ - `GET /search?q=<substring>&platform=<os>&language=<lang>` — packages whose
41
+ name contains `q` (all parameters optional), with each result's latest
42
+ non-yanked version. Yanked versions are invisible to search; `platform`
43
+ and `language` filter on the artifacts a version actually provides.
44
+
45
+ The index database defaults to a local SQLite file
46
+ (`scripticus-index.db`); set `SCRIPTICUS_INDEX_DB` to any SQLAlchemy URL
47
+ to point elsewhere. Tables are created automatically on first use.
48
+
49
+ ### Publishing
50
+
51
+ `POST /packages` publishes a package: a multipart upload of one archive
52
+ (as produced by `scripticus pack`) with your Gitea token in the
53
+ `Authorization` header:
54
+
55
+ ```console
56
+ $ curl -X POST http://localhost:8000/packages \
57
+ -H "Authorization: token <your-gitea-token>" \
58
+ -F archive=@my_tool-1.0.0-linux.macos-bash.tar.gz
59
+ ```
60
+
61
+ The server trusts nothing about the upload: it re-validates the manifest
62
+ and package tree, computes the content hash, checks with Gitea (live)
63
+ that your token may publish to the manifest's namespace — your own
64
+ username, or an organisation you belong to — stores the blob in Gitea's
65
+ generic package registry, and only then commits the index record.
66
+ Versions are immutable; the one addition an existing version accepts is
67
+ an artifact in a new archive format carrying the identical content hash.
68
+ Declared package dependencies must be fully namespaced and already
69
+ present in the index, and a publish that would create a dependency cycle
70
+ is rejected. The `library` namespace is reserved. The Gitea instance is
71
+ configured with `SCRIPTICUS_GITEA_URL` (default `http://localhost:3000`).
72
+
73
+ ### Docker
74
+
75
+ Server releases publish a Docker image to
76
+ [`kevinchannon/scripticus-server`](https://hub.docker.com/r/kevinchannon/scripticus-server)
77
+ (tagged with the release version and `latest`). The repository's
78
+ `docker-compose.yml` is the full registry bundle — the index service plus
79
+ the Gitea instance that provides storage, authentication, and namespace
80
+ ownership — and needs no checkout:
81
+
82
+ ```console
83
+ $ curl -LO https://raw.githubusercontent.com/kevinchannon/scripticus/main/docker-compose.yml
84
+ $ docker compose up -d
85
+ $ curl http://localhost:8000/health
86
+ {"status":"ok"}
87
+ ```
88
+
89
+ First-run Gitea setup: accounts and organisations are managed in Gitea
90
+ (http://localhost:3000), and a Scripticus namespace *is* a Gitea user or
91
+ organisation, claimed first-come-first-served, with publish rights
92
+ following Gitea's own membership and ACLs. So, once the bundle is up:
93
+
94
+ 1. Register your user in the Gitea web UI (the first registered user is
95
+ the instance admin), and create an organisation for any shared
96
+ namespace you want.
97
+ 2. Generate a token under *Settings → Applications → Manage Access
98
+ Tokens* with package write and user read scopes.
99
+ 3. Publish with that token (see above).
100
+
101
+ ## Licence
102
+
103
+ MIT
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "scripticus-server"
3
- version = "0.1.1"
3
+ version = "0.3.0"
4
4
  description = "A package manager and registry for scripts — index service"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -8,8 +8,15 @@ license = "MIT"
8
8
  dependencies = [
9
9
  "fastapi>=0.115",
10
10
  "uvicorn>=0.30",
11
+ "sqlalchemy>=2.0",
12
+ "httpx>=0.27",
13
+ "python-multipart>=0.0.9",
14
+ "scripticus-schema>=0.1.2,<0.2.0",
11
15
  ]
12
16
 
17
+ [tool.uv.sources]
18
+ scripticus-schema = { workspace = true }
19
+
13
20
  [project.scripts]
14
21
  scripticus-svr = "scripticus_server.main:main"
15
22
 
@@ -0,0 +1,122 @@
1
+ from typing import Literal
2
+
3
+ from fastapi import Depends, FastAPI, HTTPException
4
+ from pydantic import BaseModel
5
+ from sqlalchemy import select
6
+ from sqlalchemy.orm import Session
7
+
8
+ from scripticus_schema.index_api import (
9
+ PackageSummary,
10
+ PackageVersions,
11
+ SearchResults,
12
+ VersionSummary,
13
+ )
14
+ from scripticus_schema.semver import semver_key
15
+ from scripticus_server import __version__, db
16
+ from scripticus_server.db import get_session
17
+ from scripticus_server.publish import router as publish_router
18
+
19
+ app = FastAPI(
20
+ title="Scripticus index service",
21
+ description=(
22
+ "Manifest-aware search, version and dependency resolution, and the "
23
+ "publish path for a Scripticus registry."
24
+ ),
25
+ version=__version__,
26
+ )
27
+ app.include_router(publish_router)
28
+
29
+
30
+ # Local to the server on purpose: a liveness shape is not part of the
31
+ # package contract, so it doesn't meet the schema/ admission rule (D29).
32
+ class HealthStatus(BaseModel):
33
+ status: Literal["ok"] = "ok"
34
+
35
+
36
+ # Unauthenticated by design: a liveness probe carries nothing worth gating,
37
+ # and the index service stays out of the ACL business anyway (D24). Leave it
38
+ # open even once other endpoints grow auth.
39
+ @app.get("/health")
40
+ def health() -> HealthStatus:
41
+ return HealthStatus()
42
+
43
+
44
+ class VersionInfo(BaseModel):
45
+ version: str
46
+
47
+
48
+ @app.get("/version")
49
+ def version() -> VersionInfo:
50
+ return VersionInfo(version=__version__)
51
+
52
+
53
+ @app.get("/packages/{namespace}/{name}")
54
+ def package_versions(
55
+ namespace: str, name: str, session: Session = Depends(get_session)
56
+ ) -> PackageVersions:
57
+ package = session.scalar(
58
+ select(db.Package)
59
+ .join(db.Namespace)
60
+ .where(db.Namespace.name == namespace, db.Package.name == name)
61
+ )
62
+ if package is None:
63
+ raise HTTPException(404, f"no package '{namespace}/{name}' in the index")
64
+ ordered = sorted(
65
+ package.versions, key=lambda pv: semver_key(pv.version), reverse=True
66
+ )
67
+ return PackageVersions(
68
+ namespace=namespace,
69
+ name=name,
70
+ description=next((pv.description for pv in ordered if not pv.yanked), ""),
71
+ versions=[
72
+ VersionSummary(version=pv.version, yanked=pv.yanked) for pv in ordered
73
+ ],
74
+ )
75
+
76
+
77
+ def _has_matching_artifact(
78
+ package_version: db.PackageVersion, platform: str | None, language: str | None
79
+ ) -> bool:
80
+ if platform is None and language is None:
81
+ return True
82
+ for artifact in package_version.artifacts:
83
+ if platform is not None and platform not in artifact.platform_list():
84
+ continue
85
+ if language is not None and artifact.language != language:
86
+ continue
87
+ return True
88
+ return False
89
+
90
+
91
+ @app.get("/search")
92
+ def search(
93
+ q: str = "",
94
+ platform: str | None = None,
95
+ language: str | None = None,
96
+ session: Session = Depends(get_session),
97
+ ) -> SearchResults:
98
+ packages = session.scalars(
99
+ select(db.Package)
100
+ .join(db.Namespace)
101
+ .where(db.Package.name.contains(q))
102
+ .order_by(db.Namespace.name, db.Package.name)
103
+ ).all()
104
+ results = []
105
+ for package in packages:
106
+ candidates = [
107
+ pv
108
+ for pv in package.versions
109
+ if not pv.yanked and _has_matching_artifact(pv, platform, language)
110
+ ]
111
+ if not candidates:
112
+ continue
113
+ latest = max(candidates, key=lambda pv: semver_key(pv.version))
114
+ results.append(
115
+ PackageSummary(
116
+ namespace=package.namespace.name,
117
+ name=package.name,
118
+ description=latest.description,
119
+ latest_version=latest.version,
120
+ )
121
+ )
122
+ return SearchResults(results=results)
@@ -0,0 +1,175 @@
1
+ """The index data model (ARCHITECTURE.md), SQLite via SQLAlchemy (D23).
2
+
3
+ No SQLite-isms: everything here must work unchanged on Postgres, so the
4
+ database stays a configuration change (``SCRIPTICUS_INDEX_DB``). Tables are
5
+ created with ``create_all`` at startup — no migration tool until the schema
6
+ has released consumers (D31).
7
+
8
+ The extracted columns are a publish-time projection of the verbatim
9
+ manifest blob, never independently editable (D21).
10
+ """
11
+
12
+ import os
13
+ from collections.abc import Iterator
14
+
15
+ from sqlalchemy import ForeignKey, Text, UniqueConstraint, create_engine
16
+ from sqlalchemy.engine import Engine
17
+ from sqlalchemy.orm import (
18
+ DeclarativeBase,
19
+ Mapped,
20
+ Session,
21
+ mapped_column,
22
+ relationship,
23
+ sessionmaker,
24
+ )
25
+
26
+ DEFAULT_DB_URL = "sqlite:///scripticus-index.db"
27
+
28
+
29
+ def database_url() -> str:
30
+ return os.environ.get("SCRIPTICUS_INDEX_DB", DEFAULT_DB_URL)
31
+
32
+
33
+ def make_engine(url: str | None = None) -> Engine:
34
+ return create_engine(url or database_url())
35
+
36
+
37
+ def init_db(engine: Engine) -> None:
38
+ Base.metadata.create_all(engine)
39
+
40
+
41
+ _session_factory: sessionmaker | None = None
42
+
43
+
44
+ def get_session() -> Iterator[Session]:
45
+ # FastAPI dependency. Lazy so that importing the app never touches the
46
+ # database; tables are created on first use (D31: create_all until the
47
+ # schema stabilises).
48
+ global _session_factory
49
+ if _session_factory is None:
50
+ engine = make_engine()
51
+ init_db(engine)
52
+ _session_factory = sessionmaker(bind=engine)
53
+ with _session_factory() as session:
54
+ yield session
55
+
56
+
57
+ class Base(DeclarativeBase):
58
+ pass
59
+
60
+
61
+ class Namespace(Base):
62
+ """A cached reference to a Gitea user/org — an FK anchor only. Gitea
63
+ remains authoritative for ownership and ACLs; nothing permission-shaped
64
+ is ever stored here (D24).
65
+ """
66
+
67
+ __tablename__ = "namespace"
68
+
69
+ id: Mapped[int] = mapped_column(primary_key=True)
70
+ name: Mapped[str] = mapped_column(unique=True)
71
+
72
+ packages: Mapped[list["Package"]] = relationship(back_populates="namespace")
73
+
74
+
75
+ class Package(Base):
76
+ __tablename__ = "package"
77
+ __table_args__ = (UniqueConstraint("namespace_id", "name"),)
78
+
79
+ id: Mapped[int] = mapped_column(primary_key=True)
80
+ namespace_id: Mapped[int] = mapped_column(ForeignKey("namespace.id"))
81
+ name: Mapped[str]
82
+
83
+ namespace: Mapped[Namespace] = relationship(back_populates="packages")
84
+ versions: Mapped[list["PackageVersion"]] = relationship(back_populates="package")
85
+
86
+
87
+ class PackageVersion(Base):
88
+ """Immutable once written; yank is a whole-version flag (npm model)."""
89
+
90
+ __tablename__ = "package_version"
91
+ __table_args__ = (UniqueConstraint("package_id", "version"),)
92
+
93
+ id: Mapped[int] = mapped_column(primary_key=True)
94
+ package_id: Mapped[int] = mapped_column(ForeignKey("package.id"))
95
+ version: Mapped[str]
96
+ description: Mapped[str] = mapped_column(default="")
97
+ yanked: Mapped[bool] = mapped_column(default=False)
98
+ published_at: Mapped[str] = mapped_column(default="") # ISO 8601 UTC
99
+ publisher: Mapped[str] = mapped_column(default="")
100
+
101
+ package: Mapped[Package] = relationship(back_populates="versions")
102
+ artifacts: Mapped[list["Artifact"]] = relationship(back_populates="package_version")
103
+ dependencies: Mapped[list["Dependency"]] = relationship()
104
+ tool_deps: Mapped[list["ToolDep"]] = relationship()
105
+ commands: Mapped[list["Command"]] = relationship()
106
+ manifest_blob: Mapped["ManifestBlob | None"] = relationship(
107
+ back_populates="package_version"
108
+ )
109
+
110
+
111
+ class Artifact(Base):
112
+ """One per platform/language variant of a version."""
113
+
114
+ __tablename__ = "artifact"
115
+
116
+ id: Mapped[int] = mapped_column(primary_key=True)
117
+ package_version_id: Mapped[int] = mapped_column(ForeignKey("package_version.id"))
118
+ # Comma-joined OS names from the manifest's platforms.os — a queryable
119
+ # projection; the manifest blob remains the authoritative list.
120
+ platforms: Mapped[str]
121
+ language: Mapped[str]
122
+ archive_format: Mapped[str] = mapped_column(default="")
123
+ content_hash: Mapped[str] = mapped_column(default="")
124
+ size: Mapped[int] = mapped_column(default=0)
125
+ gitea_pointer: Mapped[str] = mapped_column(default="")
126
+
127
+ package_version: Mapped[PackageVersion] = relationship(back_populates="artifacts")
128
+
129
+ def platform_list(self) -> list[str]:
130
+ return self.platforms.split(",") if self.platforms else []
131
+
132
+
133
+ class Dependency(Base):
134
+ __tablename__ = "dependency"
135
+
136
+ id: Mapped[int] = mapped_column(primary_key=True)
137
+ package_version_id: Mapped[int] = mapped_column(ForeignKey("package_version.id"))
138
+ target: Mapped[str] # fully namespaced: "owner/name"
139
+ spec: Mapped[str] # semver range constraint
140
+
141
+
142
+ class ToolDep(Base):
143
+ __tablename__ = "tool_dep"
144
+
145
+ id: Mapped[int] = mapped_column(primary_key=True)
146
+ package_version_id: Mapped[int] = mapped_column(ForeignKey("package_version.id"))
147
+ name: Mapped[str]
148
+ required: Mapped[bool] = mapped_column(default=True)
149
+
150
+
151
+ class Command(Base):
152
+ __tablename__ = "command"
153
+
154
+ id: Mapped[int] = mapped_column(primary_key=True)
155
+ package_version_id: Mapped[int] = mapped_column(ForeignKey("package_version.id"))
156
+ name: Mapped[str]
157
+ script_path: Mapped[str]
158
+
159
+
160
+ class ManifestBlob(Base):
161
+ """The verbatim manifest as published — the authoritative record every
162
+ extracted column above is re-derivable from (D21).
163
+ """
164
+
165
+ __tablename__ = "manifest_blob"
166
+
167
+ id: Mapped[int] = mapped_column(primary_key=True)
168
+ package_version_id: Mapped[int] = mapped_column(
169
+ ForeignKey("package_version.id"), unique=True
170
+ )
171
+ toml: Mapped[str] = mapped_column(Text)
172
+
173
+ package_version: Mapped[PackageVersion] = relationship(
174
+ back_populates="manifest_blob"
175
+ )
@@ -0,0 +1,115 @@
1
+ """The server's boundary to Gitea (D2): permission checks and blob storage.
2
+
3
+ Auth is pass-through (D32): every publish carries the caller's own Gitea
4
+ token, and this client acts with exactly that token's rights — the index
5
+ service holds no credentials and caches nothing ACL-shaped (D24). The
6
+ narrow surface here exists so tests can fake Gitea; nothing outside this
7
+ module speaks HTTP to it.
8
+ """
9
+
10
+ import os
11
+
12
+ import httpx
13
+ from fastapi import Header, HTTPException
14
+
15
+ DEFAULT_GITEA_URL = "http://localhost:3000"
16
+
17
+
18
+ def gitea_url() -> str:
19
+ return os.environ.get("SCRIPTICUS_GITEA_URL", DEFAULT_GITEA_URL)
20
+
21
+
22
+ class GiteaAuthError(Exception):
23
+ """The token is missing, invalid, or expired."""
24
+
25
+
26
+ class GiteaError(Exception):
27
+ """Gitea is unreachable or answered something unexpected."""
28
+
29
+
30
+ class GiteaClient:
31
+ def __init__(self, base_url: str, token: str):
32
+ self._client = httpx.Client(
33
+ base_url=base_url,
34
+ headers={"Authorization": f"token {token}"},
35
+ timeout=30,
36
+ )
37
+
38
+ def _get(self, path: str) -> httpx.Response:
39
+ try:
40
+ return self._client.get(path)
41
+ except httpx.HTTPError as exc:
42
+ raise GiteaError(f"cannot reach Gitea: {exc}") from exc
43
+
44
+ def authenticated_user(self) -> str:
45
+ """The login of the token's owner; raises GiteaAuthError on a bad token."""
46
+ response = self._get("/api/v1/user")
47
+ if response.status_code in (401, 403):
48
+ raise GiteaAuthError("Gitea rejected the token")
49
+ if response.status_code != 200:
50
+ raise GiteaError(f"unexpected {response.status_code} from Gitea /user")
51
+ return response.json()["login"]
52
+
53
+ def can_publish(self, namespace: str, user: str) -> bool:
54
+ """Live ACL check (D24): the namespace is the user themselves, or an
55
+ organisation the user is a member of. Unknown namespaces are simply
56
+ not publishable — Gitea owns namespace existence.
57
+ """
58
+ if namespace == user:
59
+ return True
60
+ response = self._get(f"/api/v1/orgs/{namespace}/members/{user}")
61
+ if response.status_code == 204:
62
+ return True
63
+ if response.status_code < 500:
64
+ # Non-membership, nonexistent org, and permission refusals all
65
+ # come back as assorted 3xx/4xx depending on Gitea version; every
66
+ # one of them means "cannot publish here".
67
+ return False
68
+ raise GiteaError(f"unexpected {response.status_code} from Gitea org check")
69
+
70
+ def _blob_path(self, namespace: str, name: str, version: str, filename: str) -> str:
71
+ return f"/api/packages/{namespace}/generic/{name}/{version}/{filename}"
72
+
73
+ def upload_blob(
74
+ self, namespace: str, name: str, version: str, filename: str, data: bytes
75
+ ) -> str:
76
+ """Write the archive to Gitea's generic package registry; returns the
77
+ download path (relative to the Gitea base URL) as the artifact pointer.
78
+ """
79
+ path = self._blob_path(namespace, name, version, filename)
80
+ try:
81
+ response = self._client.put(path, content=data)
82
+ except httpx.HTTPError as exc:
83
+ raise GiteaError(f"cannot reach Gitea: {exc}") from exc
84
+ if response.status_code == 201:
85
+ return path
86
+ if response.status_code in (401, 403):
87
+ raise GiteaAuthError("Gitea refused the blob write")
88
+ raise GiteaError(
89
+ f"Gitea blob write failed with {response.status_code}: {response.text}"
90
+ )
91
+
92
+ def delete_blob(
93
+ self, namespace: str, name: str, version: str, filename: str
94
+ ) -> None:
95
+ """Best-effort cleanup of an uploaded blob after a failed publish;
96
+ never raises — the publish error it accompanies matters more.
97
+ """
98
+ try:
99
+ self._client.delete(self._blob_path(namespace, name, version, filename))
100
+ except httpx.HTTPError:
101
+ pass
102
+
103
+
104
+ def get_gitea_client(authorization: str | None = Header(None)) -> GiteaClient:
105
+ """FastAPI dependency: a GiteaClient acting as the request's caller.
106
+
107
+ Accepts ``Authorization: token <t>`` (Gitea's own convention) or
108
+ ``Bearer <t>``.
109
+ """
110
+ if authorization is None:
111
+ raise HTTPException(401, "publishing requires a Gitea token")
112
+ scheme, _, token = authorization.partition(" ")
113
+ if scheme.lower() not in ("token", "bearer") or not token:
114
+ raise HTTPException(401, "malformed Authorization header")
115
+ return GiteaClient(gitea_url(), token)
@@ -0,0 +1,316 @@
1
+ """The write path: atomic server-mediated publish (D8, D32).
2
+
3
+ Order of operations is the atomicity guarantee: every validation and
4
+ permission check happens first, the blob goes to Gitea next, and the index
5
+ record is committed only after Gitea confirms the write. Failure at any
6
+ step rejects the whole publish; a commit failure after the blob write
7
+ triggers a best-effort blob delete so nothing dangles.
8
+
9
+ Nothing the client claims is trusted (D8): identity, variant tags,
10
+ dependencies, and the content hash are all derived from the uploaded
11
+ archive server-side.
12
+ """
13
+
14
+ import re
15
+ import tarfile
16
+ import tempfile
17
+ import zipfile
18
+ from datetime import datetime, timezone
19
+ from pathlib import Path
20
+
21
+ from fastapi import APIRouter, Depends, HTTPException, UploadFile
22
+ from sqlalchemy import select
23
+ from sqlalchemy.exc import IntegrityError
24
+ from sqlalchemy.orm import Session
25
+
26
+ from scripticus_schema.manifest import (
27
+ FORMAT_GROUPS,
28
+ NAMESPACE_RE,
29
+ PACKAGE_NAME_RE,
30
+ Manifest,
31
+ ManifestError,
32
+ commands_of,
33
+ load_manifest,
34
+ )
35
+ from scripticus_schema.publish_api import PublishedArtifact, PublishResult
36
+ from scripticus_schema.treehash import tree_hash
37
+ from scripticus_server import db
38
+ from scripticus_server.db import get_session
39
+ from scripticus_server.gitea import (
40
+ GiteaAuthError,
41
+ GiteaClient,
42
+ GiteaError,
43
+ get_gitea_client,
44
+ )
45
+
46
+ router = APIRouter()
47
+
48
+ RESERVED_NAMESPACES = ("library",)
49
+
50
+ _SAFE_FILENAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
51
+
52
+
53
+ def _archive_format(filename: str) -> str:
54
+ if filename.endswith((".tar.gz", ".tgz")):
55
+ return "tar.gz"
56
+ if filename.endswith(".zip"):
57
+ return "zip"
58
+ raise HTTPException(400, f"'{filename}' is not a supported archive (.tar.gz or .zip)")
59
+
60
+
61
+ def _extract_archive(archive: Path, destination: Path) -> Path:
62
+ """Extract and return the single root directory of the package tree.
63
+
64
+ The tar "data" filter (PEP 706) rejects path traversal, links pointing
65
+ outside the tree, and device files; zipfile's extract sanitises
66
+ absolute paths and parent references itself.
67
+ """
68
+ try:
69
+ if archive.name.endswith(".zip"):
70
+ with zipfile.ZipFile(archive) as zf:
71
+ zf.extractall(destination)
72
+ else:
73
+ with tarfile.open(archive) as tar:
74
+ tar.extractall(destination, filter="data")
75
+ except (tarfile.TarError, zipfile.BadZipFile, OSError) as exc:
76
+ raise HTTPException(400, f"cannot extract archive: {exc}") from exc
77
+
78
+ roots = list(destination.iterdir())
79
+ if len(roots) != 1 or not roots[0].is_dir():
80
+ raise HTTPException(400, "archive does not contain a single package directory")
81
+ return roots[0]
82
+
83
+
84
+ def _artifact_platforms(manifest: Manifest, archive_format: str) -> list[str]:
85
+ group = dict(FORMAT_GROUPS)[archive_format]
86
+ platforms = [os_name for os_name in group if os_name in manifest.platforms.os]
87
+ if not platforms:
88
+ raise HTTPException(
89
+ 422,
90
+ f"a .{archive_format} archive carries {'/'.join(group)} targets, but the"
91
+ f" manifest declares platforms {manifest.platforms.os}",
92
+ )
93
+ return platforms
94
+
95
+
96
+ def _check_dependency_targets(session: Session, manifest: Manifest) -> None:
97
+ """Publish-time dependency rules (D33): targets must be fully namespaced
98
+ and already present in the index.
99
+ """
100
+ missing = []
101
+ for target in sorted(manifest.dependencies.packages):
102
+ namespace, _, name = target.partition("/")
103
+ if not (NAMESPACE_RE.match(namespace) and name and PACKAGE_NAME_RE.match(name)):
104
+ raise HTTPException(
105
+ 422,
106
+ f"dependency '{target}' is not a fully namespaced package"
107
+ " reference (namespace/name)",
108
+ )
109
+ known = session.scalar(
110
+ select(db.Package)
111
+ .join(db.Namespace)
112
+ .where(db.Namespace.name == namespace, db.Package.name == name)
113
+ )
114
+ if known is None or not known.versions:
115
+ missing.append(target)
116
+ if missing:
117
+ raise HTTPException(
118
+ 422,
119
+ "dependencies not present in the index: " + ", ".join(missing),
120
+ )
121
+
122
+
123
+ def _check_no_cycle(session: Session, manifest: Manifest) -> None:
124
+ """Reject a publish that would make the publishing package reachable from
125
+ its own dependencies (D33). Edges are package-level: the union of every
126
+ version's declared dependencies.
127
+ """
128
+ rows = session.execute(
129
+ select(db.Namespace.name, db.Package.name, db.Dependency.target)
130
+ .join(db.Package, db.Package.namespace_id == db.Namespace.id)
131
+ .join(db.PackageVersion, db.PackageVersion.package_id == db.Package.id)
132
+ .join(db.Dependency, db.Dependency.package_version_id == db.PackageVersion.id)
133
+ ).all()
134
+ edges: dict[str, set[str]] = {}
135
+ for namespace, name, target in rows:
136
+ edges.setdefault(f"{namespace}/{name}", set()).add(target)
137
+
138
+ publishing = f"{manifest.package.namespace}/{manifest.package.name}"
139
+ edges.setdefault(publishing, set()).update(manifest.dependencies.packages)
140
+
141
+ seen = set()
142
+ stack = list(edges[publishing])
143
+ while stack:
144
+ node = stack.pop()
145
+ if node == publishing:
146
+ raise HTTPException(
147
+ 422,
148
+ f"publishing would create a dependency cycle back to '{publishing}'",
149
+ )
150
+ if node in seen:
151
+ continue
152
+ seen.add(node)
153
+ stack.extend(edges.get(node, ()))
154
+
155
+
156
+ def _storage_filename(upload_name: str, manifest: Manifest, archive_format: str) -> str:
157
+ """The blob's filename in Gitea. The client's wheel-style name is kept
158
+ when sane (filenames are human-legible redundancy only — the manifest is
159
+ the source of truth), otherwise a minimal name is generated.
160
+ """
161
+ candidate = Path(upload_name).name
162
+ if _SAFE_FILENAME_RE.match(candidate) and candidate.endswith(f".{archive_format}"):
163
+ return candidate
164
+ package = manifest.package
165
+ return (
166
+ f"{package.name.replace('-', '_')}-{package.version.replace('-', '_')}"
167
+ f".{archive_format}"
168
+ )
169
+
170
+
171
+ @router.post("/packages", status_code=201)
172
+ def publish(
173
+ archive: UploadFile,
174
+ session: Session = Depends(get_session),
175
+ gitea: GiteaClient = Depends(get_gitea_client),
176
+ ) -> PublishResult:
177
+ data = archive.file.read()
178
+ archive_format = _archive_format(Path(archive.filename or "").name)
179
+
180
+ with tempfile.TemporaryDirectory(prefix="scripticus-publish-") as staging_dir:
181
+ staging = Path(staging_dir)
182
+ upload_path = staging / f"upload.{archive_format}"
183
+ upload_path.write_bytes(data)
184
+ package_root = _extract_archive(upload_path, staging / "tree")
185
+
186
+ try:
187
+ manifest = load_manifest(package_root)
188
+ except ManifestError as exc:
189
+ raise HTTPException(422, str(exc)) from exc
190
+ meta = manifest.package
191
+
192
+ if meta.namespace in RESERVED_NAMESPACES:
193
+ raise HTTPException(
194
+ 403, f"the '{meta.namespace}' namespace is reserved"
195
+ )
196
+ platforms = _artifact_platforms(manifest, archive_format)
197
+
198
+ try:
199
+ user = gitea.authenticated_user()
200
+ allowed = gitea.can_publish(meta.namespace, user)
201
+ except GiteaAuthError as exc:
202
+ raise HTTPException(401, str(exc)) from exc
203
+ except GiteaError as exc:
204
+ raise HTTPException(502, str(exc)) from exc
205
+ if not allowed:
206
+ raise HTTPException(
207
+ 403, f"'{user}' cannot publish to namespace '{meta.namespace}'"
208
+ )
209
+
210
+ content_hash = tree_hash(package_root)
211
+ manifest_text = (package_root / "meta.toml").read_text()
212
+
213
+ namespace = session.scalar(
214
+ select(db.Namespace).where(db.Namespace.name == meta.namespace)
215
+ )
216
+ package = session.scalar(
217
+ select(db.Package)
218
+ .join(db.Namespace)
219
+ .where(db.Namespace.name == meta.namespace, db.Package.name == meta.name)
220
+ )
221
+ existing = None
222
+ if package is not None:
223
+ existing = next(
224
+ (pv for pv in package.versions if pv.version == meta.version), None
225
+ )
226
+ if existing is not None:
227
+ # The variant rule (D32): more artifacts may join a version only if
228
+ # they carry the identical tree, in a format not yet published.
229
+ recorded_hashes = {a.content_hash for a in existing.artifacts}
230
+ if recorded_hashes and content_hash not in recorded_hashes:
231
+ raise HTTPException(
232
+ 409,
233
+ f"{meta.namespace}/{meta.name} {meta.version} already exists"
234
+ " with different content; versions are immutable",
235
+ )
236
+ if any(a.archive_format == archive_format for a in existing.artifacts):
237
+ raise HTTPException(
238
+ 409,
239
+ f"{meta.namespace}/{meta.name} {meta.version} already has a"
240
+ f" .{archive_format} artifact; duplicate versions are rejected",
241
+ )
242
+ else:
243
+ _check_dependency_targets(session, manifest)
244
+ _check_no_cycle(session, manifest)
245
+
246
+ filename = _storage_filename(archive.filename or "", manifest, archive_format)
247
+ try:
248
+ pointer = gitea.upload_blob(meta.namespace, meta.name, meta.version, filename, data)
249
+ except GiteaAuthError as exc:
250
+ raise HTTPException(401, str(exc)) from exc
251
+ except GiteaError as exc:
252
+ raise HTTPException(502, str(exc)) from exc
253
+
254
+ # Gitea has confirmed the write; only now may the index record go in.
255
+ try:
256
+ if existing is None:
257
+ if namespace is None:
258
+ namespace = db.Namespace(name=meta.namespace)
259
+ if package is None:
260
+ package = db.Package(namespace=namespace, name=meta.name)
261
+ version = db.PackageVersion(
262
+ package=package,
263
+ version=meta.version,
264
+ description=meta.description,
265
+ published_at=datetime.now(timezone.utc).isoformat(),
266
+ publisher=user,
267
+ )
268
+ for target, spec in sorted(manifest.dependencies.packages.items()):
269
+ version.dependencies.append(db.Dependency(target=target, spec=spec))
270
+ for tool in manifest.dependencies.tools.requires:
271
+ version.tool_deps.append(db.ToolDep(name=tool, required=True))
272
+ for tool in manifest.dependencies.tools.optional:
273
+ version.tool_deps.append(db.ToolDep(name=tool, required=False))
274
+ for command, script in sorted(commands_of(manifest).items()):
275
+ version.commands.append(db.Command(name=command, script_path=script))
276
+ db.ManifestBlob(package_version=version, toml=manifest_text)
277
+ else:
278
+ version = existing
279
+ db.Artifact(
280
+ package_version=version,
281
+ platforms=",".join(platforms),
282
+ language=meta.language,
283
+ archive_format=archive_format,
284
+ content_hash=content_hash,
285
+ size=len(data),
286
+ gitea_pointer=pointer,
287
+ )
288
+ session.add(version)
289
+ session.commit()
290
+ except IntegrityError as exc:
291
+ session.rollback()
292
+ gitea.delete_blob(meta.namespace, meta.name, meta.version, filename)
293
+ raise HTTPException(
294
+ 409,
295
+ f"{meta.namespace}/{meta.name} {meta.version} was published"
296
+ " concurrently; duplicate versions are rejected",
297
+ ) from exc
298
+ except Exception:
299
+ session.rollback()
300
+ gitea.delete_blob(meta.namespace, meta.name, meta.version, filename)
301
+ raise
302
+
303
+ return PublishResult(
304
+ namespace=meta.namespace,
305
+ name=meta.name,
306
+ version=meta.version,
307
+ content_hash=content_hash,
308
+ publisher=user,
309
+ artifact=PublishedArtifact(
310
+ filename=filename,
311
+ archive_format=archive_format,
312
+ platforms=platforms,
313
+ language=meta.language,
314
+ size=len(data),
315
+ ),
316
+ )
@@ -1,66 +0,0 @@
1
- Metadata-Version: 2.4
2
- Name: scripticus-server
3
- Version: 0.1.1
4
- Summary: A package manager and registry for scripts — index service
5
- License-Expression: MIT
6
- Requires-Dist: fastapi>=0.115
7
- Requires-Dist: uvicorn>=0.30
8
- Requires-Python: >=3.11
9
- Description-Content-Type: text/markdown
10
-
11
- # Scripticus server
12
-
13
- The index service for [Scripticus](https://github.com/kevinchannon/scripticus),
14
- a package manager and registry for scripts. The server provides
15
- manifest-aware search, version and dependency resolution, and the publish
16
- path for a Scripticus registry. Installing this package provides the
17
- `scripticus-svr` command.
18
-
19
- ## Running the server
20
-
21
- `scripticus-svr` starts the service, printing its version and address on
22
- start-up:
23
-
24
- ```console
25
- $ scripticus-svr --host 0.0.0.0 --port 8000
26
- scripticus-svr 0.1.0 — serving on http://0.0.0.0:8000 (interactive API docs at http://0.0.0.0:8000/docs)
27
- ```
28
-
29
- Both options are optional; the default is `127.0.0.1:8000`. The API is
30
- self-describing: interactive docs are served at `/docs` and the OpenAPI
31
- spec at `/openapi.json`.
32
-
33
- ### Health check
34
-
35
- `GET /health` returns `200` with `{"status": "ok"}` while the service is
36
- up. It is deliberately unauthenticated — it's a liveness probe for load
37
- balancers and container orchestrators.
38
-
39
- ### Docker
40
-
41
- Server releases publish a Docker image to
42
- [`kevinchannon/scripticus-server`](https://hub.docker.com/r/kevinchannon/scripticus-server)
43
- (tagged with the release version and `latest`). The repository ships a
44
- `docker-compose.yml` running it as a single container — no checkout
45
- needed:
46
-
47
- ```console
48
- $ curl -LO https://raw.githubusercontent.com/kevinchannon/scripticus/main/docker-compose.yml
49
- $ docker compose up -d
50
- $ curl http://localhost:8000/health
51
- {"status":"ok"}
52
- ```
53
-
54
- The intended v1 deployment pairs the index service with a Gitea instance
55
- that provides storage, authentication, and namespace ownership; Gitea
56
- integration doesn't exist yet, so the compose file is currently
57
- server-only and does not stand up a working registry.
58
-
59
- Once Gitea is part of the bundle: accounts and organisations are managed
60
- in Gitea; a Scripticus namespace is a Gitea user or organisation, claimed
61
- first-come-first-served, and publish rights follow Gitea's own membership
62
- and ACLs. The `library` namespace is reserved.
63
-
64
- ## Licence
65
-
66
- MIT
@@ -1,56 +0,0 @@
1
- # Scripticus server
2
-
3
- The index service for [Scripticus](https://github.com/kevinchannon/scripticus),
4
- a package manager and registry for scripts. The server provides
5
- manifest-aware search, version and dependency resolution, and the publish
6
- path for a Scripticus registry. Installing this package provides the
7
- `scripticus-svr` command.
8
-
9
- ## Running the server
10
-
11
- `scripticus-svr` starts the service, printing its version and address on
12
- start-up:
13
-
14
- ```console
15
- $ scripticus-svr --host 0.0.0.0 --port 8000
16
- scripticus-svr 0.1.0 — serving on http://0.0.0.0:8000 (interactive API docs at http://0.0.0.0:8000/docs)
17
- ```
18
-
19
- Both options are optional; the default is `127.0.0.1:8000`. The API is
20
- self-describing: interactive docs are served at `/docs` and the OpenAPI
21
- spec at `/openapi.json`.
22
-
23
- ### Health check
24
-
25
- `GET /health` returns `200` with `{"status": "ok"}` while the service is
26
- up. It is deliberately unauthenticated — it's a liveness probe for load
27
- balancers and container orchestrators.
28
-
29
- ### Docker
30
-
31
- Server releases publish a Docker image to
32
- [`kevinchannon/scripticus-server`](https://hub.docker.com/r/kevinchannon/scripticus-server)
33
- (tagged with the release version and `latest`). The repository ships a
34
- `docker-compose.yml` running it as a single container — no checkout
35
- needed:
36
-
37
- ```console
38
- $ curl -LO https://raw.githubusercontent.com/kevinchannon/scripticus/main/docker-compose.yml
39
- $ docker compose up -d
40
- $ curl http://localhost:8000/health
41
- {"status":"ok"}
42
- ```
43
-
44
- The intended v1 deployment pairs the index service with a Gitea instance
45
- that provides storage, authentication, and namespace ownership; Gitea
46
- integration doesn't exist yet, so the compose file is currently
47
- server-only and does not stand up a working registry.
48
-
49
- Once Gitea is part of the bundle: accounts and organisations are managed
50
- in Gitea; a Scripticus namespace is a Gitea user or organisation, claimed
51
- first-come-first-served, and publish rights follow Gitea's own membership
52
- and ACLs. The `library` namespace is reserved.
53
-
54
- ## Licence
55
-
56
- MIT
@@ -1,29 +0,0 @@
1
- from typing import Literal
2
-
3
- from fastapi import FastAPI
4
- from pydantic import BaseModel
5
-
6
- from scripticus_server import __version__
7
-
8
- app = FastAPI(
9
- title="Scripticus index service",
10
- description=(
11
- "Manifest-aware search, version and dependency resolution, and the "
12
- "publish path for a Scripticus registry."
13
- ),
14
- version=__version__,
15
- )
16
-
17
-
18
- # Local to the server on purpose: a liveness shape is not part of the
19
- # package contract, so it doesn't meet the schema/ admission rule (D29).
20
- class HealthStatus(BaseModel):
21
- status: Literal["ok"] = "ok"
22
-
23
-
24
- # Unauthenticated by design: a liveness probe carries nothing worth gating,
25
- # and the index service stays out of the ACL business anyway (D24). Leave it
26
- # open even once other endpoints grow auth.
27
- @app.get("/health")
28
- def health() -> HealthStatus:
29
- return HealthStatus()