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.
- aisc/VERSION +1 -0
- aisc/__init__.py +49 -0
- aisc/__main__.py +6 -0
- aisc/adapters/__init__.py +1 -0
- aisc/adapters/config_reader.py +215 -0
- aisc/adapters/container_registry.py +718 -0
- aisc/adapters/data_root_store.py +296 -0
- aisc/adapters/docker_.py +1183 -0
- aisc/adapters/docker_gateway.py +1244 -0
- aisc/adapters/docker_sdk_backed.py +274 -0
- aisc/adapters/maintenance_lock.py +97 -0
- aisc/adapters/state_file.py +184 -0
- aisc/adapters/system.py +101 -0
- aisc/adapters/windows_config_reader.py +249 -0
- aisc/adapters/workspace_lease_store.py +189 -0
- aisc/application/__init__.py +1 -0
- aisc/application/artifact.py +265 -0
- aisc/application/bundle_fetch.py +836 -0
- aisc/application/cc_switch_provider.py +245 -0
- aisc/application/cc_switch_resolver.py +363 -0
- aisc/application/config_service.py +452 -0
- aisc/application/conversation.py +516 -0
- aisc/application/data_migration.py +440 -0
- aisc/application/data_root.py +275 -0
- aisc/application/docker_lifecycle.py +659 -0
- aisc/application/doctor.py +821 -0
- aisc/application/legacy_scan.py +252 -0
- aisc/application/network_subscription.py +642 -0
- aisc/application/profile_service.py +105 -0
- aisc/application/provider.py +92 -0
- aisc/application/repair.py +387 -0
- aisc/application/resources.py +270 -0
- aisc/application/runtime.py +1826 -0
- aisc/application/session.py +320 -0
- aisc/application/tool_versions.py +210 -0
- aisc/application/toolchain.py +91 -0
- aisc/application/update.py +286 -0
- aisc/application/usage.py +305 -0
- aisc/application/version.py +133 -0
- aisc/application/web_gateway.py +459 -0
- aisc/application/workspace_reconcile.py +318 -0
- aisc/applog.py +103 -0
- aisc/cli/__init__.py +1 -0
- aisc/cli/build_progress_parser.py +217 -0
- aisc/cli/commands/__init__.py +1 -0
- aisc/cli/commands/agents.py +117 -0
- aisc/cli/commands/artifact.py +210 -0
- aisc/cli/commands/build.py +566 -0
- aisc/cli/commands/bundle.py +107 -0
- aisc/cli/commands/cc_switch.py +158 -0
- aisc/cli/commands/config.py +104 -0
- aisc/cli/commands/container.py +787 -0
- aisc/cli/commands/conversation.py +72 -0
- aisc/cli/commands/data_root.py +84 -0
- aisc/cli/commands/logs.py +89 -0
- aisc/cli/commands/network.py +132 -0
- aisc/cli/commands/profile.py +52 -0
- aisc/cli/commands/provider.py +49 -0
- aisc/cli/commands/run.py +494 -0
- aisc/cli/commands/runs.py +125 -0
- aisc/cli/commands/runtime.py +450 -0
- aisc/cli/commands/serve.py +550 -0
- aisc/cli/commands/serve_fs.py +369 -0
- aisc/cli/commands/session.py +167 -0
- aisc/cli/commands/update.py +145 -0
- aisc/cli/commands/usage.py +74 -0
- aisc/cli/commands/wizard.py +130 -0
- aisc/cli/commands/workspaces.py +162 -0
- aisc/cli/help_i18n.py +385 -0
- aisc/cli/main.py +2926 -0
- aisc/cli/output.py +239 -0
- aisc/cli/tutorial.py +109 -0
- aisc/domain/__init__.py +1 -0
- aisc/domain/artifacts.py +279 -0
- aisc/domain/cc_switch_release.py +303 -0
- aisc/domain/config.py +64 -0
- aisc/domain/data_migration.py +259 -0
- aisc/domain/data_root.py +124 -0
- aisc/domain/docker_ownership.py +148 -0
- aisc/domain/gateway.py +168 -0
- aisc/domain/models.py +674 -0
- aisc/domain/toolchain.py +72 -0
- aisc/domain/web_services.py +355 -0
- aisc/domain/workspace_lease.py +139 -0
- aisc/schemas/__init__.py +1 -0
- aisc/schemas/config_schema.py +145 -0
- aisc_cli-0.1.0.dist-info/METADATA +420 -0
- aisc_cli-0.1.0.dist-info/RECORD +92 -0
- aisc_cli-0.1.0.dist-info/WHEEL +5 -0
- aisc_cli-0.1.0.dist-info/entry_points.txt +2 -0
- aisc_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- 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
|