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.
@@ -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 &#10; 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
+ }