dirigent-cli 0.9.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.
dirigent_cli/main.py ADDED
@@ -0,0 +1,1169 @@
1
+ """The dirigent command line: installed as `dirigent` and as the short alias `dg`."""
2
+
3
+ import os
4
+ import shutil
5
+ import sys
6
+ from collections.abc import Sequence
7
+ from datetime import UTC, datetime, timedelta
8
+ from importlib.metadata import PackageNotFoundError, version
9
+ from pathlib import Path
10
+ from typing import TYPE_CHECKING, Annotated, Any, cast
11
+
12
+ import typer
13
+ from pydantic import ValidationError
14
+ from typer import _click, rich_utils
15
+ from typer.core import TyperGroup
16
+
17
+ from dirigent_cli import commands, triggers
18
+ from dirigent_cli.aliases import add_alias, add_list_aliases
19
+ from dirigent_cli.context import CliState, state_of
20
+ from dirigent_cli.formatters import DEFAULT, names
21
+ from dirigent_cli.output import configure, detail_mode, emit_fact, emit_problem, emit_rendered, refuse
22
+ from dirigent_client.enums import UserRole
23
+ from dirigent_core import migrations
24
+ from dirigent_core.config import STATE_DIR, Settings, get_settings, redacted_url, reset_settings_cache
25
+ from dirigent_core.logging import LOG_FORMAT_ENV, configure_logging, silence_stdout
26
+ from dirigent_core.protocol import FORMATS, Format, Record, as_json, make
27
+ from dirigent_core.telemetry import configure_telemetry
28
+
29
+ if TYPE_CHECKING:
30
+ import uvicorn
31
+ from fastapi import FastAPI
32
+ from sqlalchemy.ext.asyncio import AsyncEngine
33
+
34
+ from dirigent_cli.health import Check
35
+ from dirigent_cli.stream import Sink
36
+ from dirigent_common import JsonMap
37
+ from dirigent_core import retention
38
+ from dirigent_core.worker import Worker
39
+
40
+ #: Help is capped rather than stretched: a panel the width of a wide terminal is unreadable.
41
+ rich_utils.MAX_WIDTH = 100
42
+
43
+ RUN_PANEL = "Run"
44
+ DEFINE_PANEL = "Define"
45
+ CONNECT_PANEL = "Connect"
46
+ TRIGGER_PANEL = "Triggers"
47
+ PROCESS_PANEL = "Processes"
48
+ ADMIN_PANEL = "Administration"
49
+
50
+ PANEL_ORDER = (RUN_PANEL, DEFINE_PANEL, CONNECT_PANEL, TRIGGER_PANEL, PROCESS_PANEL, ADMIN_PANEL)
51
+
52
+
53
+ class PanelOrderedGroup(TyperGroup):
54
+ """A group whose help panels follow PANEL_ORDER.
55
+
56
+ Typer renders a panel where its first command appears and lists every plain command
57
+ before every subcommand group, so panel order otherwise depends on which entries
58
+ happen to be groups.
59
+ """
60
+
61
+ # Typer vendors its own click; the signature must match the base class or this is a
62
+ # new method rather than an override.
63
+ def list_commands(self, ctx: _click.Context) -> list[str]:
64
+ """List the commands panel by panel, keeping registration order inside each panel."""
65
+
66
+ def rank(name: str) -> int:
67
+ command = self.get_command(ctx, name)
68
+ panel = getattr(command, "rich_help_panel", None)
69
+ return PANEL_ORDER.index(panel) if panel in PANEL_ORDER else len(PANEL_ORDER)
70
+
71
+ return sorted(super().list_commands(ctx), key=rank)
72
+
73
+
74
+ app = typer.Typer(
75
+ name="dirigent",
76
+ cls=PanelOrderedGroup,
77
+ help="A pipeline orchestrator. Pipelines are documents; blocks are what they run.",
78
+ no_args_is_help=True,
79
+ add_completion=True,
80
+ )
81
+
82
+ db_app = typer.Typer(name="db", help="Database schema management.", no_args_is_help=True)
83
+ config_app = typer.Typer(name="config", help="Inspect the effective configuration.", no_args_is_help=True)
84
+ health_app = typer.Typer(name="health", help="Process-side checks: everything this host runs, or one part.")
85
+ docker_app = typer.Typer(name="docker", help="The docker daemon this host's worker uses.", no_args_is_help=True)
86
+
87
+ app.add_typer(commands.runs_app, rich_help_panel=RUN_PANEL)
88
+ app.add_typer(commands.pipeline_app, rich_help_panel=DEFINE_PANEL)
89
+ app.add_typer(commands.blocks_app, rich_help_panel=DEFINE_PANEL)
90
+ app.add_typer(commands.schema_app, rich_help_panel=DEFINE_PANEL)
91
+ app.add_typer(commands.connection_app, rich_help_panel=CONNECT_PANEL)
92
+ app.add_typer(triggers.schedule_app, rich_help_panel=TRIGGER_PANEL)
93
+ app.add_typer(triggers.webhook_app, rich_help_panel=TRIGGER_PANEL)
94
+ app.add_typer(triggers.trigger_document_app, rich_help_panel=TRIGGER_PANEL)
95
+ app.add_typer(triggers.alerts_app, rich_help_panel=TRIGGER_PANEL)
96
+ app.add_typer(health_app, rich_help_panel=PROCESS_PANEL)
97
+ app.add_typer(docker_app, rich_help_panel=PROCESS_PANEL)
98
+ app.add_typer(db_app, rich_help_panel=ADMIN_PANEL)
99
+ app.add_typer(config_app, rich_help_panel=ADMIN_PANEL)
100
+ app.add_typer(commands.auth_app, rich_help_panel=ADMIN_PANEL)
101
+ app.add_typer(commands.system_app, rich_help_panel=ADMIN_PANEL)
102
+ app.add_typer(commands.admin_app, rich_help_panel=ADMIN_PANEL)
103
+
104
+ app.command("run", rich_help_panel=RUN_PANEL)(commands.run_command)
105
+ app.command("backfill", rich_help_panel=RUN_PANEL)(commands.backfill_command)
106
+ app.command("init", rich_help_panel=DEFINE_PANEL)(commands.init_command)
107
+ app.command("apply", rich_help_panel=DEFINE_PANEL)(commands.apply_command)
108
+ app.command("validate", rich_help_panel=DEFINE_PANEL)(commands.validate_command)
109
+ app.command("export", rich_help_panel=DEFINE_PANEL)(commands.export_command)
110
+
111
+
112
+ @app.callback()
113
+ def main_callback(
114
+ ctx: typer.Context,
115
+ url: Annotated[str | None, typer.Option("--url", help="The server to talk to.")] = None,
116
+ token: Annotated[str | None, typer.Option("--token", help="The bearer token to present.")] = None,
117
+ profile: Annotated[str | None, typer.Option("--profile", help="Which profile to use.")] = None,
118
+ verbose: Annotated[
119
+ int,
120
+ typer.Option("-v", "--verbose", count=True, help="Engine events and API calls, dirigent's own only."),
121
+ ] = 0,
122
+ debug: Annotated[
123
+ bool,
124
+ typer.Option("--debug", "-d", help="Engine internals: claims, references, probes, leases."),
125
+ ] = False,
126
+ debug_all: Annotated[
127
+ bool,
128
+ typer.Option("--debug-all", help="Everything, including every library's own logging."),
129
+ ] = False,
130
+ json_output: Annotated[
131
+ bool,
132
+ typer.Option("--json", help="Write JSON. The same as --output json."),
133
+ ] = False,
134
+ output: Annotated[
135
+ str | None,
136
+ typer.Option("-o", "--output", help="Output to write: json (the default) or console."),
137
+ ] = None,
138
+ ) -> None:
139
+ """Resolve which server the CLI talks to and how loud to be, before any command runs.
140
+
141
+ Precedence is flags, then DG_URL / DG_TOKEN / DIRIGENT_LOG_LEVEL, then the profile.
142
+ """
143
+ ctx.obj = CliState(
144
+ url=url,
145
+ token=token,
146
+ profile=profile,
147
+ verbose=verbose,
148
+ debug=debug,
149
+ debug_all=debug_all,
150
+ output=_output_format(output, json_output=json_output),
151
+ chosen=output is not None or json_output or LOG_FORMAT_ENV in os.environ,
152
+ )
153
+ ctx.obj.configure_output()
154
+
155
+
156
+ def _output_format(named: str | None, *, json_output: bool) -> Format:
157
+ """Resolve the output: the flag, then the environment, then NDJSON.
158
+
159
+ ``--json`` is the same request as ``--output json``, and the environment is where a
160
+ container says it once. NDJSON is what an unasked invocation writes, so what a command
161
+ emits does not depend on who happens to be reading it; ``-o console`` renders it.
162
+ """
163
+ chosen = named or ("json" if json_output else None) or os.environ.get(LOG_FORMAT_ENV)
164
+ if chosen is None:
165
+ return "json"
166
+ resolved = chosen.lower()
167
+ if resolved not in FORMATS:
168
+ # The output that was asked for is exactly what is missing, so the refusal is written
169
+ # in the default one rather than in the one that was named.
170
+ emit_problem(
171
+ f"{chosen!r} is not an output format",
172
+ status=2,
173
+ title="Not an output format",
174
+ problems=[f"the outputs are {', '.join(FORMATS)}"],
175
+ )
176
+ raise typer.Exit(code=2)
177
+ return cast("Format", resolved)
178
+
179
+
180
+ DISTRIBUTIONS = ("dirigent-cli", "dirigent-core", "dirigent-server", "dirigent-plugin", "dirigent-blocks")
181
+
182
+ DEV_ADMIN = "dev"
183
+ DEV_PASSWORD = "dirigent-dev" # noqa: S105
184
+ DEV_TOKEN_NAME = "dev"
185
+
186
+ SCHEDULER_ENV = "DIRIGENT_SCHEDULER_ENABLED"
187
+
188
+ UI_ENV = "DIRIGENT_UI_ENABLED"
189
+
190
+
191
+ def distribution_version(name: str) -> str:
192
+ """Read an installed distribution's version, or report that it is absent."""
193
+ try:
194
+ return version(name)
195
+ except PackageNotFoundError:
196
+ return "not installed"
197
+
198
+
199
+ @app.command(name="format", rich_help_panel=RUN_PANEL)
200
+ def format_command(
201
+ formatter: Annotated[
202
+ str | None,
203
+ typer.Argument(help=f"Which formatter renders the records. One of: {', '.join(names())}."),
204
+ ] = None,
205
+ file: Annotated[
206
+ Path | None,
207
+ typer.Option("-f", "--file", help="A file holding one record per line; standard input when omitted."),
208
+ ] = None,
209
+ ) -> None:
210
+ """Render an NDJSON stream for reading, from a pipe or from a file.
211
+
212
+ A line that is not a record is passed through untouched, so a mixed log still reads.
213
+ """
214
+ from dirigent_cli.formatters import looks_like_a_template, registry, rendered
215
+
216
+ # Rendering is what this command is. Every other command writes NDJSON unless asked
217
+ # otherwise, and inheriting that default here would mute the console doing the work.
218
+ configure(output="console", detail=detail_mode())
219
+ registered = registry()
220
+ named = DEFAULT if formatter is None else formatter
221
+ if named not in registered:
222
+ if looks_like_a_template(named):
223
+ # docker and kubectl take a template here, so the habit is worth answering rather
224
+ # than refusing. Picking fields out of a record is what jq does, over this stream.
225
+ emit_problem(
226
+ "a formatter renders whole records; it takes no template",
227
+ status=2,
228
+ title="Not a formatter",
229
+ problems=["to pick fields out, use jq: dg dev | jq -r '.step'"],
230
+ )
231
+ else:
232
+ # The formatter that would render this refusal is the one that is missing, so it
233
+ # is written in the default output rather than rendered.
234
+ emit_problem(
235
+ f"{named!r} is not a formatter",
236
+ status=2,
237
+ title="Not a formatter",
238
+ problems=[f"the formatters are {', '.join(registered)}"],
239
+ )
240
+ raise typer.Exit(code=2)
241
+ lines = file.read_text().splitlines() if file is not None else sys.stdin
242
+ for item in rendered(lines, registered[named]):
243
+ emit_rendered(item)
244
+
245
+
246
+ @app.command(name="version", rich_help_panel=ADMIN_PANEL)
247
+ def version_command() -> None:
248
+ """Show the versions of the installed dirigent packages."""
249
+ emit_fact(
250
+ "version",
251
+ message="dirigent",
252
+ packages=[{"package": name, "version": distribution_version(name)} for name in DISTRIBUTIONS],
253
+ )
254
+
255
+
256
+ @config_app.command("show")
257
+ def config_show() -> None:
258
+ """Show the effective configuration, with secrets redacted."""
259
+ settings = get_settings()
260
+ emit_fact(
261
+ "config",
262
+ message="effective configuration",
263
+ settings={
264
+ name: ("***" if name == "secret_key" and value is not None else str(value))
265
+ for name, value in settings.model_dump().items()
266
+ },
267
+ )
268
+
269
+
270
+ @db_app.command("upgrade")
271
+ def db_upgrade(
272
+ revision: Annotated[str, typer.Argument(help="The revision to reach; the newest when omitted.")] = "head",
273
+ ) -> None:
274
+ """Bring the database schema up to a revision, creating it when empty."""
275
+ settings = get_settings()
276
+ migrations.upgrade(revision, settings)
277
+ emit_fact(
278
+ "db.upgraded",
279
+ message="upgraded",
280
+ database=redacted_url(settings),
281
+ target=revision,
282
+ revision=migrations.current_revision(settings),
283
+ )
284
+
285
+
286
+ @db_app.command("current")
287
+ def db_current() -> None:
288
+ """Show the revision the database is stamped with."""
289
+ settings = get_settings()
290
+ current = migrations.current_revision(settings)
291
+ head = migrations.head_revision(settings)
292
+ if current is None:
293
+ refuse("the database has never been migrated", title="Not migrated", problems=["run dg db upgrade"])
294
+ raise typer.Exit(code=1)
295
+ emit_fact(
296
+ "db.revision",
297
+ message="at head" if current == head else "behind head",
298
+ revision=current,
299
+ head=head,
300
+ at_head=current == head,
301
+ )
302
+
303
+
304
+ @db_app.command("history")
305
+ def db_history() -> None:
306
+ """Show the migration history."""
307
+ emit_fact("db.history", message="migration history", history=migrations.history(get_settings()).rstrip())
308
+
309
+
310
+ @commands.connection_app.command("ensure")
311
+ def connection_ensure(
312
+ kind_id: Annotated[str, typer.Argument(metavar="KIND", help="The connection kind, such as s3.")],
313
+ code: Annotated[str, typer.Argument(help="What to call it; documents reference this code.")],
314
+ name: Annotated[str | None, typer.Option(help="A human title for this connection.")] = None,
315
+ set_value: Annotated[list[str] | None, typer.Option("--set", help="field=value, repeatable.")] = None,
316
+ description: Annotated[str | None, typer.Option(help="What this credential is for.")] = None,
317
+ ) -> None:
318
+ """Write a connection straight to the database, creating it or bringing it to this config.
319
+
320
+ Process-side the way ``dg db upgrade`` is: it reads the database URL and the instance key
321
+ rather than a token, so a one-shot container can put a credential in place before
322
+ anything else starts. The config is validated against its kind and its secret half sealed
323
+ with the instance key exactly as the API does it, so the row is indistinguishable from one
324
+ made through ``dg connection create``.
325
+
326
+ The row is brought to what the arguments say, whole: a field left out is cleared.
327
+ """
328
+ import asyncio
329
+
330
+ from dirigent_core.plugins import load_plugin_host
331
+ from dirigent_core.secrets import REDACTED, SecretBox, SecretError, redact, secret_fields
332
+
333
+ settings = get_settings()
334
+ kinds = load_plugin_host().connection_kinds
335
+ kind = kinds.get(kind_id)
336
+ if kind is None:
337
+ known = ", ".join(sorted(kinds)) or "none are installed"
338
+ commands.fail(f"no connection kind {kind_id!r} is installed ({known})")
339
+ model = kind.config_model
340
+ config = commands.parse_params(set_value, schema=model.model_json_schema())
341
+ try:
342
+ validated = model.model_validate(config)
343
+ except ValidationError as error:
344
+ # include_input=False: the input here is a credential, and pydantic's default error
345
+ # payload echoes the value that failed.
346
+ commands.fail(f"{code} is not a usable {kind_id} connection: {error.errors(include_input=False)}")
347
+ key = settings.secret_key.get_secret_value() if settings.secret_key else None
348
+ try:
349
+ public, envelope, key_id = SecretBox(key).encrypt_config(model, validated)
350
+ except SecretError as error:
351
+ commands.fail(str(error))
352
+ written = asyncio.run(
353
+ store_connection(
354
+ settings,
355
+ code=code,
356
+ kind=kind_id,
357
+ name=name,
358
+ description=description,
359
+ config=public,
360
+ envelope=envelope,
361
+ key_id=key_id,
362
+ )
363
+ )
364
+ # A sealed field is not on the row at all, so the marker is put back: the record says the
365
+ # credential is set without saying what it is.
366
+ visible = redact(model, dict(public))
367
+ for field in secret_fields(model):
368
+ visible.setdefault(field, REDACTED if envelope is not None else None)
369
+ emit_fact(
370
+ f"connection.{written}",
371
+ message=written,
372
+ code=code,
373
+ connection_kind=kind_id,
374
+ name=name,
375
+ description=description,
376
+ config=visible,
377
+ )
378
+
379
+
380
+ async def store_connection(
381
+ settings: Settings,
382
+ *,
383
+ code: str,
384
+ kind: str,
385
+ name: str | None,
386
+ description: str | None,
387
+ config: "JsonMap",
388
+ envelope: bytes | None,
389
+ key_id: str | None,
390
+ ) -> str:
391
+ """Insert or overwrite one connection row, and say which of the two happened."""
392
+ import sqlalchemy as sa
393
+
394
+ from dirigent_core.database import create_engine, create_session_factory, session_scope
395
+ from dirigent_core.models import Connection
396
+
397
+ engine = create_engine(settings)
398
+ try:
399
+ async with session_scope(create_session_factory(engine)) as session:
400
+ found = await session.execute(sa.select(Connection).where(Connection.code == code))
401
+ row = found.scalar_one_or_none()
402
+ written = "updated" if row is not None else "created"
403
+ if row is None:
404
+ row = Connection(code=code)
405
+ session.add(row)
406
+ row.kind = kind
407
+ row.name = name
408
+ row.description = description
409
+ row.config = config
410
+ row.secret_envelope = envelope
411
+ row.secret_key_id = key_id
412
+ return written
413
+ finally:
414
+ await engine.dispose()
415
+
416
+
417
+ @health_app.callback(invoke_without_command=True)
418
+ def health_here(ctx: typer.Context) -> None:
419
+ """Check the instance this shell resolves: database, workers, schedules, the server.
420
+
421
+ A part that is simply not there is reported and does not fail the command; naming one
422
+ is what asserts it should be running.
423
+ """
424
+ if ctx.invoked_subcommand is not None:
425
+ return
426
+ from dirigent_cli.health import every_check, named_server
427
+
428
+ state = state_of(ctx)
429
+ server = named_server(url=state.url, profile=state.profile)
430
+ _checked(every_check(get_settings(), server=server), asserted=False, summarise=True)
431
+
432
+
433
+ @health_app.command("worker")
434
+ def health_worker() -> None:
435
+ """Say whether a worker on this host is still heartbeating, and exit 0 or 1."""
436
+ from dirigent_cli.health import worker_health
437
+
438
+ _checked([worker_health(get_settings())], asserted=True)
439
+
440
+
441
+ @health_app.command("server")
442
+ def health_server(
443
+ ctx: typer.Context,
444
+ liveness: Annotated[
445
+ bool, typer.Option("--liveness", help="Ask whether the process answers at all, not whether it is ready.")
446
+ ] = False,
447
+ ) -> None:
448
+ """Ask the server -- the named one, or this host's own -- for its readiness, and exit 0 or 1."""
449
+ from dirigent_cli.health import named_server, server_check
450
+
451
+ state = state_of(ctx)
452
+ server = named_server(url=state.url, profile=state.profile)
453
+ check = server_check(get_settings(), server=server, probe="liveness" if liveness else "readiness")
454
+ _checked([check], asserted=True)
455
+
456
+
457
+ @health_app.command("scheduler")
458
+ def health_scheduler() -> None:
459
+ """Say whether the schedules that should have fired have fired, and exit 0 or 1."""
460
+ from dirigent_cli.health import scheduler_health
461
+
462
+ _checked([scheduler_health(get_settings())], asserted=True)
463
+
464
+
465
+ @health_app.command("database")
466
+ def health_database() -> None:
467
+ """Say whether the configured database answers, and exit 0 or 1."""
468
+ from dirigent_cli.health import database_health
469
+
470
+ _checked([database_health(get_settings())], asserted=True)
471
+
472
+
473
+ def _checked(checks: "Sequence[Check]", *, asserted: bool, summarise: bool = False) -> None:
474
+ """Write one record per check, then the verdict, and exit 1 if any of them failed."""
475
+ from dirigent_cli.output import output_mode
476
+ from dirigent_cli.stream import Sink
477
+
478
+ sink = Sink(output_mode())
479
+ failed = False
480
+ for check in checks:
481
+ failed = failed or check.failed(asserted=asserted)
482
+ sink.event(
483
+ "check",
484
+ level="error" if check.failed(asserted=asserted) else "info",
485
+ message=check.detail,
486
+ check=check.check,
487
+ status=check.status,
488
+ probe=check.probe,
489
+ )
490
+ if summarise:
491
+ from dirigent_cli.health import verdict
492
+
493
+ sink.event("health", **verdict(checks))
494
+ if failed:
495
+ raise typer.Exit(code=1)
496
+
497
+
498
+ def _level(ctx: typer.Context, settings: Settings) -> str:
499
+ """Choose the level a process entry point runs at: -v first, then its own settings."""
500
+ state = ctx.find_object(CliState)
501
+ return state.level if state is not None and state.verbose else settings.log_level
502
+
503
+
504
+ def _cap_foreign(ctx: typer.Context) -> bool:
505
+ """Report whether a raised verbosity should stay pointed at dirigent's own loggers.
506
+
507
+ A process at its configured level logs whatever it is configured to, uvicorn's access
508
+ lines included; a person who asked for more detail asked for dirigent's, and only
509
+ --debug-all asks for everybody's.
510
+ """
511
+ state = ctx.find_object(CliState)
512
+ if state is None or state.debug_all:
513
+ return False
514
+ return bool(state.verbose or state.debug)
515
+
516
+
517
+ @app.command(name="prune", rich_help_panel=ADMIN_PANEL)
518
+ def prune_command(
519
+ ctx: typer.Context,
520
+ runs: Annotated[str | None, typer.Option(help="Keep settled runs younger than this, such as 30d.")] = None,
521
+ logs: Annotated[str | None, typer.Option(help="Keep log entries younger than this.")] = None,
522
+ deliveries: Annotated[str | None, typer.Option(help="Keep webhook deliveries younger than this.")] = None,
523
+ firings: Annotated[str | None, typer.Option(help="Keep schedule firings younger than this.")] = None,
524
+ notifications: Annotated[str | None, typer.Option(help="Keep sent alerts younger than this.")] = None,
525
+ scratch: Annotated[
526
+ bool,
527
+ typer.Option("--scratch/--no-scratch", help="Delete a pruned run's artifacts from storage too."),
528
+ ] = True,
529
+ dry_run: Annotated[bool, typer.Option("--dry-run", help="Report what would go without deleting it.")] = False,
530
+ ) -> None:
531
+ """Delete what the retention ages have outlived, against this host's database.
532
+
533
+ An age given here beats the configured one, and a family with neither is not pruned:
534
+ a prune deletes what it was asked to delete and never guesses at an age.
535
+ """
536
+ import asyncio
537
+
538
+ settings = get_settings()
539
+ state = state_of(ctx)
540
+ try:
541
+ policy = _prune_policy(settings, runs, logs, deliveries, firings, notifications, scratch=scratch)
542
+ except ValueError as error:
543
+ refuse(str(error), status=2, title="Invalid age")
544
+ raise typer.Exit(code=2) from error
545
+ asyncio.run(_prune(settings, policy, state, dry_run=dry_run))
546
+
547
+
548
+ def _prune_policy(
549
+ settings: Settings,
550
+ runs: str | None,
551
+ logs: str | None,
552
+ deliveries: str | None,
553
+ firings: str | None,
554
+ notifications: str | None,
555
+ *,
556
+ scratch: bool,
557
+ ) -> "retention.Policy":
558
+ """Resolve each family's age: the flag, then the setting, then not pruned at all."""
559
+ from dirigent_common import parse_duration
560
+ from dirigent_core import retention
561
+
562
+ def age(given: str | None, configured: timedelta | None) -> timedelta | None:
563
+ return cast("timedelta", parse_duration(given)) if given is not None else configured
564
+
565
+ return retention.Policy(
566
+ runs=age(runs, settings.retention_runs),
567
+ logs=age(logs, settings.retention_logs),
568
+ deliveries=age(deliveries, settings.retention_deliveries),
569
+ firings=age(firings, settings.retention_firings),
570
+ notifications=age(notifications, settings.retention_notifications),
571
+ scratch=scratch,
572
+ )
573
+
574
+
575
+ async def _prune(settings: Settings, policy: "retention.Policy", state: CliState, *, dry_run: bool) -> None:
576
+ """Sweep directly against the configured database, and write what went."""
577
+ from dirigent_core import retention
578
+ from dirigent_core.database import create_engine, create_session_factory, session_scope
579
+ from dirigent_core.engine.services import EngineServices
580
+ from dirigent_core.plugins import load_plugin_host
581
+ from dirigent_core.scheduler import prune as prune_all
582
+
583
+ out = commands.sink(state)
584
+ if not retention.configured(policy):
585
+ refuse(
586
+ "no family has an age, so there is nothing to prune",
587
+ status=2,
588
+ title="Nothing configured",
589
+ problems=["give --runs, --logs, --deliveries, --firings or --notifications, or configure one"],
590
+ )
591
+ raise typer.Exit(code=2)
592
+ engine = create_engine(settings)
593
+ try:
594
+ sessions = create_session_factory(engine)
595
+ services = EngineServices.build(settings, load_plugin_host())
596
+ if dry_run:
597
+ async with session_scope(sessions) as session:
598
+ counts = await retention.counted(session, policy)
599
+ out.event("prune", message="would prune", dry_run=True, total=sum(counts.values()), **counts)
600
+ return
601
+ swept = await prune_all(sessions, services, policy)
602
+ out.event(
603
+ "prune",
604
+ message="pruned",
605
+ dry_run=False,
606
+ total=swept.total,
607
+ artifacts=swept.scratch_deleted,
608
+ unreachable=swept.scratch_failed or None,
609
+ **swept.counts,
610
+ )
611
+ finally:
612
+ await engine.dispose()
613
+
614
+
615
+ @app.command(rich_help_panel=PROCESS_PANEL)
616
+ def server(
617
+ ctx: typer.Context,
618
+ host: Annotated[str | None, typer.Option(help="Address to bind.")] = None,
619
+ port: Annotated[int | None, typer.Option(help="Port to bind.")] = None,
620
+ reload: Annotated[bool, typer.Option(help="Reload on source changes.")] = False,
621
+ scheduler: Annotated[
622
+ bool,
623
+ typer.Option("--scheduler/--no-scheduler", help="Embed the scheduler, or leave it to its own process."),
624
+ ] = True,
625
+ ui: Annotated[
626
+ bool | None,
627
+ typer.Option(
628
+ "--ui/--no-ui", help="Serve the web UI, or run as an API only; the ui_enabled setting decides when omitted."
629
+ ),
630
+ ] = None,
631
+ ) -> None:
632
+ """Run the API server, with the scheduler embedded unless it is isolated."""
633
+ import os
634
+
635
+ import uvicorn
636
+
637
+ if not scheduler:
638
+ # Must travel through the environment: uvicorn builds the app in a reloader
639
+ # subprocess, where an argument to this function would not survive.
640
+ os.environ[SCHEDULER_ENV] = "false"
641
+ reset_settings_cache()
642
+ if ui is not None:
643
+ # The same subprocess constraint as the scheduler flag.
644
+ os.environ[UI_ENV] = "true" if ui else "false"
645
+ reset_settings_cache()
646
+ settings = get_settings()
647
+ configure_logging(_level(ctx, settings), "json", cap_foreign=_cap_foreign(ctx), stream=sys.stdout)
648
+ if settings.is_sqlite and settings.scheduler_enabled:
649
+ refuse(
650
+ "dg server embeds the scheduler, and leadership is a PostgreSQL advisory lock: on SQLite "
651
+ "nothing stops a second server double-firing every schedule",
652
+ status=commands.GUARD_EXIT,
653
+ title="SQLite cannot elect a leader",
654
+ problems=[
655
+ "use dg dev for the standalone mode",
656
+ "or pass --no-scheduler",
657
+ "or point DIRIGENT_DATABASE_URL at PostgreSQL",
658
+ ],
659
+ )
660
+ raise typer.Exit(code=commands.GUARD_EXIT)
661
+ uvicorn.run(
662
+ "dirigent_cli.main:build_app",
663
+ factory=True,
664
+ host=host or settings.host,
665
+ port=port or settings.port,
666
+ reload=reload,
667
+ log_level=settings.log_level.lower(),
668
+ )
669
+
670
+
671
+ @docker_app.command("reap")
672
+ def docker_reap(
673
+ ctx: typer.Context,
674
+ dry_run: Annotated[
675
+ bool,
676
+ typer.Option("--dry-run", help="Say what would be taken down, and take nothing down."),
677
+ ] = False,
678
+ ) -> None:
679
+ """Take down compose stacks this host's daemon still holds for runs that have ended.
680
+
681
+ The same pass a worker runs on `docker_reap_interval`, on demand. It sees only the daemon
682
+ this host's environment names, which with one daemon per worker is that worker's own stacks.
683
+ """
684
+ import asyncio
685
+
686
+ from dirigent_cli import reaper
687
+ from dirigent_cli.output import output_mode
688
+ from dirigent_cli.stream import Sink
689
+
690
+ settings = get_settings()
691
+ configure_logging(_level(ctx, settings), "json", cap_foreign=_cap_foreign(ctx), stream=sys.stderr)
692
+ if not reaper.reachable():
693
+ refuse(
694
+ "docker is not on this host's PATH, so there is no daemon to reap stacks from",
695
+ status=commands.GUARD_EXIT,
696
+ title="No docker daemon",
697
+ )
698
+ raise typer.Exit(code=commands.GUARD_EXIT)
699
+ asyncio.run(_docker_reap(settings, Sink(output_mode()), dry_run=dry_run))
700
+
701
+
702
+ async def _docker_reap(settings: Settings, sink: "Sink", *, dry_run: bool) -> None:
703
+ """Run one reaping pass and write a record per project it acted on."""
704
+ from dirigent_cli import reaper
705
+ from dirigent_core.database import create_engine, create_session_factory
706
+
707
+ engine = create_engine(settings)
708
+ try:
709
+ reaped = await reaper.pass_once(settings, create_session_factory(engine), dry_run=dry_run)
710
+ finally:
711
+ await engine.dispose()
712
+ for one in reaped:
713
+ sink.event("docker_reaped", message=reaper.outcome(one, dry_run=dry_run), **reaper.record(one))
714
+
715
+
716
+ def build_app() -> "FastAPI":
717
+ """Build the ASGI application; uvicorn calls this as its factory."""
718
+ from dirigent_server import create_app
719
+
720
+ return create_app(get_settings())
721
+
722
+
723
+ def build_worker(
724
+ settings: Settings,
725
+ *,
726
+ concurrency: int | None = None,
727
+ tags: list[str] | None = None,
728
+ name: str | None = None,
729
+ ) -> tuple["Worker", "AsyncEngine"]:
730
+ """Assemble a worker from the installed plugins and the configured database."""
731
+ from dirigent_cli import reaper
732
+ from dirigent_core.database import create_engine, create_session_factory
733
+ from dirigent_core.engine import EngineServices
734
+ from dirigent_core.plugins import load_plugin_host
735
+ from dirigent_core.worker import Worker
736
+
737
+ services = EngineServices.build(settings, load_plugin_host())
738
+ engine = create_engine(settings)
739
+ sessions = create_session_factory(engine)
740
+ reaping = reaper.chore(settings, sessions)
741
+ worker = Worker(
742
+ sessions,
743
+ services,
744
+ name=name,
745
+ concurrency=concurrency,
746
+ tags=tags,
747
+ chores=[reaping] if reaping is not None else [],
748
+ )
749
+ return worker, engine
750
+
751
+
752
+ @commands.user_app.command("create")
753
+ def user_create(
754
+ username: Annotated[str, typer.Argument(help="The account to create.")],
755
+ role: Annotated[UserRole, typer.Option("--role", help="What the account may do.")],
756
+ password: Annotated[str | None, typer.Option(help="Its password; prompted for when omitted.")] = None,
757
+ email: Annotated[str | None, typer.Option(help="Its email address, which is unique across accounts.")] = None,
758
+ ) -> None:
759
+ """Create a local account against this host's database, which is the first-run path.
760
+
761
+ The role is named on every account; `DIRIGENT_BOOTSTRAP_ADMIN_PASSWORD` creates the
762
+ first admin unattended.
763
+ """
764
+ import asyncio
765
+
766
+ settings = get_settings()
767
+ secret = password or typer.prompt("password", hide_input=True, confirmation_prompt=True)
768
+ created = asyncio.run(_create_user(settings, username, secret, role, email))
769
+ emit_fact(
770
+ "user.created",
771
+ message="created",
772
+ username=created,
773
+ role=role.value,
774
+ database=redacted_url(settings),
775
+ hint="log in with dg auth login, or mint a token with dg admin token create",
776
+ )
777
+
778
+
779
+ async def _create_user(
780
+ settings: Settings, username: str, password: str, role: UserRole, email: str | None = None
781
+ ) -> str:
782
+ """Create one account directly against the configured database."""
783
+ from dirigent_core.auth import AuthError, create_user
784
+ from dirigent_core.database import create_engine, create_session_factory, session_scope
785
+
786
+ engine = create_engine(settings)
787
+ try:
788
+ async with session_scope(create_session_factory(engine)) as session:
789
+ user = await create_user(session, username, password, role=role, email=email)
790
+ return user.username
791
+ except AuthError as error:
792
+ refuse(str(error))
793
+ raise typer.Exit(code=1) from error
794
+ finally:
795
+ await engine.dispose()
796
+
797
+
798
+ @app.command(rich_help_panel=PROCESS_PANEL)
799
+ def dev(
800
+ ctx: typer.Context,
801
+ host: Annotated[str | None, typer.Option(help="Address to bind.")] = None,
802
+ port: Annotated[int | None, typer.Option(help="Port to bind.")] = None,
803
+ ui: Annotated[
804
+ bool | None,
805
+ typer.Option(
806
+ "--ui/--no-ui", help="Serve the web UI, or run as an API only; the ui_enabled setting decides when omitted."
807
+ ),
808
+ ] = None,
809
+ wipe_state: Annotated[
810
+ bool,
811
+ typer.Option(
812
+ "--wipe-state/--keep-state",
813
+ help="Delete the state directory before starting, or keep what the last run left.",
814
+ ),
815
+ ] = True,
816
+ ) -> None:
817
+ """Run the API and an embedded worker in one process, on SQLite, with no dependencies.
818
+
819
+ The database and the artifacts live in .dirigent/state under the working directory, so
820
+ starting this somewhere else means a different instance, with none of the same runs.
821
+
822
+ That directory is deleted on every start unless --keep-state says otherwise: SQLite here
823
+ is a development artifact, and a database left over from an older schema answers
824
+ strangely rather than failing. Only a directory dirigent named itself is removed.
825
+ """
826
+ import asyncio
827
+ import os
828
+
829
+ if ui is not None:
830
+ os.environ[UI_ENV] = "true" if ui else "false"
831
+ reset_settings_cache()
832
+ state = ctx.find_object(CliState)
833
+ settings = get_settings()
834
+ # A developer convenience starts its own logging at WARNING and -v is what asks for more,
835
+ # while a named DIRIGENT_LOG_LEVEL still wins.
836
+ configure_logging(
837
+ state.level if state is not None else "WARNING",
838
+ "json",
839
+ cap_foreign=state is None or not state.debug_all,
840
+ stream=sys.stdout,
841
+ )
842
+ if not settings.is_sqlite:
843
+ refuse(
844
+ "dg dev is the SQLite standalone mode",
845
+ status=commands.GUARD_EXIT,
846
+ title="Not SQLite",
847
+ problems=["use dg server on PostgreSQL"],
848
+ )
849
+ raise typer.Exit(code=commands.GUARD_EXIT)
850
+ if wipe_state:
851
+ cleared = clear_state(settings)
852
+ if cleared is not None:
853
+ emit(state_cleared(cleared))
854
+ migrated = _migrate_quietly(settings)
855
+ admin, token = asyncio.run(dev_admin(settings))
856
+ bound = f"http://{host or settings.host}:{port or settings.port}"
857
+ emit(dev_started(settings, bound=bound, admin=admin, token=token, migrated=migrated))
858
+ asyncio.run(_dev(settings, host or settings.host, port or settings.port))
859
+
860
+
861
+ def clear_state(settings: Settings) -> Path | None:
862
+ """Delete the state directory this instance owns, naming it, or report that none went.
863
+
864
+ Only a directory spelled ``.dirigent/state`` is removed. A database configuration pointed
865
+ somewhere else belongs to whoever pointed it there -- a test fixture and the UI's
866
+ end-to-end harness both keep their database beside files they still need -- so the wipe
867
+ is refused rather than guessed at.
868
+ """
869
+ database = settings.sqlite_path
870
+ if database is None:
871
+ return None
872
+ directory = database.parent.resolve()
873
+ if directory.parts[-2:] != Path(STATE_DIR).parts:
874
+ return None
875
+ if not directory.exists():
876
+ return None
877
+ shutil.rmtree(directory)
878
+ return directory
879
+
880
+
881
+ def state_cleared(directory: Path) -> Record:
882
+ """Build the record saying the state went, and how to have kept it.
883
+
884
+ Emitted only when something was actually deleted, so a first start on an empty machine
885
+ stays quiet.
886
+ """
887
+ return make(
888
+ "process",
889
+ at=datetime.now(UTC),
890
+ message="state cleared",
891
+ process="dev",
892
+ state=str(directory),
893
+ hint="--keep-state keeps it",
894
+ )
895
+
896
+
897
+ def _migrate_quietly(settings: Settings) -> str | None:
898
+ """Bring the schema up to date, and name the revision only when something moved."""
899
+ head = migrations.head_revision(settings)
900
+ if migrations.current_revision(settings) == head:
901
+ return None
902
+ migrations.upgrade("head", settings)
903
+ return head
904
+
905
+
906
+ def dev_started(
907
+ settings: Settings, *, bound: str, admin: str | None, token: str | None, migrated: str | None
908
+ ) -> Record:
909
+ """Build the record carrying what a person needs to start working against a dev instance.
910
+
911
+ The token is minted once and never shown again, so it is a field on this record rather
912
+ than something only a rendering ever held.
913
+ """
914
+ state = settings.sqlite_path
915
+ fields: dict[str, Any] = {"process": "dev", "api": bound, "docs": f"{bound}/docs", "admin": admin}
916
+ if state is not None:
917
+ fields["state"] = str(state.parent.resolve())
918
+ if token is not None:
919
+ fields["token"] = token
920
+ if migrated is not None:
921
+ fields["migrated"] = migrated
922
+ return make("process", at=datetime.now(UTC), message="starting", **fields)
923
+
924
+
925
+ def emit(record: Record) -> None:
926
+ """Write one record to the stream and flush it: a reader is waiting on this line.
927
+
928
+ A reader that has gone ends the stream rather than the process: the pipe is closed and
929
+ every later write goes to the void, while shutdown runs to completion.
930
+ """
931
+ try:
932
+ sys.stdout.write(f"{as_json(record)}\n")
933
+ sys.stdout.flush()
934
+ except BrokenPipeError:
935
+ silence_stdout()
936
+
937
+
938
+ async def dev_admin(settings: Settings) -> tuple[str | None, str | None]:
939
+ """Name the admin to log in as, and mint it a token where one is owed.
940
+
941
+ An empty instance gets the development admin made for it. One that already has accounts
942
+ -- ``dg init`` made it, or a person did -- keeps them: this names the admin that is
943
+ there rather than one that is not, and mints nothing, because that account's token was
944
+ handed over when it was created.
945
+ """
946
+ from dirigent_core.auth import create_user, find_user, issue_token, list_tokens, list_users
947
+ from dirigent_core.database import create_engine, create_session_factory, session_scope
948
+
949
+ engine = create_engine(settings)
950
+ try:
951
+ sessions = create_session_factory(engine)
952
+ async with session_scope(sessions) as session:
953
+ existing = await list_users(session)
954
+ if existing:
955
+ return (existing[0].username, None)
956
+ await create_user(session, DEV_ADMIN, DEV_PASSWORD, role=UserRole.ADMIN, name="Development admin")
957
+ user = await find_user(session, DEV_ADMIN)
958
+ if user is None: # pragma: no cover - it was just created
959
+ return (None, None)
960
+ if any(token.name == DEV_TOKEN_NAME and token.revoked_at is None for token in await list_tokens(session)):
961
+ return (DEV_ADMIN, None)
962
+ issued = await issue_token(session, user, name=DEV_TOKEN_NAME)
963
+ return (DEV_ADMIN, issued.secret.get_secret_value())
964
+ finally:
965
+ await engine.dispose()
966
+
967
+
968
+ async def _dev(settings: Settings, host: str, port: int) -> None:
969
+ """Run the API, the scheduler, and a worker as tasks in one event loop.
970
+
971
+ The scheduler is started by the application's own lifespan, not here.
972
+ """
973
+ import asyncio
974
+
975
+ import uvicorn
976
+
977
+ from dirigent_core.worker import install_signal_handlers
978
+
979
+ worker, engine = build_worker(settings, concurrency=None, tags=None, name=None)
980
+ install_signal_handlers(worker)
981
+ server_config = uvicorn.Config(build_app(), host=host, port=port, log_config=None)
982
+ api = uvicorn.Server(server_config)
983
+ worker_task = asyncio.create_task(worker.run())
984
+ ready = asyncio.create_task(_announce_ready(api))
985
+ try:
986
+ await api.serve()
987
+ finally:
988
+ ready.cancel()
989
+ worker.request_stop()
990
+ await worker_task
991
+ await engine.dispose()
992
+
993
+
994
+ async def _announce_ready(api: "uvicorn.Server") -> None:
995
+ """Say ready once the port is actually accepting, and not a moment before."""
996
+ import asyncio
997
+
998
+ while not api.started:
999
+ await asyncio.sleep(0.05)
1000
+ emit(make("process", at=datetime.now(UTC), message="ready", process="dev"))
1001
+
1002
+
1003
+ @app.command(rich_help_panel=PROCESS_PANEL)
1004
+ def worker(
1005
+ ctx: typer.Context,
1006
+ concurrency: Annotated[int | None, typer.Option(help="How many block calls run at a time.")] = None,
1007
+ tag: Annotated[list[str] | None, typer.Option(help="Capability tag this worker advertises.")] = None,
1008
+ name: Annotated[str | None, typer.Option(help="Registry name; defaults to hostname and pid.")] = None,
1009
+ ) -> None:
1010
+ """Run a worker: claim due work, execute it, probe what is waiting, and drain on SIGTERM."""
1011
+ import asyncio
1012
+
1013
+ settings = get_settings()
1014
+ configure_logging(_level(ctx, settings), "json", cap_foreign=_cap_foreign(ctx), stream=sys.stdout)
1015
+ configure_telemetry(settings)
1016
+ if settings.is_sqlite:
1017
+ refuse(
1018
+ "dg worker refuses to start on SQLite: its claim fallback is only correct with exactly one process",
1019
+ status=commands.GUARD_EXIT,
1020
+ title="SQLite claims only one process",
1021
+ problems=["use dg dev", "or point DIRIGENT_DATABASE_URL at PostgreSQL"],
1022
+ )
1023
+ raise typer.Exit(code=commands.GUARD_EXIT)
1024
+ asyncio.run(_worker(settings, concurrency, tag, name))
1025
+
1026
+
1027
+ async def _worker(settings: Settings, concurrency: int | None, tags: list[str] | None, name: str | None) -> None:
1028
+ """Run one worker until it is asked to drain."""
1029
+ from dirigent_core.worker import install_signal_handlers
1030
+
1031
+ worker, engine = build_worker(settings, concurrency=concurrency, tags=tags, name=name)
1032
+ install_signal_handlers(worker)
1033
+ try:
1034
+ await worker.run()
1035
+ finally:
1036
+ await engine.dispose()
1037
+
1038
+
1039
+ @app.command(name="scheduler", rich_help_panel=PROCESS_PANEL)
1040
+ def scheduler_command(ctx: typer.Context) -> None:
1041
+ """Run the scheduler on its own: take leadership, then turn clock time into runs.
1042
+
1043
+ Leadership is an advisory lock, so starting this beside a server that still embeds a
1044
+ scheduler is harmless: the second one stands by.
1045
+ """
1046
+ import asyncio
1047
+
1048
+ settings = get_settings()
1049
+ configure_logging(_level(ctx, settings), "json", cap_foreign=_cap_foreign(ctx), stream=sys.stdout)
1050
+ configure_telemetry(settings)
1051
+ if settings.is_sqlite:
1052
+ refuse(
1053
+ "dg scheduler refuses to start on SQLite: leadership is a PostgreSQL advisory lock, and "
1054
+ "without one nothing stops a second scheduler double-firing every schedule",
1055
+ status=commands.GUARD_EXIT,
1056
+ title="SQLite cannot elect a leader",
1057
+ problems=["use dg dev", "or point DIRIGENT_DATABASE_URL at PostgreSQL"],
1058
+ )
1059
+ raise typer.Exit(code=commands.GUARD_EXIT)
1060
+ emit(
1061
+ make(
1062
+ "process",
1063
+ at=datetime.now(UTC),
1064
+ message="waiting for leadership",
1065
+ process="scheduler",
1066
+ database=redacted_url(settings),
1067
+ )
1068
+ )
1069
+ asyncio.run(_scheduler(settings))
1070
+
1071
+
1072
+ async def _scheduler(settings: Settings) -> None:
1073
+ """Run one scheduler until it is asked to stop, releasing leadership on the way out."""
1074
+ from dirigent_core.database import create_engine, create_session_factory
1075
+ from dirigent_core.engine import EngineServices
1076
+ from dirigent_core.plugins import load_plugin_host
1077
+ from dirigent_core.scheduler import Scheduler, install_signal_handlers
1078
+
1079
+ services = EngineServices.build(settings, load_plugin_host())
1080
+ engine = create_engine(settings)
1081
+ clock = Scheduler(create_session_factory(engine), services)
1082
+ install_signal_handlers(clock)
1083
+ try:
1084
+ await clock.run()
1085
+ finally:
1086
+ await engine.dispose()
1087
+
1088
+
1089
+ #: Options declared on the group, which a person expects to work in any position.
1090
+ GLOBAL_TOKENS = frozenset({"-v", "-vv", "-vvv", "--verbose", "-d", "--debug", "--debug-all", "--json", "--timestamps"})
1091
+
1092
+ #: The options declared on ``dg`` that take a value. Hoisting one moves its value with it.
1093
+ GLOBAL_VALUE_OPTIONS = frozenset({"--url", "--token", "--profile", "-o", "--output"})
1094
+
1095
+ # Options that consume the next token; a verbosity-looking token after one of these is a
1096
+ # value, not a flag. A new value-taking option must be added here.
1097
+ VALUE_OPTIONS = frozenset(
1098
+ {
1099
+ "--url",
1100
+ "--token",
1101
+ "--profile",
1102
+ "-p",
1103
+ "--param",
1104
+ "-P",
1105
+ "--params-file",
1106
+ "-o",
1107
+ "--output",
1108
+ "-f",
1109
+ "--file",
1110
+ "--as",
1111
+ "--cron",
1112
+ "--interval",
1113
+ "--at",
1114
+ "--tz",
1115
+ "--step",
1116
+ "--template",
1117
+ "--connections",
1118
+ "--concurrency",
1119
+ "--tag",
1120
+ "--event",
1121
+ "--notifier",
1122
+ "--status",
1123
+ "--pipeline",
1124
+ "--since",
1125
+ "--enable-unsafe",
1126
+ }
1127
+ )
1128
+
1129
+
1130
+ def hoist_globals(argv: list[str]) -> list[str]:
1131
+ """Move the options declared on ``dg`` in front of the subcommand, values and all.
1132
+
1133
+ Click reads a group's options only before the subcommand, so without this ``dg run
1134
+ --local doc.yaml -o json`` is refused for an option the help says is global. One that
1135
+ takes a value carries the next token with it, unless it was written as ``--url=...``.
1136
+ """
1137
+ hoisted: list[str] = []
1138
+ rest: list[str] = []
1139
+ previous = ""
1140
+ wants_value = False
1141
+ for index, token in enumerate(argv):
1142
+ if token == "--":
1143
+ rest.extend(argv[index:])
1144
+ break
1145
+ if wants_value:
1146
+ hoisted.append(token)
1147
+ wants_value = False
1148
+ elif token.split("=", 1)[0] in GLOBAL_VALUE_OPTIONS and previous not in VALUE_OPTIONS:
1149
+ hoisted.append(token)
1150
+ wants_value = "=" not in token
1151
+ elif token in GLOBAL_TOKENS and previous not in VALUE_OPTIONS:
1152
+ hoisted.append(token)
1153
+ else:
1154
+ rest.append(token)
1155
+ previous = token
1156
+ return hoisted + rest
1157
+
1158
+
1159
+ # `serve` is what mkdocs and jekyll call it, and there is no reason to make anyone stop and
1160
+ # think; `server` stays the name, so the three process commands keep naming a role.
1161
+ add_alias(app, "server", "serve")
1162
+
1163
+ # Must stay last: a command registered after this walk does not get an alias.
1164
+ LIST_ALIASES = add_list_aliases(app)
1165
+
1166
+
1167
+ def run() -> None:
1168
+ """Entry point for the `dirigent` and `dg` console scripts."""
1169
+ app(args=hoist_globals(sys.argv[1:]))