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 ADDED
@@ -0,0 +1,2 @@
1
+ """EmpowerNow extension deploy CLI."""
2
+ __version__ = "0.1.0"
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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ enow = enow_cli.main:cli
@@ -0,0 +1 @@
1
+ enow_cli