arc-kernel 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.
- arc/__init__.py +65 -0
- arc/_state.py +31 -0
- arc/cli.py +841 -0
- arc/codec.py +72 -0
- arc/doctor.py +107 -0
- arc/health.py +55 -0
- arc/healthcmd.py +85 -0
- arc/kernel.py +290 -0
- arc/plugin_cli.py +120 -0
- arc/registry.py +173 -0
- arc/resolver.py +347 -0
- arc/runtime.py +189 -0
- arc/secrets.py +85 -0
- arc/settings.py +246 -0
- arc_kernel-0.1.0.dist-info/METADATA +111 -0
- arc_kernel-0.1.0.dist-info/RECORD +19 -0
- arc_kernel-0.1.0.dist-info/WHEEL +4 -0
- arc_kernel-0.1.0.dist-info/entry_points.txt +2 -0
- arc_kernel-0.1.0.dist-info/licenses/LICENSE +21 -0
arc/cli.py
ADDED
|
@@ -0,0 +1,841 @@
|
|
|
1
|
+
"""
|
|
2
|
+
arc.cli
|
|
3
|
+
--------------
|
|
4
|
+
The `arc` command. Subcommands implemented here:
|
|
5
|
+
|
|
6
|
+
arc init [project_name] [--env dev|staging|prod]
|
|
7
|
+
arc build [-p/--plugin NAME] [--no-lock]
|
|
8
|
+
arc settings get <key> [--reveal]
|
|
9
|
+
arc settings set <key> <value> [--secret]
|
|
10
|
+
arc settings delete <key>
|
|
11
|
+
arc plugin enable <name>
|
|
12
|
+
arc plugin disable <name>
|
|
13
|
+
arc plugin list
|
|
14
|
+
arc doctor [--json]
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import asyncio
|
|
20
|
+
import os
|
|
21
|
+
import re
|
|
22
|
+
import secrets as stdlib_secrets
|
|
23
|
+
import shutil
|
|
24
|
+
import subprocess
|
|
25
|
+
import sys
|
|
26
|
+
import warnings
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from urllib.parse import urlparse
|
|
29
|
+
|
|
30
|
+
import tomlkit
|
|
31
|
+
import typer
|
|
32
|
+
from rich.console import Console
|
|
33
|
+
from rich.table import Table
|
|
34
|
+
|
|
35
|
+
from . import registry
|
|
36
|
+
from .doctor import doctor as _doctor_command
|
|
37
|
+
from .healthcmd import health as _health_command
|
|
38
|
+
from .plugin_cli import mount_plugin_clis
|
|
39
|
+
from .settings import REDACTED, SettingsError, SettingsManager
|
|
40
|
+
|
|
41
|
+
app = typer.Typer(name="arc", help="ARC kernel CLI", no_args_is_help=True)
|
|
42
|
+
settings_app = typer.Typer(help="Get, set, or delete a setting.", no_args_is_help=True)
|
|
43
|
+
plugin_app = typer.Typer(help="Enable, disable, or list plugins.", no_args_is_help=True)
|
|
44
|
+
app.add_typer(settings_app, name="settings")
|
|
45
|
+
app.add_typer(plugin_app, name="plugin")
|
|
46
|
+
app.command(name="doctor")(_doctor_command)
|
|
47
|
+
app.command(name="health")(_health_command)
|
|
48
|
+
mount_plugin_clis(app)
|
|
49
|
+
|
|
50
|
+
console = Console()
|
|
51
|
+
err_console = Console(stderr=True, style="bold red")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# --------------------------------------------------------------------------- #
|
|
55
|
+
# Shared helpers
|
|
56
|
+
# --------------------------------------------------------------------------- #
|
|
57
|
+
def find_project_root(start: Path | None = None) -> Path:
|
|
58
|
+
"""Walk upward from `start` (default: cwd) looking for a .arc/arc.toml."""
|
|
59
|
+
current = (start or Path.cwd()).resolve()
|
|
60
|
+
for candidate in [current, *current.parents]:
|
|
61
|
+
if (candidate / ".arc" / "arc.toml").exists():
|
|
62
|
+
return candidate
|
|
63
|
+
err_console.print(
|
|
64
|
+
"Not an ARC project (no .arc/arc.toml found in this directory or any parent). "
|
|
65
|
+
"Run `arc init` first."
|
|
66
|
+
)
|
|
67
|
+
raise typer.Exit(code=1)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def run(cmd: list[str], cwd: Path) -> None:
|
|
71
|
+
console.print(f"[dim]$ {' '.join(cmd)}[/dim]")
|
|
72
|
+
result = subprocess.run(cmd, cwd=cwd)
|
|
73
|
+
if result.returncode != 0:
|
|
74
|
+
err_console.print(f"Command failed: {' '.join(cmd)}")
|
|
75
|
+
raise typer.Exit(code=result.returncode)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# --------------------------------------------------------------------------- #
|
|
79
|
+
# arc init
|
|
80
|
+
# --------------------------------------------------------------------------- #
|
|
81
|
+
@app.command()
|
|
82
|
+
def init(
|
|
83
|
+
project_name: str = typer.Argument(
|
|
84
|
+
None, help="Directory to create. Defaults to the current directory."
|
|
85
|
+
),
|
|
86
|
+
env: str = typer.Option("dev", "--env", help="Default ARC_ENV for this project."),
|
|
87
|
+
kernel_repo: str = typer.Option(
|
|
88
|
+
os.environ.get("ARC_KERNEL_REPO", ""),
|
|
89
|
+
"--kernel-repo",
|
|
90
|
+
help="Git URL (or local path) to clone the kernel from into arc/. "
|
|
91
|
+
"Can also be set via the ARC_KERNEL_REPO env var.",
|
|
92
|
+
),
|
|
93
|
+
kernel_branch: str = typer.Option("main", "--kernel-branch", help="Branch/tag to clone."),
|
|
94
|
+
) -> None:
|
|
95
|
+
"""
|
|
96
|
+
Scaffold a new ARC instance: arc/ (kernel, git-cloned), plugins/, .arc/,
|
|
97
|
+
config/, logs/, backups/ — and wire it all up as a single uv workspace.
|
|
98
|
+
"""
|
|
99
|
+
root = Path(project_name).resolve() if project_name else Path.cwd()
|
|
100
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
101
|
+
|
|
102
|
+
arc_dir = root / ".arc"
|
|
103
|
+
(arc_dir / "runtime").mkdir(parents=True, exist_ok=True)
|
|
104
|
+
|
|
105
|
+
for d in ["plugins", "config", "logs", "backups/db", "backups/files"]:
|
|
106
|
+
(root / d).mkdir(parents=True, exist_ok=True)
|
|
107
|
+
for keep in ["logs", "backups/db", "backups/files"]:
|
|
108
|
+
(root / keep / ".gitkeep").touch(exist_ok=True)
|
|
109
|
+
|
|
110
|
+
# --- kernel source: clone arc/ if it doesn't already exist -------------
|
|
111
|
+
kernel_dir = root / "arc"
|
|
112
|
+
if kernel_dir.exists():
|
|
113
|
+
console.print(f"[yellow]{kernel_dir} already exists — leaving untouched.[/yellow]")
|
|
114
|
+
elif kernel_repo:
|
|
115
|
+
run(["git", "clone", "--branch", kernel_branch, kernel_repo, str(kernel_dir)], cwd=root)
|
|
116
|
+
else:
|
|
117
|
+
err_console.print(
|
|
118
|
+
"No --kernel-repo given and ARC_KERNEL_REPO is not set. "
|
|
119
|
+
"The kernel source has nothing to clone from.\n"
|
|
120
|
+
"Pass --kernel-repo <git-url-or-path>, or set ARC_KERNEL_REPO once "
|
|
121
|
+
"in your shell profile so every `arc init` picks it up automatically."
|
|
122
|
+
)
|
|
123
|
+
raise typer.Exit(code=1)
|
|
124
|
+
|
|
125
|
+
# --- master key ----------------------------------------------------
|
|
126
|
+
mkey_path = arc_dir / "arc.mkey"
|
|
127
|
+
if mkey_path.exists():
|
|
128
|
+
console.print(f"[yellow]{mkey_path} already exists — leaving untouched.[/yellow]")
|
|
129
|
+
else:
|
|
130
|
+
mkey_path.write_text(stdlib_secrets.token_hex(32))
|
|
131
|
+
mkey_path.chmod(0o600)
|
|
132
|
+
console.print(f"[green]Generated master key: {mkey_path}[/green]")
|
|
133
|
+
|
|
134
|
+
# --- empty secrets store --------------------------------------------
|
|
135
|
+
secrets_path = arc_dir / "arc.secrets"
|
|
136
|
+
if not secrets_path.exists():
|
|
137
|
+
secrets_path.touch()
|
|
138
|
+
secrets_path.chmod(0o600)
|
|
139
|
+
console.print(f"[green]Created empty secrets store: {secrets_path}[/green]")
|
|
140
|
+
|
|
141
|
+
# --- arc.toml --------------------------------------------------------
|
|
142
|
+
toml_path = arc_dir / "arc.toml"
|
|
143
|
+
if toml_path.exists():
|
|
144
|
+
console.print(f"[yellow]{toml_path} already exists — leaving untouched.[/yellow]")
|
|
145
|
+
else:
|
|
146
|
+
doc = tomlkit.document()
|
|
147
|
+
|
|
148
|
+
project_table = tomlkit.table()
|
|
149
|
+
project_table["name"] = root.name
|
|
150
|
+
project_table["env"] = env
|
|
151
|
+
doc["project"] = project_table
|
|
152
|
+
|
|
153
|
+
doc["settings"] = tomlkit.table()
|
|
154
|
+
|
|
155
|
+
secrets_section = tomlkit.table()
|
|
156
|
+
secrets_section["provider"] = "local_file"
|
|
157
|
+
secrets_section["declared"] = tomlkit.array()
|
|
158
|
+
doc["secrets"] = secrets_section
|
|
159
|
+
|
|
160
|
+
logging_table = tomlkit.table()
|
|
161
|
+
logging_table["level"] = "INFO"
|
|
162
|
+
logging_table["dir"] = "logs"
|
|
163
|
+
doc["logging"] = logging_table
|
|
164
|
+
|
|
165
|
+
toml_path.write_text(tomlkit.dumps(doc))
|
|
166
|
+
console.print(f"[green]Wrote default {toml_path}[/green]")
|
|
167
|
+
|
|
168
|
+
# --- config/*.toml overlays -------------------------------------------
|
|
169
|
+
for env_name in ["common", "dev", "staging", "prod"]:
|
|
170
|
+
p = root / "config" / f"{env_name}.toml"
|
|
171
|
+
if not p.exists():
|
|
172
|
+
p.write_text(f"# {env_name} environment overrides — merged on top of .arc/arc.toml\n")
|
|
173
|
+
|
|
174
|
+
# --- plugins.lock skeleton --------------------------------------------
|
|
175
|
+
lock_path = arc_dir / "plugins.lock"
|
|
176
|
+
if not lock_path.exists():
|
|
177
|
+
registry.save_lock(lock_path, registry.load_lock(lock_path))
|
|
178
|
+
console.print(f"[green]Created empty {lock_path}[/green]")
|
|
179
|
+
|
|
180
|
+
# --- .gitignore --------------------------------------------------------
|
|
181
|
+
# arc/ and plugins/*/ are each their OWN independent git repos. If this
|
|
182
|
+
# project root is also a git repo, git would otherwise treat them as
|
|
183
|
+
# "embedded repositories" (silently skipped or added as a dangling
|
|
184
|
+
# gitlink, not real tracked files) — so the outer repo ignores their
|
|
185
|
+
# contents entirely and each is managed via its own remote instead.
|
|
186
|
+
gitignore_entries = [
|
|
187
|
+
".arc/arc.mkey", ".arc/arc.rkey", ".arc/arc.secrets", ".arc/runtime/",
|
|
188
|
+
"logs/*.log", "backups/db/*", "backups/files/*",
|
|
189
|
+
"!backups/db/.gitkeep", "!backups/files/.gitkeep",
|
|
190
|
+
".venv/", "__pycache__/",
|
|
191
|
+
"/arc/", "/plugins/*/",
|
|
192
|
+
]
|
|
193
|
+
gitignore_path = root / ".gitignore"
|
|
194
|
+
existing = gitignore_path.read_text().splitlines() if gitignore_path.exists() else []
|
|
195
|
+
with gitignore_path.open("a") as f:
|
|
196
|
+
for entry in gitignore_entries:
|
|
197
|
+
if entry not in existing:
|
|
198
|
+
f.write(entry + "\n")
|
|
199
|
+
|
|
200
|
+
# --- root pyproject.toml: a uv WORKSPACE, not a single package ---------
|
|
201
|
+
# arc/ and every plugins/* are independent packages with their own
|
|
202
|
+
# pyproject.toml; the root just aggregates them into one shared venv
|
|
203
|
+
# and one shared lock file. The root itself ships no code, hence
|
|
204
|
+
# tool.uv.package = false.
|
|
205
|
+
pyproject = root / "pyproject.toml"
|
|
206
|
+
if not pyproject.exists():
|
|
207
|
+
doc = tomlkit.document()
|
|
208
|
+
project_table = tomlkit.table()
|
|
209
|
+
project_table["name"] = root.name
|
|
210
|
+
project_table["version"] = "0.1.0"
|
|
211
|
+
project_table["requires-python"] = ">=3.12"
|
|
212
|
+
project_table["dependencies"] = tomlkit.array()
|
|
213
|
+
doc["project"] = project_table
|
|
214
|
+
|
|
215
|
+
uv_table = tomlkit.table()
|
|
216
|
+
uv_table["package"] = False
|
|
217
|
+
doc["tool"] = tomlkit.table()
|
|
218
|
+
doc["tool"]["uv"] = uv_table
|
|
219
|
+
|
|
220
|
+
workspace_table = tomlkit.table()
|
|
221
|
+
workspace_table["members"] = ["arc", "plugins/*"]
|
|
222
|
+
doc["tool"]["uv"]["workspace"] = workspace_table
|
|
223
|
+
|
|
224
|
+
pyproject.write_text(tomlkit.dumps(doc))
|
|
225
|
+
console.print(f"[green]Wrote workspace {pyproject}[/green]")
|
|
226
|
+
|
|
227
|
+
run(["uv", "sync", "--all-packages"], cwd=root)
|
|
228
|
+
|
|
229
|
+
console.print(f"\n[bold green]ARC instance scaffolded at: {root}[/bold green]")
|
|
230
|
+
console.print("[yellow]Using local-file secrets — fine for dev/self-hosted. "
|
|
231
|
+
"Switch [secrets].provider in .arc/arc.toml for cloud production.[/yellow]")
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _infer_plugin_name(github_url: str) -> str:
|
|
235
|
+
"""github.com/org/foo-plugin(.git) -> foo-plugin"""
|
|
236
|
+
path = urlparse(github_url).path if "://" in github_url else github_url
|
|
237
|
+
stem = path.rstrip("/").rsplit("/", 1)[-1]
|
|
238
|
+
return stem[:-4] if stem.endswith(".git") else stem
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
@app.command()
|
|
242
|
+
def install(
|
|
243
|
+
github_url: str = typer.Argument(..., help="Git URL of the plugin repo to clone."),
|
|
244
|
+
branch: str = typer.Option(
|
|
245
|
+
None, "--branch", help="Branch/tag to clone. Defaults to the repo's default branch."
|
|
246
|
+
),
|
|
247
|
+
name: str = typer.Option(
|
|
248
|
+
None, "--name", help="Directory name under plugins/. Inferred from the URL if omitted."
|
|
249
|
+
),
|
|
250
|
+
) -> None:
|
|
251
|
+
"""
|
|
252
|
+
Clone a plugin's git repo into plugins/<name> and register it —
|
|
253
|
+
analogous to `bench get-app`. Installs the plugin's own declared
|
|
254
|
+
Python dependencies via the uv workspace; no network dependency
|
|
255
|
+
resolution beyond the clone itself and a single `uv sync`.
|
|
256
|
+
"""
|
|
257
|
+
root = find_project_root()
|
|
258
|
+
plugin_name = name or _infer_plugin_name(github_url)
|
|
259
|
+
target = root / "plugins" / plugin_name
|
|
260
|
+
|
|
261
|
+
if target.exists():
|
|
262
|
+
err_console.print(
|
|
263
|
+
f"plugins/{plugin_name} already exists. "
|
|
264
|
+
f"Pass --name to install under a different directory, or remove it first."
|
|
265
|
+
)
|
|
266
|
+
raise typer.Exit(code=1)
|
|
267
|
+
|
|
268
|
+
clone_cmd = ["git", "clone"]
|
|
269
|
+
if branch:
|
|
270
|
+
clone_cmd += ["--branch", branch]
|
|
271
|
+
clone_cmd += [github_url, str(target)]
|
|
272
|
+
run(clone_cmd, cwd=root)
|
|
273
|
+
|
|
274
|
+
manifest_path = target / "plugin.toml"
|
|
275
|
+
if not manifest_path.exists():
|
|
276
|
+
err_console.print(
|
|
277
|
+
f"{github_url} was cloned into plugins/{plugin_name}, but it has no "
|
|
278
|
+
f"plugin.toml at its root — this doesn't look like an ARC plugin. "
|
|
279
|
+
f"Removing the clone."
|
|
280
|
+
)
|
|
281
|
+
shutil.rmtree(target, ignore_errors=True)
|
|
282
|
+
raise typer.Exit(code=1)
|
|
283
|
+
|
|
284
|
+
console.print("Installing dependencies via the workspace...")
|
|
285
|
+
run(["uv", "sync", "--all-packages"], cwd=root)
|
|
286
|
+
|
|
287
|
+
manifest = registry.read_manifest(manifest_path)
|
|
288
|
+
lock_path = root / ".arc" / "plugins.lock"
|
|
289
|
+
lock_doc = registry.load_lock(lock_path)
|
|
290
|
+
lock_doc = registry.merge_manifests_into_lock(lock_doc, [manifest])
|
|
291
|
+
registry.save_lock(lock_path, lock_doc)
|
|
292
|
+
|
|
293
|
+
console.print(
|
|
294
|
+
f"[bold green]Installed '{plugin_name}' "
|
|
295
|
+
f"(capability: {manifest.capability}) and enabled it.[/bold green]"
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
all_manifests = registry.discover_plugins(root / "plugins")
|
|
299
|
+
for w in registry.validate_requires(all_manifests):
|
|
300
|
+
console.print(f"[yellow]Warning: {w}[/yellow]")
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
_PLUGIN_NAME_RE = re.compile(r"^[a-z][a-z0-9_]*$")
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _plugin_template_files(name: str) -> dict[str, str]:
|
|
307
|
+
"""Every file `arc new-plugin` writes, keyed by path relative to
|
|
308
|
+
plugins/<name>/. hooks/api/tasks get real .py files with the whole
|
|
309
|
+
example commented out line-by-line — loaded for real via
|
|
310
|
+
register_hooks/register_api/register_tasks the moment the plugin
|
|
311
|
+
boots, so an uncommented decorator would immediately do something
|
|
312
|
+
(register a live hook, a real HTTP-reachable endpoint, a real
|
|
313
|
+
background job) — not what a starter file should do by default.
|
|
314
|
+
schemas/patches get a README instead of a same-shape sample .json:
|
|
315
|
+
JSON has no comment syntax, so a real .json file there would be
|
|
316
|
+
loaded as an ACTUAL schema by psqldb.register_model() the moment
|
|
317
|
+
this plugin boots, silently creating a real table nobody asked for."""
|
|
318
|
+
return {
|
|
319
|
+
".gitignore": "__pycache__/\n",
|
|
320
|
+
"plugin.toml": (
|
|
321
|
+
"[plugin]\n"
|
|
322
|
+
f'name = "{name}"\n'
|
|
323
|
+
'version = "0.1.0"\n'
|
|
324
|
+
f'capability = "{name}"\n'
|
|
325
|
+
'requires = ["psqldb", "relay"]\n'
|
|
326
|
+
'optional_requires = ["gateway"]\n'
|
|
327
|
+
),
|
|
328
|
+
"pyproject.toml": (
|
|
329
|
+
"[project]\n"
|
|
330
|
+
f'name = "{name}"\n'
|
|
331
|
+
'version = "0.1.0"\n'
|
|
332
|
+
f'description = "ARC business plugin: {name}"\n'
|
|
333
|
+
'requires-python = ">=3.12"\n'
|
|
334
|
+
"dependencies = []\n"
|
|
335
|
+
"\n"
|
|
336
|
+
'[project.entry-points."arc.plugins"]\n'
|
|
337
|
+
f"{name} = \"{name}:register\"\n"
|
|
338
|
+
"\n"
|
|
339
|
+
"[build-system]\n"
|
|
340
|
+
'requires = ["hatchling"]\n'
|
|
341
|
+
'build-backend = "hatchling.build"\n'
|
|
342
|
+
"\n"
|
|
343
|
+
"[tool.hatch.build.targets.wheel]\n"
|
|
344
|
+
f'packages = ["{name}"]\n'
|
|
345
|
+
),
|
|
346
|
+
f"{name}/__init__.py": (
|
|
347
|
+
f'"""{name} — an ARC business plugin.\n'
|
|
348
|
+
"\n"
|
|
349
|
+
"Scaffolded by `arc new-plugin`. docs/arc.MD §3.9 (schemas/patches),\n"
|
|
350
|
+
"§3.11 (hooks/api/tasks), §3.7 (directory conventions) cover how each\n"
|
|
351
|
+
'of the directories below gets loaded.\n"""\n'
|
|
352
|
+
"\n"
|
|
353
|
+
"from pathlib import Path\n"
|
|
354
|
+
"from typing import Any\n"
|
|
355
|
+
"\n"
|
|
356
|
+
f'CAPABILITY = "{name}"\n'
|
|
357
|
+
"\n"
|
|
358
|
+
"\n"
|
|
359
|
+
"def register(kernel: Any) -> None:\n"
|
|
360
|
+
' psqldb = kernel.get("psqldb")\n'
|
|
361
|
+
' psqldb.register_model(Path(__file__).parent / "schemas")\n'
|
|
362
|
+
' psqldb.register_patches(Path(__file__).parent / "patches")\n'
|
|
363
|
+
"\n"
|
|
364
|
+
' relay = kernel.get("relay")\n'
|
|
365
|
+
' relay.register_hooks(Path(__file__).parent / "hooks")\n'
|
|
366
|
+
' relay.register_api(Path(__file__).parent / "api")\n'
|
|
367
|
+
' relay.register_tasks(Path(__file__).parent / "tasks")\n'
|
|
368
|
+
"\n"
|
|
369
|
+
" # Serve this plugin's own UI, once you've built one — see ui/README.md.\n"
|
|
370
|
+
' # if kernel.has("gateway"):\n'
|
|
371
|
+
' # ui_dist = Path(__file__).parent / "ui" / "dist"\n'
|
|
372
|
+
" # if ui_dist.is_dir():\n"
|
|
373
|
+
f' # kernel.get("gateway").mount_spa(ui_dist, prefix="{name}_desk")\n'
|
|
374
|
+
"\n"
|
|
375
|
+
" kernel.export(\n"
|
|
376
|
+
f' CAPABILITY, object(), requires=["psqldb", "relay"], optional_requires=["gateway"]\n'
|
|
377
|
+
" )\n"
|
|
378
|
+
),
|
|
379
|
+
f"{name}/schemas/README.md": (
|
|
380
|
+
"# schemas/\n\n"
|
|
381
|
+
"One JSON file per table this plugin OWNS (creates). Loaded via "
|
|
382
|
+
"`psqldb.register_model(...)` in `__init__.py`. The filename (minus "
|
|
383
|
+
"`.json`) becomes the table's file **stem** — the only valid value for "
|
|
384
|
+
"a REFERENCE/TABLE field's `target` elsewhere, never the physical, "
|
|
385
|
+
"slugified table name (docs/arc.MD §3.9).\n\n"
|
|
386
|
+
'Every normal table must declare at least one business `"unique": true` '
|
|
387
|
+
"field of its own (not just the auto-injected `id`).\n\n"
|
|
388
|
+
"Example — `Department.json`:\n\n"
|
|
389
|
+
"```json\n"
|
|
390
|
+
"{\n"
|
|
391
|
+
' "system": false,\n'
|
|
392
|
+
' "audit": false,\n'
|
|
393
|
+
' "child": false,\n'
|
|
394
|
+
' "fields": [\n'
|
|
395
|
+
' {"id": "AA01", "name": "code", "type": "STRING", "unique": true, "required": true, "length": 8},\n'
|
|
396
|
+
' {"id": "AA02", "name": "dept_name", "type": "STRING", "required": true, "length": 100}\n'
|
|
397
|
+
" ],\n"
|
|
398
|
+
' "index": [{"key": "idx_dept_name", "fields": ["dept_name"]}]\n'
|
|
399
|
+
"}\n"
|
|
400
|
+
"```\n\n"
|
|
401
|
+
"After adding or changing a schema file:\n"
|
|
402
|
+
"1. `arc psqldb plan` — preview the diff, never touches the DB.\n"
|
|
403
|
+
"2. `arc psqldb migrate` — apply it (run this yourself).\n"
|
|
404
|
+
),
|
|
405
|
+
f"{name}/patches/README.md": (
|
|
406
|
+
"# patches/\n\n"
|
|
407
|
+
"Add or modify fields YOU own on a table — your own, or another "
|
|
408
|
+
"installed plugin's. Same JSON shape as `schemas/`, minus "
|
|
409
|
+
"`system`/`audit`/`child`. Never create a table here — that's "
|
|
410
|
+
"`schemas/`'s job.\n\n"
|
|
411
|
+
'A patch can\'t target a `"system": true` table (skipped with a '
|
|
412
|
+
"warning at plan/migrate time — docs/arc.MD §3.9).\n\n"
|
|
413
|
+
"Example — `Employee.json` (adding a field to a table some other "
|
|
414
|
+
"installed plugin owns):\n\n"
|
|
415
|
+
"```json\n"
|
|
416
|
+
"{\n"
|
|
417
|
+
' "fields": [\n'
|
|
418
|
+
' {"id": "AB01", "name": "emergency_contact", "type": "STRING", "length": 100}\n'
|
|
419
|
+
" ]\n"
|
|
420
|
+
"}\n"
|
|
421
|
+
"```\n"
|
|
422
|
+
),
|
|
423
|
+
f"{name}/hooks/example.py": (
|
|
424
|
+
'"""hooks/<Table Name>.py — one file per table, named exactly after '
|
|
425
|
+
"its schema (docs/arc.MD §3.11). Loaded via relay.register_hooks(...).\n\n"
|
|
426
|
+
"Delete this file, or rename it to a real table and uncomment what "
|
|
427
|
+
'you need. Nothing below runs until you do.\n"""\n'
|
|
428
|
+
"\n"
|
|
429
|
+
"# import arc\n"
|
|
430
|
+
"#\n"
|
|
431
|
+
"# @arc.relay.validate\n"
|
|
432
|
+
"# async def check_something(ctx) -> None:\n"
|
|
433
|
+
"# if ctx.doc.some_field is None:\n"
|
|
434
|
+
'# arc.relay.throw("some_field is required", code="missing_field")\n'
|
|
435
|
+
"#\n"
|
|
436
|
+
"# @arc.relay.after_save\n"
|
|
437
|
+
"# async def on_saved(ctx) -> None:\n"
|
|
438
|
+
"# if ctx.doc._is_new:\n"
|
|
439
|
+
'# arc.relay.log(f"created {ctx.new[\'id\']}")\n'
|
|
440
|
+
),
|
|
441
|
+
f"{name}/api/example.py": (
|
|
442
|
+
'"""api/*.py — whitelisted functions, not table-named (docs/arc.MD '
|
|
443
|
+
"§3.11). Loaded via relay.register_api(...). Always callable directly "
|
|
444
|
+
"via arc.relay.call(...); additionally reachable over HTTP at "
|
|
445
|
+
"/api/method/<plugin>.<function_name> when gateway is installed.\n\n"
|
|
446
|
+
"Delete this file, or rename it and uncomment what you need.\n"
|
|
447
|
+
'"""\n'
|
|
448
|
+
"\n"
|
|
449
|
+
"# import arc\n"
|
|
450
|
+
"#\n"
|
|
451
|
+
'# @arc.relay.whitelist(methods=["GET"], roles=["Guest"])\n'
|
|
452
|
+
"# async def ping() -> dict:\n"
|
|
453
|
+
'# return {"ok": True}\n'
|
|
454
|
+
),
|
|
455
|
+
f"{name}/tasks/example.py": (
|
|
456
|
+
'"""tasks/*.py — background/scheduled jobs (docs/arc.MD §3.11/'
|
|
457
|
+
"§3.15). Loaded via relay.register_tasks(...). Durable + schedulable "
|
|
458
|
+
"when the `lineup` plugin is installed; still runs in-process (just "
|
|
459
|
+
"not durably) if it isn't — never depend on `lineup` directly, "
|
|
460
|
+
"arc.relay.task/enqueue handle that automatically.\n\n"
|
|
461
|
+
"Delete this file, or rename it and uncomment what you need.\n"
|
|
462
|
+
'"""\n'
|
|
463
|
+
"\n"
|
|
464
|
+
"# import arc\n"
|
|
465
|
+
"#\n"
|
|
466
|
+
'# @arc.relay.task(queue="default")\n'
|
|
467
|
+
"# async def send_something(employee_code: str) -> None:\n"
|
|
468
|
+
"# ...\n"
|
|
469
|
+
"#\n"
|
|
470
|
+
'# @arc.relay.task(queue="low", cron="0 2 * * *")\n'
|
|
471
|
+
"# async def nightly_cleanup() -> None:\n"
|
|
472
|
+
"# ...\n"
|
|
473
|
+
),
|
|
474
|
+
f"{name}/ui/README.md": (
|
|
475
|
+
"# ui/\n\n"
|
|
476
|
+
"Most plugins don't need their own UI — this is a placeholder, not "
|
|
477
|
+
"a requirement. If you want to serve one (following admin's own "
|
|
478
|
+
"`/admin-desk` pattern, docs/arc.MD §3.14/§6):\n\n"
|
|
479
|
+
"1. `npm create vite@latest . -- --template react-ts` in this directory.\n"
|
|
480
|
+
"2. Build it, then in `__init__.py`'s `register()`, uncomment the "
|
|
481
|
+
"`mount_spa` block and point it at your own build's `dist/`.\n"
|
|
482
|
+
"3. Pick a route prefix that isn't your plugin's own name if you "
|
|
483
|
+
f'don\'t want the two coupled — e.g. `"{name}_desk"` (already the '
|
|
484
|
+
"default in the commented-out sample above).\n\n"
|
|
485
|
+
"See `plugins/admin/admin/ui/` for a complete, working reference.\n"
|
|
486
|
+
),
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
@app.command(name="new-plugin")
|
|
491
|
+
def new_plugin(
|
|
492
|
+
name: str = typer.Argument(..., help="Plugin name — becomes plugins/<name>/ and its capability name."),
|
|
493
|
+
) -> None:
|
|
494
|
+
"""Scaffolds a new plugin directory: plugin.toml, pyproject.toml, and
|
|
495
|
+
the standard schemas/patches/hooks/api/tasks/ui layout (docs/arc.MD
|
|
496
|
+
§3.7/§3.9/§3.11), each with a README or a fully commented-out example
|
|
497
|
+
showing the convention — nothing in it is live until you uncomment
|
|
498
|
+
and adapt it. Initializes its own git repo (main branch, matching
|
|
499
|
+
every other plugin — §3.6) but does not commit anything; runs `uv
|
|
500
|
+
sync --all-packages` and registers it in plugins.lock (enabled by
|
|
501
|
+
default), the same way `arc install` finishes a clone."""
|
|
502
|
+
if not _PLUGIN_NAME_RE.match(name):
|
|
503
|
+
err_console.print(
|
|
504
|
+
f"'{name}' isn't a valid plugin name — must start with a lowercase letter "
|
|
505
|
+
f"and contain only lowercase letters, digits, and underscores (no leading "
|
|
506
|
+
f"underscore either — uv's own packaging tooling rejects distribution names "
|
|
507
|
+
f"that start with one, docs/arc.MD §3.7)."
|
|
508
|
+
)
|
|
509
|
+
raise typer.Exit(code=1)
|
|
510
|
+
|
|
511
|
+
root = find_project_root()
|
|
512
|
+
target = root / "plugins" / name
|
|
513
|
+
if target.exists():
|
|
514
|
+
err_console.print(f"plugins/{name} already exists.")
|
|
515
|
+
raise typer.Exit(code=1)
|
|
516
|
+
|
|
517
|
+
for rel_path, content in _plugin_template_files(name).items():
|
|
518
|
+
full_path = target / rel_path
|
|
519
|
+
full_path.parent.mkdir(parents=True, exist_ok=True)
|
|
520
|
+
full_path.write_text(content)
|
|
521
|
+
|
|
522
|
+
run(["git", "init", "-b", "main"], cwd=target)
|
|
523
|
+
console.print(f"[dim]Initialized a new git repo at plugins/{name} — nothing committed yet.[/dim]")
|
|
524
|
+
|
|
525
|
+
console.print("Installing dependencies via the workspace...")
|
|
526
|
+
run(["uv", "sync", "--all-packages"], cwd=root)
|
|
527
|
+
|
|
528
|
+
manifest = registry.read_manifest(target / "plugin.toml")
|
|
529
|
+
lock_path = root / ".arc" / "plugins.lock"
|
|
530
|
+
lock_doc = registry.load_lock(lock_path)
|
|
531
|
+
lock_doc = registry.merge_manifests_into_lock(lock_doc, [manifest])
|
|
532
|
+
registry.save_lock(lock_path, lock_doc)
|
|
533
|
+
|
|
534
|
+
console.print(f"[bold green]Scaffolded '{name}' at plugins/{name} and enabled it.[/bold green]")
|
|
535
|
+
console.print(
|
|
536
|
+
f"[dim]Next: add a schema (see plugins/{name}/{name}/schemas/README.md), "
|
|
537
|
+
f"then `arc psqldb plan` / `arc psqldb migrate` when you're ready to create tables. "
|
|
538
|
+
f"Review and commit plugins/{name} yourself when you're happy with it.[/dim]"
|
|
539
|
+
)
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
@app.command()
|
|
543
|
+
def build(
|
|
544
|
+
plugin: str = typer.Option(
|
|
545
|
+
None, "-p", "--plugin", help="Refresh only this plugin's plugins.lock entry."
|
|
546
|
+
),
|
|
547
|
+
no_lock: bool = typer.Option(
|
|
548
|
+
False, "--no-lock", help="Skip the `uv lock` step (just sync from the existing lock)."
|
|
549
|
+
),
|
|
550
|
+
) -> None:
|
|
551
|
+
"""
|
|
552
|
+
Re-resolve and re-install everything currently on disk under arc/ and
|
|
553
|
+
plugins/*, and refresh .arc/plugins.lock to match.
|
|
554
|
+
|
|
555
|
+
This does NOT fetch anything from git — that's `arc install`'s job.
|
|
556
|
+
`arc build` is what you run after a fresh clone of the whole project
|
|
557
|
+
(CI, a new machine, restoring from backup), or after hand-editing a
|
|
558
|
+
plugin's own pyproject.toml dependencies.
|
|
559
|
+
"""
|
|
560
|
+
root = find_project_root()
|
|
561
|
+
plugins_dir = root / "plugins"
|
|
562
|
+
lock_path = root / ".arc" / "plugins.lock"
|
|
563
|
+
|
|
564
|
+
all_manifests = registry.discover_plugins(plugins_dir)
|
|
565
|
+
if not all_manifests:
|
|
566
|
+
console.print("[yellow]No plugins found under plugins/ — nothing to build.[/yellow]")
|
|
567
|
+
else:
|
|
568
|
+
warnings = registry.validate_requires(all_manifests)
|
|
569
|
+
for w in warnings:
|
|
570
|
+
console.print(f"[yellow]Warning: {w}[/yellow]")
|
|
571
|
+
|
|
572
|
+
to_refresh = (
|
|
573
|
+
[m for m in all_manifests if m.name == plugin] if plugin else all_manifests
|
|
574
|
+
)
|
|
575
|
+
if plugin and not to_refresh:
|
|
576
|
+
available = ", ".join(m.name for m in all_manifests) or "none"
|
|
577
|
+
err_console.print(f"No plugin named '{plugin}' found. Available: {available}")
|
|
578
|
+
raise typer.Exit(code=1)
|
|
579
|
+
|
|
580
|
+
if not no_lock:
|
|
581
|
+
run(["uv", "lock"], cwd=root)
|
|
582
|
+
run(["uv", "sync", "--all-packages"], cwd=root)
|
|
583
|
+
|
|
584
|
+
if to_refresh:
|
|
585
|
+
lock_doc = registry.load_lock(lock_path)
|
|
586
|
+
lock_doc = registry.merge_manifests_into_lock(lock_doc, to_refresh)
|
|
587
|
+
registry.save_lock(lock_path, lock_doc)
|
|
588
|
+
console.print(
|
|
589
|
+
f"[bold green]Build complete. Refreshed: "
|
|
590
|
+
f"{', '.join(m.name for m in to_refresh)}[/bold green]"
|
|
591
|
+
)
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
# --------------------------------------------------------------------------- #
|
|
595
|
+
# arc clear-cache
|
|
596
|
+
# --------------------------------------------------------------------------- #
|
|
597
|
+
@app.command(name="clear-cache")
|
|
598
|
+
def clear_cache() -> None:
|
|
599
|
+
"""Clears everything genuinely cache-like: relay's generic cache
|
|
600
|
+
(cache_get/cache_set/cache_delete) and authn's session/access-key
|
|
601
|
+
cache. Deliberately does NOT touch lineup's job queues or redix's
|
|
602
|
+
rate-limit counters, which live in the same Redis instance under
|
|
603
|
+
their own prefixes — clearing those would drop pending work or reset
|
|
604
|
+
brute-force protection, neither of which "clear the cache" should do.
|
|
605
|
+
|
|
606
|
+
A narrow, acknowledged exception to the kernel's own domain-blindness
|
|
607
|
+
(§3.1): this needs to know redix's/authn's own key-prefix conventions
|
|
608
|
+
by name (hardcoded below), which the kernel doesn't otherwise know or
|
|
609
|
+
care about. Kept here anyway because "clear the cache" is a
|
|
610
|
+
cross-cutting operational concern no single plugin owns — the same
|
|
611
|
+
reasoning `arc doctor`/`arc health` already lean on."""
|
|
612
|
+
import arc
|
|
613
|
+
|
|
614
|
+
with warnings.catch_warnings():
|
|
615
|
+
warnings.simplefilter("ignore", arc.ArcAdvisory)
|
|
616
|
+
try:
|
|
617
|
+
kernel = arc.boot()
|
|
618
|
+
except arc.BootError as exc:
|
|
619
|
+
err_console.print(str(exc))
|
|
620
|
+
raise typer.Exit(code=1)
|
|
621
|
+
|
|
622
|
+
if not kernel.has("redix"):
|
|
623
|
+
console.print("[dim]redix isn't installed — there's no cache to clear.[/dim]")
|
|
624
|
+
return
|
|
625
|
+
|
|
626
|
+
# relay's own generic cache: "cache:*". authn's session/access-key
|
|
627
|
+
# cache: "session:*" / "access_key:*" (docs/arc.MD §3.13). Never
|
|
628
|
+
# "lineup:*" (job queues, §3.15) or "ratelimit:*" (redix.rate_limit()).
|
|
629
|
+
_CACHE_KEY_PATTERNS = ("cache:*", "session:*", "access_key:*")
|
|
630
|
+
|
|
631
|
+
async def _run() -> int:
|
|
632
|
+
await arc.redix.open()
|
|
633
|
+
try:
|
|
634
|
+
total = 0
|
|
635
|
+
for pattern in _CACHE_KEY_PATTERNS:
|
|
636
|
+
total += await arc.redix.scan_delete(pattern)
|
|
637
|
+
return total
|
|
638
|
+
finally:
|
|
639
|
+
await arc.redix.close()
|
|
640
|
+
|
|
641
|
+
total = asyncio.run(_run())
|
|
642
|
+
console.print(
|
|
643
|
+
f"[bold green]Cleared {total} cache key(s)[/bold green] "
|
|
644
|
+
f"(relay's generic cache + authn's session/access-key cache)."
|
|
645
|
+
)
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
# --------------------------------------------------------------------------- #
|
|
649
|
+
# arc settings get / set / delete
|
|
650
|
+
# --------------------------------------------------------------------------- #
|
|
651
|
+
@settings_app.command("get")
|
|
652
|
+
def settings_get(
|
|
653
|
+
key: str,
|
|
654
|
+
reveal: bool = typer.Option(
|
|
655
|
+
False, "--reveal", help="Show the real value even if the key is a secret."
|
|
656
|
+
),
|
|
657
|
+
) -> None:
|
|
658
|
+
"""Get a setting's value. Secret values print as ******** unless --reveal is passed."""
|
|
659
|
+
root = find_project_root()
|
|
660
|
+
mgr = SettingsManager(root / ".arc")
|
|
661
|
+
try:
|
|
662
|
+
value = mgr.get(key, reveal=reveal)
|
|
663
|
+
except SettingsError as exc:
|
|
664
|
+
err_console.print(str(exc))
|
|
665
|
+
raise typer.Exit(code=1)
|
|
666
|
+
|
|
667
|
+
if value is None:
|
|
668
|
+
err_console.print(f"'{key}' is not set.")
|
|
669
|
+
raise typer.Exit(code=1)
|
|
670
|
+
console.print(value)
|
|
671
|
+
|
|
672
|
+
|
|
673
|
+
@settings_app.command("set")
|
|
674
|
+
def settings_set(
|
|
675
|
+
key: str,
|
|
676
|
+
value: str,
|
|
677
|
+
secret: bool = typer.Option(
|
|
678
|
+
False, "--secret", help="Store this value encrypted and mark the key as secret."
|
|
679
|
+
),
|
|
680
|
+
) -> None:
|
|
681
|
+
"""Set a setting. Plain settings go into .arc/arc.toml; --secret routes to arc.secrets."""
|
|
682
|
+
root = find_project_root()
|
|
683
|
+
mgr = SettingsManager(root / ".arc")
|
|
684
|
+
try:
|
|
685
|
+
mgr.set(key, value, secret=secret)
|
|
686
|
+
except SettingsError as exc:
|
|
687
|
+
err_console.print(str(exc))
|
|
688
|
+
raise typer.Exit(code=1)
|
|
689
|
+
|
|
690
|
+
shown = REDACTED if secret else value
|
|
691
|
+
console.print(f"[green]Set {key} = {shown}[/green]" + (" (secret)" if secret else ""))
|
|
692
|
+
|
|
693
|
+
|
|
694
|
+
@settings_app.command("delete")
|
|
695
|
+
def settings_delete(key: str) -> None:
|
|
696
|
+
"""Delete a setting, whether plain or secret."""
|
|
697
|
+
root = find_project_root()
|
|
698
|
+
mgr = SettingsManager(root / ".arc")
|
|
699
|
+
existed = mgr.delete(key)
|
|
700
|
+
if existed:
|
|
701
|
+
console.print(f"[green]Deleted '{key}'.[/green]")
|
|
702
|
+
else:
|
|
703
|
+
err_console.print(f"'{key}' was not set.")
|
|
704
|
+
raise typer.Exit(code=1)
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
# --------------------------------------------------------------------------- #
|
|
708
|
+
# arc plugin enable / disable / list
|
|
709
|
+
# --------------------------------------------------------------------------- #
|
|
710
|
+
@plugin_app.command("enable")
|
|
711
|
+
def plugin_enable(name: str) -> None:
|
|
712
|
+
"""Enable a plugin. arc.boot() will call its register() and attach its namespace."""
|
|
713
|
+
root = find_project_root()
|
|
714
|
+
lock_path = root / ".arc" / "plugins.lock"
|
|
715
|
+
lock_doc = registry.load_lock(lock_path)
|
|
716
|
+
try:
|
|
717
|
+
registry.set_enabled(lock_doc, name, True)
|
|
718
|
+
except registry.RegistryError as exc:
|
|
719
|
+
err_console.print(str(exc))
|
|
720
|
+
raise typer.Exit(code=1)
|
|
721
|
+
registry.save_lock(lock_path, lock_doc)
|
|
722
|
+
console.print(f"[green]Enabled plugin '{name}'.[/green]")
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
@plugin_app.command("disable")
|
|
726
|
+
def plugin_disable(
|
|
727
|
+
name: str,
|
|
728
|
+
wipe: bool = typer.Option(
|
|
729
|
+
False, "--wipe", help="Also DROP every table this plugin owns from the database. Irreversible."
|
|
730
|
+
),
|
|
731
|
+
force: bool = typer.Option(
|
|
732
|
+
False,
|
|
733
|
+
"--force",
|
|
734
|
+
help="With --wipe: proceed even if another plugin has patched fields onto, or a live "
|
|
735
|
+
"REFERENCE into, one of these tables (destroys that too — see the command's own help).",
|
|
736
|
+
),
|
|
737
|
+
yes: bool = typer.Option(False, "--yes", help="Skip the drop confirmation prompt (--wipe only)."),
|
|
738
|
+
) -> None:
|
|
739
|
+
"""Disable a plugin. It is fully unregistered — arc.boot() skips it entirely,
|
|
740
|
+
and its capability namespace (arc.<name>) will not exist at runtime.
|
|
741
|
+
|
|
742
|
+
--wipe additionally DROPs every table this plugin owns — a real DROP
|
|
743
|
+
TABLE, not the recoverable soft-delete `arc psqldb clear` uses (a
|
|
744
|
+
dropped table has no _trash entry, only a dropped ROW does). Two
|
|
745
|
+
real risks, handled two different ways: if another still-enabled
|
|
746
|
+
plugin has PATCHED extra fields onto one of these tables, that's
|
|
747
|
+
checked directly (Postgres has no concept of "which plugin owns this
|
|
748
|
+
column") and refused unless --force. If another plugin's table has a
|
|
749
|
+
live REFERENCE pointing at one of these, Postgres's own FK constraint
|
|
750
|
+
refuses the plain DROP TABLE on its own — --force retries with
|
|
751
|
+
CASCADE in that case. Either way, --force means real, additional data
|
|
752
|
+
loss beyond this plugin's own tables — read the preview before
|
|
753
|
+
confirming."""
|
|
754
|
+
root = find_project_root()
|
|
755
|
+
lock_path = root / ".arc" / "plugins.lock"
|
|
756
|
+
lock_doc = registry.load_lock(lock_path)
|
|
757
|
+
|
|
758
|
+
if name not in lock_doc.get("plugins", {}):
|
|
759
|
+
err_console.print(f"Plugin '{name}' is not in plugins.lock. Run `arc build` first.")
|
|
760
|
+
raise typer.Exit(code=1)
|
|
761
|
+
|
|
762
|
+
if wipe:
|
|
763
|
+
import arc
|
|
764
|
+
|
|
765
|
+
with warnings.catch_warnings():
|
|
766
|
+
warnings.simplefilter("ignore", arc.ArcAdvisory)
|
|
767
|
+
try:
|
|
768
|
+
arc.boot()
|
|
769
|
+
except arc.BootError as exc:
|
|
770
|
+
err_console.print(str(exc))
|
|
771
|
+
raise typer.Exit(code=1)
|
|
772
|
+
|
|
773
|
+
async def _run() -> bool:
|
|
774
|
+
await arc.psqldb.open()
|
|
775
|
+
try:
|
|
776
|
+
plan = await arc.psqldb.wipe_plugin_tables(name, force=force, dry_run=True)
|
|
777
|
+
all_tables = plan["tables"] + ([plan["audit_table"]] if plan["audit_table"] else [])
|
|
778
|
+
if not all_tables:
|
|
779
|
+
console.print(f"[dim]'{name}' owns no tables — nothing to wipe.[/dim]")
|
|
780
|
+
return True
|
|
781
|
+
console.print(f"[bold red]About to PERMANENTLY DROP {len(all_tables)} table(s):[/bold red]")
|
|
782
|
+
for t in all_tables:
|
|
783
|
+
console.print(f" {t} ({plan['row_counts'].get(t, 0)} row(s))")
|
|
784
|
+
if not yes and not typer.confirm("This cannot be undone. Proceed?", default=False):
|
|
785
|
+
console.print("[dim]Aborted — nothing dropped, plugin not disabled.[/dim]")
|
|
786
|
+
return False
|
|
787
|
+
await arc.psqldb.wipe_plugin_tables(name, force=force, dry_run=False)
|
|
788
|
+
console.print(f"[green]Dropped {len(all_tables)} table(s).[/green]")
|
|
789
|
+
return True
|
|
790
|
+
finally:
|
|
791
|
+
await arc.psqldb.close()
|
|
792
|
+
|
|
793
|
+
try:
|
|
794
|
+
proceed = asyncio.run(_run())
|
|
795
|
+
except Exception as exc:
|
|
796
|
+
err_console.print(str(exc))
|
|
797
|
+
raise typer.Exit(code=1)
|
|
798
|
+
if not proceed:
|
|
799
|
+
raise typer.Exit(code=1)
|
|
800
|
+
|
|
801
|
+
try:
|
|
802
|
+
registry.set_enabled(lock_doc, name, False)
|
|
803
|
+
except registry.RegistryError as exc:
|
|
804
|
+
err_console.print(str(exc))
|
|
805
|
+
raise typer.Exit(code=1)
|
|
806
|
+
registry.save_lock(lock_path, lock_doc)
|
|
807
|
+
console.print(f"[yellow]Disabled plugin '{name}'. It will not be loaded by arc.boot().[/yellow]")
|
|
808
|
+
|
|
809
|
+
|
|
810
|
+
@plugin_app.command("list")
|
|
811
|
+
def plugin_list() -> None:
|
|
812
|
+
"""Show every plugin known to plugins.lock and its enabled/disabled state."""
|
|
813
|
+
root = find_project_root()
|
|
814
|
+
lock_path = root / ".arc" / "plugins.lock"
|
|
815
|
+
lock_doc = registry.load_lock(lock_path)
|
|
816
|
+
entries = registry.list_plugins(lock_doc)
|
|
817
|
+
|
|
818
|
+
if not entries:
|
|
819
|
+
console.print("[dim]No plugins in plugins.lock yet. Run `arc build` first.[/dim]")
|
|
820
|
+
return
|
|
821
|
+
|
|
822
|
+
table = Table()
|
|
823
|
+
table.add_column("Name")
|
|
824
|
+
table.add_column("Version")
|
|
825
|
+
table.add_column("Capability")
|
|
826
|
+
table.add_column("Enabled")
|
|
827
|
+
table.add_column("Requires")
|
|
828
|
+
for name, entry in entries:
|
|
829
|
+
enabled = entry.get("enabled", True)
|
|
830
|
+
table.add_row(
|
|
831
|
+
name,
|
|
832
|
+
str(entry.get("version", "")),
|
|
833
|
+
str(entry.get("capability", "")),
|
|
834
|
+
"[green]yes[/green]" if enabled else "[red]no[/red]",
|
|
835
|
+
", ".join(entry.get("requires", [])) or "-",
|
|
836
|
+
)
|
|
837
|
+
console.print(table)
|
|
838
|
+
|
|
839
|
+
|
|
840
|
+
if __name__ == "__main__":
|
|
841
|
+
app()
|