acumatica-cli 0.2.2__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.
- acumatica_cli/__init__.py +0 -0
- acumatica_cli/bootstrap.py +105 -0
- acumatica_cli/bootstrap_plugin.cs +62 -0
- acumatica_cli/bootstrap_project.xml +117 -0
- acumatica_cli/cli.py +351 -0
- acumatica_cli/client.py +251 -0
- acumatica_cli/config.py +113 -0
- acumatica_cli/firstlogin.py +171 -0
- acumatica_cli/models.py +21 -0
- acumatica_cli/output.py +70 -0
- acumatica_cli/seed.py +120 -0
- acumatica_cli/tenant.py +149 -0
- acumatica_cli-0.2.2.dist-info/METADATA +187 -0
- acumatica_cli-0.2.2.dist-info/RECORD +16 -0
- acumatica_cli-0.2.2.dist-info/WHEEL +4 -0
- acumatica_cli-0.2.2.dist-info/entry_points.txt +3 -0
acumatica_cli/client.py
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
"""Contract-based REST API session (see docs/rest-api.md for verified quirks)."""
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import html
|
|
5
|
+
import re
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from .config import Instance
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def wrap(record: dict[str, Any]) -> dict[str, Any]:
|
|
14
|
+
"""Plain dict -> contract-API body: {"Field": {"value": ...}}."""
|
|
15
|
+
return {k: {"value": v} for k, v in record.items()}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def unwrap(entity: dict[str, Any]) -> dict[str, Any]:
|
|
19
|
+
"""Contract-API entity -> plain dict (top-level value fields only)."""
|
|
20
|
+
return {
|
|
21
|
+
k: v["value"] for k, v in entity.items() if isinstance(v, dict) and "value" in v
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class AcumaticaClient:
|
|
26
|
+
"""Cookie-session client for the contract-based endpoint.
|
|
27
|
+
|
|
28
|
+
Use as a context manager: sessions count against the license, so logout
|
|
29
|
+
must run even on failure.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
instance: Instance,
|
|
35
|
+
timeout: float = 120.0,
|
|
36
|
+
transport: httpx.BaseTransport | None = None,
|
|
37
|
+
):
|
|
38
|
+
self.instance = instance
|
|
39
|
+
self._http = httpx.Client(
|
|
40
|
+
base_url=instance.base_url, timeout=timeout, transport=transport
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
def __enter__(self) -> "AcumaticaClient":
|
|
44
|
+
# tenant guard (V5, docs/rest-api.md): an omitted or empty tenant is
|
|
45
|
+
# the one login the server still routes silently — to the default
|
|
46
|
+
# tenant. Defense-in-depth vs wrong-tenant writes: every data-plane
|
|
47
|
+
# session must name its tenant, so refuse before any HTTP happens.
|
|
48
|
+
if not self.instance.tenant:
|
|
49
|
+
raise RuntimeError(
|
|
50
|
+
f"no tenant set for {self.instance.host} - a session without "
|
|
51
|
+
"an explicit tenant silently lands on the default tenant; "
|
|
52
|
+
"set tenant in acu.yaml or pass -t/--tenant"
|
|
53
|
+
)
|
|
54
|
+
creds: dict[str, str] = {
|
|
55
|
+
"name": self.instance.username,
|
|
56
|
+
"password": self.instance.password,
|
|
57
|
+
"tenant": self.instance.tenant,
|
|
58
|
+
}
|
|
59
|
+
self._checked(self._http.post("/entity/auth/login", json=creds))
|
|
60
|
+
# tenant guard, landed side (V5, B5): login accepting the name proves
|
|
61
|
+
# nothing - a stale tenant map reroutes named logins to the default
|
|
62
|
+
# tenant, and a single-tenant instance accepts ANY name (both verified
|
|
63
|
+
# live, docs/rest-api.md). Verify where the session actually landed
|
|
64
|
+
# and refuse on mismatch; logout first, since __exit__ never runs
|
|
65
|
+
# when __enter__ raises (V6 - sessions count against the license).
|
|
66
|
+
try:
|
|
67
|
+
landed = self._landed_tenant()
|
|
68
|
+
if landed.casefold() != self.instance.tenant.casefold():
|
|
69
|
+
raise RuntimeError(
|
|
70
|
+
f"tenant guard: asked for tenant {self.instance.tenant!r} "
|
|
71
|
+
f"but the session landed on {landed!r} - the instance "
|
|
72
|
+
"tenant map is stale or the tenant does not exist; check "
|
|
73
|
+
"acu tenant list and recycle the app pool"
|
|
74
|
+
)
|
|
75
|
+
except BaseException:
|
|
76
|
+
self.__exit__()
|
|
77
|
+
raise
|
|
78
|
+
return self
|
|
79
|
+
|
|
80
|
+
def __exit__(self, *exc: object) -> None:
|
|
81
|
+
try:
|
|
82
|
+
# empty body sets Content-Length: 0 — IIS 411s without it
|
|
83
|
+
self._http.post("/entity/auth/logout", content=b"")
|
|
84
|
+
finally:
|
|
85
|
+
self._http.close()
|
|
86
|
+
|
|
87
|
+
def _landed_tenant(self) -> str:
|
|
88
|
+
"""The tenant this session actually landed on (login name).
|
|
89
|
+
|
|
90
|
+
Verified vs 26.101.0225 (docs/rest-api.md): the contract API exposes
|
|
91
|
+
nothing tenant-identifying, but an authenticated GET of the sign-in
|
|
92
|
+
page renders a hidden ``txtSingleCompany`` input whose value is the
|
|
93
|
+
session tenant's login name in every observed state - multi-tenant,
|
|
94
|
+
single-tenant, and mid-reroute under a stale tenant map. The probe
|
|
95
|
+
does not disturb the session.
|
|
96
|
+
"""
|
|
97
|
+
r = self._checked(self._http.get("/Frames/Login.aspx", follow_redirects=True))
|
|
98
|
+
m = re.search(r'id="txtSingleCompany" value="([^"]*)"', r.text)
|
|
99
|
+
if not m:
|
|
100
|
+
raise RuntimeError(
|
|
101
|
+
"tenant guard: /Frames/Login.aspx did not expose the landed "
|
|
102
|
+
"tenant (txtSingleCompany missing - page shape changed on "
|
|
103
|
+
"this build?); refusing the session rather than risking "
|
|
104
|
+
"wrong-tenant writes"
|
|
105
|
+
)
|
|
106
|
+
return html.unescape(m.group(1))
|
|
107
|
+
|
|
108
|
+
def _url(self, entity: str, endpoint: str | None = None) -> str:
|
|
109
|
+
return f"/entity/{endpoint or self.instance.endpoint}/{entity}"
|
|
110
|
+
|
|
111
|
+
@staticmethod
|
|
112
|
+
def _checked(r: httpx.Response) -> httpx.Response:
|
|
113
|
+
"""Surface Acumatica's exceptionMessage instead of a bare status code."""
|
|
114
|
+
if r.is_error:
|
|
115
|
+
detail = ""
|
|
116
|
+
try:
|
|
117
|
+
body = r.json()
|
|
118
|
+
detail = body.get("exceptionMessage") or body.get("message") or ""
|
|
119
|
+
except Exception:
|
|
120
|
+
pass
|
|
121
|
+
raise RuntimeError(
|
|
122
|
+
f"{r.request.method} {r.request.url.path} -> {r.status_code}"
|
|
123
|
+
+ (f": {detail}" if detail else "")
|
|
124
|
+
)
|
|
125
|
+
return r
|
|
126
|
+
|
|
127
|
+
def get_list(
|
|
128
|
+
self,
|
|
129
|
+
entity: str,
|
|
130
|
+
params: dict[str, str] | None = None,
|
|
131
|
+
endpoint: str | None = None,
|
|
132
|
+
) -> list[dict[str, Any]]:
|
|
133
|
+
"""GET the entity's records, optionally narrowed with OData params.
|
|
134
|
+
|
|
135
|
+
endpoint overrides the instance's endpoint per call (bootstrap YAML
|
|
136
|
+
targets the custom Bootstrap endpoint; everything else defaults).
|
|
137
|
+
"""
|
|
138
|
+
return self._checked(
|
|
139
|
+
self._http.get(self._url(entity, endpoint), params=params)
|
|
140
|
+
).json()
|
|
141
|
+
|
|
142
|
+
def swagger(self) -> bytes:
|
|
143
|
+
"""GET the endpoint's OpenAPI schema (swagger.json), raw bytes."""
|
|
144
|
+
return self._checked(self._http.get(self._url("swagger.json"))).content
|
|
145
|
+
|
|
146
|
+
def put(
|
|
147
|
+
self, entity: str, record: dict[str, Any], endpoint: str | None = None
|
|
148
|
+
) -> dict[str, Any]:
|
|
149
|
+
"""Upsert by the entity's key fields — the idempotence primitive."""
|
|
150
|
+
return self._checked(
|
|
151
|
+
self._http.put(self._url(entity, endpoint), json=wrap(record))
|
|
152
|
+
).json()
|
|
153
|
+
|
|
154
|
+
# -- CustomizationApi (same cookie session; works on a virgin tenant) --
|
|
155
|
+
|
|
156
|
+
@staticmethod
|
|
157
|
+
def _checked_log(r: httpx.Response) -> dict[str, Any]:
|
|
158
|
+
"""Raise on in-band CustomizationApi errors (verified vs 26.101.0225).
|
|
159
|
+
|
|
160
|
+
The CustomizationApi answers 200 even when an operation fails — the
|
|
161
|
+
failure only shows as ``log`` entries with ``logType: "error"``
|
|
162
|
+
(e.g. an import that rejects the package still returns 200).
|
|
163
|
+
"""
|
|
164
|
+
AcumaticaClient._checked(r)
|
|
165
|
+
try:
|
|
166
|
+
body: dict[str, Any] = r.json()
|
|
167
|
+
except ValueError:
|
|
168
|
+
return {}
|
|
169
|
+
log = body.get("log")
|
|
170
|
+
errors = [
|
|
171
|
+
str(entry.get("message", ""))
|
|
172
|
+
for entry in (log if isinstance(log, list) else [])
|
|
173
|
+
if isinstance(entry, dict) and entry.get("logType") == "error"
|
|
174
|
+
]
|
|
175
|
+
if errors:
|
|
176
|
+
raise RuntimeError(
|
|
177
|
+
f"POST {r.request.url.path} reported: " + "; ".join(errors)
|
|
178
|
+
)
|
|
179
|
+
return body
|
|
180
|
+
|
|
181
|
+
def customization_published(self) -> list[str]:
|
|
182
|
+
"""Names of the customization projects published in the session tenant."""
|
|
183
|
+
body = self._checked_log(
|
|
184
|
+
self._http.post("/CustomizationApi/getPublished", json={})
|
|
185
|
+
)
|
|
186
|
+
projects = body.get("projects") or []
|
|
187
|
+
return [p["name"] for p in projects if isinstance(p, dict) and "name" in p]
|
|
188
|
+
|
|
189
|
+
def customization_project_exists(self, name: str) -> bool:
|
|
190
|
+
"""True when the project's content rows exist in the session tenant.
|
|
191
|
+
|
|
192
|
+
getPublished alone is a false idempotence proxy: a tenant deleted and
|
|
193
|
+
recreated under the same CompanyID still LISTS the publication while
|
|
194
|
+
the project content (and everything the publish wrote) is gone
|
|
195
|
+
(verified live vs 26.101.0225).
|
|
196
|
+
"""
|
|
197
|
+
try:
|
|
198
|
+
body = self._checked_log(
|
|
199
|
+
self._http.post(
|
|
200
|
+
"/CustomizationApi/getProject", json={"projectName": name}
|
|
201
|
+
)
|
|
202
|
+
)
|
|
203
|
+
except RuntimeError:
|
|
204
|
+
return False
|
|
205
|
+
return bool(body.get("projectContentBase64"))
|
|
206
|
+
|
|
207
|
+
def customization_import(
|
|
208
|
+
self, name: str, zip_bytes: bytes, description: str = ""
|
|
209
|
+
) -> None:
|
|
210
|
+
"""Upload a customization package zip (replacing any same-name project).
|
|
211
|
+
|
|
212
|
+
The content field is ``projectContentBase64`` — the live binder's
|
|
213
|
+
property (ImportParamsData, verified vs 26.101.0225 by reflection).
|
|
214
|
+
The widely documented ``projectContents`` binds nothing on this
|
|
215
|
+
build: the server deletes the existing project (isReplaceIfExists)
|
|
216
|
+
and then reports "The project is not found".
|
|
217
|
+
"""
|
|
218
|
+
self._checked_log(
|
|
219
|
+
self._http.post(
|
|
220
|
+
"/CustomizationApi/import",
|
|
221
|
+
json={
|
|
222
|
+
"projectLevel": 0,
|
|
223
|
+
"isReplaceIfExists": True,
|
|
224
|
+
"projectName": name,
|
|
225
|
+
"projectDescription": description,
|
|
226
|
+
"projectContentBase64": base64.b64encode(zip_bytes).decode("ascii"),
|
|
227
|
+
},
|
|
228
|
+
)
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
def customization_publish_begin(self, names: list[str]) -> None:
|
|
232
|
+
"""Start publishing the named projects into the current tenant."""
|
|
233
|
+
self._checked_log(
|
|
234
|
+
self._http.post(
|
|
235
|
+
"/CustomizationApi/publishBegin",
|
|
236
|
+
json={
|
|
237
|
+
"isMergeWithExistingPackages": False,
|
|
238
|
+
"isOnlyValidation": False,
|
|
239
|
+
"isOnlyDbUpdates": False,
|
|
240
|
+
"isReplayPreviouslyExecutedScripts": False,
|
|
241
|
+
"projectNames": names,
|
|
242
|
+
"tenantMode": "Current",
|
|
243
|
+
},
|
|
244
|
+
)
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
def customization_publish_end(self) -> dict[str, Any]:
|
|
248
|
+
"""Poll the running publish: {isCompleted, isFailed, log: [...]}."""
|
|
249
|
+
return self._checked(
|
|
250
|
+
self._http.post("/CustomizationApi/publishEnd", json={})
|
|
251
|
+
).json()
|
acumatica_cli/config.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""The instance target (acu.yaml, found by walking up from cwd) + credentials (.env).
|
|
2
|
+
|
|
3
|
+
Layered defaults: ``host`` is the only required acu.yaml key. Everything else
|
|
4
|
+
is a code default transcribed from the verified references (docs/ac-exe.md,
|
|
5
|
+
docs/rest-api.md — V12), overridable per instance for nonstandard installs.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
import yaml
|
|
13
|
+
from dotenv import load_dotenv
|
|
14
|
+
from pydantic import ValidationError, field_validator, model_validator
|
|
15
|
+
|
|
16
|
+
from .models import Model, validation_summary
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Instance(Model):
|
|
20
|
+
"""The resolved target: the acu.yaml top-level map + credentials.
|
|
21
|
+
|
|
22
|
+
``host`` drives both planes (V1): REST ``base_url`` and control-plane
|
|
23
|
+
``ssh`` derive from it unless the acu.yaml map overrides them
|
|
24
|
+
explicitly (split-horizon DNS, port forwards, jump hosts, nonroot sites).
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
host: str
|
|
28
|
+
tenant: str = ""
|
|
29
|
+
scheme: str = "http" # docs/rest-api.md: http://acu-dev1.vm.internal/...
|
|
30
|
+
ssh_user: str = "Administrator"
|
|
31
|
+
instance_name: str = "AcumaticaERP"
|
|
32
|
+
instance_path: str = "C:\\Acumatica\\AcumaticaERP"
|
|
33
|
+
ac_exe: str = "C:\\Program Files\\Acumatica ERP\\Data\\ac.exe"
|
|
34
|
+
db_name: str = "AcumaticaDB"
|
|
35
|
+
endpoint: str = "Default/25.200.001" # V11: versioned path only
|
|
36
|
+
base_url: str = "" # default derived: <scheme>://<host>/<instance_name>
|
|
37
|
+
ssh: str = "" # default derived: <ssh_user>@<host>
|
|
38
|
+
username: str
|
|
39
|
+
password: str
|
|
40
|
+
|
|
41
|
+
@field_validator("base_url")
|
|
42
|
+
@classmethod
|
|
43
|
+
def _no_trailing_slash(cls, v: str) -> str:
|
|
44
|
+
return v.rstrip("/")
|
|
45
|
+
|
|
46
|
+
@field_validator("endpoint")
|
|
47
|
+
@classmethod
|
|
48
|
+
def _no_surrounding_slashes(cls, v: str) -> str:
|
|
49
|
+
return v.strip("/")
|
|
50
|
+
|
|
51
|
+
@model_validator(mode="before")
|
|
52
|
+
@classmethod
|
|
53
|
+
def _derive_urls(cls, data: Any) -> Any:
|
|
54
|
+
"""Construct base_url/ssh from host; an explicit override wins."""
|
|
55
|
+
if not isinstance(data, dict) or not data.get("host"):
|
|
56
|
+
return data # let field validation report the missing host
|
|
57
|
+
|
|
58
|
+
def resolved(key: str) -> object:
|
|
59
|
+
return data.get(key) or cls.model_fields[key].default
|
|
60
|
+
|
|
61
|
+
data = dict(data)
|
|
62
|
+
host = data["host"]
|
|
63
|
+
if not data.get("base_url"):
|
|
64
|
+
data["base_url"] = (
|
|
65
|
+
f"{resolved('scheme')}://{host}/{resolved('instance_name')}"
|
|
66
|
+
)
|
|
67
|
+
if not data.get("ssh"):
|
|
68
|
+
data["ssh"] = f"{resolved('ssh_user')}@{host}"
|
|
69
|
+
return data
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def data_root() -> Path:
|
|
73
|
+
"""Walk up from cwd to the first directory containing acu.yaml."""
|
|
74
|
+
for d in [Path.cwd(), *Path.cwd().parents]:
|
|
75
|
+
if (d / "acu.yaml").is_file():
|
|
76
|
+
return d
|
|
77
|
+
raise SystemExit(
|
|
78
|
+
"acu.yaml not found in the current directory or any parent - "
|
|
79
|
+
"run acu from inside a data repo (e.g. acumatica-baseline)"
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def load_instance(host: str | None = None) -> Instance:
|
|
84
|
+
"""Resolve the target from acu.yaml and merge credentials from .env/environment.
|
|
85
|
+
|
|
86
|
+
``host`` (the global --host flag) replaces the acu.yaml host before the
|
|
87
|
+
Instance is built, so derived base_url/ssh follow it; a post-hoc
|
|
88
|
+
model_copy would leave them pointing at the old host. Explicit acu.yaml
|
|
89
|
+
base_url/ssh overrides still win, exactly as they do over the file's own
|
|
90
|
+
host.
|
|
91
|
+
"""
|
|
92
|
+
root = data_root()
|
|
93
|
+
load_dotenv(root / ".env")
|
|
94
|
+
|
|
95
|
+
with open(root / "acu.yaml") as f:
|
|
96
|
+
config = yaml.safe_load(f)
|
|
97
|
+
if not isinstance(config, dict):
|
|
98
|
+
raise SystemExit("acu.yaml: expected a mapping (host + optional overrides)")
|
|
99
|
+
if host is not None:
|
|
100
|
+
config["host"] = host
|
|
101
|
+
|
|
102
|
+
password = os.environ.get("ACU_PASSWORD")
|
|
103
|
+
if not password:
|
|
104
|
+
raise SystemExit("ACU_PASSWORD not set (put it in .env or the environment)")
|
|
105
|
+
|
|
106
|
+
try:
|
|
107
|
+
return Instance(
|
|
108
|
+
username=os.environ.get("ACU_USER", "admin"),
|
|
109
|
+
password=password,
|
|
110
|
+
**config,
|
|
111
|
+
)
|
|
112
|
+
except ValidationError as exc:
|
|
113
|
+
raise SystemExit(f"acu.yaml: {validation_summary(exc)}") from exc
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""First-login admin password initialization via the Login.aspx screen flow.
|
|
2
|
+
|
|
3
|
+
A freshly created tenant seeds ``admin``/``setup`` with a
|
|
4
|
+
must-change-on-first-login flag the contract REST API cannot clear (verified —
|
|
5
|
+
see docs/rest-api.md). The sign-in screen's WebForms flow can, with plain
|
|
6
|
+
HTTP: GET the page for the hidden fields, POST the seed credentials, then
|
|
7
|
+
POST again with the new password into the change view the server renders.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import time
|
|
11
|
+
from contextlib import suppress
|
|
12
|
+
from html.parser import HTMLParser
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
|
|
16
|
+
from .config import Instance
|
|
17
|
+
|
|
18
|
+
LOGIN_PATH = "/Frames/Login.aspx"
|
|
19
|
+
SEED_PASSWORD = "setup" # what a new tenant's admin is born with
|
|
20
|
+
|
|
21
|
+
TENANT_FIELD = "ctl00$phUser$cmbCompany"
|
|
22
|
+
USER_FIELD = "ctl00$phUser$txtUser"
|
|
23
|
+
PASS_FIELD = "ctl00$phUser$txtPass"
|
|
24
|
+
NEW_PASS_FIELD = "ctl00$phUser$txtNewPassword"
|
|
25
|
+
CONFIRM_PASS_FIELD = "ctl00$phUser$txtConfirmPassword"
|
|
26
|
+
# The page has four submit inputs; this is the right one (mfLoginButton is the
|
|
27
|
+
# multi-factor flow). Both new-password fields must be posted together.
|
|
28
|
+
LOGIN_BUTTON = "ctl00$phUser$btnLogin"
|
|
29
|
+
REMEMBER_FIELD = "ctl00$phUser$rememberDevice"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class _LoginForm(HTMLParser):
|
|
33
|
+
"""Collects the sign-in form's postable fields (inputs and selects)."""
|
|
34
|
+
|
|
35
|
+
def __init__(self) -> None:
|
|
36
|
+
super().__init__()
|
|
37
|
+
self.fields: dict[str, str] = {}
|
|
38
|
+
self._select: str | None = None
|
|
39
|
+
|
|
40
|
+
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
|
41
|
+
a = dict(attrs)
|
|
42
|
+
name = a.get("name")
|
|
43
|
+
if tag == "input" and name:
|
|
44
|
+
itype = (a.get("type") or "text").lower()
|
|
45
|
+
if itype in ("submit", "button"):
|
|
46
|
+
return # buttons are posted explicitly by the caller
|
|
47
|
+
if itype == "checkbox" and "checked" not in a:
|
|
48
|
+
return
|
|
49
|
+
self.fields[name] = a.get("value") or ""
|
|
50
|
+
elif tag == "select" and name:
|
|
51
|
+
self._select = name
|
|
52
|
+
self.fields[name] = ""
|
|
53
|
+
elif tag == "option" and self._select and "selected" in a:
|
|
54
|
+
self.fields[self._select] = a.get("value") or ""
|
|
55
|
+
|
|
56
|
+
def handle_endtag(self, tag: str) -> None:
|
|
57
|
+
if tag == "select":
|
|
58
|
+
self._select = None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _form_fields(html: str) -> dict[str, str]:
|
|
62
|
+
parser = _LoginForm()
|
|
63
|
+
parser.feed(html)
|
|
64
|
+
return parser.fields
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _rest_login_works(instance: Instance, tenant: str) -> bool:
|
|
68
|
+
"""One REST login attempt with the instance credentials (then logout)."""
|
|
69
|
+
with httpx.Client(base_url=instance.base_url, timeout=60) as http:
|
|
70
|
+
r = http.post(
|
|
71
|
+
"/entity/auth/login",
|
|
72
|
+
json={
|
|
73
|
+
"name": instance.username,
|
|
74
|
+
"password": instance.password,
|
|
75
|
+
"tenant": tenant,
|
|
76
|
+
},
|
|
77
|
+
)
|
|
78
|
+
if r.status_code == 204:
|
|
79
|
+
http.post("/entity/auth/logout", content=b"")
|
|
80
|
+
return True
|
|
81
|
+
return False
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _get_login_page(http: httpx.Client, retries: int, delay: float) -> httpx.Response:
|
|
85
|
+
"""GET the sign-in page, retrying while the app warms up after a recycle."""
|
|
86
|
+
last = ""
|
|
87
|
+
for attempt in range(retries):
|
|
88
|
+
try:
|
|
89
|
+
r = http.get(LOGIN_PATH)
|
|
90
|
+
if r.status_code == 200:
|
|
91
|
+
return r
|
|
92
|
+
last = f"HTTP {r.status_code}"
|
|
93
|
+
except httpx.TransportError as e:
|
|
94
|
+
last = repr(e)
|
|
95
|
+
if attempt < retries - 1:
|
|
96
|
+
time.sleep(delay)
|
|
97
|
+
raise RuntimeError(f"sign-in page did not come back after recycle: {last}")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def initialize_admin_password(
|
|
101
|
+
instance: Instance, tenant: str, retries: int = 24, delay: float = 5.0
|
|
102
|
+
) -> str:
|
|
103
|
+
"""Change the tenant's seeded admin password to the instance password.
|
|
104
|
+
|
|
105
|
+
Returns ``"already initialized"`` if REST login already works (the normal
|
|
106
|
+
case — ``acu tenant create`` presets the password via ac.exe), or
|
|
107
|
+
``"password changed"`` after a successful screen-flow change (verified by
|
|
108
|
+
a REST login). Raises RuntimeError when neither path succeeds.
|
|
109
|
+
"""
|
|
110
|
+
with httpx.Client(
|
|
111
|
+
base_url=instance.base_url,
|
|
112
|
+
timeout=httpx.Timeout(10.0, read=300.0),
|
|
113
|
+
follow_redirects=True,
|
|
114
|
+
) as http:
|
|
115
|
+
# fetch the page first: it doubles as the wait for the app to warm up
|
|
116
|
+
# after the post-create app-pool recycle
|
|
117
|
+
page = _get_login_page(http, retries, delay)
|
|
118
|
+
if _rest_login_works(instance, tenant):
|
|
119
|
+
return "already initialized"
|
|
120
|
+
|
|
121
|
+
fields = _form_fields(page.text)
|
|
122
|
+
fields.pop(REMEMBER_FIELD, None)
|
|
123
|
+
fields.update(
|
|
124
|
+
{
|
|
125
|
+
TENANT_FIELD: tenant,
|
|
126
|
+
USER_FIELD: instance.username,
|
|
127
|
+
PASS_FIELD: SEED_PASSWORD,
|
|
128
|
+
LOGIN_BUTTON: "Sign In",
|
|
129
|
+
}
|
|
130
|
+
)
|
|
131
|
+
page = http.post(LOGIN_PATH, data=fields)
|
|
132
|
+
|
|
133
|
+
fields = _form_fields(page.text)
|
|
134
|
+
if NEW_PASS_FIELD not in fields:
|
|
135
|
+
state = (
|
|
136
|
+
"seed password signed straight in - no change demanded; "
|
|
137
|
+
"the admin password is still the seed"
|
|
138
|
+
if "Main" in page.url.path
|
|
139
|
+
else f"landed on {page.url}"
|
|
140
|
+
)
|
|
141
|
+
raise RuntimeError(
|
|
142
|
+
f"tenant {tenant!r}: expected the password-change view after "
|
|
143
|
+
f"signing in with the seed password; {state}"
|
|
144
|
+
)
|
|
145
|
+
fields.pop(REMEMBER_FIELD, None)
|
|
146
|
+
fields.update(
|
|
147
|
+
{
|
|
148
|
+
TENANT_FIELD: tenant,
|
|
149
|
+
USER_FIELD: instance.username,
|
|
150
|
+
PASS_FIELD: SEED_PASSWORD,
|
|
151
|
+
NEW_PASS_FIELD: instance.password,
|
|
152
|
+
CONFIRM_PASS_FIELD: instance.password,
|
|
153
|
+
LOGIN_BUTTON: "Sign In",
|
|
154
|
+
}
|
|
155
|
+
)
|
|
156
|
+
page = http.post(LOGIN_PATH, data=fields)
|
|
157
|
+
with suppress(httpx.HTTPError):
|
|
158
|
+
# the screen session holds a license seat — best-effort logout
|
|
159
|
+
http.post("/entity/auth/logout", content=b"")
|
|
160
|
+
if "Main" not in page.url.path:
|
|
161
|
+
raise RuntimeError(
|
|
162
|
+
f"tenant {tenant!r}: password change not accepted "
|
|
163
|
+
f"(landed on {page.url})"
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
if not _rest_login_works(instance, tenant):
|
|
167
|
+
raise RuntimeError(
|
|
168
|
+
f"tenant {tenant!r}: password change looked accepted but REST "
|
|
169
|
+
"login with the new password failed"
|
|
170
|
+
)
|
|
171
|
+
return "password changed"
|
acumatica_cli/models.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""pydantic is the repo's model standard.
|
|
2
|
+
|
|
3
|
+
Every structured value crossing a boundary (acu.yaml maps, baseline YAML,
|
|
4
|
+
sqlcmd rows) is a frozen pydantic model validated at parse time — unknown
|
|
5
|
+
fields are rejected, not silently carried.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, ConfigDict, ValidationError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Model(BaseModel):
|
|
12
|
+
"""Base for all acu models: immutable, unknown fields are errors."""
|
|
13
|
+
|
|
14
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def validation_summary(exc: ValidationError) -> str:
|
|
18
|
+
"""One line per error: dotted field path + pydantic's message."""
|
|
19
|
+
return "; ".join(
|
|
20
|
+
f"{'.'.join(str(p) for p in err['loc'])}: {err['msg']}" for err in exc.errors()
|
|
21
|
+
)
|
acumatica_cli/output.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""CLI output helpers — the only place `acu` writes to the terminal.
|
|
2
|
+
|
|
3
|
+
Two audiences, one code path (SPEC V9): rich renders color, tables,
|
|
4
|
+
and spinners on a TTY and degrades to plain deterministic text when piped
|
|
5
|
+
(how LLM agents and scripts see it). stdout carries data and results;
|
|
6
|
+
stderr carries status, warnings, and errors.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from collections.abc import Generator, Iterable
|
|
10
|
+
from contextlib import contextmanager
|
|
11
|
+
|
|
12
|
+
from rich import box
|
|
13
|
+
from rich.console import Console
|
|
14
|
+
from rich.table import Table
|
|
15
|
+
|
|
16
|
+
out = Console(markup=False, emoji=False, highlight=False)
|
|
17
|
+
err = Console(stderr=True, markup=False, emoji=False, highlight=False)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def data(msg: str) -> None:
|
|
21
|
+
"""Result line on stdout — what a script or agent consumes."""
|
|
22
|
+
# soft_wrap: a result line must stay one greppable line, never
|
|
23
|
+
# hard-wrapped at console width (long paths, drift lines)
|
|
24
|
+
out.print(msg, soft_wrap=True)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def info(msg: str) -> None:
|
|
28
|
+
"""Progress line on stderr."""
|
|
29
|
+
err.print(msg, style="dim cyan")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def success(msg: str) -> None:
|
|
33
|
+
"""Success line on stderr."""
|
|
34
|
+
err.print(f"+ {msg}", style="green")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def warn(msg: str) -> None:
|
|
38
|
+
"""Warning line on stderr."""
|
|
39
|
+
err.print(f"! {msg}", style="yellow")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def error(msg: str) -> None:
|
|
43
|
+
"""Error line on stderr."""
|
|
44
|
+
err.print(f"x {msg}", style="red")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def table(title: str, columns: Iterable[str], rows: Iterable[Iterable[str]]) -> None:
|
|
48
|
+
"""Table on stdout: ASCII box on a TTY, plain aligned columns piped."""
|
|
49
|
+
t = Table(
|
|
50
|
+
title=title,
|
|
51
|
+
title_justify="left",
|
|
52
|
+
box=box.ASCII if out.is_terminal else None,
|
|
53
|
+
)
|
|
54
|
+
for column in columns:
|
|
55
|
+
t.add_column(column)
|
|
56
|
+
for row in rows:
|
|
57
|
+
t.add_row(*row)
|
|
58
|
+
out.print(t)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@contextmanager
|
|
62
|
+
def step(msg: str) -> Generator[None]:
|
|
63
|
+
"""Long operation: spinner on a TTY, plain stderr line when piped."""
|
|
64
|
+
if err.is_terminal:
|
|
65
|
+
# "line" is the ASCII spinner (-\|/); the default "dots" is braille
|
|
66
|
+
with err.status(msg, spinner="line"):
|
|
67
|
+
yield
|
|
68
|
+
else:
|
|
69
|
+
info(msg)
|
|
70
|
+
yield
|