enow-cli 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,10 @@
1
+ Metadata-Version: 2.4
2
+ Name: enow-cli
3
+ Version: 0.1.0
4
+ Summary: EmpowerNow extension deploy CLI — manifest-driven, cross-platform
5
+ Requires-Python: >=3.11
6
+ Requires-Dist: pyyaml>=6.0
7
+ Requires-Dist: click>=8.1
8
+ Provides-Extra: dev
9
+ Requires-Dist: pytest>=7.0; extra == "dev"
10
+ Requires-Dist: pytest-tmp-files>=0.0.2; extra == "dev"
@@ -0,0 +1,22 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68.0", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "enow-cli"
7
+ version = "0.1.0"
8
+ description = "EmpowerNow extension deploy CLI — manifest-driven, cross-platform"
9
+ requires-python = ">=3.11"
10
+ dependencies = [
11
+ "pyyaml>=6.0",
12
+ "click>=8.1",
13
+ ]
14
+
15
+ [project.optional-dependencies]
16
+ dev = ["pytest>=7.0", "pytest-tmp-files>=0.0.2"]
17
+
18
+ [project.scripts]
19
+ enow = "enow_cli.main:cli"
20
+
21
+ [tool.setuptools.packages.find]
22
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,2 @@
1
+ """EmpowerNow extension deploy CLI."""
2
+ __version__ = "0.1.0"
@@ -0,0 +1,250 @@
1
+ """
2
+ Extension deploy engine.
3
+
4
+ Copies extension artifacts into ServiceConfigs, handles PDP config
5
+ merges, policy overlays, and experience plugin builds — all driven
6
+ by extension.yaml.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import shutil
11
+ import subprocess
12
+ from dataclasses import dataclass, field
13
+ from pathlib import Path
14
+
15
+ from .manifest import ExtensionManifest, FileMapping, YamlMerge, PluginBuild, file_hash
16
+
17
+
18
+ @dataclass
19
+ class DeployAction:
20
+ """A single deploy action with its result."""
21
+ kind: str # COPY, SKIP, MERGE, MKDIR, BUILD, HASH, ERROR
22
+ path: str
23
+ detail: str = ""
24
+
25
+
26
+ @dataclass
27
+ class DeployResult:
28
+ """Outcome of a deploy run."""
29
+ actions: list[DeployAction] = field(default_factory=list)
30
+ copied: int = 0
31
+ unchanged: int = 0
32
+ merged: int = 0
33
+ errors: int = 0
34
+
35
+ @property
36
+ def success(self) -> bool:
37
+ return self.errors == 0
38
+
39
+
40
+ def deploy_extension(
41
+ manifest: ExtensionManifest,
42
+ serviceconfigs_root: Path,
43
+ dry_run: bool = False,
44
+ ) -> DeployResult:
45
+ """
46
+ Deploy an extension package into ServiceConfigs.
47
+
48
+ Steps:
49
+ 1. Sync extension files into extensions/<id>/
50
+ 2. Copy PDP policy files into pdp/config/policies/
51
+ 3. Merge YAML fragments into shared PDP configs
52
+ 4. Build experience plugins and copy bundles + update integrity
53
+ """
54
+ result = DeployResult()
55
+ ext_target = serviceconfigs_root / "extensions" / manifest.id
56
+ pdp_config = serviceconfigs_root / "pdp" / "config"
57
+
58
+ _sync_files(manifest.get_file_mappings(), ext_target, dry_run, result)
59
+ _copy_policies(manifest.get_policy_copies(), pdp_config, dry_run, result)
60
+ _merge_yamls(manifest.get_yaml_merges(), pdp_config, dry_run, result)
61
+ _build_plugins(manifest.get_plugin_builds(), serviceconfigs_root, dry_run, result)
62
+
63
+ return result
64
+
65
+
66
+ def diff_extension(
67
+ manifest: ExtensionManifest,
68
+ serviceconfigs_root: Path,
69
+ ) -> list[DeployAction]:
70
+ """Show what would change without modifying anything."""
71
+ actions: list[DeployAction] = []
72
+ ext_target = serviceconfigs_root / "extensions" / manifest.id
73
+
74
+ for fm in manifest.get_file_mappings():
75
+ dst = ext_target / fm.dst_relative
76
+ if not fm.exists:
77
+ actions.append(DeployAction("MISSING", fm.dst_relative, f"Source: {fm.src}"))
78
+ continue
79
+ if not dst.is_file():
80
+ actions.append(DeployAction("NEW", fm.dst_relative))
81
+ elif file_hash(fm.src) != file_hash(dst):
82
+ actions.append(DeployAction("CHANGED", fm.dst_relative))
83
+
84
+ pdp_config = serviceconfigs_root / "pdp" / "config"
85
+ for merge in manifest.get_yaml_merges():
86
+ target = pdp_config / merge.target_relative
87
+ if not target.is_file():
88
+ actions.append(DeployAction("NEW", f"pdp/config/{merge.target_relative}"))
89
+ else:
90
+ content = target.read_text(encoding="utf-8")
91
+ if merge.marker not in content:
92
+ actions.append(DeployAction("MERGE", f"pdp/config/{merge.target_relative}", f"marker: {merge.marker}"))
93
+
94
+ return actions
95
+
96
+
97
+ def _sync_files(
98
+ mappings: list[FileMapping],
99
+ target_root: Path,
100
+ dry_run: bool,
101
+ result: DeployResult,
102
+ ) -> None:
103
+ for fm in mappings:
104
+ dst = target_root / fm.dst_relative
105
+ if not fm.exists:
106
+ result.actions.append(DeployAction("MISSING", fm.dst_relative, f"{fm.src}"))
107
+ result.errors += 1
108
+ continue
109
+
110
+ if dst.is_file() and file_hash(fm.src) == file_hash(dst):
111
+ result.actions.append(DeployAction("SKIP", fm.dst_relative))
112
+ result.unchanged += 1
113
+ continue
114
+
115
+ if not dry_run:
116
+ dst.parent.mkdir(parents=True, exist_ok=True)
117
+ shutil.copy2(fm.src, dst)
118
+
119
+ result.actions.append(DeployAction("COPY", fm.dst_relative))
120
+ result.copied += 1
121
+
122
+
123
+ def _copy_policies(
124
+ policies: list[FileMapping],
125
+ pdp_config_root: Path,
126
+ dry_run: bool,
127
+ result: DeployResult,
128
+ ) -> None:
129
+ for fm in policies:
130
+ dst = pdp_config_root / fm.dst_relative
131
+ if not fm.exists:
132
+ result.actions.append(DeployAction("MISSING", fm.dst_relative, f"{fm.src}"))
133
+ result.errors += 1
134
+ continue
135
+
136
+ if dst.is_file() and file_hash(fm.src) == file_hash(dst):
137
+ result.actions.append(DeployAction("SKIP", f"pdp/{fm.dst_relative}"))
138
+ result.unchanged += 1
139
+ continue
140
+
141
+ if not dry_run:
142
+ dst.parent.mkdir(parents=True, exist_ok=True)
143
+ shutil.copy2(fm.src, dst)
144
+
145
+ result.actions.append(DeployAction("COPY", f"pdp/{fm.dst_relative}"))
146
+ result.copied += 1
147
+
148
+
149
+ def _merge_yamls(
150
+ merges: list[YamlMerge],
151
+ pdp_config_root: Path,
152
+ dry_run: bool,
153
+ result: DeployResult,
154
+ ) -> None:
155
+ for merge in merges:
156
+ if not merge.src.is_file():
157
+ result.actions.append(DeployAction("MISSING", merge.target_relative, f"{merge.src}"))
158
+ result.errors += 1
159
+ continue
160
+
161
+ target = pdp_config_root / merge.target_relative
162
+ if not target.is_file():
163
+ if not dry_run:
164
+ target.parent.mkdir(parents=True, exist_ok=True)
165
+ shutil.copy2(merge.src, target)
166
+ result.actions.append(DeployAction("COPY", f"pdp/config/{merge.target_relative}", "new file"))
167
+ result.copied += 1
168
+ continue
169
+
170
+ content = target.read_text(encoding="utf-8")
171
+ if merge.marker and merge.marker in content:
172
+ result.actions.append(DeployAction("SKIP", f"pdp/config/{merge.target_relative}", f"marker '{merge.marker}' present"))
173
+ result.unchanged += 1
174
+ continue
175
+
176
+ if not dry_run:
177
+ fragment = merge.src.read_text(encoding="utf-8")
178
+ separator = "\n" if content.endswith("\n") else "\n\n"
179
+ with open(target, "a", encoding="utf-8") as f:
180
+ f.write(separator + fragment)
181
+
182
+ result.actions.append(DeployAction("MERGE", f"pdp/config/{merge.target_relative}", f"appended '{merge.marker}'"))
183
+ result.merged += 1
184
+
185
+
186
+ def _build_plugins(
187
+ plugins: list[PluginBuild],
188
+ serviceconfigs_root: Path,
189
+ dry_run: bool,
190
+ result: DeployResult,
191
+ ) -> None:
192
+ for plugin in plugins:
193
+ pkg_json = plugin.source_dir / "package.json"
194
+ if not pkg_json.is_file():
195
+ result.actions.append(DeployAction("SKIP", f"plugin:{plugin.plugin_id}", "no package.json"))
196
+ return
197
+
198
+ if dry_run:
199
+ result.actions.append(DeployAction("BUILD", f"plugin:{plugin.plugin_id}", "would build"))
200
+ result.actions.append(DeployAction("COPY", f"BFF/plugins/{plugin.plugin_id}/{plugin.version}/index.esm.js"))
201
+ result.actions.append(DeployAction("HASH", f"BFF/config/plugins/manifests/{plugin.plugin_id}.yaml"))
202
+ return
203
+
204
+ node_modules = plugin.source_dir / "node_modules"
205
+ if not node_modules.is_dir():
206
+ proc = subprocess.run(
207
+ ["npm", "install", "--silent"],
208
+ cwd=plugin.source_dir,
209
+ capture_output=True, text=True,
210
+ )
211
+ if proc.returncode != 0:
212
+ result.actions.append(DeployAction("ERROR", f"plugin:{plugin.plugin_id}", f"npm install failed: {proc.stderr[:200]}"))
213
+ result.errors += 1
214
+ continue
215
+
216
+ proc = subprocess.run(
217
+ plugin.build_command.split(),
218
+ cwd=plugin.source_dir,
219
+ capture_output=True, text=True,
220
+ )
221
+ if proc.returncode != 0:
222
+ result.actions.append(DeployAction("ERROR", f"plugin:{plugin.plugin_id}", f"build failed: {proc.stderr[:200]}"))
223
+ result.errors += 1
224
+ continue
225
+
226
+ result.actions.append(DeployAction("BUILD", f"plugin:{plugin.plugin_id}", "success"))
227
+
228
+ bundle_src = plugin.bundle_path
229
+ if not bundle_src.is_file():
230
+ result.actions.append(DeployAction("ERROR", f"plugin:{plugin.plugin_id}", f"bundle not found: {bundle_src}"))
231
+ result.errors += 1
232
+ continue
233
+
234
+ bundle_dst = serviceconfigs_root / "BFF" / "plugins" / plugin.plugin_id / plugin.version / "index.esm.js"
235
+ bundle_dst.parent.mkdir(parents=True, exist_ok=True)
236
+ shutil.copy2(bundle_src, bundle_dst)
237
+ result.actions.append(DeployAction("COPY", str(bundle_dst.relative_to(serviceconfigs_root))))
238
+ result.copied += 1
239
+
240
+ integrity = f"sha256:{file_hash(bundle_dst)}"
241
+ manifest_path = serviceconfigs_root / "BFF" / "config" / "plugins" / "manifests" / f"{plugin.plugin_id}.yaml"
242
+ if manifest_path.is_file():
243
+ content = manifest_path.read_text(encoding="utf-8")
244
+ import re
245
+ updated = re.sub(r"integrity:\s*sha256:[a-f0-9]+", f"integrity: {integrity}", content)
246
+ if updated != content:
247
+ manifest_path.write_text(updated, encoding="utf-8")
248
+ result.actions.append(DeployAction("HASH", str(manifest_path.relative_to(serviceconfigs_root)), integrity))
249
+ else:
250
+ result.actions.append(DeployAction("WARN", f"manifests/{plugin.plugin_id}.yaml", "not found — update integrity manually"))
@@ -0,0 +1,215 @@
1
+ """
2
+ enow CLI — EmpowerNow extension management tool.
3
+
4
+ Usage:
5
+ enow extension validate [PATH]
6
+ enow extension deploy --target <serviceconfigs> [--dry-run]
7
+ enow extension diff --target <serviceconfigs>
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import sys
12
+ from pathlib import Path
13
+
14
+ import click
15
+
16
+ from .manifest import ExtensionManifest, ManifestError
17
+ from .deploy import deploy_extension, diff_extension
18
+ from .scaffold import scaffold_extension, ALL_SURFACES, DEFAULT_SURFACES
19
+
20
+
21
+ def _find_manifest(path: str | None) -> Path:
22
+ """Resolve extension.yaml from a path or current directory."""
23
+ if path:
24
+ p = Path(path)
25
+ if p.is_file() and p.name == "extension.yaml":
26
+ return p
27
+ if p.is_dir():
28
+ candidate = p / "extension.yaml"
29
+ if candidate.is_file():
30
+ return candidate
31
+ raise click.ClickException(f"No extension.yaml found in {p}")
32
+ raise click.ClickException(f"Path not found: {p}")
33
+
34
+ cwd = Path.cwd()
35
+ candidate = cwd / "extension.yaml"
36
+ if candidate.is_file():
37
+ return candidate
38
+
39
+ for parent in cwd.parents:
40
+ candidate = parent / "extension.yaml"
41
+ if candidate.is_file():
42
+ return candidate
43
+ if (parent / ".git").exists():
44
+ break
45
+
46
+ raise click.ClickException(
47
+ "No extension.yaml found. Run from an extension repo or pass --path."
48
+ )
49
+
50
+
51
+ @click.group()
52
+ @click.version_option()
53
+ def cli():
54
+ """EmpowerNow extension management CLI."""
55
+ pass
56
+
57
+
58
+ @cli.group()
59
+ def extension():
60
+ """Manage extension packages."""
61
+ pass
62
+
63
+
64
+ @extension.command()
65
+ @click.argument("path", required=False)
66
+ def validate(path: str | None):
67
+ """Validate an extension.yaml manifest and check all referenced files exist."""
68
+ manifest_path = _find_manifest(path)
69
+ click.echo(f"Validating: {manifest_path}")
70
+
71
+ try:
72
+ manifest = ExtensionManifest.load(manifest_path)
73
+ except ManifestError as e:
74
+ click.secho(f"FAIL Manifest error: {e}", fg="red")
75
+ sys.exit(1)
76
+
77
+ click.echo(f" id: {manifest.id}")
78
+ click.echo(f" version: {manifest.version}")
79
+ click.echo(f" status: {manifest.status}")
80
+ click.echo(f" enabled: {manifest.enabled}")
81
+ click.echo(f" namespace: {manifest.namespace}")
82
+ click.echo()
83
+
84
+ errors = manifest.validate_files()
85
+ mappings = manifest.get_file_mappings()
86
+ merges = manifest.get_yaml_merges()
87
+ plugins = manifest.get_plugin_builds()
88
+ policies = manifest.get_policy_copies()
89
+
90
+ click.echo(f"Files: {len(mappings)} mapped")
91
+ click.echo(f"Merges: {len(merges)} YAML merge(s)")
92
+ click.echo(f"Policies: {len(policies)} policy file(s)")
93
+ click.echo(f"Plugins: {len(plugins)} experience plugin(s)")
94
+ click.echo()
95
+
96
+ if errors:
97
+ click.secho(f"FAIL {len(errors)} error(s):", fg="red")
98
+ for err in errors:
99
+ click.secho(f" {err}", fg="red")
100
+ sys.exit(1)
101
+ else:
102
+ click.secho("OK All referenced files exist.", fg="green")
103
+
104
+
105
+ @extension.command()
106
+ @click.option("--target", "-t", required=True, type=click.Path(exists=True),
107
+ help="Path to ServiceConfigs repo root")
108
+ @click.option("--dry-run", is_flag=True, help="Show what would change without modifying files")
109
+ @click.option("--path", "-p", default=None, help="Path to extension repo or extension.yaml")
110
+ def deploy(target: str, dry_run: bool, path: str | None):
111
+ """Deploy extension package into ServiceConfigs."""
112
+ manifest_path = _find_manifest(path)
113
+ manifest = ExtensionManifest.load(manifest_path)
114
+
115
+ click.echo(f"Deploying: {manifest.id} v{manifest.version}")
116
+ click.echo(f" Source: {manifest.root}")
117
+ click.echo(f" Target: {target}")
118
+ if dry_run:
119
+ click.secho(" [DRY RUN]", fg="yellow")
120
+ click.echo()
121
+
122
+ result = deploy_extension(manifest, Path(target), dry_run=dry_run)
123
+
124
+ for action in result.actions:
125
+ color = {
126
+ "COPY": "green", "MERGE": "green", "BUILD": "cyan",
127
+ "HASH": "cyan", "SKIP": None, "MISSING": "red",
128
+ "ERROR": "red", "WARN": "yellow", "MKDIR": None,
129
+ }.get(action.kind, None)
130
+ detail = f" ({action.detail})" if action.detail else ""
131
+ click.secho(f" {action.kind:<7} {action.path}{detail}", fg=color)
132
+
133
+ click.echo()
134
+ click.echo(
135
+ f"Done: {result.copied} copied, {result.merged} merged, "
136
+ f"{result.unchanged} unchanged, {result.errors} errors"
137
+ )
138
+
139
+ if not result.success:
140
+ sys.exit(1)
141
+
142
+
143
+ @extension.command()
144
+ @click.option("--target", "-t", required=True, type=click.Path(exists=True),
145
+ help="Path to ServiceConfigs repo root")
146
+ @click.option("--path", "-p", default=None, help="Path to extension repo or extension.yaml")
147
+ def diff(target: str, path: str | None):
148
+ """Show what would change in ServiceConfigs (no modifications)."""
149
+ manifest_path = _find_manifest(path)
150
+ manifest = ExtensionManifest.load(manifest_path)
151
+
152
+ click.echo(f"Diff: {manifest.id} v{manifest.version} -> {target}")
153
+ click.echo()
154
+
155
+ actions = diff_extension(manifest, Path(target))
156
+
157
+ if not actions:
158
+ click.secho("No changes needed — target is up to date.", fg="green")
159
+ return
160
+
161
+ for action in actions:
162
+ color = {"NEW": "green", "CHANGED": "yellow", "MERGE": "cyan", "MISSING": "red"}.get(action.kind, None)
163
+ detail = f" ({action.detail})" if action.detail else ""
164
+ click.secho(f" {action.kind:<8} {action.path}{detail}", fg=color)
165
+
166
+ click.echo()
167
+ click.echo(f"{len(actions)} change(s) pending.")
168
+
169
+
170
+ @extension.command()
171
+ @click.option("--name", "-n", required=True, help="Extension id (e.g. acme-corp)")
172
+ @click.option("--namespace", "-ns", required=True, help="Short namespace prefix (e.g. acme)")
173
+ @click.option(
174
+ "--surface", "-s", multiple=True,
175
+ type=click.Choice(ALL_SURFACES, case_sensitive=False),
176
+ help="Surfaces to include (repeatable). Defaults: config_pack, policy_pack, membership_projection, seed_data",
177
+ )
178
+ @click.option("--customer", "-c", default="", help="Customer name for the owner block")
179
+ @click.option("--team", default="platform-engineering", help="Team name for the owner block")
180
+ @click.option("--output", "-o", default=".", type=click.Path(), help="Output parent directory")
181
+ def scaffold(name: str, namespace: str, surface: tuple[str, ...], customer: str, team: str, output: str):
182
+ """Generate a new extension package from a template."""
183
+ surfaces = list(surface) if surface else None
184
+ output_path = Path(output)
185
+
186
+ click.echo(f"Scaffolding extension: {name}")
187
+ click.echo(f" Namespace: {namespace}")
188
+ click.echo(f" Surfaces: {', '.join(surfaces or DEFAULT_SURFACES)}")
189
+ click.echo(f" Output: {output_path / name}")
190
+ click.echo()
191
+
192
+ result = scaffold_extension(
193
+ output_dir=output_path,
194
+ name=name,
195
+ namespace=namespace,
196
+ surfaces=surfaces,
197
+ customer=customer,
198
+ team=team,
199
+ )
200
+
201
+ click.secho(f"Created {len(result.files_created)} files:", fg="green")
202
+ for f in result.files_created:
203
+ rel = Path(f).relative_to(result.root) if Path(f).is_relative_to(result.root) else f
204
+ click.echo(f" {rel}")
205
+
206
+ click.echo()
207
+ click.secho("Next steps:", fg="cyan")
208
+ click.echo(f" 1. cd {result.root}")
209
+ click.echo(f" 2. Edit extension.yaml and fill in capabilities")
210
+ click.echo(f" 3. enow extension validate")
211
+ click.echo(f" 4. enow extension deploy --target /path/to/ServiceConfigs")
212
+
213
+
214
+ if __name__ == "__main__":
215
+ cli()