acumatica-cli 0.2.2__py3-none-any.whl

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.
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
+ }
@@ -0,0 +1,117 @@
1
+ <?xml version="1.0" encoding="utf-8"?>
2
+ <!--
3
+ acu bootstrap customization package template (SPEC T2 + T11 + T12).
4
+
5
+ package_zip() (bootstrap.py) parses this template and injects a
6
+ <Graph ClassName="AcuBootstrapPlugin" FileType="NewFile" Source="..."/>
7
+ item carrying bootstrap_plugin.cs - the CustomizationPlugin that enables
8
+ features on publish (CS100000 is un-writable through the contract API;
9
+ T3 verdict). Item shape verified vs 26.101.0225: Customization.CstCodeFile
10
+ Tag=Graph, source rides in the Source ATTRIBUTE (XML-escaped), not CDATA.
11
+
12
+ Root element shape verified vs the training packages shipped on the live
13
+ box (HelpAndTraining/T*/PhoneRepairShop.zip, same 26.101.0225 build):
14
+ project.xml's root is <Customization level description product-version>;
15
+ the project NAME comes from the import call, not the XML.
16
+
17
+ The <EntityEndpoint> item (T12, verified vs 26.101.0225 by import
18
+ round-trip 2026-07-08): the <Endpoint> child is the XmlSerializer form of
19
+ PX.Api.ContractBased.Common.Model.Endpoint - namespace
20
+ http://www.acumatica.com/entity/maintenance/5.31 (the earlier
21
+ entity/data-model guess is what the importer rejected as "Unknown root
22
+ node"), systemContractVersion 4 (SystemContracts.V4, the build's only
23
+ IsCurrent implementation). No .endpoint file is involved - the
24
+ PX.Api.ContractBased.Common.dll "*.endpoint" globs belong to the
25
+ source-control folder layout, not the package zip. The server's own
26
+ getProject re-serialization carries no attributes on <EntityEndpoint>;
27
+ identity comes from the child's name/version.
28
+
29
+ Entity payload (screen->graph->view->DAC chain read off the live box):
30
+ - Company CS101500 -> OrganizationMaint, PrimaryView BAccount
31
+ (DAC PX.Objects.CS.DAC.OrganizationBAccount); the old
32
+ draft's Organization view / OrganizationCD do not exist on
33
+ this build. Mappings follow the screen's OWN bindings
34
+ (CS101500.aspx read off the live box, T13) - a DAC prop
35
+ existing on the primary view is not enough, writes only
36
+ land through the view the screen edits:
37
+ - AcctCD/AcctName -> BAccount (primary view);
38
+ - OrganizationType/BaseCuryID -> OrganizationView
39
+ (PX.Objects.GL.DAC.Organization; the BAccount projections
40
+ of these echo the PUT but insert an empty GL row - 422
41
+ "BaseCuryID cannot be empty");
42
+ - CountryID -> AddressDummy (PX.Objects.CR.Address, the
43
+ Main Address binding; the graph inserts the Address row
44
+ on first save and its CountryID is required - 422).
45
+ - CreditTerms CS206500 -> TermsMaint, PrimaryView TermsDef
46
+ (DAC PX.Objects.CS.Terms); DayDue00 is Nullable<Int16>
47
+ -> ShortValue, DiscPercent Nullable<Decimal> -> DecimalValue.
48
+ Contract field names = DAC property names verbatim (no rename layer).
49
+ Features (CS100000) stays OUT of the endpoint - writes through any
50
+ contract endpoint do not persist (T3 verdict); the plugin owns features.
51
+ -->
52
+ <Customization level="" description="acu bootstrap: CustomizationPlugin enables features on publish; Bootstrap endpoint exposes company + credit terms" product-version="26.101">
53
+ <EntityEndpoint>
54
+ <Endpoint xmlns="http://www.acumatica.com/entity/maintenance/5.31" name="Bootstrap" version="1.0.0" systemContractVersion="4">
55
+ <TopLevelEntity name="Company" screen="CS101500">
56
+ <Fields>
57
+ <Field name="AcctCD" type="StringValue" />
58
+ <Field name="AcctName" type="StringValue" />
59
+ <Field name="OrganizationType" type="StringValue" />
60
+ <Field name="BaseCuryID" type="StringValue" />
61
+ <Field name="CountryID" type="StringValue" />
62
+ </Fields>
63
+ <Mappings>
64
+ <Mapping field="AcctCD">
65
+ <To object="BAccount" field="AcctCD" />
66
+ </Mapping>
67
+ <Mapping field="AcctName">
68
+ <To object="BAccount" field="AcctName" />
69
+ </Mapping>
70
+ <Mapping field="OrganizationType">
71
+ <To object="OrganizationView" field="OrganizationType" />
72
+ </Mapping>
73
+ <Mapping field="BaseCuryID">
74
+ <To object="OrganizationView" field="BaseCuryID" />
75
+ </Mapping>
76
+ <Mapping field="CountryID">
77
+ <To object="AddressDummy" field="CountryID" />
78
+ </Mapping>
79
+ </Mappings>
80
+ </TopLevelEntity>
81
+ <TopLevelEntity name="CreditTerms" screen="CS206500">
82
+ <Fields>
83
+ <Field name="TermsID" type="StringValue" />
84
+ <Field name="Descr" type="StringValue" />
85
+ <Field name="VisibleTo" type="StringValue" />
86
+ <Field name="DueType" type="StringValue" />
87
+ <Field name="DayDue00" type="ShortValue" />
88
+ <Field name="DiscType" type="StringValue" />
89
+ <Field name="DiscPercent" type="DecimalValue" />
90
+ </Fields>
91
+ <Mappings>
92
+ <Mapping field="TermsID">
93
+ <To object="TermsDef" field="TermsID" />
94
+ </Mapping>
95
+ <Mapping field="Descr">
96
+ <To object="TermsDef" field="Descr" />
97
+ </Mapping>
98
+ <Mapping field="VisibleTo">
99
+ <To object="TermsDef" field="VisibleTo" />
100
+ </Mapping>
101
+ <Mapping field="DueType">
102
+ <To object="TermsDef" field="DueType" />
103
+ </Mapping>
104
+ <Mapping field="DayDue00">
105
+ <To object="TermsDef" field="DayDue00" />
106
+ </Mapping>
107
+ <Mapping field="DiscType">
108
+ <To object="TermsDef" field="DiscType" />
109
+ </Mapping>
110
+ <Mapping field="DiscPercent">
111
+ <To object="TermsDef" field="DiscPercent" />
112
+ </Mapping>
113
+ </Mappings>
114
+ </TopLevelEntity>
115
+ </Endpoint>
116
+ </EntityEndpoint>
117
+ </Customization>
acumatica_cli/cli.py ADDED
@@ -0,0 +1,351 @@
1
+ """acu - Acumatica configuration as code.
2
+
3
+ acu provision --id N --login T one command: create -> bootstrap -> apply -> diff
4
+ acu tenant list|create|delete tenant CRUD (ac.exe over SSH)
5
+ acu apply [--dry-run] FILES... seed baseline YAML via the REST API
6
+ acu diff FILES... drift check: baseline vs live tenant
7
+ acu schema [--out DIR] dump the endpoint's OpenAPI schema
8
+ acu config show print the resolved target instance
9
+ """
10
+
11
+ import os
12
+ from pathlib import Path
13
+
14
+ import click
15
+ import httpx
16
+ import yaml
17
+
18
+ from . import bootstrap, firstlogin, output, seed
19
+ from .client import AcumaticaClient
20
+ from .config import Instance, data_root, load_instance
21
+ from .tenant import TenantManager
22
+
23
+
24
+ @click.group(help=__doc__)
25
+ @click.version_option(package_name="acumatica-cli")
26
+ @click.option(
27
+ "-t", "--tenant", default=None, help="Override the tenant API sessions sign in to"
28
+ )
29
+ @click.option(
30
+ "--host",
31
+ default=None,
32
+ help="Override the acu.yaml host (base_url/ssh re-derive from it)",
33
+ )
34
+ @click.pass_context
35
+ def cli(ctx: click.Context, tenant: str | None, host: str | None) -> None:
36
+ """Resolve the target instance and stash it in the Click context."""
37
+ inst = load_instance(host=host)
38
+ if tenant is not None:
39
+ inst = inst.model_copy(update={"tenant": tenant})
40
+ ctx.obj = inst
41
+
42
+
43
+ def main() -> None:
44
+ """Entry point: run the CLI, mapping expected failures to one-line errors.
45
+
46
+ RuntimeError (SSH/ac.exe, REST, first-login) and httpx transport errors
47
+ print `x message` and exit 1; ACU_DEBUG=1 re-raises for the traceback.
48
+ """
49
+ try:
50
+ cli()
51
+ except (RuntimeError, httpx.HTTPError) as exc:
52
+ if os.environ.get("ACU_DEBUG"):
53
+ raise
54
+ output.error(str(exc))
55
+ raise SystemExit(1) from exc
56
+
57
+
58
+ @cli.group("tenant")
59
+ def tenant_group() -> None:
60
+ """Tenant CRUD on the instance (ac.exe CompanyConfig over SSH)."""
61
+
62
+
63
+ @tenant_group.command("list")
64
+ @click.pass_obj
65
+ def tenant_list(inst: Instance) -> None:
66
+ """List tenants: CompanyID, sign-in name, internal CD, type."""
67
+ tenants = TenantManager(inst).list()
68
+ output.table(
69
+ f"Tenants on {inst.host}",
70
+ ("ID", "Login", "CD", "Type"),
71
+ (
72
+ (str(t.company_id), t.login_name, t.company_cd, t.company_type)
73
+ for t in tenants
74
+ ),
75
+ )
76
+
77
+
78
+ @tenant_group.command("create")
79
+ @click.option(
80
+ "--id",
81
+ "company_id",
82
+ type=int,
83
+ required=True,
84
+ help="CompanyID (first free is usually 3)",
85
+ )
86
+ @click.option(
87
+ "--login",
88
+ "login_name",
89
+ required=True,
90
+ help="Acumatica tenant name as shown on the sign-in page",
91
+ )
92
+ @click.option(
93
+ "--type",
94
+ "company_type",
95
+ default="",
96
+ help="Inserted data set: '' = clean, SalesDemo = demo",
97
+ )
98
+ @click.option("--parent", "parent_id", type=int, default=1, show_default=True)
99
+ @click.option("--hidden", is_flag=True, help="Do not show on the sign-in page")
100
+ @click.option(
101
+ "--no-init",
102
+ is_flag=True,
103
+ help="Skip app-pool recycle + first-login password change",
104
+ )
105
+ @click.pass_obj
106
+ def tenant_create(
107
+ inst: Instance,
108
+ company_id: int,
109
+ login_name: str,
110
+ company_type: str,
111
+ parent_id: int,
112
+ hidden: bool,
113
+ no_init: bool,
114
+ ) -> None:
115
+ """Create a tenant and make it automation-ready.
116
+
117
+ Chains the verified steps (docs/ac-exe.md, docs/rest-api.md): ac.exe
118
+ CompanyConfig with the admin password preset, an app-pool recycle so the
119
+ running app sees the tenant, then a REST login check (with the sign-in
120
+ screen's first-login password-change flow as fallback).
121
+ """
122
+ mgr = TenantManager(inst)
123
+ with output.step(f"creating tenant {company_id} ({login_name}) on {inst.host}"):
124
+ raw = mgr.create(company_id, login_name, parent_id, not hidden, company_type)
125
+ output.data(raw.splitlines()[-1] if raw.strip() else "created")
126
+ if no_init:
127
+ output.warn("skipping init: tenant is invisible until an app-pool recycle")
128
+ return
129
+ _init_tenant(inst, mgr, login_name)
130
+
131
+
132
+ def _init_tenant(inst: Instance, mgr: TenantManager, login_name: str) -> None:
133
+ """Make a freshly created tenant automation-ready (V5 recycle + login check)."""
134
+ with output.step("recycling app pool (tenant map loads at app start)"):
135
+ mgr.recycle_app_pool()
136
+ with output.step("verifying REST login (screen-flow password change as fallback)"):
137
+ result = firstlogin.initialize_admin_password(inst, tenant=login_name)
138
+ output.success(f"admin {result}; tenant {login_name} is ready")
139
+
140
+
141
+ @tenant_group.command("delete")
142
+ @click.option("--id", "company_id", type=int, required=True)
143
+ @click.confirmation_option(prompt="Delete this tenant and all its data?")
144
+ @click.pass_obj
145
+ def tenant_delete(inst: Instance, company_id: int) -> None:
146
+ """Delete the tenant and all its data, then recycle the app pool."""
147
+ mgr = TenantManager(inst)
148
+ raw = mgr.delete(company_id)
149
+ output.data(raw.splitlines()[-1] if raw.strip() else "done")
150
+ with output.step("recycling app pool (drops the tenant from the running app)"):
151
+ mgr.recycle_app_pool()
152
+
153
+
154
+ @cli.group("config")
155
+ def config_group() -> None:
156
+ """Local read-only config inspection (never talks to a live instance)."""
157
+
158
+
159
+ @config_group.command("show")
160
+ @click.pass_obj
161
+ def config_show(inst: Instance) -> None:
162
+ """Print the fully resolved instance as a complete acu.yaml document.
163
+
164
+ Resolves through the same load_instance path every live command uses,
165
+ so the printed values are exactly what a live command would trust.
166
+ Credentials never appear - redirect to a file and edit: the output
167
+ loads back through load_instance unchanged.
168
+ """
169
+ doc = inst.model_dump(exclude={"username", "password"})
170
+ output.data("# resolved by `acu config show` - a complete acu.yaml")
171
+ output.data(
172
+ "# credentials come from .env (ACU_USER / ACU_PASSWORD), never from here"
173
+ )
174
+ for line in yaml.safe_dump(doc, sort_keys=False).splitlines():
175
+ output.data(line)
176
+
177
+
178
+ def expand_files(files: tuple[Path, ...]) -> list[Path]:
179
+ """Expand directory arguments into their *.yaml files, sorted."""
180
+ paths: list[Path] = []
181
+ for path in files:
182
+ if path.is_dir():
183
+ found = sorted(path.glob("*.yaml"))
184
+ if not found:
185
+ raise SystemExit(f"{path}: no *.yaml files in directory")
186
+ paths += found
187
+ else:
188
+ paths.append(path)
189
+ return paths
190
+
191
+
192
+ @cli.command("provision")
193
+ @click.option(
194
+ "--id",
195
+ "company_id",
196
+ type=int,
197
+ required=True,
198
+ help="CompanyID (first free is usually 3)",
199
+ )
200
+ @click.option(
201
+ "--login",
202
+ "login_name",
203
+ required=True,
204
+ help="Acumatica tenant name as shown on the sign-in page",
205
+ )
206
+ @click.option(
207
+ "--type",
208
+ "company_type",
209
+ default="",
210
+ help="Inserted data set: '' = clean, SalesDemo = demo",
211
+ )
212
+ @click.option("--parent", "parent_id", type=int, default=1, show_default=True)
213
+ @click.pass_obj
214
+ def provision_cmd(
215
+ inst: Instance,
216
+ company_id: int,
217
+ login_name: str,
218
+ company_type: str,
219
+ parent_id: int,
220
+ ) -> None:
221
+ """One command to a configured tenant: create -> bootstrap -> apply -> diff.
222
+
223
+ Chains the verified pipeline (docs/rest-api.md) idempotently: tenant
224
+ create over SSH (skipped when the login name already exists), bootstrap
225
+ package publish (skipped when already published), bootstrap/ YAML through
226
+ the custom endpoint, baseline/ through the Default endpoint, then a drift
227
+ check over everything applied - exit 2 on drift.
228
+ """
229
+ root = data_root()
230
+ baseline_dir = root / "baseline"
231
+ if not baseline_dir.is_dir():
232
+ raise SystemExit(f"{baseline_dir}: not a directory - nothing to provision")
233
+ seed_dirs = [d for d in (root / "bootstrap", baseline_dir) if d.is_dir()]
234
+ # every session below signs in to the provisioned tenant, never a default
235
+ inst = inst.model_copy(update={"tenant": login_name})
236
+
237
+ mgr = TenantManager(inst)
238
+ if any(t.login_name == login_name for t in mgr.list()):
239
+ output.info(f"tenant {login_name} exists on {inst.host} - skipping create")
240
+ else:
241
+ with output.step(f"creating tenant {company_id} ({login_name}) on {inst.host}"):
242
+ raw = mgr.create(company_id, login_name, parent_id, True, company_type)
243
+ output.data(raw.splitlines()[-1] if raw.strip() else "created")
244
+ _init_tenant(inst, mgr, login_name)
245
+
246
+ with (
247
+ output.step(f"publishing {bootstrap.PACKAGE_NAME} to {inst.tenant}"),
248
+ AcumaticaClient(inst) as client,
249
+ ):
250
+ result = bootstrap.publish(client)
251
+ output.success(f"{bootstrap.PACKAGE_NAME} {result}")
252
+ # unconditional: the publish restarts the site BEFORE its DB transaction
253
+ # commits, so the restarted domain caches the feature slot pre-plugin
254
+ # (verified live: gated screens stay 403 until one more recycle); on the
255
+ # skip path a recycle is the cheap way to make a resumed run sound too
256
+ with output.step("recycling app pool (feature set loads at app start)"):
257
+ mgr.recycle_app_pool()
258
+ with output.step("waiting for the site to come back"):
259
+ firstlogin.initialize_admin_password(inst, tenant=login_name)
260
+
261
+ # fresh session: publishing restarts the app domain, so don't trust the
262
+ # cookie that watched it happen
263
+ drifts: list[str] = []
264
+ seed_paths = expand_files(tuple(seed_dirs))
265
+ with AcumaticaClient(inst) as client:
266
+ for path in seed_paths:
267
+ baseline = seed.load_baseline(path)
268
+ output.data(f"{path} -> {inst.host}/{inst.tenant} ({baseline.entity})")
269
+ n = seed.apply(client, baseline)
270
+ output.data(f" {n} record(s)")
271
+ # diff everything applied, bootstrap YAML included - a PUT that
272
+ # answers 200 can persist nothing (T3), the drift check is the proof
273
+ for path in seed_paths:
274
+ drifts += seed.diff(client, seed.load_baseline(path))
275
+ _exit_on_drift(inst, drifts, len(seed_paths))
276
+
277
+
278
+ @cli.command("apply")
279
+ @click.argument(
280
+ "files", nargs=-1, required=True, type=click.Path(exists=True, path_type=Path)
281
+ )
282
+ @click.option("--dry-run", is_flag=True, help="Show what would be PUT without writing")
283
+ @click.pass_obj
284
+ def apply_cmd(inst: Instance, files: tuple[Path, ...], dry_run: bool) -> None:
285
+ """Seed baseline YAML into the tenant (idempotent PUT upserts).
286
+
287
+ FILES are baseline YAML files or directories containing them.
288
+ """
289
+ with AcumaticaClient(inst) as client:
290
+ for path in expand_files(files):
291
+ baseline = seed.load_baseline(path)
292
+ output.data(f"{path} -> {inst.host}/{inst.tenant} ({baseline.entity})")
293
+ n = seed.apply(client, baseline, dry_run=dry_run)
294
+ output.data(f" {n} record(s){' (dry run)' if dry_run else ''}")
295
+
296
+
297
+ @cli.command("schema")
298
+ @click.option(
299
+ "--out",
300
+ "out_dir",
301
+ type=click.Path(file_okay=False, path_type=Path),
302
+ default=None,
303
+ help="Output directory (default: <data repo>/schemas)",
304
+ )
305
+ @click.pass_obj
306
+ def schema_cmd(inst: Instance, out_dir: Path | None) -> None:
307
+ """Dump the endpoint's OpenAPI schema (swagger.json) into schemas/.
308
+
309
+ The schema is the authoritative field-level reference for the exact
310
+ build - regenerate rather than version (the file is ~3 MB).
311
+ """
312
+ if out_dir is None:
313
+ out_dir = data_root() / "schemas"
314
+ out_file = out_dir / f"swagger-{inst.endpoint.replace('/', '-')}.json"
315
+ with (
316
+ output.step(f"dumping OpenAPI schema from {inst.host} ({inst.endpoint})"),
317
+ AcumaticaClient(inst) as client,
318
+ ):
319
+ raw = client.swagger()
320
+ out_dir.mkdir(parents=True, exist_ok=True)
321
+ out_file.write_bytes(raw)
322
+ output.data(f"{out_file} ({len(raw)} bytes)")
323
+
324
+
325
+ @cli.command("diff")
326
+ @click.argument(
327
+ "files", nargs=-1, required=True, type=click.Path(exists=True, path_type=Path)
328
+ )
329
+ @click.pass_obj
330
+ def diff_cmd(inst: Instance, files: tuple[Path, ...]) -> None:
331
+ """Compare baseline YAML against the live tenant; exit 2 on drift.
332
+
333
+ FILES are baseline YAML files or directories containing them.
334
+ """
335
+ paths = expand_files(files)
336
+ drifts: list[str] = []
337
+ with AcumaticaClient(inst) as client:
338
+ for path in paths:
339
+ baseline = seed.load_baseline(path)
340
+ drifts += seed.diff(client, baseline)
341
+ _exit_on_drift(inst, drifts, len(paths))
342
+
343
+
344
+ def _exit_on_drift(inst: Instance, drifts: list[str], files: int) -> None:
345
+ """Report drift lines and exit 2 (the load-bearing diff contract, V9)."""
346
+ if drifts:
347
+ output.error(f"DRIFT on {inst.host}/{inst.tenant}:")
348
+ for line in drifts:
349
+ output.data(f" {line}")
350
+ raise SystemExit(2)
351
+ output.success(f"no drift on {inst.host}/{inst.tenant} ({files} file(s))")