acumatica-cli 0.2.2__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.
- acumatica_cli-0.2.2/PKG-INFO +187 -0
- acumatica_cli-0.2.2/README.md +172 -0
- acumatica_cli-0.2.2/pyproject.toml +102 -0
- acumatica_cli-0.2.2/src/acumatica_cli/__init__.py +0 -0
- acumatica_cli-0.2.2/src/acumatica_cli/bootstrap.py +105 -0
- acumatica_cli-0.2.2/src/acumatica_cli/bootstrap_plugin.cs +62 -0
- acumatica_cli-0.2.2/src/acumatica_cli/bootstrap_project.xml +117 -0
- acumatica_cli-0.2.2/src/acumatica_cli/cli.py +351 -0
- acumatica_cli-0.2.2/src/acumatica_cli/client.py +251 -0
- acumatica_cli-0.2.2/src/acumatica_cli/config.py +113 -0
- acumatica_cli-0.2.2/src/acumatica_cli/firstlogin.py +171 -0
- acumatica_cli-0.2.2/src/acumatica_cli/models.py +21 -0
- acumatica_cli-0.2.2/src/acumatica_cli/output.py +70 -0
- acumatica_cli-0.2.2/src/acumatica_cli/seed.py +120 -0
- acumatica_cli-0.2.2/src/acumatica_cli/tenant.py +149 -0
|
@@ -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,172 @@
|
|
|
1
|
+
# Acumatica CLI
|
|
2
|
+
|
|
3
|
+
**`acu`** — configure Acumatica ERP purely from source code: no UI clicks,
|
|
4
|
+
no Configuration Wizard.
|
|
5
|
+
|
|
6
|
+
Every operation is idempotent:
|
|
7
|
+
|
|
8
|
+
- `acu apply` upserts records by key — running it twice is safe
|
|
9
|
+
- `acu diff` detects drift between your YAML and the live tenant
|
|
10
|
+
- `acu provision` takes an empty instance to a fully configured tenant in
|
|
11
|
+
one command, and skips every step that is already done
|
|
12
|
+
|
|
13
|
+
## Why?
|
|
14
|
+
|
|
15
|
+
Acumatica configuration normally lives in the web UI: wizards, screens, and
|
|
16
|
+
manual data entry that nobody can review, version, or reproduce. `acu` moves
|
|
17
|
+
that configuration into YAML files in a git repo, so a tenant can be rebuilt
|
|
18
|
+
from scratch, audited in a pull request, and checked for drift like any other
|
|
19
|
+
infrastructure.
|
|
20
|
+
|
|
21
|
+
## Quick start
|
|
22
|
+
|
|
23
|
+
From the data repo:
|
|
24
|
+
|
|
25
|
+
```sh
|
|
26
|
+
acu config show # sanity-check the resolved target
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
```sh
|
|
30
|
+
acu provision --id 3 --login DEV # create → bootstrap → apply → diff
|
|
31
|
+
acu diff baseline/ # later: prove zero drift (exit 2 on drift)
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## CLI Map
|
|
35
|
+
|
|
36
|
+
```text
|
|
37
|
+
acu [-t TENANT]
|
|
38
|
+
│
|
|
39
|
+
├── provision --id N --login NAME one command: create → bootstrap → apply → diff
|
|
40
|
+
│ [--type SalesDemo] [--parent N]
|
|
41
|
+
│
|
|
42
|
+
├── tenant tenant CRUD (ac.exe over SSH — control plane)
|
|
43
|
+
│ ├── list CompanyID, sign-in name, internal CD, type
|
|
44
|
+
│ ├── create create a tenant and make it automation-ready
|
|
45
|
+
│ └── delete delete a tenant and its data, recycle app pool
|
|
46
|
+
│
|
|
47
|
+
├── apply [--dry-run] FILES... seed baseline YAML via REST (idempotent PUT upserts)
|
|
48
|
+
├── diff FILES... drift check: YAML vs live tenant (exit 2 on drift)
|
|
49
|
+
├── schema [--out DIR] dump the endpoint's OpenAPI schema (swagger.json)
|
|
50
|
+
│
|
|
51
|
+
└── config local, read-only — never talks to a live instance
|
|
52
|
+
└── show print the resolved target as a complete acu.yaml
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
The target instance comes from `acu.yaml` (found by walking up from the
|
|
56
|
+
current directory); the one global option `-t/--tenant` overrides the tenant
|
|
57
|
+
that API sessions sign in to. Run `acu <command> --help` for details on any
|
|
58
|
+
command.
|
|
59
|
+
|
|
60
|
+
## Installation
|
|
61
|
+
|
|
62
|
+
## Configuration
|
|
63
|
+
|
|
64
|
+
Configuration is split into three layers, each answering one question:
|
|
65
|
+
|
|
66
|
+
1. **`acu.yaml`** — *where* to apply. The whole file is the target
|
|
67
|
+
instance: a flat map where `host` is the only required key; everything
|
|
68
|
+
else has a code default matching a stock Acumatica install:
|
|
69
|
+
|
|
70
|
+
```yaml
|
|
71
|
+
host: acu-dev1.vm.internal
|
|
72
|
+
tenant: Company # sign-in name of the tenant API sessions use
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Optional overrides (defaults shown) for nonstandard installs,
|
|
76
|
+
split-horizon DNS, port forwards, or jump hosts:
|
|
77
|
+
|
|
78
|
+
`acu config show` prints the fully resolved instance as a complete,
|
|
79
|
+
valid `acu.yaml` — every knob visible, credentials excluded. To turn
|
|
80
|
+
the resolved state into your working config, redirect and edit:
|
|
81
|
+
|
|
82
|
+
```sh
|
|
83
|
+
acu config show > acu.yaml
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
2. **`.env`** — *who* signs in to the REST API. Loaded from the data-repo
|
|
87
|
+
root (or the plain environment):
|
|
88
|
+
|
|
89
|
+
```sh
|
|
90
|
+
ACU_PASSWORD=... # required
|
|
91
|
+
ACU_USER=admin # optional, defaults to admin
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
The data repo stores it encrypted as `.env.gpg`; `make decrypt` produces
|
|
95
|
+
the plaintext `.env` once per clone.
|
|
96
|
+
|
|
97
|
+
3. **SSH** — *how* the control plane reaches the Windows guest. Configured on
|
|
98
|
+
the guest and in your `~/.ssh`, not in `acu.yaml` — see the next section.
|
|
99
|
+
|
|
100
|
+
Verify the result before touching anything live:
|
|
101
|
+
|
|
102
|
+
```sh
|
|
103
|
+
acu config show # the fully resolved instance as acu.yaml, no secrets
|
|
104
|
+
acu apply --dry-run baseline/
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## Two planes
|
|
108
|
+
|
|
109
|
+
`acu` talks to an instance over two independent channels:
|
|
110
|
+
|
|
111
|
+
- **Control plane (SSH):** tenant create/delete/list via `ac.exe
|
|
112
|
+
-cm:CompanyConfig` and `sqlcmd` on the Windows guest — see
|
|
113
|
+
[`docs/ac-exe.md`](docs/ac-exe.md). Used by `acu tenant` and the first step
|
|
114
|
+
of `acu provision`.
|
|
115
|
+
- **Data plane (REST):** baseline configuration and reference data through
|
|
116
|
+
the contract-based API (`/entity/Default/25.200.001/`), where `PUT` is a
|
|
117
|
+
keyed upsert — see [`docs/rest-api.md`](docs/rest-api.md). Used by
|
|
118
|
+
`acu apply`, `acu diff`, and `acu schema`.
|
|
119
|
+
|
|
120
|
+
If you only apply and diff baseline YAML, you never need SSH. SSH setup is
|
|
121
|
+
required only for `acu tenant` and `acu provision`.
|
|
122
|
+
|
|
123
|
+
## SSH setup (control plane)
|
|
124
|
+
|
|
125
|
+
`acu tenant` and `acu provision` run commands on the Windows guest through
|
|
126
|
+
plain `ssh`. Two things about this setup are not obvious, and both are hard
|
|
127
|
+
requirements:
|
|
128
|
+
|
|
129
|
+
**1. The default SSH shell on the Windows guest must be PowerShell.**
|
|
130
|
+
`acu` sends PowerShell syntax over the wire (`& 'C:\...\ac.exe' ...`,
|
|
131
|
+
`Import-Module WebAdministration`, `exit $LASTEXITCODE`). Windows OpenSSH
|
|
132
|
+
ships with `cmd.exe` as the default shell, where every one of those commands
|
|
133
|
+
fails. Switch it once, in an elevated PowerShell on the guest:
|
|
134
|
+
|
|
135
|
+
```powershell
|
|
136
|
+
New-ItemProperty -Path "HKLM:\SOFTWARE\OpenSSH" -Name DefaultShell `
|
|
137
|
+
-Value "C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe" `
|
|
138
|
+
-PropertyType String -Force
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
**2. Authentication must be key-based and non-interactive.**
|
|
142
|
+
`acu` connects with `BatchMode=yes`, so it will never answer a password
|
|
143
|
+
prompt — without a working key the connection just fails. And because the
|
|
144
|
+
default `ssh_user` is `Administrator` (an administrators-group member),
|
|
145
|
+
Windows OpenSSH reads the key from the *machine-wide* file
|
|
146
|
+
`C:\ProgramData\ssh\administrators_authorized_keys` — **not** from
|
|
147
|
+
`~\.ssh\authorized_keys` like on Linux. On the guest:
|
|
148
|
+
|
|
149
|
+
```powershell
|
|
150
|
+
# install + start the server (once)
|
|
151
|
+
Add-WindowsCapability -Online -Name OpenSSH.Server~~~~0.0.1.0
|
|
152
|
+
Set-Service sshd -StartupType Automatic
|
|
153
|
+
Start-Service sshd
|
|
154
|
+
|
|
155
|
+
# authorize your public key for administrators
|
|
156
|
+
Add-Content -Path C:\ProgramData\ssh\administrators_authorized_keys Value "ssh-ed25519 AAAA... you@laptop"
|
|
157
|
+
|
|
158
|
+
# the file must be readable by SYSTEM/Administrators only, or sshd ignores it
|
|
159
|
+
icacls C:\ProgramData\ssh\administrators_authorized_keys /inheritance:r /grant "Administrators:F" /grant "SYSTEM:F"
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
Then verify from your workstation — this one test proves both requirements
|
|
163
|
+
at once (key auth works, and the shell is PowerShell):
|
|
164
|
+
|
|
165
|
+
```sh
|
|
166
|
+
ssh -o BatchMode=yes Administrator@acu-dev1.vm.internal '$PSVersionTable.PSVersion'
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
A version table means you are ready; a password prompt or
|
|
170
|
+
`'$PSVersionTable.PSVersion' is not recognized...` means requirement 2 or 1
|
|
171
|
+
(respectively) is not met yet. Host aliases, jump hosts, and ports go in your
|
|
172
|
+
`~/.ssh/config` as usual, or set `ssh: user@alias` in `acu.yaml`.
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "acumatica-cli"
|
|
3
|
+
version = "0.2.2"
|
|
4
|
+
description = "The acu CLI - Acumatica ERP configuration as code: tenant provisioning, baseline config, and reference data"
|
|
5
|
+
authors = [{ name = "Konstantin Borovik", email = "kb@lab5.ca" }]
|
|
6
|
+
readme = "README.md"
|
|
7
|
+
requires-python = ">=3.12"
|
|
8
|
+
dependencies = [
|
|
9
|
+
"click>=8.1",
|
|
10
|
+
"httpx>=0.27",
|
|
11
|
+
"pydantic>=2.7",
|
|
12
|
+
"python-dotenv>=1.0",
|
|
13
|
+
"pyyaml>=6.0",
|
|
14
|
+
"rich>=13",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
[project.scripts]
|
|
18
|
+
acu = "acumatica_cli.cli:main"
|
|
19
|
+
|
|
20
|
+
[build-system]
|
|
21
|
+
requires = ["uv_build>=0.11,<0.12"]
|
|
22
|
+
build-backend = "uv_build"
|
|
23
|
+
|
|
24
|
+
[dependency-groups]
|
|
25
|
+
dev = [
|
|
26
|
+
"basedpyright>=1.37.0",
|
|
27
|
+
"pytest>=8",
|
|
28
|
+
"ruff>=0.13.0",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
[tool.pytest.ini_options]
|
|
32
|
+
testpaths = ["tests"]
|
|
33
|
+
addopts = '-q -m "not e2e"'
|
|
34
|
+
markers = [
|
|
35
|
+
"e2e: live end-to-end tests against a real Acumatica instance (make e2e)",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
[tool.basedpyright]
|
|
39
|
+
typeCheckingMode = "strict"
|
|
40
|
+
pythonVersion = "3.12"
|
|
41
|
+
include = ["src/acumatica_cli/", "tests/"]
|
|
42
|
+
reportMissingTypeStubs = false
|
|
43
|
+
reportUnknownVariableType = false
|
|
44
|
+
reportUnknownMemberType = false
|
|
45
|
+
reportUnknownArgumentType = false
|
|
46
|
+
reportUnknownParameterType = false
|
|
47
|
+
reportUnknownLambdaType = false
|
|
48
|
+
reportUnusedFunction = "warning"
|
|
49
|
+
|
|
50
|
+
[tool.ruff]
|
|
51
|
+
target-version = "py312"
|
|
52
|
+
src = ["src"]
|
|
53
|
+
|
|
54
|
+
[tool.ruff.format]
|
|
55
|
+
docstring-code-format = true
|
|
56
|
+
|
|
57
|
+
[tool.ruff.lint]
|
|
58
|
+
fixable = ["ALL"]
|
|
59
|
+
select = [
|
|
60
|
+
"B", # flake8-bugbear
|
|
61
|
+
"C4", # flake8-comprehensions
|
|
62
|
+
"C90", # mccabe
|
|
63
|
+
"COM", # flake8-commas
|
|
64
|
+
"D", # pydocstyle
|
|
65
|
+
"E", # pycodestyle
|
|
66
|
+
"F", # pyflakes
|
|
67
|
+
"I", # isort
|
|
68
|
+
"N", # pep8-naming
|
|
69
|
+
"PL", # Pylint
|
|
70
|
+
"PT", # flake8-pytest-style
|
|
71
|
+
"RUF", # Ruff-specific rules
|
|
72
|
+
"SIM", # flake8-simplify
|
|
73
|
+
"T20", # flake8-print: no bare print(); use acumatica_cli.output
|
|
74
|
+
"TID", # flake8-tidy-imports
|
|
75
|
+
"UP", # pyupgrade
|
|
76
|
+
"W", # pycodestyle warnings
|
|
77
|
+
]
|
|
78
|
+
ignore = [
|
|
79
|
+
"PLR2004", # magic values in comparisons
|
|
80
|
+
"PLC0415", # import not at top-level (lazy imports)
|
|
81
|
+
"D100", # Missing docstring in public module
|
|
82
|
+
"D104", # Missing docstring in public package
|
|
83
|
+
"D105", # Missing docstring in magic method
|
|
84
|
+
"D107", # Missing docstring in __init__
|
|
85
|
+
"COM812", # trailing commas
|
|
86
|
+
]
|
|
87
|
+
|
|
88
|
+
[tool.ruff.lint.pydocstyle]
|
|
89
|
+
convention = "google"
|
|
90
|
+
|
|
91
|
+
[tool.ruff.lint.isort]
|
|
92
|
+
known-first-party = ["acumatica_cli"]
|
|
93
|
+
|
|
94
|
+
[tool.ruff.lint.per-file-ignores]
|
|
95
|
+
"src/acumatica_cli/cli.py" = [
|
|
96
|
+
"PLR0913", # Click commands have many parameters by design
|
|
97
|
+
]
|
|
98
|
+
"tests/*" = [
|
|
99
|
+
"D101", # test classes/functions are named descriptively instead
|
|
100
|
+
"D102",
|
|
101
|
+
"D103",
|
|
102
|
+
]
|
|
File without changes
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Bootstrap customization package: build the zip, publish via /CustomizationApi.
|
|
2
|
+
|
|
3
|
+
An unconfigured tenant cannot be configured through the Default endpoint
|
|
4
|
+
(features are off, no company/branch exists, credit terms have no entity —
|
|
5
|
+
docs/rest-api.md). The CustomizationApi is the one door that works on a
|
|
6
|
+
virgin tenant, so bootstrap = publish a package whose CustomizationPlugin
|
|
7
|
+
(`bootstrap_plugin.cs`) enables features on publish — the contract API
|
|
8
|
+
cannot write CS100000 at all (T3 verdict) — and whose Bootstrap contract
|
|
9
|
+
endpoint exposes CS101500 company + CS206500 credit terms for seeding
|
|
10
|
+
(`bootstrap_project.xml`, serialization verified T12).
|
|
11
|
+
|
|
12
|
+
Customization publishes are tenant-scoped, so the package must be published
|
|
13
|
+
per tenant; publish() is idempotent — an already-published package is a
|
|
14
|
+
no-op skip, and the plugin's UpdateDatabase is a keyed update on re-run.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import io
|
|
18
|
+
import time
|
|
19
|
+
import xml.etree.ElementTree as ET
|
|
20
|
+
import zipfile
|
|
21
|
+
from importlib import resources
|
|
22
|
+
|
|
23
|
+
import httpx
|
|
24
|
+
|
|
25
|
+
from .client import AcumaticaClient
|
|
26
|
+
|
|
27
|
+
# Alphanumeric only: CstDbStorage.ValidatePackageName rejects '-' and '_'
|
|
28
|
+
# (verified vs 26.101.0225 — "Invalid project name")
|
|
29
|
+
PACKAGE_NAME = "AcuBootstrap"
|
|
30
|
+
PACKAGE_DESCRIPTION = (
|
|
31
|
+
"acu bootstrap: CustomizationPlugin enables features on publish; "
|
|
32
|
+
"Bootstrap endpoint exposes company + credit terms"
|
|
33
|
+
)
|
|
34
|
+
PLUGIN_CLASS = "AcuBootstrapPlugin"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def package_zip() -> bytes:
|
|
38
|
+
"""Build the customization package: a zip holding project.xml.
|
|
39
|
+
|
|
40
|
+
The C# plugin travels as a <Graph> item whose Source ATTRIBUTE holds the
|
|
41
|
+
file content (Customization.CstCodeFile shape, verified vs 26.101.0225:
|
|
42
|
+
inline CDATA and zip-file variants are silently dropped on import).
|
|
43
|
+
ElementTree escapes the newlines as on serialization.
|
|
44
|
+
"""
|
|
45
|
+
pkg = resources.files("acumatica_cli")
|
|
46
|
+
root = ET.fromstring((pkg / "bootstrap_project.xml").read_bytes())
|
|
47
|
+
graph = ET.SubElement(root, "Graph")
|
|
48
|
+
graph.set("ClassName", PLUGIN_CLASS)
|
|
49
|
+
graph.set("FileType", "NewFile")
|
|
50
|
+
graph.set("Source", (pkg / "bootstrap_plugin.cs").read_text(encoding="utf-8"))
|
|
51
|
+
buf = io.BytesIO()
|
|
52
|
+
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
|
|
53
|
+
zf.writestr("project.xml", ET.tostring(root, encoding="utf-8"))
|
|
54
|
+
return buf.getvalue()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _log_tail(status: dict[str, object], limit: int = 5) -> str:
|
|
58
|
+
"""Last few publish-log messages, for a one-line error (SPEC V9)."""
|
|
59
|
+
log = status.get("log")
|
|
60
|
+
if not isinstance(log, list):
|
|
61
|
+
return ""
|
|
62
|
+
messages = [
|
|
63
|
+
str(entry.get("message", "")) for entry in log if isinstance(entry, dict)
|
|
64
|
+
]
|
|
65
|
+
return "; ".join(m for m in messages[-limit:] if m)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def publish(client: AcumaticaClient, timeout: float = 600.0, poll: float = 5.0) -> str:
|
|
69
|
+
"""Publish the bootstrap package into the client's session tenant.
|
|
70
|
+
|
|
71
|
+
Idempotent: returns ``"already published"`` when getPublished lists the
|
|
72
|
+
package AND its content still exists (a recreated tenant with the same
|
|
73
|
+
CompanyID keeps the stale publication row while the content and the
|
|
74
|
+
plugin's writes are gone), else import -> publishBegin -> poll publishEnd
|
|
75
|
+
until the server reports completion. Transport errors while polling are
|
|
76
|
+
tolerated — publishing restarts the app domain, so the site may briefly
|
|
77
|
+
drop connections mid-publish. Raises RuntimeError on a failed publish or
|
|
78
|
+
on timeout.
|
|
79
|
+
"""
|
|
80
|
+
if PACKAGE_NAME in client.customization_published() and (
|
|
81
|
+
client.customization_project_exists(PACKAGE_NAME)
|
|
82
|
+
):
|
|
83
|
+
return "already published"
|
|
84
|
+
client.customization_import(
|
|
85
|
+
PACKAGE_NAME, package_zip(), description=PACKAGE_DESCRIPTION
|
|
86
|
+
)
|
|
87
|
+
client.customization_publish_begin([PACKAGE_NAME])
|
|
88
|
+
deadline = time.monotonic() + timeout
|
|
89
|
+
while True:
|
|
90
|
+
try:
|
|
91
|
+
status = client.customization_publish_end()
|
|
92
|
+
except httpx.TransportError:
|
|
93
|
+
status = {} # site restarting mid-publish; keep polling
|
|
94
|
+
if status.get("isFailed"):
|
|
95
|
+
detail = _log_tail(status)
|
|
96
|
+
raise RuntimeError(
|
|
97
|
+
f"publishing {PACKAGE_NAME} failed" + (f": {detail}" if detail else "")
|
|
98
|
+
)
|
|
99
|
+
if status.get("isCompleted"):
|
|
100
|
+
return "published"
|
|
101
|
+
if time.monotonic() >= deadline:
|
|
102
|
+
raise RuntimeError(
|
|
103
|
+
f"publishing {PACKAGE_NAME} did not complete within {timeout:.0f}s"
|
|
104
|
+
)
|
|
105
|
+
time.sleep(poll)
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// acu bootstrap CustomizationPlugin (SPEC T11).
|
|
2
|
+
//
|
|
3
|
+
// Runs in-process on customization publish - the one write path to
|
|
4
|
+
// FeaturesSet that works: the contract API cannot persist features no
|
|
5
|
+
// matter what endpoint fronts CS100000 (T3 verdict, docs/rest-api.md).
|
|
6
|
+
//
|
|
7
|
+
// Deliberately writes through PXDatabase, not FeaturesMaint: the graph
|
|
8
|
+
// save collides with the publish pipeline's concurrent plugin invocations
|
|
9
|
+
// ("Another process has added the 'FeaturesSet' record" - observed live,
|
|
10
|
+
// 26.101.0225) and nothing persists. A keyed row write is deterministic
|
|
11
|
+
// and idempotent: update the existing row, insert when absent. All 205
|
|
12
|
+
// NOT NULL bit columns are filled reflectively (only ~136 carry DB
|
|
13
|
+
// defaults); Status = 0 means Validated (PXIntList, verified vs live).
|
|
14
|
+
using System.Collections.Generic;
|
|
15
|
+
using System.Reflection;
|
|
16
|
+
using PX.Data;
|
|
17
|
+
using PX.Objects.CS;
|
|
18
|
+
|
|
19
|
+
namespace AcuBootstrap
|
|
20
|
+
{
|
|
21
|
+
public class AcuBootstrapPlugin : Customization.CustomizationPlugin
|
|
22
|
+
{
|
|
23
|
+
private static readonly HashSet<string> Enabled = new HashSet<string>
|
|
24
|
+
{
|
|
25
|
+
"FinancialModule",
|
|
26
|
+
"FinancialStandard",
|
|
27
|
+
"DistributionModule",
|
|
28
|
+
"Inventory",
|
|
29
|
+
"Branch",
|
|
30
|
+
"MultiCompany",
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
public override void UpdateDatabase()
|
|
34
|
+
{
|
|
35
|
+
var flags = new List<PXDataFieldAssign>();
|
|
36
|
+
foreach (PropertyInfo prop in typeof(FeaturesSet).GetProperties())
|
|
37
|
+
{
|
|
38
|
+
if (prop.PropertyType == typeof(bool?))
|
|
39
|
+
{
|
|
40
|
+
flags.Add(new PXDataFieldAssign(
|
|
41
|
+
prop.Name, PXDbType.Bit, Enabled.Contains(prop.Name)));
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
flags.Add(new PXDataFieldAssign("Status", PXDbType.Int, 0)); // Validated
|
|
45
|
+
|
|
46
|
+
using (var tx = new PXTransactionScope())
|
|
47
|
+
{
|
|
48
|
+
bool updated = PXDatabase.Update<FeaturesSet>(flags.ToArray());
|
|
49
|
+
if (!updated)
|
|
50
|
+
{
|
|
51
|
+
PXDatabase.Insert<FeaturesSet>(flags.ToArray());
|
|
52
|
+
WriteLog("AcuBootstrap: FeaturesSet row inserted (features enabled)");
|
|
53
|
+
}
|
|
54
|
+
else
|
|
55
|
+
{
|
|
56
|
+
WriteLog("AcuBootstrap: FeaturesSet row updated (features enabled)");
|
|
57
|
+
}
|
|
58
|
+
tx.Complete();
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|