acumatica-cli 0.11.0__tar.gz → 0.12.1__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 (71) hide show
  1. {acumatica_cli-0.11.0 → acumatica_cli-0.12.1}/PKG-INFO +25 -20
  2. {acumatica_cli-0.11.0 → acumatica_cli-0.12.1}/README.md +24 -19
  3. {acumatica_cli-0.11.0 → acumatica_cli-0.12.1}/pyproject.toml +1 -1
  4. {acumatica_cli-0.11.0 → acumatica_cli-0.12.1}/src/acumatica_cli/bootstrap.py +32 -21
  5. {acumatica_cli-0.11.0 → acumatica_cli-0.12.1}/src/acumatica_cli/cli.py +145 -63
  6. {acumatica_cli-0.11.0 → acumatica_cli-0.12.1}/src/acumatica_cli/client.py +55 -9
  7. {acumatica_cli-0.11.0 → acumatica_cli-0.12.1}/src/acumatica_cli/config.py +116 -47
  8. {acumatica_cli-0.11.0 → acumatica_cli-0.12.1}/src/acumatica_cli/run.py +159 -25
  9. acumatica_cli-0.12.1/src/acumatica_cli/templates/distribution/README.md +59 -0
  10. acumatica_cli-0.12.1/src/acumatica_cli/templates/distribution/scenario/10-seed-capital.yaml +46 -0
  11. acumatica_cli-0.12.1/src/acumatica_cli/templates/distribution/scenario/20-buy-gateways.yaml +112 -0
  12. acumatica_cli-0.12.1/src/acumatica_cli/templates/distribution/scenario/30-build.yaml +5 -0
  13. acumatica_cli-0.11.0/src/acumatica_cli/templates/distribution/scenario/buy-sell.yaml → acumatica_cli-0.12.1/src/acumatica_cli/templates/distribution/scenario/40-sell.yaml +76 -47
  14. acumatica_cli-0.11.0/src/acumatica_cli/templates/distribution/README.md +0 -49
  15. {acumatica_cli-0.11.0 → acumatica_cli-0.12.1}/src/acumatica_cli/__init__.py +0 -0
  16. {acumatica_cli-0.11.0 → acumatica_cli-0.12.1}/src/acumatica_cli/bootstrap_plugin.cs +0 -0
  17. {acumatica_cli-0.11.0 → acumatica_cli-0.12.1}/src/acumatica_cli/bootstrap_project.xml +0 -0
  18. {acumatica_cli-0.11.0 → acumatica_cli-0.12.1}/src/acumatica_cli/extract.py +0 -0
  19. {acumatica_cli-0.11.0 → acumatica_cli-0.12.1}/src/acumatica_cli/extract_manifest.yaml +0 -0
  20. {acumatica_cli-0.11.0 → acumatica_cli-0.12.1}/src/acumatica_cli/firstlogin.py +0 -0
  21. {acumatica_cli-0.11.0 → acumatica_cli-0.12.1}/src/acumatica_cli/models.py +0 -0
  22. {acumatica_cli-0.11.0 → acumatica_cli-0.12.1}/src/acumatica_cli/output.py +0 -0
  23. {acumatica_cli-0.11.0 → acumatica_cli-0.12.1}/src/acumatica_cli/seed.py +0 -0
  24. {acumatica_cli-0.11.0 → acumatica_cli-0.12.1}/src/acumatica_cli/target.py +0 -0
  25. {acumatica_cli-0.11.0 → acumatica_cli-0.12.1}/src/acumatica_cli/templates/distribution/baseline/20-accounts.yaml +0 -0
  26. {acumatica_cli-0.11.0 → acumatica_cli-0.12.1}/src/acumatica_cli/templates/distribution/baseline/60-ledger-company.yaml +0 -0
  27. {acumatica_cli-0.11.0 → acumatica_cli-0.12.1}/src/acumatica_cli/templates/distribution/baseline/90-uoms.yaml +0 -0
  28. {acumatica_cli-0.11.0 → acumatica_cli-0.12.1}/src/acumatica_cli/templates/distribution/bootstrap/company.yaml +0 -0
  29. {acumatica_cli-0.11.0 → acumatica_cli-0.12.1}/src/acumatica_cli/templates/distribution/bootstrap/features.yaml +0 -0
  30. {acumatica_cli-0.11.0 → acumatica_cli-0.12.1}/src/acumatica_cli/templates/distribution/master/10-reason-codes.yaml +0 -0
  31. {acumatica_cli-0.11.0 → acumatica_cli-0.12.1}/src/acumatica_cli/templates/distribution/master/20-in-preferences.yaml +0 -0
  32. {acumatica_cli-0.11.0 → acumatica_cli-0.12.1}/src/acumatica_cli/templates/distribution/master/30-availability-rules.yaml +0 -0
  33. {acumatica_cli-0.11.0 → acumatica_cli-0.12.1}/src/acumatica_cli/templates/distribution/master/40-posting-classes.yaml +0 -0
  34. {acumatica_cli-0.11.0 → acumatica_cli-0.12.1}/src/acumatica_cli/templates/distribution/master/50-warehouse.yaml +0 -0
  35. {acumatica_cli-0.11.0 → acumatica_cli-0.12.1}/src/acumatica_cli/templates/distribution/master/51-warehouse-locations.yaml +0 -0
  36. {acumatica_cli-0.11.0 → acumatica_cli-0.12.1}/src/acumatica_cli/templates/distribution/master/52-warehouse-defaults.yaml +0 -0
  37. {acumatica_cli-0.11.0 → acumatica_cli-0.12.1}/src/acumatica_cli/templates/distribution/master/53-tax-categories.yaml +0 -0
  38. {acumatica_cli-0.11.0 → acumatica_cli-0.12.1}/src/acumatica_cli/templates/distribution/master/54-item-classes.yaml +0 -0
  39. {acumatica_cli-0.11.0 → acumatica_cli-0.12.1}/src/acumatica_cli/templates/distribution/master/56-so-preferences.yaml +0 -0
  40. {acumatica_cli-0.11.0 → acumatica_cli-0.12.1}/src/acumatica_cli/templates/distribution/master/57-po-preferences.yaml +0 -0
  41. {acumatica_cli-0.11.0 → acumatica_cli-0.12.1}/src/acumatica_cli/templates/distribution/master/58-order-types.yaml +0 -0
  42. {acumatica_cli-0.11.0 → acumatica_cli-0.12.1}/src/acumatica_cli/templates/distribution/master/60-ar-preferences.yaml +0 -0
  43. {acumatica_cli-0.11.0 → acumatica_cli-0.12.1}/src/acumatica_cli/templates/distribution/master/61-ap-preferences.yaml +0 -0
  44. {acumatica_cli-0.11.0 → acumatica_cli-0.12.1}/src/acumatica_cli/templates/distribution/master/62-ca-preferences.yaml +0 -0
  45. {acumatica_cli-0.11.0 → acumatica_cli-0.12.1}/src/acumatica_cli/templates/distribution/master/63-cash-account.yaml +0 -0
  46. {acumatica_cli-0.11.0 → acumatica_cli-0.12.1}/src/acumatica_cli/templates/distribution/master/64-payment-methods.yaml +0 -0
  47. {acumatica_cli-0.11.0 → acumatica_cli-0.12.1}/src/acumatica_cli/templates/distribution/master/65-statement-cycles.yaml +0 -0
  48. {acumatica_cli-0.11.0 → acumatica_cli-0.12.1}/src/acumatica_cli/templates/distribution/master/70-vendor-classes.yaml +0 -0
  49. {acumatica_cli-0.11.0 → acumatica_cli-0.12.1}/src/acumatica_cli/templates/distribution/master/71-customer-classes.yaml +0 -0
  50. {acumatica_cli-0.11.0 → acumatica_cli-0.12.1}/src/acumatica_cli/templates/distribution/master/75-vendors.yaml +0 -0
  51. {acumatica_cli-0.11.0 → acumatica_cli-0.12.1}/src/acumatica_cli/templates/distribution/master/76-customers.yaml +0 -0
  52. {acumatica_cli-0.11.0 → acumatica_cli-0.12.1}/src/acumatica_cli/templates/distribution/master/80-stock-items-parts.yaml +0 -0
  53. {acumatica_cli-0.11.0 → acumatica_cli-0.12.1}/src/acumatica_cli/templates/distribution/master/82-stock-items-kits.yaml +0 -0
  54. {acumatica_cli-0.11.0 → acumatica_cli-0.12.1}/src/acumatica_cli/templates/distribution/master/85-kit-specifications.yaml +0 -0
  55. {acumatica_cli-0.11.0 → acumatica_cli-0.12.1}/src/acumatica_cli/templates/distribution/setup/30-open-periods.yaml +0 -0
  56. {acumatica_cli-0.11.0 → acumatica_cli-0.12.1}/src/acumatica_cli/templates/finance/baseline/10-subaccounts.yaml +0 -0
  57. {acumatica_cli-0.11.0 → acumatica_cli-0.12.1}/src/acumatica_cli/templates/finance/baseline/20-accounts.yaml +0 -0
  58. {acumatica_cli-0.11.0 → acumatica_cli-0.12.1}/src/acumatica_cli/templates/finance/baseline/40-ledger.yaml +0 -0
  59. {acumatica_cli-0.11.0 → acumatica_cli-0.12.1}/src/acumatica_cli/templates/finance/baseline/50-gl-preferences.yaml +0 -0
  60. {acumatica_cli-0.11.0 → acumatica_cli-0.12.1}/src/acumatica_cli/templates/finance/baseline/60-ledger-company.yaml +0 -0
  61. {acumatica_cli-0.11.0 → acumatica_cli-0.12.1}/src/acumatica_cli/templates/finance/baseline/90-uoms.yaml +0 -0
  62. {acumatica_cli-0.11.0 → acumatica_cli-0.12.1}/src/acumatica_cli/templates/finance/bootstrap/company.yaml +0 -0
  63. {acumatica_cli-0.11.0 → acumatica_cli-0.12.1}/src/acumatica_cli/templates/finance/bootstrap/credit-terms.yaml +0 -0
  64. {acumatica_cli-0.11.0 → acumatica_cli-0.12.1}/src/acumatica_cli/templates/finance/bootstrap/features.yaml +0 -0
  65. {acumatica_cli-0.11.0 → acumatica_cli-0.12.1}/src/acumatica_cli/templates/finance/env +0 -0
  66. {acumatica_cli-0.11.0 → acumatica_cli-0.12.1}/src/acumatica_cli/templates/finance/gitignore +0 -0
  67. {acumatica_cli-0.11.0 → acumatica_cli-0.12.1}/src/acumatica_cli/templates/finance/setup/10-financial-year.yaml +0 -0
  68. {acumatica_cli-0.11.0 → acumatica_cli-0.12.1}/src/acumatica_cli/templates/finance/setup/20-master-calendar.yaml +0 -0
  69. {acumatica_cli-0.11.0 → acumatica_cli-0.12.1}/src/acumatica_cli/templates/finance/setup/30-open-periods.yaml +0 -0
  70. {acumatica_cli-0.11.0 → acumatica_cli-0.12.1}/src/acumatica_cli/templates/finance/target +0 -0
  71. {acumatica_cli-0.11.0 → acumatica_cli-0.12.1}/src/acumatica_cli/tenant.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: acumatica-cli
3
- Version: 0.11.0
3
+ Version: 0.12.1
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>
@@ -47,15 +47,18 @@ acu --tenant DEV apply # seed bootstrap/, baseline/, setup/ (,
47
47
  acu --tenant DEV diff # prove zero drift (exit 2 on drift)
48
48
  ```
49
49
 
50
- **Distribution demo seed** (inventory, warehouse, items, vendors/customers, golden scenario):
50
+ **Distribution demo seed** (inventory, warehouse, items, vendors/customers, lifecycle scenarios):
51
51
 
52
52
  ```sh
53
53
  acu config init --flavor distribution --host erp.example.com my-dist
54
54
  cd my-dist # edit .env; start from an empty tenant
55
- acu config check && acu bootstrap && acu apply && acu run scenario/ && acu diff
55
+ acu config check && acu bootstrap \
56
+ && acu apply config/ && acu run scenario/ && acu diff config/
57
+ # warm re-run: capital once-skips; Owner Capital stays 50000 (not 100000)
58
+ acu run scenario/
56
59
  ```
57
60
 
58
- See [docs/distribution.md](docs/distribution.md) for the entity map and apply-order notes.
61
+ See [docs/distribution.md](docs/distribution.md) for the entity map, once-guard, and apply-order notes.
59
62
 
60
63
  **Hosted Acumatica (no SSH):** the tenant already exists; leave `ACU_SSH` blank.
61
64
 
@@ -97,34 +100,36 @@ acu [--tenant NAME] [--url URL] [--ssh USER@HOST] [--api-version V]
97
100
  └── check [--strict] preflight: discovery, secrets, target, REST, endpoints, SSH
98
101
  ```
99
102
 
100
- `apply` and `diff` called without FILES default to existing scaffolded directories, in order: `bootstrap/`, `baseline/`, `setup/`, then `master/` when present.
101
- `run` called without FILES defaults to `scenario/`.
103
+ `apply` and `diff` without FILES prefer `config/<name>/` when any seed child exists under `config/`; otherwise root `bootstrap/`, `baseline/`, `setup/`, then `master/` when present.
104
+ A path like `config/` expands nested seed dirs in that fixed order.
105
+ `run` without FILES defaults to `scenario/`.
102
106
  `acu --completion` emits a completion script for bash, zsh, or fish — source it from your shell profile.
103
107
  Run `acu <command> --help` for details on any command.
104
108
 
105
109
  ## The data repo
106
110
 
107
111
  Your configuration lives in its own git repo.
108
- `acu config init` scaffolds finance-minimal seeds (Bootstrap `project.xml` at `Bootstrap/1.0.0`, no `master/`).
109
- `acu config init --flavor distribution` adds the full demo set (same contract identity, expanded COA, `master/`, golden `scenario/`, README).
110
-
111
- | Path | What it holds |
112
- | ------------- | -------------------------------------------------------------------------- |
113
- | `bootstrap/` | what makes a virgin tenant configurable: features, company, credit terms |
114
- | `baseline/` | reference data: subaccounts, chart of accounts, ledger, units of measure |
115
- | `setup/` | one-time actions: financial year, master calendar, open periods |
116
- | `master/` | distribution masters (prefs, warehouse, items, vendors, customers); flavor only |
117
- | `scenario/` | transaction scenarios for `acu run`: purchase, build, sell flows |
118
- | `target.yaml` | committed verified matrix: `erp` + `default_api` (what, not where) |
119
- | `.env` | where to apply and who signs in, every key an `ACU_*` variable |
112
+ `acu config init` scaffolds finance-minimal seeds at the **root** (Bootstrap `project.xml` at `Bootstrap/1.0.0`, no `master/`, no scenario).
113
+ `acu config init --flavor distribution` scaffolds the full demo under `config/` (same contract identity, expanded COA, masters) plus lifecycle `scenario/` and README.
114
+
115
+ | Path | What it holds |
116
+ | ---- | ------------- |
117
+ | `bootstrap/` / `config/bootstrap/` | virgin-tenant config: features, company, credit terms, `project.xml` |
118
+ | `baseline/` / `config/baseline/` | reference data: subaccounts, COA, ledger, UOMs |
119
+ | `setup/` / `config/setup/` | one-time actions: financial year, master calendar, open periods |
120
+ | `config/master/` | distribution masters (prefs, warehouse, items, parties); flavor only |
121
+ | `scenario/` | lifecycle txns for `acu run`: once capital, then buy, build stub, sell |
122
+ | `target.yaml` | committed verified matrix: `erp` + `default_api` (what, not where) |
123
+ | `.env` | where to apply and who signs in, every key an `ACU_*` variable |
120
124
 
121
125
  Files in each directory apply alphabetically; the numbered prefixes (`10-`, `20-`, and so on) encode dependency order.
122
126
  The scaffolded `.gitignore` keeps `.env` out of git — store it encrypted (for example as `.env.gpg`) and decrypt once per clone.
123
127
  Commit `target.yaml` with the seeds so every clone knows the verified ERP line and Default API generation.
124
128
 
125
- Seed YAML in `bootstrap/`, `baseline/`, and `setup/` is state: `apply` upserts it, `diff` proves it.
129
+ Seed YAML is state: `apply` upserts it, `diff` proves it.
126
130
  Scenario YAML is different — it describes transactions that flow forward.
127
- `acu run` executes each step in order (`put`, `action`, `wait`, `get`), captures server-assigned document numbers into `${var}` references for later steps, and checks `expect:` assertions as deltas against a pre-run snapshot, so a scenario re-runs safely on a warm tenant.
131
+ `acu run` executes each step in order (`put`, `action`, `wait`, `get`), captures server-assigned document numbers into `${var}` references for later steps, and checks `expect:` assertions as deltas against a pre-run snapshot, so additive scenarios re-run safely on a warm tenant.
132
+ `once: true` scenarios declare a `present` inquire-absolute gate; when the probe already holds, the CLI prints `skip <path> (once: already present)` and runs neither steps nor expects (Owner Capital does not restack).
128
133
 
129
134
  ### Seed `endpoint:` symbols
130
135
 
@@ -29,15 +29,18 @@ acu --tenant DEV apply # seed bootstrap/, baseline/, setup/ (,
29
29
  acu --tenant DEV diff # prove zero drift (exit 2 on drift)
30
30
  ```
31
31
 
32
- **Distribution demo seed** (inventory, warehouse, items, vendors/customers, golden scenario):
32
+ **Distribution demo seed** (inventory, warehouse, items, vendors/customers, lifecycle scenarios):
33
33
 
34
34
  ```sh
35
35
  acu config init --flavor distribution --host erp.example.com my-dist
36
36
  cd my-dist # edit .env; start from an empty tenant
37
- acu config check && acu bootstrap && acu apply && acu run scenario/ && acu diff
37
+ acu config check && acu bootstrap \
38
+ && acu apply config/ && acu run scenario/ && acu diff config/
39
+ # warm re-run: capital once-skips; Owner Capital stays 50000 (not 100000)
40
+ acu run scenario/
38
41
  ```
39
42
 
40
- See [docs/distribution.md](docs/distribution.md) for the entity map and apply-order notes.
43
+ See [docs/distribution.md](docs/distribution.md) for the entity map, once-guard, and apply-order notes.
41
44
 
42
45
  **Hosted Acumatica (no SSH):** the tenant already exists; leave `ACU_SSH` blank.
43
46
 
@@ -79,34 +82,36 @@ acu [--tenant NAME] [--url URL] [--ssh USER@HOST] [--api-version V]
79
82
  └── check [--strict] preflight: discovery, secrets, target, REST, endpoints, SSH
80
83
  ```
81
84
 
82
- `apply` and `diff` called without FILES default to existing scaffolded directories, in order: `bootstrap/`, `baseline/`, `setup/`, then `master/` when present.
83
- `run` called without FILES defaults to `scenario/`.
85
+ `apply` and `diff` without FILES prefer `config/<name>/` when any seed child exists under `config/`; otherwise root `bootstrap/`, `baseline/`, `setup/`, then `master/` when present.
86
+ A path like `config/` expands nested seed dirs in that fixed order.
87
+ `run` without FILES defaults to `scenario/`.
84
88
  `acu --completion` emits a completion script for bash, zsh, or fish — source it from your shell profile.
85
89
  Run `acu <command> --help` for details on any command.
86
90
 
87
91
  ## The data repo
88
92
 
89
93
  Your configuration lives in its own git repo.
90
- `acu config init` scaffolds finance-minimal seeds (Bootstrap `project.xml` at `Bootstrap/1.0.0`, no `master/`).
91
- `acu config init --flavor distribution` adds the full demo set (same contract identity, expanded COA, `master/`, golden `scenario/`, README).
92
-
93
- | Path | What it holds |
94
- | ------------- | -------------------------------------------------------------------------- |
95
- | `bootstrap/` | what makes a virgin tenant configurable: features, company, credit terms |
96
- | `baseline/` | reference data: subaccounts, chart of accounts, ledger, units of measure |
97
- | `setup/` | one-time actions: financial year, master calendar, open periods |
98
- | `master/` | distribution masters (prefs, warehouse, items, vendors, customers); flavor only |
99
- | `scenario/` | transaction scenarios for `acu run`: purchase, build, sell flows |
100
- | `target.yaml` | committed verified matrix: `erp` + `default_api` (what, not where) |
101
- | `.env` | where to apply and who signs in, every key an `ACU_*` variable |
94
+ `acu config init` scaffolds finance-minimal seeds at the **root** (Bootstrap `project.xml` at `Bootstrap/1.0.0`, no `master/`, no scenario).
95
+ `acu config init --flavor distribution` scaffolds the full demo under `config/` (same contract identity, expanded COA, masters) plus lifecycle `scenario/` and README.
96
+
97
+ | Path | What it holds |
98
+ | ---- | ------------- |
99
+ | `bootstrap/` / `config/bootstrap/` | virgin-tenant config: features, company, credit terms, `project.xml` |
100
+ | `baseline/` / `config/baseline/` | reference data: subaccounts, COA, ledger, UOMs |
101
+ | `setup/` / `config/setup/` | one-time actions: financial year, master calendar, open periods |
102
+ | `config/master/` | distribution masters (prefs, warehouse, items, parties); flavor only |
103
+ | `scenario/` | lifecycle txns for `acu run`: once capital, then buy, build stub, sell |
104
+ | `target.yaml` | committed verified matrix: `erp` + `default_api` (what, not where) |
105
+ | `.env` | where to apply and who signs in, every key an `ACU_*` variable |
102
106
 
103
107
  Files in each directory apply alphabetically; the numbered prefixes (`10-`, `20-`, and so on) encode dependency order.
104
108
  The scaffolded `.gitignore` keeps `.env` out of git — store it encrypted (for example as `.env.gpg`) and decrypt once per clone.
105
109
  Commit `target.yaml` with the seeds so every clone knows the verified ERP line and Default API generation.
106
110
 
107
- Seed YAML in `bootstrap/`, `baseline/`, and `setup/` is state: `apply` upserts it, `diff` proves it.
111
+ Seed YAML is state: `apply` upserts it, `diff` proves it.
108
112
  Scenario YAML is different — it describes transactions that flow forward.
109
- `acu run` executes each step in order (`put`, `action`, `wait`, `get`), captures server-assigned document numbers into `${var}` references for later steps, and checks `expect:` assertions as deltas against a pre-run snapshot, so a scenario re-runs safely on a warm tenant.
113
+ `acu run` executes each step in order (`put`, `action`, `wait`, `get`), captures server-assigned document numbers into `${var}` references for later steps, and checks `expect:` assertions as deltas against a pre-run snapshot, so additive scenarios re-run safely on a warm tenant.
114
+ `once: true` scenarios declare a `present` inquire-absolute gate; when the probe already holds, the CLI prints `skip <path> (once: already present)` and runs neither steps nor expects (Owner Capital does not restack).
110
115
 
111
116
  ### Seed `endpoint:` symbols
112
117
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "acumatica-cli"
3
- version = "0.11.0"
3
+ version = "0.12.1"
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
  license = "PolyForm-Noncommercial-1.0.0"
@@ -8,10 +8,11 @@ virgin tenant, so bootstrap = publish a package whose CustomizationPlugin
8
8
  cannot write CS100000 at all (T3 verdict) — and whose Bootstrap contract
9
9
  endpoint exposes the seeding surface (serialization verified T12).
10
10
 
11
- Contract ownership is hybrid (T69/V2/T81): the data repo's
12
- ``bootstrap/project.xml`` is preferred when present; else the packaged
13
- full company ``bootstrap_project.xml`` (``Bootstrap/1.0.0``) so PyPI-only
14
- and offline virgin paths still bootstrap the full surface.
11
+ Contract ownership is hybrid (T69/V2/T81/T85): the data repo's
12
+ ``config/bootstrap/project.xml`` is preferred, then root
13
+ ``bootstrap/project.xml``; else the packaged full company
14
+ ``bootstrap_project.xml`` (``Bootstrap/1.0.0``) so PyPI-only and offline
15
+ virgin paths still bootstrap the full surface.
15
16
 
16
17
  Customization publishes are tenant-scoped, so the package must be published
17
18
  per tenant; publish() is idempotent on content — the skip gate compares the
@@ -19,8 +20,9 @@ digest embedded in the published package's description against the package
19
20
  built now, and the plugin's UpdateDatabase is a keyed update on re-run.
20
21
 
21
22
  The feature set the plugin enables is data, not code (V2): load_features()
22
- reads the data repo's bootstrap/features.yaml (absent -> the built-in six)
23
- and package_zip() splices it into the plugin source at build time.
23
+ reads config/bootstrap/features.yaml then root bootstrap/features.yaml
24
+ (absent -> the built-in six) and package_zip() splices it into the plugin
25
+ source at build time.
24
26
  """
25
27
 
26
28
  import hashlib
@@ -43,11 +45,10 @@ PACKAGE_NAME = "AcuBootstrap"
43
45
  PLUGIN_CLASS = "AcuBootstrapPlugin"
44
46
  _ENDPOINT_NS = "{http://www.acumatica.com/entity/maintenance/5.31}"
45
47
 
46
- # Code default when the data repo carries no bootstrap/features.yaml
47
- # (SPEC I.data) — the minimum for company/branch + baseline seeding.
48
- # The set is injected into the plugin source at package build; it never
49
- # lives in bootstrap_plugin.cs (V2: feature flags are config, not tool
50
- # source — B6).
48
+ # Code default when the data repo carries no features.yaml (SPEC I.data)
49
+ # — the minimum for company/branch + baseline seeding. The set is injected
50
+ # into the plugin source at package build; it never lives in
51
+ # bootstrap_plugin.cs (V2: feature flags are config, not tool source — B6).
51
52
  DEFAULT_FEATURES = (
52
53
  "FinancialModule",
53
54
  "FinancialStandard",
@@ -59,6 +60,14 @@ DEFAULT_FEATURES = (
59
60
  FEATURES_SENTINEL = "/*ACU_FEATURES*/"
60
61
 
61
62
 
63
+ def _bootstrap_file(root: Path, name: str) -> Path | None:
64
+ """Resolve a bootstrap package input: config/bootstrap then root (V30)."""
65
+ for path in (root / "config" / "bootstrap" / name, root / "bootstrap" / name):
66
+ if path.is_file():
67
+ return path
68
+ return None
69
+
70
+
62
71
  def packaged_contract_xml() -> bytes:
63
72
  """The CLI-shipped full company Bootstrap contract (``Bootstrap/1.0.0``)."""
64
73
  return (resources.files("acumatica_cli") / "bootstrap_project.xml").read_bytes()
@@ -68,12 +77,13 @@ def load_contract_xml(root: Path | None = None) -> bytes:
68
77
  """Active contract bytes: data-repo override when present, else packaged.
69
78
 
70
79
  ``root`` is the data-repo discovery root (the dir holding ``.env``).
71
- Absent root or absent ``bootstrap/project.xml`` falls back to the
72
- packaged full company contract (V2/T81, same absence pattern as features.yaml).
80
+ Prefer ``config/bootstrap/project.xml``, then root ``bootstrap/project.xml``
81
+ (V30 dual resolve). Absent root or file falls back to the packaged full
82
+ company contract (V2/T81, same absence pattern as features.yaml).
73
83
  """
74
84
  if root is not None:
75
- path = root / "bootstrap" / "project.xml"
76
- if path.is_file():
85
+ path = _bootstrap_file(root, "project.xml")
86
+ if path is not None:
77
87
  return path.read_bytes()
78
88
  return packaged_contract_xml()
79
89
 
@@ -97,15 +107,16 @@ PACKAGE_DESCRIPTION = ET.fromstring(packaged_contract_xml()).get("description",
97
107
 
98
108
 
99
109
  def load_features(root: Path) -> list[str]:
100
- """The FeaturesSet property names from <root>/bootstrap/features.yaml.
110
+ """FeaturesSet property names from config/ then root bootstrap (V30).
101
111
 
112
+ Prefer ``config/bootstrap/features.yaml``, then ``bootstrap/features.yaml``.
102
113
  Absent file -> the built-in six (SPEC I.data). Names are validated as
103
114
  plausible property names here (they are spliced into C# string literals);
104
115
  whether each matches a real FeaturesSet property only the plugin can
105
116
  tell — it logs the strays at publish time (the silent-typo guard).
106
117
  """
107
- path = root / "bootstrap" / "features.yaml"
108
- if not path.is_file():
118
+ path = _bootstrap_file(root, "features.yaml")
119
+ if path is None:
109
120
  return list(DEFAULT_FEATURES)
110
121
  with open(path) as f:
111
122
  data = yaml.safe_load(f)
@@ -136,9 +147,9 @@ def package_zip(
136
147
  ``Enabled`` set at the ACU_FEATURES sentinel — the one point where the
137
148
  data repo's feature list enters the package (V2).
138
149
 
139
- Contract XML (V2/T69/T81): ``contract`` when given; else
140
- ``bootstrap/project.xml`` under ``root`` when present; else the
141
- packaged full company template.
150
+ Contract XML (V2/T69/T81/T85): ``contract`` when given; else
151
+ ``config/bootstrap/project.xml`` then ``bootstrap/project.xml`` under
152
+ ``root`` when present; else the packaged full company template.
142
153
  """
143
154
  pkg = resources.files("acumatica_cli")
144
155
  if contract is None:
@@ -428,26 +428,27 @@ def config_init(host: str | None, flavor: str | None, directory: Path | None) ->
428
428
  Templates ship with the package; every value is a placeholder or a
429
429
  verified minimal example - no secrets. Both flavors scaffold the full
430
430
  company ``bootstrap/project.xml`` (Bootstrap/1.0.0). ``--flavor
431
- distribution`` adds expanded COA, master/, scenario/, and README (V28).
432
- Existing files are never overwritten (reported as skipped). DIRECTORY
433
- defaults to the current directory and is created if absent. No git
434
- init, no gpg.
431
+ distribution`` scaffolds under ``config/`` + lifecycle ``scenario/``
432
+ + README (V28/T87). Existing files are never overwritten (reported as
433
+ skipped). DIRECTORY defaults to the current directory and is created
434
+ if absent. No git init, no gpg.
435
435
  """
436
436
  target = directory or Path.cwd()
437
437
  for action, path in scaffold(target, host=host, flavor=flavor):
438
438
  suffix = " (exists)" if action == "skip" else ""
439
439
  output.data(f"{action} {path}{suffix}")
440
- # next-step cmds (issue #18): operator rebuild order after scaffold
440
+ # next-step cmds (issue #18/#19): operator rebuild order after scaffold
441
441
  output.data("")
442
442
  output.data("next:")
443
443
  output.data(" 1. edit .env (set ACU_PASSWORD, ACU_TENANT; keep ACU_API_VERSION)")
444
444
  output.data(" 2. acu config check")
445
445
  output.data(" 3. acu bootstrap # or: acu tenant create ... (SSH)")
446
- output.data(" 4. acu apply")
447
446
  if flavor == "distribution":
447
+ output.data(" 4. acu apply config/")
448
448
  output.data(" 5. acu run scenario/")
449
- output.data(" 6. acu diff")
449
+ output.data(" 6. acu diff config/")
450
450
  else:
451
+ output.data(" 4. acu apply")
451
452
  output.data(" 5. acu diff")
452
453
 
453
454
 
@@ -536,37 +537,73 @@ def config_check(ctx: click.Context, strict: bool) -> None:
536
537
  # both live probes run through the exact objects live commands use, so
537
538
  # a pass here proves the real code path, not a parallel one
538
539
  inst = _resolve_instance(ctx)
539
- failed = _probe_target(root, inst, strict=strict)
540
+ failed, claimed_erp = _probe_target(root, inst, strict=strict)
541
+ if not _probe_rest(inst, claimed_erp):
542
+ failed = True
543
+ if not _probe_ssh(inst):
544
+ failed = True
545
+ if failed:
546
+ raise SystemExit(1)
547
+
548
+
549
+ def _probe_rest(inst: Instance, claimed_erp: str | None) -> bool:
550
+ """REST login + endpoints + optional ERP build; True = all passed.
551
+
552
+ Entering the client is the probe: login + landed-tenant verify (V5);
553
+ context manager guarantees logout (V6). One GET /entity feeds endpoints
554
+ (T74/T90) and optional ERP build (T92).
555
+ """
540
556
  try:
541
- # entering the client is the whole probe: login + landed-tenant
542
- # verify (V5), and the context manager guarantees logout (V6);
543
- # endpoints probe (T74) reuses the same session when login succeeds
544
557
  with AcumaticaClient(inst) as client:
545
558
  output.data(f"ok rest ({inst.base_url}, tenant {inst.tenant})")
546
- if not _probe_endpoints(client, inst):
547
- failed = True
559
+ return _probe_entity_root(client, inst, claimed_erp)
548
560
  except (RuntimeError, httpx.HTTPError) as exc:
549
561
  output.data(f"fail rest: {exc}")
550
- failed = True
562
+ return False
563
+
564
+
565
+ def _probe_entity_root(
566
+ client: AcumaticaClient, inst: Instance, claimed_erp: str | None
567
+ ) -> bool:
568
+ """Endpoints (+ ERP when claimed); True = all passed."""
569
+ ok = True
570
+ try:
571
+ endpoints, live_build = client.entity_root()
572
+ except (RuntimeError, httpx.HTTPError) as exc:
573
+ output.data(f"fail endpoints: {exc}")
574
+ endpoints, live_build = [], None
575
+ ok = False
576
+ else:
577
+ if not _probe_endpoints(endpoints, inst):
578
+ ok = False
579
+ if claimed_erp is not None and not _probe_erp(claimed_erp, live_build):
580
+ ok = False
581
+ return ok
582
+
583
+
584
+ def _probe_ssh(inst: Instance) -> bool:
585
+ """SSH ping or skip when unset; True = pass/skip, False = fail."""
551
586
  if not inst.ssh:
552
587
  # V3/I.cmd: ACU_SSH optional — hosted data-plane path skips, never fails
553
588
  output.data("skip ssh (ACU_SSH not set)")
554
- else:
555
- try:
556
- TenantManager(inst).ping()
557
- output.data(f"ok ssh ({inst.ssh})")
558
- except RuntimeError as exc:
559
- output.data(f"fail ssh: {exc}")
560
- failed = True
561
- if failed:
562
- raise SystemExit(1)
589
+ return True
590
+ try:
591
+ TenantManager(inst).ping()
592
+ output.data(f"ok ssh ({inst.ssh})")
593
+ return True
594
+ except RuntimeError as exc:
595
+ output.data(f"fail ssh: {exc}")
596
+ return False
563
597
 
564
598
 
565
- def _probe_target(root: Path | None, inst: Instance, *, strict: bool) -> bool:
566
- """Emit the local target probe line; return True when it failed (V27).
599
+ def _probe_target(
600
+ root: Path | None, inst: Instance, *, strict: bool
601
+ ) -> tuple[bool, str | None]:
602
+ """Emit local target probe; return ``(failed, claimed_erp?)`` (V27).
567
603
 
568
604
  Invalid target hard-exits (any loader). Missing under data root warns
569
- unless --strict. No data root → skip. Match → ok (erp claimed-only).
605
+ unless --strict. No data root → skip. Match → ok + claimed ``erp`` for
606
+ the live ERP probe after REST (T92).
570
607
  """
571
608
  try:
572
609
  target = load_target(root)
@@ -575,7 +612,7 @@ def _probe_target(root: Path | None, inst: Instance, *, strict: bool) -> bool:
575
612
  raise SystemExit(1) from exc
576
613
  if root is None:
577
614
  output.data("skip target (no data root)")
578
- return False
615
+ return False, None
579
616
  if target is None:
580
617
  msg = (
581
618
  f"target: no target.yaml under {root} - dataset verified matrix "
@@ -583,35 +620,27 @@ def _probe_target(root: Path | None, inst: Instance, *, strict: bool) -> bool:
583
620
  "to require it"
584
621
  )
585
622
  output.data(f"{'fail' if strict else 'warn'} {msg}")
586
- return strict
623
+ return strict, None
587
624
  if target.default_api != inst.api_version:
588
625
  output.data(
589
626
  f"fail target: dataset default_api={target.default_api} vs "
590
627
  f"configured {inst.api_version}"
591
628
  )
592
- return True
629
+ return True, None
593
630
  output.data(
594
631
  f"ok target (default_api={target.default_api} matches configured; "
595
632
  f"erp={target.erp} claimed)"
596
633
  )
597
- # T76: no stable HTTP ERP-build discovery (V12) — keep erp claimed-only;
598
- # skip rather than invent SSH/sqlcmd (control-plane exclusion, V1)
599
- output.data(f"skip erp (live probe not available; claimed {target.erp})")
600
- return False
634
+ return False, target.erp
601
635
 
602
636
 
603
- def _probe_endpoints(client: AcumaticaClient, inst: Instance) -> bool:
637
+ def _probe_endpoints(endpoints: list[tuple[str, str]], inst: Instance) -> bool:
604
638
  """Emit endpoints probe; return True on pass, False on fail (V12/V27).
605
639
 
606
640
  Exact match: a Default entry whose version half equals
607
- ``Instance.api_version``. Fail-closed when GET /entity is unparseable.
641
+ ``Instance.api_version``. Caller already fail-closed on unparseable GET.
608
642
  """
609
643
  want = f"Default/{inst.api_version}"
610
- try:
611
- endpoints = client.list_endpoints()
612
- except (RuntimeError, httpx.HTTPError) as exc:
613
- output.data(f"fail endpoints: {exc}")
614
- return False
615
644
  defaults = [v for name, v in endpoints if name == "Default"]
616
645
  if inst.api_version in defaults:
617
646
  output.data(f"ok endpoints ({want} present)")
@@ -624,40 +653,91 @@ def _probe_endpoints(client: AcumaticaClient, inst: Instance) -> bool:
624
653
  return False
625
654
 
626
655
 
656
+ def _major_minor(version: str) -> str:
657
+ """First two dotted segments (T76/T92 major.minor match)."""
658
+ parts = version.split(".")
659
+ if len(parts) >= 2:
660
+ return f"{parts[0]}.{parts[1]}"
661
+ return version
662
+
663
+
664
+ def _probe_erp(claimed: str, live: str | None) -> bool:
665
+ """Emit ERP probe; return True on pass/skip, False on fail (T92).
666
+
667
+ Live id comes from 26.x ``GET /entity`` wrapper
668
+ ``version.acumaticaBuildVersion``. Bare array → skip (no HTTP surface).
669
+ Compare major.minor only — patch builds may drift within a claimed line.
670
+ """
671
+ if live is None:
672
+ output.data(f"skip erp (live probe not available; claimed {claimed})")
673
+ return True
674
+ if _major_minor(live) == _major_minor(claimed):
675
+ output.data(f"ok erp ({live} matches claimed {claimed})")
676
+ return True
677
+ output.data(
678
+ f"fail erp: live {live} vs claimed {claimed} "
679
+ f"(major.minor {_major_minor(live)} vs {_major_minor(claimed)})"
680
+ )
681
+ return False
682
+
683
+
627
684
  SEED_DIRS = ("bootstrap", "baseline", "setup", "master")
628
685
 
629
686
 
687
+ def _seed_child_dirs(parent: Path) -> list[Path]:
688
+ """SEED_DIRS children of ``parent`` that exist, fixed order (V22/V30)."""
689
+ return [parent / name for name in SEED_DIRS if (parent / name).is_dir()]
690
+
691
+
630
692
  def default_seed_dirs() -> tuple[Path, ...]:
631
- """The init-scaffolded seed dirs that exist at the data-repo root.
632
-
633
- apply and diff default to these when called with no FILES, in fixed
634
- order (bootstrap, baseline, setup, master); only dirs that exist are
635
- included (finance-minimal has no master/; distribution does). The data
636
- repo is the .env dir (V3 walk-up). None existing is an error - an empty
637
- default would make a bare run a silent no-op. Paths come back relative
638
- to cwd (the root is always cwd or an ancestor), so a bare run prints
639
- exactly what naming the dirs would.
693
+ """Default apply/diff dirs: prefer ``config/`` SEED_DIRS, else root (V30).
694
+
695
+ When the data-repo ``config/`` has any SEED_DIRS child, only those
696
+ ``config/<name>/`` paths are returned (dual layout never merges with
697
+ root). Else root ``bootstrap/``…``master/`` for present names
698
+ (finance-minimal). The data repo is the .env dir (V3 walk-up). None
699
+ existing is an error - an empty default would make a bare run a silent
700
+ no-op. Paths come back relative to cwd so a bare run prints exactly
701
+ what naming the dirs would.
640
702
  """
641
703
  root = data_root()
642
- dirs = tuple(
643
- Path(os.path.relpath(d)) for name in SEED_DIRS if (d := root / name).is_dir()
644
- )
704
+ config = root / "config"
705
+ if config.is_dir() and _seed_child_dirs(config):
706
+ parents = _seed_child_dirs(config)
707
+ else:
708
+ parents = _seed_child_dirs(root)
709
+ dirs = tuple(Path(os.path.relpath(d)) for d in parents)
645
710
  if not dirs:
646
711
  expected = ", ".join(f"{name}/" for name in SEED_DIRS)
647
- raise SystemExit(f"{root}: none of the seed directories exist ({expected})")
712
+ raise SystemExit(
713
+ f"{root}: none of the seed directories exist (config/<name>/ or {expected})"
714
+ )
648
715
  return dirs
649
716
 
650
717
 
718
+ def _leaf_yaml(directory: Path) -> list[Path]:
719
+ """Sorted ``*.yaml`` in a leaf seed dir; skip ``features.yaml`` (I.data)."""
720
+ return sorted(p for p in directory.glob("*.yaml") if p.name != "features.yaml")
721
+
722
+
651
723
  def expand_files(files: tuple[Path, ...]) -> list[Path]:
652
- """Expand directory arguments into their *.yaml files, sorted.
724
+ """Expand directory arguments into seed ``*.yaml`` files (V22/V30).
653
725
 
654
- features.yaml is skipped: it configures the bootstrap package build
655
- (a feature-name list, SPEC I.data), not an entity/records seed file.
726
+ A dir with any SEED_DIRS child (umbrella e.g. ``config/``) expands those
727
+ nested subdirs in fixed SEED_DIRS order, then leaf ``*.yaml`` per subdir.
728
+ A leaf dir expands its own ``*.yaml`` only. ``features.yaml`` is skipped:
729
+ it configures the bootstrap package build, not an entity/records seed.
656
730
  """
657
731
  paths: list[Path] = []
658
732
  for path in files:
659
733
  if path.is_dir():
660
- found = sorted(p for p in path.glob("*.yaml") if p.name != "features.yaml")
734
+ children = _seed_child_dirs(path)
735
+ if children:
736
+ found: list[Path] = []
737
+ for child in children:
738
+ found += _leaf_yaml(child)
739
+ else:
740
+ found = _leaf_yaml(path)
661
741
  if not found:
662
742
  raise SystemExit(f"{path}: no seed *.yaml files in directory")
663
743
  paths += found
@@ -675,9 +755,10 @@ def expand_files(files: tuple[Path, ...]) -> list[Path]:
675
755
  def apply_cmd(inst: Instance, files: tuple[Path, ...], dry_run: bool) -> None:
676
756
  """Seed baseline YAML into the tenant (idempotent PUT upserts).
677
757
 
678
- FILES are baseline YAML files or directories containing them. Omitted,
679
- they default to the data repo's existing init-scaffolded directories in
680
- fixed order: bootstrap/, baseline/, setup/, master/ (when present).
758
+ FILES are baseline YAML files or directories containing them. A dir with
759
+ SEED_DIRS children (e.g. config/) expands nested trees in fixed order.
760
+ Omitted, defaults prefer config/<name>/ when present, else root SEED_DIRS
761
+ (V30).
681
762
  """
682
763
  assert_target_compatible(inst)
683
764
  with AcumaticaClient(inst) as client:
@@ -729,9 +810,10 @@ def schema_cmd(inst: Instance, out_dir: Path | None) -> None:
729
810
  def diff_cmd(inst: Instance, files: tuple[Path, ...]) -> None:
730
811
  """Compare baseline YAML against the live tenant; exit 2 on drift.
731
812
 
732
- FILES are baseline YAML files or directories containing them. Omitted,
733
- they default to the data repo's existing init-scaffolded directories in
734
- fixed order: bootstrap/, baseline/, setup/, master/ (when present).
813
+ FILES are baseline YAML files or directories containing them. A dir with
814
+ SEED_DIRS children (e.g. config/) expands nested trees in fixed order.
815
+ Omitted, defaults prefer config/<name>/ when present, else root SEED_DIRS
816
+ (V30).
735
817
  """
736
818
  assert_target_compatible(inst)
737
819
  paths = expand_files(files or default_seed_dirs())