artoo-artifacts 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.
- artoo/__init__.py +8 -0
- artoo/build.py +47 -0
- artoo/cli.py +274 -0
- artoo/deploy/__init__.py +46 -0
- artoo/deploy/base.py +45 -0
- artoo/deploy/command.py +53 -0
- artoo/deploy/github_pages.py +328 -0
- artoo/deploy/rsync.py +116 -0
- artoo/discover.py +67 -0
- artoo/firewall.py +92 -0
- artoo/generators/__init__.py +33 -0
- artoo/generators/explainer/__init__.py +362 -0
- artoo/generators/explainer/inventory.py +245 -0
- artoo/generators/explainer/prompts.py +141 -0
- artoo/generators/explainer/templates.py +81 -0
- artoo/libraries/__init__.py +143 -0
- artoo/libraries/kit/__init__.py +19 -0
- artoo/libraries/kit/assets/article.css +170 -0
- artoo/libraries/kit/assets/base.css +147 -0
- artoo/libraries/kit/assets/components.css +237 -0
- artoo/libraries/kit/assets/kit.js +49 -0
- artoo/libraries/kit/assets/tokens.css +142 -0
- artoo/manifest.py +228 -0
- artoo/research.py +118 -0
- artoo/scaffold.py +104 -0
- artoo/workers.py +143 -0
- artoo_artifacts-0.1.0.dist-info/METADATA +106 -0
- artoo_artifacts-0.1.0.dist-info/RECORD +31 -0
- artoo_artifacts-0.1.0.dist-info/WHEEL +4 -0
- artoo_artifacts-0.1.0.dist-info/entry_points.txt +13 -0
- artoo_artifacts-0.1.0.dist-info/licenses/LICENSE +21 -0
artoo/__init__.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""artoo — generate and manage artifacts.
|
|
2
|
+
|
|
3
|
+
An artifact is a directory marked by an ``artifact.toml``: a self-contained,
|
|
4
|
+
publishable HTML mini-site (``site/``) plus the research material that backs
|
|
5
|
+
it, kept behind a deny-by-default deploy firewall.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
__version__ = "0.1.0"
|
artoo/build.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Build an artifact: run its refresh commands, then verify the site."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import subprocess
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
|
|
8
|
+
from . import firewall
|
|
9
|
+
from .manifest import Manifest
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class BuildResult:
|
|
14
|
+
ok: bool
|
|
15
|
+
ran: list[str] = field(default_factory=list)
|
|
16
|
+
problems: list[str] = field(default_factory=list)
|
|
17
|
+
withheld: list[str] = field(default_factory=list)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def build(m: Manifest, *, dry_run: bool = False) -> BuildResult:
|
|
21
|
+
"""Run ``[build] commands`` from the artifact directory, then firewall-check.
|
|
22
|
+
|
|
23
|
+
Build commands refresh generated inputs (exports, data snapshots). They
|
|
24
|
+
run with the artifact directory as cwd so relative paths in the manifest
|
|
25
|
+
stay portable.
|
|
26
|
+
"""
|
|
27
|
+
result = BuildResult(ok=True)
|
|
28
|
+
for command in m.build_commands:
|
|
29
|
+
result.ran.append(command)
|
|
30
|
+
if dry_run:
|
|
31
|
+
continue
|
|
32
|
+
proc = subprocess.run(
|
|
33
|
+
command, shell=True, cwd=m.dir, capture_output=True, text=True
|
|
34
|
+
)
|
|
35
|
+
if proc.returncode != 0:
|
|
36
|
+
detail = (proc.stderr or proc.stdout or "").strip()
|
|
37
|
+
result.ok = False
|
|
38
|
+
result.problems.append(
|
|
39
|
+
f"build command failed ({proc.returncode}): {command}\n{detail}"
|
|
40
|
+
)
|
|
41
|
+
return result
|
|
42
|
+
|
|
43
|
+
result.problems.extend(firewall.check(m))
|
|
44
|
+
if result.problems:
|
|
45
|
+
result.ok = False
|
|
46
|
+
result.withheld = [str(p) for p in firewall.withheld(m.site_dir)] if m.site_dir.is_dir() else []
|
|
47
|
+
return result
|
artoo/cli.py
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
"""The artoo CLI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import tempfile
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
|
|
10
|
+
from . import __version__, build as build_mod, deploy as deploy_mod
|
|
11
|
+
from . import discover, firewall, generators
|
|
12
|
+
from . import libraries as libraries_mod
|
|
13
|
+
from . import manifest as manifest_mod
|
|
14
|
+
from . import scaffold
|
|
15
|
+
from .manifest import KINDS, Manifest
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _resolve(path: str | None) -> Manifest:
|
|
19
|
+
try:
|
|
20
|
+
return discover.resolve_artifact(Path(path) if path else None)
|
|
21
|
+
except FileNotFoundError as exc:
|
|
22
|
+
raise click.ClickException(str(exc)) from exc
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@click.group()
|
|
26
|
+
@click.version_option(version=__version__, prog_name="artoo")
|
|
27
|
+
def main():
|
|
28
|
+
"""Generate and manage artifacts — self-contained HTML mini-sites
|
|
29
|
+
that pair presentation with the research backing it."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@main.command()
|
|
33
|
+
@click.argument("path", type=click.Path(path_type=Path))
|
|
34
|
+
@click.option("--slug", default="", help="Artifact slug (default: directory name).")
|
|
35
|
+
@click.option("--title", default="", help="Human title.")
|
|
36
|
+
@click.option("--kind", default="report", type=click.Choice(KINDS), show_default=True)
|
|
37
|
+
@click.option("--description", default="")
|
|
38
|
+
@click.option("--notebook", is_flag=True, help="Also create a research notebook.")
|
|
39
|
+
def init(path: Path, slug: str, title: str, kind: str, description: str, notebook: bool):
|
|
40
|
+
"""Scaffold a new artifact at PATH."""
|
|
41
|
+
try:
|
|
42
|
+
m = scaffold.init_artifact(
|
|
43
|
+
path, slug=slug, title=title, kind=kind,
|
|
44
|
+
description=description, with_notebook=notebook,
|
|
45
|
+
)
|
|
46
|
+
except FileExistsError as exc:
|
|
47
|
+
raise click.ClickException(str(exc)) from exc
|
|
48
|
+
click.echo(f"created {m.dir}")
|
|
49
|
+
click.echo(f" manifest {m.path.relative_to(Path.cwd()) if m.path.is_relative_to(Path.cwd()) else m.path}")
|
|
50
|
+
click.echo(f" site {m.site}/index.html")
|
|
51
|
+
if notebook:
|
|
52
|
+
click.echo(" notebook notebook/")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@main.command(name="list")
|
|
56
|
+
@click.argument("root", type=click.Path(exists=True, path_type=Path), default=".")
|
|
57
|
+
def list_cmd(root: Path):
|
|
58
|
+
"""Discover artifacts under ROOT."""
|
|
59
|
+
paths = discover.find_artifacts(root)
|
|
60
|
+
if not paths:
|
|
61
|
+
click.echo(f"no artifacts under {root.resolve()}")
|
|
62
|
+
return
|
|
63
|
+
for p in paths:
|
|
64
|
+
try:
|
|
65
|
+
m = manifest_mod.load(p)
|
|
66
|
+
label = f"{m.slug:24} {m.kind:16} {m.status:9}"
|
|
67
|
+
target = m.deploy_target or "-"
|
|
68
|
+
click.echo(f"{label} {target:14} {p}")
|
|
69
|
+
except Exception as exc:
|
|
70
|
+
click.echo(f"{'?':24} {'invalid':16} {'':9} {'':14} {p} ({exc})")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@main.command()
|
|
74
|
+
@click.argument("path", type=click.Path(path_type=Path), required=False)
|
|
75
|
+
def status(path: Path | None):
|
|
76
|
+
"""Manifest health, firewall report, and library drift for an artifact."""
|
|
77
|
+
m = _resolve(str(path) if path else None)
|
|
78
|
+
click.echo(f"{m.slug} — {m.title}")
|
|
79
|
+
click.echo(f" kind {m.kind} · status {m.status} · deploy {m.deploy_target or '(unset)'}")
|
|
80
|
+
|
|
81
|
+
problems = m.validate() + firewall.check(m)
|
|
82
|
+
for problem in problems:
|
|
83
|
+
click.secho(f" ✗ {problem}", fg="red")
|
|
84
|
+
|
|
85
|
+
if m.site_dir.is_dir():
|
|
86
|
+
held = firewall.withheld(m.site_dir)
|
|
87
|
+
if held:
|
|
88
|
+
click.echo(f" firewall withholds {len(held)} file(s) inside site/:")
|
|
89
|
+
for rel in held[:8]:
|
|
90
|
+
click.echo(f" - {rel}")
|
|
91
|
+
|
|
92
|
+
for row in libraries_mod.status(m):
|
|
93
|
+
mark = "✓" if row["state"] == "intact" else "!"
|
|
94
|
+
click.echo(f" {mark} lib {row['name']} {row['version']} — {row['state']}")
|
|
95
|
+
|
|
96
|
+
if not problems:
|
|
97
|
+
click.secho(" ✓ manifest and firewall clean", fg="green")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@main.command(name="build")
|
|
101
|
+
@click.argument("path", type=click.Path(path_type=Path), required=False)
|
|
102
|
+
@click.option("--dry-run", is_flag=True)
|
|
103
|
+
def build_cmd(path: Path | None, dry_run: bool):
|
|
104
|
+
"""Run the artifact's build commands, then verify the site."""
|
|
105
|
+
m = _resolve(str(path) if path else None)
|
|
106
|
+
result = build_mod.build(m, dry_run=dry_run)
|
|
107
|
+
for command in result.ran:
|
|
108
|
+
click.echo(f"{'would run' if dry_run else 'ran'}: {command}")
|
|
109
|
+
for problem in result.problems:
|
|
110
|
+
click.secho(f"✗ {problem}", fg="red")
|
|
111
|
+
if result.ok:
|
|
112
|
+
click.secho(f"✓ site ready: {m.site_dir}", fg="green")
|
|
113
|
+
else:
|
|
114
|
+
raise SystemExit(1)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@main.command(name="deploy")
|
|
118
|
+
@click.argument("path", type=click.Path(path_type=Path), required=False)
|
|
119
|
+
@click.option("--dry-run", is_flag=True, help="Show what would happen; touch nothing remote.")
|
|
120
|
+
@click.option("--skip-build", is_flag=True, help="Skip build commands before deploying.")
|
|
121
|
+
def deploy_cmd(path: Path | None, dry_run: bool, skip_build: bool):
|
|
122
|
+
"""Firewall-stage the site, then hand it to the deploy adapter."""
|
|
123
|
+
m = _resolve(str(path) if path else None)
|
|
124
|
+
if not m.deploy_target:
|
|
125
|
+
raise click.ClickException(
|
|
126
|
+
"no [deploy] target in the manifest. Set one, e.g.\n"
|
|
127
|
+
' [deploy]\n target = "github-pages"'
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
if not skip_build:
|
|
131
|
+
result = build_mod.build(m, dry_run=dry_run)
|
|
132
|
+
if not result.ok:
|
|
133
|
+
for problem in result.problems:
|
|
134
|
+
click.secho(f"✗ {problem}", fg="red")
|
|
135
|
+
raise SystemExit(1)
|
|
136
|
+
|
|
137
|
+
try:
|
|
138
|
+
adapter_cls = deploy_mod.get(m.deploy_target)
|
|
139
|
+
except KeyError as exc:
|
|
140
|
+
raise click.ClickException(str(exc)) from exc
|
|
141
|
+
|
|
142
|
+
with tempfile.TemporaryDirectory(prefix="artoo-deploy-") as tmp:
|
|
143
|
+
staged = Path(tmp) / "site"
|
|
144
|
+
staged_files = firewall.stage(m, staged)
|
|
145
|
+
click.echo(f"staged {len(staged_files)} file(s) through the firewall")
|
|
146
|
+
ctx = deploy_mod.DeployContext(
|
|
147
|
+
manifest=m, staged=staged, config=m.deploy_config, dry_run=dry_run
|
|
148
|
+
)
|
|
149
|
+
outcome = adapter_cls().deploy(ctx)
|
|
150
|
+
|
|
151
|
+
for action in outcome.actions:
|
|
152
|
+
if action:
|
|
153
|
+
click.echo(f" {action}")
|
|
154
|
+
if outcome.ok:
|
|
155
|
+
click.secho(f"✓ {outcome.message}", fg="green")
|
|
156
|
+
else:
|
|
157
|
+
click.secho(f"✗ {outcome.message}", fg="red")
|
|
158
|
+
raise SystemExit(1)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
# -- lib subcommands ---------------------------------------------------------
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
@main.group()
|
|
165
|
+
def lib():
|
|
166
|
+
"""Manage vendored site libraries."""
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@lib.command(name="list")
|
|
170
|
+
def lib_list():
|
|
171
|
+
"""Libraries available to vendor."""
|
|
172
|
+
for name, library in sorted(libraries_mod.available().items()):
|
|
173
|
+
click.echo(f"{name:20} {library.version:10} {library.root}")
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
@lib.command(name="add")
|
|
177
|
+
@click.argument("name")
|
|
178
|
+
@click.option("--artifact", type=click.Path(path_type=Path), default=None)
|
|
179
|
+
def lib_add(name: str, artifact: Path | None):
|
|
180
|
+
"""Vendor a library into the artifact's site."""
|
|
181
|
+
m = _resolve(str(artifact) if artifact else None)
|
|
182
|
+
try:
|
|
183
|
+
record = libraries_mod.add(m, name)
|
|
184
|
+
except KeyError as exc:
|
|
185
|
+
raise click.ClickException(str(exc)) from exc
|
|
186
|
+
click.echo(f"vendored {record['name']} {record['version']} → {m.site}/lib/{name}/")
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
@lib.command(name="status")
|
|
190
|
+
@click.option("--artifact", type=click.Path(path_type=Path), default=None)
|
|
191
|
+
def lib_status(artifact: Path | None):
|
|
192
|
+
"""Intact / modified / outdated state of each vendored library."""
|
|
193
|
+
m = _resolve(str(artifact) if artifact else None)
|
|
194
|
+
rows = libraries_mod.status(m)
|
|
195
|
+
if not rows:
|
|
196
|
+
click.echo("no libraries recorded in the manifest")
|
|
197
|
+
return
|
|
198
|
+
for row in rows:
|
|
199
|
+
click.echo(f"{row['name']:20} {row['version']:10} {row['state']}")
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
@lib.command(name="update")
|
|
203
|
+
@click.argument("name")
|
|
204
|
+
@click.option("--artifact", type=click.Path(path_type=Path), default=None)
|
|
205
|
+
def lib_update(name: str, artifact: Path | None):
|
|
206
|
+
"""Re-vendor a library at its current version."""
|
|
207
|
+
m = _resolve(str(artifact) if artifact else None)
|
|
208
|
+
try:
|
|
209
|
+
record = libraries_mod.update(m, name)
|
|
210
|
+
except KeyError as exc:
|
|
211
|
+
raise click.ClickException(str(exc)) from exc
|
|
212
|
+
click.echo(f"updated {record['name']} → {record['version']}")
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
@lib.command(name="vendor")
|
|
216
|
+
@click.argument("name")
|
|
217
|
+
@click.argument("url")
|
|
218
|
+
@click.option("--artifact", type=click.Path(path_type=Path), default=None)
|
|
219
|
+
def lib_vendor(name: str, url: str, artifact: Path | None):
|
|
220
|
+
"""Vendor a single asset from a URL with a pinned hash."""
|
|
221
|
+
m = _resolve(str(artifact) if artifact else None)
|
|
222
|
+
record = libraries_mod.vendor_url(m, name, url)
|
|
223
|
+
click.echo(f"vendored {name} → {record['path']} ({record['sha256'][:12]}…)")
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
# -- generate ---------------------------------------------------------------
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
class _GenerateGroup(click.Group):
|
|
230
|
+
def list_commands(self, ctx):
|
|
231
|
+
return sorted(generators.available())
|
|
232
|
+
|
|
233
|
+
def get_command(self, ctx, name):
|
|
234
|
+
return generators.available().get(name)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
@main.command(cls=_GenerateGroup, name="generate")
|
|
238
|
+
def generate_cmd():
|
|
239
|
+
"""Run a generator plugin."""
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
# -- doctor -------------------------------------------------------------------
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
@main.command()
|
|
246
|
+
@click.argument("root", type=click.Path(exists=True, path_type=Path), default=".")
|
|
247
|
+
def doctor(root: Path):
|
|
248
|
+
"""Repo-wide coherence report over every artifact under ROOT."""
|
|
249
|
+
paths = discover.find_artifacts(root)
|
|
250
|
+
if not paths:
|
|
251
|
+
click.echo(f"no artifacts under {root.resolve()}")
|
|
252
|
+
return
|
|
253
|
+
healthy = 0
|
|
254
|
+
for p in paths:
|
|
255
|
+
try:
|
|
256
|
+
m = manifest_mod.load(p)
|
|
257
|
+
except Exception as exc:
|
|
258
|
+
click.secho(f"✗ {p}: unreadable manifest ({exc})", fg="red")
|
|
259
|
+
continue
|
|
260
|
+
problems = m.validate() + firewall.check(m)
|
|
261
|
+
drifted = [r for r in libraries_mod.status(m) if r["state"] != "intact"]
|
|
262
|
+
if not problems and not drifted:
|
|
263
|
+
healthy += 1
|
|
264
|
+
continue
|
|
265
|
+
click.echo(f"{m.slug} ({p})")
|
|
266
|
+
for problem in problems:
|
|
267
|
+
click.secho(f" ✗ {problem}", fg="red")
|
|
268
|
+
for row in drifted:
|
|
269
|
+
click.secho(f" ! lib {row['name']}: {row['state']}", fg="yellow")
|
|
270
|
+
click.echo(f"{healthy}/{len(paths)} artifacts clean")
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
if __name__ == "__main__":
|
|
274
|
+
main()
|
artoo/deploy/__init__.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Deploy adapters: registry and dispatch.
|
|
2
|
+
|
|
3
|
+
Adapters are discovered from the ``artoo.deployers`` entry-point group so
|
|
4
|
+
they can ship as separate packages; the built-ins are registered directly
|
|
5
|
+
so artoo works uninstalled (from a source checkout).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from importlib.metadata import entry_points
|
|
11
|
+
|
|
12
|
+
from .base import DeployContext, Deployer, DeployResult
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _builtin() -> dict[str, type[Deployer]]:
|
|
16
|
+
from .command import CommandDeployer
|
|
17
|
+
from .github_pages import GitHubPagesDeployer
|
|
18
|
+
from .rsync import RsyncDeployer
|
|
19
|
+
|
|
20
|
+
return {
|
|
21
|
+
GitHubPagesDeployer.name: GitHubPagesDeployer,
|
|
22
|
+
RsyncDeployer.name: RsyncDeployer,
|
|
23
|
+
CommandDeployer.name: CommandDeployer,
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def available() -> dict[str, type[Deployer]]:
|
|
28
|
+
registry = _builtin()
|
|
29
|
+
for ep in entry_points(group="artoo.deployers"):
|
|
30
|
+
if ep.name not in registry:
|
|
31
|
+
try:
|
|
32
|
+
registry[ep.name] = ep.load()
|
|
33
|
+
except Exception: # a broken third-party plugin must not break artoo
|
|
34
|
+
continue
|
|
35
|
+
return registry
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def get(name: str) -> type[Deployer]:
|
|
39
|
+
registry = available()
|
|
40
|
+
if name not in registry:
|
|
41
|
+
known = ", ".join(sorted(registry))
|
|
42
|
+
raise KeyError(f"no deploy adapter named {name!r} (available: {known})")
|
|
43
|
+
return registry[name]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
__all__ = ["DeployContext", "Deployer", "DeployResult", "available", "get"]
|
artoo/deploy/base.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Deploy adapter protocol and shared context."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import subprocess
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import ClassVar, Protocol, runtime_checkable
|
|
9
|
+
|
|
10
|
+
from ..manifest import Manifest
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class DeployContext:
|
|
15
|
+
manifest: Manifest
|
|
16
|
+
staged: Path # firewall-cleared copy of the site, owned by the caller
|
|
17
|
+
config: dict = field(default_factory=dict)
|
|
18
|
+
dry_run: bool = False
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
def repo_root(self) -> Path | None:
|
|
22
|
+
"""Root of the git repo containing the artifact, if any."""
|
|
23
|
+
proc = subprocess.run(
|
|
24
|
+
["git", "rev-parse", "--show-toplevel"],
|
|
25
|
+
cwd=self.manifest.dir,
|
|
26
|
+
capture_output=True,
|
|
27
|
+
text=True,
|
|
28
|
+
)
|
|
29
|
+
if proc.returncode != 0:
|
|
30
|
+
return None
|
|
31
|
+
return Path(proc.stdout.strip())
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class DeployResult:
|
|
36
|
+
ok: bool
|
|
37
|
+
message: str
|
|
38
|
+
actions: list[str] = field(default_factory=list)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@runtime_checkable
|
|
42
|
+
class Deployer(Protocol):
|
|
43
|
+
name: ClassVar[str]
|
|
44
|
+
|
|
45
|
+
def deploy(self, ctx: DeployContext) -> DeployResult: ...
|
artoo/deploy/command.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""The escape-hatch adapter: publish with an arbitrary command.
|
|
2
|
+
|
|
3
|
+
The command runs from the artifact directory with the staged (firewall-
|
|
4
|
+
cleared) site exposed via environment variables. Whatever bespoke pipeline
|
|
5
|
+
already exists — ssh scripts, CI triggers, cloud CLIs — works today.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import subprocess
|
|
12
|
+
from typing import ClassVar
|
|
13
|
+
|
|
14
|
+
from .base import DeployContext, DeployResult
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class CommandDeployer:
|
|
18
|
+
name: ClassVar[str] = "command"
|
|
19
|
+
|
|
20
|
+
def deploy(self, ctx: DeployContext) -> DeployResult:
|
|
21
|
+
command = ctx.config.get("run", "")
|
|
22
|
+
if not command:
|
|
23
|
+
return DeployResult(
|
|
24
|
+
ok=False,
|
|
25
|
+
message='command adapter needs [deploy.command] run = "<shell command>"',
|
|
26
|
+
)
|
|
27
|
+
env = os.environ.copy()
|
|
28
|
+
env.update(
|
|
29
|
+
{
|
|
30
|
+
"ARTOO_SITE_DIR": str(ctx.staged),
|
|
31
|
+
"ARTOO_SLUG": ctx.manifest.slug,
|
|
32
|
+
"ARTOO_ARTIFACT_DIR": str(ctx.manifest.dir),
|
|
33
|
+
"ARTOO_REPO_ROOT": str(ctx.repo_root or ""),
|
|
34
|
+
}
|
|
35
|
+
)
|
|
36
|
+
if ctx.dry_run:
|
|
37
|
+
return DeployResult(
|
|
38
|
+
ok=True,
|
|
39
|
+
message="dry run — command not executed",
|
|
40
|
+
actions=[f"would run: {command}"],
|
|
41
|
+
)
|
|
42
|
+
proc = subprocess.run(
|
|
43
|
+
command, shell=True, cwd=ctx.manifest.dir, env=env,
|
|
44
|
+
capture_output=True, text=True,
|
|
45
|
+
)
|
|
46
|
+
output = (proc.stdout + proc.stderr).strip()
|
|
47
|
+
if proc.returncode != 0:
|
|
48
|
+
return DeployResult(
|
|
49
|
+
ok=False,
|
|
50
|
+
message=f"publish command exited {proc.returncode}",
|
|
51
|
+
actions=[command, output],
|
|
52
|
+
)
|
|
53
|
+
return DeployResult(ok=True, message="published", actions=[command, output])
|