dna-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.
- dna_cli/__init__.py +87 -0
- dna_cli/_active_story.py +134 -0
- dna_cli/_banner.py +61 -0
- dna_cli/_ctx.py +433 -0
- dna_cli/_git_symbiosis.py +167 -0
- dna_cli/_github_bridge.py +294 -0
- dna_cli/_methodology_gates.py +164 -0
- dna_cli/data/git-hooks/prepare-commit-msg +132 -0
- dna_cli/doc_cmd.py +842 -0
- dna_cli/docs_cmd.py +79 -0
- dna_cli/eval_cmd.py +274 -0
- dna_cli/hooks_cmd.py +146 -0
- dna_cli/install_cmd.py +496 -0
- dna_cli/issue_bridge_cmd.py +305 -0
- dna_cli/kind_cmd.py +84 -0
- dna_cli/memory_cmd.py +298 -0
- dna_cli/pr_cmd.py +251 -0
- dna_cli/recall_cmd.py +161 -0
- dna_cli/research_cmd.py +284 -0
- dna_cli/scope_cmd.py +129 -0
- dna_cli/sdlc_cmd.py +6549 -0
- dna_cli/source_cmd.py +437 -0
- dna_cli/testkit_cmd.py +352 -0
- dna_cli-0.1.0.dist-info/METADATA +57 -0
- dna_cli-0.1.0.dist-info/RECORD +28 -0
- dna_cli-0.1.0.dist-info/WHEEL +4 -0
- dna_cli-0.1.0.dist-info/entry_points.txt +2 -0
- dna_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
dna_cli/doc_cmd.py
ADDED
|
@@ -0,0 +1,842 @@
|
|
|
1
|
+
"""``dna doc`` — CRUD on documents within a scope.
|
|
2
|
+
|
|
3
|
+
Migrated to dna-client for read/write CRUD (no local kernel needed
|
|
4
|
+
for the common path). `apply` is the lone exception: it walks a
|
|
5
|
+
bundle directory or markdown marker file and needs the kernel's
|
|
6
|
+
registered Kinds to resolve marker→kind — that read happens in-process
|
|
7
|
+
via dna_session. Migration of `apply` requires a server-side endpoint
|
|
8
|
+
that accepts the bundle+marker and resolves kind itself (TODO).
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json as _json
|
|
13
|
+
import sys
|
|
14
|
+
from pathlib import Path # noqa: F401 — used in deferred-eval annotations
|
|
15
|
+
|
|
16
|
+
import click
|
|
17
|
+
|
|
18
|
+
from dna_cli._ctx import (
|
|
19
|
+
dna_client,
|
|
20
|
+
dna_session,
|
|
21
|
+
fail,
|
|
22
|
+
print_json,
|
|
23
|
+
print_table,
|
|
24
|
+
run_async,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _tenant_write_note(tenant: str | None) -> tuple[str | None, str | None]:
|
|
29
|
+
"""Resolve the EFFECTIVE write tenant (``--tenant`` > ``DNA_TENANT`` >
|
|
30
|
+
unbound) and a warning (i-020).
|
|
31
|
+
|
|
32
|
+
Returns ``(effective, warning)``. ``warning`` is set when the effective
|
|
33
|
+
tenant is non-null but absent from ``DNA_DEV_ALLOWED_TENANTS`` — the silent
|
|
34
|
+
trap where a write lands in a tenant the Studio never browses (e.g. the old
|
|
35
|
+
``dev-tenant`` default while Studio browses ``acme``)."""
|
|
36
|
+
import os
|
|
37
|
+
effective = tenant if tenant is not None else os.getenv("DNA_TENANT")
|
|
38
|
+
warning: str | None = None
|
|
39
|
+
allowed_env = os.getenv("DNA_DEV_ALLOWED_TENANTS")
|
|
40
|
+
if effective and allowed_env:
|
|
41
|
+
allowed = [t.strip() for t in allowed_env.split(",") if t.strip()]
|
|
42
|
+
if allowed and effective not in allowed:
|
|
43
|
+
warning = (
|
|
44
|
+
f"tenant '{effective}' não está em DNA_DEV_ALLOWED_TENANTS "
|
|
45
|
+
f"({', '.join(allowed)}) — o doc pode ficar invisível no Studio"
|
|
46
|
+
)
|
|
47
|
+
return effective, warning
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@click.group("doc", help="List, show, create, edit, delete documents.")
|
|
51
|
+
def doc() -> None:
|
|
52
|
+
"""Group root."""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@doc.command("list")
|
|
56
|
+
@click.argument("kind_name")
|
|
57
|
+
@click.option("--scope", default="dna-development")
|
|
58
|
+
@click.option("--tenant", default=None, help="Bind to this tenant (overrides DNA_TENANT).")
|
|
59
|
+
@click.option("--json", "as_json", is_flag=True)
|
|
60
|
+
def list_docs(
|
|
61
|
+
kind_name: str, scope: str, tenant: str | None, as_json: bool,
|
|
62
|
+
) -> None:
|
|
63
|
+
"""List documents of a Kind in the scope."""
|
|
64
|
+
with dna_client(tenant=tenant) as dna:
|
|
65
|
+
try:
|
|
66
|
+
body = run_async(dna.docs(scope).list(kind=kind_name))
|
|
67
|
+
except Exception as e: # noqa: BLE001
|
|
68
|
+
raise fail(f"docs.list failed: {e}") from e
|
|
69
|
+
items = body.get("items") if isinstance(body, dict) else body
|
|
70
|
+
if not isinstance(items, list):
|
|
71
|
+
items = []
|
|
72
|
+
rows = [
|
|
73
|
+
{
|
|
74
|
+
"name": (it.get("metadata", {}) or {}).get("name") or it.get("name") or "?",
|
|
75
|
+
"kind": it.get("kind") or kind_name,
|
|
76
|
+
}
|
|
77
|
+
for it in items
|
|
78
|
+
]
|
|
79
|
+
rows.sort(key=lambda r: r["name"])
|
|
80
|
+
if as_json:
|
|
81
|
+
print_json(rows)
|
|
82
|
+
else:
|
|
83
|
+
print_table(rows, ["name", "kind"])
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@doc.command("show")
|
|
87
|
+
@click.argument("kind_name")
|
|
88
|
+
@click.argument("doc_name")
|
|
89
|
+
@click.option("--scope", default="dna-development")
|
|
90
|
+
@click.option("--tenant", default=None, help="Bind to this tenant (overrides DNA_TENANT).")
|
|
91
|
+
def show(
|
|
92
|
+
kind_name: str, doc_name: str, scope: str, tenant: str | None,
|
|
93
|
+
) -> None:
|
|
94
|
+
"""Print the full document (raw frontmatter + spec) as JSON."""
|
|
95
|
+
with dna_client(tenant=tenant) as dna:
|
|
96
|
+
try:
|
|
97
|
+
doc_body = run_async(dna.docs(scope).get(kind_name, doc_name))
|
|
98
|
+
except Exception as e: # noqa: BLE001
|
|
99
|
+
msg = str(e)
|
|
100
|
+
if "404" in msg or "not found" in msg.lower() or "not_found" in msg.lower():
|
|
101
|
+
raise fail(f"{kind_name}/{doc_name} not found in scope.") from e
|
|
102
|
+
raise fail(f"docs.get failed: {e}") from e
|
|
103
|
+
raw = doc_body.get("raw") if isinstance(doc_body, dict) else None
|
|
104
|
+
if raw is None and isinstance(doc_body, dict):
|
|
105
|
+
raw = doc_body
|
|
106
|
+
print_json(
|
|
107
|
+
{
|
|
108
|
+
"kind": raw.get("kind") if isinstance(raw, dict) else kind_name,
|
|
109
|
+
"name": (raw.get("metadata") or {}).get("name") or doc_name if isinstance(raw, dict) else doc_name,
|
|
110
|
+
"metadata": (raw.get("metadata") if isinstance(raw, dict) else None) or {},
|
|
111
|
+
"spec": (raw.get("spec") if isinstance(raw, dict) else None) or {},
|
|
112
|
+
}
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _read_spec(spec_arg: str | None) -> dict:
|
|
117
|
+
"""Read spec JSON from a file path or `-` for stdin."""
|
|
118
|
+
if spec_arg is None:
|
|
119
|
+
raise fail("Missing --spec=PATH (or `-` for stdin).")
|
|
120
|
+
if spec_arg == "-":
|
|
121
|
+
body = sys.stdin.read()
|
|
122
|
+
else:
|
|
123
|
+
with open(spec_arg, encoding="utf-8") as f:
|
|
124
|
+
body = f.read()
|
|
125
|
+
try:
|
|
126
|
+
return _json.loads(body)
|
|
127
|
+
except _json.JSONDecodeError as e:
|
|
128
|
+
raise fail(f"Invalid JSON in spec: {e}")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _fetch_kind_descriptor(dna, scope: str, kind_name: str) -> dict:
|
|
132
|
+
"""Fetch a kind descriptor via dna-client. Returns {schema, api_version, ...}.
|
|
133
|
+
|
|
134
|
+
Raises fail() if the kind isn't registered in the scope.
|
|
135
|
+
"""
|
|
136
|
+
try:
|
|
137
|
+
return run_async(dna.scopes.kind_schema(scope, kind_name))
|
|
138
|
+
except Exception as e: # noqa: BLE001
|
|
139
|
+
msg = str(e)
|
|
140
|
+
if "404" in msg or "not found" in msg.lower():
|
|
141
|
+
raise fail(f"Kind '{kind_name}' not registered in scope '{scope}'.") from e
|
|
142
|
+
raise fail(f"kind_schema fetch failed: {e}") from e
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _coerce_value(value: str, schema_type: str | None) -> object:
|
|
146
|
+
"""Coerce a CLI string value to the JSON Schema type the field declares.
|
|
147
|
+
|
|
148
|
+
integer → int, number → float, boolean → bool from truthy strings,
|
|
149
|
+
array → split on ';' (with optional `[]` wrapping). Everything else → str.
|
|
150
|
+
"""
|
|
151
|
+
if schema_type == "integer":
|
|
152
|
+
try:
|
|
153
|
+
return int(value)
|
|
154
|
+
except ValueError:
|
|
155
|
+
return value
|
|
156
|
+
if schema_type == "number":
|
|
157
|
+
try:
|
|
158
|
+
return float(value)
|
|
159
|
+
except ValueError:
|
|
160
|
+
return value
|
|
161
|
+
if schema_type == "boolean":
|
|
162
|
+
return value.lower() in ("true", "1", "yes", "y")
|
|
163
|
+
if schema_type == "array":
|
|
164
|
+
v = value.strip()
|
|
165
|
+
if v.startswith("[") and v.endswith("]"):
|
|
166
|
+
v = v[1:-1]
|
|
167
|
+
return [item.strip() for item in v.split(";") if item.strip()]
|
|
168
|
+
return value
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
@doc.command("make")
|
|
172
|
+
@click.argument("kind_name")
|
|
173
|
+
@click.argument("doc_name")
|
|
174
|
+
@click.argument("fields", nargs=-1)
|
|
175
|
+
@click.option("--scope", default="dna-development")
|
|
176
|
+
@click.option("--tenant", default=None, help="Bind the write to this tenant.")
|
|
177
|
+
@click.option("--dry-run", is_flag=True, help="Validate without writing.")
|
|
178
|
+
def make_doc(
|
|
179
|
+
kind_name: str,
|
|
180
|
+
doc_name: str,
|
|
181
|
+
fields: tuple,
|
|
182
|
+
scope: str,
|
|
183
|
+
tenant: str | None,
|
|
184
|
+
dry_run: bool,
|
|
185
|
+
) -> None:
|
|
186
|
+
"""Create a doc via schema-driven flags (no JSON file needed).
|
|
187
|
+
|
|
188
|
+
Syntax: dna doc make <Kind> <name> field1=value1 field2=value2 ...
|
|
189
|
+
|
|
190
|
+
Field types are coerced from the Kind's JSON Schema:
|
|
191
|
+
severity=high → "high" (string)
|
|
192
|
+
time_box_hours=8 → 8 (integer)
|
|
193
|
+
repro_steps="step1;step2" → ["step1", "step2"] (array)
|
|
194
|
+
labels= → [] (empty array on empty value)
|
|
195
|
+
"""
|
|
196
|
+
with dna_client(tenant=tenant) as dna:
|
|
197
|
+
descriptor = _fetch_kind_descriptor(dna, scope, kind_name)
|
|
198
|
+
schema = descriptor.get("schema") or {}
|
|
199
|
+
schema_props = (schema.get("properties") or {}) if isinstance(schema, dict) else {}
|
|
200
|
+
api_version = descriptor.get("api_version") or "github.com/ruinosus/dna/v1"
|
|
201
|
+
|
|
202
|
+
spec: dict[str, object] = {}
|
|
203
|
+
for field in fields:
|
|
204
|
+
if "=" not in field:
|
|
205
|
+
raise fail(f"Field arg '{field}' must be 'key=value' (got no '=').")
|
|
206
|
+
key, _, raw_value = field.partition("=")
|
|
207
|
+
key = key.strip()
|
|
208
|
+
prop = schema_props.get(key) if isinstance(schema_props, dict) else None
|
|
209
|
+
schema_type = prop.get("type") if isinstance(prop, dict) else None
|
|
210
|
+
spec[key] = _coerce_value(raw_value, schema_type)
|
|
211
|
+
|
|
212
|
+
if "created_at" in schema_props and "created_at" not in spec:
|
|
213
|
+
from datetime import datetime, timezone
|
|
214
|
+
spec["created_at"] = datetime.now(timezone.utc).isoformat(timespec="seconds")
|
|
215
|
+
|
|
216
|
+
raw = {
|
|
217
|
+
"apiVersion": api_version,
|
|
218
|
+
"kind": kind_name,
|
|
219
|
+
"metadata": {"name": doc_name},
|
|
220
|
+
"spec": spec,
|
|
221
|
+
}
|
|
222
|
+
if dry_run:
|
|
223
|
+
print_json({"dry_run": True, "would_write": raw, "tenant": tenant})
|
|
224
|
+
return
|
|
225
|
+
try:
|
|
226
|
+
run_async(dna.docs(scope).put(kind_name, doc_name, raw))
|
|
227
|
+
except Exception as e: # noqa: BLE001
|
|
228
|
+
raise fail(f"write failed: {e}") from e
|
|
229
|
+
click.secho(
|
|
230
|
+
f"Created {kind_name}/{doc_name} in scope {scope} "
|
|
231
|
+
f"({len(spec)} fields){' (tenant=' + tenant + ')' if tenant else ''}",
|
|
232
|
+
fg="green",
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
@doc.command("transition")
|
|
237
|
+
@click.argument("kind_name")
|
|
238
|
+
@click.argument("doc_name")
|
|
239
|
+
@click.argument("new_status")
|
|
240
|
+
@click.option("--scope", default="dna-development")
|
|
241
|
+
@click.option("--tenant", default=None)
|
|
242
|
+
@click.option("--commit-ref", default=None, help="Git SHA to stamp on transition.")
|
|
243
|
+
@click.option("--reason", default=None, help="Optional reason string.")
|
|
244
|
+
def transition(
|
|
245
|
+
kind_name: str,
|
|
246
|
+
doc_name: str,
|
|
247
|
+
new_status: str,
|
|
248
|
+
scope: str,
|
|
249
|
+
tenant: str | None,
|
|
250
|
+
commit_ref: str | None,
|
|
251
|
+
reason: str | None,
|
|
252
|
+
) -> None:
|
|
253
|
+
"""Generic status transition for any Kind that declares ``status`` in schema.
|
|
254
|
+
|
|
255
|
+
Validates new_status against the Kind's status enum. Stamps updated_at,
|
|
256
|
+
optionally closed_at (if new_status is terminal — heuristic), commit_ref,
|
|
257
|
+
and a timeline entry.
|
|
258
|
+
"""
|
|
259
|
+
with dna_client(tenant=tenant) as dna:
|
|
260
|
+
descriptor = _fetch_kind_descriptor(dna, scope, kind_name)
|
|
261
|
+
schema = descriptor.get("schema") or {}
|
|
262
|
+
schema_props = (schema.get("properties") or {}) if isinstance(schema, dict) else {}
|
|
263
|
+
api_version = descriptor.get("api_version") or "github.com/ruinosus/dna/v1"
|
|
264
|
+
|
|
265
|
+
try:
|
|
266
|
+
current = run_async(dna.docs(scope).get(kind_name, doc_name))
|
|
267
|
+
except Exception as e: # noqa: BLE001
|
|
268
|
+
raise fail(f"{kind_name}/{doc_name} not found in scope: {e}") from e
|
|
269
|
+
current_raw = current.get("raw") if isinstance(current, dict) else current
|
|
270
|
+
current_spec = (current_raw or {}).get("spec") if isinstance(current_raw, dict) else None
|
|
271
|
+
if not isinstance(current_spec, dict):
|
|
272
|
+
current_spec = {}
|
|
273
|
+
|
|
274
|
+
status_prop = schema_props.get("status") if isinstance(schema_props, dict) else None
|
|
275
|
+
if isinstance(status_prop, dict) and "enum" in status_prop:
|
|
276
|
+
if new_status not in status_prop["enum"]:
|
|
277
|
+
raise fail(
|
|
278
|
+
f"Status '{new_status}' not in {kind_name}.status enum: "
|
|
279
|
+
f"{status_prop['enum']}"
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
from datetime import datetime, timezone
|
|
283
|
+
prev_status = current_spec.get("status")
|
|
284
|
+
now = datetime.now(timezone.utc).isoformat(timespec="seconds")
|
|
285
|
+
spec = dict(current_spec)
|
|
286
|
+
spec["status"] = new_status
|
|
287
|
+
spec["updated_at"] = now
|
|
288
|
+
if new_status in ("done", "resolved", "wont-fix", "duplicate", "cancelled", "reported", "executed"):
|
|
289
|
+
spec.setdefault("closed_at", now)
|
|
290
|
+
if commit_ref:
|
|
291
|
+
commit_refs = list(spec.get("commit_refs") or [])
|
|
292
|
+
if commit_ref not in commit_refs:
|
|
293
|
+
commit_refs.append(commit_ref)
|
|
294
|
+
spec["commit_refs"] = commit_refs
|
|
295
|
+
timeline_entry: dict[str, object] = {
|
|
296
|
+
"at": now,
|
|
297
|
+
"actor": "cli",
|
|
298
|
+
"type": "status_change",
|
|
299
|
+
"from": prev_status,
|
|
300
|
+
"to": new_status,
|
|
301
|
+
}
|
|
302
|
+
if commit_ref:
|
|
303
|
+
timeline_entry["commit_ref"] = commit_ref
|
|
304
|
+
if reason:
|
|
305
|
+
timeline_entry["reason"] = reason
|
|
306
|
+
spec.setdefault("timeline", []).append(timeline_entry)
|
|
307
|
+
raw = {
|
|
308
|
+
"apiVersion": api_version,
|
|
309
|
+
"kind": kind_name,
|
|
310
|
+
"metadata": {"name": doc_name},
|
|
311
|
+
"spec": spec,
|
|
312
|
+
}
|
|
313
|
+
try:
|
|
314
|
+
run_async(dna.docs(scope).put(kind_name, doc_name, raw))
|
|
315
|
+
except Exception as e: # noqa: BLE001
|
|
316
|
+
raise fail(f"write failed: {e}") from e
|
|
317
|
+
click.secho(
|
|
318
|
+
f"Transitioned {kind_name}/{doc_name}: {prev_status} → {new_status}",
|
|
319
|
+
fg="green",
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
@doc.command("fields")
|
|
324
|
+
@click.argument("kind_name")
|
|
325
|
+
@click.option("--scope", default="dna-development")
|
|
326
|
+
@click.option("--tenant", default=None)
|
|
327
|
+
def fields_help(kind_name: str, scope: str, tenant: str | None) -> None:
|
|
328
|
+
"""List the fields a Kind accepts (with type + enum + required marker)."""
|
|
329
|
+
with dna_client(tenant=tenant) as dna:
|
|
330
|
+
descriptor = _fetch_kind_descriptor(dna, scope, kind_name)
|
|
331
|
+
schema = descriptor.get("schema") or {}
|
|
332
|
+
if not isinstance(schema, dict):
|
|
333
|
+
click.secho(f"Kind '{kind_name}' has no schema()", fg="yellow")
|
|
334
|
+
return
|
|
335
|
+
required = set(schema.get("required") or [])
|
|
336
|
+
props = schema.get("properties") or {}
|
|
337
|
+
click.secho(f"Fields for {kind_name}", bold=True)
|
|
338
|
+
click.echo(f" required: {sorted(required)}")
|
|
339
|
+
click.echo("")
|
|
340
|
+
for name in sorted(props.keys()):
|
|
341
|
+
p = props[name]
|
|
342
|
+
if not isinstance(p, dict):
|
|
343
|
+
continue
|
|
344
|
+
t = p.get("type", "?")
|
|
345
|
+
enum = p.get("enum")
|
|
346
|
+
desc = (p.get("description") or "")[:60]
|
|
347
|
+
marker = " *" if name in required else ""
|
|
348
|
+
enum_str = f" enum={enum}" if enum else ""
|
|
349
|
+
click.echo(f" {name:<24} ({t}){enum_str}{marker} {desc}")
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
@doc.command("create")
|
|
353
|
+
@click.argument("kind_name")
|
|
354
|
+
@click.argument("doc_name")
|
|
355
|
+
@click.option("--spec", "spec_path", default=None, help="Path to JSON file (or `-` for stdin).")
|
|
356
|
+
@click.option("--scope", default="dna-development")
|
|
357
|
+
@click.option("--tenant", default=None, help="Bind the write to this tenant (overrides DNA_TENANT).")
|
|
358
|
+
@click.option("--dry-run", is_flag=True, help="Validate without writing.")
|
|
359
|
+
def create(
|
|
360
|
+
kind_name: str,
|
|
361
|
+
doc_name: str,
|
|
362
|
+
spec_path: str | None,
|
|
363
|
+
scope: str,
|
|
364
|
+
tenant: str | None,
|
|
365
|
+
dry_run: bool,
|
|
366
|
+
) -> None:
|
|
367
|
+
"""Create a new document via the kernel WriterPort."""
|
|
368
|
+
spec = _read_spec(spec_path)
|
|
369
|
+
with dna_client(tenant=tenant) as dna:
|
|
370
|
+
descriptor = _fetch_kind_descriptor(dna, scope, kind_name)
|
|
371
|
+
api_version = descriptor.get("api_version") or "github.com/ruinosus/dna/v1"
|
|
372
|
+
|
|
373
|
+
raw = {
|
|
374
|
+
"apiVersion": api_version,
|
|
375
|
+
"kind": kind_name,
|
|
376
|
+
"metadata": {"name": doc_name},
|
|
377
|
+
"spec": spec,
|
|
378
|
+
}
|
|
379
|
+
if dry_run:
|
|
380
|
+
print_json({"dry_run": True, "would_write": raw, "tenant": tenant})
|
|
381
|
+
return
|
|
382
|
+
try:
|
|
383
|
+
run_async(dna.docs(scope).put(kind_name, doc_name, raw))
|
|
384
|
+
except Exception as e: # noqa: BLE001
|
|
385
|
+
raise fail(f"write failed: {e}") from e
|
|
386
|
+
_eff, _warn = _tenant_write_note(tenant) # i-020
|
|
387
|
+
if _warn:
|
|
388
|
+
click.secho(f" ⚠ {_warn}", fg="yellow", err=True)
|
|
389
|
+
suffix = f" (tenant={_eff})" if _eff else " (tenant=unbound/global)"
|
|
390
|
+
click.secho(f"Created {kind_name}/{doc_name} in scope {scope}{suffix}.", fg="green")
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
@doc.command("delete")
|
|
394
|
+
@click.argument("kind_name")
|
|
395
|
+
@click.argument("doc_name")
|
|
396
|
+
@click.option("--scope", default="dna-development")
|
|
397
|
+
@click.option("--tenant", default=None, help="Bind the delete to this tenant (overrides DNA_TENANT).")
|
|
398
|
+
@click.option("--yes", is_flag=True, help="Skip confirmation.")
|
|
399
|
+
def delete(
|
|
400
|
+
kind_name: str, doc_name: str, scope: str, tenant: str | None, yes: bool,
|
|
401
|
+
) -> None:
|
|
402
|
+
"""Delete a document from the scope. Asks for confirmation unless --yes."""
|
|
403
|
+
_eff, _warn = _tenant_write_note(tenant) # i-020: show effective tenant
|
|
404
|
+
if _warn:
|
|
405
|
+
click.secho(f" ⚠ {_warn}", fg="yellow", err=True)
|
|
406
|
+
suffix = f" (tenant={_eff})" if _eff else " (tenant=unbound/global)"
|
|
407
|
+
if not yes:
|
|
408
|
+
click.confirm(
|
|
409
|
+
f"Delete {kind_name}/{doc_name} from scope {scope}{suffix}?",
|
|
410
|
+
abort=True,
|
|
411
|
+
)
|
|
412
|
+
with dna_client(tenant=tenant) as dna:
|
|
413
|
+
try:
|
|
414
|
+
run_async(dna.docs(scope).delete(kind_name, doc_name))
|
|
415
|
+
except Exception as e: # noqa: BLE001
|
|
416
|
+
raise fail(f"delete failed: {e}") from e
|
|
417
|
+
click.secho(f"Deleted {kind_name}/{doc_name}{suffix}.", fg="green")
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
# ---------------------------------------------------------------------------
|
|
421
|
+
# `apply` — bundle / marker handling still needs local kernel for
|
|
422
|
+
# marker→kind resolution. TODO: add a server-side endpoint that takes
|
|
423
|
+
# (bundle_bytes, marker_name) and returns the canonical raw doc, so
|
|
424
|
+
# this can also migrate to dna-client. Until then, `dna doc apply`
|
|
425
|
+
# requires DNA_SOURCE_URL to be set.
|
|
426
|
+
# ---------------------------------------------------------------------------
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
_BUNDLE_TEXT_EXTS = {
|
|
430
|
+
".py", ".md", ".markdown", ".txt", ".yaml", ".yml", ".json",
|
|
431
|
+
".toml", ".cfg", ".ini", ".sh", ".html", ".css", ".js", ".ts",
|
|
432
|
+
".tsx", ".jsx", ".vue", ".sql",
|
|
433
|
+
}
|
|
434
|
+
_BUNDLE_SKIP_DIRS = {
|
|
435
|
+
".git", "__pycache__", "node_modules", ".venv", "dist", "build",
|
|
436
|
+
"target", ".pytest_cache", ".ruff_cache", ".mypy_cache", "tasks",
|
|
437
|
+
}
|
|
438
|
+
_BUNDLE_DOCKERFILE_PREFIXES = ("Dockerfile",)
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def _is_bundle_text_file(rel_path: "Path", marker_filename: str) -> bool:
|
|
442
|
+
if rel_path.name == marker_filename:
|
|
443
|
+
return False
|
|
444
|
+
if any(part in _BUNDLE_SKIP_DIRS for part in rel_path.parts):
|
|
445
|
+
return False
|
|
446
|
+
if rel_path.suffix in _BUNDLE_TEXT_EXTS:
|
|
447
|
+
return True
|
|
448
|
+
if any(rel_path.name.startswith(p) for p in _BUNDLE_DOCKERFILE_PREFIXES):
|
|
449
|
+
return True
|
|
450
|
+
return False
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def _collect_bundle_files(
|
|
454
|
+
root: "Path", marker_filename: str,
|
|
455
|
+
) -> "dict[str, str | bytes]":
|
|
456
|
+
"""All bundle entries under ``root`` (excluding the marker + skip dirs):
|
|
457
|
+
text files as ``str``, everything else (fonts, images, audio, archives)
|
|
458
|
+
as ``bytes``. i-062 — the text-only collection dropped binary assets, so
|
|
459
|
+
`dna doc apply` never synced fonts/images to the target source.
|
|
460
|
+
|
|
461
|
+
The downstream apply pops these from spec.source_files and writes each via
|
|
462
|
+
``kernel.write_bundle_entry_async`` (which takes ``str | bytes``).
|
|
463
|
+
"""
|
|
464
|
+
out: "dict[str, str | bytes]" = {}
|
|
465
|
+
for f in root.rglob("*"):
|
|
466
|
+
if not f.is_file():
|
|
467
|
+
continue
|
|
468
|
+
rel = f.relative_to(root)
|
|
469
|
+
if rel.name == marker_filename:
|
|
470
|
+
continue
|
|
471
|
+
# Skip junk: skip-dirs (.git, __pycache__, …) and dotfiles (.DS_Store).
|
|
472
|
+
if any(part in _BUNDLE_SKIP_DIRS for part in rel.parts):
|
|
473
|
+
continue
|
|
474
|
+
if rel.name.startswith("."):
|
|
475
|
+
continue
|
|
476
|
+
if _is_bundle_text_file(rel, marker_filename):
|
|
477
|
+
try:
|
|
478
|
+
out[str(rel)] = f.read_text(encoding="utf-8")
|
|
479
|
+
except (UnicodeDecodeError, OSError):
|
|
480
|
+
continue
|
|
481
|
+
else:
|
|
482
|
+
try:
|
|
483
|
+
out[str(rel)] = f.read_bytes()
|
|
484
|
+
except OSError:
|
|
485
|
+
continue
|
|
486
|
+
return out
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def _load_apply_input(path: str, kernel) -> dict:
|
|
490
|
+
"""Load `dna doc apply` input — bundle dir, marker file, or YAML/JSON."""
|
|
491
|
+
import yaml as _yaml
|
|
492
|
+
from pathlib import Path as _Path
|
|
493
|
+
from dna.kernel.generic_rw import _parse_frontmatter, _parse_body # noqa: F401
|
|
494
|
+
p = _Path(path)
|
|
495
|
+
|
|
496
|
+
if p.is_dir():
|
|
497
|
+
marker_path: _Path | None = None
|
|
498
|
+
kind_name_from_marker: str | None = None
|
|
499
|
+
registered_markers = {}
|
|
500
|
+
for kp in kernel._kinds.values():
|
|
501
|
+
sd = getattr(kp, "storage", None)
|
|
502
|
+
marker = getattr(sd, "marker", None) if sd else None
|
|
503
|
+
if marker:
|
|
504
|
+
registered_markers[marker] = kp.kind
|
|
505
|
+
for child in p.iterdir():
|
|
506
|
+
if child.is_file() and child.name in registered_markers:
|
|
507
|
+
marker_path = child
|
|
508
|
+
kind_name_from_marker = registered_markers[child.name]
|
|
509
|
+
break
|
|
510
|
+
if marker_path is None:
|
|
511
|
+
raise fail(
|
|
512
|
+
f"{path}: directory does not contain a recognized bundle marker. "
|
|
513
|
+
f"Looked for {sorted(registered_markers.keys())}."
|
|
514
|
+
)
|
|
515
|
+
raw_text = marker_path.read_text(encoding="utf-8")
|
|
516
|
+
fm, body = _parse_frontmatter(raw_text, source=str(marker_path))
|
|
517
|
+
if "kind" not in fm:
|
|
518
|
+
fm["kind"] = kind_name_from_marker
|
|
519
|
+
|
|
520
|
+
# i-062 — collect text AND binary bundle entries (fonts, images, …).
|
|
521
|
+
source_files = _collect_bundle_files(p, marker_path.name)
|
|
522
|
+
|
|
523
|
+
return _build_raw_from_marker(
|
|
524
|
+
marker_path, fm, body, kernel,
|
|
525
|
+
extra_source_files=source_files or None,
|
|
526
|
+
)
|
|
527
|
+
|
|
528
|
+
suffix = p.suffix.lower()
|
|
529
|
+
if suffix not in (".md", ".markdown"):
|
|
530
|
+
with open(path, encoding="utf-8") as f:
|
|
531
|
+
raw_text = f.read()
|
|
532
|
+
try:
|
|
533
|
+
raw = _yaml.safe_load(raw_text)
|
|
534
|
+
except _yaml.YAMLError as e:
|
|
535
|
+
raise fail(f"Invalid YAML/JSON in {path}: {e}")
|
|
536
|
+
if not isinstance(raw, dict):
|
|
537
|
+
raise fail(
|
|
538
|
+
f"{path} top-level must be a mapping (apiVersion/kind/metadata/spec)."
|
|
539
|
+
)
|
|
540
|
+
return raw
|
|
541
|
+
|
|
542
|
+
raw_text = p.read_text(encoding="utf-8")
|
|
543
|
+
fm, body = _parse_frontmatter(raw_text, source=str(p))
|
|
544
|
+
# i-061 — a single marker file (e.g. AGENT.md) is still a bundle: collect
|
|
545
|
+
# its sibling entries (instruction.md, scripts/, references/) from the
|
|
546
|
+
# parent directory so `dna doc apply path/to/AGENT.md` is equivalent to
|
|
547
|
+
# `dna doc apply path/to/`. Without this, applying the marker alone dropped
|
|
548
|
+
# the instruction_file fragment, zeroing the agent's instruction.
|
|
549
|
+
# i-062 — collect text AND binary siblings (fonts, images, …).
|
|
550
|
+
sibling_files = _collect_bundle_files(p.parent, p.name)
|
|
551
|
+
return _build_raw_from_marker(
|
|
552
|
+
p, fm, body, kernel, extra_source_files=sibling_files or None,
|
|
553
|
+
)
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
def _build_raw_from_marker(
|
|
557
|
+
marker_path: "Path",
|
|
558
|
+
fm: dict,
|
|
559
|
+
body: str,
|
|
560
|
+
kernel,
|
|
561
|
+
extra_source_files: "dict[str, str] | None" = None,
|
|
562
|
+
) -> dict:
|
|
563
|
+
"""Build the canonical raw doc dict from a parsed marker file."""
|
|
564
|
+
from dna.kernel.generic_rw import _parse_body
|
|
565
|
+
|
|
566
|
+
kind_name: str | None = fm.get("kind")
|
|
567
|
+
if not kind_name:
|
|
568
|
+
marker_name = marker_path.name
|
|
569
|
+
for kp in kernel._kinds.values():
|
|
570
|
+
sd = getattr(kp, "storage", None)
|
|
571
|
+
if sd and getattr(sd, "marker", None) == marker_name:
|
|
572
|
+
kind_name = kp.kind
|
|
573
|
+
break
|
|
574
|
+
if not kind_name:
|
|
575
|
+
raise fail(
|
|
576
|
+
f"{marker_path}: cannot infer kind. Add `kind: <KindName>` to the "
|
|
577
|
+
f"frontmatter or use a recognized marker filename."
|
|
578
|
+
)
|
|
579
|
+
|
|
580
|
+
sd = kernel.storage_for_kind(kind_name)
|
|
581
|
+
if sd is None:
|
|
582
|
+
raise fail(f"{marker_path}: kind {kind_name!r} is not registered in the kernel.")
|
|
583
|
+
|
|
584
|
+
api_version = fm.get("apiVersion") or getattr(
|
|
585
|
+
kernel._kinds.get(kind_name.lower(), object()), "api_version", None,
|
|
586
|
+
)
|
|
587
|
+
if not api_version:
|
|
588
|
+
for kp in kernel._kinds.values():
|
|
589
|
+
if kp.kind == kind_name:
|
|
590
|
+
api_version = getattr(kp, "api_version", None)
|
|
591
|
+
if api_version:
|
|
592
|
+
break
|
|
593
|
+
if not api_version:
|
|
594
|
+
api_version = "github.com/ruinosus/dna/helix/v1"
|
|
595
|
+
|
|
596
|
+
_meta = fm.get("metadata") if isinstance(fm.get("metadata"), dict) else {}
|
|
597
|
+
name_val = fm.get("name") or _meta.get("name")
|
|
598
|
+
if not name_val:
|
|
599
|
+
if marker_path.parent and marker_path.parent.name:
|
|
600
|
+
name_val = marker_path.parent.name
|
|
601
|
+
if not name_val:
|
|
602
|
+
raise fail(
|
|
603
|
+
f"{marker_path}: missing 'name' in frontmatter (or 'metadata.name')."
|
|
604
|
+
)
|
|
605
|
+
|
|
606
|
+
metadata_keys = {"name", "apiVersion", "kind", "description", "labels", "metadata"}
|
|
607
|
+
nested_spec = fm.get("spec") if isinstance(fm.get("spec"), dict) else None
|
|
608
|
+
spec: dict = {
|
|
609
|
+
k: v for k, v in fm.items()
|
|
610
|
+
if k not in metadata_keys and k != "spec"
|
|
611
|
+
}
|
|
612
|
+
if nested_spec:
|
|
613
|
+
spec.update(nested_spec)
|
|
614
|
+
body_field = getattr(sd, "body_field", None)
|
|
615
|
+
body_as = getattr(sd, "body_as", None)
|
|
616
|
+
if body_field and body_as is not None:
|
|
617
|
+
spec[body_field] = _parse_body(body, body_as)
|
|
618
|
+
elif body.strip():
|
|
619
|
+
spec.setdefault("body", body.strip())
|
|
620
|
+
|
|
621
|
+
if extra_source_files:
|
|
622
|
+
spec["source_files"] = extra_source_files
|
|
623
|
+
|
|
624
|
+
raw = {
|
|
625
|
+
"apiVersion": api_version,
|
|
626
|
+
"kind": kind_name,
|
|
627
|
+
"metadata": {
|
|
628
|
+
"name": name_val,
|
|
629
|
+
**({"description": fm["description"]} if fm.get("description") else {}),
|
|
630
|
+
},
|
|
631
|
+
"spec": spec,
|
|
632
|
+
}
|
|
633
|
+
return raw
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
def _stamp_created_at_if_in_schema(s, kind_name: str, raw: dict) -> None:
|
|
637
|
+
"""Stamp spec.created_at if Kind's schema declares it. Fail-soft."""
|
|
638
|
+
spec = raw.get("spec")
|
|
639
|
+
if not isinstance(spec, dict):
|
|
640
|
+
return
|
|
641
|
+
if spec.get("created_at"):
|
|
642
|
+
return
|
|
643
|
+
try:
|
|
644
|
+
kind_port = next(
|
|
645
|
+
(kp for kp in s.kernel._kinds.values() if getattr(kp, "kind", None) == kind_name),
|
|
646
|
+
None,
|
|
647
|
+
)
|
|
648
|
+
if kind_port is None:
|
|
649
|
+
return
|
|
650
|
+
schema = kind_port.schema() or {}
|
|
651
|
+
props = schema.get("properties") or {}
|
|
652
|
+
if "created_at" not in props:
|
|
653
|
+
return
|
|
654
|
+
except Exception: # noqa: BLE001
|
|
655
|
+
return
|
|
656
|
+
|
|
657
|
+
from datetime import datetime, timezone
|
|
658
|
+
spec["created_at"] = datetime.now(timezone.utc).isoformat()
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
def _load_apply_inputs(path: str, kernel) -> list[dict]:
|
|
662
|
+
"""Load `dna doc apply` input as a LIST of raw docs.
|
|
663
|
+
|
|
664
|
+
YAML/JSON files may contain MULTIPLE documents separated by ``---``
|
|
665
|
+
(a YAML stream); each is applied independently. Bundle directories
|
|
666
|
+
and markdown marker files stay single-doc (markers have one body).
|
|
667
|
+
|
|
668
|
+
Single-doc files still return a one-element list, so the apply loop
|
|
669
|
+
behaves identically to the legacy single-doc path.
|
|
670
|
+
"""
|
|
671
|
+
from pathlib import Path as _Path
|
|
672
|
+
|
|
673
|
+
p = _Path(path)
|
|
674
|
+
suffix = p.suffix.lower()
|
|
675
|
+
# Multi-doc only makes sense for plain YAML/JSON streams. Bundle dirs
|
|
676
|
+
# and markdown markers carry exactly one document by construction.
|
|
677
|
+
if p.is_dir() or suffix in (".md", ".markdown"):
|
|
678
|
+
return [_load_apply_input(path, kernel)]
|
|
679
|
+
|
|
680
|
+
import yaml as _yaml
|
|
681
|
+
|
|
682
|
+
with open(path, encoding="utf-8") as f:
|
|
683
|
+
raw_text = f.read()
|
|
684
|
+
try:
|
|
685
|
+
docs = [d for d in _yaml.safe_load_all(raw_text) if d is not None]
|
|
686
|
+
except _yaml.YAMLError as e:
|
|
687
|
+
raise fail(f"Invalid YAML/JSON in {path}: {e}")
|
|
688
|
+
if not docs:
|
|
689
|
+
raise fail(f"{path} contains no documents.")
|
|
690
|
+
# Single-doc YAML: defer to _load_apply_input so its validation +
|
|
691
|
+
# behavior (and any test monkeypatch of it) stays the single source of
|
|
692
|
+
# truth for the common case. Multi-doc only kicks in for `---` streams.
|
|
693
|
+
if len(docs) == 1:
|
|
694
|
+
return [_load_apply_input(path, kernel)]
|
|
695
|
+
for idx, d in enumerate(docs):
|
|
696
|
+
if not isinstance(d, dict):
|
|
697
|
+
raise fail(
|
|
698
|
+
f"{path} document #{idx} top-level must be a mapping "
|
|
699
|
+
f"(apiVersion/kind/metadata/spec)."
|
|
700
|
+
)
|
|
701
|
+
return docs
|
|
702
|
+
|
|
703
|
+
|
|
704
|
+
def _apply_one(s, raw: dict, *, path: str, doc_index: int | None,
|
|
705
|
+
tenant: str | None, dry_run: bool) -> None:
|
|
706
|
+
"""Validate + upsert a single raw doc. Shared by single- and multi-doc apply."""
|
|
707
|
+
label = f"{path}" if doc_index is None else f"{path} document #{doc_index}"
|
|
708
|
+
if not isinstance(raw, dict):
|
|
709
|
+
raise fail(
|
|
710
|
+
f"{label} top-level must be a mapping (apiVersion/kind/metadata/spec)."
|
|
711
|
+
)
|
|
712
|
+
for key in ("apiVersion", "kind", "metadata"):
|
|
713
|
+
if key not in raw:
|
|
714
|
+
raise fail(f"{label} missing required field: {key}")
|
|
715
|
+
name = (raw.get("metadata") or {}).get("name")
|
|
716
|
+
if not name:
|
|
717
|
+
raise fail(f"{label} missing metadata.name")
|
|
718
|
+
kind_name = raw["kind"]
|
|
719
|
+
|
|
720
|
+
# i-061 — bundle entries (e.g. the `instruction_file` fragment, scripts/,
|
|
721
|
+
# references/) ride in `spec.source_files` from the loader. Pop them BEFORE
|
|
722
|
+
# the doc write so they don't bloat the stored spec, then persist each as a
|
|
723
|
+
# bundle entry AFTER the doc exists. Source-agnostic (FS / SQLite / Postgres)
|
|
724
|
+
# via kernel.write_bundle_entry_async. Without this, applying an
|
|
725
|
+
# instruction_file Agent to a fresh bundle leaves no instruction
|
|
726
|
+
# fragment → resolve_document re-resolves it to empty → broken agent.
|
|
727
|
+
_bundle_entries: dict = {}
|
|
728
|
+
_spec_for_entries = raw.get("spec")
|
|
729
|
+
if isinstance(_spec_for_entries, dict):
|
|
730
|
+
_src = _spec_for_entries.pop("source_files", None)
|
|
731
|
+
if isinstance(_src, dict):
|
|
732
|
+
_bundle_entries = _src
|
|
733
|
+
|
|
734
|
+
current = s.get_doc(kind_name, name)
|
|
735
|
+
if current is None:
|
|
736
|
+
action = "CREATED"
|
|
737
|
+
else:
|
|
738
|
+
current_spec = dict(current.spec) if current.spec else {}
|
|
739
|
+
new_spec = raw.get("spec") or {}
|
|
740
|
+
if _json.dumps(current_spec, sort_keys=True, default=str) == _json.dumps(
|
|
741
|
+
new_spec, sort_keys=True, default=str
|
|
742
|
+
):
|
|
743
|
+
action = "UNCHANGED"
|
|
744
|
+
else:
|
|
745
|
+
action = "UPDATED"
|
|
746
|
+
|
|
747
|
+
if dry_run:
|
|
748
|
+
print_json(
|
|
749
|
+
{
|
|
750
|
+
"dry_run": True,
|
|
751
|
+
"action": action,
|
|
752
|
+
"kind": kind_name,
|
|
753
|
+
"name": name,
|
|
754
|
+
"scope": s.scope,
|
|
755
|
+
"tenant": tenant,
|
|
756
|
+
}
|
|
757
|
+
)
|
|
758
|
+
return
|
|
759
|
+
|
|
760
|
+
if action == "UNCHANGED":
|
|
761
|
+
click.secho(f"UNCHANGED {kind_name}/{name}", fg="yellow")
|
|
762
|
+
return
|
|
763
|
+
|
|
764
|
+
_stamp_created_at_if_in_schema(s, kind_name, raw)
|
|
765
|
+
|
|
766
|
+
# Tenant binding: explicit --tenant > DNA_TENANT > unbound (legacy/global).
|
|
767
|
+
# i-020: surface the EFFECTIVE tenant + warn on allow-list mismatch.
|
|
768
|
+
effective, _tenant_warn = _tenant_write_note(tenant)
|
|
769
|
+
if _tenant_warn:
|
|
770
|
+
click.secho(f" ⚠ {_tenant_warn}", fg="yellow", err=True)
|
|
771
|
+
kernel = s.kernel.with_tenant(effective) if effective else s.kernel
|
|
772
|
+
try:
|
|
773
|
+
s.run(kernel.write_document(s.scope, kind_name, name, raw))
|
|
774
|
+
except Exception as e: # noqa: BLE001
|
|
775
|
+
# Surface prompt-budget errors with a clear message instead of the
|
|
776
|
+
# generic "write failed:" wrapper. The error message from
|
|
777
|
+
# PromptBudgetExceededError already includes model, cap, and
|
|
778
|
+
# actionable advice — prepending "write failed:" would obscure it.
|
|
779
|
+
try:
|
|
780
|
+
from dna.kernel.prompt_budget import PromptBudgetExceededError
|
|
781
|
+
if isinstance(e, PromptBudgetExceededError):
|
|
782
|
+
click.secho(str(e), fg="red", err=True)
|
|
783
|
+
raise SystemExit(1)
|
|
784
|
+
except ImportError:
|
|
785
|
+
pass
|
|
786
|
+
raise fail(f"write failed: {e}")
|
|
787
|
+
# i-061 — persist bundle entries now that the parent doc exists. These were
|
|
788
|
+
# popped from spec.source_files above; the marker file (AGENT.md) is already
|
|
789
|
+
# written by write_document, and source_files excludes it by construction.
|
|
790
|
+
for _entry_path, _content in _bundle_entries.items():
|
|
791
|
+
try:
|
|
792
|
+
# i-083 — pass text entries as str and binary entries as bytes so
|
|
793
|
+
# the adapter routes them to the right column (content vs
|
|
794
|
+
# content_binary). Force-encoding str→bytes here buried every text
|
|
795
|
+
# payload (instruction fragments, asset.json, scripts) in the
|
|
796
|
+
# binary column. _collect_bundle_files already returns str for text
|
|
797
|
+
# extensions and bytes for binary.
|
|
798
|
+
_data = (
|
|
799
|
+
_content if isinstance(_content, (str, bytes, bytearray))
|
|
800
|
+
else str(_content)
|
|
801
|
+
)
|
|
802
|
+
s.run(kernel.write_bundle_entry_async(
|
|
803
|
+
s.scope, kind_name, name, _entry_path, _data, tenant=effective,
|
|
804
|
+
))
|
|
805
|
+
except Exception as _be: # noqa: BLE001
|
|
806
|
+
click.secho(
|
|
807
|
+
f" ⚠ bundle entry {_entry_path!r} not persisted: {_be}",
|
|
808
|
+
fg="yellow", err=True,
|
|
809
|
+
)
|
|
810
|
+
s.holder.reload()
|
|
811
|
+
fg = "green" if action == "CREATED" else "cyan"
|
|
812
|
+
suffix = f" (tenant={effective})" if effective else " (tenant=unbound/global)"
|
|
813
|
+
click.secho(f"{action} {kind_name}/{name} in scope {s.scope}{suffix}.", fg=fg)
|
|
814
|
+
|
|
815
|
+
|
|
816
|
+
@doc.command("apply")
|
|
817
|
+
@click.argument("path", type=click.Path(exists=True, dir_okay=True, readable=True))
|
|
818
|
+
@click.option("--scope", default=None, help="Override scope (default from env or doc).")
|
|
819
|
+
@click.option("--tenant", default=None, help="Bind the apply to this tenant (overrides DNA_TENANT).")
|
|
820
|
+
@click.option("--dry-run", is_flag=True, help="Validate without writing.")
|
|
821
|
+
def apply(path: str, scope: str | None, tenant: str | None, dry_run: bool) -> None:
|
|
822
|
+
"""Upsert document(s) from a YAML/JSON file, a bundle marker, or a bundle directory.
|
|
823
|
+
|
|
824
|
+
YAML/JSON files may hold MULTIPLE documents separated by ``---`` (a YAML
|
|
825
|
+
stream); each is applied independently in order. Single-doc files behave
|
|
826
|
+
exactly as before.
|
|
827
|
+
|
|
828
|
+
NOTE: this command still uses the local kernel (via dna_session) because
|
|
829
|
+
bundle/marker → kind resolution requires walking registered Kinds. Other
|
|
830
|
+
`dna doc` commands run via dna-client and don't need DNA_SOURCE_URL set.
|
|
831
|
+
"""
|
|
832
|
+
with dna_session(scope) as s:
|
|
833
|
+
raws = _load_apply_inputs(path, s.kernel)
|
|
834
|
+
multi = len(raws) > 1
|
|
835
|
+
for idx, raw in enumerate(raws):
|
|
836
|
+
_apply_one(
|
|
837
|
+
s, raw,
|
|
838
|
+
path=path,
|
|
839
|
+
doc_index=idx if multi else None,
|
|
840
|
+
tenant=tenant,
|
|
841
|
+
dry_run=dry_run,
|
|
842
|
+
)
|