ecadlib 0.3.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.
Files changed (199) hide show
  1. ecadlib/__init__.py +3 -0
  2. ecadlib/__main__.py +5 -0
  3. ecadlib/a365_sync.py +637 -0
  4. ecadlib/agent.py +1407 -0
  5. ecadlib/agent_queue.py +780 -0
  6. ecadlib/agent_watch.py +732 -0
  7. ecadlib/altium365.py +385 -0
  8. ecadlib/altium_dblib_sync.py +530 -0
  9. ecadlib/altium_export.py +209 -0
  10. ecadlib/altium_forms.py +768 -0
  11. ecadlib/altium_lib.py +1089 -0
  12. ecadlib/altium_menu.py +1751 -0
  13. ecadlib/altium_pkg.py +396 -0
  14. ecadlib/altium_project.py +1095 -0
  15. ecadlib/app.py +1458 -0
  16. ecadlib/approvals.py +395 -0
  17. ecadlib/assets/demo_bom.csv +8 -0
  18. ecadlib/assets/kicad_action_plugin.py +607 -0
  19. ecadlib/assets/kicad_reread.py +60 -0
  20. ecadlib/assets/pcblib_template.json.gz +0 -0
  21. ecadlib/bom_inputs.py +339 -0
  22. ecadlib/bom_triage.py +519 -0
  23. ecadlib/cad_model.py +528 -0
  24. ecadlib/cfb.py +171 -0
  25. ecadlib/cli.py +185 -0
  26. ecadlib/clihelp.py +149 -0
  27. ecadlib/cliutil.py +119 -0
  28. ecadlib/completion.py +173 -0
  29. ecadlib/costs.py +276 -0
  30. ecadlib/crosscheck.py +412 -0
  31. ecadlib/datasheet.py +2168 -0
  32. ecadlib/dedupe.py +572 -0
  33. ecadlib/detect.py +170 -0
  34. ecadlib/doctor.py +450 -0
  35. ecadlib/ds_route.py +541 -0
  36. ecadlib/eagle_export.py +1409 -0
  37. ecadlib/easy_setup.py +338 -0
  38. ecadlib/easyeda_convert.py +386 -0
  39. ecadlib/easyeda_pro_export.py +972 -0
  40. ecadlib/eval/__init__.py +0 -0
  41. ecadlib/eval/baselines/kicad-v1.json +404 -0
  42. ecadlib/eval/build_set.py +299 -0
  43. ecadlib/eval/cli.py +119 -0
  44. ecadlib/eval/gate.py +358 -0
  45. ecadlib/eval/golden/demo.txt +379 -0
  46. ecadlib/eval/golden/specs.txt +3120 -0
  47. ecadlib/eval/golden/testpack.txt +463 -0
  48. ecadlib/eval/golden.py +446 -0
  49. ecadlib/eval/replay/kicad-v1.json.gz +0 -0
  50. ecadlib/eval/runner.py +456 -0
  51. ecadlib/eval/score.py +179 -0
  52. ecadlib/eval/sets/kicad-v1.json +630 -0
  53. ecadlib/firstrun.py +400 -0
  54. ecadlib/footprint_adv.py +1006 -0
  55. ecadlib/footprint_bench.py +218 -0
  56. ecadlib/footprint_check.py +562 -0
  57. ecadlib/footprint_gen.py +582 -0
  58. ecadlib/footprint_rules.py +1092 -0
  59. ecadlib/forge.py +267 -0
  60. ecadlib/fp_share.py +95 -0
  61. ecadlib/fp_tidy.py +389 -0
  62. ecadlib/fsutil.py +62 -0
  63. ecadlib/house_style.py +1051 -0
  64. ecadlib/httpcache.py +124 -0
  65. ecadlib/hub.py +1490 -0
  66. ecadlib/hub_copy.py +161 -0
  67. ecadlib/hub_tools.py +79 -0
  68. ecadlib/kicad_dblib.py +257 -0
  69. ecadlib/kicad_export.py +837 -0
  70. ecadlib/kicad_import.py +913 -0
  71. ecadlib/kicad_index.py +160 -0
  72. ecadlib/kicad_menu.py +193 -0
  73. ecadlib/kicad_pcm.py +475 -0
  74. ecadlib/launcher.py +156 -0
  75. ecadlib/librarian.py +963 -0
  76. ecadlib/lifecycle.py +691 -0
  77. ecadlib/llm/__init__.py +276 -0
  78. ecadlib/llm/cli.py +70 -0
  79. ecadlib/llm/openai_compat.py +214 -0
  80. ecadlib/llm/profiles.py +245 -0
  81. ecadlib/llm/rails.py +570 -0
  82. ecadlib/llm/run.py +126 -0
  83. ecadlib/llm/sampling.py +49 -0
  84. ecadlib/mcp_server.py +723 -0
  85. ecadlib/messages.py +419 -0
  86. ecadlib/migrate.py +407 -0
  87. ecadlib/notify.py +329 -0
  88. ecadlib/package_schema.json +1238 -0
  89. ecadlib/package_specs/CP_EIA-3216-18_Kemet-A.json +27 -0
  90. ecadlib/package_specs/C_0402_1005Metric.json +22 -0
  91. ecadlib/package_specs/C_0805_2012Metric.json +22 -0
  92. ecadlib/package_specs/DFN-8-1EP_3x3mm_P0.5mm_EP1.66x2.38mm.json +40 -0
  93. ecadlib/package_specs/D_SMA.json +27 -0
  94. ecadlib/package_specs/D_SMB.json +27 -0
  95. ecadlib/package_specs/D_SOD-123.json +43 -0
  96. ecadlib/package_specs/D_SOD-323.json +43 -0
  97. ecadlib/package_specs/LED_0603_1608Metric.json +23 -0
  98. ecadlib/package_specs/LED_0805_2012Metric.json +23 -0
  99. ecadlib/package_specs/LQFP-48_7x7mm_P0.5mm.json +31 -0
  100. ecadlib/package_specs/L_0805_2012Metric.json +22 -0
  101. ecadlib/package_specs/MSOP-8_3x3mm_P0.65mm.json +31 -0
  102. ecadlib/package_specs/QFN-16-1EP_3x3mm_P0.5mm_EP1.75x1.75mm.json +39 -0
  103. ecadlib/package_specs/QFN-32-1EP_5x5mm_P0.5mm_EP3.45x3.45mm.json +37 -0
  104. ecadlib/package_specs/R_0402_1005Metric.json +22 -0
  105. ecadlib/package_specs/R_0603_1608Metric.json +22 -0
  106. ecadlib/package_specs/R_0805_2012Metric.json +22 -0
  107. ecadlib/package_specs/R_1206_3216Metric.json +22 -0
  108. ecadlib/package_specs/SOIC-16_3.9x9.9mm_P1.27mm.json +32 -0
  109. ecadlib/package_specs/SOIC-8_3.9x4.9mm_P1.27mm.json +32 -0
  110. ecadlib/package_specs/SOT-223-3_TabPin2.json +56 -0
  111. ecadlib/package_specs/SOT-23-5.json +57 -0
  112. ecadlib/package_specs/SOT-23-6.json +62 -0
  113. ecadlib/package_specs/SOT-23.json +48 -0
  114. ecadlib/package_specs/TSSOP-16_4.4x5mm_P0.65mm.json +31 -0
  115. ecadlib/package_specs/VQFN-16-1EP_3x3mm_P0.5mm_EP1.68x1.68mm.json +39 -0
  116. ecadlib/package_specs/advanced/BGA-100_11.0x11.0mm_Layout10x10_P1.0mm_Ball0.5mm_Pad0.4mm_NSMD.json +26 -0
  117. ecadlib/package_specs/advanced/BGA-121_9.0x9.0mm_Layout11x11_P0.8mm_Ball0.4mm_Pad0.35mm_NSMD.json +26 -0
  118. ecadlib/package_specs/advanced/BGA-144_7.0x7.0mm_Layout13x13_P0.5mm_Ball0.3mm_Pad0.25mm_NSMD.json +29 -0
  119. ecadlib/package_specs/advanced/BGA-169_11.0x11.0mm_Layout13x13_P0.8mm_Ball0.5mm_Pad0.4mm_NSMD.json +23 -0
  120. ecadlib/package_specs/advanced/BGA-256_11.0x11.0mm_Layout20x20_P0.5mm_Ball0.3mm_Pad0.25mm_NSMD.json +26 -0
  121. ecadlib/package_specs/advanced/BGA-256_17.0x17.0mm_Layout16x16_P1.0mm_Ball0.5mm_Pad0.4mm_NSMD.json +26 -0
  122. ecadlib/package_specs/advanced/BGA-324_15.0x15.0mm_Layout18x18_P0.8mm_Ball0.5mm_Pad0.4mm_NSMD.json +26 -0
  123. ecadlib/package_specs/advanced/BGA-36_3.396x3.466mm_Layout6x6_P0.4mm_Ball0.25mm_Pad0.2mm_NSMD.json +24 -0
  124. ecadlib/package_specs/advanced/BGA-672_27.0x27.0mm_Layout26x26_P1.0mm_Ball0.6mm_Pad0.5mm_NSMD.json +32 -0
  125. ecadlib/package_specs/advanced/BGA-68_5.0x5.0mm_Layout9x9_P0.5mm_Ball0.3mm_Pad0.25mm_NSMD.json +30 -0
  126. ecadlib/package_specs/advanced/BGA-90_8.0x13.0mm_Layout2x3x15_P0.8mm.json +26 -0
  127. ecadlib/package_specs/advanced/DFN-8-1EP_3x3mm_P0.5mm_EP1.65x2.38mm_ThermalVias.json +60 -0
  128. ecadlib/package_specs/advanced/Example_Custom_LPad_Slot.json +24 -0
  129. ecadlib/package_specs/advanced/JST_GH_SM04B-GHS-TB_1x04-1MP_P1.25mm_Horizontal.json +49 -0
  130. ecadlib/package_specs/advanced/JST_PH_B4B-PH-K_1x04_P2.00mm_Vertical.json +33 -0
  131. ecadlib/package_specs/advanced/JST_PH_S4B-PH-SM4-TB_1x04-1MP_P2.00mm_Horizontal.json +55 -0
  132. ecadlib/package_specs/advanced/JST_SH_SM04B-SRSS-TB_1x04-1MP_P1.00mm_Horizontal.json +49 -0
  133. ecadlib/package_specs/advanced/JST_XH_B2B-XH-A_1x02_P2.50mm_Vertical.json +34 -0
  134. ecadlib/package_specs/advanced/JST_XH_B4B-XH-A_1x04_P2.50mm_Vertical.json +34 -0
  135. ecadlib/package_specs/advanced/JST_XH_B6B-XH-A_1x06_P2.50mm_Vertical.json +33 -0
  136. ecadlib/package_specs/advanced/LFBGA-144_10x10mm_Layout12x12_P0.8mm.json +23 -0
  137. ecadlib/package_specs/advanced/Molex_KK-254_AE-6410-04A_1x04_P2.54mm_Vertical.json +34 -0
  138. ecadlib/package_specs/advanced/Molex_PicoBlade_53398-0471_1x04-1MP_P1.25mm_Vertical.json +54 -0
  139. ecadlib/package_specs/advanced/PinHeader_1x05_P1.00mm_Vertical_SMD_Pin1Left.json +14 -0
  140. ecadlib/package_specs/advanced/PinHeader_1x05_P1.27mm_Vertical_SMD_Pin1Left.json +14 -0
  141. ecadlib/package_specs/advanced/PinHeader_1x05_P2.00mm_Vertical.json +12 -0
  142. ecadlib/package_specs/advanced/PinHeader_1x05_P2.00mm_Vertical_SMD_Pin1Left.json +14 -0
  143. ecadlib/package_specs/advanced/PinHeader_1x05_P2.54mm_Vertical.json +12 -0
  144. ecadlib/package_specs/advanced/PinHeader_1x05_P2.54mm_Vertical_SMD_Pin1Left.json +25 -0
  145. ecadlib/package_specs/advanced/PinHeader_1x05_P2.54mm_Vertical_SMD_Pin1Right.json +14 -0
  146. ecadlib/package_specs/advanced/PinHeader_1x10_P1.00mm_Vertical.json +15 -0
  147. ecadlib/package_specs/advanced/PinHeader_2x05_P1.00mm_Vertical_SMD.json +13 -0
  148. ecadlib/package_specs/advanced/PinHeader_2x05_P1.27mm_Vertical.json +18 -0
  149. ecadlib/package_specs/advanced/PinHeader_2x05_P1.27mm_Vertical_SMD.json +24 -0
  150. ecadlib/package_specs/advanced/PinHeader_2x05_P2.00mm_Vertical_SMD.json +13 -0
  151. ecadlib/package_specs/advanced/PinHeader_2x05_P2.54mm_Vertical.json +12 -0
  152. ecadlib/package_specs/advanced/PinHeader_2x05_P2.54mm_Vertical_SMD.json +24 -0
  153. ecadlib/package_specs/advanced/PinHeader_2x20_P2.54mm_Vertical.json +12 -0
  154. ecadlib/package_specs/advanced/QFN-16-1EP_3x3mm_P0.5mm_EP1.75x1.75mm_ThermalVias.json +87 -0
  155. ecadlib/package_specs/advanced/UFBGA-132_7x7mm_Layout12x12_P0.5mm.json +43 -0
  156. ecadlib/package_specs/advanced/USB_C_Receptacle_HRO_TYPE-C-31-M-12.json +218 -0
  157. ecadlib/package_specs/standard.json.gz +0 -0
  158. ecadlib/parity.py +1204 -0
  159. ecadlib/parts_export.py +154 -0
  160. ecadlib/partview.py +215 -0
  161. ecadlib/plugins.py +231 -0
  162. ecadlib/providers.py +226 -0
  163. ecadlib/rebuild.py +471 -0
  164. ecadlib/release_check.py +437 -0
  165. ecadlib/reports.py +274 -0
  166. ecadlib/resolve.py +556 -0
  167. ecadlib/review_cli.py +292 -0
  168. ecadlib/rules.py +979 -0
  169. ecadlib/rules_infer.py +1205 -0
  170. ecadlib/sexpr.py +149 -0
  171. ecadlib/sheetsafe.py +65 -0
  172. ecadlib/sim_models.py +1087 -0
  173. ecadlib/sourcing.py +1161 -0
  174. ecadlib/stdparts.py +755 -0
  175. ecadlib/step_gen.py +929 -0
  176. ecadlib/symbol_layout.py +798 -0
  177. ecadlib/team.py +558 -0
  178. ecadlib/team_setup.py +288 -0
  179. ecadlib/term.py +420 -0
  180. ecadlib/term_views.py +64 -0
  181. ecadlib/testpack.py +334 -0
  182. ecadlib/ui/__init__.py +1 -0
  183. ecadlib/ui/__main__.py +3 -0
  184. ecadlib/ui/server.py +1029 -0
  185. ecadlib/ui/static/hairline.js +2 -0
  186. ecadlib/ui/static/index.html +2098 -0
  187. ecadlib/ui/static/setup.html +294 -0
  188. ecadlib/upkeep.py +717 -0
  189. ecadlib/upstream.py +573 -0
  190. ecadlib/userconfig.py +257 -0
  191. ecadlib/visual_check.py +1603 -0
  192. ecadlib/ws_status.py +203 -0
  193. ecadlib-0.3.0.dist-info/METADATA +2245 -0
  194. ecadlib-0.3.0.dist-info/RECORD +199 -0
  195. ecadlib-0.3.0.dist-info/WHEEL +5 -0
  196. ecadlib-0.3.0.dist-info/entry_points.txt +2 -0
  197. ecadlib-0.3.0.dist-info/licenses/LICENSE +21 -0
  198. ecadlib-0.3.0.dist-info/licenses/NOTICE +53 -0
  199. ecadlib-0.3.0.dist-info/top_level.txt +1 -0
ecadlib/team_setup.py ADDED
@@ -0,0 +1,288 @@
1
+ """One command to run the team server for a team, with HTTPS (Docker Compose: ecadlib + Caddy).
2
+
3
+ python3 -m ecadlib team setup (from the ecadlib checkout; or `ecadlib team setup --dir CHECKOUT`)
4
+ python3 -m ecadlib team setup --domain ecadlib.example.com --admin ada --name "Ada L" --email ada@example.com
5
+ python3 -m ecadlib team setup --domain 192.168.1.20 (LAN: Caddy's internal CA)
6
+ python3 -m ecadlib team setup --domain ecadlib.example.com --own-cert ./certs (ecadlib serves your cert, no Caddy)
7
+
8
+ It asks for whatever is not given, then:
9
+ 1. writes .env next to docker-compose.yml (mode 0600; domain, TLS mode, ports; no password, no keys),
10
+ 2. builds the image (or pulls --image),
11
+ 3. creates the first admin in the ecadlib-data volume, unless an admin already exists there,
12
+ 4. starts the stack with `docker compose up -d --wait`.
13
+ Running it again is safe: the .env keeps its values unless a flag changes them, an existing admin is left alone (no
14
+ password is asked), and compose only recreates what changed. The password is read without echo (or one line from
15
+ stdin with --password-stdin), handed to `ecadlib team add-user --password-stdin` on a pipe, and never written to a
16
+ file, put on a command line or printed. Standard library only, so it runs from a checkout without `pip install`."""
17
+ import argparse, getpass, ipaddress, os, re, shutil, subprocess, sys
18
+ from pathlib import Path
19
+
20
+ from . import team as TM
21
+
22
+ ENV_FILE = ".env"
23
+ BASE, OWN_CERT = "docker-compose.yml", "docker-compose.own-cert.yml"
24
+ WORKSPACE = "/data/workspace" # inside the ecadlib container (the ecadlib-data volume)
25
+ HOSTNAME = re.compile(r"^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$")
26
+ LOCAL_SUFFIXES = (".lan", ".local", ".internal", ".home.arpa", ".localhost", ".test", ".corp", ".intranet")
27
+ ENV_HEADER = ("# Written by `ecadlib team setup`. No secrets here: users and password hashes live in the ecadlib-data\n"
28
+ "# volume; API keys go in api-keys.env. Edit and run `docker compose up -d` (or the setup again).\n")
29
+
30
+
31
+ class SetupError(Exception):
32
+ pass
33
+
34
+
35
+ # ---------------------------------------------------------------- values
36
+ def clean_domain(v):
37
+ """The host name or IP address people will type, without scheme, port or path."""
38
+ d = str(v or "").strip().lower()
39
+ d = re.sub(r"^https?://", "", d).split("/")[0]
40
+ try:
41
+ ipaddress.ip_address(d.strip("[]"))
42
+ return d.strip("[]")
43
+ except ValueError:
44
+ pass
45
+ if not HOSTNAME.match(d):
46
+ raise SetupError(f"{v!r} is not a host name or IP address (e.g. ecadlib.example.com or 192.168.1.20)")
47
+ return d
48
+
49
+
50
+ def is_local(domain):
51
+ """True for names a public CA cannot certify: IP addresses, single labels and LAN-only suffixes."""
52
+ try:
53
+ ipaddress.ip_address(domain)
54
+ return True
55
+ except ValueError:
56
+ return "." not in domain or domain == "localhost" or domain.endswith(LOCAL_SUFFIXES)
57
+
58
+
59
+ def _env_value(k, v):
60
+ v = str(v)
61
+ if not re.match(r"^[A-Za-z0-9._@+:/;,\-]*$", v):
62
+ raise SetupError(f"{k}: {v!r} has characters .env cannot hold safely")
63
+ return v
64
+
65
+
66
+ def read_env(path):
67
+ out = {}
68
+ if path.exists():
69
+ for line in path.read_text().splitlines():
70
+ line = line.strip()
71
+ if line and not line.startswith("#") and "=" in line:
72
+ k, _, v = line.partition("=")
73
+ out[k.strip()] = v.strip().strip("'\"")
74
+ return out
75
+
76
+
77
+ def write_env(path, values):
78
+ """Write .env (0600). Returns True when the content changed; an unchanged file is left untouched."""
79
+ order = ("ECADLIB_DOMAIN", "ECADLIB_TLS", "ECADLIB_HTTP_PORT", "ECADLIB_HTTPS_PORT", "ECADLIB_IMAGE", "COMPOSE_FILE",
80
+ "ECADLIB_CERT_DIR")
81
+ keys = [k for k in order if k in values] + sorted(k for k in values if k not in order) # stable: re-runs match
82
+ text = ENV_HEADER + "".join(f"{k}={_env_value(k, values[k])}\n" for k in keys if values[k] not in (None, ""))
83
+ if path.exists() and path.read_text() == text:
84
+ return False
85
+ TM._write_private(path, text)
86
+ return True
87
+
88
+
89
+ # ---------------------------------------------------------------- talking to the person
90
+ class Asker:
91
+ def __init__(self, interactive):
92
+ self.interactive = interactive
93
+
94
+ def value(self, label, current=None, flag=None, required=True):
95
+ if current:
96
+ return current
97
+ if not self.interactive:
98
+ if required:
99
+ raise SetupError(f"no {label}: give {flag}")
100
+ return ""
101
+ while True:
102
+ try:
103
+ v = input(f"{label}: ").strip()
104
+ except EOFError: # stdin ran out (Windows calls NUL a terminal)
105
+ raise SetupError(f"no {label}: give {flag}") from None
106
+ if v or not required:
107
+ return v
108
+
109
+ def password(self, from_stdin):
110
+ if from_stdin:
111
+ pw = sys.stdin.readline().rstrip("\r\n")
112
+ elif not self.interactive:
113
+ raise SetupError("no password: run this in a terminal, or pipe one line to --password-stdin")
114
+ else:
115
+ pw = getpass.getpass(f"admin password (at least {TM.MIN_PASSWORD} characters, not shown): ")
116
+ if pw != getpass.getpass("again: "):
117
+ raise SetupError("the passwords differ")
118
+ if len(pw) < TM.MIN_PASSWORD:
119
+ raise SetupError(f"the password must be at least {TM.MIN_PASSWORD} characters")
120
+ return pw
121
+
122
+
123
+ # ---------------------------------------------------------------- docker compose
124
+ class Compose:
125
+ def __init__(self, directory, docker="docker"):
126
+ self.dir, self.docker = Path(directory), docker
127
+
128
+ def cmd(self, *args):
129
+ return [self.docker, "compose", "--project-directory", str(self.dir), *args]
130
+
131
+ def run(self, *args, input=None, capture=False):
132
+ p = subprocess.run(self.cmd(*args), cwd=self.dir, input=input, text=True,
133
+ capture_output=capture, stdin=None if input is not None else subprocess.DEVNULL)
134
+ if p.returncode:
135
+ err = (p.stderr or "").strip()[-2000:] if capture else ""
136
+ raise SetupError(f"`docker compose {' '.join(args[:3])} ...` failed (exit {p.returncode})" + (f":\n{err}" if err else ""))
137
+ return p.stdout or ""
138
+
139
+ def team(self, *args, input=None):
140
+ """`ecadlib team --workspace /data/workspace ...` in a throwaway container on the data volume."""
141
+ return self.run("run", "--rm", "--no-deps", "-T", "ecadlib", "team", "--workspace", WORKSPACE, *args,
142
+ input=input, capture=True)
143
+
144
+
145
+ def admins(compose):
146
+ """User names with the admin role in the data volume (parses `ecadlib team list`)."""
147
+ out = []
148
+ for line in compose.team("list").splitlines():
149
+ parts = line.split()
150
+ if line.startswith(" ") and len(parts) >= 2 and parts[1] == "admin":
151
+ out.append(parts[0])
152
+ return out
153
+
154
+
155
+ # ---------------------------------------------------------------- main
156
+ def plan_env(a, env, ask):
157
+ """The .env values from flags, the existing .env and answers. Pure apart from asking."""
158
+ domain = clean_domain(a.domain or ask.value("Domain name or IP address people will use "
159
+ "(e.g. ecadlib.example.com, or 192.168.1.20 on a LAN)",
160
+ env.get("ECADLIB_DOMAIN"), "--domain"))
161
+ new = {k: v for k, v in env.items() if k not in ("ECADLIB_DOMAIN", "ECADLIB_TLS", "COMPOSE_FILE", "ECADLIB_CERT_DIR")}
162
+ new["ECADLIB_DOMAIN"] = domain
163
+ own = a.own_cert or (env.get("ECADLIB_CERT_DIR") if a.tls == "auto" and env.get("COMPOSE_FILE") else None)
164
+ tls = a.tls
165
+ if tls == "auto":
166
+ tls = "own-cert" if own else "internal" if is_local(domain) else "acme"
167
+ if tls == "own-cert":
168
+ if not own:
169
+ raise SetupError("--tls own-cert needs --own-cert DIR (with cert.pem and key.pem)")
170
+ cert_dir = Path(own) if Path(own).is_absolute() else (Path(a.dir) / own)
171
+ for f in ("cert.pem", "key.pem"):
172
+ if not (cert_dir / f).is_file():
173
+ raise SetupError(f"{cert_dir / f} is missing (--own-cert DIR needs cert.pem and key.pem)")
174
+ new["COMPOSE_FILE"] = os.pathsep.join((BASE, OWN_CERT))
175
+ own = Path(own).as_posix()
176
+ new["ECADLIB_CERT_DIR"] = own if Path(own).is_absolute() or own.startswith(".") else "./" + own # not a volume name
177
+ new["ECADLIB_TLS"] = "internal" # unused without Caddy; keeps the file complete
178
+ elif tls == "internal":
179
+ new["ECADLIB_TLS"] = "internal"
180
+ else:
181
+ if is_local(domain):
182
+ raise SetupError(f"{domain} cannot get a public certificate: use --tls internal (Caddy's own CA) or --own-cert")
183
+ old = env.get("ECADLIB_TLS", "")
184
+ email = a.acme_email or (old if "@" in old else "") or a.email
185
+ email = ask.value("E-mail for Let's Encrypt (certificate expiry notices)", email, "--acme-email")
186
+ if not re.match(r"^[^@\s]+@[^@\s]+\.[^@\s]+$", email):
187
+ raise SetupError(f"{email!r} does not look like an e-mail address")
188
+ new["ECADLIB_TLS"] = email
189
+ for k, flag in (("ECADLIB_HTTP_PORT", a.http_port), ("ECADLIB_HTTPS_PORT", a.https_port), ("ECADLIB_IMAGE", a.image)):
190
+ if flag:
191
+ new[k] = str(flag)
192
+ new.setdefault("ECADLIB_HTTP_PORT", "80")
193
+ new.setdefault("ECADLIB_HTTPS_PORT", "443")
194
+ return new, tls
195
+
196
+
197
+ def url_of(env):
198
+ port = env.get("ECADLIB_HTTPS_PORT", "443")
199
+ d = env["ECADLIB_DOMAIN"]
200
+ host = f"[{d}]" if ":" in d else d
201
+ return f"https://{host}" + ("" if port == "443" else f":{port}") + "/"
202
+
203
+
204
+ def main(argv=None):
205
+ ap = argparse.ArgumentParser(prog="ecadlib team setup", description=__doc__.split("\n\n")[0],
206
+ formatter_class=argparse.RawDescriptionHelpFormatter, epilog=__doc__.split("\n\n", 1)[1])
207
+ ap.add_argument("--dir", default=".", help="folder with docker-compose.yml (the ecadlib checkout); .env goes here")
208
+ ap.add_argument("--domain", help="host name or IP address people will use")
209
+ ap.add_argument("--tls", choices=("auto", "acme", "internal", "own-cert"), default="auto",
210
+ help="auto: Let's Encrypt for a public name, Caddy's internal CA for an IP or LAN name")
211
+ ap.add_argument("--acme-email", help="e-mail for Let's Encrypt (default: the admin's)")
212
+ ap.add_argument("--own-cert", metavar="DIR", help="ecadlib serves DIR/cert.pem + DIR/key.pem itself; no Caddy")
213
+ ap.add_argument("--http-port", type=int, help="host port for HTTP (default 80; ACME needs 80)")
214
+ ap.add_argument("--https-port", type=int, help="host port for HTTPS (default 443)")
215
+ ap.add_argument("--image", help="use a published image (e.g. ghcr.io/olibrowne/pcblivinglibrary:0.3.0) instead of building")
216
+ g = ap.add_argument_group("first admin (only used while the data volume has no admin)")
217
+ g.add_argument("--admin", help="user name")
218
+ g.add_argument("--name", help="full name")
219
+ g.add_argument("--email", help="e-mail")
220
+ g.add_argument("--password-stdin", action="store_true", help="read the password from one line of stdin")
221
+ ap.add_argument("--no-start", action="store_true", help="write .env and create the admin, but do not start the stack")
222
+ ap.add_argument("--docker", default="docker", help=argparse.SUPPRESS) # tests use a stand-in
223
+ a = ap.parse_args(argv)
224
+ try:
225
+ return _setup(a)
226
+ except SetupError as e:
227
+ print(f"ecadlib team setup: {e}", file=sys.stderr)
228
+ return 1
229
+ except KeyboardInterrupt:
230
+ print("\nstopped", file=sys.stderr)
231
+ return 130
232
+
233
+
234
+ def _setup(a):
235
+ d = Path(a.dir).resolve()
236
+ if not (d / BASE).is_file():
237
+ raise SetupError(f"no {BASE} in {d}: run this from the ecadlib checkout, or pass --dir")
238
+ if not shutil.which(a.docker):
239
+ raise SetupError("docker is not installed or not on PATH (https://docs.docker.com/get-docker/)")
240
+ ask = Asker(interactive=sys.stdin.isatty() and not a.password_stdin)
241
+ env_path = d / ENV_FILE
242
+ env, tls = plan_env(a, read_env(env_path), ask)
243
+ print(("wrote " if write_env(env_path, env) else "unchanged: ") + str(env_path)
244
+ + f" (domain {env['ECADLIB_DOMAIN']}, TLS: " + {"acme": "Let's Encrypt", "internal": "Caddy's internal CA",
245
+ "own-cert": "your certificate, served by ecadlib"}[tls] + ")")
246
+ compose = Compose(d, a.docker)
247
+ if env.get("ECADLIB_IMAGE"):
248
+ print(f"pulling {env['ECADLIB_IMAGE']} ...")
249
+ compose.run("pull", "ecadlib")
250
+ else:
251
+ print("building the ecadlib image ...")
252
+ compose.run("build", "ecadlib")
253
+ have = admins(compose)
254
+ if have:
255
+ print(f"admin already set up ({', '.join(have)}): no password needed. Manage users on the Users page or with\n"
256
+ f" docker compose run --rm ecadlib team --workspace {WORKSPACE} add-user|reset-password|list ...")
257
+ else:
258
+ user = TM.check_username(ask.value("First admin's user name (e.g. ada)", a.admin, "--admin"))
259
+ name = ask.value("Full name (recorded with approvals)", a.name, "--name", required=False)
260
+ email = ask.value("E-mail", a.email or (env["ECADLIB_TLS"] if "@" in env.get("ECADLIB_TLS", "") else ""),
261
+ "--email", required=False)
262
+ pw = ask.password(a.password_stdin)
263
+ try:
264
+ compose.team("add-user", user, "--role", "admin", "--name", name, "--email", email,
265
+ "--password-stdin", input=pw + "\n")
266
+ finally:
267
+ pw = None
268
+ print(f"added {user} as admin (in the ecadlib-data volume)")
269
+ if a.no_start:
270
+ print("not started (--no-start): run `docker compose up -d` in " + str(d))
271
+ return 0
272
+ print("starting ...")
273
+ if tls == "own-cert":
274
+ compose.run("rm", "--stop", "--force", "caddy") # left over from proxy mode (no-op when absent)
275
+ compose.run("up", "-d", "--wait", "--wait-timeout", "180")
276
+ print(f"\necadlib is running: {url_of(env)}")
277
+ if tls == "internal":
278
+ print("Browsers will warn until they trust Caddy's local CA. Export it once and install it on each computer\n"
279
+ "(or accept the warning): docker compose cp caddy:/data/caddy/pki/authorities/local/root.crt ecadlib-ca.crt")
280
+ elif tls == "acme":
281
+ print(f"The certificate comes from Let's Encrypt: {env['ECADLIB_DOMAIN']} must resolve to this machine and ports "
282
+ f"{env['ECADLIB_HTTP_PORT']}/{env['ECADLIB_HTTPS_PORT']} must be reachable. Logs: docker compose logs caddy")
283
+ print("Back up: see README, 'Run it for your team'. Upgrade: git pull, then run this setup again.")
284
+ return 0
285
+
286
+
287
+ if __name__ == "__main__":
288
+ sys.exit(main())