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.
Files changed (85) hide show
  1. kanibako/__init__.py +3 -0
  2. kanibako/__main__.py +6 -0
  3. kanibako/auth_browser.py +296 -0
  4. kanibako/auth_parser.py +51 -0
  5. kanibako/browser_sidecar.py +183 -0
  6. kanibako/browser_state.py +103 -0
  7. kanibako/bun_sea.py +144 -0
  8. kanibako/cli.py +344 -0
  9. kanibako/commands/__init__.py +0 -0
  10. kanibako/commands/archive.py +228 -0
  11. kanibako/commands/box/__init__.py +22 -0
  12. kanibako/commands/box/_duplicate.py +395 -0
  13. kanibako/commands/box/_migrate.py +574 -0
  14. kanibako/commands/box/_parser.py +1178 -0
  15. kanibako/commands/clean.py +166 -0
  16. kanibako/commands/crab_cmd.py +480 -0
  17. kanibako/commands/diagnose.py +239 -0
  18. kanibako/commands/fork_cmd.py +51 -0
  19. kanibako/commands/helper_cmd.py +669 -0
  20. kanibako/commands/image.py +1300 -0
  21. kanibako/commands/install.py +152 -0
  22. kanibako/commands/refresh_credentials.py +67 -0
  23. kanibako/commands/restore.py +298 -0
  24. kanibako/commands/setup_cmd.py +89 -0
  25. kanibako/commands/start.py +1600 -0
  26. kanibako/commands/stop.py +116 -0
  27. kanibako/commands/system_cmd.py +224 -0
  28. kanibako/commands/upgrade.py +161 -0
  29. kanibako/commands/vault_cmd.py +199 -0
  30. kanibako/commands/workset_cmd.py +552 -0
  31. kanibako/config.py +514 -0
  32. kanibako/config_interface.py +573 -0
  33. kanibako/config_io.py +36 -0
  34. kanibako/container.py +607 -0
  35. kanibako/containerfiles.py +58 -0
  36. kanibako/containers/Containerfile.kanibako +99 -0
  37. kanibako/containers/Containerfile.template-android +55 -0
  38. kanibako/containers/Containerfile.template-dotnet +29 -0
  39. kanibako/containers/Containerfile.template-js +43 -0
  40. kanibako/containers/Containerfile.template-jvm +27 -0
  41. kanibako/containers/Containerfile.template-systems +46 -0
  42. kanibako/containers/__init__.py +0 -0
  43. kanibako/crabs.py +89 -0
  44. kanibako/errors.py +33 -0
  45. kanibako/freshness.py +67 -0
  46. kanibako/git.py +114 -0
  47. kanibako/helper_client.py +132 -0
  48. kanibako/helper_listener.py +538 -0
  49. kanibako/helpers.py +339 -0
  50. kanibako/hygiene.py +296 -0
  51. kanibako/image_sharing.py +133 -0
  52. kanibako/instructions.py +160 -0
  53. kanibako/log.py +31 -0
  54. kanibako/names.py +248 -0
  55. kanibako/paths.py +1483 -0
  56. kanibako/plugins/__init__.py +10 -0
  57. kanibako/registry.py +71 -0
  58. kanibako/rig_bundle.py +121 -0
  59. kanibako/rig_meta.py +92 -0
  60. kanibako/rig_registry.py +132 -0
  61. kanibako/rig_resolve.py +182 -0
  62. kanibako/rig_source.py +245 -0
  63. kanibako/scripts/__init__.py +0 -0
  64. kanibako/scripts/helper-init.sh +45 -0
  65. kanibako/scripts/kanibako-entry +12 -0
  66. kanibako/settings_resolve.py +312 -0
  67. kanibako/settings_seeds.py +154 -0
  68. kanibako/settings_shares.py +154 -0
  69. kanibako/shellenv.py +75 -0
  70. kanibako/snapshots.py +281 -0
  71. kanibako/targets/__init__.py +173 -0
  72. kanibako/targets/base.py +243 -0
  73. kanibako/targets/no_agent.py +58 -0
  74. kanibako/templates.py +60 -0
  75. kanibako/templates_image.py +224 -0
  76. kanibako/tweakcc.py +140 -0
  77. kanibako/tweakcc_cache.py +171 -0
  78. kanibako/utils.py +136 -0
  79. kanibako/workset.py +347 -0
  80. kanibako_cli-1.5.0.dev14.dist-info/METADATA +15 -0
  81. kanibako_cli-1.5.0.dev14.dist-info/RECORD +85 -0
  82. kanibako_cli-1.5.0.dev14.dist-info/WHEEL +5 -0
  83. kanibako_cli-1.5.0.dev14.dist-info/entry_points.txt +5 -0
  84. kanibako_cli-1.5.0.dev14.dist-info/licenses/LICENSE.md +594 -0
  85. 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