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,566 @@
1
+ """``aisc build`` — Docker image build command.
2
+
3
+ Plans a ``docker build`` invocation based on user args + config/versions.env,
4
+ then optionally executes it. Supports ``--events`` JSONL output.
5
+
6
+ **All** docker operations go through an injected ``DockerExecutor``;
7
+ this module never calls ``subprocess.run`` directly.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import re
14
+ import sys as _sys
15
+ import time as _time
16
+ from dataclasses import dataclass, field, asdict
17
+ from pathlib import Path
18
+ from typing import Any, Dict, List, Optional
19
+
20
+ from aisc.domain.models import BuildPlan, CliError
21
+ from aisc.adapters.docker_ import (
22
+ DockerExecutor,
23
+ RealDockerExecutor,
24
+ validate_build_resources,
25
+ )
26
+ from aisc.application.version import _parse_versions_env
27
+ from aisc.cli.build_progress_parser import BuildProgressParser, parse_context_bytes
28
+ from aisc.cli.output import JsonlEmitter
29
+
30
+
31
+ # ---------------------------------------------------------------------------
32
+ # Build service: plan the build
33
+ # ---------------------------------------------------------------------------
34
+
35
+ @dataclass(frozen=True)
36
+ class _BuildEnv:
37
+ """Parsed values from config/versions.env used during build."""
38
+ use_cn_mirror: str = "1"
39
+ node_image: str = ""
40
+ node_image_cn: str = ""
41
+ node_image_mirrors: tuple = ()
42
+ # A5 (D-9): tool pins forwarded as build args — factory values layered
43
+ # under the data-root USER pin overlay (tool_versions.py, D-27).
44
+ claude_code_version: str = "latest"
45
+ codex_version: str = "latest"
46
+
47
+
48
+ def _parse_build_env(root: Path, data_root: Optional[Path] = None) -> _BuildEnv:
49
+ """Parse build-relevant values from config/versions.env.
50
+
51
+ A5: tool versions resolve through the D-27 overlay — factory base from
52
+ the root, per-key overrides from the data-root user layer."""
53
+ from aisc.application.tool_versions import effective_tool_versions
54
+
55
+ env = _parse_versions_env(root)
56
+ eff = effective_tool_versions(root, data_root)
57
+ claude = eff.get("CLAUDE_CODE_VERSION", ("latest", "default"))[0]
58
+ codex = eff.get("CODEX_VERSION", ("latest", "default"))[0]
59
+ use_cn = env.get("USE_CN_MIRROR", "1")
60
+ node_image = env.get("NODE_IMAGE", "")
61
+ node_image_cn = env.get("NODE_IMAGE_CN", "")
62
+ # T8a: comma-separated registry prefixes for the pre-pull chain.
63
+ mirrors = tuple(
64
+ m.strip().rstrip("/")
65
+ for m in env.get("NODE_IMAGE_MIRRORS", "").split(",")
66
+ if m.strip()
67
+ )
68
+ return _BuildEnv(
69
+ use_cn_mirror=use_cn, node_image=node_image,
70
+ node_image_cn=node_image_cn, node_image_mirrors=mirrors,
71
+ claude_code_version=claude or "latest",
72
+ codex_version=codex or "latest",
73
+ )
74
+
75
+
76
+ def _open_build_log() -> Optional[Path]:
77
+ """Create a timestamped build log under ``<data-root>/logs`` (Gate-S4 §1:
78
+ the UI keeps only a bounded tail; the complete raw output lives here and
79
+ ``build.start`` carries its path). Best-effort — never blocks the build."""
80
+ try:
81
+ from aisc.application.data_root import shared_root
82
+ logs = shared_root() / "logs"
83
+ logs.mkdir(parents=True, exist_ok=True)
84
+ path = logs / f"build-{int(_time.time())}.log"
85
+ path.write_text("", encoding="utf-8")
86
+ return path
87
+ except Exception:
88
+ return None
89
+
90
+
91
+ def _previous_context_total(skip_log: Optional[Path]) -> Optional[float]:
92
+ """The FINAL `transferring context: X done` bytes from the most recent
93
+ PREVIOUS build log — the estimated denominator for the prepare-phase
94
+ progress bar (Gate-S4 §1 amendment). Best-effort; absence = the bar
95
+ simply stays indeterminate."""
96
+ try:
97
+ from aisc.application.data_root import shared_root
98
+ logs = shared_root() / "logs"
99
+ candidates = sorted(
100
+ (p for p in logs.glob("build-*.log") if p != skip_log),
101
+ key=lambda p: p.name,
102
+ reverse=True,
103
+ )
104
+ for path in candidates[:3]: # newest few; tolerate partial logs
105
+ try:
106
+ text = path.read_text(encoding="utf-8", errors="replace")
107
+ except OSError:
108
+ continue
109
+ last: Optional[float] = None
110
+ for line in text.splitlines():
111
+ stripped = line.strip()
112
+ if stripped.endswith("done") or stripped.endswith("done)"):
113
+ val = parse_context_bytes(stripped)
114
+ if val:
115
+ last = val
116
+ if last:
117
+ return last
118
+ except Exception:
119
+ pass
120
+ return None
121
+
122
+
123
+ # Dockerfile instructions that count as build steps (candidate step_total).
124
+ _INSTRUCTION_RE = re.compile(
125
+ r"^\s*(?:FROM|RUN|CMD|LABEL|MAINTAINER|EXPOSE|ENV|ADD|COPY|ENTRYPOINT"
126
+ r"|VOLUME|USER|WORKDIR|ARG|ONBUILD|STOPSIGNAL|HEALTHCHECK|SHELL)\b",
127
+ re.IGNORECASE,
128
+ )
129
+
130
+
131
+ def _dockerfile_instruction_count(plan: BuildPlan) -> Optional[int]:
132
+ """Metadata only (Gate-S4): the Dockerfile's instruction count as a
133
+ candidate ``step_total``. build.progress itself only trusts what the
134
+ docker output actually maps; this feeds the plan event for consumers
135
+ that want an early hint."""
136
+ try:
137
+ if not plan.root or not plan.dockerfile:
138
+ return None
139
+ text = (Path(plan.root) / plan.dockerfile).read_text(encoding="utf-8")
140
+ except OSError:
141
+ return None
142
+ count = 0
143
+ for line in text.splitlines():
144
+ stripped = line.strip()
145
+ if not stripped or stripped.startswith("#"):
146
+ continue
147
+ if _INSTRUCTION_RE.match(stripped):
148
+ count += 1
149
+ return count or None
150
+
151
+
152
+ def plan_build(
153
+ root: Path,
154
+ tag: str = "super-claude:latest",
155
+ no_cache: bool = False,
156
+ pull: bool = False,
157
+ dry_run: bool = False,
158
+ cc_switch: Optional[Any] = None,
159
+ data_root: Optional[Path] = None,
160
+ ) -> BuildPlan:
161
+ """Create an immutable ``BuildPlan`` from user args.
162
+
163
+ Args:
164
+ cc_switch: a ``ResolvedRelease`` (aisc.domain.cc_switch_release) from
165
+ the resolver — injected as docker build args + image labels (Stage
166
+ 8b, CS-01/CS-02). ``None`` keeps the legacy ARG-fallback path
167
+ (manual docker builds only; never used by ``aisc build``).
168
+
169
+ Returns a ``BuildPlan`` with the computed ``docker argv``.
170
+
171
+ Raises:
172
+ CliError (exit 1, AISC_ERR_GENERAL): if Dockerfile, versions.env,
173
+ or NODE_IMAGE are missing. These are planning errors,
174
+ NOT build failures (exit 4).
175
+ """
176
+ # Validate resources — planning phase, exit 1 on missing
177
+ try:
178
+ validate_build_resources(root)
179
+ except FileNotFoundError as exc:
180
+ raise CliError(message=str(exc), exit_code=1,
181
+ error_code="AISC_ERR_GENERAL") from exc
182
+
183
+ dockerfile = str(root / "container" / "Dockerfile")
184
+ build_env = _parse_build_env(root, data_root)
185
+
186
+ if not build_env.node_image:
187
+ raise CliError(
188
+ message="NODE_IMAGE is missing from config/versions.env. "
189
+ "Please add NODE_IMAGE=<base-image> to config/versions.env.",
190
+ exit_code=1,
191
+ error_code="AISC_ERR_GENERAL",
192
+ )
193
+
194
+ # If tag is just a name without tag (no ':'), append ':latest'
195
+ if ":" not in tag:
196
+ tag = f"{tag}:latest"
197
+
198
+ # Select node image based on USE_CN_MIRROR
199
+ selected_node_image = build_env.node_image
200
+ if build_env.use_cn_mirror == "1" and build_env.node_image_cn:
201
+ selected_node_image = build_env.node_image_cn
202
+
203
+ cc_kwargs: Dict[str, str] = {}
204
+ if cc_switch is not None:
205
+ manifest = cc_switch.to_manifest()
206
+ # Compact label JSON — keep the OCI label small and stable-shaped.
207
+ label_manifest = json.dumps(
208
+ {
209
+ "schema": manifest["schema"],
210
+ "channel": manifest["channel"],
211
+ "version": manifest["version"],
212
+ "commit": manifest["commit"],
213
+ "asset_sha256": manifest["asset_sha256"],
214
+ "asset_name": manifest["asset_name"],
215
+ "source": manifest["source"],
216
+ },
217
+ separators=(",", ":"),
218
+ )
219
+ cc_kwargs = {
220
+ "cc_switch_version": cc_switch.tag,
221
+ "cc_switch_commit": cc_switch.commit,
222
+ "cc_switch_asset_url": cc_switch.asset_url,
223
+ "cc_switch_asset_sha256": cc_switch.asset_sha256,
224
+ "cc_switch_asset_name": cc_switch.asset_name,
225
+ "cc_switch_manifest": label_manifest,
226
+ }
227
+
228
+ return BuildPlan(
229
+ tag=tag,
230
+ root=str(root),
231
+ dockerfile=dockerfile,
232
+ no_cache=no_cache,
233
+ pull=pull,
234
+ build_arg_use_cn_mirror=build_env.use_cn_mirror,
235
+ node_image_mirrors=build_env.node_image_mirrors,
236
+ build_arg_node_image=selected_node_image,
237
+ build_arg_claude_code_version=build_env.claude_code_version,
238
+ build_arg_codex_version=build_env.codex_version,
239
+ dry_run=dry_run,
240
+ **cc_kwargs,
241
+ )
242
+
243
+
244
+ # ---------------------------------------------------------------------------
245
+ # Build result (structured outcome)
246
+ # ---------------------------------------------------------------------------
247
+
248
+ @dataclass
249
+ class BuildResult:
250
+ """Result of a build execution — all fields populated even on failure."""
251
+ image_tag: str = ""
252
+ docker_argv: List[str] = field(default_factory=list)
253
+ docker_exit_code: Optional[int] = None
254
+ dry_run: bool = False
255
+ executed: bool = False
256
+ """Stage 8b: resolved cc-switch summary (version/sha256/source/...) and
257
+ the manifest file path — the reproducibility receipt for the build."""
258
+ cc_switch: Dict[str, Any] = field(default_factory=dict)
259
+ cc_switch_manifest_path: str = ""
260
+
261
+ def to_dict(self) -> Dict[str, Any]:
262
+ return {
263
+ "image_tag": self.image_tag,
264
+ "dry_run": self.dry_run,
265
+ "executed": self.executed,
266
+ "docker_argv": list(self.docker_argv),
267
+ "docker_exit_code": self.docker_exit_code,
268
+ "cc_switch": dict(self.cc_switch),
269
+ "cc_switch_manifest_path": self.cc_switch_manifest_path,
270
+ }
271
+
272
+
273
+ # ---------------------------------------------------------------------------
274
+ # Run the build (orchestration)
275
+ # ---------------------------------------------------------------------------
276
+
277
+ # T8a (2.1.9 D-9): host-side base-image pre-pull through a mirror chain.
278
+ # Evidence: nairong's first build crawled docker.1ms.run for ~10 minutes and
279
+ # was abandoned — buildkit's FROM has no mirror fallback, no retry, no
280
+ # guidance. aisc build therefore pre-pulls the base itself: local hit →
281
+ # zero docker calls; otherwise walk the chain (selected image first, then
282
+ # NODE_IMAGE_MIRRORS entries), `docker tag` the first success to the bare
283
+ # local name so buildkit hits the local store, and only fail (with guidance)
284
+ # when every mirror failed.
285
+ _PULL_TIMEOUT_S = 600.0
286
+
287
+
288
+ def _base_image_candidates(plan: "BuildPlan") -> list:
289
+ """Pull candidates in priority order: the plan's selected image, then
290
+ each mirror prefix joined with the bare image name (deduped)."""
291
+ selected = plan.build_arg_node_image
292
+ bare = selected.rsplit("/", 1)[-1]
293
+ seen = {selected}
294
+ out = [selected]
295
+ for prefix in plan.node_image_mirrors:
296
+ ref = f"{str(prefix).rstrip('/')}/{bare}"
297
+ if ref not in seen:
298
+ seen.add(ref)
299
+ out.append(ref)
300
+ return out
301
+
302
+
303
+ def _ensure_base_image(exec_, plan, emitter, streaming: bool) -> str:
304
+ """Pre-pull the base image through the mirror chain.
305
+
306
+ Returns the node image reference the build argv should use (the bare
307
+ local name once tagged). Raises CliError (exit 4) when every candidate
308
+ fails — with actionable guidance for mainland-network users.
309
+ """
310
+ from aisc.domain.models import ImageInspectStatus
311
+
312
+ candidates = _base_image_candidates(plan)
313
+ bare = candidates[0].rsplit("/", 1)[-1]
314
+
315
+ def _say(line: str) -> None:
316
+ if emitter is not None:
317
+ emitter.emit("build.output", data={"stream": "stderr", "chunk": line})
318
+ if streaming and emitter is None:
319
+ _sys.stderr.write(line)
320
+ _sys.stderr.flush()
321
+
322
+ # Local store hit → buildkit needs no network at all.
323
+ if exec_.inspect_image(bare).status == ImageInspectStatus.EXISTS:
324
+ return bare
325
+
326
+ for ref in candidates:
327
+ _say(f"⬇️ 基础镜像 try: {ref}\n")
328
+ proc = exec_.run_captured(["pull", ref], timeout=_PULL_TIMEOUT_S)
329
+ if proc.exit_code == 0:
330
+ if ref == bare:
331
+ return bare
332
+ # Retag to the bare local name; if tagging fails, build directly
333
+ # from the pulled (now local) reference instead of failing.
334
+ if exec_.run_captured(["tag", ref, bare], timeout=30.0).exit_code == 0:
335
+ return bare
336
+ return ref
337
+
338
+ raise CliError(
339
+ message=(
340
+ "基础镜像预拉失败(所有镜像源均不可达):" + ", ".join(candidates) +
341
+ "。可依次尝试:① Docker Desktop 设置里配置 registry-mirrors 后重试;"
342
+ "② 手动 docker pull <任一镜像源>/node:20-slim 成功后重新构建;"
343
+ "③ 切换到可直连 Docker Hub 的网络。"
344
+ ),
345
+ exit_code=4, error_code="AISC_ERR_BUILD_FAILED",
346
+ data={"base_image_candidates": candidates},
347
+ )
348
+
349
+
350
+ def _rewrite_node_image(argv: list, new_value: str) -> list:
351
+ """Replace the NODE_IMAGE build-arg value in a docker build argv copy."""
352
+ out = list(argv)
353
+ for i, tok in enumerate(out):
354
+ if tok == "--build-arg" and i + 1 < len(out) and out[i + 1].startswith("NODE_IMAGE="):
355
+ out[i + 1] = f"NODE_IMAGE={new_value}"
356
+ break
357
+ return out
358
+
359
+
360
+ def run_build(
361
+ plan: BuildPlan,
362
+ *,
363
+ emitter: Optional[JsonlEmitter] = None,
364
+ executor: Optional[DockerExecutor] = None,
365
+ streaming: bool = False,
366
+ cc_switch_summary: Optional[Dict[str, Any]] = None,
367
+ cc_switch_manifest_path: str = "",
368
+ ) -> BuildResult:
369
+ """Execute or simulate a build, emitting events as needed.
370
+
371
+ All docker operations go through *executor* (RealDockerExecutor by default).
372
+
373
+ Args:
374
+ plan: The immutable build plan.
375
+ emitter: JsonlEmitter for --events mode, or None.
376
+ executor: DockerExecutor for injection (testing).
377
+ streaming:
378
+ ``True`` (text mode) — use ``run_streaming`` with inherited
379
+ stdout/stderr.
380
+ ``False`` (JSON/events mode) — use ``run_captured`` and forward
381
+ docker stdout/stderr to stderr so stdout stays pure envelope/JSONL.
382
+ cc_switch_summary / cc_switch_manifest_path: Stage 8b resolver facts
383
+ surfaced in BuildResult (and thus in error payloads too).
384
+
385
+ Returns:
386
+ BuildResult with outcome data.
387
+
388
+ Raises:
389
+ CliError: with ``data`` field carrying full BuildResult.to_dict().
390
+ """
391
+ exec_ = executor or RealDockerExecutor()
392
+
393
+ if cc_switch_summary is None and plan.cc_switch_version:
394
+ # Derive from the plan's label manifest so error payloads still carry
395
+ # the pin even when the caller passed no explicit summary.
396
+ try:
397
+ cc_switch_summary = json.loads(plan.cc_switch_manifest)
398
+ except json.JSONDecodeError:
399
+ cc_switch_summary = {"version": plan.cc_switch_version}
400
+
401
+ result = BuildResult(
402
+ image_tag=plan.tag,
403
+ docker_argv=list(plan.docker_argv),
404
+ dry_run=plan.dry_run,
405
+ executed=False,
406
+ cc_switch=dict(cc_switch_summary or {}),
407
+ cc_switch_manifest_path=cc_switch_manifest_path,
408
+ )
409
+
410
+ # --- emit start event ---
411
+ # v2.1.7 S4 (Gate-S4): build.start carries the full-log path — the raw
412
+ # docker output lands there while the UI keeps only a bounded tail.
413
+ build_log_path = _open_build_log() if emitter is not None else None
414
+ if emitter is not None:
415
+ emitter.emit("build.start", data={
416
+ "image_tag": plan.tag,
417
+ "log_path": str(build_log_path) if build_log_path else None,
418
+ })
419
+
420
+ # --- dry-run: zero docker calls ---
421
+ if plan.dry_run:
422
+ if emitter is not None:
423
+ emitter.emit("build.plan", data={
424
+ "docker_argv": result.docker_argv,
425
+ "dry_run": True,
426
+ "cc_switch": result.cc_switch,
427
+ })
428
+ return result
429
+
430
+ # --- preflight ---
431
+ pf = exec_.preflight()
432
+ if not pf.available:
433
+ raise CliError(
434
+ message=f"Docker unavailable: {pf.reason}",
435
+ exit_code=pf.exit_code,
436
+ error_code=pf.error_code,
437
+ data=result.to_dict(),
438
+ )
439
+
440
+ # --- check if image already exists ---
441
+ from aisc.domain.models import ImageInspectStatus
442
+ inspect_result = exec_.inspect_image(plan.tag)
443
+ image_exists = inspect_result.status == ImageInspectStatus.EXISTS
444
+ if image_exists and streaming and emitter is None:
445
+ # Text mode: warn user about existing image
446
+ _sys.stderr.write(f"\n⚠️ Image already exists: {plan.tag}\n")
447
+ _sys.stderr.write(" Building will replace it (may create dangling <none> images).\n")
448
+ _sys.stderr.write(" Tip: Use 'docker rmi {tag}' before build, or use a different tag.\n\n")
449
+ _sys.stderr.flush()
450
+
451
+ # --- plan event (non-dry-run) ---
452
+ if emitter is not None:
453
+ emitter.emit("build.plan", data={
454
+ "docker_argv": result.docker_argv,
455
+ "image_exists": image_exists,
456
+ "cc_switch": result.cc_switch,
457
+ # Gate-S4: Dockerfile instruction count as candidate step_total
458
+ # metadata; build.progress only trusts what the output maps.
459
+ "step_total": _dockerfile_instruction_count(plan),
460
+ })
461
+
462
+ # --- T8a: pre-pull the base image through the mirror chain (CN 网络兜底;
463
+ # local hit is a zero-IPC no-op) and point the build at the local name ---
464
+ effective_node_image = _ensure_base_image(exec_, plan, emitter, streaming)
465
+
466
+ # --- execute docker build ---
467
+ argv = _rewrite_node_image(plan.docker_argv, effective_node_image)
468
+ result.docker_argv = argv # error payloads carry the argv that actually ran
469
+
470
+ if streaming:
471
+ # Text mode: inherit streams for real-time build log
472
+ proc = exec_.run_streaming(argv)
473
+ result.docker_exit_code = proc.exit_code if proc.exit_code >= 0 else proc.exit_code
474
+ result.executed = True
475
+
476
+ if proc.command_not_found:
477
+ raise CliError(
478
+ message="Docker CLI not found",
479
+ exit_code=3, error_code="AISC_ERR_DOCKER_UNAVAILABLE",
480
+ data=result.to_dict(),
481
+ )
482
+ if proc.timed_out:
483
+ raise CliError(
484
+ message="Docker build timed out",
485
+ exit_code=1, error_code="AISC_ERR_GENERAL",
486
+ data=result.to_dict(),
487
+ )
488
+ if proc.exit_code != 0:
489
+ raise CliError(
490
+ message=f"Docker build failed (exit {proc.exit_code})",
491
+ exit_code=4, error_code="AISC_ERR_BUILD_FAILED",
492
+ data=result.to_dict(),
493
+ )
494
+ elif emitter is not None:
495
+ # --events: stream docker output as real-time build.output events and
496
+ # (v2.1.7 S4, Gate-S4) derive structured build.progress updates from
497
+ # the SAME bytes — the emitter is the only progress fact source; the
498
+ # UI never parses build.output. Raw chunks also append to the
499
+ # build.start log file.
500
+ progress = BuildProgressParser(
501
+ context_total_bytes=_previous_context_total(build_log_path)
502
+ )
503
+
504
+ def _on_chunk(stream: str, chunk: str) -> None:
505
+ if build_log_path is not None:
506
+ try:
507
+ with open(build_log_path, "a", encoding="utf-8", errors="replace") as _lf:
508
+ _lf.write(chunk)
509
+ except OSError:
510
+ pass # logging must never break the build
511
+ emitter.emit("build.output", data={"stream": stream, "chunk": chunk})
512
+ for upd in progress.feed(chunk):
513
+ emitter.emit("build.progress", data=asdict(upd))
514
+
515
+ try:
516
+ proc = exec_.run_streaming_captured(argv, _on_chunk)
517
+ except KeyboardInterrupt:
518
+ # Cancel: the executor already killpg'd the docker child. Emit the
519
+ # terminal build.cancelled with a resource summary and exit 130
520
+ # (bypasses main.py's generic handler so we control the payload).
521
+ emitter.emit_terminal("build.cancelled", 130, extra_data={
522
+ "image_tag": plan.tag,
523
+ "docker_exit_code": None,
524
+ "reason": "cancelled",
525
+ })
526
+ _sys.exit(130)
527
+ result.docker_exit_code = proc.exit_code
528
+ result.executed = True
529
+ if proc.command_not_found:
530
+ raise CliError(
531
+ message="Docker CLI not found",
532
+ exit_code=3, error_code="AISC_ERR_DOCKER_UNAVAILABLE",
533
+ data=result.to_dict(),
534
+ )
535
+ if proc.timed_out:
536
+ raise CliError(
537
+ message="Docker build timed out",
538
+ exit_code=1, error_code="AISC_ERR_GENERAL",
539
+ data=result.to_dict(),
540
+ )
541
+ if proc.exit_code != 0:
542
+ raise CliError(
543
+ message=f"Docker build failed (exit {proc.exit_code})",
544
+ exit_code=4, error_code="AISC_ERR_BUILD_FAILED",
545
+ data=result.to_dict(),
546
+ )
547
+ else:
548
+ # --format json: capture, forward docker output to stderr (stdout pure).
549
+ proc = exec_.run_captured(argv)
550
+ result.docker_exit_code = proc.exit_code
551
+ result.executed = True
552
+
553
+ if proc.stdout:
554
+ _sys.stderr.write(proc.stdout)
555
+ if proc.stderr:
556
+ _sys.stderr.write(proc.stderr)
557
+
558
+ if proc.exit_code != 0:
559
+ raise CliError(
560
+ message=f"Docker build failed (exit {proc.exit_code})",
561
+ exit_code=4, error_code="AISC_ERR_BUILD_FAILED",
562
+ data=result.to_dict(),
563
+ )
564
+
565
+ # --- success: main.py emits the build.complete terminal event ---
566
+ return result
@@ -0,0 +1,107 @@
1
+ """``aisc bundle`` command group (0.1.0 A3, guide 3.3.3).
2
+
3
+ fetch / list / remove / path over the data-root bundle store
4
+ (``<data-root>/bundles/<ver>/``). Non-interactive by contract; all output
5
+ is JSON-serializable for the aisc.cli/v1 envelope; text mode prints a
6
+ short summary. Network failures fail CLOSED with the three manual escape
7
+ hatches in the copy (manual URL / --from-file / --aisc-root).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from pathlib import Path
13
+ from typing import Any, Dict, Optional
14
+
15
+ from aisc import __version__
16
+ from aisc.application.bundle_fetch import (
17
+ AISC_RELEASE_REPO,
18
+ BundleFetchError,
19
+ BundleFetcher,
20
+ )
21
+ from aisc.application.data_root import shared_root
22
+ from aisc.domain.models import CliError
23
+
24
+
25
+ def _map_error(exc: BundleFetchError) -> CliError:
26
+ return CliError(message=exc.message, exit_code=1, error_code="AISC_ERR_GENERAL")
27
+
28
+
29
+ def _active_root() -> Optional[Path]:
30
+ from aisc.application.resources import locate_aisc_root
31
+
32
+ try:
33
+ return locate_aisc_root()
34
+ except Exception:
35
+ return None
36
+
37
+
38
+ def cmd_bundle_fetch(
39
+ *,
40
+ version: Optional[str] = None,
41
+ from_file: Optional[str] = None,
42
+ sha256: Optional[str] = None,
43
+ allow_mismatch: bool = False,
44
+ ) -> Dict[str, Any]:
45
+ fetcher = BundleFetcher()
46
+ try:
47
+ return fetcher.fetch(
48
+ shared_root(),
49
+ __version__,
50
+ version=version,
51
+ from_file=Path(from_file) if from_file else None,
52
+ sha256=sha256,
53
+ allow_mismatch=allow_mismatch,
54
+ )
55
+ except BundleFetchError as exc:
56
+ raise _map_error(exc) from exc
57
+
58
+
59
+ def cmd_bundle_list() -> Dict[str, Any]:
60
+ fetcher = BundleFetcher()
61
+ versions = fetcher.installed_versions(shared_root())
62
+ active = _active_root()
63
+ active_version = None
64
+ if active is not None:
65
+ # bundles/<ver>/aisc-bundle -> <ver>
66
+ try:
67
+ active_version = active.resolve().parent.name
68
+ except OSError:
69
+ active_version = None
70
+ return {
71
+ "repo": AISC_RELEASE_REPO,
72
+ "installed": versions,
73
+ "active_version": active_version,
74
+ "cli_version": __version__,
75
+ }
76
+
77
+
78
+ def cmd_bundle_remove(version: str) -> Dict[str, Any]:
79
+ fetcher = BundleFetcher()
80
+ try:
81
+ return fetcher.remove(shared_root(), version, _active_root())
82
+ except BundleFetchError as exc:
83
+ raise _map_error(exc) from exc
84
+
85
+
86
+ def cmd_bundle_path() -> Dict[str, Any]:
87
+ return {"bundles_root": str(BundleFetcher().bundles_root(shared_root()))}
88
+
89
+
90
+ def print_bundle_text(sub: str, data: Any) -> None:
91
+ """Minimal human-readable output for text mode."""
92
+ if not isinstance(data, dict):
93
+ return
94
+ if sub == "fetch":
95
+ print(f"status : {data.get('status')}")
96
+ print(f"version: {data.get('version')}")
97
+ print(f"path : {data.get('path')}")
98
+ elif sub == "list":
99
+ print(f"repo : {data.get('repo')}")
100
+ print(f"cli : {data.get('cli_version')}")
101
+ for v in data.get("installed", []):
102
+ mark = " (active)" if v == data.get("active_version") else ""
103
+ print(f" {v}{mark}")
104
+ elif sub == "remove":
105
+ print(f"removed: {data.get('version')}")
106
+ elif sub == "path":
107
+ print(data.get("bundles_root", ""))