dirigent-cli 0.9.5__tar.gz → 0.10.0__tar.gz

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.
Files changed (33) hide show
  1. {dirigent_cli-0.9.5 → dirigent_cli-0.10.0}/PKG-INFO +2 -1
  2. {dirigent_cli-0.9.5 → dirigent_cli-0.10.0}/pyproject.toml +2 -1
  3. {dirigent_cli-0.9.5 → dirigent_cli-0.10.0}/pyproject.toml.orig +2 -1
  4. {dirigent_cli-0.9.5 → dirigent_cli-0.10.0}/src/dirigent_cli/commands.py +72 -43
  5. dirigent_cli-0.10.0/src/dirigent_cli/init_form.py +253 -0
  6. {dirigent_cli-0.9.5 → dirigent_cli-0.10.0}/src/dirigent_cli/project.py +576 -87
  7. {dirigent_cli-0.9.5 → dirigent_cli-0.10.0}/LICENSE +0 -0
  8. {dirigent_cli-0.9.5 → dirigent_cli-0.10.0}/README.md +0 -0
  9. {dirigent_cli-0.9.5 → dirigent_cli-0.10.0}/src/dirigent_cli/__init__.py +0 -0
  10. {dirigent_cli-0.9.5 → dirigent_cli-0.10.0}/src/dirigent_cli/aliases.py +0 -0
  11. {dirigent_cli-0.9.5 → dirigent_cli-0.10.0}/src/dirigent_cli/context.py +0 -0
  12. {dirigent_cli-0.9.5 → dirigent_cli-0.10.0}/src/dirigent_cli/formatters.py +0 -0
  13. {dirigent_cli-0.9.5 → dirigent_cli-0.10.0}/src/dirigent_cli/graph.py +0 -0
  14. {dirigent_cli-0.9.5 → dirigent_cli-0.10.0}/src/dirigent_cli/health.py +0 -0
  15. {dirigent_cli-0.9.5 → dirigent_cli-0.10.0}/src/dirigent_cli/local.py +0 -0
  16. {dirigent_cli-0.9.5 → dirigent_cli-0.10.0}/src/dirigent_cli/main.py +0 -0
  17. {dirigent_cli-0.9.5 → dirigent_cli-0.10.0}/src/dirigent_cli/output.py +0 -0
  18. {dirigent_cli-0.9.5 → dirigent_cli-0.10.0}/src/dirigent_cli/params.py +0 -0
  19. {dirigent_cli-0.9.5 → dirigent_cli-0.10.0}/src/dirigent_cli/profiles.py +0 -0
  20. {dirigent_cli-0.9.5 → dirigent_cli-0.10.0}/src/dirigent_cli/py.typed +0 -0
  21. {dirigent_cli-0.9.5 → dirigent_cli-0.10.0}/src/dirigent_cli/reaper.py +0 -0
  22. {dirigent_cli-0.9.5 → dirigent_cli-0.10.0}/src/dirigent_cli/scaffold.py +0 -0
  23. {dirigent_cli-0.9.5 → dirigent_cli-0.10.0}/src/dirigent_cli/schemas.py +0 -0
  24. {dirigent_cli-0.9.5 → dirigent_cli-0.10.0}/src/dirigent_cli/sources.py +0 -0
  25. {dirigent_cli-0.9.5 → dirigent_cli-0.10.0}/src/dirigent_cli/stream.py +0 -0
  26. {dirigent_cli-0.9.5 → dirigent_cli-0.10.0}/src/dirigent_cli/summaries.py +0 -0
  27. {dirigent_cli-0.9.5 → dirigent_cli-0.10.0}/src/dirigent_cli/templates/pack/README.md.tmpl +0 -0
  28. {dirigent_cli-0.9.5 → dirigent_cli-0.10.0}/src/dirigent_cli/templates/pack/__init__.py.tmpl +0 -0
  29. {dirigent_cli-0.9.5 → dirigent_cli-0.10.0}/src/dirigent_cli/templates/pack/operator.py.tmpl +0 -0
  30. {dirigent_cli-0.9.5 → dirigent_cli-0.10.0}/src/dirigent_cli/templates/pack/pyproject.toml.tmpl +0 -0
  31. {dirigent_cli-0.9.5 → dirigent_cli-0.10.0}/src/dirigent_cli/templates/pack/test_plugin.py.tmpl +0 -0
  32. {dirigent_cli-0.9.5 → dirigent_cli-0.10.0}/src/dirigent_cli/timing.py +0 -0
  33. {dirigent_cli-0.9.5 → dirigent_cli-0.10.0}/src/dirigent_cli/triggers.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dirigent-cli
3
- Version: 0.9.5
3
+ Version: 0.10.0
4
4
  Summary: The dirigent command line interface (dirigent / dg).
5
5
  License-Expression: LicenseRef-Proprietary
6
6
  License-File: LICENSE
@@ -14,6 +14,7 @@ Requires-Dist: httpx2>=2.12.0
14
14
  Requires-Dist: python-dotenv>=1.1.0
15
15
  Requires-Dist: pyyaml>=6.0.3
16
16
  Requires-Dist: rich>=15.0.0
17
+ Requires-Dist: textual>=8.0
17
18
  Requires-Dist: tomli-w>=1.2.0
18
19
  Requires-Dist: typer>=0.27.2
19
20
  Requires-Dist: uvicorn>=0.52.4
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "dirigent-cli"
3
- version = "0.9.5"
3
+ version = "0.10.0"
4
4
  description = "The dirigent command line interface (dirigent / dg)."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.13"
@@ -17,6 +17,7 @@ dependencies = [
17
17
  "python-dotenv>=1.1.0",
18
18
  "pyyaml>=6.0.3",
19
19
  "rich>=15.0.0",
20
+ "textual>=8.0",
20
21
  "tomli-w>=1.2.0",
21
22
  "typer>=0.27.2",
22
23
  "uvicorn>=0.52.4",
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "dirigent-cli"
3
- version = "0.9.5"
3
+ version = "0.10.0"
4
4
  description = "The dirigent command line interface (dirigent / dg)."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.13"
@@ -17,6 +17,7 @@ dependencies = [
17
17
  "python-dotenv>=1.1.0",
18
18
  "pyyaml>=6.0.3",
19
19
  "rich>=15.0.0",
20
+ "textual>=8.0",
20
21
  "tomli-w>=1.2.0",
21
22
  "typer>=0.27.2",
22
23
  "uvicorn>=0.52.4",
@@ -57,9 +57,9 @@ from dirigent_cli.output import (
57
57
  )
58
58
  from dirigent_cli.params import ParamError, build_params
59
59
  from dirigent_cli.project import (
60
- COMPOSE_TEMPLATE_NAME,
60
+ InitChoices,
61
61
  ProjectError,
62
- check_template,
62
+ check_choices,
63
63
  find_project,
64
64
  scaffold,
65
65
  write_token_env,
@@ -479,22 +479,32 @@ def _trigger_targets(dg: Session, wanted: set[str]) -> dict[str, TriggerTarget]:
479
479
  def init_command(
480
480
  ctx: typer.Context,
481
481
  directory: Annotated[Path, typer.Argument(help="Where to create the instance and project.")] = Path(),
482
- template: Annotated[str, typer.Option(help="Which template to scaffold: basic, ci or compose.")] = "basic",
483
- documents_only: Annotated[
484
- bool,
485
- typer.Option("--documents-only", help="Scaffold the documents and initialise no instance."),
482
+ template: Annotated[
483
+ str | None,
484
+ typer.Option(help="Where it runs: local (dg dev on SQLite), compose (a container stack) or documents."),
485
+ ] = None,
486
+ service: Annotated[
487
+ list[str] | None,
488
+ typer.Option("--service", help="A service the stack carries: s3, docker, kafka or rabbitmq. Repeatable."),
489
+ ] = None,
490
+ workflow: Annotated[
491
+ bool, typer.Option("--workflow", help="Write a GitHub workflow that applies on merge.")
486
492
  ] = False,
493
+ pack: Annotated[
494
+ list[str] | None, typer.Option("--pack", help="A pack to add, such as dirigent-dhis2. Repeatable.")
495
+ ] = None,
487
496
  admin: Annotated[str, typer.Option(help="The first admin account's username.")] = "admin",
488
- password: Annotated[str | None, typer.Option(help="Its password; prompted for when omitted.")] = None,
497
+ password: Annotated[str | None, typer.Option(help="Its password; asked for when omitted.")] = None,
489
498
  ) -> None:
490
499
  """Initialise a uv project and the instance it addresses, ready for `dg dev`.
491
500
 
492
- Writes the documents and a `pyproject.toml` pinning the running dirigent, so `uv sync`
493
- builds the project's environment and `uv run dg` is the runtime it was scaffolded on.
494
- Then creates the state directory, migrates the schema, creates the first admin and mints
495
- it a token. `--documents-only` stops after the documents. `--template compose` writes the
496
- documents and a container stack instead, and initialises nothing locally: the instance is
497
- the containers.
501
+ At a terminal, with no --template, one form asks everything: where the project runs, the
502
+ stack's services, packs, and the first admin. The flags answer the same questions for a
503
+ script. `--template local` writes the documents and a `pyproject.toml` pinning the running
504
+ dirigent, then creates the state directory, migrates the schema, creates the first admin
505
+ and mints it a token, kept in `.env`. `--template compose` writes the documents and a
506
+ container stack instead, and initialises nothing locally: the instance is the containers.
507
+ `--template documents` writes the documents alone, against an instance somebody else runs.
498
508
  """
499
509
  import asyncio
500
510
 
@@ -505,34 +515,55 @@ def init_command(
505
515
  if not state.chosen:
506
516
  configure(output="console")
507
517
  root = directory.resolve()
508
- # Refusing and asking both happen before anything is written, so a run that cannot
509
- # finish has not half-created a project, and nobody is asked for a password to satisfy
510
- # a command that was going to fail anyway.
511
- try:
512
- check_template(template)
513
- except ProjectError as error:
514
- _init_fail(str(error))
515
- stack = template == COMPOSE_TEMPLATE_NAME
516
- if stack and documents_only:
517
- _init_fail("--documents-only does not apply to the compose template, which writes no instance to skip")
518
- if stack and admin != "admin":
519
- _init_fail("--admin does not apply to the compose template; the stack's first admin is named admin")
520
- if not documents_only and not stack:
518
+ version = cli_version()
519
+ unasked = template is None and service is None and pack is None and not workflow
520
+ asked = unasked and sys.stdin.isatty() and sys.stdout.isatty()
521
+ # Refusing happens before anything is written or asked for, so a run that cannot finish
522
+ # has not half-created a project, and nobody fills a form for a command that was going
523
+ # to fail anyway.
524
+ if template in (None, "local"):
521
525
  _refuse_an_existing_instance(root)
522
- secret = (
523
- ""
524
- if documents_only
525
- else (password or os.environ.get(BOOTSTRAP_PASSWORD_ENV) or _prompt_for_a_password(stack=stack))
526
- )
527
- if not documents_only and len(secret) < MIN_PASSWORD_LENGTH:
526
+ if asked:
527
+ from dirigent_cli.init_form import run_form
528
+
529
+ chosen = run_form(
530
+ root, version=version, admin=admin, password=password or os.environ.get(BOOTSTRAP_PASSWORD_ENV, "")
531
+ )
532
+ if chosen is None:
533
+ _init_fail("cancelled; nothing was written")
534
+ choices = chosen
535
+ else:
536
+ choices = InitChoices(
537
+ template=template or "local",
538
+ services=tuple(service) if service is not None else InitChoices().services,
539
+ workflow=workflow,
540
+ packs=tuple(pack or ()),
541
+ admin=admin,
542
+ password=password or os.environ.get(BOOTSTRAP_PASSWORD_ENV) or "",
543
+ )
544
+ try:
545
+ check_choices(choices)
546
+ except ProjectError as error:
547
+ _init_fail(str(error))
548
+ if choices.stack and admin != "admin":
549
+ _init_fail("--admin does not apply to the compose template; the stack's first admin is named admin")
550
+ if choices.template != "documents" and not choices.password:
551
+ choices = choices.model_copy(update={"password": _prompt_for_a_password(stack=choices.stack)})
552
+ if choices.template != "documents" and len(choices.password) < MIN_PASSWORD_LENGTH:
528
553
  _init_fail(str(WeakPassword()))
529
- version = cli_version()
530
554
  try:
531
- made = scaffold(directory, template=template, version=version, password=secret)
555
+ made = scaffold(directory, choices, version=version)
532
556
  except ProjectError as error:
533
557
  _init_fail(str(error))
534
558
  left = [_within(path, directory) for path in made.skipped]
535
- if documents_only or stack:
559
+ described = {
560
+ "template": choices.template,
561
+ "version": version,
562
+ **({"services": list(choices.services)} if choices.stack else {}),
563
+ **({"workflow": True} if choices.workflow else {}),
564
+ **({"packs": list(choices.packs)} if choices.packs else {}),
565
+ }
566
+ if not choices.instance:
536
567
  starting = [
537
568
  "uv sync",
538
569
  "docker compose up -d",
@@ -541,30 +572,28 @@ def init_command(
541
572
  emit_fact(
542
573
  "project.scaffolded",
543
574
  message="scaffolded",
544
- template=template,
545
575
  directory=str(directory),
546
- version=version,
547
576
  files=[_within(path, directory) for path in made.files],
577
+ **described,
548
578
  **({"skipped": left} if left else {}),
549
- **({"next": starting} if stack else {}),
579
+ **({"next": starting} if choices.stack else {}),
550
580
  )
551
581
  return
552
582
  settings = instance_settings(root)
553
583
  migrated = migrations.head_revision(settings) or "none"
554
584
  migrations.upgrade("head", settings)
555
- token = asyncio.run(first_admin(settings, admin, secret))
585
+ token = asyncio.run(first_admin(settings, choices.admin, choices.password))
556
586
  env_file = write_token_env(root, token)
557
587
  emit_fact(
558
588
  "instance.initialised",
559
589
  message="initialised",
560
- template=template,
561
590
  directory=str(root),
562
591
  state=STATE_DIR,
563
592
  schema=migrated,
564
- admin=admin,
593
+ admin=choices.admin,
565
594
  token=token,
566
- version=version,
567
595
  files=[_within(path, root) for path in [*made.files, env_file]],
596
+ **described,
568
597
  **({"skipped": [_within(path, root) for path in made.skipped]} if made.skipped else {}),
569
598
  )
570
599
 
@@ -615,7 +644,7 @@ def _refuse_an_existing_instance(root: Path) -> None:
615
644
  ways=[
616
645
  ("dg dev", "starts it"),
617
646
  ("dg db upgrade", "brings its schema forward"),
618
- ("dg init --documents-only", "scaffolds documents beside it"),
647
+ ("dg init --template documents", "scaffolds documents beside it"),
619
648
  ],
620
649
  )
621
650
 
@@ -0,0 +1,253 @@
1
+ """The ``dg init`` form: one Textual screen instead of a run of flags and prompts.
2
+
3
+ Everything is visible at once and revised before anything is written: where the project
4
+ runs, which services the stack carries, which packs come along, and the first admin. The
5
+ form returns the choices and the caller scaffolds, so quitting leaves no half-made project.
6
+ Nothing here touches the disk.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from pathlib import Path
12
+ from typing import cast
13
+
14
+ from textual.app import App, ComposeResult
15
+ from textual.binding import Binding
16
+ from textual.containers import Horizontal, Vertical, VerticalScroll
17
+ from textual.theme import Theme
18
+ from textual.widgets import Button, Checkbox, Input, Label, RadioButton, RadioSet, SelectionList
19
+
20
+ from dirigent_cli.project import (
21
+ DEFAULT_SERVICES,
22
+ PACKS,
23
+ SERVICE_EXAMPLES,
24
+ SERVICES,
25
+ InitChoices,
26
+ )
27
+
28
+ MIN_PASSWORD_LENGTH = 8
29
+
30
+ #: The web UI's dark palette, so the form is the same product as the screen behind the door:
31
+ #: the amber accent on the near-black ground, with the same surfaces, inks and status colours.
32
+ DIRIGENT_THEME = Theme(
33
+ name="dirigent",
34
+ primary="#e9b452",
35
+ secondary="#00a7e2",
36
+ accent="#e9b452",
37
+ foreground="#eaeff5",
38
+ background="#070a0f",
39
+ surface="#10151c",
40
+ panel="#191f26",
41
+ success="#5cb85c",
42
+ warning="#f9b64f",
43
+ error="#ed5350",
44
+ dark=True,
45
+ variables={
46
+ "input-cursor-background": "#e9b452",
47
+ "input-cursor-foreground": "#0a0e12",
48
+ "input-selection-background": "#e9b45240",
49
+ "block-cursor-background": "#e9b452",
50
+ "block-cursor-foreground": "#0a0e12",
51
+ "border": "#323941",
52
+ "border-blurred": "#191f26",
53
+ "footer-background": "#10151c",
54
+ "button-color-foreground": "#0a0e12",
55
+ },
56
+ )
57
+
58
+ #: The three places a project runs, in the order the form lists them.
59
+ KINDS = (
60
+ ("local", "Local: one process on SQLite", "dg dev; one person, one machine"),
61
+ ("compose", "A container stack", "compose: PostgreSQL, workers, the published image"),
62
+ ("documents", "Documents only", "against an instance somebody else runs"),
63
+ )
64
+
65
+
66
+ class InitForm(App[InitChoices | None]):
67
+ """The scaffolding form: kind, services, packs, workflow, admin, on one screen."""
68
+
69
+ TITLE = "dg init"
70
+
71
+ CSS = """
72
+ Screen { padding: 1 2; }
73
+ #body { height: 1fr; }
74
+ Label { width: 1fr; }
75
+ .section { color: $text-muted; margin-top: 1; }
76
+ .hint { color: $text-muted; }
77
+ SelectionList > .selection-list--button { color: $surface; background: $surface; }
78
+ SelectionList > .selection-list--button-selected { color: $success; background: $surface; }
79
+ SelectionList > .selection-list--button-highlighted { color: $surface; background: $surface; }
80
+ SelectionList > .selection-list--button-selected-highlighted { color: $success; background: $surface; }
81
+ Checkbox > .toggle--button { color: $surface; background: $surface; }
82
+ Checkbox.-on > .toggle--button { color: $success; background: $surface; }
83
+ #kind { height: auto; }
84
+ #services, #packs { height: auto; border: round $border; }
85
+ #admin-block { height: auto; }
86
+ #admin-row { height: auto; }
87
+ #admin-row Input { width: 1fr; margin-right: 2; }
88
+ #problem { color: $error; height: auto; }
89
+ #files { color: $text-muted; height: auto; margin-top: 1; }
90
+ #actions { height: auto; margin-top: 1; }
91
+ #actions Button { margin-right: 2; }
92
+ """
93
+
94
+ BINDINGS = [
95
+ Binding("escape", "cancel", "Cancel", show=True),
96
+ Binding("ctrl+s", "create", "Create", show=True, priority=True),
97
+ ]
98
+
99
+ def __init__(self, directory: Path, *, version: str, admin: str = "admin", password: str = "") -> None:
100
+ """Open on a directory, the runtime it will pin, and whatever the flags already said."""
101
+ super().__init__()
102
+ self.register_theme(DIRIGENT_THEME)
103
+ self.theme = "dirigent"
104
+ self._directory = directory
105
+ self._version = version
106
+ self._admin = admin
107
+ self._password = password
108
+
109
+ def compose(self) -> ComposeResult:
110
+ """Lay the whole form out on one screen."""
111
+ yield Label(f"New dirigent project: {self._directory.name}")
112
+ yield Label(
113
+ f"The documents, the runtime it pins ({self._version}) and its instance, all in this directory.",
114
+ classes="hint",
115
+ )
116
+ with VerticalScroll(id="body"):
117
+ yield Label("Where it runs", classes="section")
118
+ with RadioSet(id="kind"):
119
+ for index, (code, title, what) in enumerate(KINDS):
120
+ yield RadioButton(f"{title} [dim]{what}[/]", value=index == 0, id=f"kind-{code}")
121
+
122
+ yield Label("Services in the stack (space toggles)", classes="section", id="services-label")
123
+ yield SelectionList[str](
124
+ *(
125
+ (f"{service.title} [dim]{service.what}[/]", service.code, service.code in DEFAULT_SERVICES)
126
+ for service in SERVICES
127
+ ),
128
+ id="services",
129
+ )
130
+
131
+ yield Label("Packs (space toggles; pinned at this version)", classes="section")
132
+ yield SelectionList[str](*((f"{pack.name} [dim]{pack.what}[/]", pack.name) for pack in PACKS), id="packs")
133
+
134
+ yield Checkbox("A GitHub workflow that applies the project on merge", id="workflow")
135
+
136
+ with Vertical(id="admin-block"):
137
+ yield Label("First admin", classes="section")
138
+ with Horizontal(id="admin-row"):
139
+ yield Input(value=self._admin, placeholder="username", id="admin")
140
+ yield Input(value=self._password, placeholder="password", password=True, id="password")
141
+ yield Input(value=self._password, placeholder="confirm", password=True, id="confirm")
142
+ yield Label(f"At least {MIN_PASSWORD_LENGTH} characters, typed twice.", classes="hint")
143
+
144
+ yield Label("", id="problem")
145
+ yield Label("", id="files")
146
+ with Horizontal(id="actions"):
147
+ yield Button("Create project", variant="primary", id="create")
148
+ yield Button("Cancel", id="cancel")
149
+
150
+ def on_mount(self) -> None:
151
+ """Open on the kind, and say what the defaults will write."""
152
+ self.query_one("#kind", RadioSet).focus()
153
+ self._follow_kind()
154
+
155
+ @property
156
+ def kind(self) -> str:
157
+ """The place the project runs, as the radio set has it."""
158
+ pressed = self.query_one("#kind", RadioSet).pressed_button
159
+ return pressed.id.removeprefix("kind-") if pressed is not None and pressed.id else "local"
160
+
161
+ def _follow_kind(self) -> None:
162
+ """Show the services for the stack alone, and the admin for the two kinds that make one."""
163
+ kind = self.kind
164
+ stack = kind == "compose"
165
+ self.query_one("#services-label").display = stack
166
+ self.query_one("#services").display = stack
167
+ self.query_one("#admin-block").display = kind != "documents"
168
+ self._refresh_files()
169
+
170
+ def on_radio_set_changed(self, _event: RadioSet.Changed) -> None:
171
+ """A kind decides which sections apply."""
172
+ self._follow_kind()
173
+
174
+ def on_selection_list_selected_changed(self, _event: SelectionList.SelectedChanged[str]) -> None:
175
+ """A toggle changes what is written."""
176
+ self._refresh_files()
177
+
178
+ def on_checkbox_changed(self, _event: Checkbox.Changed) -> None:
179
+ """The workflow box changes what is written."""
180
+ self._refresh_files()
181
+
182
+ def _collect(self) -> InitChoices:
183
+ """Read the form into choices, as they stand."""
184
+ # query_one's type argument is a runtime isinstance check, so it cannot take a
185
+ # subscripted generic: the widgets are fetched untyped and narrowed here.
186
+ services = cast("SelectionList[str]", self.query_one("#services"))
187
+ packs = cast("SelectionList[str]", self.query_one("#packs"))
188
+ picked_services: list[str] = list(services.selected)
189
+ picked_packs: list[str] = list(packs.selected)
190
+ kind = self.kind
191
+ chosen_services = tuple(code for code in (s.code for s in SERVICES) if code in picked_services)
192
+ return InitChoices(
193
+ template=kind,
194
+ services=chosen_services if kind == "compose" else DEFAULT_SERVICES,
195
+ workflow=self.query_one("#workflow", Checkbox).value,
196
+ packs=tuple(name for name in (p.name for p in PACKS) if name in picked_packs),
197
+ admin=self.query_one("#admin", Input).value.strip() or "admin",
198
+ password=self.query_one("#password", Input).value,
199
+ )
200
+
201
+ def _refresh_files(self) -> None:
202
+ """Say what Create will write, so the file list after the fact is not the first sight of it."""
203
+ choices = self._collect()
204
+ files = [
205
+ "dirigent.yaml",
206
+ "pipelines/hello-world.yaml",
207
+ ".dirigent/profiles.yaml",
208
+ "pyproject.toml",
209
+ "README.md",
210
+ ]
211
+ if choices.stack:
212
+ files += [f"pipelines/{SERVICE_EXAMPLES[code][0]}" for code in choices.services]
213
+ files += ["compose.yaml", "Dockerfile", ".env"]
214
+ elif choices.instance:
215
+ files += [".env", ".dirigent/state/"]
216
+ if choices.workflow:
217
+ files.append(".github/workflows/dirigent.yml")
218
+ self.query_one("#files", Label).update("Will write: " + ", ".join(files))
219
+
220
+ def on_button_pressed(self, event: Button.Pressed) -> None:
221
+ """The two buttons are the two bindings."""
222
+ if event.button.id == "create":
223
+ self.action_create()
224
+ else:
225
+ self.action_cancel()
226
+
227
+ def action_cancel(self) -> None:
228
+ """Leave with nothing, so nothing is written."""
229
+ self.exit(None)
230
+
231
+ def action_create(self) -> None:
232
+ """Refuse an admin the instance could not be made with, else hand the choices back."""
233
+ choices = self._collect()
234
+ problem = self.query_one("#problem", Label)
235
+ if choices.template != "documents":
236
+ confirm = self.query_one("#confirm", Input).value
237
+ if choices.stack and choices.admin != "admin":
238
+ problem.update("The stack's first admin is named admin.")
239
+ return
240
+ if len(choices.password) < MIN_PASSWORD_LENGTH:
241
+ problem.update(f"A password must be at least {MIN_PASSWORD_LENGTH} characters.")
242
+ self.query_one("#password", Input).focus()
243
+ return
244
+ if confirm != choices.password:
245
+ problem.update("The two passwords differ.")
246
+ self.query_one("#confirm", Input).focus()
247
+ return
248
+ self.exit(choices)
249
+
250
+
251
+ def run_form(directory: Path, *, version: str, admin: str = "admin", password: str = "") -> InitChoices | None:
252
+ """Run the form, returning the choices, or None when the person quit."""
253
+ return InitForm(directory, version=version, admin=admin, password=password).run()