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.
- scripticus_server-0.3.0/PKG-INFO +117 -0
- scripticus_server-0.3.0/README.md +103 -0
- {scripticus_server-0.1.1 → scripticus_server-0.3.0}/pyproject.toml +8 -1
- scripticus_server-0.3.0/src/scripticus_server/app.py +122 -0
- scripticus_server-0.3.0/src/scripticus_server/db.py +175 -0
- scripticus_server-0.3.0/src/scripticus_server/gitea.py +115 -0
- scripticus_server-0.3.0/src/scripticus_server/publish.py +316 -0
- scripticus_server-0.1.1/PKG-INFO +0 -66
- scripticus_server-0.1.1/README.md +0 -56
- scripticus_server-0.1.1/src/scripticus_server/app.py +0 -29
- {scripticus_server-0.1.1 → scripticus_server-0.3.0}/src/scripticus_server/__init__.py +0 -0
- {scripticus_server-0.1.1 → scripticus_server-0.3.0}/src/scripticus_server/main.py +0 -0
|
@@ -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.
|
|
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
|
+
)
|
scripticus_server-0.1.1/PKG-INFO
DELETED
|
@@ -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()
|
|
File without changes
|
|
File without changes
|