acumatica-cli 0.8.1__tar.gz → 0.8.3__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 (32) hide show
  1. {acumatica_cli-0.8.1 → acumatica_cli-0.8.3}/PKG-INFO +41 -17
  2. {acumatica_cli-0.8.1 → acumatica_cli-0.8.3}/README.md +40 -16
  3. {acumatica_cli-0.8.1 → acumatica_cli-0.8.3}/pyproject.toml +1 -1
  4. {acumatica_cli-0.8.1 → acumatica_cli-0.8.3}/src/acumatica_cli/cli.py +98 -17
  5. {acumatica_cli-0.8.1 → acumatica_cli-0.8.3}/src/acumatica_cli/__init__.py +0 -0
  6. {acumatica_cli-0.8.1 → acumatica_cli-0.8.3}/src/acumatica_cli/bootstrap.py +0 -0
  7. {acumatica_cli-0.8.1 → acumatica_cli-0.8.3}/src/acumatica_cli/bootstrap_plugin.cs +0 -0
  8. {acumatica_cli-0.8.1 → acumatica_cli-0.8.3}/src/acumatica_cli/bootstrap_project.xml +0 -0
  9. {acumatica_cli-0.8.1 → acumatica_cli-0.8.3}/src/acumatica_cli/client.py +0 -0
  10. {acumatica_cli-0.8.1 → acumatica_cli-0.8.3}/src/acumatica_cli/config.py +0 -0
  11. {acumatica_cli-0.8.1 → acumatica_cli-0.8.3}/src/acumatica_cli/extract.py +0 -0
  12. {acumatica_cli-0.8.1 → acumatica_cli-0.8.3}/src/acumatica_cli/extract_manifest.yaml +0 -0
  13. {acumatica_cli-0.8.1 → acumatica_cli-0.8.3}/src/acumatica_cli/firstlogin.py +0 -0
  14. {acumatica_cli-0.8.1 → acumatica_cli-0.8.3}/src/acumatica_cli/models.py +0 -0
  15. {acumatica_cli-0.8.1 → acumatica_cli-0.8.3}/src/acumatica_cli/output.py +0 -0
  16. {acumatica_cli-0.8.1 → acumatica_cli-0.8.3}/src/acumatica_cli/run.py +0 -0
  17. {acumatica_cli-0.8.1 → acumatica_cli-0.8.3}/src/acumatica_cli/seed.py +0 -0
  18. {acumatica_cli-0.8.1 → acumatica_cli-0.8.3}/src/acumatica_cli/templates/baseline/10-subaccounts.yaml +0 -0
  19. {acumatica_cli-0.8.1 → acumatica_cli-0.8.3}/src/acumatica_cli/templates/baseline/20-accounts.yaml +0 -0
  20. {acumatica_cli-0.8.1 → acumatica_cli-0.8.3}/src/acumatica_cli/templates/baseline/40-ledger.yaml +0 -0
  21. {acumatica_cli-0.8.1 → acumatica_cli-0.8.3}/src/acumatica_cli/templates/baseline/50-gl-preferences.yaml +0 -0
  22. {acumatica_cli-0.8.1 → acumatica_cli-0.8.3}/src/acumatica_cli/templates/baseline/60-ledger-company.yaml +0 -0
  23. {acumatica_cli-0.8.1 → acumatica_cli-0.8.3}/src/acumatica_cli/templates/baseline/90-uoms.yaml +0 -0
  24. {acumatica_cli-0.8.1 → acumatica_cli-0.8.3}/src/acumatica_cli/templates/bootstrap/company.yaml +0 -0
  25. {acumatica_cli-0.8.1 → acumatica_cli-0.8.3}/src/acumatica_cli/templates/bootstrap/credit-terms.yaml +0 -0
  26. {acumatica_cli-0.8.1 → acumatica_cli-0.8.3}/src/acumatica_cli/templates/bootstrap/features.yaml +0 -0
  27. {acumatica_cli-0.8.1 → acumatica_cli-0.8.3}/src/acumatica_cli/templates/env +0 -0
  28. {acumatica_cli-0.8.1 → acumatica_cli-0.8.3}/src/acumatica_cli/templates/gitignore +0 -0
  29. {acumatica_cli-0.8.1 → acumatica_cli-0.8.3}/src/acumatica_cli/templates/setup/10-financial-year.yaml +0 -0
  30. {acumatica_cli-0.8.1 → acumatica_cli-0.8.3}/src/acumatica_cli/templates/setup/20-master-calendar.yaml +0 -0
  31. {acumatica_cli-0.8.1 → acumatica_cli-0.8.3}/src/acumatica_cli/templates/setup/30-open-periods.yaml +0 -0
  32. {acumatica_cli-0.8.1 → acumatica_cli-0.8.3}/src/acumatica_cli/tenant.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: acumatica-cli
3
- Version: 0.8.1
3
+ Version: 0.8.3
4
4
  Summary: Acumatica ERP Config-as-Code: tenant provisioning, baseline config, and reference data
5
5
  Author: Konstantin Borovik
6
6
  Author-email: Konstantin Borovik <kb@lab5.ca>
@@ -40,11 +40,24 @@ acu config init --host erp.example.com my-erp
40
40
  cd my-erp # edit .env: set ACU_PASSWORD
41
41
 
42
42
  acu config check # read-only preflight
43
- acu tenant create --id 3 --login DEV # create the tenant + bootstrap it
43
+ acu tenant create --id 3 --login DEV # create the tenant + bootstrap it (needs SSH)
44
44
  acu --tenant DEV apply # seed bootstrap/, baseline/, setup/
45
45
  acu --tenant DEV diff # prove zero drift (exit 2 on drift)
46
46
  ```
47
47
 
48
+ **Hosted Acumatica (no SSH):** the tenant already exists; leave `ACU_SSH` blank.
49
+
50
+ ```sh
51
+ acu config init --host customer.acumatica.com my-erp
52
+ cd my-erp # edit .env: ACU_TENANT, ACU_PASSWORD; ACU_SSH=
53
+ acu config check # REST preflight; ssh probe is skipped
54
+ acu --tenant DEV bootstrap # publish AcuBootstrap via REST only
55
+ acu --tenant DEV apply
56
+ acu --tenant DEV diff
57
+ # offline UI fallback when REST publish is blocked:
58
+ acu bootstrap --export AcuBootstrap.zip # import + publish on SM204505
59
+ ```
60
+
48
61
  ## CLI map
49
62
 
50
63
  ```text
@@ -53,10 +66,11 @@ acu [--tenant NAME] [--url URL] [--ssh USER@HOST] [--api-version V]
53
66
  │
54
67
  ├── tenant tenant CRUD (ac.exe over SSH — control plane)
55
68
  │ ├── list CompanyID, sign-in name, internal CD, type
56
- │ ├── create --id N --login NAME create + bootstrap; re-run to republish
69
+ │ ├── create --id N --login NAME create + bootstrap; re-run to republish (SSH)
57
70
  │ │ [--type SalesDemo|T100|U100] [--parent N] [--hidden] [--no-init]
58
71
  │ └── delete --id N [--yes] delete the tenant and its data, recycle app pool
59
72
  │
73
+ ├── bootstrap [--export PATH] publish AcuBootstrap (REST); --export = offline zip
60
74
  ├── apply [--dry-run] [FILES...] push YAML via REST (idempotent PUT upserts)
61
75
  ├── diff [FILES...] drift check vs the live tenant (exit 2 on drift)
62
76
  ├── run [--dry-run] [FILES...] execute transaction scenario YAML (exit 1 on any miss)
@@ -116,16 +130,20 @@ Everything lives in one `.env` file: *where* to apply and *who* signs in. Three
116
130
  ```sh
117
131
  ACU_BASE_URL=http://acu-dev1.vm.internal/AcumaticaERP # required: REST root
118
132
  ACU_TENANT=LAB5 # sign-in name of the tenant API sessions use
119
- ACU_SSH=Administrator@acu-dev1.vm.internal # required: control-plane user@host
133
+ ACU_SSH=Administrator@acu-dev1.vm.internal # optional: control-plane user@host (tenant CRUD)
120
134
  ACU_USER=admin # optional, defaults to admin
121
- ACU_PASSWORD=... # required
135
+ ACU_PASSWORD=... # required for live commands
122
136
  ```
123
137
 
124
138
  Worth knowing:
125
139
 
126
- - 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.
127
- - 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.
128
- - `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`.
140
+ - The file is found by walking up from the current directory, so any subdirectory of the data repo works.
141
+ - Without a `.env`, global flags plus the process environment supply the full configuration.
142
+ - `ACU_SSH` is optional.
143
+ - Leave it blank on hosted instances; only `acu tenant` needs it.
144
+ - Nothing is derived: split-horizon DNS, port forwards, and jump hosts are all handled by writing the address you actually want into the address keys.
145
+ - `acu config show` prints the fully resolved configuration as a complete, valid `.env` — every knob visible, the password excluded.
146
+ - Redirect it to turn resolved state into a working config: `acu config show > .env`.
129
147
 
130
148
  Verify before touching anything live:
131
149
 
@@ -139,9 +157,11 @@ acu apply --dry-run # show what would be written, write nothing
139
157
  `acu` talks to an instance over two independent channels:
140
158
 
141
159
  - **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).
142
- - **Data plane (REST):** `acu apply`, `diff`, `run`, `extract`, 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).
160
+ - **Data plane (REST):** `acu bootstrap`, `apply`, `diff`, `run`, `extract`, and `schema` use the contract-based API and CustomizationApi — see [`docs/rest-api.md`](docs/rest-api.md).
143
161
 
144
- If you never touch `acu tenant`, you never need SSH — every other command is pure REST.
162
+ If you never touch `acu tenant`, you never need SSH.
163
+ On a hosted instance, publish AcuBootstrap with `acu bootstrap`, then seed with `apply`.
164
+ `acu bootstrap --export PATH` writes the package zip for manual import on the Customization Projects screen (SM204505) when you cannot call the API.
145
165
 
146
166
  ## SSH setup (control plane)
147
167
 
@@ -188,29 +208,33 @@ ssh -o BatchMode=yes Administrator@acu-dev1.vm.internal '$PSVersionTable.PSVersi
188
208
 
189
209
  ## Development
190
210
 
211
+ Requires **GNU Make at least 3.82** — the Makefile uses `.ONESHELL`.
212
+ On macOS use Homebrew's `gmake` (`brew install make`); `/usr/bin/make` is 3.81 and fails the guard.
213
+ Elsewhere plain `make` is fine when it is GNU Make.
214
+
191
215
  ```sh
192
216
  git clone https://github.com/kborovik/acumatica-cli.git
193
217
  cd acumatica-cli
194
- make install # editable install as a global uv tool
195
- make check # offline gate: ruff, basedpyright strict, pytest
218
+ gmake install # editable install as a global uv tool
219
+ gmake check # offline gate: ruff, basedpyright strict, pytest
196
220
  ```
197
221
 
198
222
  The default test suite is fully offline.
199
223
  REST is faked with `httpx.MockTransport`, SSH with a monkeypatched `subprocess.run` — no live instance is needed.
200
- `make check` must pass before every commit.
224
+ `gmake check` must pass before every commit.
201
225
 
202
226
  ### Live end-to-end tier
203
227
 
204
- `make e2e` runs the opt-in live tier against a real Acumatica instance (pytest marker `e2e`, deselected by the default suite).
228
+ `gmake e2e` runs the opt-in live tier against a real Acumatica instance (pytest marker `e2e`, deselected by the default suite).
205
229
 
206
230
  Configuration is one file: a decrypted `.env` at the repo root names the instance — `ACU_BASE_URL`, `ACU_SSH`, `ACU_TENANT`, `ACU_PASSWORD`.
207
- `make e2e` refuses to start without it.
231
+ `gmake e2e` refuses to start without it.
208
232
 
209
233
  The tier is self-contained.
210
234
  Each run scaffolds a synthetic single-org company from the packaged `acu config init` templates into a temporary directory, copies the real `.env` into it, and runs the installed `acu` binary from there — no data repo, no pre-existing fixtures on the instance.
211
235
  Scratch tenants (`E2E`, `E2EA`, `E2EB`) are created on the way in and always deleted on the way out, so nothing persists.
212
236
 
213
237
  ```sh
214
- make e2e # whole tier, about 20 minutes
215
- make e2e FILE=test_provision_lifecycle # one file, by stem or path
238
+ gmake e2e # whole tier, about 20 minutes
239
+ gmake e2e FILE=test_provision_lifecycle # one file, by stem or path
216
240
  ```
@@ -23,11 +23,24 @@ acu config init --host erp.example.com my-erp
23
23
  cd my-erp # edit .env: set ACU_PASSWORD
24
24
 
25
25
  acu config check # read-only preflight
26
- acu tenant create --id 3 --login DEV # create the tenant + bootstrap it
26
+ acu tenant create --id 3 --login DEV # create the tenant + bootstrap it (needs SSH)
27
27
  acu --tenant DEV apply # seed bootstrap/, baseline/, setup/
28
28
  acu --tenant DEV diff # prove zero drift (exit 2 on drift)
29
29
  ```
30
30
 
31
+ **Hosted Acumatica (no SSH):** the tenant already exists; leave `ACU_SSH` blank.
32
+
33
+ ```sh
34
+ acu config init --host customer.acumatica.com my-erp
35
+ cd my-erp # edit .env: ACU_TENANT, ACU_PASSWORD; ACU_SSH=
36
+ acu config check # REST preflight; ssh probe is skipped
37
+ acu --tenant DEV bootstrap # publish AcuBootstrap via REST only
38
+ acu --tenant DEV apply
39
+ acu --tenant DEV diff
40
+ # offline UI fallback when REST publish is blocked:
41
+ acu bootstrap --export AcuBootstrap.zip # import + publish on SM204505
42
+ ```
43
+
31
44
  ## CLI map
32
45
 
33
46
  ```text
@@ -36,10 +49,11 @@ acu [--tenant NAME] [--url URL] [--ssh USER@HOST] [--api-version V]
36
49
  │
37
50
  ├── tenant tenant CRUD (ac.exe over SSH — control plane)
38
51
  │ ├── list CompanyID, sign-in name, internal CD, type
39
- │ ├── create --id N --login NAME create + bootstrap; re-run to republish
52
+ │ ├── create --id N --login NAME create + bootstrap; re-run to republish (SSH)
40
53
  │ │ [--type SalesDemo|T100|U100] [--parent N] [--hidden] [--no-init]
41
54
  │ └── delete --id N [--yes] delete the tenant and its data, recycle app pool
42
55
  │
56
+ ├── bootstrap [--export PATH] publish AcuBootstrap (REST); --export = offline zip
43
57
  ├── apply [--dry-run] [FILES...] push YAML via REST (idempotent PUT upserts)
44
58
  ├── diff [FILES...] drift check vs the live tenant (exit 2 on drift)
45
59
  ├── run [--dry-run] [FILES...] execute transaction scenario YAML (exit 1 on any miss)
@@ -99,16 +113,20 @@ Everything lives in one `.env` file: *where* to apply and *who* signs in. Three
99
113
  ```sh
100
114
  ACU_BASE_URL=http://acu-dev1.vm.internal/AcumaticaERP # required: REST root
101
115
  ACU_TENANT=LAB5 # sign-in name of the tenant API sessions use
102
- ACU_SSH=Administrator@acu-dev1.vm.internal # required: control-plane user@host
116
+ ACU_SSH=Administrator@acu-dev1.vm.internal # optional: control-plane user@host (tenant CRUD)
103
117
  ACU_USER=admin # optional, defaults to admin
104
- ACU_PASSWORD=... # required
118
+ ACU_PASSWORD=... # required for live commands
105
119
  ```
106
120
 
107
121
  Worth knowing:
108
122
 
109
- - 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.
110
- - 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.
111
- - `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`.
123
+ - The file is found by walking up from the current directory, so any subdirectory of the data repo works.
124
+ - Without a `.env`, global flags plus the process environment supply the full configuration.
125
+ - `ACU_SSH` is optional.
126
+ - Leave it blank on hosted instances; only `acu tenant` needs it.
127
+ - Nothing is derived: split-horizon DNS, port forwards, and jump hosts are all handled by writing the address you actually want into the address keys.
128
+ - `acu config show` prints the fully resolved configuration as a complete, valid `.env` — every knob visible, the password excluded.
129
+ - Redirect it to turn resolved state into a working config: `acu config show > .env`.
112
130
 
113
131
  Verify before touching anything live:
114
132
 
@@ -122,9 +140,11 @@ acu apply --dry-run # show what would be written, write nothing
122
140
  `acu` talks to an instance over two independent channels:
123
141
 
124
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).
125
- - **Data plane (REST):** `acu apply`, `diff`, `run`, `extract`, 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).
143
+ - **Data plane (REST):** `acu bootstrap`, `apply`, `diff`, `run`, `extract`, and `schema` use the contract-based API and CustomizationApi — see [`docs/rest-api.md`](docs/rest-api.md).
126
144
 
127
- If you never touch `acu tenant`, you never need SSH — every other command is pure REST.
145
+ If you never touch `acu tenant`, you never need SSH.
146
+ On a hosted instance, publish AcuBootstrap with `acu bootstrap`, then seed with `apply`.
147
+ `acu bootstrap --export PATH` writes the package zip for manual import on the Customization Projects screen (SM204505) when you cannot call the API.
128
148
 
129
149
  ## SSH setup (control plane)
130
150
 
@@ -171,29 +191,33 @@ ssh -o BatchMode=yes Administrator@acu-dev1.vm.internal '$PSVersionTable.PSVersi
171
191
 
172
192
  ## Development
173
193
 
194
+ Requires **GNU Make at least 3.82** — the Makefile uses `.ONESHELL`.
195
+ On macOS use Homebrew's `gmake` (`brew install make`); `/usr/bin/make` is 3.81 and fails the guard.
196
+ Elsewhere plain `make` is fine when it is GNU Make.
197
+
174
198
  ```sh
175
199
  git clone https://github.com/kborovik/acumatica-cli.git
176
200
  cd acumatica-cli
177
- make install # editable install as a global uv tool
178
- make check # offline gate: ruff, basedpyright strict, pytest
201
+ gmake install # editable install as a global uv tool
202
+ gmake check # offline gate: ruff, basedpyright strict, pytest
179
203
  ```
180
204
 
181
205
  The default test suite is fully offline.
182
206
  REST is faked with `httpx.MockTransport`, SSH with a monkeypatched `subprocess.run` — no live instance is needed.
183
- `make check` must pass before every commit.
207
+ `gmake check` must pass before every commit.
184
208
 
185
209
  ### Live end-to-end tier
186
210
 
187
- `make e2e` runs the opt-in live tier against a real Acumatica instance (pytest marker `e2e`, deselected by the default suite).
211
+ `gmake e2e` runs the opt-in live tier against a real Acumatica instance (pytest marker `e2e`, deselected by the default suite).
188
212
 
189
213
  Configuration is one file: a decrypted `.env` at the repo root names the instance — `ACU_BASE_URL`, `ACU_SSH`, `ACU_TENANT`, `ACU_PASSWORD`.
190
- `make e2e` refuses to start without it.
214
+ `gmake e2e` refuses to start without it.
191
215
 
192
216
  The tier is self-contained.
193
217
  Each run scaffolds a synthetic single-org company from the packaged `acu config init` templates into a temporary directory, copies the real `.env` into it, and runs the installed `acu` binary from there — no data repo, no pre-existing fixtures on the instance.
194
218
  Scratch tenants (`E2E`, `E2EA`, `E2EB`) are created on the way in and always deleted on the way out, so nothing persists.
195
219
 
196
220
  ```sh
197
- make e2e # whole tier, about 20 minutes
198
- make e2e FILE=test_provision_lifecycle # one file, by stem or path
221
+ gmake e2e # whole tier, about 20 minutes
222
+ gmake e2e FILE=test_provision_lifecycle # one file, by stem or path
199
223
  ```
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "acumatica-cli"
3
- version = "0.8.1"
3
+ version = "0.8.3"
4
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"
@@ -282,32 +282,102 @@ def _init_tenant(inst: Instance, mgr: TenantManager, login_name: str) -> None:
282
282
  output.success(f"admin {result}; tenant {login_name} is ready")
283
283
 
284
284
 
285
- def _bootstrap_tenant(inst: Instance, mgr: TenantManager, login_name: str) -> None:
286
- """Publish the bootstrap package into the fresh tenant (data plane).
285
+ def _bootstrap_features() -> list[str] | None:
286
+ """Feature list from the data repo, or None → package-built-in six.
287
287
 
288
- Idempotent on content, not existence (V4): publish() skips only when the
289
- published package carries the digest of the package built now. The
290
- session targets the new tenant explicitly (V5), never a config default.
291
- The feature set is data (V2): bootstrap/features.yaml in the data repo,
292
- no data repo or no file -> the built-in six.
288
+ features.yaml is package-build config (V2), not a seed file: both the
289
+ live publish path and --export read it the same way.
293
290
  """
294
- inst = inst.model_copy(update={"tenant": login_name})
295
291
  root = find_data_root()
296
- features = bootstrap.load_features(root) if root is not None else None
292
+ return bootstrap.load_features(root) if root is not None else None
293
+
294
+
295
+ def _publish_bootstrap_package(inst: Instance, features: list[str] | None) -> str:
296
+ """Import + publish AcuBootstrap into inst.tenant (data plane only)."""
297
297
  with (
298
298
  output.step(f"publishing {bootstrap.PACKAGE_NAME} to {inst.tenant}"),
299
299
  AcumaticaClient(inst) as client,
300
300
  ):
301
- result = bootstrap.publish(client, features=features)
302
- output.success(f"{bootstrap.PACKAGE_NAME} {result}")
303
- # unconditional: the publish restarts the site BEFORE its DB transaction
304
- # commits, so the restarted domain caches the feature slot pre-plugin
305
- # (verified live: gated screens stay 403 until one more recycle); on the
306
- # skip path a recycle is the cheap way to make a resumed run sound too
301
+ return bootstrap.publish(client, features=features)
302
+
303
+
304
+ def _recycle_after_bootstrap(inst: Instance, mgr: TenantManager | None) -> None:
305
+ """Post-publish app-pool recycle when the control plane is available.
306
+
307
+ The publish restarts the site BEFORE its DB transaction commits, so the
308
+ restarted domain caches the feature slot pre-plugin (verified live:
309
+ gated screens stay 403 until one more recycle). On the skip path a
310
+ recycle is the cheap way to make a resumed run sound too. Hosted
311
+ (no ACU_SSH): warn and continue — the customer must restart the site
312
+ another way before feature-gated apply can succeed.
313
+ """
314
+ if mgr is None and not inst.ssh:
315
+ output.warn(
316
+ "ACU_SSH not set: skipped app-pool recycle; "
317
+ "feature-gated screens may stay 403 until the site restarts"
318
+ )
319
+ return
320
+ if mgr is None:
321
+ mgr = TenantManager(inst)
307
322
  with output.step("recycling app pool (feature set loads at app start)"):
308
323
  mgr.recycle_app_pool()
309
324
  with output.step("waiting for the site to come back"):
310
- firstlogin.initialize_admin_password(inst, tenant=login_name)
325
+ firstlogin.initialize_admin_password(inst, tenant=inst.tenant)
326
+
327
+
328
+ def _bootstrap_tenant(inst: Instance, mgr: TenantManager, login_name: str) -> None:
329
+ """Publish the bootstrap package into the fresh tenant (data plane).
330
+
331
+ Idempotent on content, not existence (V4): publish() skips only when the
332
+ published package carries the digest of the package built now. The
333
+ session targets the new tenant explicitly (V5), never a config default.
334
+ Tenant create always has SSH (TenantManager already constructed), so
335
+ the post-publish recycle always runs on this path.
336
+ """
337
+ inst = inst.model_copy(update={"tenant": login_name})
338
+ features = _bootstrap_features()
339
+ result = _publish_bootstrap_package(inst, features)
340
+ output.success(f"{bootstrap.PACKAGE_NAME} {result}")
341
+ _recycle_after_bootstrap(inst, mgr)
342
+
343
+
344
+ @cli.command("bootstrap")
345
+ @click.option(
346
+ "--export",
347
+ "export_path",
348
+ type=click.Path(path_type=Path, dir_okay=False),
349
+ default=None,
350
+ help="Write the package zip to PATH (offline; no REST, no SSH) "
351
+ "for Customization Projects UI import",
352
+ )
353
+ @click.pass_context
354
+ def bootstrap_cmd(ctx: click.Context, export_path: Path | None) -> None:
355
+ """Publish AcuBootstrap into the session tenant (or export the package zip).
356
+
357
+ Hosted / no-SSH path: CustomizationApi publish is pure REST, so this
358
+ command never requires ACU_SSH. When SSH is set, an app-pool recycle
359
+ follows publish so feature-gated screens load; when unset, a warning
360
+ notes that the site may need another restart before apply.
361
+
362
+ --export writes the same feature-spliced package that publish would
363
+ import, with no HTTP and no password — the SM204505 UI-import fallback.
364
+ """
365
+ features = _bootstrap_features()
366
+ if export_path is not None:
367
+ if not export_path.parent.exists():
368
+ raise SystemExit(f"{export_path.parent}: directory does not exist")
369
+ export_path.write_bytes(bootstrap.package_zip(features))
370
+ output.success(f"wrote {export_path}")
371
+ return
372
+ inst = _resolve_instance(ctx)
373
+ if not inst.tenant:
374
+ raise SystemExit(
375
+ "tenant not set (pass --tenant, "
376
+ "or put ACU_TENANT in .env or the environment)"
377
+ )
378
+ result = _publish_bootstrap_package(inst, features)
379
+ output.success(f"{bootstrap.PACKAGE_NAME} {result}")
380
+ _recycle_after_bootstrap(inst, mgr=None)
311
381
 
312
382
 
313
383
  @tenant_group.command("delete")
@@ -558,7 +628,18 @@ def diff_cmd(inst: Instance, files: tuple[Path, ...]) -> None:
558
628
  with AcumaticaClient(inst) as client:
559
629
  for path in paths:
560
630
  baseline = seed.load_baseline(path)
561
- drifts += seed.diff(client, baseline)
631
+ # same file banner as apply so a long multi-file diff shows
632
+ # progress instead of silence until the final status line
633
+ output.data(
634
+ f"{path} -> {inst.tenant} on {inst.base_url} ({baseline.entity})"
635
+ )
636
+ file_drifts = seed.diff(client, baseline)
637
+ drifts += file_drifts
638
+ n = 1 if isinstance(baseline, seed.ActionFile) else len(baseline.records)
639
+ if file_drifts:
640
+ output.data(f" {n} record(s), {len(file_drifts)} drift(s)")
641
+ else:
642
+ output.data(f" {n} record(s) ok")
562
643
  _exit_on_drift(inst, drifts, len(paths))
563
644
 
564
645