kanibako-cli 1.5.0.dev14__py3-none-any.whl
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.
- kanibako/__init__.py +3 -0
- kanibako/__main__.py +6 -0
- kanibako/auth_browser.py +296 -0
- kanibako/auth_parser.py +51 -0
- kanibako/browser_sidecar.py +183 -0
- kanibako/browser_state.py +103 -0
- kanibako/bun_sea.py +144 -0
- kanibako/cli.py +344 -0
- kanibako/commands/__init__.py +0 -0
- kanibako/commands/archive.py +228 -0
- kanibako/commands/box/__init__.py +22 -0
- kanibako/commands/box/_duplicate.py +395 -0
- kanibako/commands/box/_migrate.py +574 -0
- kanibako/commands/box/_parser.py +1178 -0
- kanibako/commands/clean.py +166 -0
- kanibako/commands/crab_cmd.py +480 -0
- kanibako/commands/diagnose.py +239 -0
- kanibako/commands/fork_cmd.py +51 -0
- kanibako/commands/helper_cmd.py +669 -0
- kanibako/commands/image.py +1300 -0
- kanibako/commands/install.py +152 -0
- kanibako/commands/refresh_credentials.py +67 -0
- kanibako/commands/restore.py +298 -0
- kanibako/commands/setup_cmd.py +89 -0
- kanibako/commands/start.py +1600 -0
- kanibako/commands/stop.py +116 -0
- kanibako/commands/system_cmd.py +224 -0
- kanibako/commands/upgrade.py +161 -0
- kanibako/commands/vault_cmd.py +199 -0
- kanibako/commands/workset_cmd.py +552 -0
- kanibako/config.py +514 -0
- kanibako/config_interface.py +573 -0
- kanibako/config_io.py +36 -0
- kanibako/container.py +607 -0
- kanibako/containerfiles.py +58 -0
- kanibako/containers/Containerfile.kanibako +99 -0
- kanibako/containers/Containerfile.template-android +55 -0
- kanibako/containers/Containerfile.template-dotnet +29 -0
- kanibako/containers/Containerfile.template-js +43 -0
- kanibako/containers/Containerfile.template-jvm +27 -0
- kanibako/containers/Containerfile.template-systems +46 -0
- kanibako/containers/__init__.py +0 -0
- kanibako/crabs.py +89 -0
- kanibako/errors.py +33 -0
- kanibako/freshness.py +67 -0
- kanibako/git.py +114 -0
- kanibako/helper_client.py +132 -0
- kanibako/helper_listener.py +538 -0
- kanibako/helpers.py +339 -0
- kanibako/hygiene.py +296 -0
- kanibako/image_sharing.py +133 -0
- kanibako/instructions.py +160 -0
- kanibako/log.py +31 -0
- kanibako/names.py +248 -0
- kanibako/paths.py +1483 -0
- kanibako/plugins/__init__.py +10 -0
- kanibako/registry.py +71 -0
- kanibako/rig_bundle.py +121 -0
- kanibako/rig_meta.py +92 -0
- kanibako/rig_registry.py +132 -0
- kanibako/rig_resolve.py +182 -0
- kanibako/rig_source.py +245 -0
- kanibako/scripts/__init__.py +0 -0
- kanibako/scripts/helper-init.sh +45 -0
- kanibako/scripts/kanibako-entry +12 -0
- kanibako/settings_resolve.py +312 -0
- kanibako/settings_seeds.py +154 -0
- kanibako/settings_shares.py +154 -0
- kanibako/shellenv.py +75 -0
- kanibako/snapshots.py +281 -0
- kanibako/targets/__init__.py +173 -0
- kanibako/targets/base.py +243 -0
- kanibako/targets/no_agent.py +58 -0
- kanibako/templates.py +60 -0
- kanibako/templates_image.py +224 -0
- kanibako/tweakcc.py +140 -0
- kanibako/tweakcc_cache.py +171 -0
- kanibako/utils.py +136 -0
- kanibako/workset.py +347 -0
- kanibako_cli-1.5.0.dev14.dist-info/METADATA +15 -0
- kanibako_cli-1.5.0.dev14.dist-info/RECORD +85 -0
- kanibako_cli-1.5.0.dev14.dist-info/WHEEL +5 -0
- kanibako_cli-1.5.0.dev14.dist-info/entry_points.txt +5 -0
- kanibako_cli-1.5.0.dev14.dist-info/licenses/LICENSE.md +594 -0
- kanibako_cli-1.5.0.dev14.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Nested container image sharing: mount host image storage read-only into child containers.
|
|
2
|
+
|
|
3
|
+
When kanibako runs inside a container (LXC/VM), nested podman pulls images
|
|
4
|
+
separately, duplicating hundreds of MB. This module provides opt-in sharing
|
|
5
|
+
of the host's image storage via podman's ``additionalImageStores`` mechanism.
|
|
6
|
+
|
|
7
|
+
The host's overlay storage is bind-mounted **read-only** into the child
|
|
8
|
+
container at a well-known path, and a ``storage.conf`` snippet is generated
|
|
9
|
+
so the child's podman can find the shared layers.
|
|
10
|
+
|
|
11
|
+
**Known limitations**:
|
|
12
|
+
|
|
13
|
+
- UID mapping: if host and child rootless podman use different subuid ranges,
|
|
14
|
+
layer files may be inaccessible. Works best when the child container runs
|
|
15
|
+
with ``--userns=keep-id`` (which kanibako already uses).
|
|
16
|
+
- The shared store is read-only; the child can pull new images on top but
|
|
17
|
+
cannot modify or delete shared layers.
|
|
18
|
+
- Only overlay storage driver is supported (the default for rootless podman).
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import subprocess
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
from kanibako.log import get_logger
|
|
27
|
+
from kanibako.targets.base import Mount
|
|
28
|
+
|
|
29
|
+
logger = get_logger("image_sharing")
|
|
30
|
+
|
|
31
|
+
# Well-known mount point inside child containers.
|
|
32
|
+
SHARED_STORE_CONTAINER_PATH = "/var/lib/shared-images"
|
|
33
|
+
|
|
34
|
+
# Container-side storage.conf path (rootless podman).
|
|
35
|
+
_STORAGE_CONF_CONTAINER_PATH = "/home/agent/.config/containers/storage.conf"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def detect_graph_root(runtime_cmd: str) -> Path | None:
|
|
39
|
+
"""Detect the host podman/docker graph root (image storage directory).
|
|
40
|
+
|
|
41
|
+
Returns the ``GraphRoot`` path from ``podman info`` / ``docker info``,
|
|
42
|
+
or *None* if detection fails.
|
|
43
|
+
"""
|
|
44
|
+
try:
|
|
45
|
+
result = subprocess.run(
|
|
46
|
+
[runtime_cmd, "info", "--format", "{{.Store.GraphRoot}}"],
|
|
47
|
+
capture_output=True,
|
|
48
|
+
text=True,
|
|
49
|
+
timeout=10,
|
|
50
|
+
)
|
|
51
|
+
if result.returncode != 0:
|
|
52
|
+
logger.debug(
|
|
53
|
+
"Failed to detect graph root: %s", result.stderr.strip(),
|
|
54
|
+
)
|
|
55
|
+
return None
|
|
56
|
+
graph_root = result.stdout.strip()
|
|
57
|
+
if not graph_root:
|
|
58
|
+
return None
|
|
59
|
+
path = Path(graph_root)
|
|
60
|
+
if not path.is_dir():
|
|
61
|
+
logger.debug("Graph root does not exist: %s", path)
|
|
62
|
+
return None
|
|
63
|
+
return path
|
|
64
|
+
except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as exc:
|
|
65
|
+
logger.debug("Graph root detection failed: %s", exc)
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def generate_storage_conf(shared_store_path: str) -> str:
|
|
70
|
+
"""Generate a ``storage.conf`` snippet for podman's additionalImageStores.
|
|
71
|
+
|
|
72
|
+
The generated config tells the child's podman to use the overlay driver
|
|
73
|
+
and look for additional (read-only) image layers at *shared_store_path*.
|
|
74
|
+
|
|
75
|
+
Parameters
|
|
76
|
+
----------
|
|
77
|
+
shared_store_path:
|
|
78
|
+
The container-side path where the host's graph root is mounted
|
|
79
|
+
(typically ``/var/lib/shared-images``).
|
|
80
|
+
"""
|
|
81
|
+
return (
|
|
82
|
+
"[storage]\n"
|
|
83
|
+
' driver = "overlay"\n'
|
|
84
|
+
" [storage.options]\n"
|
|
85
|
+
f' additionalimagestores = ["{shared_store_path}"]\n'
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def build_image_sharing_mounts(
|
|
90
|
+
runtime_cmd: str,
|
|
91
|
+
staging_dir: Path,
|
|
92
|
+
) -> list[Mount]:
|
|
93
|
+
"""Build the bind-mounts needed for image sharing.
|
|
94
|
+
|
|
95
|
+
Returns a list of mounts (may be empty if detection fails or the
|
|
96
|
+
storage path doesn't exist). On success returns two mounts:
|
|
97
|
+
|
|
98
|
+
1. Host graph root -> ``/var/lib/shared-images`` (read-only)
|
|
99
|
+
2. Generated ``storage.conf`` -> child's config dir (read-only)
|
|
100
|
+
|
|
101
|
+
Parameters
|
|
102
|
+
----------
|
|
103
|
+
runtime_cmd:
|
|
104
|
+
Path to the container runtime binary (``podman`` or ``docker``).
|
|
105
|
+
staging_dir:
|
|
106
|
+
A host-side directory where the generated ``storage.conf`` will be
|
|
107
|
+
written. Should be under the project's metadata or cache path.
|
|
108
|
+
"""
|
|
109
|
+
graph_root = detect_graph_root(runtime_cmd)
|
|
110
|
+
if graph_root is None:
|
|
111
|
+
logger.info("Image sharing: could not detect host graph root, skipping")
|
|
112
|
+
return []
|
|
113
|
+
|
|
114
|
+
logger.info("Image sharing: host graph root at %s", graph_root)
|
|
115
|
+
|
|
116
|
+
# Generate the storage.conf snippet
|
|
117
|
+
storage_conf_content = generate_storage_conf(SHARED_STORE_CONTAINER_PATH)
|
|
118
|
+
staging_dir.mkdir(parents=True, exist_ok=True)
|
|
119
|
+
storage_conf_path = staging_dir / "storage.conf"
|
|
120
|
+
storage_conf_path.write_text(storage_conf_content)
|
|
121
|
+
|
|
122
|
+
return [
|
|
123
|
+
Mount(
|
|
124
|
+
source=graph_root,
|
|
125
|
+
destination=SHARED_STORE_CONTAINER_PATH,
|
|
126
|
+
options="ro",
|
|
127
|
+
),
|
|
128
|
+
Mount(
|
|
129
|
+
source=storage_conf_path,
|
|
130
|
+
destination=_STORAGE_CONF_CONTAINER_PATH,
|
|
131
|
+
options="ro",
|
|
132
|
+
),
|
|
133
|
+
]
|
kanibako/instructions.py
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""Layered instruction file merging.
|
|
2
|
+
|
|
3
|
+
Merges instruction files (e.g. CLAUDE.md) from three layers:
|
|
4
|
+
1. **Kanibako base** — container environment documentation
|
|
5
|
+
2. **Template** — shell template instructions
|
|
6
|
+
3. **User project** — user's own instructions (highest priority, shown last)
|
|
7
|
+
|
|
8
|
+
Each layer is concatenated with section markers so the agent can see
|
|
9
|
+
where each part comes from.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from kanibako.log import get_logger
|
|
17
|
+
|
|
18
|
+
logger = get_logger("instructions")
|
|
19
|
+
|
|
20
|
+
# Section markers used to delimit layers in merged instruction files.
|
|
21
|
+
_MARKER_BASE = "# --- kanibako base ---"
|
|
22
|
+
_MARKER_TEMPLATE = "# --- template: {name} ---"
|
|
23
|
+
_MARKER_PROJECT = "# --- project ---"
|
|
24
|
+
|
|
25
|
+
# Sentinel that marks the end of managed sections. Content after this
|
|
26
|
+
# marker is preserved verbatim (not touched by future merges).
|
|
27
|
+
_MARKER_END = "# --- end managed ---"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _read_layer(path: Path) -> str | None:
|
|
31
|
+
"""Read a file and return its stripped content, or None if missing/empty."""
|
|
32
|
+
if not path.is_file():
|
|
33
|
+
return None
|
|
34
|
+
content = path.read_text().strip()
|
|
35
|
+
return content if content else None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def merge_instruction_content(
|
|
39
|
+
*,
|
|
40
|
+
base_content: str | None = None,
|
|
41
|
+
template_content: str | None = None,
|
|
42
|
+
template_name: str = "",
|
|
43
|
+
user_content: str | None = None,
|
|
44
|
+
) -> str | None:
|
|
45
|
+
"""Merge instruction file layers into a single string.
|
|
46
|
+
|
|
47
|
+
Returns the merged content with section markers, or None if all
|
|
48
|
+
layers are empty/missing.
|
|
49
|
+
|
|
50
|
+
Layer order (top to bottom):
|
|
51
|
+
1. kanibako base
|
|
52
|
+
2. template
|
|
53
|
+
3. user project (last = highest visibility)
|
|
54
|
+
"""
|
|
55
|
+
sections: list[str] = []
|
|
56
|
+
|
|
57
|
+
if base_content:
|
|
58
|
+
sections.append(f"{_MARKER_BASE}\n\n{base_content}")
|
|
59
|
+
|
|
60
|
+
if template_content:
|
|
61
|
+
marker = _MARKER_TEMPLATE.format(name=template_name or "default")
|
|
62
|
+
sections.append(f"{marker}\n\n{template_content}")
|
|
63
|
+
|
|
64
|
+
if user_content:
|
|
65
|
+
sections.append(f"{_MARKER_PROJECT}\n\n{user_content}")
|
|
66
|
+
|
|
67
|
+
if not sections:
|
|
68
|
+
return None
|
|
69
|
+
|
|
70
|
+
return "\n\n".join(sections) + "\n"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def merge_instruction_files(
|
|
74
|
+
*,
|
|
75
|
+
shell_path: Path,
|
|
76
|
+
config_dir_name: str,
|
|
77
|
+
instruction_files: list[str],
|
|
78
|
+
templates_base: Path | None = None,
|
|
79
|
+
agent_name: str = "",
|
|
80
|
+
template_name: str = "standard",
|
|
81
|
+
) -> None:
|
|
82
|
+
"""Merge instruction files from base/template/user layers.
|
|
83
|
+
|
|
84
|
+
For each filename in *instruction_files*:
|
|
85
|
+
1. Read base content from ``templates_base/general/base/{config_dir_name}/{filename}``
|
|
86
|
+
2. Read template content from the resolved template dir ``{config_dir_name}/{filename}``
|
|
87
|
+
3. Read user content already in ``shell_path/{config_dir_name}/{filename}``
|
|
88
|
+
(placed there by template application or pre-existing)
|
|
89
|
+
|
|
90
|
+
The merged result replaces the file at ``shell_path/{config_dir_name}/{filename}``.
|
|
91
|
+
|
|
92
|
+
If the file only has content from a single layer, it is written without
|
|
93
|
+
section markers (clean output for the common case).
|
|
94
|
+
"""
|
|
95
|
+
if not instruction_files:
|
|
96
|
+
return
|
|
97
|
+
|
|
98
|
+
config_dir = shell_path / config_dir_name
|
|
99
|
+
|
|
100
|
+
for filename in instruction_files:
|
|
101
|
+
dest = config_dir / filename
|
|
102
|
+
|
|
103
|
+
# Layer 1: kanibako base — from general/base/{config_dir}/{filename}
|
|
104
|
+
base_content: str | None = None
|
|
105
|
+
if templates_base:
|
|
106
|
+
base_path = templates_base / "general" / "base" / config_dir_name / filename
|
|
107
|
+
base_content = _read_layer(base_path)
|
|
108
|
+
|
|
109
|
+
# Layer 2: template — from resolved template dir
|
|
110
|
+
# The template was already applied by apply_shell_template() which
|
|
111
|
+
# copied all files including instruction files. We need to read
|
|
112
|
+
# the template's *original* version before it was overlaid.
|
|
113
|
+
template_content: str | None = None
|
|
114
|
+
if templates_base:
|
|
115
|
+
from kanibako.templates import resolve_template
|
|
116
|
+
|
|
117
|
+
resolved = resolve_template(templates_base, agent_name, template_name)
|
|
118
|
+
if resolved:
|
|
119
|
+
tmpl_path = resolved / config_dir_name / filename
|
|
120
|
+
template_content = _read_layer(tmpl_path)
|
|
121
|
+
|
|
122
|
+
# Layer 3: user project — whatever is at the destination *now*.
|
|
123
|
+
# After apply_shell_template(), this is the template's version of
|
|
124
|
+
# the file (which we already captured above). We need to detect
|
|
125
|
+
# whether the current content is identical to the template layer
|
|
126
|
+
# to avoid duplicating it.
|
|
127
|
+
user_content: str | None = None
|
|
128
|
+
current_content = _read_layer(dest)
|
|
129
|
+
if current_content:
|
|
130
|
+
# If the current file matches the template content exactly,
|
|
131
|
+
# it's not user content — the template just put it there.
|
|
132
|
+
# Same for base content.
|
|
133
|
+
if current_content != template_content and current_content != base_content:
|
|
134
|
+
user_content = current_content
|
|
135
|
+
elif template_content is None and base_content is None:
|
|
136
|
+
# File exists but no template/base layers — treat as user content.
|
|
137
|
+
user_content = current_content
|
|
138
|
+
|
|
139
|
+
# Count non-None layers to decide whether to use markers.
|
|
140
|
+
layers = [x for x in (base_content, template_content, user_content) if x]
|
|
141
|
+
|
|
142
|
+
if not layers:
|
|
143
|
+
# No content from any layer — skip (don't create empty file).
|
|
144
|
+
continue
|
|
145
|
+
|
|
146
|
+
if len(layers) == 1:
|
|
147
|
+
# Single layer — write without markers for clean output.
|
|
148
|
+
merged: str | None = layers[0] + "\n"
|
|
149
|
+
else:
|
|
150
|
+
merged = merge_instruction_content(
|
|
151
|
+
base_content=base_content,
|
|
152
|
+
template_content=template_content,
|
|
153
|
+
template_name=template_name,
|
|
154
|
+
user_content=user_content,
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
if merged:
|
|
158
|
+
config_dir.mkdir(parents=True, exist_ok=True)
|
|
159
|
+
dest.write_text(merged)
|
|
160
|
+
logger.debug("Merged instruction file: %s (%d layers)", dest, len(layers))
|
kanibako/log.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Logging setup for kanibako."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def setup_logging(verbose: bool = False) -> None:
|
|
10
|
+
"""Configure the ``kanibako`` root logger.
|
|
11
|
+
|
|
12
|
+
Normal mode: WARNING+ to stderr.
|
|
13
|
+
Verbose mode: DEBUG+ to stderr with ``kanibako: <message>`` format.
|
|
14
|
+
"""
|
|
15
|
+
logger = logging.getLogger("kanibako")
|
|
16
|
+
logger.handlers.clear()
|
|
17
|
+
|
|
18
|
+
handler = logging.StreamHandler(sys.stderr)
|
|
19
|
+
if verbose:
|
|
20
|
+
logger.setLevel(logging.DEBUG)
|
|
21
|
+
handler.setFormatter(logging.Formatter("kanibako: %(message)s"))
|
|
22
|
+
else:
|
|
23
|
+
logger.setLevel(logging.WARNING)
|
|
24
|
+
|
|
25
|
+
handler.setLevel(logging.DEBUG)
|
|
26
|
+
logger.addHandler(handler)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def get_logger(name: str) -> logging.Logger:
|
|
30
|
+
"""Return a child logger under ``kanibako``."""
|
|
31
|
+
return logging.getLogger(f"kanibako.{name}")
|
kanibako/names.py
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
"""Project name registry (names.yaml).
|
|
2
|
+
|
|
3
|
+
Central index at ``{data_path}/names.yaml`` mapping human-readable names to
|
|
4
|
+
project paths (for default-mode projects) and workset roots (for worksets).
|
|
5
|
+
Standalone projects are intentionally excluded — they have no central
|
|
6
|
+
registration.
|
|
7
|
+
|
|
8
|
+
The file has two sections::
|
|
9
|
+
|
|
10
|
+
projects:
|
|
11
|
+
myapp: /home/user/projects/myapp
|
|
12
|
+
|
|
13
|
+
worksets:
|
|
14
|
+
clientwork: /home/user/worksets/client
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
from kanibako.config_io import dump_doc, load_doc
|
|
22
|
+
from kanibako.errors import ProjectError
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# ---------------------------------------------------------------------------
|
|
26
|
+
# I/O helpers
|
|
27
|
+
# ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
def _names_path(data_path: Path) -> Path:
|
|
30
|
+
return data_path / "names.yaml"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _load(data_path: Path) -> dict[str, dict[str, str]]:
|
|
34
|
+
"""Load names.yaml and return raw sections."""
|
|
35
|
+
path = _names_path(data_path)
|
|
36
|
+
if not path.is_file():
|
|
37
|
+
return {"projects": {}, "worksets": {}}
|
|
38
|
+
data = load_doc(path)
|
|
39
|
+
return {
|
|
40
|
+
"projects": {k: str(v) for k, v in data.get("projects", {}).items()},
|
|
41
|
+
"worksets": {k: str(v) for k, v in data.get("worksets", {}).items()},
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _save(data_path: Path, names: dict[str, dict[str, str]]) -> None:
|
|
46
|
+
"""Write names.yaml from sections dict."""
|
|
47
|
+
path = _names_path(data_path)
|
|
48
|
+
data: dict = {}
|
|
49
|
+
for section in ("projects", "worksets"):
|
|
50
|
+
entries = names.get(section, {})
|
|
51
|
+
data[section] = {name: entries[name] for name in sorted(entries)}
|
|
52
|
+
dump_doc(path, data)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# ---------------------------------------------------------------------------
|
|
56
|
+
# Public API
|
|
57
|
+
# ---------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
def read_names(data_path: Path) -> dict[str, dict[str, str]]:
|
|
60
|
+
"""Load names.yaml.
|
|
61
|
+
|
|
62
|
+
Returns ``{"projects": {name: path, ...}, "worksets": {name: path, ...}}``.
|
|
63
|
+
"""
|
|
64
|
+
return _load(data_path)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def register_name(
|
|
68
|
+
data_path: Path,
|
|
69
|
+
name: str,
|
|
70
|
+
path: str,
|
|
71
|
+
section: str = "projects",
|
|
72
|
+
) -> None:
|
|
73
|
+
"""Register a name → path mapping.
|
|
74
|
+
|
|
75
|
+
Raises ``ProjectError`` if *name* is already registered in either section,
|
|
76
|
+
or if *path* resolves to ``$HOME``.
|
|
77
|
+
"""
|
|
78
|
+
# Guard: never register $HOME as a project path.
|
|
79
|
+
if Path(path).resolve() == Path.home().resolve():
|
|
80
|
+
raise ProjectError(
|
|
81
|
+
"Refusing to register $HOME as a project path — this would "
|
|
82
|
+
"mount your entire home directory as the workspace."
|
|
83
|
+
)
|
|
84
|
+
names = _load(data_path)
|
|
85
|
+
# Check for duplicates across both sections.
|
|
86
|
+
for sec in ("projects", "worksets"):
|
|
87
|
+
if name in names[sec]:
|
|
88
|
+
raise ProjectError(
|
|
89
|
+
f"Name '{name}' is already registered"
|
|
90
|
+
f" ({sec}: {names[sec][name]})"
|
|
91
|
+
)
|
|
92
|
+
names[section][name] = path
|
|
93
|
+
_save(data_path, names)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def update_name_path(
|
|
97
|
+
data_path: Path,
|
|
98
|
+
name: str,
|
|
99
|
+
new_path: str,
|
|
100
|
+
section: str = "projects",
|
|
101
|
+
) -> bool:
|
|
102
|
+
"""Update the path for an existing registered name.
|
|
103
|
+
|
|
104
|
+
Returns True if the name was found and updated, False otherwise.
|
|
105
|
+
Raises ``ProjectError`` if *new_path* resolves to ``$HOME``.
|
|
106
|
+
"""
|
|
107
|
+
if Path(new_path).resolve() == Path.home().resolve():
|
|
108
|
+
raise ProjectError(
|
|
109
|
+
"Refusing to register $HOME as a project path — this would "
|
|
110
|
+
"mount your entire home directory as the workspace."
|
|
111
|
+
)
|
|
112
|
+
names = _load(data_path)
|
|
113
|
+
if name not in names.get(section, {}):
|
|
114
|
+
return False
|
|
115
|
+
names[section][name] = new_path
|
|
116
|
+
_save(data_path, names)
|
|
117
|
+
return True
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def unregister_name(
|
|
121
|
+
data_path: Path,
|
|
122
|
+
name: str,
|
|
123
|
+
section: str = "projects",
|
|
124
|
+
) -> bool:
|
|
125
|
+
"""Remove a name from the registry.
|
|
126
|
+
|
|
127
|
+
Returns True if the name was found and removed, False otherwise.
|
|
128
|
+
"""
|
|
129
|
+
names = _load(data_path)
|
|
130
|
+
if name not in names.get(section, {}):
|
|
131
|
+
return False
|
|
132
|
+
del names[section][name]
|
|
133
|
+
_save(data_path, names)
|
|
134
|
+
return True
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def lookup_by_path(
|
|
138
|
+
data_path: Path,
|
|
139
|
+
path: str,
|
|
140
|
+
) -> tuple[str, str] | None:
|
|
141
|
+
"""Find a registered name by its path value.
|
|
142
|
+
|
|
143
|
+
Returns ``(name, section)`` if found, ``None`` otherwise.
|
|
144
|
+
"""
|
|
145
|
+
resolved = str(Path(path).resolve())
|
|
146
|
+
names = _load(data_path)
|
|
147
|
+
for section in ("projects", "worksets"):
|
|
148
|
+
for name, registered_path in names[section].items():
|
|
149
|
+
if str(Path(registered_path).resolve()) == resolved:
|
|
150
|
+
return name, section
|
|
151
|
+
return None
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def resolve_name(
|
|
155
|
+
data_path: Path,
|
|
156
|
+
name: str,
|
|
157
|
+
cwd: Path | None = None,
|
|
158
|
+
) -> tuple[str, str]:
|
|
159
|
+
"""Look up a bare name and return ``(path, kind)``.
|
|
160
|
+
|
|
161
|
+
Resolution order:
|
|
162
|
+
|
|
163
|
+
1. If *cwd* is inside a workset → check that workset's projects first
|
|
164
|
+
2. ``[projects]`` section (default-mode projects)
|
|
165
|
+
3. ``[worksets]`` section (workset names)
|
|
166
|
+
|
|
167
|
+
*kind* is ``"project"`` or ``"workset"``.
|
|
168
|
+
Raises ``ProjectError`` if no match is found.
|
|
169
|
+
"""
|
|
170
|
+
names = _load(data_path)
|
|
171
|
+
|
|
172
|
+
# 1. Context-aware: if cwd is inside a registered workset, check its
|
|
173
|
+
# projects first.
|
|
174
|
+
if cwd is not None:
|
|
175
|
+
cwd_str = str(cwd.resolve())
|
|
176
|
+
for ws_name, ws_root in names["worksets"].items():
|
|
177
|
+
if cwd_str == ws_root or cwd_str.startswith(ws_root + "/"):
|
|
178
|
+
# cwd is inside this workset — check if name matches a
|
|
179
|
+
# workspace subdir.
|
|
180
|
+
ws_path = Path(ws_root)
|
|
181
|
+
candidate = ws_path / "workspaces" / name
|
|
182
|
+
if candidate.is_dir():
|
|
183
|
+
return str(candidate), "project"
|
|
184
|
+
|
|
185
|
+
# 2. Default-mode projects.
|
|
186
|
+
if name in names["projects"]:
|
|
187
|
+
return names["projects"][name], "project"
|
|
188
|
+
|
|
189
|
+
# 3. Worksets.
|
|
190
|
+
if name in names["worksets"]:
|
|
191
|
+
return names["worksets"][name], "workset"
|
|
192
|
+
|
|
193
|
+
raise ProjectError(f"Unknown project or workset: '{name}'")
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def resolve_qualified_name(
|
|
197
|
+
data_path: Path,
|
|
198
|
+
qualified: str,
|
|
199
|
+
) -> tuple[str, str]:
|
|
200
|
+
"""Resolve a qualified name (``workset/project``).
|
|
201
|
+
|
|
202
|
+
Returns ``(project_workspace_path, workset_name)``.
|
|
203
|
+
Raises ``ProjectError`` if the workset or project is not found.
|
|
204
|
+
"""
|
|
205
|
+
if "/" not in qualified:
|
|
206
|
+
raise ProjectError(
|
|
207
|
+
f"Not a qualified name (expected workset/project): '{qualified}'"
|
|
208
|
+
)
|
|
209
|
+
ws_name, proj_name = qualified.split("/", 1)
|
|
210
|
+
names = _load(data_path)
|
|
211
|
+
|
|
212
|
+
if ws_name not in names["worksets"]:
|
|
213
|
+
raise ProjectError(f"Unknown workset: '{ws_name}'")
|
|
214
|
+
|
|
215
|
+
ws_root = Path(names["worksets"][ws_name])
|
|
216
|
+
candidate = ws_root / "workspaces" / proj_name
|
|
217
|
+
if not candidate.is_dir():
|
|
218
|
+
raise ProjectError(
|
|
219
|
+
f"Project '{proj_name}' not found in workset '{ws_name}'"
|
|
220
|
+
)
|
|
221
|
+
return str(candidate), ws_name
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def assign_name(
|
|
225
|
+
data_path: Path,
|
|
226
|
+
path: str,
|
|
227
|
+
section: str = "projects",
|
|
228
|
+
) -> str:
|
|
229
|
+
"""Auto-assign a name from the basename of *path*.
|
|
230
|
+
|
|
231
|
+
Handles collisions by appending a number: ``name``, ``name2``, ``name3``, ...
|
|
232
|
+
Registers the name and returns it.
|
|
233
|
+
"""
|
|
234
|
+
base = Path(path).name
|
|
235
|
+
if not base:
|
|
236
|
+
base = "project"
|
|
237
|
+
|
|
238
|
+
names = _load(data_path)
|
|
239
|
+
all_names = set(names["projects"]) | set(names["worksets"])
|
|
240
|
+
|
|
241
|
+
candidate = base
|
|
242
|
+
n = 2
|
|
243
|
+
while candidate in all_names:
|
|
244
|
+
candidate = f"{base}{n}"
|
|
245
|
+
n += 1
|
|
246
|
+
|
|
247
|
+
register_name(data_path, candidate, path, section=section)
|
|
248
|
+
return candidate
|