cfgit 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.
- cfg/__init__.py +13 -0
- cfg/adapters/__init__.py +25 -0
- cfg/adapters/base.py +127 -0
- cfg/adapters/mongo.py +570 -0
- cfg/adapters/postgres.py +756 -0
- cfg/approval/__init__.py +5 -0
- cfg/approval/base.py +29 -0
- cfg/cli/__init__.py +2 -0
- cfg/cli/main.py +665 -0
- cfg/core/__init__.py +13 -0
- cfg/core/authz.py +58 -0
- cfg/core/config.py +324 -0
- cfg/core/diff.py +43 -0
- cfg/core/engine.py +1388 -0
- cfg/core/hashing.py +102 -0
- cfg/core/identity.py +213 -0
- cfg/interfaces/__init__.py +2 -0
- cfg/interfaces/actions.py +598 -0
- cfg/mcp/__init__.py +10 -0
- cfg/mcp/server.py +452 -0
- cfg/ui/__init__.py +2 -0
- cfg/ui/server.py +1066 -0
- cfgit-0.1.0.dist-info/METADATA +744 -0
- cfgit-0.1.0.dist-info/RECORD +28 -0
- cfgit-0.1.0.dist-info/WHEEL +4 -0
- cfgit-0.1.0.dist-info/entry_points.txt +3 -0
- cfgit-0.1.0.dist-info/licenses/LICENSE +201 -0
- cfgit-0.1.0.dist-info/licenses/NOTICE +10 -0
cfg/cli/main.py
ADDED
|
@@ -0,0 +1,665 @@
|
|
|
1
|
+
# Copyright 2026 Mohammad Ausaf. Licensed under the Apache License, Version 2.0.
|
|
2
|
+
"""cfg CLI."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
from dataclasses import asdict, is_dataclass
|
|
7
|
+
from datetime import datetime, time, timezone
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
import sys
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from cfg.adapters.base import AtomicityUnavailable, AmbiguousConfig, NoSuchConfig, StaleHead, StaleLive
|
|
15
|
+
from cfg.core.authz import PermissionDenied, permission_role
|
|
16
|
+
from cfg.core.config import ProjectConfig, load_config
|
|
17
|
+
from cfg.core.diff import format_diff
|
|
18
|
+
from cfg.core.engine import BranchingDisabled, Engine, RecordRef, SecretBlocked
|
|
19
|
+
from cfg.core.identity import IdentityError, hash_token, resolve_identity
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
EXIT_OK = 0
|
|
23
|
+
EXIT_ARG = 1
|
|
24
|
+
EXIT_DIRTY = 2
|
|
25
|
+
EXIT_STORAGE = 3
|
|
26
|
+
EXIT_FORBIDDEN = 4
|
|
27
|
+
EXIT_NOT_FOUND = 5
|
|
28
|
+
EXIT_INVARIANT = 6
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def main(argv: list[str] | None = None) -> int:
|
|
32
|
+
_load_dotenv(Path(".env"))
|
|
33
|
+
parser = _parser()
|
|
34
|
+
raw_argv = list(sys.argv[1:] if argv is None else argv)
|
|
35
|
+
json_mode = "--json" in raw_argv
|
|
36
|
+
raw_argv = [item for item in raw_argv if item != "--json"]
|
|
37
|
+
explicit_ui_port = _has_option(raw_argv, "--port")
|
|
38
|
+
args = parser.parse_args(raw_argv)
|
|
39
|
+
args.json = bool(args.json or json_mode)
|
|
40
|
+
if args.cmd == "ui":
|
|
41
|
+
from cfg.ui.server import run_ui
|
|
42
|
+
|
|
43
|
+
return run_ui(
|
|
44
|
+
config_file=args.config_file,
|
|
45
|
+
env=args.env,
|
|
46
|
+
author=args.author,
|
|
47
|
+
host=args.host,
|
|
48
|
+
port=args.port,
|
|
49
|
+
open_browser=not args.no_open,
|
|
50
|
+
allow_port_fallback=not explicit_ui_port,
|
|
51
|
+
)
|
|
52
|
+
if args.cmd == "identity-hash":
|
|
53
|
+
try:
|
|
54
|
+
token = _identity_hash_input(args)
|
|
55
|
+
hashed = hash_token(token)
|
|
56
|
+
_emit({"sha256": hashed, "fingerprint": hashed[7:12]}, json_mode=args.json)
|
|
57
|
+
return EXIT_OK
|
|
58
|
+
except ValueError as exc:
|
|
59
|
+
_emit_error("error", str(exc), args)
|
|
60
|
+
return EXIT_ARG
|
|
61
|
+
try:
|
|
62
|
+
project = load_config(args.config_file)
|
|
63
|
+
engine = _engine(project, args.env, author=args.author)
|
|
64
|
+
result, code = _dispatch(engine, args)
|
|
65
|
+
_emit(result, json_mode=args.json)
|
|
66
|
+
return code
|
|
67
|
+
except AmbiguousConfig as exc:
|
|
68
|
+
_emit_error("bad_config", str(exc), args)
|
|
69
|
+
return EXIT_INVARIANT
|
|
70
|
+
except (StaleHead, StaleLive) as exc:
|
|
71
|
+
_emit_error("changed_outside_cfgit", str(exc), args)
|
|
72
|
+
return EXIT_DIRTY
|
|
73
|
+
except PermissionDenied as exc:
|
|
74
|
+
_emit_error("forbidden", str(exc), args)
|
|
75
|
+
return EXIT_FORBIDDEN
|
|
76
|
+
except IdentityError as exc:
|
|
77
|
+
_emit_error("identity_required", str(exc), args)
|
|
78
|
+
return EXIT_FORBIDDEN
|
|
79
|
+
except AtomicityUnavailable as exc:
|
|
80
|
+
_emit_error("atomicity_unavailable", str(exc), args)
|
|
81
|
+
return EXIT_STORAGE
|
|
82
|
+
except NoSuchConfig as exc:
|
|
83
|
+
_emit_error("not_found", str(exc), args)
|
|
84
|
+
return EXIT_NOT_FOUND
|
|
85
|
+
except (BranchingDisabled, SecretBlocked, ValueError, FileNotFoundError, KeyError) as exc:
|
|
86
|
+
_emit_error("error", str(exc), args)
|
|
87
|
+
return EXIT_ARG
|
|
88
|
+
except Exception as exc: # pragma: no cover - final CLI guard
|
|
89
|
+
_emit_error("error", str(exc), args)
|
|
90
|
+
return EXIT_STORAGE
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _parser() -> argparse.ArgumentParser:
|
|
94
|
+
parser = argparse.ArgumentParser(prog="cfg")
|
|
95
|
+
parser.add_argument("--config-file", default=None)
|
|
96
|
+
parser.add_argument("--env", default="dev")
|
|
97
|
+
parser.add_argument("--author", default=None)
|
|
98
|
+
parser.add_argument("--branch", default=None)
|
|
99
|
+
parser.add_argument("--json", action="store_true")
|
|
100
|
+
sub = parser.add_subparsers(dest="cmd", required=True)
|
|
101
|
+
|
|
102
|
+
sub.add_parser("init")
|
|
103
|
+
sub.add_parser("whoami")
|
|
104
|
+
|
|
105
|
+
p_branch = sub.add_parser("branch")
|
|
106
|
+
branch_sub = p_branch.add_subparsers(dest="branch_cmd", required=True)
|
|
107
|
+
branch_sub.add_parser("list")
|
|
108
|
+
p_branch_create = branch_sub.add_parser("create")
|
|
109
|
+
p_branch_create.add_argument("name")
|
|
110
|
+
p_branch_create.add_argument("--from", dest="from_branch", default="main")
|
|
111
|
+
p_branch_create.add_argument("-m", "--message", default=None)
|
|
112
|
+
p_branch_delete = branch_sub.add_parser("delete")
|
|
113
|
+
p_branch_delete.add_argument("name")
|
|
114
|
+
|
|
115
|
+
p_switch = sub.add_parser("switch")
|
|
116
|
+
p_switch.add_argument("name")
|
|
117
|
+
|
|
118
|
+
p_pr = sub.add_parser("pr")
|
|
119
|
+
pr_sub = p_pr.add_subparsers(dest="pr_cmd", required=True)
|
|
120
|
+
p_pr_create = pr_sub.add_parser("create")
|
|
121
|
+
p_pr_create.add_argument("--base", default="main")
|
|
122
|
+
p_pr_create.add_argument("--head", required=True)
|
|
123
|
+
p_pr_create.add_argument("-m", "--message", required=True)
|
|
124
|
+
p_pr_list = pr_sub.add_parser("list")
|
|
125
|
+
p_pr_list.add_argument("--status", default=None)
|
|
126
|
+
p_pr_show = pr_sub.add_parser("show")
|
|
127
|
+
p_pr_show.add_argument("id")
|
|
128
|
+
p_pr_close = pr_sub.add_parser("close")
|
|
129
|
+
p_pr_close.add_argument("id")
|
|
130
|
+
p_pr_merge = pr_sub.add_parser("merge")
|
|
131
|
+
p_pr_merge.add_argument("id")
|
|
132
|
+
p_pr_merge.add_argument("-m", "--message", default=None)
|
|
133
|
+
|
|
134
|
+
p_import = sub.add_parser("import")
|
|
135
|
+
p_import.add_argument("record", nargs="?")
|
|
136
|
+
p_import.add_argument("--all", action="store_true")
|
|
137
|
+
p_import.add_argument("-m", "--message", default="initial import")
|
|
138
|
+
p_import.add_argument("--allow-secret", action="store_true")
|
|
139
|
+
|
|
140
|
+
p_doctor = sub.add_parser("doctor")
|
|
141
|
+
p_doctor.add_argument("record", nargs="?")
|
|
142
|
+
p_doctor.add_argument("--large-field-bytes", type=int, default=20000,
|
|
143
|
+
help="flag string fields at or above this size (default 20000)")
|
|
144
|
+
|
|
145
|
+
p_status = sub.add_parser("status")
|
|
146
|
+
p_status.add_argument("record", nargs="?")
|
|
147
|
+
|
|
148
|
+
p_diff = sub.add_parser("diff")
|
|
149
|
+
p_diff.add_argument("record")
|
|
150
|
+
p_diff.add_argument("a", nargs="?", default="=HEAD")
|
|
151
|
+
p_diff.add_argument("b", nargs="?", default="=live")
|
|
152
|
+
|
|
153
|
+
p_impact = sub.add_parser("impact")
|
|
154
|
+
p_impact.add_argument("record")
|
|
155
|
+
p_impact.add_argument("a", nargs="?", default="=HEAD")
|
|
156
|
+
p_impact.add_argument("b", nargs="?", default="=live")
|
|
157
|
+
p_impact.add_argument("--llm", action="store_true")
|
|
158
|
+
p_impact.add_argument("--provider")
|
|
159
|
+
p_impact.add_argument("--model")
|
|
160
|
+
p_impact.add_argument(
|
|
161
|
+
"--against",
|
|
162
|
+
action="append",
|
|
163
|
+
metavar="RECORD",
|
|
164
|
+
help="reason the change against these records only (repeat, or comma-separate). "
|
|
165
|
+
"Without it, the whole system is used.",
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
p_commit = sub.add_parser("commit")
|
|
169
|
+
p_commit.add_argument("record", nargs="?")
|
|
170
|
+
p_commit.add_argument("--from", dest="from_file")
|
|
171
|
+
p_commit.add_argument(
|
|
172
|
+
"--bulk-from",
|
|
173
|
+
dest="bulk_from_file",
|
|
174
|
+
help="JSON file containing a list/map of record+doc items to commit as one batch intent",
|
|
175
|
+
)
|
|
176
|
+
p_commit.add_argument("-m", "--message", required=True)
|
|
177
|
+
p_commit.add_argument("--allow-secret", action="store_true")
|
|
178
|
+
|
|
179
|
+
p_log = sub.add_parser("log")
|
|
180
|
+
p_log.add_argument("record", nargs="?")
|
|
181
|
+
p_log.add_argument("-n", "--limit", type=int, default=20)
|
|
182
|
+
|
|
183
|
+
p_show = sub.add_parser("show")
|
|
184
|
+
p_show.add_argument("record")
|
|
185
|
+
p_show.add_argument("ref")
|
|
186
|
+
|
|
187
|
+
p_adopt = sub.add_parser("adopt")
|
|
188
|
+
p_adopt.add_argument("record", nargs="?")
|
|
189
|
+
p_adopt.add_argument("--all", action="store_true")
|
|
190
|
+
p_adopt.add_argument("-m", "--message", required=True)
|
|
191
|
+
p_adopt.add_argument("--allow-secret", action="store_true")
|
|
192
|
+
|
|
193
|
+
p_restore = sub.add_parser("restore")
|
|
194
|
+
p_restore.add_argument("record", nargs="?")
|
|
195
|
+
p_restore.add_argument("ref", nargs="?")
|
|
196
|
+
p_restore.add_argument("--as-of", dest="as_of")
|
|
197
|
+
p_restore.add_argument("--tag", dest="tag")
|
|
198
|
+
p_restore.add_argument("--dry-run", action="store_true")
|
|
199
|
+
p_restore.add_argument("-m", "--message", required=True)
|
|
200
|
+
|
|
201
|
+
p_tag = sub.add_parser("tag")
|
|
202
|
+
p_tag.add_argument("name")
|
|
203
|
+
|
|
204
|
+
sub.add_parser("fsck")
|
|
205
|
+
|
|
206
|
+
p_identity_hash = sub.add_parser("identity-hash")
|
|
207
|
+
p_identity_hash.add_argument("token", nargs="?")
|
|
208
|
+
p_identity_hash.add_argument("--stdin", action="store_true")
|
|
209
|
+
|
|
210
|
+
p_ui = sub.add_parser("ui")
|
|
211
|
+
p_ui.add_argument("--host", default="127.0.0.1")
|
|
212
|
+
p_ui.add_argument("--port", type=int, default=8765)
|
|
213
|
+
p_ui.add_argument("--no-open", action="store_true")
|
|
214
|
+
return parser
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _has_option(argv: list[str], name: str) -> bool:
|
|
218
|
+
return any(item == name or item.startswith(f"{name}=") for item in argv)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _dispatch(engine: Engine, args: argparse.Namespace) -> tuple[Any, int]:
|
|
222
|
+
if args.cmd == "init":
|
|
223
|
+
result = engine.init()
|
|
224
|
+
violations = result["invariant_violations"]
|
|
225
|
+
return _plain_init(result), EXIT_INVARIANT if violations else EXIT_OK
|
|
226
|
+
|
|
227
|
+
if args.cmd == "whoami":
|
|
228
|
+
env = engine.config.envs[engine.env]
|
|
229
|
+
return {
|
|
230
|
+
"author": engine.author,
|
|
231
|
+
"identity": engine.identity.history_meta(),
|
|
232
|
+
"identity_display": engine.identity.display,
|
|
233
|
+
"env": engine.env,
|
|
234
|
+
"database": env.database,
|
|
235
|
+
"permission_role": permission_role(env.permissions, engine.author),
|
|
236
|
+
"permission_mode": env.permissions.mode,
|
|
237
|
+
"identity_mode": env.identity.mode,
|
|
238
|
+
}, EXIT_OK
|
|
239
|
+
|
|
240
|
+
if args.cmd == "branch":
|
|
241
|
+
if args.branch_cmd == "list":
|
|
242
|
+
return engine.branch_list(), EXIT_OK
|
|
243
|
+
if args.branch_cmd == "create":
|
|
244
|
+
return engine.branch_create(args.name, from_branch=args.from_branch, message=args.message), EXIT_OK
|
|
245
|
+
if args.branch_cmd == "delete":
|
|
246
|
+
return engine.branch_delete(args.name), EXIT_OK
|
|
247
|
+
raise ValueError(f"unknown branch command: {args.branch_cmd}")
|
|
248
|
+
|
|
249
|
+
if args.cmd == "switch":
|
|
250
|
+
result = engine.branch_current(args.name)
|
|
251
|
+
_write_state(engine.config, engine.env, result["branch"])
|
|
252
|
+
return {**result, "state": "switched"}, EXIT_OK
|
|
253
|
+
|
|
254
|
+
if args.cmd == "pr":
|
|
255
|
+
if args.pr_cmd == "create":
|
|
256
|
+
return engine.pr_create(base=args.base, head=args.head, message=args.message), EXIT_OK
|
|
257
|
+
if args.pr_cmd == "list":
|
|
258
|
+
return engine.pr_list(status=args.status), EXIT_OK
|
|
259
|
+
if args.pr_cmd == "show":
|
|
260
|
+
return engine.pr_show(args.id), EXIT_OK
|
|
261
|
+
if args.pr_cmd == "close":
|
|
262
|
+
return engine.pr_close(args.id), EXIT_OK
|
|
263
|
+
if args.pr_cmd == "merge":
|
|
264
|
+
return engine.pr_merge(args.id, message=args.message), EXIT_OK
|
|
265
|
+
raise ValueError(f"unknown PR command: {args.pr_cmd}")
|
|
266
|
+
|
|
267
|
+
if args.cmd == "import":
|
|
268
|
+
if not args.all and not args.record:
|
|
269
|
+
raise ValueError("import needs --all or a record")
|
|
270
|
+
result = engine.import_records(
|
|
271
|
+
_parse_record(args.record) if args.record else None,
|
|
272
|
+
message=args.message,
|
|
273
|
+
allow_secret=args.allow_secret,
|
|
274
|
+
)
|
|
275
|
+
return result, EXIT_OK
|
|
276
|
+
|
|
277
|
+
if args.cmd == "status":
|
|
278
|
+
rows = engine.status(_parse_record(args.record) if args.record else None)
|
|
279
|
+
code = EXIT_DIRTY if any(r.state == "changed_outside_cfgit" for r in rows) else EXIT_OK
|
|
280
|
+
return rows, code
|
|
281
|
+
|
|
282
|
+
if args.cmd == "doctor":
|
|
283
|
+
report = engine.doctor(
|
|
284
|
+
_parse_record(args.record) if args.record else None,
|
|
285
|
+
large_field_bytes=args.large_field_bytes,
|
|
286
|
+
)
|
|
287
|
+
report["text"] = _format_doctor(report)
|
|
288
|
+
code = EXIT_OK if report["ok"] else EXIT_DIRTY
|
|
289
|
+
return report, code
|
|
290
|
+
|
|
291
|
+
if args.cmd == "diff":
|
|
292
|
+
if ".." in args.record and ":" not in args.record:
|
|
293
|
+
return engine.branch_diff(args.record), EXIT_OK
|
|
294
|
+
changes = engine.diff(_parse_record(args.record), args.a, args.b)
|
|
295
|
+
return {"changes": changes, "text": format_diff(changes)}, EXIT_OK
|
|
296
|
+
|
|
297
|
+
if args.cmd == "impact":
|
|
298
|
+
from cfg.interfaces.actions import impact
|
|
299
|
+
|
|
300
|
+
against = None
|
|
301
|
+
if args.against:
|
|
302
|
+
against = [
|
|
303
|
+
part.strip()
|
|
304
|
+
for entry in args.against
|
|
305
|
+
for part in str(entry).split(",")
|
|
306
|
+
if part.strip()
|
|
307
|
+
] or None
|
|
308
|
+
return impact(
|
|
309
|
+
engine,
|
|
310
|
+
args.record,
|
|
311
|
+
a=args.a,
|
|
312
|
+
b=args.b,
|
|
313
|
+
use_llm=args.llm,
|
|
314
|
+
provider=args.provider,
|
|
315
|
+
model=args.model,
|
|
316
|
+
against=against,
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
if args.cmd == "commit":
|
|
320
|
+
branch = _active_branch(engine.config, engine.env, args)
|
|
321
|
+
if args.bulk_from_file:
|
|
322
|
+
if args.record or args.from_file:
|
|
323
|
+
raise ValueError("bulk commit uses --bulk-from without record or --from")
|
|
324
|
+
from cfg.interfaces.actions import bulk_commit_exit_code, parse_bulk_commit_items
|
|
325
|
+
|
|
326
|
+
items = parse_bulk_commit_items(_load_json_any(args.bulk_from_file))
|
|
327
|
+
if branch != engine.config.branches.default_branch:
|
|
328
|
+
result = engine.branch_commit_many(
|
|
329
|
+
branch,
|
|
330
|
+
items,
|
|
331
|
+
message=args.message,
|
|
332
|
+
allow_secret=args.allow_secret,
|
|
333
|
+
)
|
|
334
|
+
else:
|
|
335
|
+
result = engine.commit_many(
|
|
336
|
+
items,
|
|
337
|
+
message=args.message,
|
|
338
|
+
allow_secret=args.allow_secret,
|
|
339
|
+
)
|
|
340
|
+
return result, bulk_commit_exit_code(result)
|
|
341
|
+
if not args.record or not args.from_file:
|
|
342
|
+
raise ValueError("commit needs record and --from, or --bulk-from")
|
|
343
|
+
doc = _load_json_file(args.from_file)
|
|
344
|
+
if branch != engine.config.branches.default_branch:
|
|
345
|
+
result = engine.branch_commit(
|
|
346
|
+
branch,
|
|
347
|
+
_parse_record(args.record),
|
|
348
|
+
doc,
|
|
349
|
+
message=args.message,
|
|
350
|
+
allow_secret=args.allow_secret,
|
|
351
|
+
)
|
|
352
|
+
else:
|
|
353
|
+
result = engine.commit(
|
|
354
|
+
_parse_record(args.record),
|
|
355
|
+
doc,
|
|
356
|
+
message=args.message,
|
|
357
|
+
allow_secret=args.allow_secret,
|
|
358
|
+
)
|
|
359
|
+
code = EXIT_DIRTY if result.get("state") == "changed_outside_cfgit" else EXIT_OK
|
|
360
|
+
return result, code
|
|
361
|
+
|
|
362
|
+
if args.cmd == "log":
|
|
363
|
+
branch = _active_branch(engine.config, engine.env, args)
|
|
364
|
+
if branch != engine.config.branches.default_branch:
|
|
365
|
+
if args.record:
|
|
366
|
+
raise ValueError("branch log does not take a record in v1")
|
|
367
|
+
return engine.branch_log(branch, limit=args.limit), EXIT_OK
|
|
368
|
+
if not args.record:
|
|
369
|
+
raise ValueError("log needs a record on main, or switch/select a branch")
|
|
370
|
+
return engine.log(_parse_record(args.record), limit=args.limit), EXIT_OK
|
|
371
|
+
|
|
372
|
+
if args.cmd == "show":
|
|
373
|
+
return engine.resolve_ref(_parse_record(args.record), args.ref), EXIT_OK
|
|
374
|
+
|
|
375
|
+
if args.cmd == "adopt":
|
|
376
|
+
if args.all:
|
|
377
|
+
results = []
|
|
378
|
+
for row in engine.status():
|
|
379
|
+
if row.state == "changed_outside_cfgit":
|
|
380
|
+
result = engine.adopt(
|
|
381
|
+
RecordRef(row.collection, row.record_id),
|
|
382
|
+
message=args.message,
|
|
383
|
+
allow_secret=args.allow_secret,
|
|
384
|
+
)
|
|
385
|
+
results.append({"collection": row.collection, "record_id": row.record_id, **result})
|
|
386
|
+
return results, EXIT_OK
|
|
387
|
+
if not args.record:
|
|
388
|
+
raise ValueError("adopt needs --all or a record")
|
|
389
|
+
return engine.adopt(
|
|
390
|
+
_parse_record(args.record),
|
|
391
|
+
message=args.message,
|
|
392
|
+
allow_secret=args.allow_secret,
|
|
393
|
+
), EXIT_OK
|
|
394
|
+
|
|
395
|
+
if args.cmd == "restore":
|
|
396
|
+
if args.as_of and args.tag:
|
|
397
|
+
raise ValueError("restore accepts only one of --as-of or --tag")
|
|
398
|
+
if args.as_of:
|
|
399
|
+
if args.record or args.ref:
|
|
400
|
+
raise ValueError("restore --as-of restores all records; omit record and ref")
|
|
401
|
+
result = engine.restore_system_as_of(
|
|
402
|
+
_parse_when(args.as_of),
|
|
403
|
+
message=args.message,
|
|
404
|
+
dry_run=args.dry_run,
|
|
405
|
+
)
|
|
406
|
+
code = _restore_exit_code(result)
|
|
407
|
+
return result, code
|
|
408
|
+
if args.tag:
|
|
409
|
+
if args.record or args.ref:
|
|
410
|
+
raise ValueError("restore --tag restores all records; omit record and ref")
|
|
411
|
+
result = engine.restore_system_tag(args.tag, message=args.message, dry_run=args.dry_run)
|
|
412
|
+
code = _restore_exit_code(result)
|
|
413
|
+
return result, code
|
|
414
|
+
if args.dry_run:
|
|
415
|
+
raise ValueError("--dry-run is only supported with system restore")
|
|
416
|
+
if not args.record or not args.ref:
|
|
417
|
+
raise ValueError("restore needs record and ref, or --as-of/--tag")
|
|
418
|
+
result = engine.restore(_parse_record(args.record), args.ref, message=args.message)
|
|
419
|
+
code = EXIT_DIRTY if result.get("state") == "changed_outside_cfgit" else EXIT_OK
|
|
420
|
+
return result, code
|
|
421
|
+
|
|
422
|
+
if args.cmd == "tag":
|
|
423
|
+
return engine.tag(args.name), EXIT_OK
|
|
424
|
+
|
|
425
|
+
if args.cmd == "fsck":
|
|
426
|
+
return {
|
|
427
|
+
"invariant_violations": engine.adapter.check_runtime_invariant(),
|
|
428
|
+
"atomicity": engine.adapter.check_atomicity_scope(),
|
|
429
|
+
"reconcile": engine.adapter.reconcile(),
|
|
430
|
+
}, EXIT_OK
|
|
431
|
+
|
|
432
|
+
raise ValueError(f"unknown command: {args.cmd}")
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def _engine(project: ProjectConfig, env_name: str, *, author: str | None) -> Engine:
|
|
436
|
+
if env_name not in project.envs:
|
|
437
|
+
raise ValueError(f"unknown env: {env_name}")
|
|
438
|
+
env = project.envs[env_name]
|
|
439
|
+
if env.database == "mongo":
|
|
440
|
+
from cfg.adapters.mongo import MongoAdapter
|
|
441
|
+
|
|
442
|
+
adapter = MongoAdapter(project=project, env_name=env_name)
|
|
443
|
+
elif env.database == "postgres":
|
|
444
|
+
from cfg.adapters.postgres import PostgresAdapter
|
|
445
|
+
|
|
446
|
+
adapter = PostgresAdapter(project=project, env_name=env_name)
|
|
447
|
+
else:
|
|
448
|
+
raise ValueError(f"unsupported database for v1 slice: {env.database}")
|
|
449
|
+
|
|
450
|
+
identity = resolve_identity(env, adapter, explicit_author=author)
|
|
451
|
+
return Engine(project, adapter, env=env_name, identity=identity)
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def _active_branch(project: ProjectConfig, env: str, args: argparse.Namespace) -> str:
|
|
455
|
+
if args.branch:
|
|
456
|
+
return str(args.branch)
|
|
457
|
+
if not project.branches.enabled:
|
|
458
|
+
return project.branches.default_branch
|
|
459
|
+
state = _read_state(project)
|
|
460
|
+
if state.get("env") == env and state.get("branch"):
|
|
461
|
+
return str(state["branch"])
|
|
462
|
+
return project.branches.default_branch
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def _state_path(project: ProjectConfig) -> Path:
|
|
466
|
+
return project.path.parent / ".cfgit" / "state.json"
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def _read_state(project: ProjectConfig) -> dict[str, Any]:
|
|
470
|
+
path = _state_path(project)
|
|
471
|
+
if not path.exists():
|
|
472
|
+
return {}
|
|
473
|
+
try:
|
|
474
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
475
|
+
except (OSError, json.JSONDecodeError):
|
|
476
|
+
return {}
|
|
477
|
+
return data if isinstance(data, dict) else {}
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def _write_state(project: ProjectConfig, env: str, branch: str) -> None:
|
|
481
|
+
path = _state_path(project)
|
|
482
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
483
|
+
path.write_text(json.dumps({"env": env, "branch": branch}, indent=2, sort_keys=True), encoding="utf-8")
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def _parse_record(raw: str | None) -> RecordRef:
|
|
487
|
+
if not raw or ":" not in raw:
|
|
488
|
+
raise ValueError("record must be collection:id, for example agent_configs:agent_planner")
|
|
489
|
+
collection, record_id = raw.split(":", 1)
|
|
490
|
+
if not collection or not record_id:
|
|
491
|
+
raise ValueError("record must be collection:id")
|
|
492
|
+
return RecordRef(collection, record_id)
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def _load_json_file(path: str) -> dict[str, Any]:
|
|
496
|
+
data = _load_json_any(path)
|
|
497
|
+
if not isinstance(data, dict):
|
|
498
|
+
raise ValueError("--from file must contain one JSON object")
|
|
499
|
+
return data
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def _load_json_any(path: str) -> Any:
|
|
503
|
+
with Path(path).open("r", encoding="utf-8") as f:
|
|
504
|
+
return json.load(f)
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def _parse_when(raw: str) -> datetime:
|
|
508
|
+
value = raw.strip()
|
|
509
|
+
date_only = len(value) == 10 and value[4] == "-" and value[7] == "-"
|
|
510
|
+
if date_only:
|
|
511
|
+
dt = datetime.combine(datetime.fromisoformat(value).date(), time.max)
|
|
512
|
+
else:
|
|
513
|
+
dt = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
514
|
+
if dt.tzinfo is None:
|
|
515
|
+
dt = dt.replace(tzinfo=timezone.utc)
|
|
516
|
+
return dt.astimezone(timezone.utc)
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def _format_doctor(report: dict[str, Any]) -> str:
|
|
520
|
+
lines: list[str] = []
|
|
521
|
+
n = report.get("scanned", 0)
|
|
522
|
+
if report.get("ok"):
|
|
523
|
+
lines.append(f"doctor: {n} live record(s) scanned — no blockers. Safe to import.")
|
|
524
|
+
return "\n".join(lines)
|
|
525
|
+
sb = report.get("secret_blocks", [])
|
|
526
|
+
lf = report.get("large_fields", [])
|
|
527
|
+
ki = report.get("key_issues", [])
|
|
528
|
+
lines.append(
|
|
529
|
+
f"doctor: {n} live record(s) scanned — {len(sb)} secret block(s), "
|
|
530
|
+
f"{len(lf)} large field(s), {len(ki)} key issue(s)."
|
|
531
|
+
)
|
|
532
|
+
if ki:
|
|
533
|
+
lines.append("")
|
|
534
|
+
lines.append("Key / live-rule issues (fix id_field or live_when before import):")
|
|
535
|
+
for issue in ki:
|
|
536
|
+
lines.append(f" {issue}")
|
|
537
|
+
if sb:
|
|
538
|
+
has_value = any(g["kind"] == "value" for g in sb)
|
|
539
|
+
lines.append("")
|
|
540
|
+
lines.append("Secret-deny matches (would refuse import). Two ways to resolve each:")
|
|
541
|
+
lines.append(" - secret_fields = strip the value from history (use when the field is NOT")
|
|
542
|
+
lines.append(" needed in the record, or is schema structure).")
|
|
543
|
+
lines.append(" - import/commit --allow-secret = STORE the real value in history (use when")
|
|
544
|
+
lines.append(" the key must stay in the record so restore writes it back; value is then")
|
|
545
|
+
lines.append(" in cfgit history in plaintext).")
|
|
546
|
+
for g in sb:
|
|
547
|
+
tag = "real value" if g["kind"] == "value" else "field name"
|
|
548
|
+
lines.append(f" {g['collection']}: {g['path']} [{tag}: {g['pattern']}] "
|
|
549
|
+
f"x{g['count']} (e.g. {g['example']})")
|
|
550
|
+
if has_value:
|
|
551
|
+
lines.append(" ! at least one match is a REAL secret VALUE — if that key must live in")
|
|
552
|
+
lines.append(" the record, keep it OUT of secret_fields and import with --allow-secret.")
|
|
553
|
+
if lf:
|
|
554
|
+
lines.append("")
|
|
555
|
+
kb = report.get("large_field_bytes", 0) // 1000
|
|
556
|
+
lines.append(f"Large fields (>= {kb}KB; consider ignore_fields to keep diffs readable):")
|
|
557
|
+
for g in lf:
|
|
558
|
+
lines.append(f" {g['collection']}: {g['path']} up to {g['max_bytes']//1000}KB x{g['count']}")
|
|
559
|
+
sug = report.get("suggestions", {})
|
|
560
|
+
if sug:
|
|
561
|
+
lines.append("")
|
|
562
|
+
lines.append("Paste-ready fixes (per collection in .cfg.toml):")
|
|
563
|
+
for coll in sorted(sug):
|
|
564
|
+
entry = sug[coll]
|
|
565
|
+
lines.append(f" # [[collection]] name = \"{coll}\"")
|
|
566
|
+
if entry.get("secret_fields"):
|
|
567
|
+
joined = ", ".join(f'"{p}"' for p in sorted(set(entry["secret_fields"])))
|
|
568
|
+
lines.append(f" secret_fields = [{joined}]")
|
|
569
|
+
if entry.get("ignore_fields"):
|
|
570
|
+
joined = ", ".join(f'"{p}"' for p in sorted(set(entry["ignore_fields"])))
|
|
571
|
+
lines.append(f" ignore_fields = [{joined}]")
|
|
572
|
+
return "\n".join(lines)
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
def _emit(value: Any, *, json_mode: bool) -> None:
|
|
576
|
+
if json_mode:
|
|
577
|
+
print(json.dumps(_to_json(value), indent=2, sort_keys=True))
|
|
578
|
+
return
|
|
579
|
+
if isinstance(value, list):
|
|
580
|
+
for item in value:
|
|
581
|
+
print(_format_item(item))
|
|
582
|
+
return
|
|
583
|
+
if isinstance(value, dict) and "text" in value and ("changes" in value or "secret_blocks" in value):
|
|
584
|
+
print(value["text"])
|
|
585
|
+
return
|
|
586
|
+
print(_format_item(value))
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
def _emit_error(status: str, message: str, args: argparse.Namespace) -> None:
|
|
590
|
+
if getattr(args, "json", False):
|
|
591
|
+
print(json.dumps({"status": status, "message": message}, indent=2), file=sys.stderr)
|
|
592
|
+
else:
|
|
593
|
+
print(f"{status}: {message}", file=sys.stderr)
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
def _format_item(value: Any) -> str:
|
|
597
|
+
if is_dataclass(value):
|
|
598
|
+
value = asdict(value)
|
|
599
|
+
if isinstance(value, dict):
|
|
600
|
+
if {"collection", "record_id", "state"} <= set(value):
|
|
601
|
+
return f"{value['collection']}:{value['record_id']} {value['state']}"
|
|
602
|
+
if {"collection", "record_id", "seq", "oid"} <= set(value):
|
|
603
|
+
return f"{value['collection']}:{value['record_id']} @{value['seq']} {str(value['oid'])[:12]}"
|
|
604
|
+
return json.dumps(_to_json(value), sort_keys=True)
|
|
605
|
+
return str(value)
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
def _to_json(value: Any) -> Any:
|
|
609
|
+
if is_dataclass(value):
|
|
610
|
+
return _to_json(asdict(value))
|
|
611
|
+
if isinstance(value, dict):
|
|
612
|
+
return {str(k): _to_json(v) for k, v in value.items()}
|
|
613
|
+
if isinstance(value, (list, tuple)):
|
|
614
|
+
return [_to_json(v) for v in value]
|
|
615
|
+
if isinstance(value, datetime):
|
|
616
|
+
return value.isoformat()
|
|
617
|
+
try:
|
|
618
|
+
json.dumps(value)
|
|
619
|
+
return value
|
|
620
|
+
except TypeError:
|
|
621
|
+
return str(value)
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
def _plain_init(result: dict[str, Any]) -> dict[str, Any]:
|
|
625
|
+
atomic = result["atomic"]
|
|
626
|
+
return {
|
|
627
|
+
"atomic": asdict(atomic) if is_dataclass(atomic) else atomic,
|
|
628
|
+
"invariant_violations": result["invariant_violations"],
|
|
629
|
+
"branches": result.get("branches"),
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
def _restore_exit_code(result: dict[str, Any]) -> int:
|
|
634
|
+
if result.get("state") == "blocked":
|
|
635
|
+
return EXIT_DIRTY
|
|
636
|
+
if result.get("state") == "partial":
|
|
637
|
+
return EXIT_STORAGE
|
|
638
|
+
return EXIT_OK
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
def _identity_hash_input(args: argparse.Namespace) -> str:
|
|
642
|
+
if args.stdin:
|
|
643
|
+
token = sys.stdin.read().strip()
|
|
644
|
+
else:
|
|
645
|
+
token = args.token or ""
|
|
646
|
+
if not token:
|
|
647
|
+
raise ValueError("identity-hash needs a token argument or --stdin")
|
|
648
|
+
return token
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
def _load_dotenv(path: Path) -> None:
|
|
652
|
+
if not path.exists():
|
|
653
|
+
return
|
|
654
|
+
for line in path.read_text(encoding="utf-8").splitlines():
|
|
655
|
+
line = line.strip()
|
|
656
|
+
if not line or line.startswith("#") or "=" not in line:
|
|
657
|
+
continue
|
|
658
|
+
key, value = line.split("=", 1)
|
|
659
|
+
key = key.strip()
|
|
660
|
+
value = value.strip().strip('"').strip("'")
|
|
661
|
+
os.environ.setdefault(key, value)
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
if __name__ == "__main__":
|
|
665
|
+
raise SystemExit(main())
|
cfg/core/__init__.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Copyright 2026 Mohammad Ausaf. Licensed under the Apache License, Version 2.0.
|
|
2
|
+
"""cfg.core — the engine (SPEC §1).
|
|
3
|
+
|
|
4
|
+
Depends ONLY on StorageAdapter (cfg.adapters.base) and ApprovalProvider
|
|
5
|
+
(cfg.approval.base). MUST NOT import any DB driver (pymongo/psycopg/...) or LLM SDK.
|
|
6
|
+
That boundary is enforced by tests/test_core_purity.py.
|
|
7
|
+
|
|
8
|
+
Modules (to be built, SPEC §17):
|
|
9
|
+
hashing — oid(doc) = sha256(canonical(strip(doc))) [SPEC §4]
|
|
10
|
+
asof — valid-time interval reconstruction [SPEC §5.8, V3-5]
|
|
11
|
+
engine — commit/restore/adopt/status orchestration over apply()
|
|
12
|
+
refs — ref grammar (@seq | sha256: | @{date} | tag: | =live | =HEAD) [SPEC §5.9]
|
|
13
|
+
"""
|