aisc-cli 0.1.0__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 (92) hide show
  1. aisc/VERSION +1 -0
  2. aisc/__init__.py +49 -0
  3. aisc/__main__.py +6 -0
  4. aisc/adapters/__init__.py +1 -0
  5. aisc/adapters/config_reader.py +215 -0
  6. aisc/adapters/container_registry.py +718 -0
  7. aisc/adapters/data_root_store.py +296 -0
  8. aisc/adapters/docker_.py +1183 -0
  9. aisc/adapters/docker_gateway.py +1244 -0
  10. aisc/adapters/docker_sdk_backed.py +274 -0
  11. aisc/adapters/maintenance_lock.py +97 -0
  12. aisc/adapters/state_file.py +184 -0
  13. aisc/adapters/system.py +101 -0
  14. aisc/adapters/windows_config_reader.py +249 -0
  15. aisc/adapters/workspace_lease_store.py +189 -0
  16. aisc/application/__init__.py +1 -0
  17. aisc/application/artifact.py +265 -0
  18. aisc/application/bundle_fetch.py +836 -0
  19. aisc/application/cc_switch_provider.py +245 -0
  20. aisc/application/cc_switch_resolver.py +363 -0
  21. aisc/application/config_service.py +452 -0
  22. aisc/application/conversation.py +516 -0
  23. aisc/application/data_migration.py +440 -0
  24. aisc/application/data_root.py +275 -0
  25. aisc/application/docker_lifecycle.py +659 -0
  26. aisc/application/doctor.py +821 -0
  27. aisc/application/legacy_scan.py +252 -0
  28. aisc/application/network_subscription.py +642 -0
  29. aisc/application/profile_service.py +105 -0
  30. aisc/application/provider.py +92 -0
  31. aisc/application/repair.py +387 -0
  32. aisc/application/resources.py +270 -0
  33. aisc/application/runtime.py +1826 -0
  34. aisc/application/session.py +320 -0
  35. aisc/application/tool_versions.py +210 -0
  36. aisc/application/toolchain.py +91 -0
  37. aisc/application/update.py +286 -0
  38. aisc/application/usage.py +305 -0
  39. aisc/application/version.py +133 -0
  40. aisc/application/web_gateway.py +459 -0
  41. aisc/application/workspace_reconcile.py +318 -0
  42. aisc/applog.py +103 -0
  43. aisc/cli/__init__.py +1 -0
  44. aisc/cli/build_progress_parser.py +217 -0
  45. aisc/cli/commands/__init__.py +1 -0
  46. aisc/cli/commands/agents.py +117 -0
  47. aisc/cli/commands/artifact.py +210 -0
  48. aisc/cli/commands/build.py +566 -0
  49. aisc/cli/commands/bundle.py +107 -0
  50. aisc/cli/commands/cc_switch.py +158 -0
  51. aisc/cli/commands/config.py +104 -0
  52. aisc/cli/commands/container.py +787 -0
  53. aisc/cli/commands/conversation.py +72 -0
  54. aisc/cli/commands/data_root.py +84 -0
  55. aisc/cli/commands/logs.py +89 -0
  56. aisc/cli/commands/network.py +132 -0
  57. aisc/cli/commands/profile.py +52 -0
  58. aisc/cli/commands/provider.py +49 -0
  59. aisc/cli/commands/run.py +494 -0
  60. aisc/cli/commands/runs.py +125 -0
  61. aisc/cli/commands/runtime.py +450 -0
  62. aisc/cli/commands/serve.py +550 -0
  63. aisc/cli/commands/serve_fs.py +369 -0
  64. aisc/cli/commands/session.py +167 -0
  65. aisc/cli/commands/update.py +145 -0
  66. aisc/cli/commands/usage.py +74 -0
  67. aisc/cli/commands/wizard.py +130 -0
  68. aisc/cli/commands/workspaces.py +162 -0
  69. aisc/cli/help_i18n.py +385 -0
  70. aisc/cli/main.py +2926 -0
  71. aisc/cli/output.py +239 -0
  72. aisc/cli/tutorial.py +109 -0
  73. aisc/domain/__init__.py +1 -0
  74. aisc/domain/artifacts.py +279 -0
  75. aisc/domain/cc_switch_release.py +303 -0
  76. aisc/domain/config.py +64 -0
  77. aisc/domain/data_migration.py +259 -0
  78. aisc/domain/data_root.py +124 -0
  79. aisc/domain/docker_ownership.py +148 -0
  80. aisc/domain/gateway.py +168 -0
  81. aisc/domain/models.py +674 -0
  82. aisc/domain/toolchain.py +72 -0
  83. aisc/domain/web_services.py +355 -0
  84. aisc/domain/workspace_lease.py +139 -0
  85. aisc/schemas/__init__.py +1 -0
  86. aisc/schemas/config_schema.py +145 -0
  87. aisc_cli-0.1.0.dist-info/METADATA +420 -0
  88. aisc_cli-0.1.0.dist-info/RECORD +92 -0
  89. aisc_cli-0.1.0.dist-info/WHEEL +5 -0
  90. aisc_cli-0.1.0.dist-info/entry_points.txt +2 -0
  91. aisc_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
  92. aisc_cli-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,275 @@
1
+ """DataRootResolver — the only producer of the AISC data root (Stage 7, 7a).
2
+
3
+ Mirrors the branch style of ``application/artifact.py::data_root()`` (the
4
+ Stage-3 precedent this generalizes) with the Stage-7 contract layered on top
5
+ (D7-01/02/04/05):
6
+
7
+ - default root: ``%LOCALAPPDATA%\\AISC\\data`` on Windows, XDG data elsewhere
8
+ (Python CLI/container stay cross-platform per D-24);
9
+ - ``AISC_DATA_ROOT`` is an explicit dev/test/enterprise override only: it must
10
+ be absolute and must not overlap the workspace (fail closed — never fall
11
+ back to polluting the workspace);
12
+ - every existing path segment of the root is checked for reparse
13
+ points/symlinks (junction escapes, OneDrive placeholders) before use;
14
+ - ``resolve`` is READ-ONLY (lifecycle contract): it builds the structured
15
+ ``ResolvedDataRoot`` and reports writability; directory creation is
16
+ ``prepare`` (7b), never here.
17
+
18
+ Stable error codes: AISC_ERR_DATA_ROOT_OVERRIDE_RELATIVE,
19
+ AISC_ERR_DATA_ROOT_REPARSE_POINT, AISC_ERR_DATA_ROOT_WORKSPACE_OVERLAP.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import os
25
+ import stat
26
+ from pathlib import Path
27
+ from typing import Mapping, Optional
28
+
29
+ from aisc.domain.data_root import (
30
+ DATA_ROOT_PROTOCOL,
31
+ DATA_ROOT_SCHEMA_VERSION,
32
+ ERR_OVERRIDE_RELATIVE,
33
+ ERR_REPARSE_POINT,
34
+ ERR_WORKSPACE_OVERLAP,
35
+ SHARED_SUBDIRS,
36
+ WORKSPACE_SUBDIRS,
37
+ ResolvedDataRoot,
38
+ workspace_dir_name,
39
+ workspace_hash_v1,
40
+ )
41
+ from aisc.domain.models import CliError
42
+
43
+ ENV_OVERRIDE = "AISC_DATA_ROOT"
44
+
45
+ # Stable error codes are domain-owned (domain/data_root.py); re-exported here
46
+ # for callers that already import them from the resolver.
47
+ __all__ = [
48
+ "ENV_OVERRIDE",
49
+ "ERR_OVERRIDE_RELATIVE",
50
+ "ERR_REPARSE_POINT",
51
+ "ERR_WORKSPACE_OVERLAP",
52
+ "DataRootResolver",
53
+ ]
54
+
55
+ # windows.h FILE_ATTRIBUTE_REPARSE_POINT (symlinks, junctions, OneDrive
56
+ # placeholders, app-exec links — any tag, per D7-04 fail-closed).
57
+ _FILE_ATTRIBUTE_REPARSE_POINT = 0x0400
58
+
59
+
60
+ def _default_root(env: Mapping[str, str], is_windows: bool) -> Path:
61
+ """Platform default, injectable for tests (no os.environ/os.name reads)."""
62
+ if is_windows:
63
+ base = env.get("LOCALAPPDATA") or os.path.expanduser("~\\AppData\\Local")
64
+ return Path(base) / "AISC" / "data"
65
+ xdg = env.get("XDG_DATA_HOME")
66
+ if xdg:
67
+ return Path(xdg) / "aisc" / "data"
68
+ return Path.home() / ".local" / "share" / "aisc" / "data"
69
+
70
+
71
+ def _is_reparse_point(path: Path) -> bool:
72
+ """True if the path itself (not its target) is a reparse point/symlink."""
73
+ try:
74
+ st = os.lstat(path)
75
+ except OSError:
76
+ return False # non-existent segments are fine (created by prepare)
77
+ if stat.S_ISLNK(st.st_mode):
78
+ return True
79
+ attrs = getattr(st, "st_file_attributes", 0)
80
+ return bool(attrs & _FILE_ATTRIBUTE_REPARSE_POINT)
81
+
82
+
83
+ def _nearest_existing(path: Path) -> Optional[Path]:
84
+ """Deepest existing ancestor-or-self of *path* (None if even the drive is
85
+ missing — the writability probe is skipped then)."""
86
+ cur = path
87
+ while True:
88
+ if cur.exists():
89
+ return cur
90
+ parent = cur.parent
91
+ if parent == cur:
92
+ return None
93
+ cur = parent
94
+
95
+
96
+ class DataRootResolver:
97
+ """Resolves (and validates, but never creates) the canonical data root."""
98
+
99
+ def __init__(self, env: Optional[Mapping[str, str]] = None) -> None:
100
+ # ``env`` injection keeps resolver tests hermetic; production callers
101
+ # use the process environment (test_artifact_contract.py precedent).
102
+ self._env = os.environ if env is None else env
103
+
104
+ def resolve_shared_root(self) -> Path:
105
+ """The data root for MACHINE-GLOBAL (workspace-independent) state.
106
+
107
+ Same selection semantics as ``resolve`` minus the workspace-shaped
108
+ gates — a global file has no workspace to overlap-check against, and
109
+ anchoring a fake workspace (cwd/tempdir) false-positives whenever it
110
+ lands inside/around the real root (nas: $HOME, tests: AISC_DATA_ROOT
111
+ under tempdir). F2-C cli-runs.json is the first consumer.
112
+ """
113
+ return self._select_root(Path("/"))[0]
114
+
115
+ def resolve(self, workspace: Path) -> ResolvedDataRoot:
116
+ ws = Path(workspace)
117
+ root, origin = self._select_root(ws)
118
+
119
+ self._check_overlap(root, ws)
120
+ self._check_reparse_segments(root)
121
+
122
+ ws_hash = workspace_hash_v1(ws)
123
+ shared = {name: root / name for name in SHARED_SUBDIRS}
124
+ ws_root_dir = shared["workspaces"] / workspace_dir_name(ws_hash)
125
+ return ResolvedDataRoot(
126
+ schema=DATA_ROOT_PROTOCOL,
127
+ schema_version=DATA_ROOT_SCHEMA_VERSION,
128
+ root=root,
129
+ origin=origin,
130
+ workspace_hash=ws_hash,
131
+ writable=self._probe_writable(root),
132
+ shared_dirs=shared,
133
+ workspace_dirs={name: ws_root_dir / name for name in WORKSPACE_SUBDIRS},
134
+ )
135
+
136
+ # -- root selection ---------------------------------------------------
137
+
138
+ def _select_root(self, workspace: Path) -> tuple[Path, str]:
139
+ override = self._env.get(ENV_OVERRIDE, "")
140
+ if override:
141
+ # Whitespace edges are a misconfiguration, not something to strip
142
+ # silently (domain/config.py::_validate_absolute_root precedent).
143
+ if override != override.strip():
144
+ raise CliError(
145
+ f"{ENV_OVERRIDE} has leading/trailing whitespace: {override!r}",
146
+ exit_code=1,
147
+ error_code=ERR_OVERRIDE_RELATIVE,
148
+ )
149
+ if not Path(override).is_absolute():
150
+ raise CliError(
151
+ f"{ENV_OVERRIDE} must be an absolute path: {override!r}",
152
+ exit_code=1,
153
+ error_code=ERR_OVERRIDE_RELATIVE,
154
+ hint="Use an absolute path, or unset the variable to use "
155
+ f"the platform default ({ENV_OVERRIDE} is a "
156
+ "dev/test/enterprise override).",
157
+ )
158
+ return Path(override), "env"
159
+ return _default_root(self._env, os.name == "nt"), "default"
160
+
161
+ # -- containment (fail closed, D7-04) ---------------------------------
162
+
163
+ def _check_overlap(self, root: Path, workspace: Path) -> None:
164
+ """The data root and the workspace must be disjoint subtrees: a root
165
+ inside the workspace recreates DATA-01 pollution; a workspace inside
166
+ the root lets migration/quarantine touch user files."""
167
+ try:
168
+ root_rel = root.resolve().relative_to(workspace.resolve())
169
+ except ValueError:
170
+ root_rel = None
171
+ try:
172
+ ws_rel = workspace.resolve().relative_to(root.resolve())
173
+ except ValueError:
174
+ ws_rel = None
175
+ if root_rel is not None or ws_rel is not None:
176
+ which = "inside the workspace" if root_rel is not None else "contains the workspace"
177
+ raise CliError(
178
+ f"data root {which}: {root}",
179
+ exit_code=1,
180
+ error_code=ERR_WORKSPACE_OVERLAP,
181
+ hint=f"{ENV_OVERRIDE} must not overlap the opened workspace; "
182
+ "pick a directory outside it.",
183
+ )
184
+
185
+ def _check_reparse_segments(self, root: Path) -> None:
186
+ """Reject reparse points on any EXISTING segment of the root path
187
+ (01-risk-analysis: junction/symlink escapes → arbitrary paths)."""
188
+ cur = root
189
+ while True:
190
+ if _is_reparse_point(cur):
191
+ raise CliError(
192
+ f"data root path component is a reparse point/symlink: {cur}",
193
+ exit_code=1,
194
+ error_code=ERR_REPARSE_POINT,
195
+ hint="Point the data root at a real directory (not a "
196
+ "junction/symlink/OneDrive placeholder).",
197
+ )
198
+ parent = cur.parent
199
+ if parent == cur:
200
+ return
201
+ cur = parent
202
+
203
+ # -- writability (informational; prepare fails closed on real writes) --
204
+
205
+ def _probe_writable(self, root: Path) -> bool:
206
+ probe = _nearest_existing(root)
207
+ if probe is None:
208
+ return False
209
+ return os.access(probe, os.W_OK)
210
+
211
+
212
+ # ---------------------------------------------------------------------------
213
+ # State wiring (7e): the one boundary every registry/state caller uses
214
+ # ---------------------------------------------------------------------------
215
+
216
+ # Legacy AISC-owned state files adopted into the data root on first use
217
+ # (copy-when-absent, never overwrite — they carry no user data, so no
218
+ # consent gate applies; unknown files stay in the workspace until the
219
+ # user runs `aisc data-root migrate`).
220
+ _ADOPTED_STATE_FILES = ("containers.json", "state.env")
221
+
222
+
223
+ def _adopt_file(src: Path, dst: Path) -> None:
224
+ """Copy src→dst only if dst is absent (hardlink create = no-overwrite
225
+ even under a concurrent adopter; sources are never modified)."""
226
+ import shutil
227
+
228
+ if dst.exists() or not src.is_file():
229
+ return
230
+ dst.parent.mkdir(parents=True, exist_ok=True)
231
+ tmp = dst.with_name(f".{dst.name}.adopt.{os.getpid()}")
232
+ shutil.copyfile(src, tmp)
233
+ try:
234
+ os.link(tmp, dst)
235
+ except FileExistsError:
236
+ pass # concurrent adopter won
237
+ except OSError:
238
+ # Hardlinks unsupported (exotic fs): last-writer-checked copy.
239
+ if not dst.exists():
240
+ os.replace(tmp, dst)
241
+ return
242
+ finally:
243
+ try:
244
+ tmp.unlink()
245
+ except OSError:
246
+ pass
247
+
248
+
249
+ def shared_root(env=None) -> Path:
250
+ """The shared data root (no workspace coupling) for workspace-
251
+ independent trees like ``artifacts/`` — same env override, platform
252
+ default and reparse validation as the full resolver (fail closed)."""
253
+ resolver = DataRootResolver(env if env is not None else os.environ)
254
+ root, _origin = resolver._select_root(Path("_")) # placeholder ws, unused
255
+ resolver._check_reparse_segments(root)
256
+ return root
257
+
258
+
259
+ def workspace_state_dir(workspace: Path, *, env=None) -> Path:
260
+ """Canonical state dir for a workspace (``workspaces/<hash>/runtime``).
261
+
262
+ Adopts the legacy ``<workspace>/.aisc`` containers.json/state.env on
263
+ first use so pre-Stage-7 deployments keep their registry without a
264
+ manual migration. Resolver validation errors propagate (fail closed —
265
+ never silently fall back to writing the workspace).
266
+ """
267
+ resolved = DataRootResolver(env if env is not None else os.environ).resolve(
268
+ Path(workspace)
269
+ )
270
+ runtime_dir = resolved.workspace_dirs["runtime"]
271
+ legacy = Path(workspace).resolve() / ".aisc"
272
+ if legacy.is_dir():
273
+ for name in _ADOPTED_STATE_FILES:
274
+ _adopt_file(legacy / name, runtime_dir / name)
275
+ return runtime_dir