agentlinkops 0.6.13 → 0.6.16
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.
- package/.claude-plugin/plugin.json +2 -2
- package/.codex-plugin/plugin.json +2 -2
- package/CHANGELOG.md +25 -2
- package/PACK-ARTIFACT.json +16 -0
- package/PACKS.json +591 -0
- package/README.md +34 -48
- package/cli/adapters/suppliers.js +1 -1
- package/cli/agent-setup.js +101 -17
- package/cli/campaign-bridge.js +325 -0
- package/cli/campaign-outcomes.js +107 -0
- package/cli/catalog.js +45702 -18674
- package/cli/check.js +40 -10
- package/cli/client.js +2 -1
- package/cli/commands.js +17 -8
- package/cli/connection.js +92 -18
- package/cli/contact-adoption.js +91 -0
- package/cli/context.js +13 -2
- package/cli/doctor.js +15 -4
- package/cli/import.js +7 -1
- package/cli/key-store.js +115 -0
- package/cli/ledger.js +1 -0
- package/cli/local-result.js +41 -6
- package/cli/main.js +51 -12
- package/cli/mcp-client.js +2 -2
- package/cli/migration.js +308 -0
- package/cli/prompt.js +25 -0
- package/cli/public-http.js +168 -0
- package/cli/state.js +14 -0
- package/cli/sync.js +1 -1
- package/package.json +7 -8
- package/references/citation-panels.md +2 -0
- package/references/cli.md +2 -2
- package/references/disavow.md +1 -1
- package/references/discovery-handoff.md +1 -1
- package/references/local-crm-draft-handoff.md +1 -1
- package/references/portable-ledger-handoff.md +11 -10
- package/references/recipes/catalog.json +263 -7
- package/references/recipes/command-notes.md +5 -3
- package/references/recipes/internal-link-improvement.md +25 -0
- package/references/recipes/placement-reconciliation.md +53 -1
- package/scripts/portable-handoff.py +17 -4
- package/shared/admission-contract.js +3 -2
- package/shared/admission-report-contract.js +3 -2
- package/shared/analytics-history-contract.js +15 -0
- package/shared/campaign-outcome-contract.js +12 -0
- package/shared/campaign-prospect-contract.js +23 -0
- package/shared/classification-contract.js +54 -0
- package/shared/contact-record-contract.js +27 -0
- package/shared/disavow-contract.js +37 -9
- package/shared/index-observation-contract.js +5 -4
- package/shared/internal-links-contract.js +143 -0
- package/shared/link-category-contract.js +35 -0
- package/shared/link-category.js +138 -0
- package/shared/link-labels.js +26 -0
- package/shared/link-summary-contract.js +17 -0
- package/shared/observation-event.js +1 -1
- package/shared/onboarding.js +20 -0
- package/shared/response-contract.js +38 -5
- package/shared/search-connection-contract.js +2 -1
- package/shared/table-page-contract.js +13 -0
- package/skills/agentlinkops-assets/SKILL-DEPS.json +290 -0
- package/skills/agentlinkops-assets/SKILL.md +3 -1
- package/skills/agentlinkops-assets/agents/openai.yaml +1 -1
- package/skills/agentlinkops-assets/references/portable/references/cli.md +220 -0
- package/skills/agentlinkops-assets/references/portable/references/disavow.md +73 -0
- package/skills/agentlinkops-assets/references/portable/references/reports.md +53 -0
- package/skills/agentlinkops-assets/references/portable/scripts/agentlinkops.py +676 -0
- package/skills/agentlinkops-assets/references/portable/scripts/disavow.py +207 -0
- package/skills/agentlinkops-assets/references/portable/scripts/migrations/001_crm.sql +45 -0
- package/skills/agentlinkops-assets/references/portable/scripts/migrations/002_cloud_events.sql +13 -0
- package/skills/agentlinkops-assets/references/portable/scripts/migrations/003_disavow.sql +20 -0
- package/skills/agentlinkops-assets/references/portable/scripts/migrations/004_placement_costs.sql +2 -0
- package/skills/agentlinkops-assets/references/portable/scripts/report.py +113 -0
- package/skills/agentlinkops-assets/references/portable/templates/campaigns.v1.json +65 -0
- package/skills/agentlinkops-campaigns/SKILL-DEPS.json +336 -0
- package/skills/agentlinkops-campaigns/SKILL.md +7 -5
- package/skills/agentlinkops-campaigns/agents/openai.yaml +1 -1
- package/skills/agentlinkops-campaigns/references/first-campaign.md +31 -0
- package/skills/agentlinkops-campaigns/references/mailbox-campaign.md +46 -0
- package/skills/agentlinkops-campaigns/references/portable/references/cli.md +220 -0
- package/skills/agentlinkops-campaigns/references/portable/references/disavow.md +73 -0
- package/skills/agentlinkops-campaigns/references/portable/references/reports.md +53 -0
- package/skills/agentlinkops-campaigns/references/portable/scripts/agentlinkops.py +676 -0
- package/skills/agentlinkops-campaigns/references/portable/scripts/disavow.py +207 -0
- package/skills/agentlinkops-campaigns/references/portable/scripts/migrations/001_crm.sql +45 -0
- package/skills/agentlinkops-campaigns/references/portable/scripts/migrations/002_cloud_events.sql +13 -0
- package/skills/agentlinkops-campaigns/references/portable/scripts/migrations/003_disavow.sql +20 -0
- package/skills/agentlinkops-campaigns/references/portable/scripts/migrations/004_placement_costs.sql +2 -0
- package/skills/agentlinkops-campaigns/references/portable/scripts/report.py +113 -0
- package/skills/agentlinkops-campaigns/references/portable/templates/campaigns.v1.json +65 -0
- package/skills/agentlinkops-connect/SKILL-DEPS.json +104 -0
- package/skills/agentlinkops-connect/SKILL.md +2 -0
- package/skills/agentlinkops-connect/agents/openai.yaml +1 -1
- package/skills/agentlinkops-connect/references/agentlinkops.md +164 -62
- package/skills/agentlinkops-connect/references/placement-workflows.md +19 -0
- package/skills/agentlinkops-crm/SKILL-DEPS.json +381 -0
- package/skills/agentlinkops-crm/SKILL.md +26 -9
- package/skills/agentlinkops-crm/agents/openai.yaml +1 -1
- package/skills/agentlinkops-crm/references/portable/references/cli.md +220 -0
- package/skills/agentlinkops-crm/references/portable/references/disavow.md +73 -0
- package/skills/agentlinkops-crm/references/portable/references/local-crm-draft-handoff.md +76 -0
- package/skills/agentlinkops-crm/references/portable/references/portable-ledger-handoff.md +51 -0
- package/skills/agentlinkops-crm/references/portable/references/reports.md +53 -0
- package/skills/agentlinkops-crm/references/portable/scripts/agentlinkops.py +676 -0
- package/skills/agentlinkops-crm/references/portable/scripts/disavow.py +207 -0
- package/skills/agentlinkops-crm/references/portable/scripts/migrations/001_crm.sql +45 -0
- package/skills/agentlinkops-crm/references/portable/scripts/migrations/002_cloud_events.sql +13 -0
- package/skills/agentlinkops-crm/references/portable/scripts/migrations/003_disavow.sql +20 -0
- package/skills/agentlinkops-crm/references/portable/scripts/migrations/004_placement_costs.sql +2 -0
- package/skills/agentlinkops-crm/references/portable/scripts/outreach-crm-draft.py +121 -0
- package/skills/agentlinkops-crm/references/portable/scripts/portable-handoff.py +197 -0
- package/skills/agentlinkops-crm/references/portable/scripts/report.py +113 -0
- package/skills/agentlinkops-crm/references/portable/templates/campaigns.v1.json +65 -0
- package/skills/agentlinkops-discovery/SKILL-DEPS.json +301 -0
- package/skills/agentlinkops-discovery/SKILL.md +4 -2
- package/skills/agentlinkops-discovery/references/portable/references/cli.md +220 -0
- package/skills/agentlinkops-discovery/references/portable/references/disavow.md +73 -0
- package/skills/agentlinkops-discovery/references/portable/references/discovery-handoff.md +32 -0
- package/skills/agentlinkops-discovery/references/portable/references/reports.md +53 -0
- package/skills/agentlinkops-discovery/references/portable/scripts/agentlinkops.py +676 -0
- package/skills/agentlinkops-discovery/references/portable/scripts/disavow.py +207 -0
- package/skills/agentlinkops-discovery/references/portable/scripts/migrations/001_crm.sql +45 -0
- package/skills/agentlinkops-discovery/references/portable/scripts/migrations/002_cloud_events.sql +13 -0
- package/skills/agentlinkops-discovery/references/portable/scripts/migrations/003_disavow.sql +20 -0
- package/skills/agentlinkops-discovery/references/portable/scripts/migrations/004_placement_costs.sql +2 -0
- package/skills/agentlinkops-discovery/references/portable/scripts/report.py +113 -0
- package/skills/agentlinkops-discovery/references/portable/templates/campaigns.v1.json +65 -0
- package/skills/agentlinkops-discovery/references/saved-backlink-workflows.md +23 -0
- package/src/competitors/contract.js +17 -1
- package/src/context/site-profile.js +34 -121
- package/src/corpus/acquire.js +68 -32
- package/src/corpus/extract.js +147 -0
- package/src/corpus/frontier.js +151 -0
- package/src/discovery/contract.js +8 -0
- package/src/discovery/import.js +13 -1
- package/src/mentions.js +193 -0
- package/src/page-acquisition/acquire.js +50 -0
- package/src/page-acquisition/bytes.js +138 -0
- package/src/page-acquisition/capture.js +11 -0
- package/src/page-acquisition/contracts.js +67 -0
- package/src/page-acquisition/documents.js +166 -0
- package/src/page-acquisition/http.js +307 -0
- package/src/page-acquisition/policy.js +20 -0
- package/src/page-acquisition/recovery.js +164 -0
- package/src/page-acquisition/render-failure.js +82 -0
- package/src/page-extraction/html.js +359 -0
- package/src/page-extraction/index.js +30 -0
- package/src/page-extraction/metadata.js +16 -0
- package/src/response-shaping.js +4 -3
- package/src/tool-views.js +22 -12
- package/src/verifier/fetch.js +4 -339
- package/src/verifier/index.js +81 -23
- package/src/verifier/page-quality.js +14 -0
- package/src/verifier/parser.js +2 -241
- package/src/verifier/readiness.js +64 -13
- package/src/verifier/render-evidence.js +25 -2
- package/src/verifier/target.js +201 -0
- package/src/verifier/transition.js +18 -3
- package/src/verifier/url.js +31 -0
- package/src/watch-crawlability.js +144 -0
- package/skills/content-defingerprinting/SKILL.md +0 -29
- package/skills/content-defingerprinting/scripts/watermarks/README.md +0 -102
- package/skills/content-defingerprinting/scripts/watermarks/UPSTREAM.json +0 -26
- package/skills/content-defingerprinting/scripts/watermarks/revendor.sh +0 -52
- package/skills/content-defingerprinting/scripts/watermarks/vendor/LICENSE.upstream +0 -21
- package/skills/content-defingerprinting/scripts/watermarks/vendor/clean_text.py +0 -96
- package/skills/content-defingerprinting/scripts/watermarks/vendor/common.py +0 -432
- package/skills/content-defingerprinting/scripts/watermarks/vendor/inspect_text.py +0 -86
- package/skills/content-defingerprinting/scripts/watermarks/vendor/score_stylometry.py +0 -460
- package/skills/content-defingerprinting/scripts/watermarks/vendor/text_unicode.py +0 -651
- package/skills/content-defingerprinting/scripts/watermarks/verify-vendor.py +0 -27
- package/skills/content-defingerprinting/scripts/watermarks/wm-scan.py +0 -212
- package/skills/internal-linking-optimizer/SKILL.md +0 -56
- package/skills/internal-linking-optimizer/references/source-prompt.md +0 -76
- package/skills/pre-publish-review/SKILL.md +0 -179
- package/skills/source-cited-content-builder/SKILL.md +0 -56
- package/skills/source-cited-content-builder/references/source-prompt.md +0 -19
- package/skills/source-cited-humanizer/SKILL.md +0 -20
|
@@ -0,0 +1,676 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""AgentLinkOps portable CRM. Python 3.10+, standard library only, no telemetry."""
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
# The floor is checked BEFORE anything else is imported, because the failure it prevents is the
|
|
6
|
+
# one a user cannot act on: without this, an older interpreter fails somewhere deep inside a
|
|
7
|
+
# command with an AttributeError about a string method, and nothing in that message says
|
|
8
|
+
# "upgrade Python". The version we support is the version we test on.
|
|
9
|
+
MINIMUM_PYTHON = (3, 10)
|
|
10
|
+
if sys.version_info < MINIMUM_PYTHON: # pragma: no cover - exercised by test_python_floor
|
|
11
|
+
sys.stderr.write(
|
|
12
|
+
"agentlinkops: needs Python {}.{} or newer; this is Python {}.{}.{}.\n"
|
|
13
|
+
"Install a newer Python and run it explicitly, for example:\n"
|
|
14
|
+
" python3.12 {} --help\n".format(
|
|
15
|
+
MINIMUM_PYTHON[0], MINIMUM_PYTHON[1],
|
|
16
|
+
sys.version_info[0], sys.version_info[1], sys.version_info[2], sys.argv[0]))
|
|
17
|
+
raise SystemExit(2)
|
|
18
|
+
|
|
19
|
+
"""Portable AgentLinkOps CRM. Python 3.10+, standard library only, no telemetry."""
|
|
20
|
+
import argparse
|
|
21
|
+
import csv
|
|
22
|
+
import datetime as dt
|
|
23
|
+
import hashlib
|
|
24
|
+
import io
|
|
25
|
+
import json
|
|
26
|
+
import os
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
import sqlite3
|
|
29
|
+
import stat
|
|
30
|
+
import sys
|
|
31
|
+
import urllib.error
|
|
32
|
+
import urllib.parse
|
|
33
|
+
import urllib.request
|
|
34
|
+
import disavow
|
|
35
|
+
import report
|
|
36
|
+
|
|
37
|
+
VERSION = "0.2.0"
|
|
38
|
+
APPLICATION_ID = 0x4C54524C
|
|
39
|
+
ROOT = Path(__file__).resolve().parent
|
|
40
|
+
ENTITIES = {
|
|
41
|
+
"product": ("products", {"name", "url", "audience", "description", "target_pages", "notes"}, {"name", "url"}),
|
|
42
|
+
"campaign": ("campaigns", {"product_id", "name", "type", "template_version", "status", "goal", "target_url", "notes"}, {"name", "type"}),
|
|
43
|
+
"contact": ("contacts", {"publisher_url", "name", "email", "contact_url", "source_url", "observed_at", "confidence", "notes"}, {"publisher_url"}),
|
|
44
|
+
"opportunity": ("opportunities", {"campaign_id", "contact_id", "source_url", "target_url", "type", "status", "evidence", "notes"}, {"source_url", "target_url"}),
|
|
45
|
+
"activity": ("activities", {"opportunity_id", "contact_id", "campaign_id", "channel", "kind", "external_message_id", "occurred_at", "outcome", "notes"}, {"kind", "occurred_at"}),
|
|
46
|
+
"placement": ("placements", {"opportunity_id", "project_id", "watch_id", "source_url", "target_url", "status", "notes", "cost_amount", "cost_currency"}, {"source_url", "target_url"}),
|
|
47
|
+
}
|
|
48
|
+
JSON_COLUMNS = {"target_pages", "evidence", "cloud_state", "payload"}
|
|
49
|
+
FORBIDDEN_KEYS = {"password", "api_key", "access_token", "refresh_token", "secret", "credentials", "message_body", "email_body", "mailbox_password", "body", "html_body"}
|
|
50
|
+
WATCH_EVENTS = {"watch.checked", "watch.state_changed", "watch.recovered", "watch.expectations_changed", "watch.created", "watch.updated", "watch.deleted", "placement_acquired", "placement_lost", "placement_recovered", "placement_changed", "source_unavailable", "placement.deleted", "placement.state_changed"}
|
|
51
|
+
MAX_JSON = 4 * 1024 * 1024
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class CRMError(Exception):
|
|
55
|
+
def __init__(self, message, code="INVALID_INPUT"):
|
|
56
|
+
super().__init__(message)
|
|
57
|
+
self.code = code
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def now():
|
|
61
|
+
return dt.datetime.now(dt.timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def encode(value):
|
|
65
|
+
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False, allow_nan=False)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def text_id(value, name="id"):
|
|
69
|
+
if not isinstance(value, str) or not value.strip() or len(value) > 1024 or any(ord(c) < 32 for c in value):
|
|
70
|
+
raise CRMError(f"{name} must be a nonempty string without control characters")
|
|
71
|
+
return value
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def date_string(value, name):
|
|
75
|
+
text_id(value, name)
|
|
76
|
+
try:
|
|
77
|
+
parsed = dt.datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
78
|
+
except ValueError as exc:
|
|
79
|
+
raise CRMError(f"{name} must be an ISO 8601 timestamp") from exc
|
|
80
|
+
if parsed.tzinfo is None:
|
|
81
|
+
raise CRMError(f"{name} must include a timezone")
|
|
82
|
+
return value
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def public_url(value, name, empty=False):
|
|
86
|
+
if empty and value == "":
|
|
87
|
+
return value
|
|
88
|
+
if not isinstance(value, str):
|
|
89
|
+
raise CRMError(f"{name} must be an HTTP(S) URL")
|
|
90
|
+
try:
|
|
91
|
+
parsed = urllib.parse.urlsplit(value)
|
|
92
|
+
if parsed.scheme not in {"https", "http"} or not parsed.hostname or parsed.username or parsed.password:
|
|
93
|
+
raise ValueError()
|
|
94
|
+
parsed.port
|
|
95
|
+
except ValueError as exc:
|
|
96
|
+
raise CRMError(f"{name} must be an HTTP(S) URL without credentials") from exc
|
|
97
|
+
return value
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def no_sensitive_fields(value):
|
|
101
|
+
if isinstance(value, dict):
|
|
102
|
+
for key, child in value.items():
|
|
103
|
+
if key.lower().replace("-", "_") in FORBIDDEN_KEYS:
|
|
104
|
+
raise CRMError(f"Field {key} is not stored; use external message IDs and outcome notes")
|
|
105
|
+
no_sensitive_fields(child)
|
|
106
|
+
elif isinstance(value, list):
|
|
107
|
+
for child in value:
|
|
108
|
+
no_sensitive_fields(child)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def migrations():
|
|
112
|
+
result = []
|
|
113
|
+
for path in sorted((ROOT / "migrations").glob("[0-9][0-9][0-9]_*.sql")):
|
|
114
|
+
sql = path.read_text()
|
|
115
|
+
result.append((int(path.name[:3]), path.name, sql, hashlib.sha256(sql.encode()).hexdigest()))
|
|
116
|
+
if [row[0] for row in result] != list(range(1, len(result) + 1)):
|
|
117
|
+
raise CRMError("Migration files must be contiguous from 001", "SCHEMA_ERROR")
|
|
118
|
+
return result
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def require_private_file(info):
|
|
122
|
+
if not stat.S_ISREG(info.st_mode):
|
|
123
|
+
raise CRMError("CRM storage must be a regular file", "DATABASE_ERROR")
|
|
124
|
+
if os.name == "posix" and stat.S_IMODE(info.st_mode) != 0o600:
|
|
125
|
+
raise CRMError("CRM storage requires mode 0600; secure existing files or use a filesystem that enforces private permissions", "PRIVATE_STORAGE_REQUIRED")
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def open_private_file(path):
|
|
129
|
+
descriptor = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
|
|
130
|
+
identity = None
|
|
131
|
+
try:
|
|
132
|
+
identity = os.fstat(descriptor)
|
|
133
|
+
require_private_file(identity)
|
|
134
|
+
return descriptor
|
|
135
|
+
except Exception:
|
|
136
|
+
# Check the created inode is still empty before cleanup.
|
|
137
|
+
try:
|
|
138
|
+
current = os.lstat(path)
|
|
139
|
+
if identity and (current.st_dev, current.st_ino) == (identity.st_dev, identity.st_ino) and current.st_size == 0:
|
|
140
|
+
os.unlink(path)
|
|
141
|
+
finally:
|
|
142
|
+
os.close(descriptor)
|
|
143
|
+
raise
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def connect(path, allow_create=False):
|
|
147
|
+
path = Path(path).expanduser()
|
|
148
|
+
if path.is_symlink():
|
|
149
|
+
raise CRMError("Refusing a symlink database path", "DATABASE_ERROR")
|
|
150
|
+
if not allow_create and not path.is_file():
|
|
151
|
+
raise CRMError("CRM does not exist; run init with a workspace ID", "DATABASE_ERROR")
|
|
152
|
+
if allow_create and not path.exists():
|
|
153
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
154
|
+
os.close(open_private_file(path))
|
|
155
|
+
require_private_file(path.lstat())
|
|
156
|
+
connection = sqlite3.connect(str(path), timeout=10, isolation_level=None)
|
|
157
|
+
connection.row_factory = sqlite3.Row
|
|
158
|
+
connection.execute("PRAGMA foreign_keys = ON")
|
|
159
|
+
return connection
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def inspect_schema(connection, allow_empty=False):
|
|
163
|
+
tables = {r[0] for r in connection.execute("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'")}
|
|
164
|
+
app = connection.execute("PRAGMA application_id").fetchone()[0]
|
|
165
|
+
version = connection.execute("PRAGMA user_version").fetchone()[0]
|
|
166
|
+
if not tables and app == 0 and version == 0 and allow_empty:
|
|
167
|
+
return 0
|
|
168
|
+
if app != APPLICATION_ID or "schema_migrations" not in tables or "crm_meta" not in tables:
|
|
169
|
+
raise CRMError("File is not an AgentLinkOps CRM; it was not modified", "SCHEMA_ERROR")
|
|
170
|
+
known = migrations()
|
|
171
|
+
if version > len(known):
|
|
172
|
+
raise CRMError("CRM was created by a newer toolkit; refusing to downgrade", "SCHEMA_TOO_NEW")
|
|
173
|
+
recorded = list(connection.execute("SELECT version, name, checksum FROM schema_migrations ORDER BY version"))
|
|
174
|
+
if len(recorded) != version:
|
|
175
|
+
raise CRMError("Migration ledger and schema version disagree", "SCHEMA_ERROR")
|
|
176
|
+
for row, expected in zip(recorded, known):
|
|
177
|
+
if tuple(row) != (expected[0], expected[1], expected[3]):
|
|
178
|
+
raise CRMError("Applied migration checksum changed; restore original migration files", "SCHEMA_ERROR")
|
|
179
|
+
return version
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def workspace(connection):
|
|
183
|
+
row = connection.execute("SELECT value FROM crm_meta WHERE key='workspace_id'").fetchone()
|
|
184
|
+
if row is None:
|
|
185
|
+
raise CRMError("CRM workspace binding is missing", "SCHEMA_ERROR")
|
|
186
|
+
return row[0]
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def migrate(connection, path, workspace_id=None, initialize=False):
|
|
190
|
+
version = inspect_schema(connection, allow_empty=initialize)
|
|
191
|
+
if version:
|
|
192
|
+
actual_workspace = workspace(connection)
|
|
193
|
+
if workspace_id is not None and actual_workspace != workspace_id:
|
|
194
|
+
raise CRMError("CRM already belongs to another workspace", "WORKSPACE_MISMATCH")
|
|
195
|
+
elif workspace_id is None:
|
|
196
|
+
raise CRMError("A workspace ID is required for initialization")
|
|
197
|
+
pending = [m for m in migrations() if m[0] > version]
|
|
198
|
+
backup = None
|
|
199
|
+
if version and pending:
|
|
200
|
+
backup = str(Path(path).expanduser()) + ".pre-v" + str(version + 1) + "." + dt.datetime.now().strftime("%Y%m%d%H%M%S%f") + ".bak"
|
|
201
|
+
os.close(open_private_file(backup))
|
|
202
|
+
with sqlite3.connect(backup) as target:
|
|
203
|
+
connection.backup(target)
|
|
204
|
+
connection.execute("BEGIN IMMEDIATE")
|
|
205
|
+
try:
|
|
206
|
+
# Lock first and recheck: concurrent init/migrate must not replay DDL.
|
|
207
|
+
version = inspect_schema(connection, allow_empty=initialize)
|
|
208
|
+
if version and workspace_id is not None and workspace(connection) != workspace_id:
|
|
209
|
+
raise CRMError("CRM already belongs to another workspace", "WORKSPACE_MISMATCH")
|
|
210
|
+
connection.execute("CREATE TABLE IF NOT EXISTS schema_migrations (version INTEGER PRIMARY KEY, name TEXT NOT NULL, checksum TEXT NOT NULL, applied_at TEXT NOT NULL)")
|
|
211
|
+
for number, name, sql, checksum in migrations():
|
|
212
|
+
if number <= version:
|
|
213
|
+
continue
|
|
214
|
+
# Migrations contain simple DDL only. executescript would commit the outer transaction.
|
|
215
|
+
statement = ""
|
|
216
|
+
for line in sql.splitlines(keepends=True):
|
|
217
|
+
statement += line
|
|
218
|
+
if sqlite3.complete_statement(statement):
|
|
219
|
+
connection.execute(statement)
|
|
220
|
+
statement = ""
|
|
221
|
+
if statement.strip():
|
|
222
|
+
raise CRMError(f"Incomplete migration: {name}", "SCHEMA_ERROR")
|
|
223
|
+
connection.execute("INSERT INTO schema_migrations VALUES (?, ?, ?, ?)", (number, name, checksum, now()))
|
|
224
|
+
connection.execute(f"PRAGMA user_version = {number}")
|
|
225
|
+
connection.execute(f"PRAGMA application_id = {APPLICATION_ID}")
|
|
226
|
+
if version == 0:
|
|
227
|
+
connection.execute("INSERT INTO crm_meta VALUES ('workspace_id', ?)", (text_id(workspace_id, "workspace_id"),))
|
|
228
|
+
connection.execute("COMMIT")
|
|
229
|
+
except Exception:
|
|
230
|
+
connection.execute("ROLLBACK")
|
|
231
|
+
raise
|
|
232
|
+
return {"schema_version": len(migrations()), "workspace_id": workspace(connection), "backup": backup}
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def require_current(connection):
|
|
236
|
+
if inspect_schema(connection) != len(migrations()):
|
|
237
|
+
raise CRMError("CRM needs migration; run migrate (a backup is created first)", "MIGRATION_REQUIRED")
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def decode_row(row):
|
|
241
|
+
result = dict(row)
|
|
242
|
+
for key in JSON_COLUMNS & result.keys():
|
|
243
|
+
result[key] = json.loads(result[key])
|
|
244
|
+
return result
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def upsert(connection, entity, record):
|
|
248
|
+
if not isinstance(record, dict):
|
|
249
|
+
raise CRMError("Record must be a JSON object")
|
|
250
|
+
no_sensitive_fields(record)
|
|
251
|
+
table, allowed, required = ENTITIES[entity]
|
|
252
|
+
unknown = record.keys() - allowed - {"id"}
|
|
253
|
+
if unknown:
|
|
254
|
+
raise CRMError("Unsupported fields: " + ", ".join(sorted(unknown)))
|
|
255
|
+
record = dict(record)
|
|
256
|
+
text_id(record.get("id"))
|
|
257
|
+
existing = connection.execute(f"SELECT * FROM {table} WHERE id=?", (record["id"],)).fetchone()
|
|
258
|
+
if existing is None and required - record.keys():
|
|
259
|
+
raise CRMError("Missing fields: " + ", ".join(sorted(required - record.keys())))
|
|
260
|
+
for key in required & record.keys():
|
|
261
|
+
if not isinstance(record[key], str) or not record[key].strip():
|
|
262
|
+
raise CRMError(f"{key} must be a nonempty string")
|
|
263
|
+
if entity == "placement" and ({"cost_amount", "cost_currency"} & record.keys()):
|
|
264
|
+
try:
|
|
265
|
+
record["cost_amount"], record["cost_currency"] = report.cost(record.get("cost_amount", existing["cost_amount"] if existing else None), record.get("cost_currency", existing["cost_currency"] if existing else None))
|
|
266
|
+
except ValueError as exc:
|
|
267
|
+
raise CRMError(str(exc)) from exc
|
|
268
|
+
for key, value in record.items():
|
|
269
|
+
if key in JSON_COLUMNS:
|
|
270
|
+
expected = list if key == "target_pages" else dict
|
|
271
|
+
if not isinstance(value, expected):
|
|
272
|
+
raise CRMError(f"{key} must be a JSON {expected.__name__}")
|
|
273
|
+
if key == "target_pages":
|
|
274
|
+
for item in value:
|
|
275
|
+
public_url(item, "target_pages item")
|
|
276
|
+
record[key] = encode(value)
|
|
277
|
+
elif key == "template_version":
|
|
278
|
+
if type(value) is not int or value < 1:
|
|
279
|
+
raise CRMError("template_version must be a positive integer")
|
|
280
|
+
elif value is None and (key.endswith("_id") or key in {"cost_amount", "cost_currency"}):
|
|
281
|
+
continue
|
|
282
|
+
elif not isinstance(value, str):
|
|
283
|
+
raise CRMError(f"{key} must be a string")
|
|
284
|
+
elif key == "url" or key.endswith("_url"):
|
|
285
|
+
public_url(value, key, empty=key not in required)
|
|
286
|
+
elif key.endswith("_at") and value:
|
|
287
|
+
date_string(value, key)
|
|
288
|
+
elif key.endswith("_id") and value:
|
|
289
|
+
text_id(value, key)
|
|
290
|
+
if entity == "campaign":
|
|
291
|
+
chosen = record.get("type", existing["type"] if existing else None)
|
|
292
|
+
if chosen not in templates()["campaigns"]:
|
|
293
|
+
raise CRMError("Unknown campaign type; use a supported template or custom")
|
|
294
|
+
stamp = now()
|
|
295
|
+
record["updated_at"] = stamp
|
|
296
|
+
if existing is None:
|
|
297
|
+
record["created_at"] = stamp
|
|
298
|
+
keys = list(record)
|
|
299
|
+
connection.execute(f"INSERT INTO {table} ({','.join(keys)}) VALUES ({','.join('?' for _ in keys)})", [record[k] for k in keys])
|
|
300
|
+
else:
|
|
301
|
+
keys = [key for key in record if key != "id"]
|
|
302
|
+
connection.execute(f"UPDATE {table} SET {','.join(key + '=?' for key in keys)} WHERE id=?", [record[k] for k in keys] + [record["id"]])
|
|
303
|
+
return decode_row(connection.execute(f"SELECT * FROM {table} WHERE id=?", (record["id"],)).fetchone())
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def list_records(connection, entity, record_id=None):
|
|
307
|
+
table = ENTITIES[entity][0]
|
|
308
|
+
rows = connection.execute(f"SELECT * FROM {table}" + (" WHERE id=?" if record_id else "") + " ORDER BY id", (record_id,) if record_id else ())
|
|
309
|
+
return [decode_row(row) for row in rows]
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def templates():
|
|
313
|
+
return json.loads((ROOT.parent / "templates" / "campaigns.v1.json").read_text())
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def validate_page(page, expected_workspace, requested_cursor):
|
|
317
|
+
if not isinstance(page, dict) or page.get("workspace_id") != expected_workspace:
|
|
318
|
+
raise CRMError("Cloud response workspace does not match this CRM", "WORKSPACE_MISMATCH")
|
|
319
|
+
events = page.get("events")
|
|
320
|
+
if not isinstance(events, list) or len(events) > 1000 or type(page.get("has_more")) is not bool:
|
|
321
|
+
raise CRMError("Malformed event page", "INVALID_EVENT_PAGE")
|
|
322
|
+
next_cursor = page.get("next_cursor")
|
|
323
|
+
text_id(next_cursor, "next_cursor")
|
|
324
|
+
# A project-restricted feed can advance across hidden events, including empty pages.
|
|
325
|
+
if page["has_more"] and (next_cursor is None or next_cursor == requested_cursor):
|
|
326
|
+
raise CRMError("Event pagination made no progress", "INVALID_EVENT_PAGE")
|
|
327
|
+
seen_ids, seen_cursors = set(), set()
|
|
328
|
+
for event in events:
|
|
329
|
+
if not isinstance(event, dict) or event.get("workspace_id") != expected_workspace:
|
|
330
|
+
raise CRMError("Event workspace does not match this CRM", "WORKSPACE_MISMATCH")
|
|
331
|
+
for name in ("id", "cursor", "type"):
|
|
332
|
+
text_id(event.get(name), name)
|
|
333
|
+
date_string(event.get("created_at"), "created_at")
|
|
334
|
+
for name in ("project_id", "watch_id"):
|
|
335
|
+
if event.get(name) is not None:
|
|
336
|
+
text_id(event[name], name)
|
|
337
|
+
if not isinstance(event.get("data"), dict):
|
|
338
|
+
raise CRMError("Event data must be an object", "INVALID_EVENT_PAGE")
|
|
339
|
+
if event["id"] in seen_ids or event["cursor"] in seen_cursors:
|
|
340
|
+
raise CRMError("Event page contains duplicate identifiers", "INVALID_EVENT_PAGE")
|
|
341
|
+
seen_ids.add(event["id"])
|
|
342
|
+
seen_cursors.add(event["cursor"])
|
|
343
|
+
no_sensitive_fields(event["data"])
|
|
344
|
+
return events
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def apply_event(connection, event):
|
|
348
|
+
# Unknown event types remain in the local feed for forward compatibility.
|
|
349
|
+
if not event.get("watch_id") or event["type"] not in WATCH_EVENTS:
|
|
350
|
+
return
|
|
351
|
+
after = event["data"].get("after")
|
|
352
|
+
is_deletion = event["type"] in {"watch.deleted", "placement.deleted"}
|
|
353
|
+
if not isinstance(after, dict) and not (is_deletion and "after" in event["data"] and after is None):
|
|
354
|
+
raise CRMError("Watch after state must be an object; only deletion events allow null", "INVALID_EVENT_PAGE")
|
|
355
|
+
current = connection.execute("SELECT * FROM placements WHERE watch_id=?", (event["watch_id"],)).fetchone()
|
|
356
|
+
snapshot = dict(after or {})
|
|
357
|
+
for key in ("watch_id", "project_id"):
|
|
358
|
+
if key in snapshot and snapshot[key] != event.get(key):
|
|
359
|
+
raise CRMError("Watch snapshot has mismatched identifiers", "INVALID_EVENT_PAGE")
|
|
360
|
+
if current and current["project_id"] is not None and event.get("project_id") != current["project_id"]:
|
|
361
|
+
raise CRMError("Watch changed its project binding", "PROJECT_MISMATCH")
|
|
362
|
+
for key in ("source_url", "target_url"):
|
|
363
|
+
if key in snapshot:
|
|
364
|
+
public_url(snapshot[key], key)
|
|
365
|
+
if after is None:
|
|
366
|
+
snapshot = {"deleted": True}
|
|
367
|
+
stamp = now()
|
|
368
|
+
if current is None:
|
|
369
|
+
if "source_url" not in snapshot or "target_url" not in snapshot:
|
|
370
|
+
# Preserve the event without inventing a placement from a partial state.
|
|
371
|
+
return
|
|
372
|
+
connection.execute("INSERT INTO placements (id,project_id,watch_id,source_url,target_url,created_at,updated_at) VALUES (?,?,?,?,?,?,?)", (
|
|
373
|
+
"cloud:" + event["watch_id"], event.get("project_id"), event["watch_id"], snapshot["source_url"], snapshot["target_url"], stamp, stamp))
|
|
374
|
+
else:
|
|
375
|
+
# URL bindings are cloud owned; outreach status, notes and local relations are not.
|
|
376
|
+
for key in ("source_url", "target_url"):
|
|
377
|
+
if key in snapshot:
|
|
378
|
+
connection.execute(f"UPDATE placements SET {key}=? WHERE watch_id=?", (snapshot[key], event["watch_id"]))
|
|
379
|
+
connection.execute("UPDATE placements SET cloud_state=?, cloud_observed_at=?, cloud_event_id=?, updated_at=? WHERE watch_id=?", (
|
|
380
|
+
encode(snapshot), event["created_at"], event["id"], stamp, event["watch_id"]))
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def apply_page(connection, page, requested_cursor, endpoint):
|
|
384
|
+
events = validate_page(page, workspace(connection), requested_cursor)
|
|
385
|
+
connection.execute("BEGIN IMMEDIATE")
|
|
386
|
+
try:
|
|
387
|
+
state = connection.execute("SELECT * FROM sync_state WHERE singleton=1").fetchone()
|
|
388
|
+
if state["cursor"] != requested_cursor:
|
|
389
|
+
raise CRMError("Another sync updated the cursor; retry from current state", "SYNC_CONFLICT")
|
|
390
|
+
if state["endpoint"] not in (None, endpoint):
|
|
391
|
+
raise CRMError("CRM is bound to another service endpoint; use a separate CRM", "ENDPOINT_MISMATCH")
|
|
392
|
+
previous_position = connection.execute("SELECT rowid FROM cloud_events WHERE cursor=?", (requested_cursor,)).fetchone()
|
|
393
|
+
next_position = connection.execute("SELECT rowid FROM cloud_events WHERE cursor=?", (page["next_cursor"],)).fetchone()
|
|
394
|
+
if previous_position and next_position and next_position[0] < previous_position[0]:
|
|
395
|
+
raise CRMError("Cloud feed attempted to rewind a known cursor", "INVALID_EVENT_PAGE")
|
|
396
|
+
applied = 0
|
|
397
|
+
for event in events:
|
|
398
|
+
payload = encode(event)
|
|
399
|
+
fingerprint = hashlib.sha256(payload.encode()).hexdigest()
|
|
400
|
+
previous = connection.execute("SELECT payload_hash FROM cloud_events WHERE id=?", (event["id"],)).fetchone()
|
|
401
|
+
if previous:
|
|
402
|
+
if previous[0] != fingerprint:
|
|
403
|
+
raise CRMError("An existing cloud event changed its payload", "EVENT_CONFLICT")
|
|
404
|
+
continue
|
|
405
|
+
connection.execute("INSERT INTO cloud_events VALUES (?,?,?,?,?,?,?,?,?)", (
|
|
406
|
+
event["id"], event["cursor"], event["type"], event.get("project_id"), event.get("watch_id"), event["created_at"], payload, fingerprint, now()))
|
|
407
|
+
apply_event(connection, event)
|
|
408
|
+
applied += 1
|
|
409
|
+
connection.execute("UPDATE sync_state SET endpoint=?, cursor=?, updated_at=? WHERE singleton=1", (endpoint, page["next_cursor"], now()))
|
|
410
|
+
connection.execute("COMMIT")
|
|
411
|
+
except Exception:
|
|
412
|
+
connection.execute("ROLLBACK")
|
|
413
|
+
raise
|
|
414
|
+
return applied
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
class NoRedirect(urllib.request.HTTPRedirectHandler):
|
|
418
|
+
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
|
419
|
+
return None
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
# Environment names, with the pilot compatibility window (DP-0029): AGENTLINKOPS_* is preferred,
|
|
423
|
+
# LINKTRAIL_* is accepted with one warning per process, and two different values under the two
|
|
424
|
+
# names is a refusal rather than a guess. Values are never printed.
|
|
425
|
+
_WARNED_ENV = set()
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def read_env(suffix, environ=None):
|
|
429
|
+
environ = os.environ if environ is None else environ
|
|
430
|
+
preferred, legacy = "AGENTLINKOPS_" + suffix, "LINKTRAIL_" + suffix
|
|
431
|
+
fresh, old = environ.get(preferred, ""), environ.get(legacy, "")
|
|
432
|
+
if fresh and old and fresh != old:
|
|
433
|
+
raise CRMError(f"{preferred} and {legacy} disagree; select one value", "ENV_CONFLICT")
|
|
434
|
+
if fresh:
|
|
435
|
+
return fresh, preferred
|
|
436
|
+
if old:
|
|
437
|
+
if legacy not in _WARNED_ENV:
|
|
438
|
+
_WARNED_ENV.add(legacy)
|
|
439
|
+
sys.stderr.write(f"{legacy} is deprecated; set {preferred} instead. The old name keeps working during the pilot compatibility window.\n")
|
|
440
|
+
return old, legacy
|
|
441
|
+
return "", preferred
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def api_endpoint(value, name="AGENTLINKOPS_API_URL"):
|
|
445
|
+
public_url(value, name)
|
|
446
|
+
parsed = urllib.parse.urlsplit(value)
|
|
447
|
+
if parsed.query or parsed.fragment or parsed.path not in {"", "/"}:
|
|
448
|
+
raise CRMError(f"{name} must be a service origin without path/query/fragment")
|
|
449
|
+
if parsed.scheme != "https" and parsed.hostname not in {"localhost", "127.0.0.1", "::1"}:
|
|
450
|
+
raise CRMError("Cloud sync requires HTTPS; HTTP is allowed only on loopback")
|
|
451
|
+
return value.rstrip("/")
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def fetch_page(endpoint, key, cursor, limit):
|
|
455
|
+
params = {"limit": str(limit)}
|
|
456
|
+
if cursor is not None:
|
|
457
|
+
params["cursor"] = cursor
|
|
458
|
+
request = urllib.request.Request(endpoint + "/v1/events?" + urllib.parse.urlencode(params), headers={"Authorization": "Bearer " + key, "Accept": "application/json", "User-Agent": "AgentLinkOps-CRM/" + VERSION})
|
|
459
|
+
try:
|
|
460
|
+
with urllib.request.build_opener(NoRedirect).open(request, timeout=30) as response:
|
|
461
|
+
raw = response.read(MAX_JSON + 1)
|
|
462
|
+
if len(raw) > MAX_JSON:
|
|
463
|
+
raise CRMError("Event response exceeded 4 MiB", "INVALID_EVENT_PAGE")
|
|
464
|
+
return json.loads(raw)
|
|
465
|
+
except urllib.error.HTTPError as exc:
|
|
466
|
+
if exc.code == 410:
|
|
467
|
+
raise CRMError("Cloud cursor expired. Cursor and local notes were preserved. Export this CRM and recover a current watch snapshot into a separate CRM before replaying retained events.", "CURSOR_EXPIRED") from exc
|
|
468
|
+
if exc.code in (401, 403):
|
|
469
|
+
raise CRMError("Cloud access was rejected; check the configured account and permissions", "AUTH_REQUIRED") from exc
|
|
470
|
+
raise CRMError(f"Cloud returned HTTP {exc.code}; this page was not applied", "HTTP_ERROR") from exc
|
|
471
|
+
except (urllib.error.URLError, TimeoutError, OSError) as exc:
|
|
472
|
+
raise CRMError("Cloud could not be reached; this page was not applied", "NETWORK_ERROR") from exc
|
|
473
|
+
except (json.JSONDecodeError, UnicodeDecodeError) as exc:
|
|
474
|
+
raise CRMError("Cloud response is not valid JSON", "INVALID_EVENT_PAGE") from exc
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def sync(connection, limit=100, max_pages=100):
|
|
478
|
+
url_value, url_name = read_env("API_URL")
|
|
479
|
+
endpoint = api_endpoint(url_value, url_name)
|
|
480
|
+
key, key_name = read_env("API_KEY")
|
|
481
|
+
if not key or any(ord(c) < 33 or ord(c) > 126 for c in key):
|
|
482
|
+
raise CRMError(f"Set {key_name} in the process environment; do not paste it into chat", "AUTH_REQUIRED")
|
|
483
|
+
state = connection.execute("SELECT * FROM sync_state WHERE singleton=1").fetchone()
|
|
484
|
+
if state["endpoint"] not in (None, endpoint):
|
|
485
|
+
raise CRMError("CRM is bound to another service endpoint; use a separate CRM", "ENDPOINT_MISMATCH")
|
|
486
|
+
applied, pages = 0, 0
|
|
487
|
+
cursor = state["cursor"]
|
|
488
|
+
has_more = True
|
|
489
|
+
while has_more and pages < max_pages:
|
|
490
|
+
page = fetch_page(endpoint, key, cursor, limit)
|
|
491
|
+
applied += apply_page(connection, page, cursor, endpoint)
|
|
492
|
+
cursor, has_more = page["next_cursor"], page["has_more"]
|
|
493
|
+
pages += 1
|
|
494
|
+
return {"applied": applied, "pages": pages, "next_cursor": cursor, "has_more": has_more}
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def read_json(path):
|
|
498
|
+
if path == "-":
|
|
499
|
+
raw = sys.stdin.read(MAX_JSON + 1)
|
|
500
|
+
else:
|
|
501
|
+
with Path(path).open() as source:
|
|
502
|
+
raw = source.read(MAX_JSON + 1)
|
|
503
|
+
if len(raw) > MAX_JSON:
|
|
504
|
+
raise CRMError("Input exceeded 4 MiB")
|
|
505
|
+
return json.loads(raw)
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def export_csv(records, fieldnames):
|
|
509
|
+
output = io.StringIO(newline="")
|
|
510
|
+
writer = csv.DictWriter(output, fieldnames=fieldnames)
|
|
511
|
+
writer.writeheader()
|
|
512
|
+
for record in records:
|
|
513
|
+
escaped = {}
|
|
514
|
+
for key, value in record.items():
|
|
515
|
+
value = encode(value) if isinstance(value, (dict, list)) else value
|
|
516
|
+
# CSVs are often opened in spreadsheets; do not execute formulas from publisher text.
|
|
517
|
+
if isinstance(value, str) and value.lstrip().startswith(("=", "+", "-", "@", "\t", "\r")):
|
|
518
|
+
value = "'" + value
|
|
519
|
+
escaped[key] = value
|
|
520
|
+
writer.writerow(escaped)
|
|
521
|
+
return output.getvalue()
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def write_new(path, content, suffix=None):
|
|
525
|
+
path = Path(path).expanduser()
|
|
526
|
+
if suffix and path.suffix.lower() != suffix:
|
|
527
|
+
raise CRMError(f"Output file must end in {suffix}")
|
|
528
|
+
descriptor = open_private_file(path)
|
|
529
|
+
with os.fdopen(descriptor, "w", encoding="utf-8", newline="") as output:
|
|
530
|
+
output.write(content)
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
def main(argv=None):
|
|
534
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
535
|
+
parser.add_argument("--version", action="version", version=VERSION)
|
|
536
|
+
parser.add_argument("--db", default=read_env("CRM_PATH")[0] or None, help="Local SQLite path (or AGENTLINKOPS_CRM_PATH); never stored inside the installed plugin")
|
|
537
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
538
|
+
initialize = commands.add_parser("init")
|
|
539
|
+
initialize.add_argument("--workspace", required=True)
|
|
540
|
+
commands.add_parser("migrate")
|
|
541
|
+
commands.add_parser("status")
|
|
542
|
+
commands.add_parser("templates")
|
|
543
|
+
for entity in ENTITIES:
|
|
544
|
+
entity_parser = commands.add_parser(entity)
|
|
545
|
+
entity_actions = entity_parser.add_subparsers(dest="action", required=True)
|
|
546
|
+
put = entity_actions.add_parser("upsert")
|
|
547
|
+
put.add_argument("--file", default="-", help="JSON file or - for stdin; accepts a record or atomic list of records")
|
|
548
|
+
listing = entity_actions.add_parser("list")
|
|
549
|
+
listing.add_argument("--id")
|
|
550
|
+
exporter = commands.add_parser("export")
|
|
551
|
+
exporter.add_argument("--format", choices=("json", "csv"), default="json")
|
|
552
|
+
exporter.add_argument("--entity", choices=ENTITIES)
|
|
553
|
+
exporter.add_argument("--output", help="Create a new file; existing files are never overwritten")
|
|
554
|
+
reporter = commands.add_parser("report", help="Generate a self-contained HTML placement report from selected local data")
|
|
555
|
+
reporter.add_argument("--output", required=True, help="New .html file; existing files are never overwritten")
|
|
556
|
+
reporter.add_argument("--title", default="Link placement report")
|
|
557
|
+
reporter.add_argument("--brand", default="AgentLinkOps")
|
|
558
|
+
reporter.add_argument("--project", dest="project_id", help="Include one project's placements; defaults to all local placements")
|
|
559
|
+
reporter.add_argument("--generated-at", help="ISO 8601 report timestamp; set explicitly for deterministic output")
|
|
560
|
+
reporter.add_argument("--include-notes", action="store_true", help="Explicitly include private local placement notes")
|
|
561
|
+
reporter.add_argument("--include-costs", action="store_true", help="Explicitly include local cost amounts and totals by currency")
|
|
562
|
+
disavowing = commands.add_parser("disavow", help="User-managed, property-scoped disavow rules; no Google uploads")
|
|
563
|
+
disavow_actions = disavowing.add_subparsers(dest="action", required=True)
|
|
564
|
+
for action in ("import", "upsert", "list", "export"):
|
|
565
|
+
command = disavow_actions.add_parser(action)
|
|
566
|
+
command.add_argument("--property", required=True, dest="property_url", help="Exact Search Console URL-prefix property, not a Domain property")
|
|
567
|
+
if action in {"import", "upsert"}:
|
|
568
|
+
command.add_argument("--source", required=True, help="Provenance label chosen by the user")
|
|
569
|
+
command.add_argument("--file", required=action == "import", default="-", help="UTF-8 .txt import or upsert JSON file; upsert accepts stdin")
|
|
570
|
+
if action == "list":
|
|
571
|
+
command.add_argument("--active-only", action="store_true")
|
|
572
|
+
if action == "export":
|
|
573
|
+
command.add_argument("--output", help="New .txt file; defaults to stdout")
|
|
574
|
+
syncing = commands.add_parser("sync")
|
|
575
|
+
syncing.add_argument("--limit", type=int, default=100)
|
|
576
|
+
syncing.add_argument("--max-pages", type=int, default=100)
|
|
577
|
+
args = parser.parse_args(argv)
|
|
578
|
+
connection = None
|
|
579
|
+
try:
|
|
580
|
+
if args.command == "templates":
|
|
581
|
+
print(encode(templates()))
|
|
582
|
+
return 0
|
|
583
|
+
if not args.db:
|
|
584
|
+
raise CRMError("Supply --db or AGENTLINKOPS_CRM_PATH")
|
|
585
|
+
if args.command == "init":
|
|
586
|
+
text_id(args.workspace, "workspace_id")
|
|
587
|
+
connection = connect(args.db, allow_create=args.command == "init")
|
|
588
|
+
if args.command in {"init", "migrate"}:
|
|
589
|
+
result = migrate(connection, args.db, getattr(args, "workspace", None), initialize=args.command == "init")
|
|
590
|
+
else:
|
|
591
|
+
require_current(connection)
|
|
592
|
+
if args.command in ENTITIES:
|
|
593
|
+
if args.action == "upsert":
|
|
594
|
+
records = read_json(args.file)
|
|
595
|
+
multiple = isinstance(records, list)
|
|
596
|
+
connection.execute("BEGIN IMMEDIATE")
|
|
597
|
+
try:
|
|
598
|
+
result = [upsert(connection, args.command, record) for record in (records if multiple else [records])]
|
|
599
|
+
connection.execute("COMMIT")
|
|
600
|
+
except Exception:
|
|
601
|
+
connection.execute("ROLLBACK")
|
|
602
|
+
raise
|
|
603
|
+
if not multiple:
|
|
604
|
+
result = result[0]
|
|
605
|
+
else:
|
|
606
|
+
result = list_records(connection, args.command, args.id)
|
|
607
|
+
elif args.command == "sync":
|
|
608
|
+
if not 1 <= args.limit <= 1000 or not 1 <= args.max_pages <= 1000:
|
|
609
|
+
raise CRMError("limit and max-pages must be between 1 and 1000")
|
|
610
|
+
result = sync(connection, args.limit, args.max_pages)
|
|
611
|
+
elif args.command == "disavow":
|
|
612
|
+
if args.action == "import":
|
|
613
|
+
result = disavow.import_rules(connection, args.file, args.property_url, args.source)
|
|
614
|
+
elif args.action == "upsert":
|
|
615
|
+
record = read_json(args.file)
|
|
616
|
+
no_sensitive_fields(record)
|
|
617
|
+
result = disavow.upsert_rule(connection, record, args.property_url, args.source)
|
|
618
|
+
elif args.action == "list":
|
|
619
|
+
result = disavow.list_rules(connection, args.property_url, args.active_only)
|
|
620
|
+
else:
|
|
621
|
+
content, result = disavow.export_rules(connection, args.property_url)
|
|
622
|
+
if args.output:
|
|
623
|
+
write_new(args.output, content, ".txt")
|
|
624
|
+
result["exported"] = args.output
|
|
625
|
+
else:
|
|
626
|
+
sys.stdout.write(content)
|
|
627
|
+
return 0
|
|
628
|
+
elif args.command == "report":
|
|
629
|
+
generated_at = date_string(args.generated_at or now(), "generated_at")
|
|
630
|
+
if args.project_id:
|
|
631
|
+
text_id(args.project_id, "project_id")
|
|
632
|
+
try:
|
|
633
|
+
content, result = report.render(connection, args.title, args.brand, generated_at, args.project_id, args.include_notes, args.include_costs)
|
|
634
|
+
except ValueError as exc:
|
|
635
|
+
raise CRMError(str(exc)) from exc
|
|
636
|
+
write_new(args.output, content, ".html")
|
|
637
|
+
result["exported"] = args.output
|
|
638
|
+
elif args.command == "status":
|
|
639
|
+
result = {"version": VERSION, "schema_version": len(migrations()), "workspace_id": workspace(connection), "sync": dict(connection.execute("SELECT endpoint,cursor,updated_at FROM sync_state").fetchone()), "counts": {entity: connection.execute(f"SELECT count(*) FROM {values[0]}").fetchone()[0] for entity, values in ENTITIES.items()}}
|
|
640
|
+
elif args.command == "export":
|
|
641
|
+
if args.format == "csv" and not args.entity:
|
|
642
|
+
raise CRMError("CSV export requires --entity")
|
|
643
|
+
connection.execute("BEGIN")
|
|
644
|
+
try:
|
|
645
|
+
if args.entity:
|
|
646
|
+
data = list_records(connection, args.entity)
|
|
647
|
+
else:
|
|
648
|
+
data = {"schema_version": len(migrations()), "workspace_id": workspace(connection), "records": {entity: list_records(connection, entity) for entity in ENTITIES}, "cloud_events": [decode_row(row) for row in connection.execute("SELECT * FROM cloud_events ORDER BY rowid")], "sync": dict(connection.execute("SELECT * FROM sync_state").fetchone()), "disavow": disavow.snapshot(connection)}
|
|
649
|
+
fields = [row[1] for row in connection.execute(f"PRAGMA table_info({ENTITIES[args.entity][0]})")] if args.entity else []
|
|
650
|
+
connection.execute("COMMIT")
|
|
651
|
+
except Exception:
|
|
652
|
+
connection.execute("ROLLBACK")
|
|
653
|
+
raise
|
|
654
|
+
result_text = export_csv(data, fields) if args.format == "csv" else encode(data) + "\n"
|
|
655
|
+
if args.output:
|
|
656
|
+
write_new(args.output, result_text)
|
|
657
|
+
result = {"exported": args.output, "format": args.format}
|
|
658
|
+
else:
|
|
659
|
+
sys.stdout.write(result_text)
|
|
660
|
+
return 0
|
|
661
|
+
print(encode(result))
|
|
662
|
+
return 0
|
|
663
|
+
except (CRMError, disavow.DisavowError) as exc:
|
|
664
|
+
print(encode({"error": {"code": exc.code, "message": str(exc)}}), file=sys.stderr)
|
|
665
|
+
return 1
|
|
666
|
+
except (sqlite3.Error, ValueError, OSError) as exc:
|
|
667
|
+
# Do not print potentially sensitive input, SQL parameters or remote response bodies.
|
|
668
|
+
print(encode({"error": {"code": "LOCAL_OPERATION_FAILED", "message": "Operation failed (" + type(exc).__name__ + "); the current transaction was not committed"}}), file=sys.stderr)
|
|
669
|
+
return 1
|
|
670
|
+
finally:
|
|
671
|
+
if connection:
|
|
672
|
+
connection.close()
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
if __name__ == "__main__":
|
|
676
|
+
sys.exit(main())
|