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.
- enow_cli-0.1.0/PKG-INFO +10 -0
- enow_cli-0.1.0/pyproject.toml +22 -0
- enow_cli-0.1.0/setup.cfg +4 -0
- enow_cli-0.1.0/src/enow_cli/__init__.py +2 -0
- enow_cli-0.1.0/src/enow_cli/deploy.py +250 -0
- enow_cli-0.1.0/src/enow_cli/main.py +215 -0
- enow_cli-0.1.0/src/enow_cli/manifest.py +278 -0
- enow_cli-0.1.0/src/enow_cli/scaffold.py +462 -0
- enow_cli-0.1.0/src/enow_cli.egg-info/PKG-INFO +10 -0
- enow_cli-0.1.0/src/enow_cli.egg-info/SOURCES.txt +16 -0
- enow_cli-0.1.0/src/enow_cli.egg-info/dependency_links.txt +1 -0
- enow_cli-0.1.0/src/enow_cli.egg-info/entry_points.txt +2 -0
- enow_cli-0.1.0/src/enow_cli.egg-info/requires.txt +6 -0
- enow_cli-0.1.0/src/enow_cli.egg-info/top_level.txt +1 -0
- enow_cli-0.1.0/tests/test_cli.py +171 -0
- enow_cli-0.1.0/tests/test_deploy.py +129 -0
- enow_cli-0.1.0/tests/test_manifest.py +168 -0
- enow_cli-0.1.0/tests/test_scaffold.py +273 -0
enow_cli-0.1.0/PKG-INFO
ADDED
|
@@ -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"]
|
enow_cli-0.1.0/setup.cfg
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"))
|
|
@@ -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()
|