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
kanibako/paths.py
ADDED
|
@@ -0,0 +1,1483 @@
|
|
|
1
|
+
"""XDG resolution, project hash computation, directory creation, and initialization."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from enum import Enum
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from collections.abc import Mapping, Sequence
|
|
10
|
+
from typing import NamedTuple, Protocol
|
|
11
|
+
|
|
12
|
+
import yaml
|
|
13
|
+
|
|
14
|
+
from kanibako.config import (
|
|
15
|
+
KanibakoConfig,
|
|
16
|
+
config_file_path,
|
|
17
|
+
load_config,
|
|
18
|
+
migrate_config,
|
|
19
|
+
read_project_meta,
|
|
20
|
+
write_project_meta,
|
|
21
|
+
)
|
|
22
|
+
from kanibako.config_io import load_doc
|
|
23
|
+
from kanibako.errors import ConfigError, ProjectError, WorksetError
|
|
24
|
+
from kanibako.settings_resolve import (
|
|
25
|
+
LevelView,
|
|
26
|
+
ResolveCtx,
|
|
27
|
+
SettingsError,
|
|
28
|
+
_Unset,
|
|
29
|
+
expand_expr,
|
|
30
|
+
resolve_value,
|
|
31
|
+
)
|
|
32
|
+
from kanibako.names import (
|
|
33
|
+
assign_name,
|
|
34
|
+
read_names,
|
|
35
|
+
register_name,
|
|
36
|
+
resolve_name,
|
|
37
|
+
)
|
|
38
|
+
from kanibako.utils import project_hash
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ProjectMode(Enum):
|
|
42
|
+
"""How a project's persistent state is organized on disk."""
|
|
43
|
+
|
|
44
|
+
default = "default"
|
|
45
|
+
workset = "workset"
|
|
46
|
+
standalone = "standalone"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class DetectionResult(NamedTuple):
|
|
50
|
+
"""Result of project mode detection.
|
|
51
|
+
|
|
52
|
+
*mode* is the detected project mode. *project_root* is the ancestor
|
|
53
|
+
directory where the marker was found (may differ from the original
|
|
54
|
+
*project_dir* when the user is in a subdirectory).
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
mode: ProjectMode
|
|
58
|
+
project_root: Path
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class ProjectLayout(Enum):
|
|
62
|
+
"""Directory layout variant within a project mode.
|
|
63
|
+
|
|
64
|
+
- **simple**: shell and vault live inside the workspace (minimal footprint)
|
|
65
|
+
- **default**: shell in boxes, vault in workspace (middle ground)
|
|
66
|
+
- **robust**: full separation — all four folders are top-level siblings
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
simple = "simple"
|
|
70
|
+
default = "default"
|
|
71
|
+
robust = "robust"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# Default layout per mode.
|
|
75
|
+
_DEFAULT_LAYOUT = {
|
|
76
|
+
ProjectMode.default: ProjectLayout.default,
|
|
77
|
+
ProjectMode.workset: ProjectLayout.robust,
|
|
78
|
+
ProjectMode.standalone: ProjectLayout.simple,
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass
|
|
83
|
+
class StandardPaths:
|
|
84
|
+
"""Resolved XDG and kanibako standard directory paths."""
|
|
85
|
+
|
|
86
|
+
config_home: Path
|
|
87
|
+
data_home: Path
|
|
88
|
+
state_home: Path
|
|
89
|
+
cache_home: Path
|
|
90
|
+
config_file: Path
|
|
91
|
+
data_path: Path
|
|
92
|
+
state_path: Path
|
|
93
|
+
cache_path: Path
|
|
94
|
+
# System-level derived directories (settings-framework "system.path.*").
|
|
95
|
+
boxes: Path
|
|
96
|
+
crabs: Path
|
|
97
|
+
comms: Path
|
|
98
|
+
share_ro: Path
|
|
99
|
+
share_rw: Path
|
|
100
|
+
templates: Path
|
|
101
|
+
ws_hints: Path
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@dataclass(frozen=True)
|
|
105
|
+
class ProjectGroup:
|
|
106
|
+
"""Descriptor of a project's grouping (default workset or named workset).
|
|
107
|
+
|
|
108
|
+
Captures the default-vs-workset difference as *data* rather than control
|
|
109
|
+
flow. The implicit default group is the *default workset* (``is_default``
|
|
110
|
+
is True); a named workset forms a non-default group rooted at the workset
|
|
111
|
+
root. Standalone projects belong to no group (``ProjectPaths.group`` is
|
|
112
|
+
None).
|
|
113
|
+
|
|
114
|
+
*local_shared_base* is the root under which the local-shared path lives
|
|
115
|
+
(``base / config.paths_shared``): the standard data path for the default
|
|
116
|
+
group, the workset root for a workset group.
|
|
117
|
+
"""
|
|
118
|
+
|
|
119
|
+
name: str
|
|
120
|
+
root: Path
|
|
121
|
+
is_default: bool
|
|
122
|
+
local_shared_base: Path
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@dataclass
|
|
126
|
+
class ProjectPaths:
|
|
127
|
+
"""Resolved paths for a specific project."""
|
|
128
|
+
|
|
129
|
+
project_path: Path
|
|
130
|
+
project_hash: str
|
|
131
|
+
metadata_path: Path # host-only: project.yaml, breadcrumb, lock
|
|
132
|
+
shell_path: Path # mounted as /home/agent
|
|
133
|
+
vault_ro_path: Path # {project}/vault/ro (→ /home/agent/share-ro)
|
|
134
|
+
vault_rw_path: Path # {project}/vault/rw (→ /home/agent/share-rw)
|
|
135
|
+
is_new: bool = field(default=False)
|
|
136
|
+
mode: ProjectMode = field(default=ProjectMode.default)
|
|
137
|
+
layout: ProjectLayout = field(default=ProjectLayout.default)
|
|
138
|
+
enable_vault: bool = field(default=True)
|
|
139
|
+
group_auth: bool = field(default=True)
|
|
140
|
+
name: str = field(default="")
|
|
141
|
+
global_shared_path: Path | None = field(default=None)
|
|
142
|
+
local_shared_path: Path | None = field(default=None)
|
|
143
|
+
group: ProjectGroup | None = field(default=None)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class _WorksetLike(Protocol):
|
|
147
|
+
"""Structural type for the attributes :meth:`WorksetSpec.from_workset` reads.
|
|
148
|
+
|
|
149
|
+
Avoids importing the concrete :class:`kanibako.workset.Workset` into
|
|
150
|
+
``paths.py`` (which ``workset.py`` imports from, creating a cycle).
|
|
151
|
+
"""
|
|
152
|
+
|
|
153
|
+
name: str
|
|
154
|
+
root: Path
|
|
155
|
+
group_auth: bool
|
|
156
|
+
is_default: bool
|
|
157
|
+
|
|
158
|
+
@property
|
|
159
|
+
def projects_dir(self) -> Path: ...
|
|
160
|
+
|
|
161
|
+
@property
|
|
162
|
+
def workspaces_dir(self) -> Path: ...
|
|
163
|
+
|
|
164
|
+
@property
|
|
165
|
+
def vault_dir(self) -> Path: ...
|
|
166
|
+
|
|
167
|
+
@property
|
|
168
|
+
def projects(self) -> Sequence[_WorksetProjectLike]: ...
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class _WorksetProjectLike(Protocol):
|
|
172
|
+
"""Structural type for the workset project attributes read here."""
|
|
173
|
+
|
|
174
|
+
@property
|
|
175
|
+
def name(self) -> str: ...
|
|
176
|
+
|
|
177
|
+
@property
|
|
178
|
+
def source_path(self) -> Path: ...
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
@dataclass(frozen=True)
|
|
182
|
+
class WorksetSpec:
|
|
183
|
+
"""Primitive view of a workset, decoupled from :class:`kanibako.workset.Workset`.
|
|
184
|
+
|
|
185
|
+
Carries only the values the path resolver and project listings need, so
|
|
186
|
+
``paths.py`` does not import the ``workset`` module (which depends on
|
|
187
|
+
``paths.py``). Callers holding a full ``Workset`` build one with
|
|
188
|
+
:meth:`from_workset`.
|
|
189
|
+
"""
|
|
190
|
+
|
|
191
|
+
name: str
|
|
192
|
+
root: Path
|
|
193
|
+
group_auth: bool
|
|
194
|
+
projects_dir: Path
|
|
195
|
+
workspaces_dir: Path
|
|
196
|
+
vault_dir: Path
|
|
197
|
+
project_names: tuple[str, ...]
|
|
198
|
+
is_default: bool = False
|
|
199
|
+
|
|
200
|
+
@classmethod
|
|
201
|
+
def from_workset(cls, ws: _WorksetLike) -> WorksetSpec:
|
|
202
|
+
"""Build a :class:`WorksetSpec` from a ``Workset``-like object."""
|
|
203
|
+
return cls(
|
|
204
|
+
name=ws.name,
|
|
205
|
+
root=ws.root,
|
|
206
|
+
group_auth=ws.group_auth,
|
|
207
|
+
projects_dir=ws.projects_dir,
|
|
208
|
+
workspaces_dir=ws.workspaces_dir,
|
|
209
|
+
vault_dir=ws.vault_dir,
|
|
210
|
+
project_names=tuple(p.name for p in ws.projects),
|
|
211
|
+
is_default=ws.is_default,
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def xdg(env_var: str, default_suffix: str) -> Path:
|
|
216
|
+
"""Resolve an XDG directory from environment or default under $HOME."""
|
|
217
|
+
val = os.environ.get(env_var, "")
|
|
218
|
+
if val:
|
|
219
|
+
return Path(val).resolve()
|
|
220
|
+
return Path.home() / default_suffix
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
# ---------------------------------------------------------------------------
|
|
224
|
+
# System-level path tier (settings-framework "system.path.*")
|
|
225
|
+
# ---------------------------------------------------------------------------
|
|
226
|
+
#
|
|
227
|
+
# These model the system-level derived directories as resolver-backed path
|
|
228
|
+
# expressions. Keys are the FULL dotted names so ``@``-refs (e.g.
|
|
229
|
+
# ``@system.path.data``) resolve against the same table. Defaults reproduce
|
|
230
|
+
# today's flat ``KanibakoConfig.paths_*`` behavior byte-for-byte: the data path
|
|
231
|
+
# is ``$XDG_DATA_HOME/kanibako`` and the other dirs (and the ``ws_hints`` file)
|
|
232
|
+
# hang off it.
|
|
233
|
+
SYSTEM_PATH_DEFAULTS: dict[str, str] = {
|
|
234
|
+
"system.path.data": "$XDG_DATA_HOME/kanibako",
|
|
235
|
+
"system.path.boxes": "@system.path.data/boxes",
|
|
236
|
+
"system.path.crabs": "@system.path.data/crabs",
|
|
237
|
+
"system.path.comms": "@system.path.data/comms",
|
|
238
|
+
"system.path.share_ro": "@system.path.data/share_ro",
|
|
239
|
+
"system.path.share_rw": "@system.path.data/share_rw",
|
|
240
|
+
"system.path.templates": "@system.path.data/templates",
|
|
241
|
+
"system.path.ws_hints": "@system.path.data/worksets.yaml",
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def resolve_system_paths(
|
|
246
|
+
set_values: Mapping[str, str], *, data_home: Path, home: Path,
|
|
247
|
+
) -> dict[str, Path]:
|
|
248
|
+
"""Resolve the ``system.path.*`` tier to concrete host paths.
|
|
249
|
+
|
|
250
|
+
*set_values* holds raw user-set expressions keyed by their full dotted name
|
|
251
|
+
(``system.path.<leaf>``); typically the global config's ``system_paths``.
|
|
252
|
+
*data_home* is the already-resolved XDG data base (e.g. ``~/.local/share``)
|
|
253
|
+
exposed to expressions as ``$XDG_DATA_HOME``; *home* expands a leading
|
|
254
|
+
``~``. Returns ``{full_dotted_key: Path}`` for every key in
|
|
255
|
+
:data:`SYSTEM_PATH_DEFAULTS`.
|
|
256
|
+
"""
|
|
257
|
+
ctx = ResolveCtx(
|
|
258
|
+
crab_name=None,
|
|
259
|
+
workset_name=None,
|
|
260
|
+
host_home=str(home),
|
|
261
|
+
xdg={"XDG_DATA_HOME": str(data_home)},
|
|
262
|
+
)
|
|
263
|
+
levels = [
|
|
264
|
+
LevelView("system", values=dict(set_values), defaults=SYSTEM_PATH_DEFAULTS)
|
|
265
|
+
]
|
|
266
|
+
|
|
267
|
+
def lookup(ref: str, chain: tuple[str, ...]) -> str:
|
|
268
|
+
rv = resolve_value(ref, levels=levels, ctx=ctx, lookup=lookup)
|
|
269
|
+
if isinstance(rv, _Unset):
|
|
270
|
+
raise SettingsError(f"Unknown @-reference: {ref}")
|
|
271
|
+
return expand_expr(
|
|
272
|
+
rv.value, space="host", ctx=ctx, lookup=lookup, chain=chain,
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
resolved: dict[str, Path] = {}
|
|
276
|
+
for key in SYSTEM_PATH_DEFAULTS:
|
|
277
|
+
rv = resolve_value(key, levels=levels, ctx=ctx, lookup=lookup)
|
|
278
|
+
if isinstance(rv, _Unset): # Unreachable: every key has a default.
|
|
279
|
+
raise SettingsError(f"Unresolvable system path: {key}")
|
|
280
|
+
expanded = expand_expr(rv.value, space="host", ctx=ctx, lookup=lookup)
|
|
281
|
+
resolved[key] = Path(expanded)
|
|
282
|
+
return resolved
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _migrate_global_env(config_home: Path, data_path: Path) -> None:
|
|
286
|
+
"""Move global env file from old config_home/kanibako/env to data_path/env."""
|
|
287
|
+
old = config_home / "kanibako" / "env"
|
|
288
|
+
new = data_path / "env"
|
|
289
|
+
if old.is_file() and not new.exists():
|
|
290
|
+
import shutil
|
|
291
|
+
data_path.mkdir(parents=True, exist_ok=True)
|
|
292
|
+
shutil.move(str(old), str(new))
|
|
293
|
+
import sys
|
|
294
|
+
print(f"Migrated: {old} → {new}", file=sys.stderr)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _migrate_settings_to_boxes(data_path: Path, boxes_path: Path) -> None:
|
|
298
|
+
"""Rename the legacy ``data_path/settings`` dir to the resolved boxes dir."""
|
|
299
|
+
old = data_path / "settings"
|
|
300
|
+
if old.is_dir() and not boxes_path.exists():
|
|
301
|
+
old.rename(boxes_path)
|
|
302
|
+
import sys
|
|
303
|
+
print(f"Migrated: {old} → {boxes_path}", file=sys.stderr)
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def load_std_paths(config: KanibakoConfig | None = None) -> StandardPaths:
|
|
307
|
+
"""Compute all standard kanibako directories.
|
|
308
|
+
|
|
309
|
+
If *config* is None, it is loaded from the config file (which must exist).
|
|
310
|
+
Directories are created as needed.
|
|
311
|
+
"""
|
|
312
|
+
config_home = xdg("XDG_CONFIG_HOME", ".config")
|
|
313
|
+
data_home = xdg("XDG_DATA_HOME", ".local/share")
|
|
314
|
+
state_home = xdg("XDG_STATE_HOME", ".local/state")
|
|
315
|
+
cache_home = xdg("XDG_CACHE_HOME", ".cache")
|
|
316
|
+
|
|
317
|
+
# Migrate config file from old subdir location if needed.
|
|
318
|
+
migrate_config(config_home)
|
|
319
|
+
config_file = config_file_path(config_home)
|
|
320
|
+
|
|
321
|
+
if config is None:
|
|
322
|
+
if not config_file.exists():
|
|
323
|
+
raise ConfigError(
|
|
324
|
+
f"{config_file} is missing. Run any kanibako command to initialize."
|
|
325
|
+
)
|
|
326
|
+
config = load_config(config_file)
|
|
327
|
+
|
|
328
|
+
# Resolve the system-level path tier (settings-framework "system.path.*").
|
|
329
|
+
resolved = resolve_system_paths(
|
|
330
|
+
config.system_paths, data_home=data_home, home=Path.home(),
|
|
331
|
+
)
|
|
332
|
+
data_path = resolved["system.path.data"]
|
|
333
|
+
# state/cache paths track the data dir's leaf name (unchanged behavior:
|
|
334
|
+
# default leaf "kanibako" under each XDG base).
|
|
335
|
+
rel = data_path.name
|
|
336
|
+
state_path = state_home / rel
|
|
337
|
+
cache_path = cache_home / rel
|
|
338
|
+
|
|
339
|
+
# Migrate settings/ -> boxes/ if needed.
|
|
340
|
+
_migrate_settings_to_boxes(data_path, resolved["system.path.boxes"])
|
|
341
|
+
|
|
342
|
+
# Migrate global env file from config_home/kanibako/env to data_path/env.
|
|
343
|
+
_migrate_global_env(config_home, data_path)
|
|
344
|
+
|
|
345
|
+
# Ensure directories exist.
|
|
346
|
+
config_file.parent.mkdir(parents=True, exist_ok=True)
|
|
347
|
+
data_path.mkdir(parents=True, exist_ok=True)
|
|
348
|
+
state_path.mkdir(parents=True, exist_ok=True)
|
|
349
|
+
cache_path.mkdir(parents=True, exist_ok=True)
|
|
350
|
+
|
|
351
|
+
return StandardPaths(
|
|
352
|
+
config_home=config_home,
|
|
353
|
+
data_home=data_home,
|
|
354
|
+
state_home=state_home,
|
|
355
|
+
cache_home=cache_home,
|
|
356
|
+
config_file=config_file,
|
|
357
|
+
data_path=data_path,
|
|
358
|
+
state_path=state_path,
|
|
359
|
+
cache_path=cache_path,
|
|
360
|
+
boxes=resolved["system.path.boxes"],
|
|
361
|
+
crabs=resolved["system.path.crabs"],
|
|
362
|
+
comms=resolved["system.path.comms"],
|
|
363
|
+
share_ro=resolved["system.path.share_ro"],
|
|
364
|
+
share_rw=resolved["system.path.share_rw"],
|
|
365
|
+
templates=resolved["system.path.templates"],
|
|
366
|
+
ws_hints=resolved["system.path.ws_hints"],
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def resolve_project(
|
|
371
|
+
std: StandardPaths,
|
|
372
|
+
config: KanibakoConfig,
|
|
373
|
+
project_dir: str | None = None,
|
|
374
|
+
*,
|
|
375
|
+
initialize: bool = False,
|
|
376
|
+
layout: ProjectLayout | None = None,
|
|
377
|
+
enable_vault: bool | None = None,
|
|
378
|
+
name_override: str | None = None,
|
|
379
|
+
) -> ProjectPaths:
|
|
380
|
+
"""Resolve (and optionally initialize) per-project paths.
|
|
381
|
+
|
|
382
|
+
When *initialize* is True (used by ``start``), missing project directories
|
|
383
|
+
are created and credential templates are copied in. When False (used by
|
|
384
|
+
subcommands like ``archive``/``purge``), the paths are merely computed.
|
|
385
|
+
|
|
386
|
+
*layout* overrides the default layout for new projects. Existing projects
|
|
387
|
+
read their layout from ``project.yaml``.
|
|
388
|
+
|
|
389
|
+
*enable_vault* controls whether vault directories are created and mounted.
|
|
390
|
+
Defaults to True for new projects; existing projects read from ``project.yaml``.
|
|
391
|
+
"""
|
|
392
|
+
raw = project_dir or os.getcwd()
|
|
393
|
+
# If the user passed a bare token (no path separator) and no file/dir of
|
|
394
|
+
# that name exists in cwd, try resolving it as a registered project name.
|
|
395
|
+
# Falls through to path resolution on miss so the eventual error stays
|
|
396
|
+
# informative.
|
|
397
|
+
if raw and "/" not in raw and not Path(raw).exists():
|
|
398
|
+
try:
|
|
399
|
+
resolved, kind = resolve_name(std.data_path, raw, cwd=Path.cwd())
|
|
400
|
+
if kind == "project":
|
|
401
|
+
raw = resolved
|
|
402
|
+
except ProjectError:
|
|
403
|
+
pass
|
|
404
|
+
project_path = Path(raw).resolve()
|
|
405
|
+
|
|
406
|
+
if not project_path.is_dir():
|
|
407
|
+
raise ProjectError(f"Project path '{project_path}' does not exist.")
|
|
408
|
+
|
|
409
|
+
phash = project_hash(str(project_path))
|
|
410
|
+
project_path_str = str(project_path)
|
|
411
|
+
|
|
412
|
+
# Determine the project directory: name-based (boxes/{name}/).
|
|
413
|
+
project_name, project_dir_path = _resolve_local_dir(
|
|
414
|
+
std.data_path, project_path_str, std.boxes,
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
metadata_path = project_dir_path
|
|
418
|
+
|
|
419
|
+
# Check for stored paths in project.yaml (enables user overrides).
|
|
420
|
+
project_toml = metadata_path / "project.yaml"
|
|
421
|
+
meta = read_project_meta(project_toml)
|
|
422
|
+
if meta:
|
|
423
|
+
actual_layout = ProjectLayout(meta["layout"]) if meta.get("layout") else _DEFAULT_LAYOUT[ProjectMode.default]
|
|
424
|
+
shell_path = Path(meta["shell"]) if meta["shell"] else metadata_path / "shell"
|
|
425
|
+
vault_ro_path = Path(meta["vault_ro"]) if meta["vault_ro"] else project_path / "vault" / "ro"
|
|
426
|
+
vault_rw_path = Path(meta["vault_rw"]) if meta["vault_rw"] else project_path / "vault" / "rw"
|
|
427
|
+
actual_vault_enabled = meta.get("enable_vault", True) if enable_vault is None else enable_vault
|
|
428
|
+
else:
|
|
429
|
+
actual_layout = layout or _DEFAULT_LAYOUT[ProjectMode.default]
|
|
430
|
+
shell_path, vault_ro_path, vault_rw_path = _compute_project_paths(
|
|
431
|
+
actual_layout, metadata_path, project_path,
|
|
432
|
+
vault_root=_local_vault_root(actual_layout, metadata_path, project_path),
|
|
433
|
+
)
|
|
434
|
+
actual_vault_enabled = enable_vault if enable_vault is not None else True
|
|
435
|
+
|
|
436
|
+
# Auth mode for the default group: the default workset's
|
|
437
|
+
# group_auth (from {data_path}/config.yaml) is the base; a project may
|
|
438
|
+
# narrow shared→distinct via its own meta — mirroring the named-workset
|
|
439
|
+
# logic in resolve_workset_project. No-op on upgrade: default_workset's
|
|
440
|
+
# group_auth is True until a user runs `workset config default group_auth`,
|
|
441
|
+
# and existing project meta froze group_auth=True at init.
|
|
442
|
+
from kanibako.workset import default_workset
|
|
443
|
+
actual_group_auth = default_workset(std).group_auth
|
|
444
|
+
if actual_group_auth and meta:
|
|
445
|
+
actual_group_auth = bool(meta.get("group_auth", True))
|
|
446
|
+
|
|
447
|
+
is_new = False
|
|
448
|
+
if initialize and not project_dir_path.is_dir():
|
|
449
|
+
# Guard: refuse to implicitly create a project rooted at $HOME.
|
|
450
|
+
if project_path == Path.home().resolve():
|
|
451
|
+
raise ProjectError(
|
|
452
|
+
"Refusing to create a project rooted at $HOME — this would "
|
|
453
|
+
"mount your entire home directory as the workspace.\n"
|
|
454
|
+
"If you really want a project here, use:\n"
|
|
455
|
+
" kanibako create --standalone ~ --allow-home"
|
|
456
|
+
)
|
|
457
|
+
# New project: assign a name first, then create boxes/{name}/.
|
|
458
|
+
# An explicit override (e.g. `kanibako create --name X`) registers
|
|
459
|
+
# strictly; collisions error rather than auto-suffix.
|
|
460
|
+
if name_override:
|
|
461
|
+
register_name(std.data_path, name_override, project_path_str)
|
|
462
|
+
project_name = name_override
|
|
463
|
+
else:
|
|
464
|
+
project_name = assign_name(std.data_path, project_path_str)
|
|
465
|
+
project_dir_path = std.boxes / project_name
|
|
466
|
+
metadata_path = project_dir_path
|
|
467
|
+
# Recompute paths with the name-based directory.
|
|
468
|
+
shell_path, vault_ro_path, vault_rw_path = _compute_project_paths(
|
|
469
|
+
actual_layout, metadata_path, project_path,
|
|
470
|
+
vault_root=_local_vault_root(actual_layout, metadata_path, project_path),
|
|
471
|
+
)
|
|
472
|
+
project_toml = metadata_path / "project.yaml"
|
|
473
|
+
|
|
474
|
+
_init_project(
|
|
475
|
+
std, metadata_path, shell_path,
|
|
476
|
+
vault_ro_path, vault_rw_path, project_path,
|
|
477
|
+
enable_vault=actual_vault_enabled,
|
|
478
|
+
)
|
|
479
|
+
_global_shared = std.data_path / config.paths_shared / "global"
|
|
480
|
+
_local_shared = std.data_path / config.paths_shared
|
|
481
|
+
write_project_meta(
|
|
482
|
+
project_toml,
|
|
483
|
+
mode="default",
|
|
484
|
+
layout=actual_layout.value,
|
|
485
|
+
workspace=str(project_path),
|
|
486
|
+
shell=str(shell_path),
|
|
487
|
+
vault_ro=str(vault_ro_path),
|
|
488
|
+
vault_rw=str(vault_rw_path),
|
|
489
|
+
enable_vault=actual_vault_enabled,
|
|
490
|
+
metadata=str(metadata_path),
|
|
491
|
+
project_hash=phash,
|
|
492
|
+
global_shared=str(_global_shared),
|
|
493
|
+
local_shared=str(_local_shared),
|
|
494
|
+
name=project_name,
|
|
495
|
+
)
|
|
496
|
+
import sys
|
|
497
|
+
print(f"Project name: {project_name}", file=sys.stderr)
|
|
498
|
+
is_new = True
|
|
499
|
+
|
|
500
|
+
if initialize:
|
|
501
|
+
# Recovery: ensure shell exists even if metadata_path was present.
|
|
502
|
+
if not shell_path.is_dir():
|
|
503
|
+
shell_path.mkdir(parents=True, exist_ok=True)
|
|
504
|
+
_bootstrap_shell(shell_path)
|
|
505
|
+
# Backfill project.yaml for old-format projects (pre-v0.8).
|
|
506
|
+
if metadata_path.is_dir() and read_project_meta(metadata_path / "project.yaml") is None:
|
|
507
|
+
_global_shared_bf = std.data_path / config.paths_shared / "global"
|
|
508
|
+
_local_shared_bf = std.data_path / config.paths_shared
|
|
509
|
+
# Use directory name as project name (name-based dirs).
|
|
510
|
+
_bf_name = metadata_path.name if not metadata_path.name.startswith(phash[:8]) else ""
|
|
511
|
+
write_project_meta(
|
|
512
|
+
metadata_path / "project.yaml",
|
|
513
|
+
mode="default",
|
|
514
|
+
layout=actual_layout.value,
|
|
515
|
+
workspace=str(project_path),
|
|
516
|
+
shell=str(shell_path),
|
|
517
|
+
vault_ro=str(vault_ro_path),
|
|
518
|
+
vault_rw=str(vault_rw_path),
|
|
519
|
+
enable_vault=actual_vault_enabled,
|
|
520
|
+
metadata=str(metadata_path),
|
|
521
|
+
project_hash=phash,
|
|
522
|
+
global_shared=str(_global_shared_bf),
|
|
523
|
+
local_shared=str(_local_shared_bf),
|
|
524
|
+
name=_bf_name,
|
|
525
|
+
)
|
|
526
|
+
# Convenience symlink when vault lives outside the workspace.
|
|
527
|
+
if actual_vault_enabled:
|
|
528
|
+
_ensure_vault_symlink(project_path, vault_ro_path)
|
|
529
|
+
# Human-friendly symlink for robust layout.
|
|
530
|
+
if actual_layout == ProjectLayout.robust:
|
|
531
|
+
human_vault_dir = std.data_path / config.paths_vault
|
|
532
|
+
_ensure_human_vault_symlink(
|
|
533
|
+
human_vault_dir, project_path, vault_ro_path.parent,
|
|
534
|
+
)
|
|
535
|
+
if is_new:
|
|
536
|
+
import sys
|
|
537
|
+
print(
|
|
538
|
+
f"\nNOTE: In robust layout, the default-workset vault "
|
|
539
|
+
f"is linked from\n{human_vault_dir}. You can create a "
|
|
540
|
+
f"symlink from your home directory with:\n"
|
|
541
|
+
f" ln -s {human_vault_dir} $HOME/kanibako_vault",
|
|
542
|
+
file=sys.stderr,
|
|
543
|
+
)
|
|
544
|
+
|
|
545
|
+
# Resolve shared paths: prefer stored values (enables user overrides).
|
|
546
|
+
_computed_global_shared = std.data_path / config.paths_shared / "global"
|
|
547
|
+
_computed_local_shared = std.data_path / config.paths_shared
|
|
548
|
+
if meta and meta.get("global_shared"):
|
|
549
|
+
_computed_global_shared = Path(meta["global_shared"])
|
|
550
|
+
if meta and meta.get("local_shared"):
|
|
551
|
+
_computed_local_shared = Path(meta["local_shared"])
|
|
552
|
+
|
|
553
|
+
return ProjectPaths(
|
|
554
|
+
project_path=project_path,
|
|
555
|
+
project_hash=phash,
|
|
556
|
+
metadata_path=metadata_path,
|
|
557
|
+
shell_path=shell_path,
|
|
558
|
+
vault_ro_path=vault_ro_path,
|
|
559
|
+
vault_rw_path=vault_rw_path,
|
|
560
|
+
is_new=is_new,
|
|
561
|
+
mode=ProjectMode.default,
|
|
562
|
+
layout=actual_layout,
|
|
563
|
+
enable_vault=actual_vault_enabled,
|
|
564
|
+
group_auth=actual_group_auth,
|
|
565
|
+
name=project_name,
|
|
566
|
+
global_shared_path=_computed_global_shared,
|
|
567
|
+
local_shared_path=_computed_local_shared,
|
|
568
|
+
group=ProjectGroup(
|
|
569
|
+
name="default",
|
|
570
|
+
root=std.data_path,
|
|
571
|
+
is_default=True,
|
|
572
|
+
local_shared_base=std.data_path,
|
|
573
|
+
),
|
|
574
|
+
)
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
def _resolve_local_dir(
|
|
578
|
+
data_path: Path,
|
|
579
|
+
project_path_str: str,
|
|
580
|
+
boxes_dir: Path,
|
|
581
|
+
) -> tuple[str, Path]:
|
|
582
|
+
"""Find the boxes directory for a default-mode project.
|
|
583
|
+
|
|
584
|
+
Looks up the project name via names.yaml reverse lookup and returns
|
|
585
|
+
``(project_name, boxes_dir/{name}/)`` path. *boxes_dir* is the resolved
|
|
586
|
+
``system.path.boxes`` directory (``std.boxes``); *data_path* is still
|
|
587
|
+
needed to read ``names.yaml``.
|
|
588
|
+
|
|
589
|
+
Returns ``("", empty_path)`` when no name is registered — the caller
|
|
590
|
+
(``resolve_project``) will assign a name during initialization.
|
|
591
|
+
"""
|
|
592
|
+
names = read_names(data_path)
|
|
593
|
+
# Reverse lookup: path → name.
|
|
594
|
+
for name, path in names["projects"].items():
|
|
595
|
+
if path == project_path_str:
|
|
596
|
+
return name, boxes_dir / name
|
|
597
|
+
|
|
598
|
+
return "", boxes_dir / "__unregistered__"
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
def _compute_project_paths(
|
|
603
|
+
layout: ProjectLayout, metadata_path: Path, project_path: Path,
|
|
604
|
+
*, vault_root: Path,
|
|
605
|
+
) -> tuple[Path, Path, Path]:
|
|
606
|
+
"""Compute ``(shell, vault_ro, vault_rw)`` for default and workset modes.
|
|
607
|
+
|
|
608
|
+
The only structural difference between default and workset is *where* the
|
|
609
|
+
vault lives in the non-``simple`` layouts; the caller expresses that by
|
|
610
|
+
passing ``vault_root`` — the parent directory under which ``ro`` and
|
|
611
|
+
``rw`` are placed. The ``simple`` layout always keeps shell and
|
|
612
|
+
vault inside the workspace and ignores *vault_root*.
|
|
613
|
+
|
|
614
|
+
Caller-supplied *vault_root* must reproduce the existing per-mode policy:
|
|
615
|
+
|
|
616
|
+
- **default**: ``default`` → ``project_path/"vault"``; ``robust`` →
|
|
617
|
+
``metadata_path/"vault"``.
|
|
618
|
+
- **workset**: ``default``/``robust`` → ``vault_base/project_name``.
|
|
619
|
+
"""
|
|
620
|
+
if layout == ProjectLayout.simple:
|
|
621
|
+
shell = project_path / ".shell"
|
|
622
|
+
vault_ro = project_path / "vault" / "ro"
|
|
623
|
+
vault_rw = project_path / "vault" / "rw"
|
|
624
|
+
else: # default / robust
|
|
625
|
+
shell = metadata_path / "shell"
|
|
626
|
+
vault_ro = vault_root / "ro"
|
|
627
|
+
vault_rw = vault_root / "rw"
|
|
628
|
+
return shell, vault_ro, vault_rw
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
def _local_vault_root(layout: ProjectLayout, metadata_path: Path, project_path: Path) -> Path:
|
|
632
|
+
"""Vault parent dir for default mode in the non-``simple`` layouts."""
|
|
633
|
+
if layout == ProjectLayout.robust:
|
|
634
|
+
return metadata_path / "vault"
|
|
635
|
+
return project_path / "vault" # default
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
def _compute_standalone_paths(
|
|
639
|
+
layout: ProjectLayout, metadata_path: Path, project_path: Path,
|
|
640
|
+
) -> tuple[Path, Path, Path]:
|
|
641
|
+
"""Compute (shell, vault_ro, vault_rw) for standalone mode."""
|
|
642
|
+
if layout == ProjectLayout.robust:
|
|
643
|
+
shell = project_path / "shell"
|
|
644
|
+
vault_ro = project_path / "vault" / "ro"
|
|
645
|
+
vault_rw = project_path / "vault" / "rw"
|
|
646
|
+
else: # simple (default for standalone)
|
|
647
|
+
shell = metadata_path / "shell"
|
|
648
|
+
vault_ro = project_path / "vault" / "ro"
|
|
649
|
+
vault_rw = project_path / "vault" / "rw"
|
|
650
|
+
return shell, vault_ro, vault_rw
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
_SHELL_D_SOURCE_LINE = 'for _f in ~/.shell.d/*.sh; do [ -r "$_f" ] && . "$_f"; done\nunset _f'
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
def _bootstrap_shell(shell_path: Path) -> None:
|
|
657
|
+
"""Write minimal shell skeleton files into a new shell directory."""
|
|
658
|
+
bashrc = shell_path / ".bashrc"
|
|
659
|
+
if not bashrc.exists():
|
|
660
|
+
bashrc.write_text(
|
|
661
|
+
"# kanibako shell environment\n"
|
|
662
|
+
"[ -f /etc/bashrc ] && . /etc/bashrc\n"
|
|
663
|
+
'export PS1="${KANIBAKO_PS1:-(kanibako) \\u@\\h:\\w\\$ }"\n'
|
|
664
|
+
"# Source user init scripts\n"
|
|
665
|
+
f"{_SHELL_D_SOURCE_LINE}\n"
|
|
666
|
+
)
|
|
667
|
+
profile = shell_path / ".profile"
|
|
668
|
+
if not profile.exists():
|
|
669
|
+
profile.write_text(
|
|
670
|
+
"# kanibako login profile\n"
|
|
671
|
+
"[ -f ~/.bashrc ] && . ~/.bashrc\n"
|
|
672
|
+
)
|
|
673
|
+
# Create shell.d drop-in directory.
|
|
674
|
+
shell_d = shell_path / ".shell.d"
|
|
675
|
+
shell_d.mkdir(exist_ok=True)
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
def _upgrade_shell(shell_path: Path) -> None:
|
|
679
|
+
"""Patch an existing shell directory to add shell.d support.
|
|
680
|
+
|
|
681
|
+
Idempotent — safe to call every launch. Creates ``.shell.d/`` if missing
|
|
682
|
+
and appends the source line to ``.bashrc`` if absent. No-op if
|
|
683
|
+
*shell_path* does not exist yet.
|
|
684
|
+
"""
|
|
685
|
+
if not shell_path.is_dir():
|
|
686
|
+
return
|
|
687
|
+
shell_d = shell_path / ".shell.d"
|
|
688
|
+
shell_d.mkdir(exist_ok=True)
|
|
689
|
+
|
|
690
|
+
bashrc = shell_path / ".bashrc"
|
|
691
|
+
if not bashrc.is_file():
|
|
692
|
+
return
|
|
693
|
+
content = bashrc.read_text()
|
|
694
|
+
if ".shell.d/" in content:
|
|
695
|
+
return
|
|
696
|
+
# Append source line.
|
|
697
|
+
if content and not content.endswith("\n"):
|
|
698
|
+
content += "\n"
|
|
699
|
+
content += "# Source user init scripts\n"
|
|
700
|
+
content += f"{_SHELL_D_SOURCE_LINE}\n"
|
|
701
|
+
bashrc.write_text(content)
|
|
702
|
+
|
|
703
|
+
|
|
704
|
+
def _ensure_vault_symlink(project_path: Path, vault_ro_path: Path) -> None:
|
|
705
|
+
"""Create a convenience symlink from project_path/vault when vault lives elsewhere.
|
|
706
|
+
|
|
707
|
+
In local tree and WS default/tree layouts, vault dirs are stored outside the
|
|
708
|
+
project workspace. The symlink lets the user discover vault via their
|
|
709
|
+
project directory. No-op when vault is already under project_path or the
|
|
710
|
+
symlink target already matches.
|
|
711
|
+
"""
|
|
712
|
+
vault_parent = vault_ro_path.parent # e.g. metadata_path/vault or vault_base/name
|
|
713
|
+
link = project_path / "vault"
|
|
714
|
+
|
|
715
|
+
# Vault already lives under project_path — no symlink needed.
|
|
716
|
+
try:
|
|
717
|
+
if vault_parent.resolve() == link.resolve():
|
|
718
|
+
return
|
|
719
|
+
except OSError:
|
|
720
|
+
pass
|
|
721
|
+
|
|
722
|
+
if link.is_symlink():
|
|
723
|
+
# Symlink exists — update only if target differs.
|
|
724
|
+
if link.resolve() == vault_parent.resolve():
|
|
725
|
+
return
|
|
726
|
+
link.unlink()
|
|
727
|
+
elif link.exists():
|
|
728
|
+
# A real directory or file exists — don't overwrite.
|
|
729
|
+
return
|
|
730
|
+
|
|
731
|
+
try:
|
|
732
|
+
link.symlink_to(vault_parent)
|
|
733
|
+
except OSError:
|
|
734
|
+
pass # Best-effort; non-fatal if we can't create the symlink.
|
|
735
|
+
|
|
736
|
+
|
|
737
|
+
def _ensure_human_vault_symlink(
|
|
738
|
+
vault_dir: Path, project_path: Path, vault_parent: Path,
|
|
739
|
+
) -> Path | None:
|
|
740
|
+
"""Create a human-friendly symlink ``{vault_dir}/{basename}`` → *vault_parent*.
|
|
741
|
+
|
|
742
|
+
*vault_dir* is e.g. ``{data_path}/vault``. *project_path* is the user's
|
|
743
|
+
workspace directory whose basename is used as the symlink name.
|
|
744
|
+
*vault_parent* is the hash-based vault directory (``…/boxes/{hash}/vault``).
|
|
745
|
+
|
|
746
|
+
Collision handling: if *basename* already points elsewhere, tries
|
|
747
|
+
``{name}1``, ``{name}2``, … up to ``{name}99``.
|
|
748
|
+
|
|
749
|
+
Returns the created/existing symlink ``Path`` on success, ``None`` on
|
|
750
|
+
failure or if *vault_parent* does not exist.
|
|
751
|
+
"""
|
|
752
|
+
if not vault_parent.is_dir():
|
|
753
|
+
return None
|
|
754
|
+
|
|
755
|
+
vault_dir.mkdir(parents=True, exist_ok=True)
|
|
756
|
+
basename = project_path.name
|
|
757
|
+
|
|
758
|
+
# Try the plain name first, then name1..name99.
|
|
759
|
+
candidates = [basename] + [f"{basename}{i}" for i in range(1, 100)]
|
|
760
|
+
for name in candidates:
|
|
761
|
+
link = vault_dir / name
|
|
762
|
+
if link.is_symlink():
|
|
763
|
+
try:
|
|
764
|
+
if link.resolve() == vault_parent.resolve():
|
|
765
|
+
return link # Already correct — idempotent.
|
|
766
|
+
except OSError:
|
|
767
|
+
pass
|
|
768
|
+
continue # Points elsewhere — try next candidate.
|
|
769
|
+
if link.exists():
|
|
770
|
+
continue # Real file/dir — skip.
|
|
771
|
+
# Slot is free.
|
|
772
|
+
try:
|
|
773
|
+
link.symlink_to(vault_parent)
|
|
774
|
+
return link
|
|
775
|
+
except OSError:
|
|
776
|
+
return None # Best-effort.
|
|
777
|
+
return None # All 100 candidates exhausted.
|
|
778
|
+
|
|
779
|
+
|
|
780
|
+
def _remove_human_vault_symlink(vault_dir: Path, vault_parent: Path) -> bool:
|
|
781
|
+
"""Remove the human-friendly symlink that points to *vault_parent*.
|
|
782
|
+
|
|
783
|
+
Scans *vault_dir* for the first symlink whose target resolves to
|
|
784
|
+
*vault_parent* and removes it. Removes *vault_dir* itself if empty
|
|
785
|
+
afterwards.
|
|
786
|
+
|
|
787
|
+
Returns True if a symlink was removed, False otherwise.
|
|
788
|
+
"""
|
|
789
|
+
if not vault_dir.is_dir():
|
|
790
|
+
return False
|
|
791
|
+
try:
|
|
792
|
+
for entry in vault_dir.iterdir():
|
|
793
|
+
if entry.is_symlink():
|
|
794
|
+
try:
|
|
795
|
+
if entry.resolve() == vault_parent.resolve():
|
|
796
|
+
entry.unlink()
|
|
797
|
+
# Clean up empty vault_dir.
|
|
798
|
+
if not any(vault_dir.iterdir()):
|
|
799
|
+
vault_dir.rmdir()
|
|
800
|
+
return True
|
|
801
|
+
except OSError:
|
|
802
|
+
continue
|
|
803
|
+
except OSError:
|
|
804
|
+
pass
|
|
805
|
+
return False
|
|
806
|
+
|
|
807
|
+
|
|
808
|
+
def _remove_project_vault_symlink(project_path: Path) -> bool:
|
|
809
|
+
"""Remove ``{project_path}/vault`` if it is a symlink (not a real dir).
|
|
810
|
+
|
|
811
|
+
Returns True if a symlink was removed, False otherwise.
|
|
812
|
+
"""
|
|
813
|
+
link = project_path / "vault"
|
|
814
|
+
if link.is_symlink():
|
|
815
|
+
try:
|
|
816
|
+
link.unlink()
|
|
817
|
+
return True
|
|
818
|
+
except OSError:
|
|
819
|
+
pass
|
|
820
|
+
return False
|
|
821
|
+
|
|
822
|
+
|
|
823
|
+
def _init_common(
|
|
824
|
+
std: StandardPaths,
|
|
825
|
+
metadata_path: Path,
|
|
826
|
+
shell_path: Path,
|
|
827
|
+
vault_ro_path: Path,
|
|
828
|
+
vault_rw_path: Path,
|
|
829
|
+
project_path: Path,
|
|
830
|
+
*,
|
|
831
|
+
enable_vault: bool = True,
|
|
832
|
+
) -> None:
|
|
833
|
+
"""Shared first-time project setup: create directories, bootstrap shell.
|
|
834
|
+
|
|
835
|
+
This helper is called by both ``_init_project`` (default) and
|
|
836
|
+
``_init_standalone_project``. It performs every step common to both
|
|
837
|
+
modes: print message, create metadata and shell dirs, bootstrap the
|
|
838
|
+
shell, and set up vault directories when enabled.
|
|
839
|
+
|
|
840
|
+
Credential copy is handled separately by ``target.init_home()`` in
|
|
841
|
+
``start.py``, after template application.
|
|
842
|
+
"""
|
|
843
|
+
import sys
|
|
844
|
+
|
|
845
|
+
print(
|
|
846
|
+
f"[One Time Setup] Initializing kanibako in {project_path}... ",
|
|
847
|
+
end="",
|
|
848
|
+
flush=True,
|
|
849
|
+
file=sys.stderr,
|
|
850
|
+
)
|
|
851
|
+
metadata_path.mkdir(parents=True, exist_ok=True)
|
|
852
|
+
|
|
853
|
+
# Create persistent agent shell (mounted as /home/agent).
|
|
854
|
+
shell_path.mkdir(parents=True, exist_ok=True)
|
|
855
|
+
_bootstrap_shell(shell_path)
|
|
856
|
+
|
|
857
|
+
# Vault directories (skip when vault is disabled).
|
|
858
|
+
if enable_vault:
|
|
859
|
+
vault_ro_path.mkdir(parents=True, exist_ok=True)
|
|
860
|
+
vault_rw_path.mkdir(parents=True, exist_ok=True)
|
|
861
|
+
# .gitignore in vault/ to exclude rw from version control.
|
|
862
|
+
vault_dir = vault_ro_path.parent
|
|
863
|
+
gitignore = vault_dir / ".gitignore"
|
|
864
|
+
if not gitignore.exists():
|
|
865
|
+
gitignore.write_text("rw/\n")
|
|
866
|
+
|
|
867
|
+
print("done.", file=sys.stderr)
|
|
868
|
+
|
|
869
|
+
|
|
870
|
+
def _init_project(
|
|
871
|
+
std: StandardPaths,
|
|
872
|
+
metadata_path: Path,
|
|
873
|
+
shell_path: Path,
|
|
874
|
+
vault_ro_path: Path,
|
|
875
|
+
vault_rw_path: Path,
|
|
876
|
+
project_path: Path,
|
|
877
|
+
*,
|
|
878
|
+
enable_vault: bool = True,
|
|
879
|
+
) -> None:
|
|
880
|
+
"""First-time project setup: create directories, copy credentials from host."""
|
|
881
|
+
_init_common(
|
|
882
|
+
std, metadata_path, shell_path,
|
|
883
|
+
vault_ro_path, vault_rw_path, project_path,
|
|
884
|
+
enable_vault=enable_vault,
|
|
885
|
+
)
|
|
886
|
+
|
|
887
|
+
|
|
888
|
+
|
|
889
|
+
def _find_local_ancestor(target: Path, data_path: Path, boxes_dir: Path) -> Path | None:
|
|
890
|
+
"""Find the deepest registered default-mode project that is an ancestor of *target*.
|
|
891
|
+
|
|
892
|
+
Reads ``names.yaml`` and, for each entry whose registered path is a
|
|
893
|
+
prefix of *target*, checks that ``boxes_dir/{name}/`` actually exists on
|
|
894
|
+
disk. Among all valid matches, the deepest (most path components)
|
|
895
|
+
wins. Returns the matched path or ``None``. *boxes_dir* is the resolved
|
|
896
|
+
``system.path.boxes`` directory (``std.boxes``).
|
|
897
|
+
"""
|
|
898
|
+
names = read_names(data_path)
|
|
899
|
+
best: Path | None = None
|
|
900
|
+
best_depth = -1
|
|
901
|
+
for name, path_str in names["projects"].items():
|
|
902
|
+
registered = Path(path_str)
|
|
903
|
+
try:
|
|
904
|
+
target.relative_to(registered)
|
|
905
|
+
except ValueError:
|
|
906
|
+
continue
|
|
907
|
+
# Only accept if boxes_dir/{name}/ exists on disk.
|
|
908
|
+
if not (boxes_dir / name).is_dir():
|
|
909
|
+
continue
|
|
910
|
+
depth = len(registered.parts)
|
|
911
|
+
if depth > best_depth:
|
|
912
|
+
best = registered
|
|
913
|
+
best_depth = depth
|
|
914
|
+
return best
|
|
915
|
+
|
|
916
|
+
|
|
917
|
+
def _is_standalone_meta_dir(meta_dir: Path) -> bool:
|
|
918
|
+
"""True only if *meta_dir* is a real standalone project metadata directory.
|
|
919
|
+
|
|
920
|
+
A bare directory named ``.kanibako``/``kanibako`` is NOT sufficient: the
|
|
921
|
+
kanibako container image bakes an empty ``~/.kanibako`` runtime/IPC dir into
|
|
922
|
+
every container home (helper socket + log), which must never be mistaken for
|
|
923
|
+
a standalone project marker. Require a parseable ``project.yaml`` that
|
|
924
|
+
declares ``mode = "standalone"``.
|
|
925
|
+
"""
|
|
926
|
+
toml = meta_dir / "project.yaml"
|
|
927
|
+
if not meta_dir.is_dir() or not toml.is_file():
|
|
928
|
+
return False
|
|
929
|
+
try:
|
|
930
|
+
meta = read_project_meta(toml)
|
|
931
|
+
except (OSError, ValueError, yaml.YAMLError): # malformed/unreadable file
|
|
932
|
+
return False
|
|
933
|
+
return bool(meta and meta.get("mode") == "standalone")
|
|
934
|
+
|
|
935
|
+
|
|
936
|
+
def detect_project_mode(
|
|
937
|
+
project_dir: Path,
|
|
938
|
+
std: StandardPaths,
|
|
939
|
+
config: KanibakoConfig,
|
|
940
|
+
) -> DetectionResult:
|
|
941
|
+
"""Infer which project mode applies to *project_dir*.
|
|
942
|
+
|
|
943
|
+
Walks ancestor directories (up to ``$HOME`` or filesystem root) looking
|
|
944
|
+
for project markers. Returns a ``DetectionResult`` with the detected
|
|
945
|
+
mode and the ancestor directory where the marker was found.
|
|
946
|
+
|
|
947
|
+
Detection order:
|
|
948
|
+
1. Workset — *project_dir* lives inside a registered workset root
|
|
949
|
+
(``workspaces/`` subdirectory first, then the root itself).
|
|
950
|
+
2. Default (name-based) — one-pass scan of ``names.yaml``;
|
|
951
|
+
deepest registered path that is an ancestor of *project_dir* wins.
|
|
952
|
+
Requires ``boxes/{name}/`` to exist on disk.
|
|
953
|
+
3. Walk ancestors for standalone markers — a ``.kanibako`` or
|
|
954
|
+
``kanibako`` **directory** exists inside the ancestor.
|
|
955
|
+
``.kanibako`` takes priority.
|
|
956
|
+
4. Default — ``default`` mode at the original *project_dir*.
|
|
957
|
+
"""
|
|
958
|
+
resolved = project_dir.resolve()
|
|
959
|
+
home = Path.home().resolve()
|
|
960
|
+
|
|
961
|
+
# 1. Workset check (no walk needed — relative_to handles subdirs).
|
|
962
|
+
ws_result = _check_workset(resolved, std)
|
|
963
|
+
if ws_result is not None:
|
|
964
|
+
return ws_result
|
|
965
|
+
|
|
966
|
+
# 2. Name-based default-mode check (one-pass scan, deepest match wins).
|
|
967
|
+
ac_ancestor = _find_local_ancestor(resolved, std.data_path, std.boxes)
|
|
968
|
+
if ac_ancestor is not None:
|
|
969
|
+
return DetectionResult(ProjectMode.default, ac_ancestor)
|
|
970
|
+
|
|
971
|
+
# 3. Walk ancestors for standalone markers.
|
|
972
|
+
current = resolved
|
|
973
|
+
while True:
|
|
974
|
+
# Standalone check: .kanibako/ or kanibako/ directory with a real
|
|
975
|
+
# standalone project.yaml. A bare directory is not enough (the
|
|
976
|
+
# container image bakes an empty ~/.kanibako runtime/IPC dir).
|
|
977
|
+
if _is_standalone_meta_dir(current / ".kanibako"):
|
|
978
|
+
return DetectionResult(ProjectMode.standalone, current)
|
|
979
|
+
if _is_standalone_meta_dir(current / "kanibako"):
|
|
980
|
+
return DetectionResult(ProjectMode.standalone, current)
|
|
981
|
+
|
|
982
|
+
# Stop conditions: reached $HOME or filesystem root.
|
|
983
|
+
if current == home:
|
|
984
|
+
break
|
|
985
|
+
parent = current.parent
|
|
986
|
+
if parent == current:
|
|
987
|
+
break
|
|
988
|
+
current = parent
|
|
989
|
+
|
|
990
|
+
# 4. Default: default mode at the original directory.
|
|
991
|
+
return DetectionResult(ProjectMode.default, resolved)
|
|
992
|
+
|
|
993
|
+
|
|
994
|
+
def _check_workset(
|
|
995
|
+
resolved_dir: Path,
|
|
996
|
+
std: StandardPaths,
|
|
997
|
+
) -> DetectionResult | None:
|
|
998
|
+
"""Check whether *resolved_dir* is inside a registered workset.
|
|
999
|
+
|
|
1000
|
+
Returns a ``DetectionResult`` if found, ``None`` otherwise.
|
|
1001
|
+
Checks ``workspaces/`` first (specific project), then the workset root
|
|
1002
|
+
itself (inside workset but not necessarily a project workspace).
|
|
1003
|
+
"""
|
|
1004
|
+
worksets_toml = std.ws_hints
|
|
1005
|
+
if not worksets_toml.is_file():
|
|
1006
|
+
return None
|
|
1007
|
+
|
|
1008
|
+
_data = load_doc(worksets_toml)
|
|
1009
|
+
|
|
1010
|
+
for _root_str in _data.get("worksets", {}).values():
|
|
1011
|
+
ws_root = Path(_root_str).resolve()
|
|
1012
|
+
ws_workspaces = ws_root / "workspaces"
|
|
1013
|
+
# Check workspaces/ first (more specific).
|
|
1014
|
+
try:
|
|
1015
|
+
resolved_dir.relative_to(ws_workspaces)
|
|
1016
|
+
return DetectionResult(ProjectMode.workset, resolved_dir)
|
|
1017
|
+
except ValueError:
|
|
1018
|
+
pass
|
|
1019
|
+
# Then check workset root itself.
|
|
1020
|
+
try:
|
|
1021
|
+
resolved_dir.relative_to(ws_root)
|
|
1022
|
+
return DetectionResult(ProjectMode.workset, resolved_dir)
|
|
1023
|
+
except ValueError:
|
|
1024
|
+
continue
|
|
1025
|
+
|
|
1026
|
+
return None
|
|
1027
|
+
|
|
1028
|
+
|
|
1029
|
+
def resolve_workset_project(
|
|
1030
|
+
ws: WorksetSpec,
|
|
1031
|
+
project_name: str,
|
|
1032
|
+
std: StandardPaths,
|
|
1033
|
+
config: KanibakoConfig,
|
|
1034
|
+
*,
|
|
1035
|
+
initialize: bool = False,
|
|
1036
|
+
layout: ProjectLayout | None = None,
|
|
1037
|
+
enable_vault: bool | None = None,
|
|
1038
|
+
) -> ProjectPaths:
|
|
1039
|
+
"""Resolve per-project paths for a project inside a workset.
|
|
1040
|
+
|
|
1041
|
+
*ws* is a lightweight :class:`WorksetSpec` describing the workset's name,
|
|
1042
|
+
root, directory layout, auth mode, and registered project names. Callers
|
|
1043
|
+
holding a full ``Workset`` object pass ``WorksetSpec.from_workset(ws)``.
|
|
1044
|
+
|
|
1045
|
+
Raises ``WorksetError`` if *project_name* is not registered in *ws*.
|
|
1046
|
+
"""
|
|
1047
|
+
# Look up project in workset.
|
|
1048
|
+
if project_name not in ws.project_names:
|
|
1049
|
+
raise WorksetError(
|
|
1050
|
+
f"Project '{project_name}' not found in workset '{ws.name}'."
|
|
1051
|
+
)
|
|
1052
|
+
|
|
1053
|
+
# Name-based paths (not hash-based).
|
|
1054
|
+
project_path = ws.workspaces_dir / project_name
|
|
1055
|
+
project_dir = ws.projects_dir / project_name
|
|
1056
|
+
metadata_path = project_dir
|
|
1057
|
+
|
|
1058
|
+
# Check for stored paths in project.yaml (enables user overrides).
|
|
1059
|
+
project_toml = metadata_path / "project.yaml"
|
|
1060
|
+
meta = read_project_meta(project_toml)
|
|
1061
|
+
if meta:
|
|
1062
|
+
actual_layout = ProjectLayout(meta["layout"]) if meta.get("layout") else _DEFAULT_LAYOUT[ProjectMode.workset]
|
|
1063
|
+
shell_path = Path(meta["shell"]) if meta["shell"] else project_dir / "shell"
|
|
1064
|
+
vault_ro_path = Path(meta["vault_ro"]) if meta["vault_ro"] else ws.vault_dir / project_name / "ro"
|
|
1065
|
+
vault_rw_path = Path(meta["vault_rw"]) if meta["vault_rw"] else ws.vault_dir / project_name / "rw"
|
|
1066
|
+
actual_vault_enabled = meta.get("enable_vault", True) if enable_vault is None else enable_vault
|
|
1067
|
+
else:
|
|
1068
|
+
actual_layout = layout or _DEFAULT_LAYOUT[ProjectMode.workset]
|
|
1069
|
+
shell_path, vault_ro_path, vault_rw_path = _compute_project_paths(
|
|
1070
|
+
actual_layout, metadata_path, project_path,
|
|
1071
|
+
vault_root=ws.vault_dir / project_name,
|
|
1072
|
+
)
|
|
1073
|
+
actual_vault_enabled = enable_vault if enable_vault is not None else True
|
|
1074
|
+
|
|
1075
|
+
# Auth mode: workset-level overrides project-level.
|
|
1076
|
+
actual_group_auth = ws.group_auth
|
|
1077
|
+
if actual_group_auth and meta:
|
|
1078
|
+
actual_group_auth = bool(meta.get("group_auth", True))
|
|
1079
|
+
|
|
1080
|
+
# Hash the resolved workspace path for container naming.
|
|
1081
|
+
phash = project_hash(str(project_path.resolve()))
|
|
1082
|
+
|
|
1083
|
+
is_new = False
|
|
1084
|
+
if initialize and not shell_path.is_dir():
|
|
1085
|
+
_init_workset_project(std, metadata_path, shell_path)
|
|
1086
|
+
_ws_global_shared = std.data_path / config.paths_shared / "global"
|
|
1087
|
+
_ws_local_shared = ws.root / config.paths_shared
|
|
1088
|
+
write_project_meta(
|
|
1089
|
+
project_toml,
|
|
1090
|
+
mode="workset",
|
|
1091
|
+
layout=actual_layout.value,
|
|
1092
|
+
workspace=str(project_path),
|
|
1093
|
+
shell=str(shell_path),
|
|
1094
|
+
vault_ro=str(vault_ro_path),
|
|
1095
|
+
vault_rw=str(vault_rw_path),
|
|
1096
|
+
enable_vault=actual_vault_enabled,
|
|
1097
|
+
group_auth=actual_group_auth,
|
|
1098
|
+
metadata=str(metadata_path),
|
|
1099
|
+
project_hash=phash,
|
|
1100
|
+
global_shared=str(_ws_global_shared),
|
|
1101
|
+
local_shared=str(_ws_local_shared),
|
|
1102
|
+
)
|
|
1103
|
+
is_new = True
|
|
1104
|
+
|
|
1105
|
+
if initialize:
|
|
1106
|
+
# Recovery: ensure shell exists.
|
|
1107
|
+
if not shell_path.is_dir():
|
|
1108
|
+
shell_path.mkdir(parents=True, exist_ok=True)
|
|
1109
|
+
_bootstrap_shell(shell_path)
|
|
1110
|
+
# Convenience symlink when vault lives outside the workspace.
|
|
1111
|
+
if actual_vault_enabled:
|
|
1112
|
+
_ensure_vault_symlink(project_path, vault_ro_path)
|
|
1113
|
+
|
|
1114
|
+
# Resolve shared paths: prefer stored values (enables user overrides).
|
|
1115
|
+
_ws_computed_global = std.data_path / config.paths_shared / "global"
|
|
1116
|
+
_ws_computed_local = ws.root / config.paths_shared
|
|
1117
|
+
if meta and meta.get("global_shared"):
|
|
1118
|
+
_ws_computed_global = Path(meta["global_shared"])
|
|
1119
|
+
if meta and meta.get("local_shared"):
|
|
1120
|
+
_ws_computed_local = Path(meta["local_shared"])
|
|
1121
|
+
|
|
1122
|
+
return ProjectPaths(
|
|
1123
|
+
project_path=project_path,
|
|
1124
|
+
project_hash=phash,
|
|
1125
|
+
metadata_path=metadata_path,
|
|
1126
|
+
shell_path=shell_path,
|
|
1127
|
+
vault_ro_path=vault_ro_path,
|
|
1128
|
+
vault_rw_path=vault_rw_path,
|
|
1129
|
+
is_new=is_new,
|
|
1130
|
+
mode=ProjectMode.workset,
|
|
1131
|
+
layout=actual_layout,
|
|
1132
|
+
enable_vault=actual_vault_enabled,
|
|
1133
|
+
group_auth=actual_group_auth,
|
|
1134
|
+
name=project_name,
|
|
1135
|
+
global_shared_path=_ws_computed_global,
|
|
1136
|
+
local_shared_path=_ws_computed_local,
|
|
1137
|
+
group=ProjectGroup(
|
|
1138
|
+
name=ws.name,
|
|
1139
|
+
root=ws.root,
|
|
1140
|
+
is_default=False,
|
|
1141
|
+
local_shared_base=ws.root,
|
|
1142
|
+
),
|
|
1143
|
+
)
|
|
1144
|
+
|
|
1145
|
+
|
|
1146
|
+
def _init_workset_project(
|
|
1147
|
+
std: StandardPaths,
|
|
1148
|
+
metadata_path: Path,
|
|
1149
|
+
shell_path: Path,
|
|
1150
|
+
) -> None:
|
|
1151
|
+
"""First-time workset project setup: bootstrap shell directory.
|
|
1152
|
+
|
|
1153
|
+
Does not create vault ``.gitignore`` files (vault lives under the workset
|
|
1154
|
+
root, not inside a user git repo).
|
|
1155
|
+
|
|
1156
|
+
Credential copy is handled separately by ``target.init_home()`` in
|
|
1157
|
+
``start.py``, after template application.
|
|
1158
|
+
"""
|
|
1159
|
+
import sys
|
|
1160
|
+
|
|
1161
|
+
print(
|
|
1162
|
+
f"[One Time Setup] Initializing workset project in {metadata_path}... ",
|
|
1163
|
+
end="",
|
|
1164
|
+
flush=True,
|
|
1165
|
+
file=sys.stderr,
|
|
1166
|
+
)
|
|
1167
|
+
metadata_path.mkdir(parents=True, exist_ok=True)
|
|
1168
|
+
|
|
1169
|
+
# Create persistent agent shell (mounted as /home/agent).
|
|
1170
|
+
shell_path.mkdir(parents=True, exist_ok=True)
|
|
1171
|
+
_bootstrap_shell(shell_path)
|
|
1172
|
+
|
|
1173
|
+
print("done.", file=sys.stderr)
|
|
1174
|
+
|
|
1175
|
+
|
|
1176
|
+
def iter_projects(std: StandardPaths, config: KanibakoConfig) -> list[tuple[Path, Path | None]]:
|
|
1177
|
+
"""Return ``(metadata_path, project_path | None)`` for every known project.
|
|
1178
|
+
|
|
1179
|
+
*project_path* is read from ``project.yaml`` (``workspace`` field) when
|
|
1180
|
+
available, falling back to ``project-path.txt`` for backward compat.
|
|
1181
|
+
"""
|
|
1182
|
+
projects_dir = std.boxes
|
|
1183
|
+
if not projects_dir.is_dir():
|
|
1184
|
+
return []
|
|
1185
|
+
results: list[tuple[Path, Path | None]] = []
|
|
1186
|
+
for entry in sorted(projects_dir.iterdir()):
|
|
1187
|
+
if not entry.is_dir():
|
|
1188
|
+
continue
|
|
1189
|
+
project_path: Path | None = None
|
|
1190
|
+
# Prefer project.yaml workspace field.
|
|
1191
|
+
meta = read_project_meta(entry / "project.yaml")
|
|
1192
|
+
if meta and meta.get("workspace"):
|
|
1193
|
+
project_path = Path(meta["workspace"])
|
|
1194
|
+
else:
|
|
1195
|
+
# Backward compat: fall back to breadcrumb file.
|
|
1196
|
+
breadcrumb = entry / "project-path.txt"
|
|
1197
|
+
if breadcrumb.is_file():
|
|
1198
|
+
text = breadcrumb.read_text().strip()
|
|
1199
|
+
if text:
|
|
1200
|
+
project_path = Path(text)
|
|
1201
|
+
results.append((entry, project_path))
|
|
1202
|
+
return results
|
|
1203
|
+
|
|
1204
|
+
|
|
1205
|
+
def iter_workset_projects(
|
|
1206
|
+
std: StandardPaths,
|
|
1207
|
+
config: KanibakoConfig,
|
|
1208
|
+
) -> list[tuple[str, _WorksetLike, list[tuple[str, str]]]]:
|
|
1209
|
+
"""Return workset project info for all registered worksets.
|
|
1210
|
+
|
|
1211
|
+
Each entry is ``(workset_name, workset, [(project_name, status), ...])``.
|
|
1212
|
+
The workset object is a concrete ``kanibako.workset.Workset`` typed
|
|
1213
|
+
structurally as :class:`_WorksetLike` (so ``paths.py`` need not import
|
|
1214
|
+
``workset``). Status is ``"ok"``, ``"missing"`` (no workspace), or
|
|
1215
|
+
``"no-data"`` (no project dir).
|
|
1216
|
+
"""
|
|
1217
|
+
import sys
|
|
1218
|
+
|
|
1219
|
+
from kanibako.workset import list_worksets, load_workset
|
|
1220
|
+
|
|
1221
|
+
registry = list_worksets(std)
|
|
1222
|
+
results: list[tuple[str, _WorksetLike, list[tuple[str, str]]]] = []
|
|
1223
|
+
|
|
1224
|
+
for ws_name in sorted(registry):
|
|
1225
|
+
root = registry[ws_name]
|
|
1226
|
+
if not root.is_dir():
|
|
1227
|
+
print(
|
|
1228
|
+
f"Warning: workset '{ws_name}' root missing: {root}",
|
|
1229
|
+
file=sys.stderr,
|
|
1230
|
+
)
|
|
1231
|
+
continue
|
|
1232
|
+
try:
|
|
1233
|
+
ws = load_workset(root)
|
|
1234
|
+
except Exception as exc:
|
|
1235
|
+
print(
|
|
1236
|
+
f"Warning: failed to load workset '{ws_name}': {exc}",
|
|
1237
|
+
file=sys.stderr,
|
|
1238
|
+
)
|
|
1239
|
+
continue
|
|
1240
|
+
|
|
1241
|
+
project_list: list[tuple[str, str]] = []
|
|
1242
|
+
for proj in ws.projects:
|
|
1243
|
+
has_project_dir = (ws.projects_dir / proj.name).is_dir()
|
|
1244
|
+
has_workspace = (ws.workspaces_dir / proj.name).is_dir()
|
|
1245
|
+
if has_project_dir and has_workspace:
|
|
1246
|
+
status = "ok"
|
|
1247
|
+
elif has_project_dir and not has_workspace:
|
|
1248
|
+
status = "missing"
|
|
1249
|
+
else:
|
|
1250
|
+
status = "no-data"
|
|
1251
|
+
project_list.append((proj.name, status))
|
|
1252
|
+
|
|
1253
|
+
results.append((ws_name, ws, project_list))
|
|
1254
|
+
|
|
1255
|
+
return results
|
|
1256
|
+
|
|
1257
|
+
|
|
1258
|
+
def _find_workset_for_path(project_dir: Path, std: StandardPaths) -> tuple[_WorksetLike, str | None]:
|
|
1259
|
+
"""Return ``(workset, project_name)`` for a path inside a workset.
|
|
1260
|
+
|
|
1261
|
+
The returned object is a concrete ``kanibako.workset.Workset`` (typed
|
|
1262
|
+
structurally as :class:`_WorksetLike` to avoid importing ``workset`` into
|
|
1263
|
+
``paths.py``); callers that need a :class:`WorksetSpec` for
|
|
1264
|
+
:func:`resolve_workset_project` wrap it via ``WorksetSpec.from_workset``.
|
|
1265
|
+
|
|
1266
|
+
*project_dir* may be the workspace root, a subdirectory within it,
|
|
1267
|
+
or anywhere inside the workset root. When *project_dir* is inside
|
|
1268
|
+
``workspaces/{name}/``, the project name is returned. When inside
|
|
1269
|
+
the workset root but not in a specific workspace, ``None`` is returned
|
|
1270
|
+
as the project name.
|
|
1271
|
+
|
|
1272
|
+
Raises ``WorksetError`` if *project_dir* does not belong to any
|
|
1273
|
+
registered workset.
|
|
1274
|
+
"""
|
|
1275
|
+
from kanibako.workset import list_worksets, load_workset
|
|
1276
|
+
|
|
1277
|
+
registry = list_worksets(std)
|
|
1278
|
+
resolved = project_dir.resolve()
|
|
1279
|
+
for _name, root in registry.items():
|
|
1280
|
+
ws_root = root.resolve()
|
|
1281
|
+
ws_workspaces = ws_root / "workspaces"
|
|
1282
|
+
# Check workspaces/ first (specific project).
|
|
1283
|
+
try:
|
|
1284
|
+
rel = resolved.relative_to(ws_workspaces)
|
|
1285
|
+
project_name = rel.parts[0] if rel.parts else None
|
|
1286
|
+
ws = load_workset(root)
|
|
1287
|
+
return ws, project_name
|
|
1288
|
+
except ValueError:
|
|
1289
|
+
pass
|
|
1290
|
+
# Then check workset root itself.
|
|
1291
|
+
try:
|
|
1292
|
+
resolved.relative_to(ws_root)
|
|
1293
|
+
ws = load_workset(root)
|
|
1294
|
+
return ws, None
|
|
1295
|
+
except ValueError:
|
|
1296
|
+
continue
|
|
1297
|
+
raise WorksetError(f"No workset found for path: {project_dir}")
|
|
1298
|
+
|
|
1299
|
+
|
|
1300
|
+
def resolve_any_project(
|
|
1301
|
+
std: StandardPaths,
|
|
1302
|
+
config: KanibakoConfig,
|
|
1303
|
+
project_dir: str | None = None,
|
|
1304
|
+
*,
|
|
1305
|
+
initialize: bool = False,
|
|
1306
|
+
) -> ProjectPaths:
|
|
1307
|
+
"""Auto-detect project mode and resolve paths accordingly.
|
|
1308
|
+
|
|
1309
|
+
Uses ``detect_project_mode`` to walk ancestor directories and find the
|
|
1310
|
+
project root. The resolved *project_root* (not the raw CWD) is passed
|
|
1311
|
+
to the appropriate resolver.
|
|
1312
|
+
"""
|
|
1313
|
+
raw = project_dir or os.getcwd()
|
|
1314
|
+
# CLI front-door: a bare token (no path separator) that doesn't exist in
|
|
1315
|
+
# cwd may be a registered project/workset name. resolve_project also does
|
|
1316
|
+
# this lookup, but resolve_any_project must do it FIRST -- otherwise
|
|
1317
|
+
# Path(raw).resolve() below path-ifies the name before detect_project_mode
|
|
1318
|
+
# sees it.
|
|
1319
|
+
if raw and "/" not in raw and not Path(raw).exists():
|
|
1320
|
+
try:
|
|
1321
|
+
resolved, kind = resolve_name(std.data_path, raw, cwd=Path.cwd())
|
|
1322
|
+
if kind == "project":
|
|
1323
|
+
raw = resolved
|
|
1324
|
+
except ProjectError:
|
|
1325
|
+
pass
|
|
1326
|
+
raw_dir = Path(raw).resolve()
|
|
1327
|
+
detection = detect_project_mode(raw_dir, std, config)
|
|
1328
|
+
root_str = str(detection.project_root)
|
|
1329
|
+
|
|
1330
|
+
if detection.mode == ProjectMode.workset:
|
|
1331
|
+
ws, proj_name = _find_workset_for_path(raw_dir, std)
|
|
1332
|
+
if proj_name is None:
|
|
1333
|
+
raise WorksetError(
|
|
1334
|
+
f"Inside workset '{ws.name}' but not in a specific project workspace. "
|
|
1335
|
+
f"Change to a project directory under {ws.workspaces_dir}/."
|
|
1336
|
+
)
|
|
1337
|
+
return resolve_workset_project(
|
|
1338
|
+
WorksetSpec.from_workset(ws), proj_name, std, config, initialize=initialize,
|
|
1339
|
+
)
|
|
1340
|
+
if detection.mode == ProjectMode.standalone:
|
|
1341
|
+
return resolve_standalone_project(std, config, root_str, initialize=initialize)
|
|
1342
|
+
return resolve_project(std, config, project_dir=root_str, initialize=initialize)
|
|
1343
|
+
|
|
1344
|
+
|
|
1345
|
+
def resolve_standalone_project(
|
|
1346
|
+
std: StandardPaths,
|
|
1347
|
+
config: KanibakoConfig,
|
|
1348
|
+
project_dir: str | None = None,
|
|
1349
|
+
*,
|
|
1350
|
+
initialize: bool = False,
|
|
1351
|
+
layout: ProjectLayout | None = None,
|
|
1352
|
+
enable_vault: bool | None = None,
|
|
1353
|
+
group_auth: bool | None = None,
|
|
1354
|
+
) -> ProjectPaths:
|
|
1355
|
+
"""Resolve (and optionally initialize) per-project paths for standalone mode.
|
|
1356
|
+
|
|
1357
|
+
All project state lives inside *project_dir* itself.
|
|
1358
|
+
No data is written to ``$XDG_DATA_HOME``.
|
|
1359
|
+
"""
|
|
1360
|
+
raw = project_dir or os.getcwd()
|
|
1361
|
+
project_path = Path(raw).resolve()
|
|
1362
|
+
|
|
1363
|
+
if not project_path.is_dir():
|
|
1364
|
+
raise ProjectError(f"Project path '{project_path}' does not exist.")
|
|
1365
|
+
|
|
1366
|
+
phash = project_hash(str(project_path))
|
|
1367
|
+
|
|
1368
|
+
# Determine metadata_path (depends on layout for standalone).
|
|
1369
|
+
# For tree layout: {project}/kanibako (no dot)
|
|
1370
|
+
# For simple (default): {project}/.kanibako (dot prefix)
|
|
1371
|
+
# Check both locations for existing projects.
|
|
1372
|
+
dot_meta = project_path / ".kanibako"
|
|
1373
|
+
nodot_meta = project_path / "kanibako"
|
|
1374
|
+
|
|
1375
|
+
# Check for stored paths in existing metadata.
|
|
1376
|
+
meta = None
|
|
1377
|
+
actual_layout = None
|
|
1378
|
+
if dot_meta.is_dir():
|
|
1379
|
+
meta = read_project_meta(dot_meta / "project.yaml")
|
|
1380
|
+
metadata_path = dot_meta
|
|
1381
|
+
elif nodot_meta.is_dir():
|
|
1382
|
+
meta = read_project_meta(nodot_meta / "project.yaml")
|
|
1383
|
+
metadata_path = nodot_meta
|
|
1384
|
+
else:
|
|
1385
|
+
# New project — determine layout and metadata_path.
|
|
1386
|
+
actual_layout = layout or _DEFAULT_LAYOUT[ProjectMode.standalone]
|
|
1387
|
+
if actual_layout == ProjectLayout.robust:
|
|
1388
|
+
metadata_path = nodot_meta
|
|
1389
|
+
else:
|
|
1390
|
+
metadata_path = dot_meta
|
|
1391
|
+
|
|
1392
|
+
if meta:
|
|
1393
|
+
actual_layout = ProjectLayout(meta["layout"]) if meta.get("layout") else _DEFAULT_LAYOUT[ProjectMode.standalone]
|
|
1394
|
+
shell_path = Path(meta["shell"]) if meta["shell"] else metadata_path / "shell"
|
|
1395
|
+
vault_ro_path = Path(meta["vault_ro"]) if meta["vault_ro"] else project_path / "vault" / "ro"
|
|
1396
|
+
vault_rw_path = Path(meta["vault_rw"]) if meta["vault_rw"] else project_path / "vault" / "rw"
|
|
1397
|
+
actual_vault_enabled = meta.get("enable_vault", True) if enable_vault is None else enable_vault
|
|
1398
|
+
else:
|
|
1399
|
+
if actual_layout is None:
|
|
1400
|
+
actual_layout = layout or _DEFAULT_LAYOUT[ProjectMode.standalone]
|
|
1401
|
+
shell_path, vault_ro_path, vault_rw_path = _compute_standalone_paths(
|
|
1402
|
+
actual_layout, metadata_path, project_path,
|
|
1403
|
+
)
|
|
1404
|
+
actual_vault_enabled = enable_vault if enable_vault is not None else True
|
|
1405
|
+
|
|
1406
|
+
project_toml = metadata_path / "project.yaml"
|
|
1407
|
+
|
|
1408
|
+
# Auth mode for standalone: explicit param > meta > default.
|
|
1409
|
+
# Standalone projects are NOT in the default group, so they do
|
|
1410
|
+
# not consult the default workset config.yaml.
|
|
1411
|
+
actual_group_auth = (
|
|
1412
|
+
group_auth
|
|
1413
|
+
if group_auth is not None
|
|
1414
|
+
else (bool(meta.get("group_auth", True)) if meta else True)
|
|
1415
|
+
)
|
|
1416
|
+
|
|
1417
|
+
is_new = False
|
|
1418
|
+
if initialize and not metadata_path.is_dir():
|
|
1419
|
+
_init_standalone_project(
|
|
1420
|
+
std, metadata_path, shell_path,
|
|
1421
|
+
vault_ro_path, vault_rw_path, project_path,
|
|
1422
|
+
enable_vault=actual_vault_enabled,
|
|
1423
|
+
)
|
|
1424
|
+
write_project_meta(
|
|
1425
|
+
project_toml,
|
|
1426
|
+
mode="standalone",
|
|
1427
|
+
layout=actual_layout.value,
|
|
1428
|
+
workspace=str(project_path),
|
|
1429
|
+
shell=str(shell_path),
|
|
1430
|
+
vault_ro=str(vault_ro_path),
|
|
1431
|
+
vault_rw=str(vault_rw_path),
|
|
1432
|
+
enable_vault=actual_vault_enabled,
|
|
1433
|
+
group_auth=actual_group_auth,
|
|
1434
|
+
metadata=str(metadata_path),
|
|
1435
|
+
project_hash=phash,
|
|
1436
|
+
)
|
|
1437
|
+
is_new = True
|
|
1438
|
+
|
|
1439
|
+
if initialize:
|
|
1440
|
+
# Recovery: ensure shell exists.
|
|
1441
|
+
if not shell_path.is_dir():
|
|
1442
|
+
shell_path.mkdir(parents=True, exist_ok=True)
|
|
1443
|
+
_bootstrap_shell(shell_path)
|
|
1444
|
+
|
|
1445
|
+
return ProjectPaths(
|
|
1446
|
+
project_path=project_path,
|
|
1447
|
+
project_hash=phash,
|
|
1448
|
+
metadata_path=metadata_path,
|
|
1449
|
+
shell_path=shell_path,
|
|
1450
|
+
vault_ro_path=vault_ro_path,
|
|
1451
|
+
vault_rw_path=vault_rw_path,
|
|
1452
|
+
is_new=is_new,
|
|
1453
|
+
mode=ProjectMode.standalone,
|
|
1454
|
+
layout=actual_layout,
|
|
1455
|
+
enable_vault=actual_vault_enabled,
|
|
1456
|
+
group_auth=actual_group_auth,
|
|
1457
|
+
global_shared_path=None,
|
|
1458
|
+
)
|
|
1459
|
+
|
|
1460
|
+
|
|
1461
|
+
def _init_standalone_project(
|
|
1462
|
+
std: StandardPaths,
|
|
1463
|
+
metadata_path: Path,
|
|
1464
|
+
shell_path: Path,
|
|
1465
|
+
vault_ro_path: Path,
|
|
1466
|
+
vault_rw_path: Path,
|
|
1467
|
+
project_path: Path,
|
|
1468
|
+
*,
|
|
1469
|
+
enable_vault: bool = True,
|
|
1470
|
+
) -> None:
|
|
1471
|
+
"""First-time standalone project setup: all state inside project dir.
|
|
1472
|
+
|
|
1473
|
+
Unlike workset init, this *does* create vault directories and a
|
|
1474
|
+
``.gitignore`` (vault lives inside the user's project, likely a git repo).
|
|
1475
|
+
|
|
1476
|
+
Credential copy is handled separately by ``target.init_home()`` in
|
|
1477
|
+
``start.py``, after template application.
|
|
1478
|
+
"""
|
|
1479
|
+
_init_common(
|
|
1480
|
+
std, metadata_path, shell_path,
|
|
1481
|
+
vault_ro_path, vault_rw_path, project_path,
|
|
1482
|
+
enable_vault=enable_vault,
|
|
1483
|
+
)
|