enow-cli 0.1.0__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.
- enow_cli/__init__.py +2 -0
- enow_cli/deploy.py +250 -0
- enow_cli/main.py +215 -0
- enow_cli/manifest.py +278 -0
- enow_cli/scaffold.py +462 -0
- enow_cli-0.1.0.dist-info/METADATA +10 -0
- enow_cli-0.1.0.dist-info/RECORD +10 -0
- enow_cli-0.1.0.dist-info/WHEEL +5 -0
- enow_cli-0.1.0.dist-info/entry_points.txt +2 -0
- enow_cli-0.1.0.dist-info/top_level.txt +1 -0
enow_cli/__init__.py
ADDED
enow_cli/deploy.py
ADDED
|
@@ -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"))
|
enow_cli/main.py
ADDED
|
@@ -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()
|
enow_cli/manifest.py
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Extension manifest loader for the deploy CLI.
|
|
3
|
+
|
|
4
|
+
Parses extension.yaml, validates structure and file references,
|
|
5
|
+
and provides the deploy plan (which files go where).
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
import yaml
|
|
15
|
+
|
|
16
|
+
REQUIRED_FIELDS = {"id", "version", "enabled"}
|
|
17
|
+
VALID_STATUSES = {"prototype", "preview", "ga", "production", "demo", "deprecated"}
|
|
18
|
+
VALID_SURFACES = {
|
|
19
|
+
"pdp_resource_handler", "pdp_resource_pip", "config_pack", "policy_pack",
|
|
20
|
+
"sidecar_adapter", "membership_projection", "experience_plugin", "seed_data",
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ManifestError(Exception):
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class FileMapping:
|
|
30
|
+
"""A single file to copy from extension source to ServiceConfigs target."""
|
|
31
|
+
src: Path
|
|
32
|
+
dst_relative: str
|
|
33
|
+
category: str
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def exists(self) -> bool:
|
|
37
|
+
return self.src.is_file()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class YamlMerge:
|
|
42
|
+
"""A YAML fragment to append-if-missing into a shared PDP config file."""
|
|
43
|
+
src: Path
|
|
44
|
+
target_relative: str
|
|
45
|
+
marker: str
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class PluginBuild:
|
|
50
|
+
"""An experience plugin that needs npm build + bundle copy + integrity update."""
|
|
51
|
+
plugin_id: str
|
|
52
|
+
source_dir: Path
|
|
53
|
+
build_command: str
|
|
54
|
+
bundle_path: Path
|
|
55
|
+
version: str = "1.0.0"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass
|
|
59
|
+
class ExtensionManifest:
|
|
60
|
+
"""Parsed extension.yaml with deploy plan generation."""
|
|
61
|
+
id: str
|
|
62
|
+
namespace: str
|
|
63
|
+
version: str
|
|
64
|
+
status: str
|
|
65
|
+
enabled: bool
|
|
66
|
+
root: Path
|
|
67
|
+
raw: dict = field(default_factory=dict, repr=False)
|
|
68
|
+
|
|
69
|
+
@classmethod
|
|
70
|
+
def load(cls, path: Path) -> ExtensionManifest:
|
|
71
|
+
if not path.is_file():
|
|
72
|
+
raise ManifestError(f"Manifest not found: {path}")
|
|
73
|
+
|
|
74
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
75
|
+
raw = yaml.safe_load(f) or {}
|
|
76
|
+
|
|
77
|
+
missing = REQUIRED_FIELDS - set(raw.keys())
|
|
78
|
+
if missing:
|
|
79
|
+
raise ManifestError(f"Missing required fields: {missing}")
|
|
80
|
+
|
|
81
|
+
ext_id = raw["id"]
|
|
82
|
+
if not isinstance(ext_id, str) or not ext_id.strip():
|
|
83
|
+
raise ManifestError("'id' must be a non-empty string")
|
|
84
|
+
|
|
85
|
+
status = raw.get("status", "prototype")
|
|
86
|
+
if status not in VALID_STATUSES:
|
|
87
|
+
raise ManifestError(f"Invalid status '{status}'. Valid: {VALID_STATUSES}")
|
|
88
|
+
|
|
89
|
+
return cls(
|
|
90
|
+
id=ext_id,
|
|
91
|
+
namespace=raw.get("namespace", ext_id),
|
|
92
|
+
version=raw["version"],
|
|
93
|
+
status=status,
|
|
94
|
+
enabled=bool(raw.get("enabled", True)),
|
|
95
|
+
root=path.parent,
|
|
96
|
+
raw=raw,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
def validate_files(self) -> list[str]:
|
|
100
|
+
"""Check that all referenced files actually exist. Returns list of errors."""
|
|
101
|
+
errors: list[str] = []
|
|
102
|
+
for fm in self.get_file_mappings():
|
|
103
|
+
if not fm.exists:
|
|
104
|
+
errors.append(f"[{fm.category}] Missing: {fm.src}")
|
|
105
|
+
for merge in self.get_yaml_merges():
|
|
106
|
+
if not merge.src.is_file():
|
|
107
|
+
errors.append(f"[yaml_merge] Missing: {merge.src}")
|
|
108
|
+
for plugin in self.get_plugin_builds():
|
|
109
|
+
pkg_json = plugin.source_dir / "package.json"
|
|
110
|
+
if not pkg_json.is_file():
|
|
111
|
+
errors.append(f"[plugin:{plugin.plugin_id}] Missing: {pkg_json}")
|
|
112
|
+
return errors
|
|
113
|
+
|
|
114
|
+
def get_file_mappings(self) -> list[FileMapping]:
|
|
115
|
+
"""Build the full list of files to sync from source to extensions/<id>/."""
|
|
116
|
+
mappings: list[FileMapping] = []
|
|
117
|
+
root = self.root
|
|
118
|
+
|
|
119
|
+
mappings.append(FileMapping(
|
|
120
|
+
src=root / "extension.yaml",
|
|
121
|
+
dst_relative="extension.yaml",
|
|
122
|
+
category="manifest",
|
|
123
|
+
))
|
|
124
|
+
|
|
125
|
+
for cap in self.raw.get("capabilities", {}).get("pdp_resource_handlers", []):
|
|
126
|
+
cls_path = cap.get("class", "")
|
|
127
|
+
if cls_path:
|
|
128
|
+
py_path = cls_path.replace(".", "/") + ".py"
|
|
129
|
+
parts = py_path.rsplit("/", 1)
|
|
130
|
+
if len(parts) == 2:
|
|
131
|
+
py_path = "/".join(parts[:-1]) + ".py"
|
|
132
|
+
else:
|
|
133
|
+
py_path = parts[0]
|
|
134
|
+
py_path = cls_path.rsplit(".", 1)[0].replace(".", "/") + ".py"
|
|
135
|
+
mappings.append(FileMapping(
|
|
136
|
+
src=root / py_path,
|
|
137
|
+
dst_relative=py_path,
|
|
138
|
+
category="pdp_handler",
|
|
139
|
+
))
|
|
140
|
+
|
|
141
|
+
for cap in self.raw.get("capabilities", {}).get("membership_projections", []):
|
|
142
|
+
module = cap.get("module", "")
|
|
143
|
+
if module:
|
|
144
|
+
py_path = module.replace(".", "/") + ".py"
|
|
145
|
+
mappings.append(FileMapping(
|
|
146
|
+
src=root / py_path,
|
|
147
|
+
dst_relative=py_path,
|
|
148
|
+
category="projection",
|
|
149
|
+
))
|
|
150
|
+
init_path = root / "membership" / "projections" / "__init__.py"
|
|
151
|
+
if init_path.is_file():
|
|
152
|
+
mappings.append(FileMapping(
|
|
153
|
+
src=init_path,
|
|
154
|
+
dst_relative="membership/projections/__init__.py",
|
|
155
|
+
category="projection",
|
|
156
|
+
))
|
|
157
|
+
|
|
158
|
+
for seed in self.raw.get("capabilities", {}).get("seed_data", []):
|
|
159
|
+
seed_path = seed if isinstance(seed, str) else seed.get("ref", seed)
|
|
160
|
+
mappings.append(FileMapping(
|
|
161
|
+
src=root / seed_path,
|
|
162
|
+
dst_relative=seed_path,
|
|
163
|
+
category="seed",
|
|
164
|
+
))
|
|
165
|
+
|
|
166
|
+
for sidecar in self.raw.get("capabilities", {}).get("sidecars", []):
|
|
167
|
+
frag = sidecar.get("compose_fragment", "") if isinstance(sidecar, dict) else ""
|
|
168
|
+
if frag:
|
|
169
|
+
mappings.append(FileMapping(
|
|
170
|
+
src=root / frag,
|
|
171
|
+
dst_relative=frag,
|
|
172
|
+
category="sidecar",
|
|
173
|
+
))
|
|
174
|
+
sidecar_dir = root / Path(frag).parent
|
|
175
|
+
if sidecar_dir.is_dir():
|
|
176
|
+
for f in sidecar_dir.rglob("*"):
|
|
177
|
+
if f.is_file() and f.name != Path(frag).name:
|
|
178
|
+
rel = f.relative_to(root)
|
|
179
|
+
mappings.append(FileMapping(
|
|
180
|
+
src=f,
|
|
181
|
+
dst_relative=str(rel).replace("\\", "/"),
|
|
182
|
+
category="sidecar",
|
|
183
|
+
))
|
|
184
|
+
|
|
185
|
+
return mappings
|
|
186
|
+
|
|
187
|
+
def get_yaml_merges(self) -> list[YamlMerge]:
|
|
188
|
+
"""Get YAML merge operations for shared PDP config files."""
|
|
189
|
+
merges: list[YamlMerge] = []
|
|
190
|
+
for pack in self.raw.get("capabilities", {}).get("config_packs", []):
|
|
191
|
+
ref = pack.get("ref", "") if isinstance(pack, dict) else pack
|
|
192
|
+
pack_type = pack.get("type", "") if isinstance(pack, dict) else ""
|
|
193
|
+
if not ref:
|
|
194
|
+
continue
|
|
195
|
+
|
|
196
|
+
src = self.root / ref
|
|
197
|
+
if pack_type == "policies":
|
|
198
|
+
continue
|
|
199
|
+
|
|
200
|
+
target_name = Path(ref).name
|
|
201
|
+
marker = _extract_yaml_root_key(src) or f"{self.id}:"
|
|
202
|
+
|
|
203
|
+
merges.append(YamlMerge(
|
|
204
|
+
src=src,
|
|
205
|
+
target_relative=target_name,
|
|
206
|
+
marker=marker,
|
|
207
|
+
))
|
|
208
|
+
return merges
|
|
209
|
+
|
|
210
|
+
def get_plugin_builds(self) -> list[PluginBuild]:
|
|
211
|
+
"""Get experience plugins that need building."""
|
|
212
|
+
builds: list[PluginBuild] = []
|
|
213
|
+
for plugin in self.raw.get("capabilities", {}).get("experience_plugins", []):
|
|
214
|
+
if not isinstance(plugin, dict):
|
|
215
|
+
continue
|
|
216
|
+
source = plugin.get("source", "")
|
|
217
|
+
builds.append(PluginBuild(
|
|
218
|
+
plugin_id=plugin.get("id", ""),
|
|
219
|
+
source_dir=self.root / source,
|
|
220
|
+
build_command=plugin.get("build_command", "npm run build"),
|
|
221
|
+
bundle_path=self.root / plugin.get("bundle", ""),
|
|
222
|
+
))
|
|
223
|
+
return builds
|
|
224
|
+
|
|
225
|
+
def get_policy_copies(self) -> list[FileMapping]:
|
|
226
|
+
"""Get policy files that go directly to PDP config/policies/."""
|
|
227
|
+
copies: list[FileMapping] = []
|
|
228
|
+
for pack in self.raw.get("capabilities", {}).get("config_packs", []):
|
|
229
|
+
if not isinstance(pack, dict):
|
|
230
|
+
continue
|
|
231
|
+
if pack.get("type") != "policies":
|
|
232
|
+
continue
|
|
233
|
+
ref = pack.get("ref", "")
|
|
234
|
+
if not ref:
|
|
235
|
+
continue
|
|
236
|
+
|
|
237
|
+
src_path = self.root / ref
|
|
238
|
+
if src_path.is_dir():
|
|
239
|
+
for f in src_path.rglob("*"):
|
|
240
|
+
if f.is_file():
|
|
241
|
+
rel = f.relative_to(self.root / "pdp" / "config")
|
|
242
|
+
copies.append(FileMapping(
|
|
243
|
+
src=f,
|
|
244
|
+
dst_relative=str(rel).replace("\\", "/"),
|
|
245
|
+
category="policy",
|
|
246
|
+
))
|
|
247
|
+
elif src_path.is_file():
|
|
248
|
+
rel = src_path.relative_to(self.root / "pdp" / "config")
|
|
249
|
+
copies.append(FileMapping(
|
|
250
|
+
src=src_path,
|
|
251
|
+
dst_relative=str(rel).replace("\\", "/"),
|
|
252
|
+
category="policy",
|
|
253
|
+
))
|
|
254
|
+
return copies
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _extract_yaml_root_key(path: Path) -> str:
|
|
258
|
+
"""Extract the first top-level key from a YAML file to use as an idempotency marker."""
|
|
259
|
+
if not path.is_file():
|
|
260
|
+
return ""
|
|
261
|
+
try:
|
|
262
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
263
|
+
data = yaml.safe_load(f)
|
|
264
|
+
if isinstance(data, dict) and data:
|
|
265
|
+
first_key = next(iter(data))
|
|
266
|
+
return f"{first_key}:"
|
|
267
|
+
except Exception:
|
|
268
|
+
pass
|
|
269
|
+
return ""
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def file_hash(path: Path) -> str:
|
|
273
|
+
"""SHA-256 hex digest of a file."""
|
|
274
|
+
h = hashlib.sha256()
|
|
275
|
+
with open(path, "rb") as f:
|
|
276
|
+
for chunk in iter(lambda: f.read(8192), b""):
|
|
277
|
+
h.update(chunk)
|
|
278
|
+
return h.hexdigest()
|
enow_cli/scaffold.py
ADDED
|
@@ -0,0 +1,462 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Extension scaffold generator.
|
|
3
|
+
|
|
4
|
+
Creates a new extension directory structure with all required files
|
|
5
|
+
based on the selected surfaces. The generated extension is immediately
|
|
6
|
+
deployable via ``enow extension deploy``.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Sequence
|
|
13
|
+
|
|
14
|
+
import yaml
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
ALL_SURFACES = [
|
|
18
|
+
"config_pack",
|
|
19
|
+
"policy_pack",
|
|
20
|
+
"membership_projection",
|
|
21
|
+
"pdp_resource_pip",
|
|
22
|
+
"pdp_resource_handler",
|
|
23
|
+
"seed_data",
|
|
24
|
+
"experience_plugin",
|
|
25
|
+
"sidecar_adapter",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
DEFAULT_SURFACES = [
|
|
29
|
+
"config_pack",
|
|
30
|
+
"policy_pack",
|
|
31
|
+
"membership_projection",
|
|
32
|
+
"seed_data",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class ScaffoldResult:
|
|
38
|
+
"""Tracks files created during scaffold."""
|
|
39
|
+
root: Path
|
|
40
|
+
files_created: list[str] = field(default_factory=list)
|
|
41
|
+
directories_created: list[str] = field(default_factory=list)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def scaffold_extension(
|
|
45
|
+
output_dir: Path,
|
|
46
|
+
name: str,
|
|
47
|
+
namespace: str,
|
|
48
|
+
surfaces: Sequence[str] | None = None,
|
|
49
|
+
customer: str = "",
|
|
50
|
+
team: str = "platform-engineering",
|
|
51
|
+
) -> ScaffoldResult:
|
|
52
|
+
"""Generate a new extension package directory.
|
|
53
|
+
|
|
54
|
+
Parameters
|
|
55
|
+
----------
|
|
56
|
+
output_dir:
|
|
57
|
+
Parent directory where ``<name>/`` will be created.
|
|
58
|
+
name:
|
|
59
|
+
Extension id (e.g. ``acme-corp``). Used as directory name and
|
|
60
|
+
extension id in the manifest.
|
|
61
|
+
namespace:
|
|
62
|
+
Short namespace prefix for the extension (e.g. ``acme``).
|
|
63
|
+
surfaces:
|
|
64
|
+
Which extension surfaces to include. Defaults to the four most
|
|
65
|
+
common: config_pack, policy_pack, membership_projection, seed_data.
|
|
66
|
+
customer:
|
|
67
|
+
Customer name for the ``owner`` block.
|
|
68
|
+
team:
|
|
69
|
+
Team name for the ``owner`` block.
|
|
70
|
+
"""
|
|
71
|
+
surfaces = list(surfaces or DEFAULT_SURFACES)
|
|
72
|
+
root = output_dir / name
|
|
73
|
+
result = ScaffoldResult(root=root)
|
|
74
|
+
|
|
75
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
76
|
+
result.directories_created.append(str(root))
|
|
77
|
+
|
|
78
|
+
_write_extension_yaml(root, name, namespace, surfaces, customer, team, result)
|
|
79
|
+
|
|
80
|
+
if "config_pack" in surfaces or "policy_pack" in surfaces:
|
|
81
|
+
_scaffold_pdp_config(root, name, namespace, result)
|
|
82
|
+
|
|
83
|
+
if "membership_projection" in surfaces:
|
|
84
|
+
_scaffold_projections(root, namespace, result)
|
|
85
|
+
|
|
86
|
+
if "seed_data" in surfaces:
|
|
87
|
+
_scaffold_seed_data(root, namespace, result)
|
|
88
|
+
|
|
89
|
+
if "pdp_resource_pip" in surfaces:
|
|
90
|
+
_scaffold_pip(root, namespace, result)
|
|
91
|
+
|
|
92
|
+
if "pdp_resource_handler" in surfaces:
|
|
93
|
+
_scaffold_handler(root, namespace, result)
|
|
94
|
+
|
|
95
|
+
if "experience_plugin" in surfaces:
|
|
96
|
+
_scaffold_experience_plugin(root, name, namespace, result)
|
|
97
|
+
|
|
98
|
+
if "sidecar_adapter" in surfaces:
|
|
99
|
+
_scaffold_sidecar(root, name, result)
|
|
100
|
+
|
|
101
|
+
_scaffold_tests(root, name, result)
|
|
102
|
+
|
|
103
|
+
return result
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _mk(parent: Path, *parts: str, result: ScaffoldResult) -> Path:
|
|
107
|
+
"""Create a directory and record it."""
|
|
108
|
+
d = parent.joinpath(*parts)
|
|
109
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
110
|
+
result.directories_created.append(str(d))
|
|
111
|
+
return d
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _write(path: Path, content: str, result: ScaffoldResult) -> None:
|
|
115
|
+
"""Write a file and record it."""
|
|
116
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
117
|
+
path.write_text(content, encoding="utf-8")
|
|
118
|
+
result.files_created.append(str(path))
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _write_extension_yaml(
|
|
122
|
+
root: Path,
|
|
123
|
+
name: str,
|
|
124
|
+
namespace: str,
|
|
125
|
+
surfaces: list[str],
|
|
126
|
+
customer: str,
|
|
127
|
+
team: str,
|
|
128
|
+
result: ScaffoldResult,
|
|
129
|
+
) -> None:
|
|
130
|
+
manifest: dict = {
|
|
131
|
+
"id": name,
|
|
132
|
+
"namespace": namespace,
|
|
133
|
+
"version": "0.1.0",
|
|
134
|
+
"status": "prototype",
|
|
135
|
+
"enabled": True,
|
|
136
|
+
"owner": {
|
|
137
|
+
"team": team,
|
|
138
|
+
"customer": customer or name,
|
|
139
|
+
},
|
|
140
|
+
"host_compatibility": {
|
|
141
|
+
"pdp": ">=1.0.0",
|
|
142
|
+
"membership": ">=1.0.0",
|
|
143
|
+
},
|
|
144
|
+
"surfaces": surfaces,
|
|
145
|
+
"capabilities": {},
|
|
146
|
+
"dependencies": {
|
|
147
|
+
"services": ["membership", "pdp"],
|
|
148
|
+
},
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
caps = manifest["capabilities"]
|
|
152
|
+
|
|
153
|
+
if "membership_projection" in surfaces:
|
|
154
|
+
cls_name = _class_name(namespace, "UserProjection")
|
|
155
|
+
caps["membership_projections"] = [
|
|
156
|
+
{
|
|
157
|
+
"id": f"{namespace}_user_view",
|
|
158
|
+
"module": f"membership.projections.{namespace}_user_projection",
|
|
159
|
+
"class": cls_name,
|
|
160
|
+
}
|
|
161
|
+
]
|
|
162
|
+
|
|
163
|
+
if "pdp_resource_pip" in surfaces:
|
|
164
|
+
caps["pdp_resource_pips"] = [
|
|
165
|
+
{
|
|
166
|
+
"resource_type": f"{namespace}.entitlement",
|
|
167
|
+
"module": f"pdp.pips.{namespace}_pip",
|
|
168
|
+
"class": _class_name(namespace, "PIP"),
|
|
169
|
+
}
|
|
170
|
+
]
|
|
171
|
+
|
|
172
|
+
if "pdp_resource_handler" in surfaces:
|
|
173
|
+
caps["pdp_resource_handlers"] = [
|
|
174
|
+
{
|
|
175
|
+
"id": f"{namespace}_role",
|
|
176
|
+
"class": f"pdp.handlers.{namespace}_handler.{_class_name(namespace, 'Handler')}",
|
|
177
|
+
"mode": "plugin",
|
|
178
|
+
}
|
|
179
|
+
]
|
|
180
|
+
else:
|
|
181
|
+
caps["pdp_resource_handlers"] = []
|
|
182
|
+
|
|
183
|
+
if "config_pack" in surfaces or "policy_pack" in surfaces:
|
|
184
|
+
caps["config_packs"] = [
|
|
185
|
+
{"type": "applications", "ref": "pdp/config/applications.yaml"},
|
|
186
|
+
{"type": "policies", "ref": f"pdp/config/policies/applications/{name}/"},
|
|
187
|
+
]
|
|
188
|
+
|
|
189
|
+
if "seed_data" in surfaces:
|
|
190
|
+
caps["seed_data"] = [
|
|
191
|
+
f"membership/seed/01_{namespace}_seed.cypher",
|
|
192
|
+
]
|
|
193
|
+
|
|
194
|
+
if "experience_plugin" in surfaces:
|
|
195
|
+
caps["experience_plugins"] = [
|
|
196
|
+
{
|
|
197
|
+
"id": f"{name}-explorer",
|
|
198
|
+
"source": f"experience/{name}-explorer",
|
|
199
|
+
"build_command": "npm run build",
|
|
200
|
+
"bundle": f"experience/{name}-explorer/dist/index.esm.js",
|
|
201
|
+
}
|
|
202
|
+
]
|
|
203
|
+
|
|
204
|
+
if "sidecar_adapter" in surfaces:
|
|
205
|
+
caps["sidecars"] = [
|
|
206
|
+
{
|
|
207
|
+
"id": f"{name}-gateway",
|
|
208
|
+
"compose_fragment": "deployment/docker-compose.sidecars.yml",
|
|
209
|
+
"neo4j_direct_access": False,
|
|
210
|
+
}
|
|
211
|
+
]
|
|
212
|
+
|
|
213
|
+
content = "# Extension manifest — generated by `enow extension scaffold`\n"
|
|
214
|
+
content += yaml.dump(manifest, default_flow_style=False, sort_keys=False)
|
|
215
|
+
_write(root / "extension.yaml", content, result)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _scaffold_pdp_config(
|
|
219
|
+
root: Path, name: str, namespace: str, result: ScaffoldResult,
|
|
220
|
+
) -> None:
|
|
221
|
+
policy_dir = _mk(root, "pdp", "config", "policies", "applications", name, result=result)
|
|
222
|
+
|
|
223
|
+
app_yaml = {
|
|
224
|
+
name: {
|
|
225
|
+
"description": f"PDP application for {name} extension",
|
|
226
|
+
"policy_set": f"applications/{name}",
|
|
227
|
+
"default_decision": "deny",
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
_write(
|
|
231
|
+
root / "pdp" / "config" / "applications.yaml",
|
|
232
|
+
yaml.dump(app_yaml, default_flow_style=False, sort_keys=False),
|
|
233
|
+
result,
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
sample_policy = {
|
|
237
|
+
"policy_id": f"{namespace}-sample-rule",
|
|
238
|
+
"description": f"Sample deny rule for {name}",
|
|
239
|
+
"target": {
|
|
240
|
+
"resource_type": f"{namespace}.entitlement",
|
|
241
|
+
"action": "can_access",
|
|
242
|
+
},
|
|
243
|
+
"rules": [
|
|
244
|
+
{
|
|
245
|
+
"effect": "deny",
|
|
246
|
+
"condition": {
|
|
247
|
+
"operator": "not_equals",
|
|
248
|
+
"left": {"path": "subject.properties.active"},
|
|
249
|
+
"right": {"value": True},
|
|
250
|
+
},
|
|
251
|
+
"description": "Deny inactive users",
|
|
252
|
+
}
|
|
253
|
+
],
|
|
254
|
+
"combining_algorithm": "deny-overrides",
|
|
255
|
+
}
|
|
256
|
+
_write(
|
|
257
|
+
policy_dir / f"{namespace}-sample.json",
|
|
258
|
+
_json_dumps(sample_policy),
|
|
259
|
+
result,
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _scaffold_projections(
|
|
264
|
+
root: Path, namespace: str, result: ScaffoldResult,
|
|
265
|
+
) -> None:
|
|
266
|
+
proj_dir = _mk(root, "membership", "projections", result=result)
|
|
267
|
+
cls_name = _class_name(namespace, "UserProjection")
|
|
268
|
+
|
|
269
|
+
content = f'''"""
|
|
270
|
+
{cls_name} — user profile projection for the {namespace} extension.
|
|
271
|
+
|
|
272
|
+
Replace the sample Cypher query with real attribute selections.
|
|
273
|
+
"""
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
class {cls_name}:
|
|
277
|
+
"""Returns user profile data from the Membership graph."""
|
|
278
|
+
|
|
279
|
+
QUERY = """
|
|
280
|
+
MATCH (i:Identity {{id: $identity_id}})
|
|
281
|
+
OPTIONAL MATCH (i)-[:BELONGS_TO_ORG]->(o:OrgEntity)
|
|
282
|
+
RETURN i.id AS id,
|
|
283
|
+
i.display_name AS display_name,
|
|
284
|
+
i.email AS email,
|
|
285
|
+
i.status AS status,
|
|
286
|
+
o.name AS org_name
|
|
287
|
+
"""
|
|
288
|
+
|
|
289
|
+
async def execute(self, identity_id: str, *, driver) -> dict | None:
|
|
290
|
+
"""Run the projection query and return the result dict."""
|
|
291
|
+
async with driver.session() as session:
|
|
292
|
+
record = await session.run(self.QUERY, identity_id=identity_id)
|
|
293
|
+
row = await record.single()
|
|
294
|
+
if row is None:
|
|
295
|
+
return None
|
|
296
|
+
return dict(row)
|
|
297
|
+
'''
|
|
298
|
+
_write(proj_dir / f"{namespace}_user_projection.py", content, result)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _scaffold_seed_data(
|
|
302
|
+
root: Path, namespace: str, result: ScaffoldResult,
|
|
303
|
+
) -> None:
|
|
304
|
+
seed_dir = _mk(root, "membership", "seed", result=result)
|
|
305
|
+
content = f"""// Seed data for {namespace} extension
|
|
306
|
+
// Run via: cypher-shell < 01_{namespace}_seed.cypher
|
|
307
|
+
|
|
308
|
+
// Sample organization
|
|
309
|
+
MERGE (o:OrgEntity {{id: "{namespace}-hq"}})
|
|
310
|
+
SET o.name = "{namespace.title()} Headquarters",
|
|
311
|
+
o.status = "ACTIVE",
|
|
312
|
+
o.type = "CORPORATE";
|
|
313
|
+
|
|
314
|
+
// Sample identity
|
|
315
|
+
MERGE (i:Identity {{id: "{namespace}-admin-001"}})
|
|
316
|
+
SET i.display_name = "Admin User",
|
|
317
|
+
i.email = "admin@{namespace}.example.com",
|
|
318
|
+
i.status = "ACTIVE";
|
|
319
|
+
|
|
320
|
+
// Link identity to org
|
|
321
|
+
MERGE (i)-[:BELONGS_TO_ORG]->(o)
|
|
322
|
+
WHERE i.id = "{namespace}-admin-001" AND o.id = "{namespace}-hq";
|
|
323
|
+
"""
|
|
324
|
+
_write(seed_dir / f"01_{namespace}_seed.cypher", content, result)
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _scaffold_pip(
|
|
328
|
+
root: Path, namespace: str, result: ScaffoldResult,
|
|
329
|
+
) -> None:
|
|
330
|
+
pip_dir = _mk(root, "pdp", "pips", result=result)
|
|
331
|
+
_write(pip_dir / "__init__.py", "", result)
|
|
332
|
+
|
|
333
|
+
cls_name = _class_name(namespace, "PIP")
|
|
334
|
+
content = f'''"""
|
|
335
|
+
{cls_name} — Policy Information Point for {namespace} entitlements.
|
|
336
|
+
|
|
337
|
+
Fetches entitlement catalog from a Membership projection so the PDP
|
|
338
|
+
can evaluate subject eligibility against resource attributes.
|
|
339
|
+
"""
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
class {cls_name}:
|
|
343
|
+
"""Provides resource attributes for {namespace}.entitlement evaluations."""
|
|
344
|
+
|
|
345
|
+
async def fetch(self, resource_id: str, *, context) -> dict:
|
|
346
|
+
"""Return resource properties for the given entitlement."""
|
|
347
|
+
return {{
|
|
348
|
+
"id": resource_id,
|
|
349
|
+
"type": "{namespace}.entitlement",
|
|
350
|
+
"properties": {{}},
|
|
351
|
+
}}
|
|
352
|
+
'''
|
|
353
|
+
_write(pip_dir / f"{namespace}_pip.py", content, result)
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _scaffold_handler(
|
|
357
|
+
root: Path, namespace: str, result: ScaffoldResult,
|
|
358
|
+
) -> None:
|
|
359
|
+
handler_dir = _mk(root, "pdp", "handlers", result=result)
|
|
360
|
+
|
|
361
|
+
cls_name = _class_name(namespace, "Handler")
|
|
362
|
+
content = f'''"""
|
|
363
|
+
{cls_name} — PDP resource handler for {namespace} role derivation.
|
|
364
|
+
"""
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
class {cls_name}:
|
|
368
|
+
"""Derives eligible roles for a subject using {namespace} policies."""
|
|
369
|
+
|
|
370
|
+
async def evaluate(self, subject: dict, *, context) -> list[dict]:
|
|
371
|
+
"""Return list of eligible role assignments."""
|
|
372
|
+
return []
|
|
373
|
+
'''
|
|
374
|
+
_write(handler_dir / f"{namespace}_handler.py", content, result)
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def _scaffold_experience_plugin(
|
|
378
|
+
root: Path, name: str, namespace: str, result: ScaffoldResult,
|
|
379
|
+
) -> None:
|
|
380
|
+
plugin_dir = _mk(root, "experience", f"{name}-explorer", result=result)
|
|
381
|
+
|
|
382
|
+
package_json = {
|
|
383
|
+
"name": f"@empowernow/{name}-explorer",
|
|
384
|
+
"version": "1.0.0",
|
|
385
|
+
"private": True,
|
|
386
|
+
"scripts": {"build": "echo 'TODO: configure build'"},
|
|
387
|
+
}
|
|
388
|
+
_write(
|
|
389
|
+
plugin_dir / "package.json",
|
|
390
|
+
_json_dumps(package_json),
|
|
391
|
+
result,
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
_write(
|
|
395
|
+
plugin_dir / "src" / "index.tsx",
|
|
396
|
+
f'// {name}-explorer — EmpowerNow experience plugin\nexport default function App() {{\n return <div>{namespace.title()} Explorer</div>;\n}}\n',
|
|
397
|
+
result,
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def _scaffold_sidecar(
|
|
402
|
+
root: Path, name: str, result: ScaffoldResult,
|
|
403
|
+
) -> None:
|
|
404
|
+
deploy_dir = _mk(root, "deployment", result=result)
|
|
405
|
+
|
|
406
|
+
compose = {
|
|
407
|
+
"services": {
|
|
408
|
+
f"{name}-gateway": {
|
|
409
|
+
"build": {"context": f"../../{name}", "dockerfile": "deployment/Dockerfile"},
|
|
410
|
+
"ports": ["8090:8090"],
|
|
411
|
+
"environment": {"MEMBERSHIP_URL": "http://membership_app:8003"},
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
_write(
|
|
416
|
+
deploy_dir / "docker-compose.sidecars.yml",
|
|
417
|
+
yaml.dump(compose, default_flow_style=False, sort_keys=False),
|
|
418
|
+
result,
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
_write(
|
|
422
|
+
deploy_dir / "Dockerfile",
|
|
423
|
+
"FROM python:3.12-slim\nWORKDIR /app\nCOPY . .\nCMD [\"uvicorn\", \"app:app\", \"--host\", \"0.0.0.0\", \"--port\", \"8090\"]\n",
|
|
424
|
+
result,
|
|
425
|
+
)
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def _scaffold_tests(
|
|
429
|
+
root: Path, name: str, result: ScaffoldResult,
|
|
430
|
+
) -> None:
|
|
431
|
+
test_dir = _mk(root, "tests", "e2e", result=result)
|
|
432
|
+
_write(test_dir / "__init__.py", "", result)
|
|
433
|
+
|
|
434
|
+
content = f'''"""
|
|
435
|
+
Smoke tests for {name} extension.
|
|
436
|
+
|
|
437
|
+
Run: pytest tests/e2e/ -v
|
|
438
|
+
"""
|
|
439
|
+
import pytest
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
@pytest.mark.e2e
|
|
443
|
+
class TestExtensionSmoke:
|
|
444
|
+
"""Verify the extension deployed and basic services respond."""
|
|
445
|
+
|
|
446
|
+
def test_placeholder(self):
|
|
447
|
+
"""Replace with real assertions once deployed."""
|
|
448
|
+
assert True, "Extension smoke test placeholder"
|
|
449
|
+
'''
|
|
450
|
+
_write(test_dir / f"test_{name.replace('-', '_')}_e2e.py", content, result)
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def _class_name(namespace: str, suffix: str) -> str:
|
|
454
|
+
"""Convert namespace to PascalCase and append suffix."""
|
|
455
|
+
parts = namespace.replace("-", "_").split("_")
|
|
456
|
+
return "".join(p.capitalize() for p in parts) + suffix
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def _json_dumps(obj: dict) -> str:
|
|
460
|
+
"""JSON serialization with consistent formatting."""
|
|
461
|
+
import json
|
|
462
|
+
return json.dumps(obj, indent=2) + "\n"
|
|
@@ -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,10 @@
|
|
|
1
|
+
enow_cli/__init__.py,sha256=_McJEqfFOzWofy7S0qLsff_fza6_NkWD6tQlmCTe4aY,63
|
|
2
|
+
enow_cli/deploy.py,sha256=4egGq3uZqTo1i7A6rASAZfBZARU2zAjsA789da6VDl8,9434
|
|
3
|
+
enow_cli/main.py,sha256=06oQNJ_OAiTpyKMlfuOjPr-sZBo4xih_hQB61oXQs9E,7735
|
|
4
|
+
enow_cli/manifest.py,sha256=YcwfJQH59zCJsKwH6sPbsTc29uVxfgaSEc2rl87YsPw,10011
|
|
5
|
+
enow_cli/scaffold.py,sha256=z087biXGfdlgBael4dSxnyUxz8wAOb6vJ_P4eXR9Uw4,14148
|
|
6
|
+
enow_cli-0.1.0.dist-info/METADATA,sha256=xNrFkDLRba7vmpa8vA0GdqjosT9_uWaDadgpCOhDUwg,324
|
|
7
|
+
enow_cli-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
8
|
+
enow_cli-0.1.0.dist-info/entry_points.txt,sha256=4fUeNXDFPIbuIrOOblcummATC7--9XWzdRba3MlEuEY,43
|
|
9
|
+
enow_cli-0.1.0.dist-info/top_level.txt,sha256=BEMh5pGP4vfWr-ag1_1Hp_2IPPYpxyspnHbEUmRNeN8,9
|
|
10
|
+
enow_cli-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
enow_cli
|