s-skillkit 0.6.2__tar.gz → 0.6.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.
- {s_skillkit-0.6.2 → s_skillkit-0.6.3}/CHANGELOG.md +12 -0
- {s_skillkit-0.6.2 → s_skillkit-0.6.3}/PKG-INFO +1 -1
- s_skillkit-0.6.3/docs/edited-link-migration.md +75 -0
- {s_skillkit-0.6.2 → s_skillkit-0.6.3}/pyproject.toml +1 -1
- {s_skillkit-0.6.2 → s_skillkit-0.6.3}/skillkit/__init__.py +2 -0
- {s_skillkit-0.6.2 → s_skillkit-0.6.3}/skillkit/installer/__init__.py +33 -0
- s_skillkit-0.6.3/skillkit/installer/edited_projections.py +302 -0
- {s_skillkit-0.6.2 → s_skillkit-0.6.3}/skillkit/installer/models.py +13 -0
- s_skillkit-0.6.3/tests/test_edited_link_migration.py +340 -0
- {s_skillkit-0.6.2 → s_skillkit-0.6.3}/uv.lock +1 -1
- {s_skillkit-0.6.2 → s_skillkit-0.6.3}/.gitignore +0 -0
- {s_skillkit-0.6.2 → s_skillkit-0.6.3}/.gitlab-ci.yml +0 -0
- {s_skillkit-0.6.2 → s_skillkit-0.6.3}/.gitlab-ci.yml.bak +0 -0
- {s_skillkit-0.6.2 → s_skillkit-0.6.3}/.importlinter +0 -0
- {s_skillkit-0.6.2 → s_skillkit-0.6.3}/.importlinter.bak +0 -0
- {s_skillkit-0.6.2 → s_skillkit-0.6.3}/.skillgateignore +0 -0
- {s_skillkit-0.6.2 → s_skillkit-0.6.3}/AGENTS.md +0 -0
- {s_skillkit-0.6.2 → s_skillkit-0.6.3}/LICENSE +0 -0
- {s_skillkit-0.6.2 → s_skillkit-0.6.3}/README.md +0 -0
- {s_skillkit-0.6.2 → s_skillkit-0.6.3}/docs/adr/0000-template.md +0 -0
- {s_skillkit-0.6.2 → s_skillkit-0.6.3}/docs/adr/0001-kanon-navyka-zhivet-v-skillkit.md +0 -0
- {s_skillkit-0.6.2 → s_skillkit-0.6.3}/docs/adr/README.md +0 -0
- {s_skillkit-0.6.2 → s_skillkit-0.6.3}/docs/agent-projections.md +0 -0
- {s_skillkit-0.6.2 → s_skillkit-0.6.3}/docs/safe-updates.md +0 -0
- {s_skillkit-0.6.2 → s_skillkit-0.6.3}/scripts/check_dist_leaks.py +0 -0
- {s_skillkit-0.6.2 → s_skillkit-0.6.3}/scripts/cycles_gate.py +0 -0
- {s_skillkit-0.6.2 → s_skillkit-0.6.3}/scripts/cycles_gate.py.bak +0 -0
- {s_skillkit-0.6.2 → s_skillkit-0.6.3}/scripts/lock_gate.py +0 -0
- {s_skillkit-0.6.2 → s_skillkit-0.6.3}/scripts/pins_gate.py +0 -0
- {s_skillkit-0.6.2 → s_skillkit-0.6.3}/scripts/pins_gate.py.bak +0 -0
- {s_skillkit-0.6.2 → s_skillkit-0.6.3}/scripts/release_guard.py +0 -0
- {s_skillkit-0.6.2 → s_skillkit-0.6.3}/scripts/release_guard.py.bak +0 -0
- {s_skillkit-0.6.2 → s_skillkit-0.6.3}/scripts/source_guard.py +0 -0
- {s_skillkit-0.6.2 → s_skillkit-0.6.3}/skillkit/binary_installer.py +0 -0
- {s_skillkit-0.6.2 → s_skillkit-0.6.3}/skillkit/bootstrap.py +0 -0
- {s_skillkit-0.6.2 → s_skillkit-0.6.3}/skillkit/canon.py +0 -0
- {s_skillkit-0.6.2 → s_skillkit-0.6.3}/skillkit/collections.py +0 -0
- {s_skillkit-0.6.2 → s_skillkit-0.6.3}/skillkit/config.py +0 -0
- {s_skillkit-0.6.2 → s_skillkit-0.6.3}/skillkit/deps_installer.py +0 -0
- {s_skillkit-0.6.2 → s_skillkit-0.6.3}/skillkit/errors.py +0 -0
- {s_skillkit-0.6.2 → s_skillkit-0.6.3}/skillkit/filter.py +0 -0
- {s_skillkit-0.6.2 → s_skillkit-0.6.3}/skillkit/hook_register.py +0 -0
- {s_skillkit-0.6.2 → s_skillkit-0.6.3}/skillkit/installer/differ.py +0 -0
- {s_skillkit-0.6.2 → s_skillkit-0.6.3}/skillkit/installer/fetchers.py +0 -0
- {s_skillkit-0.6.2 → s_skillkit-0.6.3}/skillkit/installer/fsops.py +0 -0
- {s_skillkit-0.6.2 → s_skillkit-0.6.3}/skillkit/installer/meta.py +0 -0
- {s_skillkit-0.6.2 → s_skillkit-0.6.3}/skillkit/installer/ownership.py +0 -0
- {s_skillkit-0.6.2 → s_skillkit-0.6.3}/skillkit/installer/projections.py +0 -0
- {s_skillkit-0.6.2 → s_skillkit-0.6.3}/skillkit/installer_cli/__init__.py +0 -0
- {s_skillkit-0.6.2 → s_skillkit-0.6.3}/skillkit/installer_cli/_branding.py +0 -0
- {s_skillkit-0.6.2 → s_skillkit-0.6.3}/skillkit/installer_cli/_detached.py +0 -0
- {s_skillkit-0.6.2 → s_skillkit-0.6.3}/skillkit/installer_cli/config.py +0 -0
- {s_skillkit-0.6.2 → s_skillkit-0.6.3}/skillkit/installer_cli/direct.py +0 -0
- {s_skillkit-0.6.2 → s_skillkit-0.6.3}/skillkit/installer_cli/doctor.py +0 -0
- {s_skillkit-0.6.2 → s_skillkit-0.6.3}/skillkit/installer_cli/install_scripts.py +0 -0
- {s_skillkit-0.6.2 → s_skillkit-0.6.3}/skillkit/installer_cli/publish.py +0 -0
- {s_skillkit-0.6.2 → s_skillkit-0.6.3}/skillkit/installer_cli/self_update.py +0 -0
- {s_skillkit-0.6.2 → s_skillkit-0.6.3}/skillkit/installer_cli/tokens.py +0 -0
- {s_skillkit-0.6.2 → s_skillkit-0.6.3}/skillkit/linker.py +0 -0
- {s_skillkit-0.6.2 → s_skillkit-0.6.3}/skillkit/manifest.py +0 -0
- {s_skillkit-0.6.2 → s_skillkit-0.6.3}/skillkit/mcp_register.py +0 -0
- {s_skillkit-0.6.2 → s_skillkit-0.6.3}/skillkit/path_store.py +0 -0
- {s_skillkit-0.6.2 → s_skillkit-0.6.3}/skillkit/paths.py +0 -0
- {s_skillkit-0.6.2 → s_skillkit-0.6.3}/skillkit/pkg_manager.py +0 -0
- {s_skillkit-0.6.2 → s_skillkit-0.6.3}/skillkit/proc.py +0 -0
- {s_skillkit-0.6.2 → s_skillkit-0.6.3}/skillkit/project.py +0 -0
- {s_skillkit-0.6.2 → s_skillkit-0.6.3}/skillkit/targets/__init__.py +0 -0
- {s_skillkit-0.6.2 → s_skillkit-0.6.3}/skillkit/targets/antigravity.py +0 -0
- {s_skillkit-0.6.2 → s_skillkit-0.6.3}/skillkit/targets/base.py +0 -0
- {s_skillkit-0.6.2 → s_skillkit-0.6.3}/skillkit/targets/claude_code.py +0 -0
- {s_skillkit-0.6.2 → s_skillkit-0.6.3}/skillkit/targets/codex.py +0 -0
- {s_skillkit-0.6.2 → s_skillkit-0.6.3}/skillkit/targets/deepseek.py +0 -0
- {s_skillkit-0.6.2 → s_skillkit-0.6.3}/skillkit/targets/detect.py +0 -0
- {s_skillkit-0.6.2 → s_skillkit-0.6.3}/skillkit/targets/opencode.py +0 -0
- {s_skillkit-0.6.2 → s_skillkit-0.6.3}/skillkit/telemetry.py +0 -0
- {s_skillkit-0.6.2 → s_skillkit-0.6.3}/skillkit/tooling.py +0 -0
- {s_skillkit-0.6.2 → s_skillkit-0.6.3}/skillkit/uv_bootstrap.py +0 -0
- {s_skillkit-0.6.2 → s_skillkit-0.6.3}/tests/conftest.py +0 -0
- {s_skillkit-0.6.2 → s_skillkit-0.6.3}/tests/test_agent_projections.py +0 -0
- {s_skillkit-0.6.2 → s_skillkit-0.6.3}/tests/test_agent_skill_layouts_2026_07_21.py +0 -0
- {s_skillkit-0.6.2 → s_skillkit-0.6.3}/tests/test_artifact_ownership_2272.py +0 -0
- {s_skillkit-0.6.2 → s_skillkit-0.6.3}/tests/test_binary_installer.py +0 -0
- {s_skillkit-0.6.2 → s_skillkit-0.6.3}/tests/test_bootstrap.py +0 -0
- {s_skillkit-0.6.2 → s_skillkit-0.6.3}/tests/test_canon.py +0 -0
- {s_skillkit-0.6.2 → s_skillkit-0.6.3}/tests/test_claude_frontmatter.py +0 -0
- {s_skillkit-0.6.2 → s_skillkit-0.6.3}/tests/test_collections.py +0 -0
- {s_skillkit-0.6.2 → s_skillkit-0.6.3}/tests/test_config.py +0 -0
- {s_skillkit-0.6.2 → s_skillkit-0.6.3}/tests/test_deepseek_target.py +0 -0
- {s_skillkit-0.6.2 → s_skillkit-0.6.3}/tests/test_deps_binary.py +0 -0
- {s_skillkit-0.6.2 → s_skillkit-0.6.3}/tests/test_deps_installer.py +0 -0
- {s_skillkit-0.6.2 → s_skillkit-0.6.3}/tests/test_destructive_ownership_gate_2026_07_21.py +0 -0
- {s_skillkit-0.6.2 → s_skillkit-0.6.3}/tests/test_errors.py +0 -0
- {s_skillkit-0.6.2 → s_skillkit-0.6.3}/tests/test_filter.py +0 -0
- {s_skillkit-0.6.2 → s_skillkit-0.6.3}/tests/test_git_install.py +0 -0
- {s_skillkit-0.6.2 → s_skillkit-0.6.3}/tests/test_git_payload_bytes.py +0 -0
- {s_skillkit-0.6.2 → s_skillkit-0.6.3}/tests/test_hooks.py +0 -0
- {s_skillkit-0.6.2 → s_skillkit-0.6.3}/tests/test_installer.py +0 -0
- {s_skillkit-0.6.2 → s_skillkit-0.6.3}/tests/test_installer_cli_direct.py +0 -0
- {s_skillkit-0.6.2 → s_skillkit-0.6.3}/tests/test_installer_cli_doctor.py +0 -0
- {s_skillkit-0.6.2 → s_skillkit-0.6.3}/tests/test_installer_cli_publish.py +0 -0
- {s_skillkit-0.6.2 → s_skillkit-0.6.3}/tests/test_installer_cli_smoke.py +0 -0
- {s_skillkit-0.6.2 → s_skillkit-0.6.3}/tests/test_installer_cli_tokens.py +0 -0
- {s_skillkit-0.6.2 → s_skillkit-0.6.3}/tests/test_legacy_paths_no_orphans_2026_07_21.py +0 -0
- {s_skillkit-0.6.2 → s_skillkit-0.6.3}/tests/test_manifest.py +0 -0
- {s_skillkit-0.6.2 → s_skillkit-0.6.3}/tests/test_mcp_disabled_preference.py +0 -0
- {s_skillkit-0.6.2 → s_skillkit-0.6.3}/tests/test_mcp_register.py +0 -0
- {s_skillkit-0.6.2 → s_skillkit-0.6.3}/tests/test_no_raw_subprocess.py +0 -0
- {s_skillkit-0.6.2 → s_skillkit-0.6.3}/tests/test_packaging_lock.py +0 -0
- {s_skillkit-0.6.2 → s_skillkit-0.6.3}/tests/test_path_store.py +0 -0
- {s_skillkit-0.6.2 → s_skillkit-0.6.3}/tests/test_paths.py +0 -0
- {s_skillkit-0.6.2 → s_skillkit-0.6.3}/tests/test_pkg_manager.py +0 -0
- {s_skillkit-0.6.2 → s_skillkit-0.6.3}/tests/test_pkgsrc_not_in_agent_zone.py +0 -0
- {s_skillkit-0.6.2 → s_skillkit-0.6.3}/tests/test_proc.py +0 -0
- {s_skillkit-0.6.2 → s_skillkit-0.6.3}/tests/test_project.py +0 -0
- {s_skillkit-0.6.2 → s_skillkit-0.6.3}/tests/test_remove_ownership_guard_2026_07_21.py +0 -0
- {s_skillkit-0.6.2 → s_skillkit-0.6.3}/tests/test_safe_update_gate.py +0 -0
- {s_skillkit-0.6.2 → s_skillkit-0.6.3}/tests/test_stub_guard.py +0 -0
- {s_skillkit-0.6.2 → s_skillkit-0.6.3}/tests/test_targets.py +0 -0
- {s_skillkit-0.6.2 → s_skillkit-0.6.3}/tests/test_targets_public_names.py +0 -0
- {s_skillkit-0.6.2 → s_skillkit-0.6.3}/tests/test_telemetry_adapter.py +0 -0
- {s_skillkit-0.6.2 → s_skillkit-0.6.3}/tests/test_tooling.py +0 -0
- {s_skillkit-0.6.2 → s_skillkit-0.6.3}/tests/test_transactional_install.py +0 -0
- {s_skillkit-0.6.2 → s_skillkit-0.6.3}/tests/test_traversal_guard.py +0 -0
- {s_skillkit-0.6.2 → s_skillkit-0.6.3}/tests/test_typed_fields.py +0 -0
- {s_skillkit-0.6.2 → s_skillkit-0.6.3}/tests/test_uv_bootstrap.py +0 -0
- {s_skillkit-0.6.2 → s_skillkit-0.6.3}/tests/test_w268_subdir_skill.py +0 -0
- {s_skillkit-0.6.2 → s_skillkit-0.6.3}/tests/test_w272_empty_manifest_guard.py +0 -0
|
@@ -4,6 +4,18 @@
|
|
|
4
4
|
[Keep a Changelog](https://keepachangelog.com/ru/1.1.0/); версионирование —
|
|
5
5
|
[SemVer](https://semver.org/lang/ru/).
|
|
6
6
|
|
|
7
|
+
## [0.6.3] - 2026-10-14
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- Explicit owned-link migration into readable copies while preserving legitimate edits, deletions and reviewed untracked payload, with authenticated historical release baselines kept separate from current snapshots.
|
|
12
|
+
- Bounded migration results report copied untracked paths. Shared source metadata/runtime remain unchanged; strict future updates still refuse local edits and unknown baselines.
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
|
|
16
|
+
- Verify completed migration agent, scope, project, Hub identity and historical manifest before accepting a repeated no-write migration.
|
|
17
|
+
- Reject foreign bindings, metadata/runtime links and detected source/staging races; retain executable permissions when copying Linux helpers.
|
|
18
|
+
|
|
7
19
|
## [0.6.2] - 2026-10-14
|
|
8
20
|
|
|
9
21
|
### Fixed
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: s-skillkit
|
|
3
|
-
Version: 0.6.
|
|
3
|
+
Version: 0.6.3
|
|
4
4
|
Summary: Локальное ядро управления навыками: install из git/локальной папки, junction/symlink-линковка в scope агента, .skillignore-фильтр, build manifest, project-манифест. 0 завязок на сеть/auth — stdlib + tomli-w + pathspec + platformdirs.
|
|
5
5
|
Author: Dmitry
|
|
6
6
|
License: MIT
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# Preserving an edited owned link as a readable copy
|
|
2
|
+
|
|
3
|
+
`SkillStore.migrate_owned_link_to_copy` is an explicit offline operation for an
|
|
4
|
+
existing owned provider link whose central-store payload contains legitimate
|
|
5
|
+
local edits. Normal install/update/link behavior remains strict.
|
|
6
|
+
|
|
7
|
+
```python
|
|
8
|
+
result = store.migrate_owned_link_to_copy(
|
|
9
|
+
"demo",
|
|
10
|
+
trusted_release_manifest=authenticated_historical_inventory,
|
|
11
|
+
expected_version="1.2.3",
|
|
12
|
+
expected_commit_sha=historical_commit,
|
|
13
|
+
preserve_local_changes=True,
|
|
14
|
+
reviewed_untracked_paths=("notes.txt",),
|
|
15
|
+
)
|
|
16
|
+
assert result.link_kind == "copy"
|
|
17
|
+
print(result.copied_untracked_paths, result.copied_untracked_count)
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
The caller obtains the exact historical inventory through an authenticated
|
|
21
|
+
release authority and verifies its identity. Current source bytes, a current
|
|
22
|
+
default branch, and an install bundle for a newer release are not historical
|
|
23
|
+
authority. Legacy commit identifiers are compared exactly; this API does not
|
|
24
|
+
fabricate or normalize them. Missing, mismatched, or unverifiable inventory is
|
|
25
|
+
refused.
|
|
26
|
+
|
|
27
|
+
Only an owned regular Hub store and its exact existing provider link are
|
|
28
|
+
eligible. The version, commit, released hashes, file sizes and declared
|
|
29
|
+
preserved paths must agree with the supplied historical inventory. Every
|
|
30
|
+
untracked regular payload path requires an explicit finite reviewed allowlist;
|
|
31
|
+
the default refuses untracked payload. Review these paths with the canonical
|
|
32
|
+
content/secret gate before approving them. Runtime or auth-looking unknown
|
|
33
|
+
files must not be approved as payload. This API does not infer new runtime
|
|
34
|
+
exclusions or introduce a second secret classifier.
|
|
35
|
+
|
|
36
|
+
The copy preserves current file contents, legitimate deletions and reviewed
|
|
37
|
+
untracked files. The immutable `projection.baseline` records the historical
|
|
38
|
+
release inventory. `migration_current_snapshot` separately records the bytes
|
|
39
|
+
copied during this migration; it never replaces release authority. Subsequent
|
|
40
|
+
install/update/link operations still report these local modifications and
|
|
41
|
+
refuse overwrites, including when `force=True`.
|
|
42
|
+
|
|
43
|
+
The central store, its metadata and its runtime state are unchanged. Canonical
|
|
44
|
+
`PreservedPaths`, target policy and projection runtime roots exclude runtime
|
|
45
|
+
and credentials from the new copy; runtime symlinks are refused. Metadata in
|
|
46
|
+
the copy contains indexed release data and identity, excluding arbitrary
|
|
47
|
+
auth/tool configuration. Runtime state remains at the original store and is
|
|
48
|
+
not duplicated or moved. The caller must establish any later runtime
|
|
49
|
+
projection separately through its approved runtime policy.
|
|
50
|
+
|
|
51
|
+
Migration is bounded to 10,000 payload files and 64 MiB of actual bytes read
|
|
52
|
+
and copied. Staging is a verified owned sibling. Link identity, source
|
|
53
|
+
identity, metadata and payload hashes are checked again before the swap and
|
|
54
|
+
after detaching the original link; failure restores that link. The operation
|
|
55
|
+
does not adopt foreign links, unknown ordinary copies or changed bindings.
|
|
56
|
+
Repeating a completed migration performs a verified no-write operation only
|
|
57
|
+
when its original historical inventory, separate current snapshot, scope and
|
|
58
|
+
source still agree. Normal `link_existing` retains its existing return contract.
|
|
59
|
+
|
|
60
|
+
Recover unverifiable historical inventory from a verified pinned old release
|
|
61
|
+
manifest/bundle, or review/export the original data manually. `force` and a
|
|
62
|
+
snapshot of current edited bytes do not establish a missing release baseline.
|
|
63
|
+
|
|
64
|
+
Completed-copy verification also checks the requested agent, scope, project,
|
|
65
|
+
original Hub identity fields and historical manifest inventory/preserved paths.
|
|
66
|
+
Editing those fields causes refusal without rewriting the copy or its source.
|
|
67
|
+
|
|
68
|
+
Concurrency checks cover persistent link/source/staging changes at the tested
|
|
69
|
+
checkpoints and restore the old link on detected pre-swap failures. The existing
|
|
70
|
+
filesystem copy primitives do not provide an atomic no-follow file handle or an
|
|
71
|
+
exclusive snapshot. These pre/post path and hash checks do not prevent a
|
|
72
|
+
same-user writer from transiently replacing/reverting paths between checks, or
|
|
73
|
+
changing data after the final check. Callers must serialize their own writers
|
|
74
|
+
and inspect the final receipt; this API does not claim a filesystem transaction
|
|
75
|
+
against arbitrary concurrent writers. Shared source data is never rewritten.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "s-skillkit"
|
|
3
|
-
version = "0.6.
|
|
3
|
+
version = "0.6.3"
|
|
4
4
|
description = "Локальное ядро управления навыками: install из git/локальной папки, junction/symlink-линковка в scope агента, .skillignore-фильтр, build manifest, project-манифест. 0 завязок на сеть/auth — stdlib + tomli-w + pathspec + platformdirs."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = { text = "MIT" }
|
|
@@ -104,6 +104,7 @@ from skillkit.installer import (
|
|
|
104
104
|
LinkKind,
|
|
105
105
|
LocalPathSource,
|
|
106
106
|
ManifestFile,
|
|
107
|
+
OwnedLinkMigrationResult,
|
|
107
108
|
PathTraversalError,
|
|
108
109
|
PreservedPaths,
|
|
109
110
|
ProjectionKind,
|
|
@@ -155,6 +156,7 @@ __all__ = [
|
|
|
155
156
|
"SkillInstaller",
|
|
156
157
|
"InstallRequest",
|
|
157
158
|
"InstallResult",
|
|
159
|
+
"OwnedLinkMigrationResult",
|
|
158
160
|
"RemoveResult",
|
|
159
161
|
"CredentialResolver",
|
|
160
162
|
# запечатанный source-spec установки (T1)
|
|
@@ -97,6 +97,7 @@ from skillkit.installer.models import (
|
|
|
97
97
|
LinkKind,
|
|
98
98
|
LocalPathSource,
|
|
99
99
|
ManifestFile,
|
|
100
|
+
OwnedLinkMigrationResult,
|
|
100
101
|
ProjectionKind,
|
|
101
102
|
RemoveResult,
|
|
102
103
|
Scope,
|
|
@@ -119,6 +120,7 @@ __all__ = [
|
|
|
119
120
|
"InstallManifest",
|
|
120
121
|
"InstallRequest",
|
|
121
122
|
"InstallResult",
|
|
123
|
+
"OwnedLinkMigrationResult",
|
|
122
124
|
"LinkKind",
|
|
123
125
|
"LocalPathSource",
|
|
124
126
|
"ManifestFile",
|
|
@@ -762,6 +764,37 @@ class SkillStore:
|
|
|
762
764
|
link = self._target.slug_dir(dir_name, project=project)
|
|
763
765
|
return self._link_into_scope(link, store_dir, force=force, projection=projection)
|
|
764
766
|
|
|
767
|
+
def migrate_owned_link_to_copy(
|
|
768
|
+
self, dir_name: str, *, trusted_release_manifest: dict[str, Any],
|
|
769
|
+
expected_version: str, expected_commit_sha: str,
|
|
770
|
+
preserve_local_changes: bool = False, project: Path | None = None,
|
|
771
|
+
reviewed_untracked_paths: tuple[str, ...] = (),
|
|
772
|
+
) -> OwnedLinkMigrationResult:
|
|
773
|
+
"""Explicitly preserve edits while detaching an owned published-store link.
|
|
774
|
+
|
|
775
|
+
The caller supplies authenticated historical release inventory. Current
|
|
776
|
+
payload is recorded separately and never authorizes subsequent overwrites.
|
|
777
|
+
Source payload, metadata and runtime state remain unchanged.
|
|
778
|
+
"""
|
|
779
|
+
from skillkit.installer.edited_projections import migrate_owned_link
|
|
780
|
+
|
|
781
|
+
if preserve_local_changes is not True:
|
|
782
|
+
raise ValueError("preserve_local_changes=True is required for this migration")
|
|
783
|
+
if (
|
|
784
|
+
not dir_name or dir_name in {".", ".."}
|
|
785
|
+
or dir_name != dir_name.strip() or dir_name.endswith(".")
|
|
786
|
+
or any(char in dir_name for char in "/\\:")
|
|
787
|
+
):
|
|
788
|
+
raise ValueError("dir_name must be a single store directory name")
|
|
789
|
+
store_dir = self._store_path(dir_name)
|
|
790
|
+
return migrate_owned_link(
|
|
791
|
+
store_dir, self._target.slug_dir(dir_name, project=project), guard=self._guard,
|
|
792
|
+
trusted_release_manifest=trusted_release_manifest,
|
|
793
|
+
expected_version=expected_version, expected_commit_sha=expected_commit_sha,
|
|
794
|
+
target=self._target, project=project,
|
|
795
|
+
reviewed_untracked_paths=reviewed_untracked_paths,
|
|
796
|
+
)
|
|
797
|
+
|
|
765
798
|
def migrate_scope(
|
|
766
799
|
self, *, project: Path | None = None, dry_run: bool = False
|
|
767
800
|
) -> dict[str, list]:
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
"""Explicit owned-link migration; current edits never become release authority."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import os
|
|
7
|
+
import shutil
|
|
8
|
+
import stat
|
|
9
|
+
import tempfile
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from skillkit import linker
|
|
14
|
+
from skillkit.installer.meta import (
|
|
15
|
+
PreservedPaths,
|
|
16
|
+
is_managed_dir,
|
|
17
|
+
read_meta,
|
|
18
|
+
stamp_ownership,
|
|
19
|
+
write_meta,
|
|
20
|
+
)
|
|
21
|
+
from skillkit.installer.models import OwnedLinkMigrationResult
|
|
22
|
+
from skillkit.installer.ownership import PathGuard, same_path
|
|
23
|
+
from skillkit.installer.projections import (
|
|
24
|
+
ProjectionConflict,
|
|
25
|
+
_baseline,
|
|
26
|
+
_ignored,
|
|
27
|
+
_index,
|
|
28
|
+
_raise_walk_error,
|
|
29
|
+
_validate_scope_state,
|
|
30
|
+
)
|
|
31
|
+
from skillkit.targets.base import AgentTarget
|
|
32
|
+
|
|
33
|
+
_MAX_BYTES = 64 * 1024 * 1024
|
|
34
|
+
_MAX_FILES = 10_000
|
|
35
|
+
_MIGRATION = "owned-link-preserved-v1"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _snapshot(root: Path, preserved: PreservedPaths) -> dict[str, str]:
|
|
39
|
+
"""Bound actual bytes read, including concurrent growth of individual files."""
|
|
40
|
+
result: dict[str, str] = {}
|
|
41
|
+
total = 0
|
|
42
|
+
for current, dirs, files in os.walk(root, followlinks=False, onerror=_raise_walk_error):
|
|
43
|
+
parent = Path(current)
|
|
44
|
+
for name in list(dirs):
|
|
45
|
+
path = parent / name
|
|
46
|
+
rel = path.relative_to(root).as_posix()
|
|
47
|
+
if linker.is_link(path):
|
|
48
|
+
raise ProjectionConflict("projection-runtime-link", (rel,))
|
|
49
|
+
if _ignored(rel, preserved):
|
|
50
|
+
dirs.remove(name)
|
|
51
|
+
for name in files:
|
|
52
|
+
path = parent / name
|
|
53
|
+
rel = path.relative_to(root).as_posix()
|
|
54
|
+
if linker.is_link(path) or not stat.S_ISREG(path.lstat().st_mode):
|
|
55
|
+
raise ProjectionConflict("projection-runtime-link", (rel,))
|
|
56
|
+
if _ignored(rel, preserved):
|
|
57
|
+
continue
|
|
58
|
+
if len(result) >= _MAX_FILES:
|
|
59
|
+
raise ProjectionConflict("projection-resource-limit", (rel,))
|
|
60
|
+
digest = hashlib.sha256()
|
|
61
|
+
with path.open("rb") as stream:
|
|
62
|
+
while chunk := stream.read(64 * 1024):
|
|
63
|
+
total += len(chunk)
|
|
64
|
+
if total > _MAX_BYTES:
|
|
65
|
+
raise ProjectionConflict("projection-resource-limit", (rel,))
|
|
66
|
+
digest.update(chunk)
|
|
67
|
+
result[rel] = digest.hexdigest()
|
|
68
|
+
return result
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _identity(path: Path) -> tuple[int, int, int]:
|
|
72
|
+
info = path.lstat()
|
|
73
|
+
return info.st_dev, info.st_ino, info.st_mode
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _regular_parents(path: Path) -> None:
|
|
77
|
+
if any(linker.is_link(parent) for parent in path.absolute().parents):
|
|
78
|
+
raise ProjectionConflict("projection-unmanaged-path")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _copy_snapshot(source: Path, destination: Path, expected: dict[str, str]) -> None:
|
|
82
|
+
total = 0
|
|
83
|
+
for rel, expected_hash in expected.items():
|
|
84
|
+
path = source / rel
|
|
85
|
+
target = destination / rel
|
|
86
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
87
|
+
if linker.is_link(path) or not stat.S_ISREG(path.lstat().st_mode):
|
|
88
|
+
raise ProjectionConflict("projection-source-changed", (rel,))
|
|
89
|
+
identity = _identity(path)
|
|
90
|
+
digest = hashlib.sha256()
|
|
91
|
+
with path.open("rb") as reader, target.open("xb") as writer:
|
|
92
|
+
while chunk := reader.read(64 * 1024):
|
|
93
|
+
total += len(chunk)
|
|
94
|
+
if total > _MAX_BYTES:
|
|
95
|
+
raise ProjectionConflict("projection-resource-limit", (rel,))
|
|
96
|
+
digest.update(chunk)
|
|
97
|
+
writer.write(chunk)
|
|
98
|
+
if digest.hexdigest() != expected_hash or _identity(path) != identity:
|
|
99
|
+
raise ProjectionConflict("projection-source-changed", (rel,))
|
|
100
|
+
shutil.copystat(path, target, follow_symlinks=False)
|
|
101
|
+
if _identity(path) != identity or linker.is_link(path):
|
|
102
|
+
raise ProjectionConflict("projection-source-changed", (rel,))
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def migrate_owned_link(
|
|
106
|
+
store_dir: Path,
|
|
107
|
+
slot: Path,
|
|
108
|
+
*,
|
|
109
|
+
guard: PathGuard,
|
|
110
|
+
trusted_release_manifest: dict[str, Any],
|
|
111
|
+
expected_version: str,
|
|
112
|
+
expected_commit_sha: str,
|
|
113
|
+
target: AgentTarget,
|
|
114
|
+
reviewed_untracked_paths: tuple[str, ...],
|
|
115
|
+
project: Path | None,
|
|
116
|
+
) -> OwnedLinkMigrationResult:
|
|
117
|
+
"""Detach a verified owned link while retaining edits and old release hashes."""
|
|
118
|
+
_regular_parents(store_dir)
|
|
119
|
+
_regular_parents(slot)
|
|
120
|
+
metadata_path = store_dir / "_skill_meta.json"
|
|
121
|
+
if (
|
|
122
|
+
linker.is_link(store_dir)
|
|
123
|
+
or linker.is_link(metadata_path)
|
|
124
|
+
or not metadata_path.is_file()
|
|
125
|
+
or metadata_path.stat().st_size > 1024 * 1024
|
|
126
|
+
or not is_managed_dir(store_dir)
|
|
127
|
+
or not guard.inside_store(store_dir)
|
|
128
|
+
):
|
|
129
|
+
raise ProjectionConflict("projection-unmanaged-path")
|
|
130
|
+
meta = read_meta(store_dir) or {}
|
|
131
|
+
manifest = meta.get("manifest")
|
|
132
|
+
if (
|
|
133
|
+
meta.get("source") != "hub"
|
|
134
|
+
or not isinstance(manifest, dict)
|
|
135
|
+
or not isinstance(trusted_release_manifest, dict)
|
|
136
|
+
or not expected_version
|
|
137
|
+
or not expected_commit_sha
|
|
138
|
+
or meta.get("version") != expected_version
|
|
139
|
+
or meta.get("commit_sha") != expected_commit_sha
|
|
140
|
+
or manifest.get("preserved_paths", [])
|
|
141
|
+
!= trusted_release_manifest.get("preserved_paths", [])
|
|
142
|
+
):
|
|
143
|
+
raise ProjectionConflict("projection-unverifiable-baseline")
|
|
144
|
+
declared = manifest.get("preserved_paths", [])
|
|
145
|
+
if not isinstance(declared, list) or not all(isinstance(x, str) and x for x in declared):
|
|
146
|
+
raise ProjectionConflict("projection-unverifiable-baseline")
|
|
147
|
+
preserved_paths = PreservedPaths.build(manifest, target)
|
|
148
|
+
historical = _index(trusted_release_manifest, preserved_paths, fingerprint=False)
|
|
149
|
+
if historical != _index(manifest, preserved_paths, fingerprint=False):
|
|
150
|
+
raise ProjectionConflict("projection-unverifiable-baseline")
|
|
151
|
+
inventory = []
|
|
152
|
+
for item in trusted_release_manifest["files"]:
|
|
153
|
+
entry = {key: item[key] for key in ("path", "sha256", "size") if key in item}
|
|
154
|
+
size = entry.get("size")
|
|
155
|
+
if size is not None and (type(size) is not int or size < 0):
|
|
156
|
+
raise ProjectionConflict("projection-unverifiable-baseline")
|
|
157
|
+
inventory.append(entry)
|
|
158
|
+
stored_sizes = {item["path"]: item.get("size") for item in manifest["files"]}
|
|
159
|
+
if any(item.get("size") != stored_sizes.get(item["path"]) for item in inventory):
|
|
160
|
+
raise ProjectionConflict("projection-unverifiable-baseline")
|
|
161
|
+
recorded = meta.get("projection_source_baseline")
|
|
162
|
+
if recorded is not None and historical != _index(recorded, preserved_paths, fingerprint=True):
|
|
163
|
+
raise ProjectionConflict("projection-unverifiable-baseline")
|
|
164
|
+
baseline = _baseline(historical)
|
|
165
|
+
source_identity = _identity(store_dir)
|
|
166
|
+
metadata_hash = hashlib.sha256((store_dir / "_skill_meta.json").read_bytes()).hexdigest()
|
|
167
|
+
_validate_scope_state(store_dir, preserved_paths)
|
|
168
|
+
before = _snapshot(store_dir, preserved_paths)
|
|
169
|
+
if "SKILL.md" not in before:
|
|
170
|
+
raise ProjectionConflict("projection-missing-entrypoint")
|
|
171
|
+
untracked = tuple(sorted(set(before) - set(historical)))
|
|
172
|
+
if (
|
|
173
|
+
not isinstance(reviewed_untracked_paths, tuple)
|
|
174
|
+
or len(reviewed_untracked_paths) > _MAX_FILES
|
|
175
|
+
or not all(isinstance(path, str) for path in reviewed_untracked_paths)
|
|
176
|
+
or len(set(reviewed_untracked_paths)) != len(reviewed_untracked_paths)
|
|
177
|
+
or set(reviewed_untracked_paths) != set(untracked)
|
|
178
|
+
):
|
|
179
|
+
raise ProjectionConflict("projection-unreviewed-untracked-payload", untracked or (".",))
|
|
180
|
+
|
|
181
|
+
def verify_source() -> None:
|
|
182
|
+
_regular_parents(store_dir)
|
|
183
|
+
if (
|
|
184
|
+
linker.is_link(store_dir)
|
|
185
|
+
or linker.is_link(metadata_path)
|
|
186
|
+
or _identity(store_dir) != source_identity
|
|
187
|
+
or hashlib.sha256((store_dir / "_skill_meta.json").read_bytes()).hexdigest()
|
|
188
|
+
!= metadata_hash
|
|
189
|
+
or _snapshot(store_dir, preserved_paths) != before
|
|
190
|
+
):
|
|
191
|
+
raise ProjectionConflict("projection-source-changed")
|
|
192
|
+
_validate_scope_state(store_dir, preserved_paths)
|
|
193
|
+
|
|
194
|
+
if not linker.is_link(slot):
|
|
195
|
+
scope_metadata = slot / "_skill_meta.json"
|
|
196
|
+
if (
|
|
197
|
+
linker.is_link(scope_metadata)
|
|
198
|
+
or not scope_metadata.is_file()
|
|
199
|
+
or scope_metadata.stat().st_size > 1024 * 1024
|
|
200
|
+
or not is_managed_dir(slot)
|
|
201
|
+
):
|
|
202
|
+
raise ProjectionConflict("projection-unmanaged-path")
|
|
203
|
+
previous = read_meta(slot) or {}
|
|
204
|
+
projection = previous.get("projection", {})
|
|
205
|
+
expected_identity = {key: meta.get(key) for key in (
|
|
206
|
+
"slug", "skill_id", "version", "commit_sha", "source",
|
|
207
|
+
)}
|
|
208
|
+
expected_identity.update(
|
|
209
|
+
agent=target.name, scope="project" if project else "global",
|
|
210
|
+
project=str(project) if project else None,
|
|
211
|
+
)
|
|
212
|
+
previous_manifest = previous.get("manifest")
|
|
213
|
+
if (
|
|
214
|
+
not isinstance(projection, dict)
|
|
215
|
+
or projection.get("migration") != _MIGRATION
|
|
216
|
+
or projection.get("kind") != "copy"
|
|
217
|
+
or not isinstance(projection.get("store_dir"), str)
|
|
218
|
+
or not same_path(Path(projection["store_dir"]), store_dir)
|
|
219
|
+
or projection.get("baseline") != baseline
|
|
220
|
+
or projection.get("source_revision") != expected_commit_sha
|
|
221
|
+
or projection.get("preserved_paths") != list(preserved_paths)
|
|
222
|
+
or previous.get("version") != expected_version
|
|
223
|
+
or previous.get("commit_sha") != expected_commit_sha
|
|
224
|
+
or any(previous.get(key) != value for key, value in expected_identity.items())
|
|
225
|
+
or not isinstance(previous_manifest, dict)
|
|
226
|
+
or previous_manifest.get("preserved_paths", []) != declared
|
|
227
|
+
):
|
|
228
|
+
raise ProjectionConflict("projection-unverifiable-baseline")
|
|
229
|
+
if _index(previous_manifest, preserved_paths, fingerprint=False) != historical:
|
|
230
|
+
raise ProjectionConflict("projection-unverifiable-baseline")
|
|
231
|
+
previous_sizes = {}
|
|
232
|
+
for item in previous_manifest["files"]:
|
|
233
|
+
size = item.get("size")
|
|
234
|
+
if size is not None and (type(size) is not int or size < 0):
|
|
235
|
+
raise ProjectionConflict("projection-unverifiable-baseline")
|
|
236
|
+
previous_sizes[item["path"]] = size
|
|
237
|
+
if any(item.get("size") != previous_sizes.get(item["path"]) for item in inventory):
|
|
238
|
+
raise ProjectionConflict("projection-unverifiable-baseline")
|
|
239
|
+
expected = _index(
|
|
240
|
+
projection.get("migration_current_snapshot"), preserved_paths, fingerprint=True,
|
|
241
|
+
)
|
|
242
|
+
actual = _snapshot(slot, preserved_paths)
|
|
243
|
+
if actual != expected or actual != before:
|
|
244
|
+
raise ProjectionConflict("projection-local-modifications")
|
|
245
|
+
_validate_scope_state(slot, preserved_paths)
|
|
246
|
+
verify_source()
|
|
247
|
+
return OwnedLinkMigrationResult(untracked)
|
|
248
|
+
|
|
249
|
+
link_identity = _identity(slot)
|
|
250
|
+
raw_target = os.readlink(slot)
|
|
251
|
+
|
|
252
|
+
def verify_link(path: Path) -> None:
|
|
253
|
+
_regular_parents(path)
|
|
254
|
+
if (
|
|
255
|
+
not linker.is_link(path)
|
|
256
|
+
or _identity(path) != link_identity
|
|
257
|
+
or os.readlink(path) != raw_target
|
|
258
|
+
or not same_path(linker.link_target(path) or Path(), store_dir)
|
|
259
|
+
or not guard.is_ours(path, store_dir=store_dir)
|
|
260
|
+
):
|
|
261
|
+
raise ProjectionConflict("projection-unmanaged-path")
|
|
262
|
+
verify_source()
|
|
263
|
+
|
|
264
|
+
verify_link(slot)
|
|
265
|
+
# Only identity and indexed release data enter the copy, never auth/tool config.
|
|
266
|
+
scope_meta = {
|
|
267
|
+
key: meta[key] for key in ("slug", "skill_id", "version", "commit_sha", "source")
|
|
268
|
+
if key in meta and isinstance(meta[key], (str, int, type(None)))
|
|
269
|
+
}
|
|
270
|
+
scope_meta.update(
|
|
271
|
+
agent=target.name, scope="project" if project else "global",
|
|
272
|
+
project=str(project) if project else None,
|
|
273
|
+
manifest={
|
|
274
|
+
"files": inventory,
|
|
275
|
+
"preserved_paths": declared,
|
|
276
|
+
},
|
|
277
|
+
projection={
|
|
278
|
+
"kind": "copy", "store_dir": str(store_dir),
|
|
279
|
+
"source_revision": expected_commit_sha, "preserved_paths": list(preserved_paths),
|
|
280
|
+
"baseline": baseline, "migration": _MIGRATION,
|
|
281
|
+
"migration_current_snapshot": _baseline(before),
|
|
282
|
+
"copied_untracked_paths": list(untracked),
|
|
283
|
+
},
|
|
284
|
+
)
|
|
285
|
+
staging = Path(tempfile.mkdtemp(prefix=f".{slot.name}.projection-stage-", dir=slot.parent))
|
|
286
|
+
try:
|
|
287
|
+
write_meta(staging, stamp_ownership(scope_meta))
|
|
288
|
+
_copy_snapshot(store_dir, staging, before)
|
|
289
|
+
if _snapshot(staging, preserved_paths) != before:
|
|
290
|
+
raise ProjectionConflict("projection-source-changed")
|
|
291
|
+
verify_link(slot)
|
|
292
|
+
|
|
293
|
+
def verify_swap(path: Path) -> None:
|
|
294
|
+
verify_link(path)
|
|
295
|
+
if _snapshot(staging, preserved_paths) != before:
|
|
296
|
+
raise ProjectionConflict("projection-source-changed")
|
|
297
|
+
|
|
298
|
+
guard.swap_in_scope(staging, slot, store_dir=store_dir, verify_entry=verify_swap)
|
|
299
|
+
finally:
|
|
300
|
+
if os.path.lexists(staging):
|
|
301
|
+
guard.destroy_in_scope(staging)
|
|
302
|
+
return OwnedLinkMigrationResult(untracked)
|
|
@@ -27,6 +27,19 @@ Content = Literal["real", "stub"]
|
|
|
27
27
|
Source = Literal["hub", "local-path", "git-url"]
|
|
28
28
|
|
|
29
29
|
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class OwnedLinkMigrationResult:
|
|
32
|
+
"""A preserved-copy migration reports reviewed untracked paths, never contents."""
|
|
33
|
+
|
|
34
|
+
copied_untracked_paths: tuple[str, ...]
|
|
35
|
+
linked: bool = False
|
|
36
|
+
link_kind: Literal["copy"] = "copy"
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def copied_untracked_count(self) -> int:
|
|
40
|
+
return len(self.copied_untracked_paths)
|
|
41
|
+
|
|
42
|
+
|
|
30
43
|
class ManifestFile(TypedDict, total=False):
|
|
31
44
|
"""Один файл install-манифеста: относительный путь + хеш + размер."""
|
|
32
45
|
|