acumatica-cli 0.2.2__tar.gz → 0.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. acumatica_cli-0.4.0/PKG-INFO +188 -0
  2. acumatica_cli-0.4.0/README.md +171 -0
  3. {acumatica_cli-0.2.2 → acumatica_cli-0.4.0}/pyproject.toml +7 -3
  4. acumatica_cli-0.4.0/src/acumatica_cli/bootstrap.py +215 -0
  5. {acumatica_cli-0.2.2 → acumatica_cli-0.4.0}/src/acumatica_cli/bootstrap_plugin.cs +18 -6
  6. acumatica_cli-0.4.0/src/acumatica_cli/bootstrap_project.xml +460 -0
  7. acumatica_cli-0.4.0/src/acumatica_cli/cli.py +631 -0
  8. {acumatica_cli-0.2.2 → acumatica_cli-0.4.0}/src/acumatica_cli/client.py +96 -9
  9. acumatica_cli-0.4.0/src/acumatica_cli/config.py +182 -0
  10. acumatica_cli-0.4.0/src/acumatica_cli/extract.py +503 -0
  11. acumatica_cli-0.4.0/src/acumatica_cli/extract_manifest.yaml +151 -0
  12. {acumatica_cli-0.2.2 → acumatica_cli-0.4.0}/src/acumatica_cli/firstlogin.py +3 -3
  13. {acumatica_cli-0.2.2 → acumatica_cli-0.4.0}/src/acumatica_cli/models.py +6 -3
  14. {acumatica_cli-0.2.2 → acumatica_cli-0.4.0}/src/acumatica_cli/output.py +3 -1
  15. acumatica_cli-0.4.0/src/acumatica_cli/seed.py +299 -0
  16. acumatica_cli-0.4.0/src/acumatica_cli/templates/baseline/10-subaccounts.yaml +15 -0
  17. acumatica_cli-0.4.0/src/acumatica_cli/templates/baseline/20-accounts.yaml +94 -0
  18. acumatica_cli-0.4.0/src/acumatica_cli/templates/baseline/40-ledger.yaml +15 -0
  19. acumatica_cli-0.4.0/src/acumatica_cli/templates/baseline/50-gl-preferences.yaml +18 -0
  20. acumatica_cli-0.4.0/src/acumatica_cli/templates/baseline/60-ledger-company.yaml +27 -0
  21. acumatica_cli-0.4.0/src/acumatica_cli/templates/baseline/90-uoms.yaml +16 -0
  22. acumatica_cli-0.4.0/src/acumatica_cli/templates/bootstrap/company.yaml +14 -0
  23. acumatica_cli-0.4.0/src/acumatica_cli/templates/bootstrap/credit-terms.yaml +16 -0
  24. acumatica_cli-0.4.0/src/acumatica_cli/templates/bootstrap/features.yaml +16 -0
  25. acumatica_cli-0.4.0/src/acumatica_cli/templates/env +13 -0
  26. acumatica_cli-0.4.0/src/acumatica_cli/templates/gitignore +2 -0
  27. acumatica_cli-0.4.0/src/acumatica_cli/templates/setup/10-financial-year.yaml +27 -0
  28. acumatica_cli-0.4.0/src/acumatica_cli/templates/setup/20-master-calendar.yaml +28 -0
  29. acumatica_cli-0.4.0/src/acumatica_cli/templates/setup/30-open-periods.yaml +33 -0
  30. {acumatica_cli-0.2.2 → acumatica_cli-0.4.0}/src/acumatica_cli/tenant.py +16 -9
  31. acumatica_cli-0.2.2/PKG-INFO +0 -187
  32. acumatica_cli-0.2.2/README.md +0 -172
  33. acumatica_cli-0.2.2/src/acumatica_cli/bootstrap.py +0 -105
  34. acumatica_cli-0.2.2/src/acumatica_cli/bootstrap_project.xml +0 -117
  35. acumatica_cli-0.2.2/src/acumatica_cli/cli.py +0 -351
  36. acumatica_cli-0.2.2/src/acumatica_cli/config.py +0 -113
  37. acumatica_cli-0.2.2/src/acumatica_cli/seed.py +0 -120
  38. {acumatica_cli-0.2.2 → acumatica_cli-0.4.0}/src/acumatica_cli/__init__.py +0 -0
@@ -0,0 +1,188 @@
1
+ Metadata-Version: 2.3
2
+ Name: acumatica-cli
3
+ Version: 0.4.0
4
+ Summary: Acumatica ERP Config-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: pydantic-settings>=2.5
11
+ Requires-Dist: pyyaml>=6.0
12
+ Requires-Dist: rich>=13
13
+ Requires-Python: >=3.12
14
+ Project-URL: Homepage, https://lab5.ca
15
+ Project-URL: Repository, https://github.com/kborovik/acumatica-cli
16
+ Description-Content-Type: text/markdown
17
+
18
+ # Acumatica Config-as-Code
19
+
20
+ **`acu`** configures Acumatica ERP from YAML files in a git repo.
21
+
22
+ **No UI clicks, no Configuration Wizard.**
23
+
24
+ ## How it works
25
+
26
+ 1. **Read** the target Acumatica deployment and translate its configuration into YAML — config as code.
27
+ 2. **Develop** changes in the YAML, in git: edit, review, version.
28
+ 3. **Re-deploy** with `acu apply`, then prove the live tenant matches the code with `acu diff`.
29
+
30
+ Three commands do the work, and every one is safe to re-run:
31
+
32
+ - **`acu tenant create`** — creates a tenant and bootstraps it in one step, ready for `apply`. Re-running it against an existing tenant republishes the bootstrap package instead of failing.
33
+ - **`acu apply`** — pushes your YAML into the tenant as keyed upserts. Running it twice changes nothing.
34
+ - **`acu diff`** — compares your YAML against the live tenant and exits with code 2 on drift.
35
+
36
+ > **Tested against** Acumatica ERP **26.101.0225** on Windows Server 2025,
37
+ > contract REST endpoint **25.200.001**. Other versions will likely work,
38
+ > but only this combination is verified.
39
+
40
+ ## Why
41
+
42
+ Acumatica configuration normally lives in the web UI: wizards, screens, and manual data entry that nobody can review, version, or reproduce.
43
+
44
+ `acu` moves that configuration into YAML files in a git repo, so a tenant can be rebuilt from scratch, audited in a pull request, and checked for drift like any other infrastructure.
45
+
46
+ ## Quick start
47
+
48
+ ```sh
49
+ uv tool install acumatica-cli
50
+
51
+ acu config init --host erp.example.com my-erp
52
+ cd my-erp # edit .env: set ACU_PASSWORD
53
+
54
+ acu config check # read-only preflight
55
+ acu tenant create --id 3 --login DEV # create the tenant + bootstrap it
56
+ acu --tenant DEV apply # seed bootstrap/, baseline/, setup/
57
+ acu --tenant DEV diff # prove zero drift (exit 2 on drift)
58
+ ```
59
+
60
+ ## CLI map
61
+
62
+ ```text
63
+ acu [--tenant NAME] [--url URL] [--ssh USER@HOST] [--api-version V]
64
+ [--username U] [--password P]
65
+
66
+ ├── tenant tenant CRUD (ac.exe over SSH — control plane)
67
+ │ ├── list CompanyID, sign-in name, internal CD, type
68
+ │ ├── create --id N --login NAME create + bootstrap; re-run to republish
69
+ │ │ [--type SalesDemo] [--parent N] [--hidden] [--no-init]
70
+ │ └── delete --id N [--yes] delete the tenant and its data, recycle app pool
71
+
72
+ ├── apply [--dry-run] [FILES...] push YAML via REST (idempotent PUT upserts)
73
+ ├── diff [FILES...] drift check vs the live tenant (exit 2 on drift)
74
+ ├── schema [--out DIR] dump the endpoint's OpenAPI schema (swagger.json)
75
+
76
+ └── config configuration ops
77
+ ├── init [--host HOST] [DIR] scaffold a data repo (.env plus example YAML)
78
+ ├── show print the resolved config as a complete .env
79
+ └── check read-only preflight: discovery, secrets, REST, SSH
80
+ ```
81
+
82
+ `apply` and `diff` called without FILES default to the scaffolded directories, in order: `bootstrap/`, then `baseline/`, then `setup/`. Run `acu <command> --help` for details on any command.
83
+
84
+ ## The data repo
85
+
86
+ Your configuration lives in its own git repo, which `acu config init` scaffolds:
87
+
88
+ | Path | What it holds |
89
+ | ------------ | -------------------------------------------------------------------------- |
90
+ | `bootstrap/` | what makes a virgin tenant configurable: features, company, credit terms |
91
+ | `baseline/` | reference data: subaccounts, chart of accounts, ledger, units of measure |
92
+ | `setup/` | one-time actions: financial year, master calendar, open periods |
93
+ | `.env` | where to apply and who signs in, every key an `ACU_*` variable |
94
+
95
+ Files in each directory apply alphabetically; the numbered prefixes (`10-`, `20-`, and so on) encode dependency order. The scaffolded `.gitignore` keeps `.env` out of git — store it encrypted (for example as `.env.gpg`) and decrypt once per clone.
96
+
97
+ ## Installation
98
+
99
+ Requires Python 3.12 or newer.
100
+
101
+ ```sh
102
+ uv tool install acumatica-cli
103
+ ```
104
+
105
+ `pipx install acumatica-cli` and `pip install acumatica-cli` work too. For the latest development version straight from the main branch:
106
+
107
+ ```sh
108
+ uv tool install git+https://github.com/kborovik/acumatica-cli.git
109
+ ```
110
+
111
+ Verify with `acu --version`.
112
+
113
+ ## Configuration
114
+
115
+ Everything lives in one `.env` file: *where* to apply and *who* signs in. Three values are required; everything else has a code default matching a stock Acumatica install:
116
+
117
+ ```sh
118
+ ACU_BASE_URL=http://acu-dev1.vm.internal/AcumaticaERP # required: REST root
119
+ ACU_TENANT=LAB5 # sign-in name of the tenant API sessions use
120
+ ACU_SSH=Administrator@acu-dev1.vm.internal # required: control-plane user@host
121
+ ACU_USER=admin # optional, defaults to admin
122
+ ACU_PASSWORD=... # required
123
+ ```
124
+
125
+ Worth knowing:
126
+
127
+ - The file is found by walking up from the current directory, so any subdirectory of the data repo works. Without a `.env`, global flags plus the process environment supply the full configuration.
128
+ - Nothing is derived: split-horizon DNS, port forwards, and jump hosts are all handled by writing the address you actually want into the two address keys.
129
+ - `acu config show` prints the fully resolved configuration as a complete, valid `.env` — every knob visible, the password excluded. Redirect it to turn resolved state into a working config: `acu config show > .env`.
130
+
131
+ Verify before touching anything live:
132
+
133
+ ```sh
134
+ acu config check # read-only preflight: discovery, secrets, REST, SSH
135
+ acu apply --dry-run # show what would be written, write nothing
136
+ ```
137
+
138
+ ## Control and Data Planes
139
+
140
+ `acu` talks to an instance over two independent channels:
141
+
142
+ - **Control plane (SSH):** `acu tenant` runs `ac.exe -cm:CompanyConfig` and `sqlcmd` on the Windows guest — see [`docs/ac-exe.md`](docs/ac-exe.md).
143
+ - **Data plane (REST):** `acu apply`, `diff`, and `schema` use the contract-based API (`/entity/Default/25.200.001/`), where `PUT` is a keyed upsert — see [`docs/rest-api.md`](docs/rest-api.md).
144
+
145
+ If you only apply and diff YAML, you never need SSH. SSH setup is required only for `acu tenant`.
146
+
147
+ ## SSH setup (control plane)
148
+
149
+ `acu tenant` runs commands on the Windows guest through plain `ssh`. Two things about this setup are not obvious, and both are hard requirements.
150
+
151
+ **1. The default SSH shell on the Windows guest must be PowerShell.**
152
+ `acu` sends PowerShell syntax over the wire, and every one of those commands fails under `cmd.exe`, the Windows OpenSSH default. Switch it once, in an elevated PowerShell on the guest:
153
+
154
+ ```powershell
155
+ New-ItemProperty -Path "HKLM:\SOFTWARE\OpenSSH" -Name DefaultShell `
156
+ -Value "C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe" `
157
+ -PropertyType String -Force
158
+ ```
159
+
160
+ **2. Authentication must be key-based and non-interactive.**
161
+ `acu` connects with `BatchMode=yes`, so it will never answer a password prompt.
162
+ Because the default user is `Administrator` (an administrators-group member), Windows OpenSSH reads the key from the *machine-wide* file `C:\ProgramData\ssh\administrators_authorized_keys` — **not** from `~\.ssh\authorized_keys` like on Linux. On the guest:
163
+
164
+ Install + start the server (once):
165
+
166
+ ```powershell
167
+ Add-WindowsCapability -Online -Name OpenSSH.Server~~~~0.0.1.0
168
+ Set-Service sshd -StartupType Automatic
169
+ Start-Service sshd
170
+ ```
171
+
172
+ Authorize your public key for administrators:
173
+
174
+ ```powershell
175
+ Add-Content -Path C:\ProgramData\ssh\administrators_authorized_keys -Value "ssh-ed25519 AAAA... you@laptop"
176
+ ```
177
+
178
+ The file must be readable by SYSTEM/Administrators only, or sshd ignores it:
179
+
180
+ ```powershell
181
+ icacls C:\ProgramData\ssh\administrators_authorized_keys /inheritance:r /grant "Administrators:F" /grant "SYSTEM:F"
182
+ ```
183
+
184
+ Then verify from your workstation — this one test proves both requirements at once (key auth works, and the shell is PowerShell):
185
+
186
+ ```sh
187
+ ssh -o BatchMode=yes Administrator@acu-dev1.vm.internal '$PSVersionTable.PSVersion'
188
+ ```
@@ -0,0 +1,171 @@
1
+ # Acumatica Config-as-Code
2
+
3
+ **`acu`** configures Acumatica ERP from YAML files in a git repo.
4
+
5
+ **No UI clicks, no Configuration Wizard.**
6
+
7
+ ## How it works
8
+
9
+ 1. **Read** the target Acumatica deployment and translate its configuration into YAML — config as code.
10
+ 2. **Develop** changes in the YAML, in git: edit, review, version.
11
+ 3. **Re-deploy** with `acu apply`, then prove the live tenant matches the code with `acu diff`.
12
+
13
+ Three commands do the work, and every one is safe to re-run:
14
+
15
+ - **`acu tenant create`** — creates a tenant and bootstraps it in one step, ready for `apply`. Re-running it against an existing tenant republishes the bootstrap package instead of failing.
16
+ - **`acu apply`** — pushes your YAML into the tenant as keyed upserts. Running it twice changes nothing.
17
+ - **`acu diff`** — compares your YAML against the live tenant and exits with code 2 on drift.
18
+
19
+ > **Tested against** Acumatica ERP **26.101.0225** on Windows Server 2025,
20
+ > contract REST endpoint **25.200.001**. Other versions will likely work,
21
+ > but only this combination is verified.
22
+
23
+ ## Why
24
+
25
+ Acumatica configuration normally lives in the web UI: wizards, screens, and manual data entry that nobody can review, version, or reproduce.
26
+
27
+ `acu` moves that configuration into YAML files in a git repo, so a tenant can be rebuilt from scratch, audited in a pull request, and checked for drift like any other infrastructure.
28
+
29
+ ## Quick start
30
+
31
+ ```sh
32
+ uv tool install acumatica-cli
33
+
34
+ acu config init --host erp.example.com my-erp
35
+ cd my-erp # edit .env: set ACU_PASSWORD
36
+
37
+ acu config check # read-only preflight
38
+ acu tenant create --id 3 --login DEV # create the tenant + bootstrap it
39
+ acu --tenant DEV apply # seed bootstrap/, baseline/, setup/
40
+ acu --tenant DEV diff # prove zero drift (exit 2 on drift)
41
+ ```
42
+
43
+ ## CLI map
44
+
45
+ ```text
46
+ acu [--tenant NAME] [--url URL] [--ssh USER@HOST] [--api-version V]
47
+ [--username U] [--password P]
48
+
49
+ ├── tenant tenant CRUD (ac.exe over SSH — control plane)
50
+ │ ├── list CompanyID, sign-in name, internal CD, type
51
+ │ ├── create --id N --login NAME create + bootstrap; re-run to republish
52
+ │ │ [--type SalesDemo] [--parent N] [--hidden] [--no-init]
53
+ │ └── delete --id N [--yes] delete the tenant and its data, recycle app pool
54
+
55
+ ├── apply [--dry-run] [FILES...] push YAML via REST (idempotent PUT upserts)
56
+ ├── diff [FILES...] drift check vs the live tenant (exit 2 on drift)
57
+ ├── schema [--out DIR] dump the endpoint's OpenAPI schema (swagger.json)
58
+
59
+ └── config configuration ops
60
+ ├── init [--host HOST] [DIR] scaffold a data repo (.env plus example YAML)
61
+ ├── show print the resolved config as a complete .env
62
+ └── check read-only preflight: discovery, secrets, REST, SSH
63
+ ```
64
+
65
+ `apply` and `diff` called without FILES default to the scaffolded directories, in order: `bootstrap/`, then `baseline/`, then `setup/`. Run `acu <command> --help` for details on any command.
66
+
67
+ ## The data repo
68
+
69
+ Your configuration lives in its own git repo, which `acu config init` scaffolds:
70
+
71
+ | Path | What it holds |
72
+ | ------------ | -------------------------------------------------------------------------- |
73
+ | `bootstrap/` | what makes a virgin tenant configurable: features, company, credit terms |
74
+ | `baseline/` | reference data: subaccounts, chart of accounts, ledger, units of measure |
75
+ | `setup/` | one-time actions: financial year, master calendar, open periods |
76
+ | `.env` | where to apply and who signs in, every key an `ACU_*` variable |
77
+
78
+ Files in each directory apply alphabetically; the numbered prefixes (`10-`, `20-`, and so on) encode dependency order. The scaffolded `.gitignore` keeps `.env` out of git — store it encrypted (for example as `.env.gpg`) and decrypt once per clone.
79
+
80
+ ## Installation
81
+
82
+ Requires Python 3.12 or newer.
83
+
84
+ ```sh
85
+ uv tool install acumatica-cli
86
+ ```
87
+
88
+ `pipx install acumatica-cli` and `pip install acumatica-cli` work too. For the latest development version straight from the main branch:
89
+
90
+ ```sh
91
+ uv tool install git+https://github.com/kborovik/acumatica-cli.git
92
+ ```
93
+
94
+ Verify with `acu --version`.
95
+
96
+ ## Configuration
97
+
98
+ Everything lives in one `.env` file: *where* to apply and *who* signs in. Three values are required; everything else has a code default matching a stock Acumatica install:
99
+
100
+ ```sh
101
+ ACU_BASE_URL=http://acu-dev1.vm.internal/AcumaticaERP # required: REST root
102
+ ACU_TENANT=LAB5 # sign-in name of the tenant API sessions use
103
+ ACU_SSH=Administrator@acu-dev1.vm.internal # required: control-plane user@host
104
+ ACU_USER=admin # optional, defaults to admin
105
+ ACU_PASSWORD=... # required
106
+ ```
107
+
108
+ Worth knowing:
109
+
110
+ - The file is found by walking up from the current directory, so any subdirectory of the data repo works. Without a `.env`, global flags plus the process environment supply the full configuration.
111
+ - Nothing is derived: split-horizon DNS, port forwards, and jump hosts are all handled by writing the address you actually want into the two address keys.
112
+ - `acu config show` prints the fully resolved configuration as a complete, valid `.env` — every knob visible, the password excluded. Redirect it to turn resolved state into a working config: `acu config show > .env`.
113
+
114
+ Verify before touching anything live:
115
+
116
+ ```sh
117
+ acu config check # read-only preflight: discovery, secrets, REST, SSH
118
+ acu apply --dry-run # show what would be written, write nothing
119
+ ```
120
+
121
+ ## Control and Data Planes
122
+
123
+ `acu` talks to an instance over two independent channels:
124
+
125
+ - **Control plane (SSH):** `acu tenant` runs `ac.exe -cm:CompanyConfig` and `sqlcmd` on the Windows guest — see [`docs/ac-exe.md`](docs/ac-exe.md).
126
+ - **Data plane (REST):** `acu apply`, `diff`, and `schema` use the contract-based API (`/entity/Default/25.200.001/`), where `PUT` is a keyed upsert — see [`docs/rest-api.md`](docs/rest-api.md).
127
+
128
+ If you only apply and diff YAML, you never need SSH. SSH setup is required only for `acu tenant`.
129
+
130
+ ## SSH setup (control plane)
131
+
132
+ `acu tenant` runs commands on the Windows guest through plain `ssh`. Two things about this setup are not obvious, and both are hard requirements.
133
+
134
+ **1. The default SSH shell on the Windows guest must be PowerShell.**
135
+ `acu` sends PowerShell syntax over the wire, and every one of those commands fails under `cmd.exe`, the Windows OpenSSH default. Switch it once, in an elevated PowerShell on the guest:
136
+
137
+ ```powershell
138
+ New-ItemProperty -Path "HKLM:\SOFTWARE\OpenSSH" -Name DefaultShell `
139
+ -Value "C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe" `
140
+ -PropertyType String -Force
141
+ ```
142
+
143
+ **2. Authentication must be key-based and non-interactive.**
144
+ `acu` connects with `BatchMode=yes`, so it will never answer a password prompt.
145
+ Because the default user is `Administrator` (an administrators-group member), Windows OpenSSH reads the key from the *machine-wide* file `C:\ProgramData\ssh\administrators_authorized_keys` — **not** from `~\.ssh\authorized_keys` like on Linux. On the guest:
146
+
147
+ Install + start the server (once):
148
+
149
+ ```powershell
150
+ Add-WindowsCapability -Online -Name OpenSSH.Server~~~~0.0.1.0
151
+ Set-Service sshd -StartupType Automatic
152
+ Start-Service sshd
153
+ ```
154
+
155
+ Authorize your public key for administrators:
156
+
157
+ ```powershell
158
+ Add-Content -Path C:\ProgramData\ssh\administrators_authorized_keys -Value "ssh-ed25519 AAAA... you@laptop"
159
+ ```
160
+
161
+ The file must be readable by SYSTEM/Administrators only, or sshd ignores it:
162
+
163
+ ```powershell
164
+ icacls C:\ProgramData\ssh\administrators_authorized_keys /inheritance:r /grant "Administrators:F" /grant "SYSTEM:F"
165
+ ```
166
+
167
+ Then verify from your workstation — this one test proves both requirements at once (key auth works, and the shell is PowerShell):
168
+
169
+ ```sh
170
+ ssh -o BatchMode=yes Administrator@acu-dev1.vm.internal '$PSVersionTable.PSVersion'
171
+ ```
@@ -1,7 +1,7 @@
1
1
  [project]
2
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"
3
+ version = "0.4.0"
4
+ description = "Acumatica ERP Config-as-Code: tenant provisioning, baseline config, and reference data"
5
5
  authors = [{ name = "Konstantin Borovik", email = "kb@lab5.ca" }]
6
6
  readme = "README.md"
7
7
  requires-python = ">=3.12"
@@ -9,11 +9,15 @@ dependencies = [
9
9
  "click>=8.1",
10
10
  "httpx>=0.27",
11
11
  "pydantic>=2.7",
12
- "python-dotenv>=1.0",
12
+ "pydantic-settings>=2.5",
13
13
  "pyyaml>=6.0",
14
14
  "rich>=13",
15
15
  ]
16
16
 
17
+ [project.urls]
18
+ Homepage = "https://lab5.ca"
19
+ Repository = "https://github.com/kborovik/acumatica-cli"
20
+
17
21
  [project.scripts]
18
22
  acu = "acumatica_cli.cli:main"
19
23
 
@@ -0,0 +1,215 @@
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 + CM202000
10
+ financial currency for seeding (`bootstrap_project.xml`, serialization
11
+ verified T12; the Default endpoint's Currency entity is the CM201000 list
12
+ only — B8).
13
+
14
+ Customization publishes are tenant-scoped, so the package must be published
15
+ per tenant; publish() is idempotent on content — the skip gate compares the
16
+ digest embedded in the published package's description against the package
17
+ built now, and the plugin's UpdateDatabase is a keyed update on re-run.
18
+
19
+ The feature set the plugin enables is data, not code (V2): load_features()
20
+ reads the data repo's bootstrap/features.yaml (absent -> the built-in six)
21
+ and package_zip() splices it into the plugin source at build time.
22
+ """
23
+
24
+ import hashlib
25
+ import io
26
+ import time
27
+ import xml.etree.ElementTree as ET
28
+ import zipfile
29
+ from collections.abc import Sequence
30
+ from importlib import resources
31
+ from pathlib import Path
32
+
33
+ import httpx
34
+ import yaml
35
+
36
+ from .client import AcumaticaClient
37
+
38
+ # Alphanumeric only: CstDbStorage.ValidatePackageName rejects '-' and '_'
39
+ # (verified vs 26.101.0225 — "Invalid project name")
40
+ PACKAGE_NAME = "AcuBootstrap"
41
+ # Authored once, in the template's root description attribute — read it
42
+ # here rather than hand-syncing a second copy: the XML is digest input,
43
+ # so a divergent XML-only edit would fire a spurious republish (V4).
44
+ PACKAGE_DESCRIPTION = ET.fromstring(
45
+ (resources.files("acumatica_cli") / "bootstrap_project.xml").read_bytes()
46
+ ).get("description", "")
47
+ PLUGIN_CLASS = "AcuBootstrapPlugin"
48
+
49
+ # Code default when the data repo carries no bootstrap/features.yaml
50
+ # (SPEC I.data) — the minimum for company/branch + baseline seeding.
51
+ # The set is injected into the plugin source at package build; it never
52
+ # lives in bootstrap_plugin.cs (V2: feature flags are config, not tool
53
+ # source — B6).
54
+ DEFAULT_FEATURES = (
55
+ "FinancialModule",
56
+ "FinancialStandard",
57
+ "DistributionModule",
58
+ "Inventory",
59
+ "Branch",
60
+ "MultiCompany",
61
+ )
62
+ FEATURES_SENTINEL = "/*ACU_FEATURES*/"
63
+
64
+
65
+ def load_features(root: Path) -> list[str]:
66
+ """The FeaturesSet property names from <root>/bootstrap/features.yaml.
67
+
68
+ Absent file -> the built-in six (SPEC I.data). Names are validated as
69
+ plausible property names here (they are spliced into C# string literals);
70
+ whether each matches a real FeaturesSet property only the plugin can
71
+ tell — it logs the strays at publish time (the silent-typo guard).
72
+ """
73
+ path = root / "bootstrap" / "features.yaml"
74
+ if not path.is_file():
75
+ return list(DEFAULT_FEATURES)
76
+ with open(path) as f:
77
+ data = yaml.safe_load(f)
78
+ if not isinstance(data, list) or not data:
79
+ raise SystemExit(
80
+ f"{path}: expected a non-empty list of FeaturesSet property names"
81
+ )
82
+ for name in data:
83
+ if not isinstance(name, str) or not (name.isascii() and name.isidentifier()):
84
+ raise SystemExit(f"{path}: {name!r} is not a FeaturesSet property name")
85
+ return list(data)
86
+
87
+
88
+ def package_zip(features: Sequence[str] | None = None) -> bytes:
89
+ """Build the customization package: a zip holding project.xml.
90
+
91
+ The C# plugin travels as a <Graph> item whose Source ATTRIBUTE holds the
92
+ file content (Customization.CstCodeFile shape, verified vs 26.101.0225:
93
+ inline CDATA and zip-file variants are silently dropped on import).
94
+ ElementTree escapes the newlines as &#10; on serialization.
95
+
96
+ ``features`` (default: the built-in six) is spliced into the plugin's
97
+ ``Enabled`` set at the ACU_FEATURES sentinel — the one point where the
98
+ data repo's feature list enters the package (V2).
99
+ """
100
+ pkg = resources.files("acumatica_cli")
101
+ root = ET.fromstring((pkg / "bootstrap_project.xml").read_bytes())
102
+ source = (pkg / "bootstrap_plugin.cs").read_text(encoding="utf-8")
103
+ if FEATURES_SENTINEL not in source:
104
+ raise RuntimeError(f"bootstrap_plugin.cs: {FEATURES_SENTINEL} sentinel missing")
105
+ enabled = DEFAULT_FEATURES if features is None else features
106
+ source = source.replace(
107
+ FEATURES_SENTINEL, ", ".join(f'"{name}"' for name in enabled)
108
+ )
109
+ graph = ET.SubElement(root, "Graph")
110
+ graph.set("ClassName", PLUGIN_CLASS)
111
+ graph.set("FileType", "NewFile")
112
+ graph.set("Source", source)
113
+ buf = io.BytesIO()
114
+ with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
115
+ zf.writestr("project.xml", ET.tostring(root, encoding="utf-8"))
116
+ return buf.getvalue()
117
+
118
+
119
+ def content_digest(zip_bytes: bytes) -> str:
120
+ """sha256 hex of the project.xml inside the package zip.
121
+
122
+ Digest the XML bytes, not the zip: ET.tostring is deterministic, zip
123
+ container bytes are not. This is the content-parity token (V4) — it
124
+ covers everything package_zip splices in, features included.
125
+ """
126
+ with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zf:
127
+ return hashlib.sha256(zf.read("project.xml")).hexdigest()
128
+
129
+
130
+ def package_description(zip_bytes: bytes) -> str:
131
+ """The import description: human text + the content digest.
132
+
133
+ The description is the one round-trip channel the CustomizationApi
134
+ offers (verified live vs 26.101.0225): the import's projectDescription
135
+ comes back only in the root description attribute of getProject's
136
+ re-serialized project.xml — getPublished rows hold names alone.
137
+ """
138
+ return f"{PACKAGE_DESCRIPTION} [sha256:{content_digest(zip_bytes)}]"
139
+
140
+
141
+ def _published_description(client: AcumaticaClient) -> str | None:
142
+ """The description embedded in the tenant's published package, if any.
143
+
144
+ None when the project is gone (recreated tenant, B3) or its content
145
+ does not parse as a package — both mean "republish".
146
+ """
147
+ content = client.customization_project_content(PACKAGE_NAME)
148
+ if content is None:
149
+ return None
150
+ try:
151
+ with zipfile.ZipFile(io.BytesIO(content)) as zf:
152
+ root = ET.fromstring(zf.read("project.xml"))
153
+ except (zipfile.BadZipFile, KeyError, ET.ParseError):
154
+ return None
155
+ return root.get("description")
156
+
157
+
158
+ def _log_tail(status: dict[str, object], limit: int = 5) -> str:
159
+ """Last few publish-log messages, for a one-line error (SPEC V9)."""
160
+ log = status.get("log")
161
+ if not isinstance(log, list):
162
+ return ""
163
+ messages = [
164
+ str(entry.get("message", "")) for entry in log if isinstance(entry, dict)
165
+ ]
166
+ return "; ".join(m for m in messages[-limit:] if m)
167
+
168
+
169
+ def publish(
170
+ client: AcumaticaClient,
171
+ features: Sequence[str] | None = None,
172
+ timeout: float = 600.0,
173
+ poll: float = 5.0,
174
+ ) -> str:
175
+ """Publish the bootstrap package into the client's session tenant.
176
+
177
+ ``features`` (default: the built-in six) flows into the plugin's Enabled
178
+ set via package_zip. Idempotent on content, not existence (V4): the
179
+ import embeds the digest of the package we build now in the project
180
+ description, and the skip requires getPublished to list the package AND
181
+ the published description to carry that same digest. A recreated tenant
182
+ (stale publication row, content gone — B3) and a content change since
183
+ the last publish (B7 — e.g. an edited features.yaml) both fail the gate
184
+ and trigger reimport + republish. Otherwise import -> publishBegin ->
185
+ poll publishEnd until the server reports completion. Transport errors
186
+ while polling are tolerated — publishing restarts the app domain, so the
187
+ site may briefly drop connections mid-publish. Raises RuntimeError on a
188
+ failed publish or on timeout.
189
+ """
190
+ zip_bytes = package_zip(features)
191
+ description = package_description(zip_bytes)
192
+ if PACKAGE_NAME in client.customization_published() and (
193
+ _published_description(client) == description
194
+ ):
195
+ return "already published"
196
+ client.customization_import(PACKAGE_NAME, zip_bytes, description=description)
197
+ client.customization_publish_begin([PACKAGE_NAME])
198
+ deadline = time.monotonic() + timeout
199
+ while True:
200
+ try:
201
+ status = client.customization_publish_end()
202
+ except httpx.TransportError:
203
+ status = {} # site restarting mid-publish; keep polling
204
+ if status.get("isFailed"):
205
+ detail = _log_tail(status)
206
+ raise RuntimeError(
207
+ f"publishing {PACKAGE_NAME} failed" + (f": {detail}" if detail else "")
208
+ )
209
+ if status.get("isCompleted"):
210
+ return "published"
211
+ if time.monotonic() >= deadline:
212
+ raise RuntimeError(
213
+ f"publishing {PACKAGE_NAME} did not complete within {timeout:.0f}s"
214
+ )
215
+ time.sleep(poll)
@@ -11,6 +11,11 @@
11
11
  // and idempotent: update the existing row, insert when absent. All 205
12
12
  // NOT NULL bit columns are filled reflectively (only ~136 carry DB
13
13
  // defaults); Status = 0 means Validated (PXIntList, verified vs live).
14
+ //
15
+ // The Enabled set is NOT authored here (SPEC V2: feature flags are config
16
+ // "what", never tool source - B6): package_zip() substitutes the
17
+ // ACU_FEATURES sentinel below with the data repo's bootstrap/features.yaml
18
+ // list (built-in six when the file is absent) at package-build time.
14
19
  using System.Collections.Generic;
15
20
  using System.Reflection;
16
21
  using PX.Data;
@@ -22,25 +27,32 @@ namespace AcuBootstrap
22
27
  {
23
28
  private static readonly HashSet<string> Enabled = new HashSet<string>
24
29
  {
25
- "FinancialModule",
26
- "FinancialStandard",
27
- "DistributionModule",
28
- "Inventory",
29
- "Branch",
30
- "MultiCompany",
30
+ /*ACU_FEATURES*/
31
31
  };
32
32
 
33
33
  public override void UpdateDatabase()
34
34
  {
35
35
  var flags = new List<PXDataFieldAssign>();
36
+ var known = new HashSet<string>();
36
37
  foreach (PropertyInfo prop in typeof(FeaturesSet).GetProperties())
37
38
  {
38
39
  if (prop.PropertyType == typeof(bool?))
39
40
  {
41
+ known.Add(prop.Name);
40
42
  flags.Add(new PXDataFieldAssign(
41
43
  prop.Name, PXDbType.Bit, Enabled.Contains(prop.Name)));
42
44
  }
43
45
  }
46
+ foreach (string name in Enabled)
47
+ {
48
+ if (!known.Contains(name))
49
+ {
50
+ // silent-typo guard (T24): a misspelled features.yaml
51
+ // entry enables nothing - say so in the publish log
52
+ WriteLog("AcuBootstrap: unknown feature name '" + name
53
+ + "' - no FeaturesSet property, nothing enabled");
54
+ }
55
+ }
44
56
  flags.Add(new PXDataFieldAssign("Status", PXDbType.Int, 0)); // Validated
45
57
 
46
58
  using (var tx = new PXTransactionScope())