agh 0.3.2__tar.gz → 0.3.3__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.
- {agh-0.3.2/agh.egg-info → agh-0.3.3}/PKG-INFO +1 -1
- {agh-0.3.2 → agh-0.3.3}/agh/server/routes/packs.py +56 -17
- {agh-0.3.2 → agh-0.3.3}/agh/server/routes/projects.py +170 -34
- {agh-0.3.2 → agh-0.3.3/agh.egg-info}/PKG-INFO +1 -1
- {agh-0.3.2 → agh-0.3.3}/tests/test_pack_routes.py +100 -0
- {agh-0.3.2 → agh-0.3.3}/tests/test_pull_manifest_routes.py +230 -0
- {agh-0.3.2 → agh-0.3.3}/CONTRIBUTING.md +0 -0
- {agh-0.3.2 → agh-0.3.3}/LICENSE +0 -0
- {agh-0.3.2 → agh-0.3.3}/MANIFEST.in +0 -0
- {agh-0.3.2 → agh-0.3.3}/README.es.md +0 -0
- {agh-0.3.2 → agh-0.3.3}/README.md +0 -0
- {agh-0.3.2 → agh-0.3.3}/SECURITY.md +0 -0
- {agh-0.3.2 → agh-0.3.3}/agh/__init__.py +0 -0
- {agh-0.3.2 → agh-0.3.3}/agh/cli/__init__.py +0 -0
- {agh-0.3.2 → agh-0.3.3}/agh/cli/agent_integrations.py +0 -0
- {agh-0.3.2 → agh-0.3.3}/agh/cli/config.py +0 -0
- {agh-0.3.2 → agh-0.3.3}/agh/cli/main.py +0 -0
- {agh-0.3.2 → agh-0.3.3}/agh/cli/pack_init.py +0 -0
- {agh-0.3.2 → agh-0.3.3}/agh/cli/pack_publish.py +0 -0
- {agh-0.3.2 → agh-0.3.3}/agh/cli/pack_refs.py +0 -0
- {agh-0.3.2 → agh-0.3.3}/agh/cli/project_refs.py +0 -0
- {agh-0.3.2 → agh-0.3.3}/agh/cli/pull_markers.py +0 -0
- {agh-0.3.2 → agh-0.3.3}/agh/cli/pull_plan.py +0 -0
- {agh-0.3.2 → agh-0.3.3}/agh/cli/user_refs.py +0 -0
- {agh-0.3.2 → agh-0.3.3}/agh/cli/workspace_pull.py +0 -0
- {agh-0.3.2 → agh-0.3.3}/agh/cli/workspace_sync.py +0 -0
- {agh-0.3.2 → agh-0.3.3}/agh/common/__init__.py +0 -0
- {agh-0.3.2 → agh-0.3.3}/agh/common/checksums.py +0 -0
- {agh-0.3.2 → agh-0.3.3}/agh/common/ids.py +0 -0
- {agh-0.3.2 → agh-0.3.3}/agh/common/pack_manifest.py +0 -0
- {agh-0.3.2 → agh-0.3.3}/agh/common/repo_url.py +0 -0
- {agh-0.3.2 → agh-0.3.3}/agh/common/validation.py +0 -0
- {agh-0.3.2 → agh-0.3.3}/agh/server/__init__.py +0 -0
- {agh-0.3.2 → agh-0.3.3}/agh/server/app.py +0 -0
- {agh-0.3.2 → agh-0.3.3}/agh/server/auth.py +0 -0
- {agh-0.3.2 → agh-0.3.3}/agh/server/db.py +0 -0
- {agh-0.3.2 → agh-0.3.3}/agh/server/migrations/001_initial_schema.sql +0 -0
- {agh-0.3.2 → agh-0.3.3}/agh/server/migrations/002_unique_project_names.sql +0 -0
- {agh-0.3.2 → agh-0.3.3}/agh/server/migrations/__init__.py +0 -0
- {agh-0.3.2 → agh-0.3.3}/agh/server/routes/__init__.py +0 -0
- {agh-0.3.2 → agh-0.3.3}/agh/server/routes/users.py +0 -0
- {agh-0.3.2 → agh-0.3.3}/agh.egg-info/SOURCES.txt +0 -0
- {agh-0.3.2 → agh-0.3.3}/agh.egg-info/dependency_links.txt +0 -0
- {agh-0.3.2 → agh-0.3.3}/agh.egg-info/entry_points.txt +0 -0
- {agh-0.3.2 → agh-0.3.3}/agh.egg-info/requires.txt +0 -0
- {agh-0.3.2 → agh-0.3.3}/agh.egg-info/top_level.txt +0 -0
- {agh-0.3.2 → agh-0.3.3}/assets/agh-workspace-demo.gif +0 -0
- {agh-0.3.2 → agh-0.3.3}/assets/agh-workspace-demo.tape +0 -0
- {agh-0.3.2 → agh-0.3.3}/pyproject.toml +0 -0
- {agh-0.3.2 → agh-0.3.3}/scripts/install.sh +0 -0
- {agh-0.3.2 → agh-0.3.3}/setup.cfg +0 -0
- {agh-0.3.2 → agh-0.3.3}/tests/conftest.py +0 -0
- {agh-0.3.2 → agh-0.3.3}/tests/test_agent_command.py +0 -0
- {agh-0.3.2 → agh-0.3.3}/tests/test_api_errors.py +0 -0
- {agh-0.3.2 → agh-0.3.3}/tests/test_auth_bootstrap.py +0 -0
- {agh-0.3.2 → agh-0.3.3}/tests/test_cli_admin_commands.py +0 -0
- {agh-0.3.2 → agh-0.3.3}/tests/test_cli_login.py +0 -0
- {agh-0.3.2 → agh-0.3.3}/tests/test_cli_pack_commands.py +0 -0
- {agh-0.3.2 → agh-0.3.3}/tests/test_cli_pull.py +0 -0
- {agh-0.3.2 → agh-0.3.3}/tests/test_common_helpers.py +0 -0
- {agh-0.3.2 → agh-0.3.3}/tests/test_db_migrations.py +0 -0
- {agh-0.3.2 → agh-0.3.3}/tests/test_docs_guidance.py +0 -0
- {agh-0.3.2 → agh-0.3.3}/tests/test_install_script.py +0 -0
- {agh-0.3.2 → agh-0.3.3}/tests/test_integration_smoke.py +0 -0
- {agh-0.3.2 → agh-0.3.3}/tests/test_project_pack_assignments.py +0 -0
- {agh-0.3.2 → agh-0.3.3}/tests/test_project_routes.py +0 -0
- {agh-0.3.2 → agh-0.3.3}/tests/test_pull_markers.py +0 -0
- {agh-0.3.2 → agh-0.3.3}/tests/test_pull_plan.py +0 -0
- {agh-0.3.2 → agh-0.3.3}/tests/test_scaffold.py +0 -0
- {agh-0.3.2 → agh-0.3.3}/tests/test_user_routes.py +0 -0
- {agh-0.3.2 → agh-0.3.3}/tests/test_workspace_pull.py +0 -0
- {agh-0.3.2 → agh-0.3.3}/tests/test_workspace_sync.py +0 -0
- {agh-0.3.2 → agh-0.3.3}/uv.lock +0 -0
|
@@ -7,8 +7,9 @@ import json
|
|
|
7
7
|
import os
|
|
8
8
|
import shutil
|
|
9
9
|
import sqlite3
|
|
10
|
+
import stat as stat_module
|
|
10
11
|
from pathlib import Path, PurePosixPath
|
|
11
|
-
from typing import Any
|
|
12
|
+
from typing import Any, NoReturn
|
|
12
13
|
|
|
13
14
|
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
|
14
15
|
from pydantic import BaseModel, ValidationError
|
|
@@ -31,6 +32,8 @@ MAX_PACK_PATH_LENGTH = 240
|
|
|
31
32
|
MAX_PACK_FILE_BYTES = 256 * 1024
|
|
32
33
|
MAX_PACK_TOTAL_BYTES = 1024 * 1024
|
|
33
34
|
MAX_PACK_PUBLISH_BODY_BYTES = MAX_PACK_TOTAL_BYTES + (MAX_PACK_FILES * 128)
|
|
35
|
+
PACK_ARTIFACT_MISSING_DETAIL = "pack file not found"
|
|
36
|
+
PACK_ARTIFACT_STORAGE_DETAIL = "pack artifact storage unavailable"
|
|
34
37
|
|
|
35
38
|
|
|
36
39
|
class PackPublish(BaseModel):
|
|
@@ -76,6 +79,31 @@ def _pack_version_resolve_response(row: sqlite3.Row) -> dict[str, str]:
|
|
|
76
79
|
}
|
|
77
80
|
|
|
78
81
|
|
|
82
|
+
def _raise_pack_artifact_missing() -> NoReturn:
|
|
83
|
+
raise HTTPException(
|
|
84
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
85
|
+
detail=PACK_ARTIFACT_MISSING_DETAIL,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _raise_pack_artifact_storage_unavailable(
|
|
90
|
+
exc: OSError | UnicodeDecodeError,
|
|
91
|
+
) -> NoReturn:
|
|
92
|
+
raise HTTPException(
|
|
93
|
+
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
94
|
+
detail=PACK_ARTIFACT_STORAGE_DETAIL,
|
|
95
|
+
) from exc
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _read_published_pack_file(storage_dir: Path, safe_path: Path) -> str:
|
|
99
|
+
candidate = storage_dir / safe_path
|
|
100
|
+
_require_pack_artifact_read_target(storage_dir, candidate)
|
|
101
|
+
try:
|
|
102
|
+
return candidate.read_text(encoding="utf-8")
|
|
103
|
+
except (OSError, UnicodeDecodeError) as exc:
|
|
104
|
+
_raise_pack_artifact_storage_unavailable(exc)
|
|
105
|
+
|
|
106
|
+
|
|
79
107
|
def _parse_pack_version_ref_or_400(value: str) -> PackVersionRef:
|
|
80
108
|
try:
|
|
81
109
|
return parse_pack_version_ref(value, allow_latest=False)
|
|
@@ -280,23 +308,16 @@ def get_pack_file(
|
|
|
280
308
|
safe_path = _safe_relative_path(file_path)
|
|
281
309
|
except PackManifestError as exc:
|
|
282
310
|
raise HTTPException(
|
|
283
|
-
status_code=status.HTTP_404_NOT_FOUND, detail=
|
|
311
|
+
status_code=status.HTTP_404_NOT_FOUND, detail=PACK_ARTIFACT_MISSING_DETAIL
|
|
284
312
|
) from exc
|
|
285
313
|
connection = _connect(request)
|
|
286
314
|
try:
|
|
287
315
|
row = _find_pack_version(connection, domain, name, version)
|
|
288
316
|
if row is None:
|
|
289
|
-
|
|
290
|
-
status_code=status.HTTP_404_NOT_FOUND, detail="pack file not found"
|
|
291
|
-
)
|
|
317
|
+
_raise_pack_artifact_missing()
|
|
292
318
|
storage_dir = Path(row["storage_path"])
|
|
293
|
-
candidate = storage_dir / safe_path
|
|
294
|
-
if not _is_safe_pack_file(storage_dir, candidate):
|
|
295
|
-
raise HTTPException(
|
|
296
|
-
status_code=status.HTTP_404_NOT_FOUND, detail="pack file not found"
|
|
297
|
-
)
|
|
298
319
|
return Response(
|
|
299
|
-
|
|
320
|
+
_read_published_pack_file(storage_dir, safe_path),
|
|
300
321
|
media_type="text/plain; charset=utf-8",
|
|
301
322
|
)
|
|
302
323
|
finally:
|
|
@@ -557,19 +578,37 @@ def _pack_checksum(pack_dir: Path) -> str:
|
|
|
557
578
|
return f"sha256:{digest.hexdigest()}"
|
|
558
579
|
|
|
559
580
|
|
|
560
|
-
def
|
|
581
|
+
def _require_pack_artifact_read_target(storage_dir: Path, candidate: Path) -> None:
|
|
561
582
|
try:
|
|
562
583
|
resolved_root = storage_dir.resolve(strict=True)
|
|
563
584
|
resolved_candidate = candidate.resolve(strict=True)
|
|
564
585
|
resolved_candidate.relative_to(resolved_root)
|
|
565
|
-
except
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
586
|
+
except ValueError:
|
|
587
|
+
_raise_pack_artifact_missing()
|
|
588
|
+
except (FileNotFoundError, NotADirectoryError):
|
|
589
|
+
_raise_pack_artifact_missing()
|
|
590
|
+
except OSError as exc:
|
|
591
|
+
_raise_pack_artifact_storage_unavailable(exc)
|
|
592
|
+
try:
|
|
593
|
+
candidate_stat = resolved_candidate.stat()
|
|
594
|
+
except (FileNotFoundError, NotADirectoryError):
|
|
595
|
+
_raise_pack_artifact_missing()
|
|
596
|
+
except OSError as exc:
|
|
597
|
+
_raise_pack_artifact_storage_unavailable(exc)
|
|
598
|
+
if not stat_module.S_ISREG(candidate_stat.st_mode):
|
|
599
|
+
_raise_pack_artifact_missing()
|
|
569
600
|
current = candidate
|
|
570
601
|
paths: list[Path] = []
|
|
571
602
|
while current != storage_dir:
|
|
572
603
|
paths.append(current)
|
|
573
604
|
current = current.parent
|
|
574
605
|
paths.append(storage_dir)
|
|
575
|
-
|
|
606
|
+
for path in paths:
|
|
607
|
+
try:
|
|
608
|
+
path_stat = path.lstat()
|
|
609
|
+
except (FileNotFoundError, NotADirectoryError):
|
|
610
|
+
_raise_pack_artifact_missing()
|
|
611
|
+
except OSError as exc:
|
|
612
|
+
_raise_pack_artifact_storage_unavailable(exc)
|
|
613
|
+
if stat_module.S_ISLNK(path_stat.st_mode):
|
|
614
|
+
_raise_pack_artifact_missing()
|
|
@@ -5,7 +5,7 @@ from __future__ import annotations
|
|
|
5
5
|
import json
|
|
6
6
|
import sqlite3
|
|
7
7
|
from pathlib import Path
|
|
8
|
-
from typing import Any
|
|
8
|
+
from typing import Any, NoReturn
|
|
9
9
|
from urllib.parse import quote
|
|
10
10
|
|
|
11
11
|
from fastapi import ( # pyright: ignore[reportMissingImports]
|
|
@@ -329,23 +329,78 @@ def _pack_file_download_url(domain: str, name: str, version: str, path: str) ->
|
|
|
329
329
|
return f"/api/v1/packs/{domain}/{name}/versions/{version}/files/{quoted_path}"
|
|
330
330
|
|
|
331
331
|
|
|
332
|
-
def
|
|
332
|
+
def _raise_pack_artifact_missing() -> NoReturn:
|
|
333
|
+
raise HTTPException(
|
|
334
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
335
|
+
detail="pack file not found",
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _raise_pack_artifact_storage_unavailable(
|
|
340
|
+
exc: OSError | UnicodeDecodeError,
|
|
341
|
+
) -> NoReturn:
|
|
342
|
+
raise HTTPException(
|
|
343
|
+
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
344
|
+
detail="pack artifact storage unavailable",
|
|
345
|
+
) from exc
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _missing_pack_artifact_or_none(required: bool) -> None:
|
|
349
|
+
if required:
|
|
350
|
+
_raise_pack_artifact_missing()
|
|
351
|
+
return None
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _read_pack_file(
|
|
355
|
+
storage_dir: Path, relative_path: str, *, required: bool
|
|
356
|
+
) -> str | None:
|
|
333
357
|
candidate = storage_dir / relative_path
|
|
334
358
|
try:
|
|
335
359
|
resolved_root = storage_dir.resolve(strict=True)
|
|
336
360
|
resolved_candidate = candidate.resolve(strict=True)
|
|
337
361
|
resolved_candidate.relative_to(resolved_root)
|
|
338
|
-
except
|
|
339
|
-
return
|
|
362
|
+
except ValueError:
|
|
363
|
+
return _missing_pack_artifact_or_none(required)
|
|
364
|
+
except (FileNotFoundError, NotADirectoryError):
|
|
365
|
+
return _missing_pack_artifact_or_none(required)
|
|
366
|
+
except OSError as exc:
|
|
367
|
+
_raise_pack_artifact_storage_unavailable(exc)
|
|
340
368
|
if not resolved_candidate.is_file():
|
|
341
|
-
return
|
|
342
|
-
|
|
369
|
+
return _missing_pack_artifact_or_none(required)
|
|
370
|
+
if _pack_artifact_path_has_symlink_component(storage_dir, candidate):
|
|
371
|
+
return _missing_pack_artifact_or_none(required)
|
|
372
|
+
try:
|
|
373
|
+
return resolved_candidate.read_text(encoding="utf-8")
|
|
374
|
+
except (OSError, UnicodeDecodeError) as exc:
|
|
375
|
+
_raise_pack_artifact_storage_unavailable(exc)
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def _pack_artifact_path_has_symlink_component(
|
|
379
|
+
storage_dir: Path, candidate: Path
|
|
380
|
+
) -> bool:
|
|
381
|
+
current = candidate
|
|
382
|
+
while True:
|
|
383
|
+
try:
|
|
384
|
+
is_symlink = current.is_symlink()
|
|
385
|
+
except OSError as exc:
|
|
386
|
+
_raise_pack_artifact_storage_unavailable(exc)
|
|
387
|
+
if is_symlink:
|
|
388
|
+
return True
|
|
389
|
+
if current == storage_dir:
|
|
390
|
+
return False
|
|
391
|
+
current = current.parent
|
|
343
392
|
|
|
344
393
|
|
|
345
394
|
def _instruction_artifact(
|
|
346
|
-
*,
|
|
395
|
+
*,
|
|
396
|
+
domain: str,
|
|
397
|
+
name: str,
|
|
398
|
+
version: str,
|
|
399
|
+
storage_dir: Path,
|
|
400
|
+
path: str,
|
|
401
|
+
required: bool,
|
|
347
402
|
) -> dict[str, str] | None:
|
|
348
|
-
content = _read_pack_file(storage_dir, path)
|
|
403
|
+
content = _read_pack_file(storage_dir, path, required=required)
|
|
349
404
|
if content is None:
|
|
350
405
|
return None
|
|
351
406
|
target_agent = "opencode" if path.endswith("AGENTS.md") else "claude"
|
|
@@ -361,42 +416,109 @@ def _instruction_artifact(
|
|
|
361
416
|
|
|
362
417
|
|
|
363
418
|
def _skill_artifacts(
|
|
364
|
-
*,
|
|
419
|
+
*,
|
|
420
|
+
domain: str,
|
|
421
|
+
name: str,
|
|
422
|
+
version: str,
|
|
423
|
+
storage_dir: Path,
|
|
424
|
+
expected_paths: list[str] | None = None,
|
|
365
425
|
) -> list[dict[str, str]]:
|
|
426
|
+
if expected_paths is not None:
|
|
427
|
+
artifacts: list[dict[str, str]] = []
|
|
428
|
+
for path in sorted(expected_paths):
|
|
429
|
+
artifacts.extend(
|
|
430
|
+
_skill_artifacts_for_path(
|
|
431
|
+
domain=domain,
|
|
432
|
+
name=name,
|
|
433
|
+
version=version,
|
|
434
|
+
storage_dir=storage_dir,
|
|
435
|
+
path=path,
|
|
436
|
+
required=True,
|
|
437
|
+
)
|
|
438
|
+
)
|
|
439
|
+
return artifacts
|
|
440
|
+
|
|
366
441
|
skills_dir = storage_dir / "skills"
|
|
367
442
|
if not skills_dir.is_dir() or skills_dir.is_symlink():
|
|
368
443
|
return []
|
|
369
444
|
artifacts: list[dict[str, str]] = []
|
|
370
445
|
for skill_dir in sorted(item for item in skills_dir.iterdir() if item.is_dir()):
|
|
371
446
|
path = f"skills/{skill_dir.name}/SKILL.md"
|
|
372
|
-
content = _read_pack_file(storage_dir, path)
|
|
373
|
-
if content is None:
|
|
374
|
-
continue
|
|
375
|
-
checksum = managed_payload_checksum(content)
|
|
376
|
-
download_url = _pack_file_download_url(domain, name, version, path)
|
|
377
447
|
artifacts.extend(
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
},
|
|
387
|
-
{
|
|
388
|
-
"kind": "skill",
|
|
389
|
-
"path": path,
|
|
390
|
-
"target_agent": "claude",
|
|
391
|
-
"target_path": f".claude/skills/{skill_dir.name}/SKILL.md",
|
|
392
|
-
"checksum": checksum,
|
|
393
|
-
"download_url": download_url,
|
|
394
|
-
},
|
|
395
|
-
]
|
|
448
|
+
_skill_artifacts_for_path(
|
|
449
|
+
domain=domain,
|
|
450
|
+
name=name,
|
|
451
|
+
version=version,
|
|
452
|
+
storage_dir=storage_dir,
|
|
453
|
+
path=path,
|
|
454
|
+
required=False,
|
|
455
|
+
)
|
|
396
456
|
)
|
|
397
457
|
return artifacts
|
|
398
458
|
|
|
399
459
|
|
|
460
|
+
def _skill_artifacts_for_path(
|
|
461
|
+
*,
|
|
462
|
+
domain: str,
|
|
463
|
+
name: str,
|
|
464
|
+
version: str,
|
|
465
|
+
storage_dir: Path,
|
|
466
|
+
path: str,
|
|
467
|
+
required: bool,
|
|
468
|
+
) -> list[dict[str, str]]:
|
|
469
|
+
skill_name = _skill_name_from_artifact_path(path)
|
|
470
|
+
if skill_name is None:
|
|
471
|
+
return []
|
|
472
|
+
content = _read_pack_file(storage_dir, path, required=required)
|
|
473
|
+
if content is None:
|
|
474
|
+
return []
|
|
475
|
+
checksum = managed_payload_checksum(content)
|
|
476
|
+
download_url = _pack_file_download_url(domain, name, version, path)
|
|
477
|
+
return [
|
|
478
|
+
{
|
|
479
|
+
"kind": "skill",
|
|
480
|
+
"path": path,
|
|
481
|
+
"target_agent": "opencode",
|
|
482
|
+
"target_path": f".opencode/skills/{skill_name}/SKILL.md",
|
|
483
|
+
"checksum": checksum,
|
|
484
|
+
"download_url": download_url,
|
|
485
|
+
},
|
|
486
|
+
{
|
|
487
|
+
"kind": "skill",
|
|
488
|
+
"path": path,
|
|
489
|
+
"target_agent": "claude",
|
|
490
|
+
"target_path": f".claude/skills/{skill_name}/SKILL.md",
|
|
491
|
+
"checksum": checksum,
|
|
492
|
+
"download_url": download_url,
|
|
493
|
+
},
|
|
494
|
+
]
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def _skill_name_from_artifact_path(path: str) -> str | None:
|
|
498
|
+
parts = path.split("/")
|
|
499
|
+
if len(parts) == 3 and parts[0] == "skills" and parts[1] and parts[2] == "SKILL.md":
|
|
500
|
+
return parts[1]
|
|
501
|
+
return None
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def _expected_artifact_paths(manifest: dict[str, Any]) -> set[str] | None:
|
|
505
|
+
raw_paths = manifest.get("artifact_paths")
|
|
506
|
+
if raw_paths is None:
|
|
507
|
+
return None
|
|
508
|
+
if not isinstance(raw_paths, list):
|
|
509
|
+
return None
|
|
510
|
+
if not raw_paths or not all(
|
|
511
|
+
isinstance(path, str)
|
|
512
|
+
and (
|
|
513
|
+
path in {"instructions/AGENTS.md", "instructions/CLAUDE.md"}
|
|
514
|
+
or _skill_name_from_artifact_path(path) is not None
|
|
515
|
+
)
|
|
516
|
+
for path in raw_paths
|
|
517
|
+
):
|
|
518
|
+
return None
|
|
519
|
+
return set(raw_paths)
|
|
520
|
+
|
|
521
|
+
|
|
400
522
|
def _pull_manifest_pack(
|
|
401
523
|
connection: sqlite3.Connection, row: sqlite3.Row
|
|
402
524
|
) -> dict[str, Any]:
|
|
@@ -407,6 +529,8 @@ def _pull_manifest_pack(
|
|
|
407
529
|
domain = str(row["domain"])
|
|
408
530
|
name = str(row["name"])
|
|
409
531
|
storage_dir = Path(str(version_row["storage_path"]))
|
|
532
|
+
manifest = json.loads(str(version_row["manifest_json"]))
|
|
533
|
+
expected_paths = _expected_artifact_paths(manifest)
|
|
410
534
|
artifacts: list[dict[str, str]] = []
|
|
411
535
|
for instruction_path in ["instructions/AGENTS.md", "instructions/CLAUDE.md"]:
|
|
412
536
|
artifact = _instruction_artifact(
|
|
@@ -415,19 +539,31 @@ def _pull_manifest_pack(
|
|
|
415
539
|
version=version,
|
|
416
540
|
storage_dir=storage_dir,
|
|
417
541
|
path=instruction_path,
|
|
542
|
+
required=expected_paths is not None and instruction_path in expected_paths,
|
|
418
543
|
)
|
|
419
544
|
if artifact is not None:
|
|
420
545
|
artifacts.append(artifact)
|
|
546
|
+
expected_skill_paths = None
|
|
547
|
+
if expected_paths is not None:
|
|
548
|
+
expected_skill_paths = [
|
|
549
|
+
path for path in expected_paths if _skill_name_from_artifact_path(path)
|
|
550
|
+
]
|
|
421
551
|
artifacts.extend(
|
|
422
552
|
_skill_artifacts(
|
|
423
|
-
domain=domain,
|
|
553
|
+
domain=domain,
|
|
554
|
+
name=name,
|
|
555
|
+
version=version,
|
|
556
|
+
storage_dir=storage_dir,
|
|
557
|
+
expected_paths=expected_skill_paths,
|
|
424
558
|
)
|
|
425
559
|
)
|
|
426
560
|
return {
|
|
427
561
|
"id": f"{domain}/{name}@{version}",
|
|
428
562
|
"assignment_id": row["id"],
|
|
429
563
|
"position": int(row["position"]),
|
|
430
|
-
"manifest":
|
|
564
|
+
"manifest": {
|
|
565
|
+
key: value for key, value in manifest.items() if key != "artifact_paths"
|
|
566
|
+
},
|
|
431
567
|
"artifacts": artifacts,
|
|
432
568
|
}
|
|
433
569
|
|
|
@@ -62,6 +62,18 @@ def _publish(client: TestClient, token: str, files: dict[str, str]):
|
|
|
62
62
|
return client.post("/api/v1/packs", json={"files": files}, headers=_auth(token))
|
|
63
63
|
|
|
64
64
|
|
|
65
|
+
def _stored_agents_file(tmp_path: Path) -> Path:
|
|
66
|
+
return (
|
|
67
|
+
tmp_path
|
|
68
|
+
/ "packs"
|
|
69
|
+
/ "acme"
|
|
70
|
+
/ "onboarding"
|
|
71
|
+
/ "1.0.0"
|
|
72
|
+
/ "instructions"
|
|
73
|
+
/ "AGENTS.md"
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
65
77
|
def test_owner_publishes_lists_and_downloads_pack_files(
|
|
66
78
|
tmp_path: Path, monkeypatch
|
|
67
79
|
) -> None:
|
|
@@ -561,6 +573,7 @@ def test_pack_file_download_rejects_traversal_and_symlinks(
|
|
|
561
573
|
headers=_auth(owner_token),
|
|
562
574
|
)
|
|
563
575
|
assert traversal.status_code == 404
|
|
576
|
+
assert traversal.json() == {"detail": "pack file not found"}
|
|
564
577
|
|
|
565
578
|
outside = tmp_path / "outside-secret.txt"
|
|
566
579
|
outside.write_text("secret", encoding="utf-8")
|
|
@@ -571,3 +584,90 @@ def test_pack_file_download_rejects_traversal_and_symlinks(
|
|
|
571
584
|
headers=_auth(owner_token),
|
|
572
585
|
)
|
|
573
586
|
assert leak.status_code == 404
|
|
587
|
+
assert leak.json() == {"detail": "pack file not found"}
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
def test_pack_file_download_missing_artifact_returns_json_404(
|
|
591
|
+
tmp_path: Path, monkeypatch
|
|
592
|
+
) -> None:
|
|
593
|
+
client, owner_token = _client_with_owner(tmp_path, monkeypatch)
|
|
594
|
+
assert _publish(client, owner_token, _pack_files()).status_code == 201
|
|
595
|
+
stored_file = _stored_agents_file(tmp_path)
|
|
596
|
+
stored_file.unlink()
|
|
597
|
+
|
|
598
|
+
response = client.get(
|
|
599
|
+
"/api/v1/packs/acme/onboarding/versions/1.0.0/files/instructions/AGENTS.md",
|
|
600
|
+
headers=_auth(owner_token),
|
|
601
|
+
)
|
|
602
|
+
|
|
603
|
+
assert response.status_code == 404
|
|
604
|
+
assert response.json() == {"detail": "pack file not found"}
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
def test_pack_file_download_unreadable_artifact_returns_json_503(
|
|
608
|
+
tmp_path: Path, monkeypatch
|
|
609
|
+
) -> None:
|
|
610
|
+
client, owner_token = _client_with_owner(tmp_path, monkeypatch)
|
|
611
|
+
assert _publish(client, owner_token, _pack_files()).status_code == 201
|
|
612
|
+
stored_file = _stored_agents_file(tmp_path)
|
|
613
|
+
stored_file.write_bytes(b"\xff\xfe\x00")
|
|
614
|
+
|
|
615
|
+
response = client.get(
|
|
616
|
+
"/api/v1/packs/acme/onboarding/versions/1.0.0/files/instructions/AGENTS.md",
|
|
617
|
+
headers=_auth(owner_token),
|
|
618
|
+
)
|
|
619
|
+
|
|
620
|
+
assert response.status_code == 503
|
|
621
|
+
assert response.json() == {"detail": "pack artifact storage unavailable"}
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
def test_pack_file_download_read_error_returns_json_503(
|
|
625
|
+
tmp_path: Path, monkeypatch
|
|
626
|
+
) -> None:
|
|
627
|
+
client, owner_token = _client_with_owner(tmp_path, monkeypatch)
|
|
628
|
+
assert _publish(client, owner_token, _pack_files()).status_code == 201
|
|
629
|
+
stored_file = _stored_agents_file(tmp_path)
|
|
630
|
+
original_read_text = Path.read_text
|
|
631
|
+
|
|
632
|
+
def fail_candidate_read_text(
|
|
633
|
+
self: Path,
|
|
634
|
+
encoding: str | None = None,
|
|
635
|
+
errors: str | None = None,
|
|
636
|
+
) -> str:
|
|
637
|
+
if self == stored_file and encoding == "utf-8":
|
|
638
|
+
raise OSError("simulated read failure")
|
|
639
|
+
return original_read_text(self, encoding=encoding, errors=errors)
|
|
640
|
+
|
|
641
|
+
monkeypatch.setattr(Path, "read_text", fail_candidate_read_text)
|
|
642
|
+
|
|
643
|
+
response = client.get(
|
|
644
|
+
"/api/v1/packs/acme/onboarding/versions/1.0.0/files/instructions/AGENTS.md",
|
|
645
|
+
headers=_auth(owner_token),
|
|
646
|
+
)
|
|
647
|
+
|
|
648
|
+
assert response.status_code == 503
|
|
649
|
+
assert response.json() == {"detail": "pack artifact storage unavailable"}
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
def test_pack_file_download_path_resolution_error_returns_json_503(
|
|
653
|
+
tmp_path: Path, monkeypatch
|
|
654
|
+
) -> None:
|
|
655
|
+
client, owner_token = _client_with_owner(tmp_path, monkeypatch)
|
|
656
|
+
assert _publish(client, owner_token, _pack_files()).status_code == 201
|
|
657
|
+
stored_file = _stored_agents_file(tmp_path)
|
|
658
|
+
original_resolve = Path.resolve
|
|
659
|
+
|
|
660
|
+
def fail_candidate_resolve(self: Path, strict: bool = False) -> Path:
|
|
661
|
+
if self == stored_file and strict:
|
|
662
|
+
raise OSError("simulated path resolution failure")
|
|
663
|
+
return original_resolve(self, strict=strict)
|
|
664
|
+
|
|
665
|
+
monkeypatch.setattr(Path, "resolve", fail_candidate_resolve)
|
|
666
|
+
|
|
667
|
+
response = client.get(
|
|
668
|
+
"/api/v1/packs/acme/onboarding/versions/1.0.0/files/instructions/AGENTS.md",
|
|
669
|
+
headers=_auth(owner_token),
|
|
670
|
+
)
|
|
671
|
+
|
|
672
|
+
assert response.status_code == 503
|
|
673
|
+
assert response.json() == {"detail": "pack artifact storage unavailable"}
|
|
@@ -2,13 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import json
|
|
6
|
+
import shutil
|
|
5
7
|
from pathlib import Path
|
|
6
8
|
from typing import Any
|
|
7
9
|
|
|
10
|
+
import pytest
|
|
8
11
|
from fastapi.testclient import TestClient
|
|
9
12
|
|
|
10
13
|
from agh.common.checksums import managed_payload_checksum
|
|
11
14
|
from agh.server.app import create_app
|
|
15
|
+
from agh.server.db import connect_database, get_database_path
|
|
12
16
|
|
|
13
17
|
|
|
14
18
|
def _client_with_owner(tmp_path: Path, monkeypatch) -> tuple[TestClient, str]:
|
|
@@ -118,6 +122,57 @@ def _assign_pack(
|
|
|
118
122
|
return response.json()
|
|
119
123
|
|
|
120
124
|
|
|
125
|
+
def _publish_and_assign(
|
|
126
|
+
tmp_path: Path, monkeypatch, ref: str, **files: str | None
|
|
127
|
+
) -> tuple[TestClient, str, dict[str, Any]]:
|
|
128
|
+
client, owner_token = _client_with_owner(tmp_path, monkeypatch)
|
|
129
|
+
project = _create_project(client, owner_token)
|
|
130
|
+
_publish_pack(client, owner_token, ref, **files)
|
|
131
|
+
_assign_pack(client, owner_token, project["id"], ref)
|
|
132
|
+
return client, owner_token, project
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _pull_manifest(client: TestClient, token: str, project: dict[str, Any]) -> Any:
|
|
136
|
+
return client.get(
|
|
137
|
+
f"/api/v1/projects/{project['id']}/pull-manifest",
|
|
138
|
+
headers=_auth(token),
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _assert_skill_artifacts(artifacts: list[dict[str, Any]], content: str) -> None:
|
|
143
|
+
assert {artifact["target_path"] for artifact in artifacts} == {
|
|
144
|
+
".opencode/skills/lint/SKILL.md",
|
|
145
|
+
".claude/skills/lint/SKILL.md",
|
|
146
|
+
}
|
|
147
|
+
assert all(
|
|
148
|
+
artifact["checksum"] == managed_payload_checksum(content)
|
|
149
|
+
for artifact in artifacts
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _store_manifest_artifact_paths(tmp_path: Path, artifact_paths: Any) -> None:
|
|
154
|
+
connection = connect_database(get_database_path(tmp_path))
|
|
155
|
+
try:
|
|
156
|
+
row = connection.execute(
|
|
157
|
+
"SELECT id, manifest_json FROM pack_versions"
|
|
158
|
+
).fetchone()
|
|
159
|
+
manifest = json.loads(row["manifest_json"])
|
|
160
|
+
manifest["artifact_paths"] = artifact_paths
|
|
161
|
+
connection.execute(
|
|
162
|
+
"UPDATE pack_versions SET manifest_json = ? WHERE id = ?",
|
|
163
|
+
(json.dumps(manifest, sort_keys=True), row["id"]),
|
|
164
|
+
)
|
|
165
|
+
connection.commit()
|
|
166
|
+
finally:
|
|
167
|
+
connection.close()
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _stored_pack_path(tmp_path: Path, ref: str, relative_path: str) -> Path:
|
|
171
|
+
pair, version = ref.split("@", 1)
|
|
172
|
+
domain, name = pair.split("/", 1)
|
|
173
|
+
return tmp_path / "packs" / domain / name / version / relative_path
|
|
174
|
+
|
|
175
|
+
|
|
121
176
|
def test_pull_manifest_resolves_latest_orders_assignments_and_builds_artifacts(
|
|
122
177
|
tmp_path: Path, monkeypatch
|
|
123
178
|
) -> None:
|
|
@@ -221,6 +276,181 @@ def test_pull_manifest_includes_skill_only_pack_artifacts(
|
|
|
221
276
|
)
|
|
222
277
|
|
|
223
278
|
|
|
279
|
+
def test_pull_manifest_legacy_missing_discovered_skill_file_is_skipped(
|
|
280
|
+
tmp_path: Path, monkeypatch
|
|
281
|
+
) -> None:
|
|
282
|
+
agents = "# Guide\n"
|
|
283
|
+
client, owner_token, project = _publish_and_assign(
|
|
284
|
+
tmp_path,
|
|
285
|
+
monkeypatch,
|
|
286
|
+
"acme/legacy@1.0.0",
|
|
287
|
+
agents=agents,
|
|
288
|
+
skill="# Lint\n",
|
|
289
|
+
)
|
|
290
|
+
stored_skill = _stored_pack_path(
|
|
291
|
+
tmp_path, "acme/legacy@1.0.0", "skills/lint/SKILL.md"
|
|
292
|
+
)
|
|
293
|
+
stored_skill.unlink()
|
|
294
|
+
|
|
295
|
+
response = _pull_manifest(client, owner_token, project)
|
|
296
|
+
|
|
297
|
+
assert response.status_code == 200, response.text
|
|
298
|
+
artifacts = response.json()["packs"][0]["artifacts"]
|
|
299
|
+
assert [artifact["path"] for artifact in artifacts] == ["instructions/AGENTS.md"]
|
|
300
|
+
assert artifacts[0]["checksum"] == managed_payload_checksum(agents)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def test_pull_manifest_legacy_missing_optional_instruction_file_is_skipped(
|
|
304
|
+
tmp_path: Path, monkeypatch
|
|
305
|
+
) -> None:
|
|
306
|
+
agents = "# OpenCode Guide\n"
|
|
307
|
+
client, owner_token, project = _publish_and_assign(
|
|
308
|
+
tmp_path,
|
|
309
|
+
monkeypatch,
|
|
310
|
+
"acme/legacy@1.0.0",
|
|
311
|
+
agents=agents,
|
|
312
|
+
claude="# Claude Guide\n",
|
|
313
|
+
skill=None,
|
|
314
|
+
)
|
|
315
|
+
stored_claude = _stored_pack_path(
|
|
316
|
+
tmp_path, "acme/legacy@1.0.0", "instructions/CLAUDE.md"
|
|
317
|
+
)
|
|
318
|
+
stored_claude.unlink()
|
|
319
|
+
|
|
320
|
+
response = _pull_manifest(client, owner_token, project)
|
|
321
|
+
|
|
322
|
+
assert response.status_code == 200, response.text
|
|
323
|
+
artifacts = response.json()["packs"][0]["artifacts"]
|
|
324
|
+
assert [artifact["path"] for artifact in artifacts] == ["instructions/AGENTS.md"]
|
|
325
|
+
assert artifacts[0]["checksum"] == managed_payload_checksum(agents)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def test_pull_manifest_expected_instruction_read_oserror_returns_json_503(
|
|
329
|
+
tmp_path: Path, monkeypatch
|
|
330
|
+
) -> None:
|
|
331
|
+
client, owner_token, project = _publish_and_assign(
|
|
332
|
+
tmp_path,
|
|
333
|
+
monkeypatch,
|
|
334
|
+
"acme/current@1.0.0",
|
|
335
|
+
agents="# OpenCode Guide\n",
|
|
336
|
+
claude=None,
|
|
337
|
+
skill=None,
|
|
338
|
+
)
|
|
339
|
+
_store_manifest_artifact_paths(tmp_path, ["instructions/AGENTS.md"])
|
|
340
|
+
stored_agents = _stored_pack_path(
|
|
341
|
+
tmp_path, "acme/current@1.0.0", "instructions/AGENTS.md"
|
|
342
|
+
)
|
|
343
|
+
original_read_text = Path.read_text
|
|
344
|
+
|
|
345
|
+
def fail_expected_agents_read(path: Path, *args: Any, **kwargs: Any) -> str:
|
|
346
|
+
if path == stored_agents:
|
|
347
|
+
raise OSError("simulated storage read failure")
|
|
348
|
+
return original_read_text(path, *args, **kwargs)
|
|
349
|
+
|
|
350
|
+
monkeypatch.setattr(Path, "read_text", fail_expected_agents_read)
|
|
351
|
+
|
|
352
|
+
response = _pull_manifest(client, owner_token, project)
|
|
353
|
+
|
|
354
|
+
assert response.status_code == 503
|
|
355
|
+
assert response.json() == {"detail": "pack artifact storage unavailable"}
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
@pytest.mark.parametrize("artifact_paths", ["skills/lint/SKILL.md", ["not/a/thing"]])
|
|
359
|
+
def test_pull_manifest_malformed_artifact_paths_uses_legacy_discovery(
|
|
360
|
+
tmp_path: Path, monkeypatch, artifact_paths: Any
|
|
361
|
+
) -> None:
|
|
362
|
+
skill_content = "# Lint\nUse lint skill.\n"
|
|
363
|
+
client, owner_token, project = _publish_and_assign(
|
|
364
|
+
tmp_path, monkeypatch, "acme/legacy@1.0.0", skill=skill_content
|
|
365
|
+
)
|
|
366
|
+
_store_manifest_artifact_paths(tmp_path, artifact_paths)
|
|
367
|
+
|
|
368
|
+
response = _pull_manifest(client, owner_token, project)
|
|
369
|
+
|
|
370
|
+
assert response.status_code == 200, response.text
|
|
371
|
+
_assert_skill_artifacts(response.json()["packs"][0]["artifacts"], skill_content)
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
@pytest.mark.parametrize(
|
|
375
|
+
("ref", "files", "artifact_paths", "missing_path", "remove_tree"),
|
|
376
|
+
[
|
|
377
|
+
(
|
|
378
|
+
"acme/guide@1.0.0",
|
|
379
|
+
{"agents": "# Guide\n"},
|
|
380
|
+
["instructions/AGENTS.md"],
|
|
381
|
+
"instructions/AGENTS.md",
|
|
382
|
+
False,
|
|
383
|
+
),
|
|
384
|
+
(
|
|
385
|
+
"acme/mixed@1.0.0",
|
|
386
|
+
{"agents": "# Guide\n", "skill": "# Lint\n"},
|
|
387
|
+
["instructions/AGENTS.md", "skills/lint/SKILL.md"],
|
|
388
|
+
"skills",
|
|
389
|
+
True,
|
|
390
|
+
),
|
|
391
|
+
],
|
|
392
|
+
)
|
|
393
|
+
def test_pull_manifest_expected_missing_storage_returns_json_404(
|
|
394
|
+
tmp_path: Path,
|
|
395
|
+
monkeypatch,
|
|
396
|
+
ref: str,
|
|
397
|
+
files: dict[str, str],
|
|
398
|
+
artifact_paths: list[str],
|
|
399
|
+
missing_path: str,
|
|
400
|
+
remove_tree: bool,
|
|
401
|
+
) -> None:
|
|
402
|
+
client, owner_token, project = _publish_and_assign(
|
|
403
|
+
tmp_path, monkeypatch, ref, **files
|
|
404
|
+
)
|
|
405
|
+
_store_manifest_artifact_paths(tmp_path, artifact_paths)
|
|
406
|
+
stored_path = _stored_pack_path(tmp_path, ref, missing_path)
|
|
407
|
+
shutil.rmtree(stored_path) if remove_tree else stored_path.unlink()
|
|
408
|
+
|
|
409
|
+
response = _pull_manifest(client, owner_token, project)
|
|
410
|
+
|
|
411
|
+
assert response.status_code == 404
|
|
412
|
+
assert response.json() == {"detail": "pack file not found"}
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def test_pull_manifest_expected_symlink_artifact_returns_json_404(
|
|
416
|
+
tmp_path: Path, monkeypatch
|
|
417
|
+
) -> None:
|
|
418
|
+
client, owner_token, project = _publish_and_assign(
|
|
419
|
+
tmp_path, monkeypatch, "acme/skills@1.0.0", skill="# Lint\n"
|
|
420
|
+
)
|
|
421
|
+
_store_manifest_artifact_paths(tmp_path, ["skills/lint/SKILL.md"])
|
|
422
|
+
stored_skill = _stored_pack_path(
|
|
423
|
+
tmp_path, "acme/skills@1.0.0", "skills/lint/SKILL.md"
|
|
424
|
+
)
|
|
425
|
+
stored_skill.unlink()
|
|
426
|
+
stored_skill.symlink_to(
|
|
427
|
+
_stored_pack_path(tmp_path, "acme/skills@1.0.0", "agh.pack.toml")
|
|
428
|
+
)
|
|
429
|
+
|
|
430
|
+
response = _pull_manifest(client, owner_token, project)
|
|
431
|
+
|
|
432
|
+
assert response.status_code == 404
|
|
433
|
+
assert response.json() == {"detail": "pack file not found"}
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def test_pull_manifest_unreadable_expected_artifact_returns_json_503(
|
|
437
|
+
tmp_path: Path, monkeypatch
|
|
438
|
+
) -> None:
|
|
439
|
+
client, owner_token, project = _publish_and_assign(
|
|
440
|
+
tmp_path, monkeypatch, "acme/skills@1.0.0", skill="# Lint\n"
|
|
441
|
+
)
|
|
442
|
+
_store_manifest_artifact_paths(tmp_path, ["skills/lint/SKILL.md"])
|
|
443
|
+
stored_skill = _stored_pack_path(
|
|
444
|
+
tmp_path, "acme/skills@1.0.0", "skills/lint/SKILL.md"
|
|
445
|
+
)
|
|
446
|
+
stored_skill.write_bytes(b"\xff\xfe\x00")
|
|
447
|
+
|
|
448
|
+
response = _pull_manifest(client, owner_token, project)
|
|
449
|
+
|
|
450
|
+
assert response.status_code == 503
|
|
451
|
+
assert response.json() == {"detail": "pack artifact storage unavailable"}
|
|
452
|
+
|
|
453
|
+
|
|
224
454
|
def test_pull_manifest_developer_access_and_non_member_denial(
|
|
225
455
|
tmp_path: Path, monkeypatch
|
|
226
456
|
) -> None:
|
|
File without changes
|
{agh-0.3.2 → agh-0.3.3}/LICENSE
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{agh-0.3.2 → agh-0.3.3}/uv.lock
RENAMED
|
File without changes
|