chore: bump version and changelog (v0.1.0.0)
Add CW security roles reference docs and PSA ticket management plan. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
# Lessons Archive (1-40)
|
||||
# Lessons Archive (1-70)
|
||||
|
||||
> These lessons were originally in CLAUDE.md. They've been archived because the fixes are now baked into the codebase. Consult this file if you encounter a regression in any of these areas.
|
||||
|
||||
@@ -81,3 +81,67 @@
|
||||
**39. Platform settings for feature toggles:** Use `SettingsManager.get("key", db, default=True)`.
|
||||
|
||||
**40. Survey public routes:** Add at top level in `router.tsx` alongside `/login`.
|
||||
|
||||
---
|
||||
|
||||
## Archived Lessons (41-70)
|
||||
|
||||
**41. Assistant chat uses local React state, not Zustand:** `AssistantChatPage.tsx` uses `useState` for `chats`, `messages`, `input`, `loading`. No store.
|
||||
|
||||
**42. Public pages use raw `fetch()`, not `apiClient`:** Survey, shared sessions, and no-auth pages use `fetch()` with full URL. `apiClient` requires auth tokens.
|
||||
|
||||
**43. Adding new email types:** Add static async method to `EmailService` in `core/email.py`. Fire-and-forget from endpoints (log errors, don't fail).
|
||||
|
||||
**44. AI Chat Builder is flow-type-aware:** `ai_chat_service.py` dispatches by `flow_type`. Troubleshooting: `[TREE_UPDATE]` markers. Procedural: `[STEPS_UPDATE]` markers. Both support `[METADATA]`.
|
||||
|
||||
**45. Intake form field schema:** Uses `variable_name` and `field_type` (NOT `name` and `type`).
|
||||
|
||||
**46. `CreateFlowDropdown` uses `AIPromptDialog`:** Opens prompt modal, starts AI session, generates flow, navigates to editor with `{ state: { aiPanelOpen: true, sessionId } }`.
|
||||
|
||||
**47. Editor-Embedded Flow Assist:** `EditorAIPanel` (320px side panel) + `useEditorAI` hook. Ghost nodes use `_suggestion: true` flag. Delta responses use `[DELTA]...[/DELTA]` markers.
|
||||
|
||||
**48. Tree orphan validation uses dynamic root ID:** Orphan check compares against `state.treeStructure?.id` (NOT hardcoded `'root'`).
|
||||
|
||||
**49. Full-stack features — verify both ends:** schema → endpoint → API client → hook → store → UI.
|
||||
|
||||
**50. Anthropic SDK retry:** Set `max_retries=1` to fail fast. Default `max_retries=2` can take 3× timeout.
|
||||
|
||||
**51. AI model tier routing:** Use `settings.get_model_for_action(action_type)`. Model IDs: alias form (`claude-sonnet-4-6`).
|
||||
|
||||
**52. Mobile scroll-to-top:** Use `ref.current.scrollIntoView()`, not `window.scrollTo()`. Trigger via `useEffect`.
|
||||
|
||||
**53. Flex height chain:** Every ancestor must be a flex container for `flex-1` to work. Missing `flex` class collapses React Flow to 0 height.
|
||||
|
||||
**54. React Flow CSS in Tailwind v4:** Import in `index.css`, not component JS. Override dark theme using `--xy-*` CSS custom properties.
|
||||
|
||||
**55. App shell height chain:** Every wrapper between `.main-content` and canvas needs `flex` + `flex-1` + `min-h-0` or `h-full`.
|
||||
|
||||
**56. Railway backend service name is `patherly`:** Production DB name is `railway`. Public Postgres proxy: `interchange.proxy.rlwy.net:45797`.
|
||||
|
||||
**57. Node field priority:** `title` → `question` → `description` → `content` → `label`. See `copilot_service.py`.
|
||||
|
||||
**58. `scriptGeneratorStore.generate()` optional param:** Always wrap: `onClick={() => generate()}`, never `onClick={generate}`.
|
||||
|
||||
**59. ConnectWise `clientId` is server-side config:** Set in `config.py` as `CW_CLIENT_ID`. Per-connection: `company_id`, `public_key`, `private_key`, `server_url`.
|
||||
|
||||
**60. Dockerfile build args for Vite env vars:** Any new `VITE_*` var must be added as `ARG` + `ENV` in `frontend/Dockerfile`. Railway env vars are runtime-only without this; `import.meta.env.VITE_*` resolves to `undefined` in production builds.
|
||||
|
||||
**61. Procedural sessions auto-start on page load:** `ProceduralNavigationPage` calls `startSession()` immediately in `loadTree()` — no intake form screen or "Start" button. Variables filled inline. Troubleshooting flows DO have a start screen.
|
||||
|
||||
**62. Playwright strict mode — scope selectors:** Step titles appear in both sidebar and main heading. Use `getByRole('heading', { name })` for main content.
|
||||
|
||||
**63. Node 20 required for frontend builds:** `export NVM_DIR="$HOME/.nvm" && source "$NVM_DIR/nvm.sh" && nvm use 20`. Or: `PATH="$HOME/.nvm/versions/node/v20.19.0/bin:$PATH"`.
|
||||
|
||||
**64. PostHog product analytics:** `PostHogProvider` in `main.tsx`. Event helpers in `lib/analytics.ts`. `identifyUser()` in `authStore.fetchUser()`, `resetAnalytics()` on logout. Env vars: `VITE_PUBLIC_POSTHOG_KEY`, `VITE_PUBLIC_POSTHOG_HOST`.
|
||||
|
||||
**65. Local Docker Compose uses `resolutionflow` database on port 5433:** Container `resolutionflow_postgres`, DB `resolutionflow` (not `patherly`), port `5433`. Playwright config defaults must match.
|
||||
|
||||
**66. Dev environment runs on Hostinger VPS (46.202.92.250):** CORS must include VPS IP in `CORS_ORIGINS` and `FRONTEND_URL`. See DEV-ENV.md.
|
||||
|
||||
**67. Tree editor route is `/trees/new`:** NOT `/editor/new`. Use `getTreeEditorPath()` from `@/lib/routing`.
|
||||
|
||||
**68. APScheduler jobs need `max_instances=1`:** Without it, overlapping runs can process the same records twice (TOCTOU race).
|
||||
|
||||
**69. PostgreSQL `func.sum(case(...))` returns `Decimal` via asyncpg:** Cast to `int()` before storing in Pydantic `dict[str, Any]` fields.
|
||||
|
||||
**70. Toast library uses `toast.warning()` not `toast.warn()`:** Import from `@/lib/toast`. Methods: `success`, `error`, `warning`, `info`.
|
||||
|
||||
63
docs/connectwise/CW_Security_Roles/README.md
Normal file
63
docs/connectwise/CW_Security_Roles/README.md
Normal file
@@ -0,0 +1,63 @@
|
||||
# ConnectWise integration docs
|
||||
|
||||
Reference material for ResolutionFlow's ConnectWise Manage integration.
|
||||
This folder pairs a **human-editable source** (the XLSX) with two
|
||||
**generated artifacts** (YAML + Markdown). Code reads the YAML; humans
|
||||
read the Markdown; edits happen in the XLSX.
|
||||
|
||||
## Files
|
||||
|
||||
| File | Role | Edit? |
|
||||
|------|------|-------|
|
||||
| `api-member-security-roles.md` | Human-readable reference — browse on GitHub, link in PRs, onboard new contributors. | Generated — do not edit |
|
||||
| `api-member-security-roles.yaml` | Machine-readable source of truth — imported by integration code, queried by Claude Code when writing permission checks. | Generated — do not edit |
|
||||
| `source/Security_Roles_Matrix_11132017.xlsx` | Canonical source. The matrix as published by ConnectWise (with any corrections we've applied). | Yes — this is the editing surface |
|
||||
| `source/generate_role_docs.py` | Regenerates the YAML and Markdown from the XLSX. Deterministic. | Only if the matrix schema itself changes |
|
||||
| `source/requirements.txt` | Python deps for the generator (`openpyxl`, `PyYAML`). | Only when bumping deps |
|
||||
|
||||
## Regeneration workflow
|
||||
|
||||
After editing the XLSX:
|
||||
|
||||
```bash
|
||||
cd docs/integrations/connectwise/source
|
||||
pip install -r requirements.txt
|
||||
python generate_role_docs.py \
|
||||
--source Security_Roles_Matrix_11132017.xlsx \
|
||||
--out-yaml ../api-member-security-roles.yaml \
|
||||
--out-md ../api-member-security-roles.md
|
||||
```
|
||||
|
||||
Commit all three files together (XLSX, YAML, MD). The diff on the YAML
|
||||
is what reviewers should scrutinize — it is the source of truth for code.
|
||||
|
||||
## Querying the YAML from integration code
|
||||
|
||||
The YAML groups permissions by module and action. Example — checking
|
||||
what `Inquire: ALL` means for Service Desk → Service Tickets:
|
||||
|
||||
```python
|
||||
import yaml
|
||||
from pathlib import Path
|
||||
|
||||
doc = yaml.safe_load(
|
||||
Path("docs/integrations/connectwise/api-member-security-roles.yaml").read_text()
|
||||
)
|
||||
levels = doc["modules"]["Service Desk"]["actions"]["Service Tickets"]["inquire"]["levels"]
|
||||
print(levels["ALL"])
|
||||
```
|
||||
|
||||
This is the pattern `ConnectWiseAuthManager` and the proxy authorization
|
||||
layer should use when the required permission level for a given API
|
||||
endpoint needs to be documented or validated against an assigned role.
|
||||
|
||||
## Conventions
|
||||
|
||||
- **Levels are ordered most-to-least privileged:** `ALL`, `MY`, `MINE`, `NONE`.
|
||||
- **Verbs are always in this order:** `add`, `edit`, `delete`, `inquire`.
|
||||
- **`Not applicable` notes** in a verb's cell mean the meaningful level
|
||||
is documented under another verb (almost always `inquire`) — the
|
||||
generator preserves these as `note:` fields rather than inventing
|
||||
placeholder levels.
|
||||
- **The XLSX is the single source of input.** Never hand-edit the YAML
|
||||
or Markdown; your changes will be overwritten on the next regeneration.
|
||||
Binary file not shown.
1373
docs/connectwise/CW_Security_Roles/api-member-security-roles.md
Normal file
1373
docs/connectwise/CW_Security_Roles/api-member-security-roles.md
Normal file
File diff suppressed because it is too large
Load Diff
1913
docs/connectwise/CW_Security_Roles/api-member-security-roles.yaml
Normal file
1913
docs/connectwise/CW_Security_Roles/api-member-security-roles.yaml
Normal file
File diff suppressed because it is too large
Load Diff
361
docs/connectwise/CW_Security_Roles/generate_role_docs.py
Normal file
361
docs/connectwise/CW_Security_Roles/generate_role_docs.py
Normal file
@@ -0,0 +1,361 @@
|
||||
"""
|
||||
Generate ConnectWise security-role documentation from the source XLSX.
|
||||
|
||||
Produces:
|
||||
- api-member-security-roles.yaml : machine-readable source of truth
|
||||
- api-member-security-roles.md : human-readable reference
|
||||
|
||||
Re-run this script after editing the source XLSX. Both outputs are
|
||||
deterministic — they will produce identical content from identical input,
|
||||
so diffs in version control reflect only real permission-model changes.
|
||||
|
||||
Usage:
|
||||
python generate_role_docs.py \
|
||||
--source source/Security_Roles_Matrix_11132017.xlsx \
|
||||
--out-yaml ../api-member-security-roles.yaml \
|
||||
--out-md ../api-member-security-roles.md
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
import yaml
|
||||
from openpyxl import load_workbook
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Parsing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# A level description line looks like "ALL: text..." or "NONE: text..."
|
||||
# We capture the prefix (ALL | NONE | MINE | MY) and the trailing description.
|
||||
LEVEL_LINE = re.compile(r"^(ALL|NONE|MINE|MY)\s*:\s*(.*)$", re.DOTALL)
|
||||
|
||||
# Recognized ConnectWise permission levels, most-to-least privileged.
|
||||
LEVEL_ORDER = ["ALL", "MY", "MINE", "NONE"]
|
||||
|
||||
VERBS = ["add", "edit", "delete", "inquire"]
|
||||
VERB_COLS = {"add": 3, "edit": 4, "delete": 5, "inquire": 6}
|
||||
|
||||
|
||||
@dataclass
|
||||
class CellPermission:
|
||||
"""Parsed contents of a single (action, verb) cell."""
|
||||
|
||||
levels: Dict[str, str] = field(default_factory=dict) # level -> description
|
||||
note: Optional[str] = None # for "Not applicable. See Inquire level." etc.
|
||||
raw: str = "" # original cell text, preserved for audit
|
||||
|
||||
|
||||
@dataclass
|
||||
class ActionRow:
|
||||
module: str
|
||||
action: str
|
||||
permissions: Dict[str, CellPermission] # verb -> CellPermission
|
||||
|
||||
|
||||
def parse_cell(raw: Optional[str]) -> CellPermission:
|
||||
"""Parse a single cell's multi-line content into levels + note."""
|
||||
if raw is None:
|
||||
return CellPermission(raw="")
|
||||
text = str(raw).strip()
|
||||
cp = CellPermission(raw=text)
|
||||
if not text:
|
||||
return cp
|
||||
|
||||
# Split into candidate entries. Each entry is typically one line that
|
||||
# starts with a level prefix, but description text can itself contain
|
||||
# newlines. We therefore split on newlines and accumulate continuation
|
||||
# lines into the preceding entry.
|
||||
current_level: Optional[str] = None
|
||||
current_buf: List[str] = []
|
||||
note_buf: List[str] = []
|
||||
|
||||
def flush_level() -> None:
|
||||
nonlocal current_level, current_buf
|
||||
if current_level is not None:
|
||||
cp.levels[current_level] = " ".join(current_buf).strip()
|
||||
current_level = None
|
||||
current_buf = []
|
||||
|
||||
for line in text.splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
m = LEVEL_LINE.match(line)
|
||||
if m:
|
||||
flush_level()
|
||||
current_level = m.group(1).upper()
|
||||
current_buf = [m.group(2).strip()]
|
||||
elif current_level is not None:
|
||||
current_buf.append(line)
|
||||
else:
|
||||
# No level prefix yet — belongs to the note.
|
||||
note_buf.append(line)
|
||||
flush_level()
|
||||
|
||||
if note_buf:
|
||||
cp.note = " ".join(note_buf).strip()
|
||||
|
||||
return cp
|
||||
|
||||
|
||||
def read_matrix(xlsx_path: Path) -> List[ActionRow]:
|
||||
wb = load_workbook(xlsx_path, data_only=True)
|
||||
ws = wb.active # Single sheet in this workbook.
|
||||
|
||||
# Header row is row 2 per the source file; data begins row 3.
|
||||
actions: List[ActionRow] = []
|
||||
for r in range(3, ws.max_row + 1):
|
||||
module = ws.cell(row=r, column=1).value
|
||||
action = ws.cell(row=r, column=2).value
|
||||
if not (module or action):
|
||||
continue # skip fully empty rows
|
||||
if not module or not action:
|
||||
# Partial row — keep but flag. This shouldn't happen in the
|
||||
# current source; if it does, the generator should fail loudly
|
||||
# rather than silently produce wrong output.
|
||||
raise ValueError(
|
||||
f"Row {r} has a missing Module or Action: "
|
||||
f"module={module!r}, action={action!r}"
|
||||
)
|
||||
|
||||
perms: Dict[str, CellPermission] = {}
|
||||
for verb, col in VERB_COLS.items():
|
||||
perms[verb] = parse_cell(ws.cell(row=r, column=col).value)
|
||||
|
||||
actions.append(
|
||||
ActionRow(module=module.strip(), action=action.strip(), permissions=perms)
|
||||
)
|
||||
return actions
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Output: YAML
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def build_yaml_document(actions: List[ActionRow], source_file: str) -> dict:
|
||||
"""Build a plain-dict representation that YAML dumps cleanly."""
|
||||
# Group by module, preserving action order within each module.
|
||||
modules: Dict[str, List[ActionRow]] = {}
|
||||
for a in actions:
|
||||
modules.setdefault(a.module, []).append(a)
|
||||
|
||||
doc = {
|
||||
"metadata": {
|
||||
"source_file": source_file,
|
||||
"generated_on": date.today().isoformat(),
|
||||
"generator": "docs/integrations/connectwise/source/generate_role_docs.py",
|
||||
"description": (
|
||||
"ConnectWise security-role matrix. Each (module, action) entry "
|
||||
"describes what each access level (ALL, MY, MINE, NONE) means "
|
||||
"for the Add, Edit, Delete, and Inquire verbs. This is a "
|
||||
"reference catalog, not a per-role assignment — role "
|
||||
"assignments live in ConnectWise and are mirrored in the "
|
||||
"ResolutionFlow integration config."
|
||||
),
|
||||
"level_order_most_to_least_privileged": LEVEL_ORDER,
|
||||
},
|
||||
"modules": {},
|
||||
}
|
||||
|
||||
for module_name, rows in modules.items():
|
||||
module_block = {"actions": {}}
|
||||
for a in rows:
|
||||
action_block: Dict[str, object] = {}
|
||||
for verb in VERBS:
|
||||
cell = a.permissions[verb]
|
||||
entry: Dict[str, object] = {}
|
||||
if cell.levels:
|
||||
# Emit levels in canonical order, only those present.
|
||||
entry["levels"] = {
|
||||
lvl: cell.levels[lvl]
|
||||
for lvl in LEVEL_ORDER
|
||||
if lvl in cell.levels
|
||||
}
|
||||
if cell.note:
|
||||
entry["note"] = cell.note
|
||||
if not entry:
|
||||
# Truly empty cell — represent explicitly so downstream
|
||||
# consumers can distinguish "empty" from "missing".
|
||||
entry["note"] = "(no description provided)"
|
||||
action_block[verb] = entry
|
||||
module_block["actions"][a.action] = action_block
|
||||
doc["modules"][module_name] = module_block
|
||||
|
||||
return doc
|
||||
|
||||
|
||||
class _LiteralStr(str):
|
||||
"""Marker type so PyYAML renders long strings as block literals."""
|
||||
|
||||
|
||||
def _literal_presenter(dumper, data):
|
||||
return dumper.represent_scalar("tag:yaml.org,2002:str", data, style="|")
|
||||
|
||||
|
||||
yaml.add_representer(_LiteralStr, _literal_presenter)
|
||||
|
||||
|
||||
def _use_block_style_for_long_strings(obj):
|
||||
"""Recursively wrap long strings so the YAML is readable, not one-line."""
|
||||
if isinstance(obj, dict):
|
||||
return {k: _use_block_style_for_long_strings(v) for k, v in obj.items()}
|
||||
if isinstance(obj, list):
|
||||
return [_use_block_style_for_long_strings(v) for v in obj]
|
||||
if isinstance(obj, str) and (len(obj) > 80 or "\n" in obj):
|
||||
return _LiteralStr(obj)
|
||||
return obj
|
||||
|
||||
|
||||
def dump_yaml(doc: dict, out_path: Path) -> None:
|
||||
prepared = _use_block_style_for_long_strings(doc)
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with out_path.open("w", encoding="utf-8") as f:
|
||||
f.write("# ConnectWise API Member Security Roles — reference matrix.\n")
|
||||
f.write("# Generated from the source XLSX; do not edit by hand.\n")
|
||||
f.write("# Re-run generate_role_docs.py after updating the XLSX.\n\n")
|
||||
yaml.dump(
|
||||
prepared,
|
||||
f,
|
||||
sort_keys=False,
|
||||
allow_unicode=True,
|
||||
width=100,
|
||||
default_flow_style=False,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Output: Markdown
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _md_escape(text: str) -> str:
|
||||
"""Escape pipes and collapse whitespace for Markdown table cells."""
|
||||
return text.replace("|", "\\|").replace("\n", " ").strip()
|
||||
|
||||
|
||||
def build_markdown(actions: List[ActionRow], source_file: str) -> str:
|
||||
modules: Dict[str, List[ActionRow]] = {}
|
||||
for a in actions:
|
||||
modules.setdefault(a.module, []).append(a)
|
||||
|
||||
lines: List[str] = []
|
||||
lines.append("# ConnectWise API Member — Security Roles Reference")
|
||||
lines.append("")
|
||||
lines.append(
|
||||
f"_Generated {date.today().isoformat()} from "
|
||||
f"`{source_file}`. Do not edit by hand — update the XLSX and "
|
||||
f"re-run `generate_role_docs.py`._"
|
||||
)
|
||||
lines.append("")
|
||||
lines.append("## How to read this document")
|
||||
lines.append("")
|
||||
lines.append(
|
||||
"Each ConnectWise module lists the actions it governs. For every "
|
||||
"action, four permission verbs — **Add**, **Edit**, **Delete**, "
|
||||
"**Inquire** — can be granted at one of these levels, most to "
|
||||
"least privileged:"
|
||||
)
|
||||
lines.append("")
|
||||
lines.append("| Level | Meaning |")
|
||||
lines.append("|-------|---------|")
|
||||
lines.append("| `ALL` | Access to all records in the system. |")
|
||||
lines.append("| `MY` | Access to records owned by the user's team. |")
|
||||
lines.append("| `MINE` | Access only to records owned by the user. |")
|
||||
lines.append("| `NONE` | No access. |")
|
||||
lines.append("")
|
||||
lines.append(
|
||||
"Not every level applies to every action — the source matrix "
|
||||
"only documents the levels that are meaningful for each cell. "
|
||||
"Cells marked _Not applicable_ reference another verb (usually "
|
||||
"Inquire) where the meaningful level is defined."
|
||||
)
|
||||
lines.append("")
|
||||
lines.append(
|
||||
"The machine-readable form of this document is "
|
||||
"[`api-member-security-roles.yaml`](./api-member-security-roles.yaml). "
|
||||
"Use the YAML when writing integration code; use this Markdown "
|
||||
"when reviewing, discussing, or onboarding."
|
||||
)
|
||||
lines.append("")
|
||||
lines.append("## Table of contents")
|
||||
lines.append("")
|
||||
for module_name in modules:
|
||||
anchor = module_name.lower().replace(" ", "-").replace("/", "")
|
||||
lines.append(f"- [{module_name}](#{anchor}) — {len(modules[module_name])} actions")
|
||||
lines.append("")
|
||||
|
||||
for module_name, rows in modules.items():
|
||||
lines.append(f"## {module_name}")
|
||||
lines.append("")
|
||||
for a in rows:
|
||||
lines.append(f"### {a.action}")
|
||||
lines.append("")
|
||||
lines.append("| Verb | Level | Description |")
|
||||
lines.append("|------|-------|-------------|")
|
||||
wrote_any = False
|
||||
for verb in VERBS:
|
||||
cell = a.permissions[verb]
|
||||
if cell.levels:
|
||||
for lvl in LEVEL_ORDER:
|
||||
if lvl in cell.levels:
|
||||
lines.append(
|
||||
f"| {verb.capitalize()} | `{lvl}` | "
|
||||
f"{_md_escape(cell.levels[lvl])} |"
|
||||
)
|
||||
wrote_any = True
|
||||
elif cell.note:
|
||||
lines.append(
|
||||
f"| {verb.capitalize()} | — | "
|
||||
f"_{_md_escape(cell.note)}_ |"
|
||||
)
|
||||
wrote_any = True
|
||||
if not wrote_any:
|
||||
lines.append("| — | — | _(no description provided)_ |")
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def write_markdown(md_text: str, out_path: Path) -> None:
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
out_path.write_text(md_text, encoding="utf-8")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--source", type=Path, required=True,
|
||||
help="Path to the source .xlsx")
|
||||
parser.add_argument("--out-yaml", type=Path, required=True,
|
||||
help="Path to write the YAML output")
|
||||
parser.add_argument("--out-md", type=Path, required=True,
|
||||
help="Path to write the Markdown output")
|
||||
args = parser.parse_args()
|
||||
|
||||
actions = read_matrix(args.source)
|
||||
doc = build_yaml_document(actions, source_file=args.source.name)
|
||||
dump_yaml(doc, args.out_yaml)
|
||||
|
||||
md = build_markdown(actions, source_file=args.source.name)
|
||||
write_markdown(md, args.out_md)
|
||||
|
||||
# Quick data-quality summary to stdout — helpful when re-running after edits.
|
||||
from collections import Counter
|
||||
modules_seen = Counter(a.module for a in actions)
|
||||
print(f"Parsed {len(actions)} actions across {len(modules_seen)} modules:")
|
||||
for m, n in modules_seen.most_common():
|
||||
print(f" {m}: {n}")
|
||||
print(f"\nWrote {args.out_yaml}")
|
||||
print(f"Wrote {args.out_md}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
5
docs/connectwise/CW_Security_Roles/requirements.txt
Normal file
5
docs/connectwise/CW_Security_Roles/requirements.txt
Normal file
@@ -0,0 +1,5 @@
|
||||
# Dependencies for generate_role_docs.py.
|
||||
# These are only needed when regenerating the role docs from the XLSX —
|
||||
# they are not runtime dependencies of ResolutionFlow itself.
|
||||
openpyxl>=3.1,<4.0
|
||||
PyYAML>=6.0,<7.0
|
||||
3075
docs/superpowers/plans/2026-04-16-psa-ticket-management.md
Normal file
3075
docs/superpowers/plans/2026-04-16-psa-ticket-management.md
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user