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/seed.py ADDED
@@ -0,0 +1,120 @@
1
+ """Reference data as code: baseline/*.yaml applied to the live tenant.
2
+
3
+ apply = entity PUTs (upsert by key); diff = live-vs-source comparison
4
+ (the drift proof).
5
+
6
+ Baseline file format:
7
+
8
+ entity: Currency # entity name in the contract endpoint
9
+ key: CurrencyID # key field(s), string or list
10
+ endpoint: Bootstrap/1.0.0 # optional: override the instance endpoint
11
+ records:
12
+ - CurrencyID: "CAD"
13
+ Description: Canadian Dollar
14
+ """
15
+
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+ import yaml
20
+ from pydantic import Field, ValidationError, field_validator, model_validator
21
+
22
+ from . import output
23
+ from .client import AcumaticaClient, unwrap
24
+ from .models import Model, validation_summary
25
+
26
+
27
+ class BaselineFile(Model):
28
+ """A parsed baseline YAML: one entity, its key fields, its records."""
29
+
30
+ path: Path
31
+ entity: str
32
+ keys: list[str] = Field(alias="key")
33
+ records: list[dict[str, Any]]
34
+ endpoint: str | None = None # bootstrap YAML targets the custom endpoint
35
+
36
+ @field_validator("keys", mode="before")
37
+ @classmethod
38
+ def _key_as_list(cls, v: object) -> object:
39
+ return v if isinstance(v, list) else [v]
40
+
41
+ @model_validator(mode="after")
42
+ def _records_carry_keys(self) -> "BaselineFile":
43
+ for i, record in enumerate(self.records):
44
+ for k in self.keys:
45
+ if k not in record:
46
+ raise ValueError(f"records[{i}] missing key field '{k}'")
47
+ return self
48
+
49
+
50
+ def load_baseline(path: Path) -> BaselineFile:
51
+ """Parse and validate one baseline YAML file."""
52
+ with open(path) as f:
53
+ data = yaml.safe_load(f)
54
+ if not isinstance(data, dict):
55
+ raise SystemExit(f"{path}: expected a mapping at the top level")
56
+ try:
57
+ return BaselineFile.model_validate({"path": path, **data})
58
+ except ValidationError as exc:
59
+ raise SystemExit(f"{path}: {validation_summary(exc)}") from exc
60
+
61
+
62
+ def _norm(value: Any) -> str:
63
+ """Comparable form: bools case-folded, numbers by value, rest stringified.
64
+
65
+ Numbers compare by value, not spelling - a YAML `0` against the
66
+ endpoint's `0.0` (DecimalValue fields come back as floats) is not
67
+ drift (T13).
68
+ """
69
+ if isinstance(value, bool):
70
+ return str(value).lower()
71
+ if isinstance(value, int | float):
72
+ return repr(float(value))
73
+ return str(value).strip()
74
+
75
+
76
+ def _filter_for(record: dict[str, Any], keys: list[str]) -> str:
77
+ return " and ".join(f"{k} eq '{record[k]}'" for k in keys)
78
+
79
+
80
+ def apply(
81
+ client: AcumaticaClient, baseline: BaselineFile, dry_run: bool = False
82
+ ) -> int:
83
+ """PUT every record (upsert by key). Returns the record count."""
84
+ for record in baseline.records:
85
+ label = ", ".join(str(record[k]) for k in baseline.keys)
86
+ if dry_run:
87
+ output.data(f" would PUT {baseline.entity} [{label}]")
88
+ else:
89
+ client.put(baseline.entity, record, endpoint=baseline.endpoint)
90
+ output.data(f" PUT {baseline.entity} [{label}]")
91
+ return len(baseline.records)
92
+
93
+
94
+ def diff(client: AcumaticaClient, baseline: BaselineFile) -> list[str]:
95
+ """Compare each source record against the live tenant.
96
+
97
+ Returns human-readable drift lines (empty = no drift).
98
+ """
99
+ drifts: list[str] = []
100
+ for record in baseline.records:
101
+ label = (
102
+ f"{baseline.entity} [{', '.join(str(record[k]) for k in baseline.keys)}]"
103
+ )
104
+ live = client.get_list(
105
+ baseline.entity,
106
+ params={"$filter": _filter_for(record, baseline.keys)},
107
+ endpoint=baseline.endpoint,
108
+ )
109
+ if not live:
110
+ drifts.append(f"{label}: missing on tenant")
111
+ continue
112
+ actual = unwrap(live[0])
113
+ for field, expected in record.items():
114
+ if field not in actual:
115
+ drifts.append(f"{label}.{field}: not returned by endpoint")
116
+ elif _norm(actual[field]) != _norm(expected):
117
+ drifts.append(
118
+ f"{label}.{field}: source={expected!r} live={actual[field]!r}"
119
+ )
120
+ return drifts
@@ -0,0 +1,149 @@
1
+ """Tenant CRUD on the instance.
2
+
3
+ Thin SSH wrapper around ac.exe CompanyConfig (the only supported CLI route,
4
+ see docs/ac-exe.md) plus a sqlcmd read side.
5
+ """
6
+
7
+ import subprocess
8
+
9
+ from .config import Instance
10
+ from .models import Model
11
+
12
+
13
+ class Tenant(Model):
14
+ """One row of the instance's Company table.
15
+
16
+ login_name is dbo.Company.CompanyKey — the name on the sign-in page and
17
+ the REST login's tenant value. company_cd is the internal auto-generated
18
+ code (ac.exe ignores LoginName for it; see docs/ac-exe.md).
19
+ """
20
+
21
+ company_id: int
22
+ company_cd: str
23
+ login_name: str
24
+ company_type: str
25
+
26
+
27
+ class TenantManager:
28
+ """Tenant CRUD via SSH to the Windows guest."""
29
+
30
+ def __init__(self, instance: Instance):
31
+ self.instance = instance
32
+
33
+ def _ssh(self, command: str) -> str:
34
+ # PowerShell-over-ssh returns 0 even when the native command fails;
35
+ # appending the exit here (single choke point, never at call sites)
36
+ # makes every remote failure propagate (SPEC V18, B4).
37
+ r = subprocess.run(
38
+ [
39
+ "ssh",
40
+ "-o",
41
+ "BatchMode=yes",
42
+ self.instance.ssh,
43
+ command + "\nexit $LASTEXITCODE",
44
+ ],
45
+ capture_output=True,
46
+ text=True,
47
+ check=False,
48
+ )
49
+ if r.returncode != 0:
50
+ raise RuntimeError(
51
+ f"remote command failed ({r.returncode}):\n{r.stdout}\n{r.stderr}"
52
+ )
53
+ return r.stdout
54
+
55
+ def list(self) -> list[Tenant]:
56
+ """Read tenants from the Company table (sqlcmd over SSH)."""
57
+ query = (
58
+ "SET NOCOUNT ON; "
59
+ "SELECT CompanyID, CompanyCD, ISNULL(CompanyKey, ''), CompanyType "
60
+ f"FROM {self.instance.db_name}.dbo.Company ORDER BY CompanyID"
61
+ )
62
+ out = self._ssh(f'sqlcmd -S "(local)" -E -C -W -h -1 -s "|" -Q "{query}"')
63
+ tenants = []
64
+ for line in out.splitlines():
65
+ parts = [p.strip() for p in line.split("|")]
66
+ if len(parts) == 4 and parts[0].isdigit():
67
+ tenants.append(
68
+ Tenant(
69
+ company_id=int(parts[0]),
70
+ company_cd=parts[1],
71
+ login_name=parts[2],
72
+ company_type=parts[3],
73
+ )
74
+ )
75
+ return tenants
76
+
77
+ def recycle_app_pool(self) -> None:
78
+ """Recycle the IIS app pool so the running app reloads its tenant map.
79
+
80
+ The tenant map loads at app start; without this, tenants created or
81
+ deleted by CompanyConfig stay invisible to the sign-in page and REST
82
+ routing silently falls back to the default tenant (docs/rest-api.md).
83
+ The pool is named after the instance (how acumatica-infra builds it).
84
+ """
85
+ self._ssh(
86
+ "Import-Module WebAdministration; "
87
+ f"Restart-WebAppPool -Name '{self.instance.instance_name}'"
88
+ )
89
+
90
+ def _company_config(self, company: str, extra: str = "") -> str:
91
+ # -iname AND -h are both required: without -iname CompanyConfig can't
92
+ # find the site; without -h its web.config step dies on a null path
93
+ # (verified 2026-07-08, docs/ac-exe.md).
94
+ return self._ssh(
95
+ f"& '{self.instance.ac_exe}' "
96
+ f'-configmode:"CompanyConfig" -output:"Forced" '
97
+ f'-iname:"{self.instance.instance_name}" '
98
+ f'-h:"{self.instance.instance_path}" '
99
+ f'-dbsrvname:"(local)" -dbsrvwinauth:"True" '
100
+ f'-dbname:"{self.instance.db_name}" -dbnew:"False" '
101
+ f'-company:"{company}"{extra}'
102
+ )
103
+
104
+ def create(
105
+ self,
106
+ company_id: int,
107
+ login_name: str,
108
+ parent_id: int = 1,
109
+ visible: bool = True,
110
+ company_type: str = "",
111
+ ) -> str:
112
+ """Create (or, for an existing CompanyID, overwrite!) a tenant.
113
+
114
+ company_type: '' = clean tenant, 'SalesDemo' = demo data. The admin
115
+ flags preset the tenant's admin to the instance credentials with no
116
+ must-change flag, so the tenant is REST-loginable right after the
117
+ app-pool recycle — no first-login dance (verified, docs/ac-exe.md).
118
+ """
119
+ company = (
120
+ f"CompanyID={company_id};ParentID={parent_id};"
121
+ f"Visible={'Yes' if visible else 'No'};"
122
+ f"CompanyType={company_type};LoginName={login_name};"
123
+ )
124
+ admin = (
125
+ f' -aun:"{self.instance.username}" '
126
+ f'-aup:"{self.instance.password}" -auc:"False"'
127
+ )
128
+ return self._company_config(company, extra=admin)
129
+
130
+ def delete(self, company_id: int) -> str:
131
+ """Delete the tenant and all its data.
132
+
133
+ Two verified gotchas (docs/ac-exe.md):
134
+
135
+ - The sub-key is ``Deleted``, not the officially documented
136
+ ``Delete`` — an unknown key is silently ignored and the run degrades
137
+ into a *destructive data re-insert* into the tenant.
138
+ - The full spec (``ParentID`` + ``CompanyType``) must be passed. With
139
+ only ``CompanyID;Deleted=Yes`` the preflight reads an empty
140
+ CompanyType as the system tenant and aborts with "The system company
141
+ cannot be removed."
142
+ """
143
+ target = next((t for t in self.list() if t.company_id == company_id), None)
144
+ if target is None:
145
+ raise RuntimeError(f"no tenant with CompanyID={company_id}")
146
+ return self._company_config(
147
+ f"CompanyID={company_id};ParentID=1;"
148
+ f"CompanyType={target.company_type};Deleted=Yes;"
149
+ )
@@ -0,0 +1,187 @@
1
+ Metadata-Version: 2.3
2
+ Name: acumatica-cli
3
+ Version: 0.2.2
4
+ Summary: The acu CLI - Acumatica ERP configuration as code: tenant provisioning, baseline config, and reference data
5
+ Author: Konstantin Borovik
6
+ Author-email: Konstantin Borovik <kb@lab5.ca>
7
+ Requires-Dist: click>=8.1
8
+ Requires-Dist: httpx>=0.27
9
+ Requires-Dist: pydantic>=2.7
10
+ Requires-Dist: python-dotenv>=1.0
11
+ Requires-Dist: pyyaml>=6.0
12
+ Requires-Dist: rich>=13
13
+ Requires-Python: >=3.12
14
+ Description-Content-Type: text/markdown
15
+
16
+ # Acumatica CLI
17
+
18
+ **`acu`** — configure Acumatica ERP purely from source code: no UI clicks,
19
+ no Configuration Wizard.
20
+
21
+ Every operation is idempotent:
22
+
23
+ - `acu apply` upserts records by key — running it twice is safe
24
+ - `acu diff` detects drift between your YAML and the live tenant
25
+ - `acu provision` takes an empty instance to a fully configured tenant in
26
+ one command, and skips every step that is already done
27
+
28
+ ## Why?
29
+
30
+ Acumatica configuration normally lives in the web UI: wizards, screens, and
31
+ manual data entry that nobody can review, version, or reproduce. `acu` moves
32
+ that configuration into YAML files in a git repo, so a tenant can be rebuilt
33
+ from scratch, audited in a pull request, and checked for drift like any other
34
+ infrastructure.
35
+
36
+ ## Quick start
37
+
38
+ From the data repo:
39
+
40
+ ```sh
41
+ acu config show # sanity-check the resolved target
42
+ ```
43
+
44
+ ```sh
45
+ acu provision --id 3 --login DEV # create → bootstrap → apply → diff
46
+ acu diff baseline/ # later: prove zero drift (exit 2 on drift)
47
+ ```
48
+
49
+ ## CLI Map
50
+
51
+ ```text
52
+ acu [-t TENANT]
53
+
54
+ ├── provision --id N --login NAME one command: create → bootstrap → apply → diff
55
+ │ [--type SalesDemo] [--parent N]
56
+
57
+ ├── tenant tenant CRUD (ac.exe over SSH — control plane)
58
+ │ ├── list CompanyID, sign-in name, internal CD, type
59
+ │ ├── create create a tenant and make it automation-ready
60
+ │ └── delete delete a tenant and its data, recycle app pool
61
+
62
+ ├── apply [--dry-run] FILES... seed baseline YAML via REST (idempotent PUT upserts)
63
+ ├── diff FILES... drift check: YAML vs live tenant (exit 2 on drift)
64
+ ├── schema [--out DIR] dump the endpoint's OpenAPI schema (swagger.json)
65
+
66
+ └── config local, read-only — never talks to a live instance
67
+ └── show print the resolved target as a complete acu.yaml
68
+ ```
69
+
70
+ The target instance comes from `acu.yaml` (found by walking up from the
71
+ current directory); the one global option `-t/--tenant` overrides the tenant
72
+ that API sessions sign in to. Run `acu <command> --help` for details on any
73
+ command.
74
+
75
+ ## Installation
76
+
77
+ ## Configuration
78
+
79
+ Configuration is split into three layers, each answering one question:
80
+
81
+ 1. **`acu.yaml`** — *where* to apply. The whole file is the target
82
+ instance: a flat map where `host` is the only required key; everything
83
+ else has a code default matching a stock Acumatica install:
84
+
85
+ ```yaml
86
+ host: acu-dev1.vm.internal
87
+ tenant: Company # sign-in name of the tenant API sessions use
88
+ ```
89
+
90
+ Optional overrides (defaults shown) for nonstandard installs,
91
+ split-horizon DNS, port forwards, or jump hosts:
92
+
93
+ `acu config show` prints the fully resolved instance as a complete,
94
+ valid `acu.yaml` — every knob visible, credentials excluded. To turn
95
+ the resolved state into your working config, redirect and edit:
96
+
97
+ ```sh
98
+ acu config show > acu.yaml
99
+ ```
100
+
101
+ 2. **`.env`** — *who* signs in to the REST API. Loaded from the data-repo
102
+ root (or the plain environment):
103
+
104
+ ```sh
105
+ ACU_PASSWORD=... # required
106
+ ACU_USER=admin # optional, defaults to admin
107
+ ```
108
+
109
+ The data repo stores it encrypted as `.env.gpg`; `make decrypt` produces
110
+ the plaintext `.env` once per clone.
111
+
112
+ 3. **SSH** — *how* the control plane reaches the Windows guest. Configured on
113
+ the guest and in your `~/.ssh`, not in `acu.yaml` — see the next section.
114
+
115
+ Verify the result before touching anything live:
116
+
117
+ ```sh
118
+ acu config show # the fully resolved instance as acu.yaml, no secrets
119
+ acu apply --dry-run baseline/
120
+ ```
121
+
122
+ ## Two planes
123
+
124
+ `acu` talks to an instance over two independent channels:
125
+
126
+ - **Control plane (SSH):** tenant create/delete/list via `ac.exe
127
+ -cm:CompanyConfig` and `sqlcmd` on the Windows guest — see
128
+ [`docs/ac-exe.md`](docs/ac-exe.md). Used by `acu tenant` and the first step
129
+ of `acu provision`.
130
+ - **Data plane (REST):** baseline configuration and reference data through
131
+ the contract-based API (`/entity/Default/25.200.001/`), where `PUT` is a
132
+ keyed upsert — see [`docs/rest-api.md`](docs/rest-api.md). Used by
133
+ `acu apply`, `acu diff`, and `acu schema`.
134
+
135
+ If you only apply and diff baseline YAML, you never need SSH. SSH setup is
136
+ required only for `acu tenant` and `acu provision`.
137
+
138
+ ## SSH setup (control plane)
139
+
140
+ `acu tenant` and `acu provision` run commands on the Windows guest through
141
+ plain `ssh`. Two things about this setup are not obvious, and both are hard
142
+ requirements:
143
+
144
+ **1. The default SSH shell on the Windows guest must be PowerShell.**
145
+ `acu` sends PowerShell syntax over the wire (`& 'C:\...\ac.exe' ...`,
146
+ `Import-Module WebAdministration`, `exit $LASTEXITCODE`). Windows OpenSSH
147
+ ships with `cmd.exe` as the default shell, where every one of those commands
148
+ fails. Switch it once, in an elevated PowerShell on the guest:
149
+
150
+ ```powershell
151
+ New-ItemProperty -Path "HKLM:\SOFTWARE\OpenSSH" -Name DefaultShell `
152
+ -Value "C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe" `
153
+ -PropertyType String -Force
154
+ ```
155
+
156
+ **2. Authentication must be key-based and non-interactive.**
157
+ `acu` connects with `BatchMode=yes`, so it will never answer a password
158
+ prompt — without a working key the connection just fails. And because the
159
+ default `ssh_user` is `Administrator` (an administrators-group member),
160
+ Windows OpenSSH reads the key from the *machine-wide* file
161
+ `C:\ProgramData\ssh\administrators_authorized_keys` — **not** from
162
+ `~\.ssh\authorized_keys` like on Linux. On the guest:
163
+
164
+ ```powershell
165
+ # install + start the server (once)
166
+ Add-WindowsCapability -Online -Name OpenSSH.Server~~~~0.0.1.0
167
+ Set-Service sshd -StartupType Automatic
168
+ Start-Service sshd
169
+
170
+ # authorize your public key for administrators
171
+ Add-Content -Path C:\ProgramData\ssh\administrators_authorized_keys Value "ssh-ed25519 AAAA... you@laptop"
172
+
173
+ # the file must be readable by SYSTEM/Administrators only, or sshd ignores it
174
+ icacls C:\ProgramData\ssh\administrators_authorized_keys /inheritance:r /grant "Administrators:F" /grant "SYSTEM:F"
175
+ ```
176
+
177
+ Then verify from your workstation — this one test proves both requirements
178
+ at once (key auth works, and the shell is PowerShell):
179
+
180
+ ```sh
181
+ ssh -o BatchMode=yes Administrator@acu-dev1.vm.internal '$PSVersionTable.PSVersion'
182
+ ```
183
+
184
+ A version table means you are ready; a password prompt or
185
+ `'$PSVersionTable.PSVersion' is not recognized...` means requirement 2 or 1
186
+ (respectively) is not met yet. Host aliases, jump hosts, and ports go in your
187
+ `~/.ssh/config` as usual, or set `ssh: user@alias` in `acu.yaml`.
@@ -0,0 +1,16 @@
1
+ acumatica_cli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ acumatica_cli/bootstrap.py,sha256=zCYRqwRLbG4bFgrrwQgxzrtVmtkaujhseKld088Nycw,4411
3
+ acumatica_cli/bootstrap_plugin.cs,sha256=Z3DSemqPY9jmGQUe4n95pbBiRJLJJSW-OJSXULiIJm0,2358
4
+ acumatica_cli/bootstrap_project.xml,sha256=V3WIFX-jZa6wk2uEZduhlvto0aFVHQWFyPp6K52jjC8,5842
5
+ acumatica_cli/cli.py,sha256=ab9XOZZX9qAzg3IjpNnco9QCnciOJZQE6oaHa5GADaM,12685
6
+ acumatica_cli/client.py,sha256=oSQq2f9AG11h0YoyJ0jNA2A9URoMknuAkKn2gGvxyt4,10095
7
+ acumatica_cli/config.py,sha256=ZUDYYOhjTq6kel3rp4Kms_ot5vRp7C0zsZfVPnXkT44,4011
8
+ acumatica_cli/firstlogin.py,sha256=6lW29p2IKHjMvqG7oYvGwibiq0TGZZ0yekg5muxJwrI,6320
9
+ acumatica_cli/models.py,sha256=hwQPWtIVPt6A4pEV6bXV2sLTU5KZ7nDwIGzJ09mQyUw,701
10
+ acumatica_cli/output.py,sha256=2cGSUiBAW0sDQkFIDyKoYOvqTCUc62CjTzixz6WVEdM,2058
11
+ acumatica_cli/seed.py,sha256=wl04PmlKuj4Hn0kvPnpsLGEWe_abZMfAQrJg_EE4E_c,4097
12
+ acumatica_cli/tenant.py,sha256=VIRdiWiqKb7CHcVuw1CzTCfVLcVxBj5bTMM9JyMVJrY,5624
13
+ acumatica_cli-0.2.2.dist-info/WHEEL,sha256=CoDSoyhtC_eO_tlxRYzsTraPv1fPJRXFx91k6ISeAvA,81
14
+ acumatica_cli-0.2.2.dist-info/entry_points.txt,sha256=0DVtG5zKps5rMtP4AuppfHvXFvh5x2WZk0y8-bR1Ohc,48
15
+ acumatica_cli-0.2.2.dist-info/METADATA,sha256=4_-xg67y1WhShxUX8jiTh2TxCi0NmQDuNCpT8FnlARw,7194
16
+ acumatica_cli-0.2.2.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.11.28
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ acu = acumatica_cli.cli:main
3
+