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,228 @@
|
|
|
1
|
+
"""kanibako archive: archive session data + git metadata to .txz."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
import tarfile
|
|
8
|
+
from datetime import datetime, timezone
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from kanibako.config import config_file_path, load_config
|
|
12
|
+
from kanibako.errors import GitError
|
|
13
|
+
from kanibako.git import check_uncommitted, check_unpushed, get_metadata, is_git_repo
|
|
14
|
+
from kanibako.paths import xdg, load_std_paths, resolve_any_project
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def add_parser(subparsers: argparse._SubParsersAction) -> None:
|
|
18
|
+
p = subparsers.add_parser(
|
|
19
|
+
"archive",
|
|
20
|
+
help="Archive project session data to .txz file",
|
|
21
|
+
description="Archive project session data and git metadata to a .txz file.",
|
|
22
|
+
)
|
|
23
|
+
p.add_argument("path", nargs="?", default=None, help="Path to the project directory")
|
|
24
|
+
p.add_argument("file", nargs="?", default=None, help="Output filename (default: auto-generated)")
|
|
25
|
+
p.add_argument(
|
|
26
|
+
"--all", action="store_true", dest="all_projects",
|
|
27
|
+
help="Archive session data for every known project",
|
|
28
|
+
)
|
|
29
|
+
p.add_argument("--allow-uncommitted", action="store_true",
|
|
30
|
+
help="Allow archiving with uncommitted changes")
|
|
31
|
+
p.add_argument("--allow-unpushed", action="store_true",
|
|
32
|
+
help="Allow archiving with unpushed commits")
|
|
33
|
+
p.add_argument("--force", action="store_true", help="Skip all confirmation prompts")
|
|
34
|
+
p.set_defaults(func=run)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def run(args: argparse.Namespace) -> int:
|
|
38
|
+
config_file = config_file_path(xdg("XDG_CONFIG_HOME", ".config"))
|
|
39
|
+
config = load_config(config_file)
|
|
40
|
+
std = load_std_paths(config)
|
|
41
|
+
|
|
42
|
+
if args.all_projects:
|
|
43
|
+
return _archive_all(std, config, args)
|
|
44
|
+
|
|
45
|
+
if args.path is None:
|
|
46
|
+
print("Error: specify a project path, or use --all", file=sys.stderr)
|
|
47
|
+
return 1
|
|
48
|
+
|
|
49
|
+
proj = resolve_any_project(std, config, project_dir=args.path, initialize=False)
|
|
50
|
+
return _archive_one(std, config, proj, output_file=args.file, args=args)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _archive_one(std, config, proj, *, output_file, args) -> int:
|
|
54
|
+
"""Archive session data for a single project."""
|
|
55
|
+
if not proj.metadata_path.is_dir():
|
|
56
|
+
print(f"Error: No session data found for project {proj.project_path}", file=sys.stderr)
|
|
57
|
+
return 1
|
|
58
|
+
|
|
59
|
+
# Generate default archive filename
|
|
60
|
+
archive_file = output_file
|
|
61
|
+
if not archive_file:
|
|
62
|
+
label = proj.name or proj.project_path.name
|
|
63
|
+
timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
|
64
|
+
archive_file = f"kanibako-{label}-{timestamp}.txz"
|
|
65
|
+
|
|
66
|
+
# Prepare metadata
|
|
67
|
+
info_file = proj.metadata_path / "kanibako-archive-info.txt"
|
|
68
|
+
lines = [
|
|
69
|
+
f"Project path: {proj.project_path}",
|
|
70
|
+
f"Project hash: {proj.project_hash}",
|
|
71
|
+
f"Archive date: {datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')}",
|
|
72
|
+
"",
|
|
73
|
+
]
|
|
74
|
+
|
|
75
|
+
# Git checks (only if project path exists on disk)
|
|
76
|
+
if proj.project_path.is_dir() and is_git_repo(proj.project_path):
|
|
77
|
+
if not args.allow_uncommitted:
|
|
78
|
+
try:
|
|
79
|
+
check_uncommitted(proj.project_path)
|
|
80
|
+
except GitError as e:
|
|
81
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
82
|
+
return 1
|
|
83
|
+
|
|
84
|
+
if not args.allow_unpushed:
|
|
85
|
+
try:
|
|
86
|
+
check_unpushed(proj.project_path)
|
|
87
|
+
except GitError as e:
|
|
88
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
89
|
+
return 1
|
|
90
|
+
|
|
91
|
+
meta = get_metadata(proj.project_path)
|
|
92
|
+
if meta:
|
|
93
|
+
lines.append("Git repository: yes")
|
|
94
|
+
lines.append(f"Branch: {meta.branch}")
|
|
95
|
+
lines.append(f"Commit: {meta.commit}")
|
|
96
|
+
lines.append("Remotes:")
|
|
97
|
+
for name, url in meta.remotes:
|
|
98
|
+
lines.append(f" {name}: {url}")
|
|
99
|
+
else:
|
|
100
|
+
if proj.project_path.is_dir():
|
|
101
|
+
print(
|
|
102
|
+
f"Warning: No git repository detected in {proj.project_path}",
|
|
103
|
+
file=sys.stderr,
|
|
104
|
+
)
|
|
105
|
+
print("Only kanibako session data will be archived.", file=sys.stderr)
|
|
106
|
+
lines.append("")
|
|
107
|
+
lines.append("Git repository: no")
|
|
108
|
+
|
|
109
|
+
info_file.write_text("\n".join(lines) + "\n")
|
|
110
|
+
|
|
111
|
+
# Create archive using Python tarfile
|
|
112
|
+
print(f"Creating archive {archive_file}... ", end="", flush=True)
|
|
113
|
+
try:
|
|
114
|
+
with tarfile.open(archive_file, "w:xz") as tar:
|
|
115
|
+
tar.add(
|
|
116
|
+
str(proj.metadata_path),
|
|
117
|
+
arcname=proj.project_hash,
|
|
118
|
+
)
|
|
119
|
+
except Exception as e:
|
|
120
|
+
info_file.unlink(missing_ok=True)
|
|
121
|
+
print(f"\nError: Failed to create archive: {e}", file=sys.stderr)
|
|
122
|
+
return 1
|
|
123
|
+
finally:
|
|
124
|
+
info_file.unlink(missing_ok=True)
|
|
125
|
+
|
|
126
|
+
print("done.")
|
|
127
|
+
print(f"Archive created: {archive_file}")
|
|
128
|
+
return 0
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _archive_all(std, config, args) -> int:
|
|
132
|
+
"""Archive session data for all known projects."""
|
|
133
|
+
from kanibako.paths import (
|
|
134
|
+
WorksetSpec,
|
|
135
|
+
iter_projects,
|
|
136
|
+
iter_workset_projects,
|
|
137
|
+
resolve_project,
|
|
138
|
+
resolve_workset_project,
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
projects = iter_projects(std, config)
|
|
142
|
+
ws_data = iter_workset_projects(std, config)
|
|
143
|
+
|
|
144
|
+
if not projects and not ws_data:
|
|
145
|
+
print("No project session data found.")
|
|
146
|
+
return 0
|
|
147
|
+
|
|
148
|
+
total = len(projects)
|
|
149
|
+
for _, _, project_list in ws_data:
|
|
150
|
+
total += sum(1 for _, status in project_list if status != "no-data")
|
|
151
|
+
|
|
152
|
+
print(f"Found {total} project(s) to archive:")
|
|
153
|
+
for metadata_path, project_path in projects:
|
|
154
|
+
label = str(project_path) if project_path else f"(unknown) {metadata_path.name[:8]}"
|
|
155
|
+
print(f" {label}")
|
|
156
|
+
for ws_name, ws, project_list in ws_data:
|
|
157
|
+
for proj_name, status in project_list:
|
|
158
|
+
if status != "no-data":
|
|
159
|
+
print(f" {ws_name}/{proj_name}")
|
|
160
|
+
print()
|
|
161
|
+
|
|
162
|
+
archived = 0
|
|
163
|
+
failed = 0
|
|
164
|
+
|
|
165
|
+
# Default-mode projects.
|
|
166
|
+
for metadata_path, project_path in projects:
|
|
167
|
+
if project_path:
|
|
168
|
+
try:
|
|
169
|
+
proj = resolve_project(
|
|
170
|
+
std, config, project_dir=str(project_path), initialize=False
|
|
171
|
+
)
|
|
172
|
+
except Exception:
|
|
173
|
+
proj = _stub_project(metadata_path, project_path, config)
|
|
174
|
+
else:
|
|
175
|
+
proj = _stub_project(metadata_path, None, config)
|
|
176
|
+
|
|
177
|
+
rc = _archive_one(std, config, proj, output_file=None, args=args)
|
|
178
|
+
if rc == 0:
|
|
179
|
+
archived += 1
|
|
180
|
+
else:
|
|
181
|
+
failed += 1
|
|
182
|
+
|
|
183
|
+
# Workset projects.
|
|
184
|
+
for ws_name, ws, project_list in ws_data:
|
|
185
|
+
for proj_name, status in project_list:
|
|
186
|
+
if status == "no-data":
|
|
187
|
+
continue
|
|
188
|
+
try:
|
|
189
|
+
proj = resolve_workset_project(
|
|
190
|
+
WorksetSpec.from_workset(ws), proj_name, std, config, initialize=False,
|
|
191
|
+
)
|
|
192
|
+
except Exception:
|
|
193
|
+
failed += 1
|
|
194
|
+
continue
|
|
195
|
+
rc = _archive_one(std, config, proj, output_file=None, args=args)
|
|
196
|
+
if rc == 0:
|
|
197
|
+
archived += 1
|
|
198
|
+
else:
|
|
199
|
+
failed += 1
|
|
200
|
+
|
|
201
|
+
print(f"\nArchived {archived} project(s).", end="")
|
|
202
|
+
if failed:
|
|
203
|
+
print(f" {failed} failed.", end="")
|
|
204
|
+
print()
|
|
205
|
+
return 1 if failed else 0
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _stub_project(metadata_path, project_path, config):
|
|
209
|
+
"""Create a minimal ProjectPaths stand-in for projects whose path is gone."""
|
|
210
|
+
from kanibako.config import read_project_meta
|
|
211
|
+
from kanibako.paths import ProjectPaths
|
|
212
|
+
|
|
213
|
+
# Read hash and name from project.yaml when available.
|
|
214
|
+
meta = read_project_meta(metadata_path / "project.yaml")
|
|
215
|
+
phash = (meta.get("project_hash") or metadata_path.name) if meta else metadata_path.name
|
|
216
|
+
name = (meta.get("name") or "") if meta else ""
|
|
217
|
+
|
|
218
|
+
effective_path = project_path or Path(f"(unknown-{name or metadata_path.name})")
|
|
219
|
+
return ProjectPaths(
|
|
220
|
+
project_path=effective_path,
|
|
221
|
+
project_hash=phash,
|
|
222
|
+
metadata_path=metadata_path,
|
|
223
|
+
shell_path=metadata_path / "shell",
|
|
224
|
+
vault_ro_path=effective_path / "vault" / "ro",
|
|
225
|
+
vault_rw_path=effective_path / "vault" / "rw",
|
|
226
|
+
is_new=False,
|
|
227
|
+
name=name,
|
|
228
|
+
)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""kanibako box: project lifecycle management (create, list, config, migrate, duplicate, move, archive, extract, purge, vault)."""
|
|
2
|
+
|
|
3
|
+
from kanibako.commands.box._duplicate import run_duplicate
|
|
4
|
+
from kanibako.commands.box._migrate import run_migrate
|
|
5
|
+
from kanibako.commands.box._parser import (
|
|
6
|
+
_check_container_running,
|
|
7
|
+
_format_credential_age,
|
|
8
|
+
add_parser,
|
|
9
|
+
run_config,
|
|
10
|
+
run_create,
|
|
11
|
+
run_info,
|
|
12
|
+
run_list,
|
|
13
|
+
run_move,
|
|
14
|
+
run_ps,
|
|
15
|
+
run_rm,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"_check_container_running", "_format_credential_age",
|
|
20
|
+
"add_parser", "run_config", "run_create", "run_duplicate",
|
|
21
|
+
"run_info", "run_list", "run_migrate", "run_move", "run_ps", "run_rm",
|
|
22
|
+
]
|
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
"""Duplicate logic for kanibako box."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import shutil
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from kanibako.config import config_file_path, load_config
|
|
11
|
+
from kanibako.names import assign_name
|
|
12
|
+
from kanibako.paths import (
|
|
13
|
+
ProjectMode,
|
|
14
|
+
WorksetSpec,
|
|
15
|
+
_find_workset_for_path,
|
|
16
|
+
_resolve_local_dir,
|
|
17
|
+
xdg,
|
|
18
|
+
detect_project_mode,
|
|
19
|
+
load_std_paths,
|
|
20
|
+
resolve_standalone_project,
|
|
21
|
+
resolve_project,
|
|
22
|
+
resolve_workset_project,
|
|
23
|
+
)
|
|
24
|
+
from kanibako.utils import confirm_prompt
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# -- Cross-mode duplicate helpers --
|
|
28
|
+
|
|
29
|
+
def _run_duplicate_cross_mode(args: argparse.Namespace, std, config) -> int:
|
|
30
|
+
"""Duplicate a project into a different mode layout."""
|
|
31
|
+
to_mode_str = args.to_mode
|
|
32
|
+
|
|
33
|
+
# Duplicate TO workset: separate code path.
|
|
34
|
+
if to_mode_str == "workset":
|
|
35
|
+
return _duplicate_to_workset(args, std, config)
|
|
36
|
+
|
|
37
|
+
source_path = Path(args.source_path).resolve()
|
|
38
|
+
new_path = Path(args.new_path).resolve()
|
|
39
|
+
|
|
40
|
+
if source_path == new_path:
|
|
41
|
+
print("Error: source and destination paths are the same.", file=sys.stderr)
|
|
42
|
+
return 1
|
|
43
|
+
|
|
44
|
+
if not source_path.is_dir():
|
|
45
|
+
print(f"Error: source path does not exist as a directory: {source_path}", file=sys.stderr)
|
|
46
|
+
return 1
|
|
47
|
+
|
|
48
|
+
# Detect source mode and resolve.
|
|
49
|
+
source_mode = detect_project_mode(source_path, std, config).mode
|
|
50
|
+
|
|
51
|
+
# Duplicate FROM workset: separate code path.
|
|
52
|
+
if source_mode == ProjectMode.workset:
|
|
53
|
+
return _duplicate_from_workset(args, source_path, new_path, std, config)
|
|
54
|
+
|
|
55
|
+
# default<->standalone: architectural boundary (centralized vs in-workspace metadata), not re-rooting — kept distinct (#71 B2).
|
|
56
|
+
if source_mode == ProjectMode.default:
|
|
57
|
+
src_proj = resolve_project(std, config, project_dir=str(source_path), initialize=False)
|
|
58
|
+
else:
|
|
59
|
+
src_proj = resolve_standalone_project(std, config, project_dir=str(source_path), initialize=False)
|
|
60
|
+
|
|
61
|
+
if not src_proj.metadata_path.is_dir():
|
|
62
|
+
print(f"Error: no project data found for source path: {source_path}", file=sys.stderr)
|
|
63
|
+
return 1
|
|
64
|
+
|
|
65
|
+
# Lock file warning.
|
|
66
|
+
lock_file = src_proj.metadata_path / ".kanibako.lock"
|
|
67
|
+
if lock_file.exists():
|
|
68
|
+
print(
|
|
69
|
+
"Warning: lock file found — a container may be running for this project.",
|
|
70
|
+
file=sys.stderr,
|
|
71
|
+
)
|
|
72
|
+
if not args.force:
|
|
73
|
+
print("Aborted.")
|
|
74
|
+
return 2
|
|
75
|
+
|
|
76
|
+
# Confirm with user.
|
|
77
|
+
target_mode = ProjectMode.standalone if to_mode_str == "standalone" else ProjectMode.default
|
|
78
|
+
if not args.force:
|
|
79
|
+
mode = "metadata only (bare)" if args.bare else "workspace + metadata"
|
|
80
|
+
print(f"Duplicate project ({mode}) to {target_mode.value} mode:")
|
|
81
|
+
print(f" from: {source_path}")
|
|
82
|
+
print(f" to: {new_path}")
|
|
83
|
+
print()
|
|
84
|
+
try:
|
|
85
|
+
confirm_prompt("Type 'yes' to confirm: ")
|
|
86
|
+
except Exception:
|
|
87
|
+
print("Aborted.")
|
|
88
|
+
return 2
|
|
89
|
+
|
|
90
|
+
# Copy workspace (unless --bare).
|
|
91
|
+
if not args.bare:
|
|
92
|
+
shutil.copytree(source_path, new_path, dirs_exist_ok=args.force)
|
|
93
|
+
|
|
94
|
+
# Copy metadata into target mode layout.
|
|
95
|
+
# default<->standalone: architectural boundary (centralized vs in-workspace metadata), not re-rooting — kept distinct (#71 B2).
|
|
96
|
+
if target_mode == ProjectMode.standalone:
|
|
97
|
+
_duplicate_to_standalone(src_proj, new_path, args.force)
|
|
98
|
+
else:
|
|
99
|
+
_duplicate_to_local(src_proj, new_path, std, config, args.force)
|
|
100
|
+
|
|
101
|
+
print(f"Duplicated project to {target_mode.value} mode:")
|
|
102
|
+
print(f" from: {source_path}")
|
|
103
|
+
print(f" to: {new_path}")
|
|
104
|
+
return 0
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _duplicate_to_standalone(src_proj, new_path, force):
|
|
108
|
+
"""Copy metadata into standalone layout at new_path."""
|
|
109
|
+
from kanibako.utils import write_project_gitignore
|
|
110
|
+
|
|
111
|
+
dst_metadata = new_path / ".kanibako"
|
|
112
|
+
dst_shell = dst_metadata / "shell"
|
|
113
|
+
|
|
114
|
+
# Ensure new_path exists for bare duplicates.
|
|
115
|
+
new_path.mkdir(parents=True, exist_ok=True)
|
|
116
|
+
|
|
117
|
+
if force and dst_metadata.is_dir():
|
|
118
|
+
shutil.rmtree(dst_metadata)
|
|
119
|
+
shutil.copytree(
|
|
120
|
+
src_proj.metadata_path, dst_metadata,
|
|
121
|
+
ignore=shutil.ignore_patterns(".kanibako.lock", "shell"),
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
if src_proj.shell_path.is_dir():
|
|
125
|
+
if force and dst_shell.is_dir():
|
|
126
|
+
shutil.rmtree(dst_shell)
|
|
127
|
+
shutil.copytree(src_proj.shell_path, dst_shell)
|
|
128
|
+
|
|
129
|
+
write_project_gitignore(new_path)
|
|
130
|
+
|
|
131
|
+
# Write vault .gitignore if vault exists.
|
|
132
|
+
vault_dir = new_path / "vault"
|
|
133
|
+
if vault_dir.is_dir():
|
|
134
|
+
vault_gitignore = vault_dir / ".gitignore"
|
|
135
|
+
if not vault_gitignore.exists():
|
|
136
|
+
vault_gitignore.write_text("rw/\n")
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _duplicate_to_local(src_proj, new_path, std, config, force):
|
|
140
|
+
"""Copy metadata into default-mode layout for new_path."""
|
|
141
|
+
# Assign a new name for the duplicate.
|
|
142
|
+
project_name = assign_name(std.data_path, str(new_path))
|
|
143
|
+
projects_base = std.boxes
|
|
144
|
+
dst_project = projects_base / project_name
|
|
145
|
+
|
|
146
|
+
if force and dst_project.is_dir():
|
|
147
|
+
shutil.rmtree(dst_project)
|
|
148
|
+
shutil.copytree(
|
|
149
|
+
src_proj.metadata_path, dst_project,
|
|
150
|
+
ignore=shutil.ignore_patterns(".kanibako.lock"),
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
# Ensure home is inside the project dir.
|
|
154
|
+
if src_proj.shell_path.is_dir():
|
|
155
|
+
dst_home = dst_project / "shell"
|
|
156
|
+
if not dst_home.is_dir():
|
|
157
|
+
shutil.copytree(src_proj.shell_path, dst_home)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _duplicate_to_workset(args, std, config) -> int:
|
|
161
|
+
"""Duplicate a project into a workset (source untouched)."""
|
|
162
|
+
from kanibako.commands.box._migrate import _copy_into_workset
|
|
163
|
+
from kanibako.workset import list_worksets, load_workset
|
|
164
|
+
|
|
165
|
+
ws_name = getattr(args, "workset", None)
|
|
166
|
+
if not ws_name:
|
|
167
|
+
print("Error: --workset is required when duplicating to workset mode.", file=sys.stderr)
|
|
168
|
+
return 1
|
|
169
|
+
|
|
170
|
+
registry = list_worksets(std)
|
|
171
|
+
if ws_name not in registry:
|
|
172
|
+
print(f"Error: workset '{ws_name}' not found.", file=sys.stderr)
|
|
173
|
+
return 1
|
|
174
|
+
ws = load_workset(registry[ws_name])
|
|
175
|
+
|
|
176
|
+
source_path = Path(args.source_path).resolve()
|
|
177
|
+
if not source_path.is_dir():
|
|
178
|
+
print(f"Error: source path does not exist as a directory: {source_path}", file=sys.stderr)
|
|
179
|
+
return 1
|
|
180
|
+
|
|
181
|
+
source_mode = detect_project_mode(source_path, std, config).mode
|
|
182
|
+
if source_mode == ProjectMode.workset:
|
|
183
|
+
print("Error: source is already a workset project.", file=sys.stderr)
|
|
184
|
+
return 1
|
|
185
|
+
|
|
186
|
+
proj_name = getattr(args, "project_name", None) or source_path.name
|
|
187
|
+
|
|
188
|
+
# Validate name not taken.
|
|
189
|
+
for p in ws.projects:
|
|
190
|
+
if p.name == proj_name:
|
|
191
|
+
print(f"Error: project '{proj_name}' already exists in workset '{ws_name}'.", file=sys.stderr)
|
|
192
|
+
return 1
|
|
193
|
+
|
|
194
|
+
# default<->standalone: architectural boundary (centralized vs in-workspace metadata), not re-rooting — kept distinct (#71 B2).
|
|
195
|
+
if source_mode == ProjectMode.default:
|
|
196
|
+
src_proj = resolve_project(std, config, project_dir=str(source_path), initialize=False)
|
|
197
|
+
else:
|
|
198
|
+
src_proj = resolve_standalone_project(std, config, project_dir=str(source_path), initialize=False)
|
|
199
|
+
|
|
200
|
+
if not src_proj.metadata_path.is_dir():
|
|
201
|
+
print(f"Error: no project data found for source path: {source_path}", file=sys.stderr)
|
|
202
|
+
return 1
|
|
203
|
+
|
|
204
|
+
# Lock file warning.
|
|
205
|
+
lock_file = src_proj.metadata_path / ".kanibako.lock"
|
|
206
|
+
if lock_file.exists():
|
|
207
|
+
print(
|
|
208
|
+
"Warning: lock file found — a container may be running for this project.",
|
|
209
|
+
file=sys.stderr,
|
|
210
|
+
)
|
|
211
|
+
if not args.force:
|
|
212
|
+
print("Aborted.")
|
|
213
|
+
return 2
|
|
214
|
+
|
|
215
|
+
if not args.force:
|
|
216
|
+
mode = "metadata only (bare)" if args.bare else "workspace + metadata"
|
|
217
|
+
print(f"Duplicate project ({mode}) to workset:")
|
|
218
|
+
print(f" from: {source_path}")
|
|
219
|
+
print(f" workset: {ws_name}/{proj_name}")
|
|
220
|
+
print()
|
|
221
|
+
try:
|
|
222
|
+
confirm_prompt("Type 'yes' to confirm: ")
|
|
223
|
+
except Exception:
|
|
224
|
+
print("Aborted.")
|
|
225
|
+
return 2
|
|
226
|
+
|
|
227
|
+
# Re-root the project into the workset group (copy workspace unless --bare).
|
|
228
|
+
_copy_into_workset(ws, proj_name, src_proj, source_path, source_mode, copy_workspace=not args.bare)
|
|
229
|
+
|
|
230
|
+
print("Duplicated project to workset:")
|
|
231
|
+
print(f" from: {source_path}")
|
|
232
|
+
print(f" workset: {ws_name}/{proj_name}")
|
|
233
|
+
return 0
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _duplicate_from_workset(args, source_path, new_path, std, config) -> int:
|
|
237
|
+
"""Duplicate a workset project to default-mode or standalone layout (source untouched)."""
|
|
238
|
+
to_mode_str = args.to_mode
|
|
239
|
+
|
|
240
|
+
ws, proj_name = _find_workset_for_path(source_path, std)
|
|
241
|
+
if proj_name is None:
|
|
242
|
+
print("Error: not inside a specific project workspace.", file=sys.stderr)
|
|
243
|
+
return 1
|
|
244
|
+
src_proj = resolve_workset_project(
|
|
245
|
+
WorksetSpec.from_workset(ws), proj_name, std, config, initialize=False,
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
if not src_proj.metadata_path.is_dir():
|
|
249
|
+
print(f"Error: no project data found for source path: {source_path}", file=sys.stderr)
|
|
250
|
+
return 1
|
|
251
|
+
|
|
252
|
+
target_mode = ProjectMode.standalone if to_mode_str == "standalone" else ProjectMode.default
|
|
253
|
+
|
|
254
|
+
# Lock file warning.
|
|
255
|
+
lock_file = src_proj.metadata_path / ".kanibako.lock"
|
|
256
|
+
if lock_file.exists():
|
|
257
|
+
print(
|
|
258
|
+
"Warning: lock file found — a container may be running for this project.",
|
|
259
|
+
file=sys.stderr,
|
|
260
|
+
)
|
|
261
|
+
if not args.force:
|
|
262
|
+
print("Aborted.")
|
|
263
|
+
return 2
|
|
264
|
+
|
|
265
|
+
if not args.force:
|
|
266
|
+
mode = "metadata only (bare)" if args.bare else "workspace + metadata"
|
|
267
|
+
print(f"Duplicate workset project ({mode}) to {target_mode.value} mode:")
|
|
268
|
+
print(f" from: {ws.name}/{proj_name}")
|
|
269
|
+
print(f" to: {new_path}")
|
|
270
|
+
print()
|
|
271
|
+
try:
|
|
272
|
+
confirm_prompt("Type 'yes' to confirm: ")
|
|
273
|
+
except Exception:
|
|
274
|
+
print("Aborted.")
|
|
275
|
+
return 2
|
|
276
|
+
|
|
277
|
+
# Copy workspace (unless --bare).
|
|
278
|
+
if not args.bare:
|
|
279
|
+
ws_workspace = ws.workspaces_dir / proj_name
|
|
280
|
+
if ws_workspace.is_dir():
|
|
281
|
+
shutil.copytree(ws_workspace, new_path, dirs_exist_ok=args.force)
|
|
282
|
+
|
|
283
|
+
# Copy metadata into target layout.
|
|
284
|
+
# default<->standalone: architectural boundary (centralized vs in-workspace metadata), not re-rooting — kept distinct (#71 B2).
|
|
285
|
+
if target_mode == ProjectMode.standalone:
|
|
286
|
+
_duplicate_to_standalone(src_proj, new_path, args.force)
|
|
287
|
+
else:
|
|
288
|
+
_duplicate_to_local(src_proj, new_path, std, config, args.force)
|
|
289
|
+
|
|
290
|
+
print(f"Duplicated project to {target_mode.value} mode:")
|
|
291
|
+
print(f" from: {ws.name}/{proj_name}")
|
|
292
|
+
print(f" to: {new_path}")
|
|
293
|
+
return 0
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def run_duplicate(args: argparse.Namespace) -> int:
|
|
297
|
+
config_file = config_file_path(xdg("XDG_CONFIG_HOME", ".config"))
|
|
298
|
+
config = load_config(config_file)
|
|
299
|
+
std = load_std_paths(config)
|
|
300
|
+
|
|
301
|
+
# Cross-mode duplication.
|
|
302
|
+
if getattr(args, "to_mode", None) is not None:
|
|
303
|
+
return _run_duplicate_cross_mode(args, std, config)
|
|
304
|
+
|
|
305
|
+
source_path = Path(args.source_path).resolve()
|
|
306
|
+
new_path = Path(args.new_path).resolve()
|
|
307
|
+
|
|
308
|
+
# 1. Paths must differ.
|
|
309
|
+
if source_path == new_path:
|
|
310
|
+
print("Error: source and destination paths are the same.", file=sys.stderr)
|
|
311
|
+
return 1
|
|
312
|
+
|
|
313
|
+
# 2. Source must be an existing directory.
|
|
314
|
+
if not source_path.is_dir():
|
|
315
|
+
print(f"Error: source path does not exist as a directory: {source_path}", file=sys.stderr)
|
|
316
|
+
return 1
|
|
317
|
+
|
|
318
|
+
# 3. Source must have kanibako metadata.
|
|
319
|
+
source_name, source_project_dir = _resolve_local_dir(
|
|
320
|
+
std.data_path, str(source_path), std.boxes,
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
if not source_project_dir.is_dir():
|
|
324
|
+
print(
|
|
325
|
+
f"Error: no project data found for source path: {source_path}",
|
|
326
|
+
file=sys.stderr,
|
|
327
|
+
)
|
|
328
|
+
return 1
|
|
329
|
+
|
|
330
|
+
# 4. Non-bare: destination workspace must not already exist (unless --force).
|
|
331
|
+
if not args.bare and new_path.exists() and not args.force:
|
|
332
|
+
print(
|
|
333
|
+
f"Error: destination already exists: {new_path}",
|
|
334
|
+
file=sys.stderr,
|
|
335
|
+
)
|
|
336
|
+
print(" Use --force to overwrite.", file=sys.stderr)
|
|
337
|
+
return 1
|
|
338
|
+
|
|
339
|
+
# 5. Destination metadata must not already exist (unless --force).
|
|
340
|
+
new_name, new_project_dir = _resolve_local_dir(
|
|
341
|
+
std.data_path, str(new_path), std.boxes,
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
if new_project_dir.is_dir() and not args.force:
|
|
345
|
+
print(
|
|
346
|
+
f"Error: project data already exists for destination: {new_path}",
|
|
347
|
+
file=sys.stderr,
|
|
348
|
+
)
|
|
349
|
+
print(" Use --force to overwrite.", file=sys.stderr)
|
|
350
|
+
return 1
|
|
351
|
+
|
|
352
|
+
# 6. Lock file warning.
|
|
353
|
+
lock_file = source_project_dir / ".kanibako.lock"
|
|
354
|
+
if lock_file.exists():
|
|
355
|
+
print(
|
|
356
|
+
"Warning: lock file found — a container may be running for this project.",
|
|
357
|
+
file=sys.stderr,
|
|
358
|
+
)
|
|
359
|
+
if not args.force:
|
|
360
|
+
print("Aborted.")
|
|
361
|
+
return 2
|
|
362
|
+
|
|
363
|
+
# 7. User confirmation.
|
|
364
|
+
if not args.force:
|
|
365
|
+
mode = "metadata only (bare)" if args.bare else "workspace + metadata"
|
|
366
|
+
print(f"Duplicate project ({mode}):")
|
|
367
|
+
print(f" from: {source_path}")
|
|
368
|
+
print(f" to: {new_path}")
|
|
369
|
+
print()
|
|
370
|
+
try:
|
|
371
|
+
confirm_prompt("Type 'yes' to confirm: ")
|
|
372
|
+
except Exception:
|
|
373
|
+
print("Aborted.")
|
|
374
|
+
return 2
|
|
375
|
+
|
|
376
|
+
# Copy workspace (unless --bare).
|
|
377
|
+
if not args.bare:
|
|
378
|
+
shutil.copytree(source_path, new_path, dirs_exist_ok=args.force)
|
|
379
|
+
|
|
380
|
+
# Assign a new name for the duplicate.
|
|
381
|
+
dup_name = assign_name(std.data_path, str(new_path))
|
|
382
|
+
new_project_dir = std.boxes / dup_name
|
|
383
|
+
|
|
384
|
+
# Copy metadata (entire project dir including home/).
|
|
385
|
+
if args.force and new_project_dir.is_dir():
|
|
386
|
+
shutil.rmtree(new_project_dir)
|
|
387
|
+
shutil.copytree(
|
|
388
|
+
source_project_dir, new_project_dir,
|
|
389
|
+
ignore=shutil.ignore_patterns(".kanibako.lock"),
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
print("Duplicated project:")
|
|
393
|
+
print(f" from: {source_path} ({source_name})")
|
|
394
|
+
print(f" to: {new_path} ({dup_name})")
|
|
395
|
+
return 0
|