mirror of
https://github.com/bleak-ai/gcontext.git
synced 2026-08-11 13:19:23 +02:00
Add gcontext share command and submission status endpoint
CLI command validates a local template folder against the workflow standard
(frontmatter, steps/, runs/example/), bundles files, and POSTs to the
marketplace API. --status mode queries GET /api/workflows/{id}/status.
16 new tests (suite 68 -> 84).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
3b3e7da2a3
commit
5fa3f6bffd
@@ -157,6 +157,8 @@ Developing the dashboard itself needs node: `make web-dev` runs a Vite dev serve
|
||||
| `gcontext up [dir]` | Serve the folder over MCP |
|
||||
| `gcontext status [dir]` | Server state, connected clients, state overview |
|
||||
| `gcontext connect [client]` | Connection steps for claude, desktop, codex, cursor |
|
||||
| `gcontext add <id>` | Install a published workflow from the marketplace |
|
||||
| `gcontext share <path>` | Submit a workflow template to the marketplace for review |
|
||||
| `gcontext context [dir]` | Print the context ledger |
|
||||
|
||||
## Going further
|
||||
|
||||
@@ -5,7 +5,7 @@ from sqlalchemy.orm import Session, selectinload
|
||||
from .db import get_session
|
||||
from .manifest import BundleError, parse_manifest, validate_files
|
||||
from .models import APPROVED, PENDING, Template, TemplateFile
|
||||
from .schemas import FileIn, ManifestOut, SubmitIn, SubmitOut, TemplateOut
|
||||
from .schemas import FileIn, ManifestOut, StatusOut, SubmitIn, SubmitOut, TemplateOut
|
||||
|
||||
router = APIRouter(prefix="/api/workflows", tags=["workflows"])
|
||||
|
||||
@@ -39,6 +39,23 @@ def get_workflow(workflow_id: str, session: Session = Depends(get_session)):
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{workflow_id}/status", response_model=StatusOut)
|
||||
def workflow_status(workflow_id: str, session: Session = Depends(get_session)):
|
||||
template = session.scalars(
|
||||
select(Template)
|
||||
.where(Template.id == workflow_id)
|
||||
.order_by(Template.submitted_at.desc())
|
||||
).first()
|
||||
if template is None:
|
||||
raise HTTPException(status_code=404, detail="workflow not found")
|
||||
return StatusOut(
|
||||
id=template.id,
|
||||
status=template.status,
|
||||
submitted_at=template.submitted_at,
|
||||
reviewed_at=template.reviewed_at,
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=SubmitOut, status_code=201)
|
||||
def submit_workflow(body: SubmitIn, session: Session = Depends(get_session)):
|
||||
files = [f.model_dump() for f in body.files]
|
||||
|
||||
@@ -38,6 +38,13 @@ class AdminWorkflowOut(BaseModel):
|
||||
file_count: int
|
||||
|
||||
|
||||
class StatusOut(BaseModel):
|
||||
id: str
|
||||
status: str
|
||||
submitted_at: datetime
|
||||
reviewed_at: datetime | None
|
||||
|
||||
|
||||
class AdminUpdateIn(BaseModel):
|
||||
name: str | None = None
|
||||
description: str | None = None
|
||||
|
||||
+11
-3
@@ -27,8 +27,8 @@ Walk `index.md`, every file in `steps/`, and `functions/` if present. Collect ev
|
||||
|
||||
Classify each element as exactly one of:
|
||||
|
||||
- **(a) Parameter slot**: a value a new user supplies at setup or per run. Becomes a `parameters` entry in the manifest.
|
||||
- **(b) Connection requirement**: a service capability the workflow needs. Becomes a structured `connections` entry (`kind` + `description`), described generically ("the hosting panel API", not "Coolify").
|
||||
- **(a) Parameter slot**: a value that changes the scope or input of a single run. The test: "does this value appear in `0-parameters.*` and does it change what the run does?" If yes, it is a parameter. If the value configures which service to talk to, which queue to pull from, or how to authenticate, it belongs in the connection, not here. Becomes a `parameters` entry in the manifest.
|
||||
- **(b) Connection requirement**: a service capability the workflow needs, including all configuration that identifies *which* instance or account to use (team, project, queue, environment). Becomes a structured `connections` entry (`kind` + `description`), described generically ("the hosting panel API", not "Coolify").
|
||||
- **(c) Personal state**: files or content that must not ship (playbooks learned from the author's systems, configs, credentials references, logs). Excluded from the template; the setup command will regenerate the empty shapes.
|
||||
- **(d) Generic rewrite**: a concrete-service mention inside a step that stays in the text but must be reworded to the capability kind.
|
||||
|
||||
@@ -38,7 +38,7 @@ Present the full classification as one list and get the author's confirmation be
|
||||
|
||||
Create the template folder next to the source module (for example `<workflow-id>-template/`). Build:
|
||||
|
||||
- **`index.md`**: the frontmatter manifest per the spec: `id` (url-safe slug), `name`, `description`, `parameters` (name, description, required), `connections` (kind, description), `tags`. Then the body, rewritten clean: the objective in the first paragraph, what each parameter means in practice, the workflow's run naming scheme, and the general cross-step context.
|
||||
- **`index.md`**: the frontmatter manifest per the spec: `id` (url-safe slug), `name`, `description`, `parameters` (name, description, required), `connections` (kind, description), `tags`. The `parameters` and `connections` fields are critical: the marketplace site reads them from the frontmatter and renders them as two separate sections on the workflow's page. Connections show the services the workflow talks to (mapped once at setup). Parameters show the values the user provides per run (scope, target, input). Every entry must have a clear, user-facing `description`. Then the body, rewritten clean: the objective in the first paragraph, what each parameter means in practice, the workflow's run naming scheme, and the general cross-step context.
|
||||
- **`steps/`**: the same files as the source, with the classified specifics replaced by parameter references and generic capability wording. Keep the structure untouched: the shapes were proven by use; you strip, you do not redesign. Every step file must state Purpose, Input, Output (with schema when tabular), How to execute, and Done when; if a source step lacks one of these, derive it from what the lived runs show and confirm with the author.
|
||||
- **`functions/`**: same treatment, only if the source has it.
|
||||
- **`commands/setup.md`**: generate it from the slots, following the setup contract in the spec: read index.md and steps/index.md first; bind every setup-time parameter; map each connection requirement to a real service in the user's environment; generate the personal state (list in the command exactly what it creates); smoke-test the critical path; never edit steps/. Give it command frontmatter (`description`, optional `parameters`) and a self-contained prose body that assumes only file access, so it works in gcontext as an MCP prompt and standalone in any agent.
|
||||
@@ -64,3 +64,11 @@ Run three checks and show the results:
|
||||
The finished template is a local folder. Submission: the marketplace accepts templates through its API with a review step (submitted entries stay pending until approved). If the submission endpoint is not yet available, tell the author the template is ready and where it lives, and stop there.
|
||||
|
||||
Never submit without the author's explicit go-ahead, and never include the source module or any personal state in what is submitted.
|
||||
|
||||
When the template passes all checks, submit it with the CLI:
|
||||
|
||||
```
|
||||
gcontext share <template-folder>
|
||||
```
|
||||
|
||||
The command validates the template against the standard, bundles the files, and submits them to the marketplace API. The submission enters the review queue. Check its status with `gcontext share --status <workflow-id>`.
|
||||
|
||||
+168
@@ -3,6 +3,7 @@
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import socket
|
||||
import sys
|
||||
import urllib.error
|
||||
@@ -391,6 +392,15 @@ def cmd_context(args):
|
||||
print_ledger(project_dir)
|
||||
|
||||
|
||||
ID_RE = re.compile(r"^[a-z0-9][a-z0-9-]*$")
|
||||
|
||||
|
||||
def resolve_api_url(args) -> str:
|
||||
if getattr(args, "api_url", None):
|
||||
return args.api_url.rstrip("/")
|
||||
return os.environ.get("GCONTEXT_API_URL", DEFAULT_API_URL).rstrip("/")
|
||||
|
||||
|
||||
def fetch_workflow(workflow_id: str) -> dict:
|
||||
"""Fetch one approved template bundle from the workflows API. Exits on failure."""
|
||||
base = os.environ.get("GCONTEXT_API_URL", DEFAULT_API_URL).rstrip("/")
|
||||
@@ -468,6 +478,157 @@ def cmd_add(args):
|
||||
print(f"{DIM}(Re)start the server and the setup is also an MCP prompt: a slash command in Claude Code.{RESET}")
|
||||
|
||||
|
||||
def validate_template(folder: Path) -> dict:
|
||||
"""Validate a local template folder against the workflow standard.
|
||||
|
||||
Returns parsed frontmatter on success. Prints an error and exits on failure.
|
||||
"""
|
||||
from .commands import parse_command
|
||||
|
||||
index_path = folder / "index.md"
|
||||
if not index_path.exists():
|
||||
print(f"Error: {folder}/index.md not found.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
meta, _ = parse_command(index_path.read_text(encoding="utf-8"))
|
||||
except ValueError:
|
||||
print("Error: index.md has no YAML frontmatter.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
for field in ("id", "name", "description"):
|
||||
if not meta.get(field):
|
||||
print(f"Error: index.md frontmatter is missing '{field}'.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
tags = meta.get("tags")
|
||||
if not isinstance(tags, list) or len(tags) == 0:
|
||||
print("Error: index.md frontmatter is missing 'tags' (at least one tag required).", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
wid = meta["id"]
|
||||
if not isinstance(wid, str) or not ID_RE.match(wid):
|
||||
print("Error: id must be lowercase letters, digits, and hyphens.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
if not (folder / "steps").is_dir():
|
||||
print("Error: steps/ folder not found.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
if not (folder / "runs" / "example").is_dir():
|
||||
print("Error: runs/example/ folder not found.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
return meta
|
||||
|
||||
|
||||
def bundle_files(folder: Path) -> list[dict]:
|
||||
"""Walk a template folder and return [{path, content}] for all text files.
|
||||
|
||||
Skips dotfiles/dirs and __pycache__. Warns and skips non-UTF-8 files.
|
||||
"""
|
||||
files = []
|
||||
for filepath in sorted(folder.rglob("*")):
|
||||
if not filepath.is_file():
|
||||
continue
|
||||
rel_parts = filepath.relative_to(folder).parts
|
||||
if any(p.startswith(".") or p.startswith("__") for p in rel_parts):
|
||||
continue
|
||||
try:
|
||||
content = filepath.read_text(encoding="utf-8")
|
||||
except (UnicodeDecodeError, ValueError):
|
||||
print(f"Skipping {filepath.relative_to(folder)}: not a text file.", file=sys.stderr)
|
||||
continue
|
||||
files.append({"path": str(filepath.relative_to(folder)), "content": content})
|
||||
return files
|
||||
|
||||
|
||||
def cmd_share(args):
|
||||
if args.status:
|
||||
_share_status(args)
|
||||
return
|
||||
|
||||
folder = Path(args.module_path).resolve()
|
||||
if not folder.is_dir():
|
||||
print(f"Error: {args.module_path} is not a directory.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
meta = validate_template(folder)
|
||||
files = bundle_files(folder)
|
||||
wid = meta["id"]
|
||||
base = resolve_api_url(args)
|
||||
|
||||
existing = False
|
||||
try:
|
||||
with urllib.request.urlopen(f"{base}/api/workflows/{wid}", timeout=10) as resp:
|
||||
if resp.status == 200:
|
||||
existing = True
|
||||
except (urllib.error.HTTPError, urllib.error.URLError, OSError):
|
||||
pass
|
||||
|
||||
if existing and not args.yes:
|
||||
print(f"Warning: '{wid}' is already published. A new submission replaces any")
|
||||
print("pending entry and enters the review queue.")
|
||||
answer = input("Continue? [y/N] ").strip()
|
||||
if answer.lower() != "y":
|
||||
print("Aborted.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
payload = json.dumps({"files": files}).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
f"{base}/api/workflows",
|
||||
data=payload,
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
result = json.loads(resp.read())
|
||||
except urllib.error.HTTPError as e:
|
||||
body = e.read().decode("utf-8", errors="replace")
|
||||
try:
|
||||
detail = json.loads(body).get("detail", body)
|
||||
except (json.JSONDecodeError, AttributeError):
|
||||
detail = body
|
||||
print(f"Error: the API rejected the submission: {detail}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
except (urllib.error.URLError, OSError):
|
||||
print(f"Error: could not reach the API at {base}.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
print(f"{BOLD}gcontext{RESET} {DIM}-{RESET} submitted {wid} ({len(files)} files)")
|
||||
print()
|
||||
print(f"Status: {result.get('status', 'pending')} (under review)")
|
||||
print(f"Check status: gcontext share --status {wid}")
|
||||
|
||||
|
||||
def _share_status(args):
|
||||
wid = args.module_path
|
||||
base = resolve_api_url(args)
|
||||
url = f"{base}/api/workflows/{wid}/status"
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=10) as resp:
|
||||
data = json.loads(resp.read())
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code == 404:
|
||||
print(f"Error: no submission found for '{wid}'.", file=sys.stderr)
|
||||
else:
|
||||
print(f"Error: the API answered {e.code}.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
except (urllib.error.URLError, OSError):
|
||||
print(f"Error: could not reach the API at {base}.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
submitted = data.get("submitted_at", "")[:16].replace("T", " ") + " UTC" if data.get("submitted_at") else "-"
|
||||
reviewed = data.get("reviewed_at", "")[:16].replace("T", " ") + " UTC" if data.get("reviewed_at") else "-"
|
||||
|
||||
print(f"{BOLD}gcontext{RESET} {DIM}-{RESET} {wid}")
|
||||
print()
|
||||
print(f"Status: {data['status']}")
|
||||
print(f"Submitted: {submitted}")
|
||||
print(f"Reviewed: {reviewed}")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="gcontext",
|
||||
@@ -506,6 +667,12 @@ def main():
|
||||
add_parser.add_argument("workflow_id", help="Workflow id from the directory (e.g. coolify-ops)")
|
||||
add_parser.add_argument("project", nargs="?", help="Path to gcontext project directory")
|
||||
|
||||
share_parser = subparsers.add_parser("share", help="Submit a workflow template to the marketplace for review")
|
||||
share_parser.add_argument("module_path", help="Path to the template folder (or workflow id when used with --status)")
|
||||
share_parser.add_argument("--api-url", dest="api_url", help="Override the API base URL")
|
||||
share_parser.add_argument("--yes", "-y", action="store_true", help="Skip confirmation when the workflow already exists")
|
||||
share_parser.add_argument("--status", action="store_true", help="Query submission status instead of submitting")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
commands = {
|
||||
@@ -515,6 +682,7 @@ def main():
|
||||
"connect": cmd_connect,
|
||||
"context": cmd_context,
|
||||
"add": cmd_add,
|
||||
"share": cmd_share,
|
||||
}
|
||||
if args.command in commands:
|
||||
commands[args.command](args)
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
"""Tests for `gcontext share <module-path>`: validate and submit a workflow template."""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
|
||||
import pytest
|
||||
|
||||
INDEX_MD = """---
|
||||
id: test-flow
|
||||
name: Test Flow
|
||||
description: A test workflow.
|
||||
tags: [test]
|
||||
---
|
||||
|
||||
Objective paragraph.
|
||||
"""
|
||||
|
||||
|
||||
def run_cli(*args, cwd, env=None):
|
||||
return subprocess.run(
|
||||
[sys.executable, "-m", "gcontext.cli", *args],
|
||||
capture_output=True, text=True, cwd=cwd, env=env,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def api(monkeypatch):
|
||||
"""Local HTTP stub. Yields (responses_dict, posted_list)."""
|
||||
responses = {}
|
||||
posted = []
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
status, body = responses.get(self.path, (404, {"detail": "not found"}))
|
||||
payload = json.dumps(body).encode()
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.end_headers()
|
||||
self.wfile.write(payload)
|
||||
|
||||
def do_POST(self):
|
||||
length = int(self.headers.get("Content-Length", 0))
|
||||
data = json.loads(self.rfile.read(length)) if length else {}
|
||||
posted.append({"path": self.path, "body": data})
|
||||
result = {
|
||||
"id": "test-flow", "name": "Test Flow",
|
||||
"description": "A test workflow.", "tags": ["test"],
|
||||
"status": "pending",
|
||||
}
|
||||
payload = json.dumps(result).encode()
|
||||
self.send_response(201)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.end_headers()
|
||||
self.wfile.write(payload)
|
||||
|
||||
def log_message(self, *args):
|
||||
pass
|
||||
|
||||
server = HTTPServer(("127.0.0.1", 0), Handler)
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
monkeypatch.setenv("GCONTEXT_API_URL", f"http://127.0.0.1:{server.server_port}")
|
||||
yield responses, posted
|
||||
server.shutdown()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def template(tmp_path):
|
||||
"""A valid template folder."""
|
||||
t = tmp_path / "test-flow"
|
||||
t.mkdir()
|
||||
(t / "index.md").write_text(INDEX_MD)
|
||||
steps = t / "steps"
|
||||
steps.mkdir()
|
||||
(steps / "index.md").write_text("1-do.md: do things\n")
|
||||
(steps / "1-do.md").write_text("# Step 1\n")
|
||||
example = t / "runs" / "example"
|
||||
example.mkdir(parents=True)
|
||||
(example / "index.md").write_text("# Example\n")
|
||||
return t
|
||||
|
||||
|
||||
def test_share_submits_valid_template(api, template):
|
||||
responses, posted = api
|
||||
result = run_cli("share", str(template), cwd=template.parent)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert "submitted test-flow" in result.stdout
|
||||
assert "pending" in result.stdout
|
||||
assert len(posted) == 1
|
||||
assert posted[0]["path"] == "/api/workflows"
|
||||
files = posted[0]["body"]["files"]
|
||||
paths = [f["path"] for f in files]
|
||||
assert "index.md" in paths
|
||||
assert "steps/index.md" in paths
|
||||
|
||||
|
||||
def test_share_missing_index(tmp_path):
|
||||
folder = tmp_path / "empty"
|
||||
folder.mkdir()
|
||||
result = run_cli("share", str(folder), cwd=tmp_path)
|
||||
assert result.returncode == 1
|
||||
assert "index.md not found" in result.stderr
|
||||
|
||||
|
||||
def test_share_missing_frontmatter(tmp_path):
|
||||
folder = tmp_path / "bad"
|
||||
folder.mkdir()
|
||||
(folder / "index.md").write_text("# No frontmatter\n")
|
||||
result = run_cli("share", str(folder), cwd=tmp_path)
|
||||
assert result.returncode == 1
|
||||
assert "no YAML frontmatter" in result.stderr
|
||||
|
||||
|
||||
def test_share_missing_id(tmp_path):
|
||||
folder = tmp_path / "bad"
|
||||
folder.mkdir()
|
||||
(folder / "index.md").write_text("---\nname: X\ndescription: Y\ntags: [a]\n---\n")
|
||||
(folder / "steps").mkdir()
|
||||
(folder / "runs" / "example").mkdir(parents=True)
|
||||
result = run_cli("share", str(folder), cwd=tmp_path)
|
||||
assert result.returncode == 1
|
||||
assert "missing 'id'" in result.stderr
|
||||
|
||||
|
||||
def test_share_missing_name(tmp_path):
|
||||
folder = tmp_path / "bad"
|
||||
folder.mkdir()
|
||||
(folder / "index.md").write_text("---\nid: x\ndescription: Y\ntags: [a]\n---\n")
|
||||
(folder / "steps").mkdir()
|
||||
(folder / "runs" / "example").mkdir(parents=True)
|
||||
result = run_cli("share", str(folder), cwd=tmp_path)
|
||||
assert result.returncode == 1
|
||||
assert "missing 'name'" in result.stderr
|
||||
|
||||
|
||||
def test_share_missing_description(tmp_path):
|
||||
folder = tmp_path / "bad"
|
||||
folder.mkdir()
|
||||
(folder / "index.md").write_text("---\nid: x\nname: X\ntags: [a]\n---\n")
|
||||
(folder / "steps").mkdir()
|
||||
(folder / "runs" / "example").mkdir(parents=True)
|
||||
result = run_cli("share", str(folder), cwd=tmp_path)
|
||||
assert result.returncode == 1
|
||||
assert "missing 'description'" in result.stderr
|
||||
|
||||
|
||||
def test_share_missing_tags(tmp_path):
|
||||
folder = tmp_path / "bad"
|
||||
folder.mkdir()
|
||||
(folder / "index.md").write_text("---\nid: x\nname: X\ndescription: Y\n---\n")
|
||||
(folder / "steps").mkdir()
|
||||
(folder / "runs" / "example").mkdir(parents=True)
|
||||
result = run_cli("share", str(folder), cwd=tmp_path)
|
||||
assert result.returncode == 1
|
||||
assert "missing 'tags'" in result.stderr
|
||||
|
||||
|
||||
def test_share_empty_tags(tmp_path):
|
||||
folder = tmp_path / "bad"
|
||||
folder.mkdir()
|
||||
(folder / "index.md").write_text("---\nid: x\nname: X\ndescription: Y\ntags: []\n---\n")
|
||||
(folder / "steps").mkdir()
|
||||
(folder / "runs" / "example").mkdir(parents=True)
|
||||
result = run_cli("share", str(folder), cwd=tmp_path)
|
||||
assert result.returncode == 1
|
||||
assert "at least one tag" in result.stderr
|
||||
|
||||
|
||||
def test_share_bad_id_format(tmp_path):
|
||||
folder = tmp_path / "bad"
|
||||
folder.mkdir()
|
||||
(folder / "index.md").write_text("---\nid: Bad_Id\nname: X\ndescription: Y\ntags: [a]\n---\n")
|
||||
(folder / "steps").mkdir()
|
||||
(folder / "runs" / "example").mkdir(parents=True)
|
||||
result = run_cli("share", str(folder), cwd=tmp_path)
|
||||
assert result.returncode == 1
|
||||
assert "lowercase letters, digits, and hyphens" in result.stderr
|
||||
|
||||
|
||||
def test_share_missing_steps(tmp_path):
|
||||
folder = tmp_path / "bad"
|
||||
folder.mkdir()
|
||||
(folder / "index.md").write_text(INDEX_MD)
|
||||
(folder / "runs" / "example").mkdir(parents=True)
|
||||
result = run_cli("share", str(folder), cwd=tmp_path)
|
||||
assert result.returncode == 1
|
||||
assert "steps/ folder not found" in result.stderr
|
||||
|
||||
|
||||
def test_share_missing_example_run(tmp_path):
|
||||
folder = tmp_path / "bad"
|
||||
folder.mkdir()
|
||||
(folder / "index.md").write_text(INDEX_MD)
|
||||
(folder / "steps").mkdir()
|
||||
result = run_cli("share", str(folder), cwd=tmp_path)
|
||||
assert result.returncode == 1
|
||||
assert "runs/example/ folder not found" in result.stderr
|
||||
|
||||
|
||||
def test_share_skips_dotfiles(api, template):
|
||||
responses, posted = api
|
||||
(template / ".hidden").write_text("secret")
|
||||
(template / ".git").mkdir()
|
||||
(template / ".git" / "config").write_text("x")
|
||||
result = run_cli("share", str(template), cwd=template.parent)
|
||||
assert result.returncode == 0, result.stderr
|
||||
files = posted[0]["body"]["files"]
|
||||
paths = [f["path"] for f in files]
|
||||
assert ".hidden" not in paths
|
||||
assert ".git/config" not in paths
|
||||
|
||||
|
||||
def test_share_skips_pycache(api, template):
|
||||
responses, posted = api
|
||||
cache = template / "__pycache__"
|
||||
cache.mkdir()
|
||||
(cache / "mod.pyc").write_bytes(b"\x00\x01")
|
||||
result = run_cli("share", str(template), cwd=template.parent)
|
||||
assert result.returncode == 0, result.stderr
|
||||
files = posted[0]["body"]["files"]
|
||||
paths = [f["path"] for f in files]
|
||||
assert not any("__pycache__" in p for p in paths)
|
||||
|
||||
|
||||
def test_share_skips_binary_with_warning(api, template):
|
||||
responses, posted = api
|
||||
(template / "image.bin").write_bytes(b"\x80\x81\x82\xff\xfe")
|
||||
result = run_cli("share", str(template), cwd=template.parent)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert "Skipping image.bin" in result.stderr
|
||||
files = posted[0]["body"]["files"]
|
||||
paths = [f["path"] for f in files]
|
||||
assert "image.bin" not in paths
|
||||
|
||||
|
||||
def test_share_status_mode(api, tmp_path):
|
||||
responses, _ = api
|
||||
responses["/api/workflows/test-flow/status"] = (200, {
|
||||
"id": "test-flow",
|
||||
"status": "approved",
|
||||
"submitted_at": "2026-08-09T12:00:00Z",
|
||||
"reviewed_at": "2026-08-09T14:30:00Z",
|
||||
})
|
||||
result = run_cli("share", "--status", "test-flow", cwd=tmp_path)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert "approved" in result.stdout
|
||||
assert "2026-08-09 12:00 UTC" in result.stdout
|
||||
assert "2026-08-09 14:30 UTC" in result.stdout
|
||||
|
||||
|
||||
def test_share_status_not_found(api, tmp_path):
|
||||
_, _ = api
|
||||
result = run_cli("share", "--status", "nope", cwd=tmp_path)
|
||||
assert result.returncode == 1
|
||||
assert "no submission found" in result.stderr
|
||||
Reference in New Issue
Block a user