@aiguru/google-web-operations 0.6.4

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.
Files changed (116) hide show
  1. package/.env.example +42 -0
  2. package/README.md +110 -0
  3. package/apps/cli/src/index.js +286 -0
  4. package/apps/mcp-server/src/index.js +360 -0
  5. package/apps/worker/src/index.js +96 -0
  6. package/apps/worker/src/loop.js +46 -0
  7. package/bin/gwo.js +67 -0
  8. package/docs/BOOTSTRAP-NEXT-PROMPT.md +57 -0
  9. package/docs/GETTING-STARTED.he.md +63 -0
  10. package/docs/HOSTED-WORKER.md +36 -0
  11. package/docs/INSTALL.he.md +57 -0
  12. package/docs/PRODUCT-TECHNICAL-SPEC-v0.2.md +153 -0
  13. package/docs/PRODUCT-TECHNICAL-SPEC-v0.3.md +86 -0
  14. package/docs/PRODUCT-TECHNICAL-SPEC-v0.4.md +92 -0
  15. package/docs/PRODUCT-TECHNICAL-SPEC-v0.5.md +94 -0
  16. package/docs/PRODUCT-TECHNICAL-SPEC-v0.6.md +98 -0
  17. package/docs/SECURITY.md +69 -0
  18. package/docs/SITE-ADAPTERS.md +87 -0
  19. package/docs/adr/ADR-001-shared-mcp-core.md +27 -0
  20. package/docs/adr/ADR-002-policy-gated-writes.md +18 -0
  21. package/docs/adr/ADR-003-single-source-playbooks.md +18 -0
  22. package/docs/adr/ADR-004-native-typescript-runtime.md +16 -0
  23. package/docs/adr/ADR-005-two-token-publish.md +22 -0
  24. package/docs/adr/ADR-006-one-package-one-installer.md +20 -0
  25. package/hosts/claude/.claude-plugin/plugin.json +9 -0
  26. package/hosts/claude/.mcp.json +10 -0
  27. package/hosts/claude/README.md +7 -0
  28. package/hosts/claude/commands/gwo-add-site.md +7 -0
  29. package/hosts/claude/commands/gwo-daily.md +7 -0
  30. package/hosts/claude/commands/gwo-measure.md +7 -0
  31. package/hosts/claude/commands/gwo-monthly.md +7 -0
  32. package/hosts/claude/commands/gwo-onboard.md +7 -0
  33. package/hosts/claude/commands/gwo-scan.md +7 -0
  34. package/hosts/claude/commands/gwo-setup.md +7 -0
  35. package/hosts/claude/commands/gwo-triage.md +7 -0
  36. package/hosts/claude/commands/gwo-weekly.md +7 -0
  37. package/hosts/claude/skills/gwo-client-report-template/SKILL.md +36 -0
  38. package/hosts/claude/skills/gwo-content-brief/SKILL.md +60 -0
  39. package/hosts/claude/skills/gwo-daily/SKILL.md +47 -0
  40. package/hosts/claude/skills/gwo-measurement/SKILL.md +92 -0
  41. package/hosts/claude/skills/gwo-monthly/SKILL.md +30 -0
  42. package/hosts/claude/skills/gwo-onboarding/SKILL.md +27 -0
  43. package/hosts/claude/skills/gwo-operating-model/SKILL.md +76 -0
  44. package/hosts/claude/skills/gwo-setup/SKILL.md +42 -0
  45. package/hosts/claude/skills/gwo-triage/SKILL.md +37 -0
  46. package/hosts/claude/skills/gwo-weekly/SKILL.md +48 -0
  47. package/hosts/codex/.codex/config.template.toml +5 -0
  48. package/hosts/codex/.codex-plugin/plugin.json +30 -0
  49. package/hosts/codex/AGENTS.md +470 -0
  50. package/hosts/codex/README.md +13 -0
  51. package/hosts/codex/skills/gwo-client-report-template/SKILL.md +36 -0
  52. package/hosts/codex/skills/gwo-content-brief/SKILL.md +60 -0
  53. package/hosts/codex/skills/gwo-daily/SKILL.md +47 -0
  54. package/hosts/codex/skills/gwo-measurement/SKILL.md +92 -0
  55. package/hosts/codex/skills/gwo-monthly/SKILL.md +30 -0
  56. package/hosts/codex/skills/gwo-onboarding/SKILL.md +27 -0
  57. package/hosts/codex/skills/gwo-operating-model/SKILL.md +76 -0
  58. package/hosts/codex/skills/gwo-setup/SKILL.md +42 -0
  59. package/hosts/codex/skills/gwo-triage/SKILL.md +37 -0
  60. package/hosts/codex/skills/gwo-weekly/SKILL.md +48 -0
  61. package/package.json +24 -0
  62. package/packages/connectors/browser/src/index.js +122 -0
  63. package/packages/connectors/docx/src/index.js +122 -0
  64. package/packages/connectors/github/src/index.js +102 -0
  65. package/packages/connectors/google/src/auth.js +73 -0
  66. package/packages/connectors/google/src/discover.js +135 -0
  67. package/packages/connectors/google/src/ga4.js +42 -0
  68. package/packages/connectors/google/src/gsc.js +49 -0
  69. package/packages/connectors/google/src/gtm.js +261 -0
  70. package/packages/connectors/google/src/index.js +8 -0
  71. package/packages/connectors/google/src/oauth.js +198 -0
  72. package/packages/connectors/google/src/pagespeed.js +48 -0
  73. package/packages/connectors/google/src/token-store.js +89 -0
  74. package/packages/connectors/odoo/src/index.js +206 -0
  75. package/packages/connectors/repo/src/index.js +94 -0
  76. package/packages/connectors/resend/src/index.js +30 -0
  77. package/packages/connectors/web-crawler/src/a11y.js +91 -0
  78. package/packages/connectors/web-crawler/src/fetch.js +165 -0
  79. package/packages/connectors/web-crawler/src/index.js +69 -0
  80. package/packages/connectors/web-crawler/src/parse.js +157 -0
  81. package/packages/core/src/adapters.js +27 -0
  82. package/packages/core/src/admin.js +232 -0
  83. package/packages/core/src/content.js +213 -0
  84. package/packages/core/src/doctor.js +39 -0
  85. package/packages/core/src/escalate.js +36 -0
  86. package/packages/core/src/gtm-ops.js +311 -0
  87. package/packages/core/src/index.js +13 -0
  88. package/packages/core/src/operations.js +239 -0
  89. package/packages/core/src/paths.js +46 -0
  90. package/packages/core/src/registry.js +114 -0
  91. package/packages/core/src/report/monthly.js +272 -0
  92. package/packages/core/src/scan/render.js +40 -0
  93. package/packages/core/src/scan/rules.js +289 -0
  94. package/packages/core/src/scan/suppress.js +37 -0
  95. package/packages/core/src/types.js +10 -0
  96. package/packages/core/src/writes.js +50 -0
  97. package/packages/playbooks/00-operating-model.md +74 -0
  98. package/packages/playbooks/10-daily.md +45 -0
  99. package/packages/playbooks/20-weekly.md +46 -0
  100. package/packages/playbooks/30-monthly.md +28 -0
  101. package/packages/playbooks/40-triage.md +35 -0
  102. package/packages/playbooks/50-onboarding.md +25 -0
  103. package/packages/playbooks/60-content-brief.md +58 -0
  104. package/packages/playbooks/70-measurement.md +90 -0
  105. package/packages/playbooks/80-setup.md +40 -0
  106. package/packages/playbooks/90-client-report-template.md +34 -0
  107. package/packages/policy/src/index.js +115 -0
  108. package/packages/storage/src/index.js +131 -0
  109. package/policies/default.yaml +90 -0
  110. package/sites/schema.yaml +71 -0
  111. package/sites/secrets.example.yaml +6 -0
  112. package/tools/build-hosts.js +118 -0
  113. package/tools/build-package.js +43 -0
  114. package/tools/install.js +145 -0
  115. package/tools/schedule.ps1 +37 -0
  116. package/tools/smoke-mcp.js +61 -0
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Human-readable health check: the same facts as setupStatus(), laid out as
3
+ * a checklist a person can act on. Used by `gwo doctor` and, in text form,
4
+ * by the chat when the operator asks "is everything set up?".
5
+ */
6
+ import { setupStatus } from "./admin.js";
7
+ import { HOME_DIR, IS_CHECKOUT, ROOT } from "./paths.js";
8
+ const OK = ' [ok] ', MISSING = ' [--] ', OPT = ' [..] ';
9
+ export function renderDoctor(s = setupStatus()) {
10
+ const out = [];
11
+ const [major = 0, minor = 0] = process.versions.node.split('.').map(Number);
12
+ out.push('Google Web Operations: doctor', '');
13
+ out.push(`${major > 22 || (major === 22 && minor >= 18) ? OK : MISSING}Node ${process.versions.node} (needs 22.18+)`);
14
+ out.push(`${OK}package ${ROOT}`);
15
+ out.push(`${OK}state ${IS_CHECKOUT ? 'next to the code (repository checkout)' : HOME_DIR}`);
16
+ out.push('', 'Google');
17
+ out.push(`${s.google.clientFile ? OK : MISSING}OAuth client ${s.google.clientFile ?? 'not installed (google_setup_oauth_client, or gwo auth login after saving secrets/oauth-client.json)'}`);
18
+ out.push(`${s.google.authorised ? OK : MISSING}authorised ${s.google.authorised ? `as ${s.google.email ?? 'unknown account'}` : 'no (google_auth_login or gwo auth login)'}`);
19
+ for (const [k, v] of Object.entries(s.google.writeScopes))
20
+ out.push(`${v ? OK : OPT}write scope ${k}${v ? '' : ' (optional; add when that write is needed)'}`);
21
+ out.push('', 'Environment (.env)');
22
+ for (const [k, v] of Object.entries(s.env))
23
+ out.push(`${v ? OK : OPT}${k}${v ? '' : ' not set'}`);
24
+ out.push('', `Sites (${s.sites.length})`);
25
+ if (!s.sites.length)
26
+ out.push(`${MISSING}none registered (ops_add_site or gwo add-site <url> --name "...")`);
27
+ for (const site of s.sites) {
28
+ out.push(` ${site.enabled ? '*' : ' '} ${site.id.padEnd(16)} ${site.host.padEnd(34)} ${site.platform}/${site.tier}, adapter ${site.adapter}${site.adapterConfigured ? '' : ' (not configured)'}`);
29
+ out.push(` Search Console ${site.searchConsole ? 'ok' : '--'} GA4 ${site.ga4PropertyId ? 'ok' : '--'} Tag Manager ${site.gtm ? 'ok' : '--'} suppressions ${site.suppressions}`);
30
+ for (const m of site.missing)
31
+ out.push(` missing: ${m}`);
32
+ }
33
+ out.push('', 'Next steps');
34
+ if (!s.nextSteps.length)
35
+ out.push(`${OK}nothing required`);
36
+ for (const n of s.nextSteps)
37
+ out.push(` - ${n}`);
38
+ return out.join('\n');
39
+ }
@@ -0,0 +1,36 @@
1
+ const SITE_RULE_KINDS = {
2
+ 'robots.search-blocked': 'crawler-blocked',
3
+ 'geo.ai-blocked': 'crawler-blocked',
4
+ 'host.multiple-live': 'host-split',
5
+ 'sitemap.unreachable': 'sitemap-unreachable',
6
+ };
7
+ /** Pure. Given the previous and current snapshots and their diff, list what must be triaged. */
8
+ export function escalations(before, after, diff) {
9
+ const out = [];
10
+ for (const { url, changes } of diff.pageChanges) {
11
+ for (const c of changes) {
12
+ const b = c.before, a = c.after;
13
+ if (c.field === 'status' && typeof b === 'number' && typeof a === 'number' && b < 400 && a >= 400) {
14
+ out.push({ kind: 'page-broken', url, detail: `was ${b}, now ${a}` });
15
+ }
16
+ if (c.field === 'noindex' && b === false && a === true)
17
+ out.push({ kind: 'noindex-gained', url, detail: 'page is now noindex' });
18
+ if (c.field === 'canonical' && b && !a)
19
+ out.push({ kind: 'canonical-lost', url, detail: `canonical ${String(b)} removed` });
20
+ if ((c.field === 'ga4' || c.field === 'gtm') && Array.isArray(b) && b.length && Array.isArray(a) && !a.length) {
21
+ out.push({ kind: 'tracking-lost', url, detail: `${c.field.toUpperCase()} id ${b.join(',')} no longer on the page` });
22
+ }
23
+ }
24
+ }
25
+ // Site-level findings that appeared since last time.
26
+ for (const id of diff.newFindings) {
27
+ const rule = id.split('|')[0] ?? '';
28
+ const kind = SITE_RULE_KINDS[rule];
29
+ if (kind)
30
+ out.push({ kind, url: null, detail: `new finding ${rule}` });
31
+ }
32
+ if (before.sitemapCount > 0 && after.sitemapCount < before.sitemapCount * 0.8) {
33
+ out.push({ kind: 'sitemap-shrunk', url: null, detail: `sitemap went from ${before.sitemapCount} to ${after.sitemapCount} URLs` });
34
+ }
35
+ return out;
36
+ }
@@ -0,0 +1,311 @@
1
+ /**
2
+ * Tag Manager operations: reads (containers, versions, snapshot, diff) and
3
+ * the staged write path.
4
+ *
5
+ * Staging protocol, enforced here and not by the host:
6
+ * 1. gtmStage every change lands in a workspace named <prefix><date>
7
+ * (policy write_rules.gtm.workspace_name_prefix). Each
8
+ * entity is one performWrite: read, create or update,
9
+ * read back, verify. Policy action gtm.stage.
10
+ * 2. gtmWorkspaceDiff workspace status against the live version, with a
11
+ * review token bound to exactly those changes.
12
+ * 3. gtmPublish policy action gtm.publish plus the review token. If the
13
+ * workspace or the live version moved since the review,
14
+ * the token no longer matches and nothing is published.
15
+ *
16
+ * Nothing here calls a connector's mutating function outside performWrite().
17
+ */
18
+ import { gtm } from "../../connectors/google/src/index.js";
19
+ import * as storage from "../../storage/src/index.js";
20
+ import { writeRules } from "../../policy/src/index.js";
21
+ import { getSite } from "./registry.js";
22
+ import { performWrite } from "./writes.js";
23
+ /** Resolve a site's container ids, by configured ids or by looking up its public id. */
24
+ export async function gtmContainerFor(siteIdOrHost) {
25
+ const site = getSite(siteIdOrHost);
26
+ const tm = site.google.tag_manager ?? {};
27
+ if (tm.account_id && tm.container_id)
28
+ return { site, accountId: tm.account_id, containerId: tm.container_id, publicId: tm.public_id ?? null };
29
+ if (!tm.public_id)
30
+ throw new Error(`Site ${site.site_id} has no Tag Manager container configured (google.tag_manager.public_id).`);
31
+ const hit = (await gtm.listAllContainers()).find((c) => c.publicId === tm.public_id);
32
+ if (!hit)
33
+ throw new Error(`Container ${tm.public_id} is not visible to the operations account. Ask the client to grant access.`);
34
+ return { site, accountId: hit.accountId, containerId: hit.containerId, publicId: hit.publicId };
35
+ }
36
+ // ---------------------------------------------------------------- reads
37
+ /** Every container the operations account can see. */
38
+ export async function gtmContainers() {
39
+ return gtm.listAllContainers();
40
+ }
41
+ /** Version headers for a site's container: who published what, and when a new one appeared. */
42
+ export async function gtmVersions(siteIdOrHost) {
43
+ const { accountId, containerId, publicId } = await gtmContainerFor(siteIdOrHost);
44
+ const headers = await gtm.listVersionHeaders(accountId, containerId);
45
+ return { publicId, versions: headers.filter((h) => !h.deleted).map((h) => ({ id: h.containerVersionId, name: h.name ?? null, tags: Number(h.numTags ?? 0), triggers: Number(h.numTriggers ?? 0), variables: Number(h.numVariables ?? 0) })) };
46
+ }
47
+ /** Snapshot the live container version, persist it, return the rendered summary. */
48
+ export async function gtmSnapshot(siteIdOrHost) {
49
+ const { site, accountId, containerId, publicId } = await gtmContainerFor(siteIdOrHost);
50
+ const live = await gtm.getLiveVersion(accountId, containerId);
51
+ const snap = gtm.shapeSnapshot(live, { accountId, containerId, publicId });
52
+ const file = storage.saveNamed(site.site_id, 'gtm', snap);
53
+ storage.appendAudit({ siteId: site.site_id, operation: 'gtm.snapshot', status: 'observed', risk: 'auto',
54
+ summary: `GTM ${publicId ?? containerId} live version ${snap.versionId}: ${snap.counts.tags} tags, ${snap.counts.paused} paused`, data: snap.counts });
55
+ return { snapshot: snap, file, rendered: gtm.renderSnapshot(snap) };
56
+ }
57
+ /** Diff the two most recent GTM snapshots of a site. */
58
+ export function gtmDiff(siteIdOrHost) {
59
+ const site = getSite(siteIdOrHost);
60
+ const names = storage.listNamed(site.site_id, 'gtm');
61
+ if (names.length < 2)
62
+ return { error: `Only ${names.length} GTM snapshot(s) for ${site.site_id}; two are needed.` };
63
+ const b = storage.loadNamed(site.site_id, 'gtm', names.at(-2));
64
+ const a = storage.loadNamed(site.site_id, 'gtm', names.at(-1));
65
+ if (!b || !a)
66
+ return { error: 'GTM snapshot file missing.' };
67
+ return { before: names.at(-2), after: names.at(-1), diff: gtm.diffSnapshots(b, a) };
68
+ }
69
+ /** Workspaces of a site's container with their pending change counts. Shows staged work and clutter. */
70
+ export async function gtmWorkspaces(siteIdOrHost) {
71
+ const { accountId, containerId, publicId } = await gtmContainerFor(siteIdOrHost);
72
+ const out = [];
73
+ for (const w of await gtm.listWorkspaces(accountId, containerId)) {
74
+ const st = await gtm.workspaceStatus(accountId, containerId, w.workspaceId);
75
+ const changes = (st.workspaceChange ?? []).filter((c) => c.changeStatus !== 'none').length;
76
+ out.push({ id: w.workspaceId, name: w.name, description: w.description ?? null, changes, conflicts: st.mergeConflict?.length ?? 0, ours: w.name.startsWith(prefix()) });
77
+ }
78
+ return { publicId, workspaces: out };
79
+ }
80
+ // ---------------------------------------------------------------- staging
81
+ function prefix() {
82
+ const rules = writeRules();
83
+ return rules.gtm?.workspace_name_prefix ?? 'gwo-';
84
+ }
85
+ function scopeHint(e, scope) {
86
+ const msg = e.message ?? String(e);
87
+ if (/403|PERMISSION_DENIED|insufficient/i.test(msg)) {
88
+ return new Error(`${msg}\nThe stored authorisation lacks the Tag Manager ${scope === 'gtm_edit' ? 'edit' : 'publish'} scope. Run: npm run cli -- auth login --scope ${scope}`);
89
+ }
90
+ return e;
91
+ }
92
+ /** Find our workspace to stage into: an existing <prefix>* workspace (empty ones first), else null. */
93
+ async function findOurWorkspace(ids, workspaceId) {
94
+ const all = await gtm.listWorkspaces(ids.accountId, ids.containerId);
95
+ if (workspaceId) {
96
+ const w = all.find((x) => x.workspaceId === workspaceId);
97
+ if (!w)
98
+ throw new Error(`Workspace ${workspaceId} does not exist in container ${ids.publicId ?? ids.containerId}.`);
99
+ if (!w.name.startsWith(prefix()))
100
+ throw new Error(`Workspace "${w.name}" is not a ${prefix()} workspace. Policy: always_use_workspace with prefix ${prefix()}.`);
101
+ return w;
102
+ }
103
+ const ours = all.filter((w) => w.name.startsWith(prefix()));
104
+ if (!ours.length)
105
+ return null;
106
+ // Prefer a workspace that already holds staged changes so a multi-step
107
+ // change stays together; otherwise any of ours.
108
+ for (const w of ours) {
109
+ const st = await gtm.workspaceStatus(ids.accountId, ids.containerId, w.workspaceId);
110
+ if ((st.workspaceChange ?? []).some((c) => c.changeStatus !== 'none'))
111
+ return w;
112
+ }
113
+ return ours[0] ?? null;
114
+ }
115
+ const isNumeric = (s) => /^\d+$/.test(s);
116
+ /** Tags may name their triggers; resolve names to ids against the workspace and the built-ins. */
117
+ function resolveTriggerRefs(body, triggers) {
118
+ const byName = new Map(Object.entries(gtm.BUILT_IN_TRIGGERS));
119
+ for (const t of triggers)
120
+ if (t.triggerId)
121
+ byName.set(t.name, t.triggerId);
122
+ const out = { ...body };
123
+ for (const k of ['firingTriggerId', 'blockingTriggerId']) {
124
+ const refs = body[k];
125
+ if (!refs)
126
+ continue;
127
+ out[k] = refs.map((r) => {
128
+ if (isNumeric(r))
129
+ return r;
130
+ const id = byName.get(r);
131
+ if (!id)
132
+ throw new Error(`${k}: no trigger named "${r}" in the workspace. Stage the trigger first or use its id.`);
133
+ return id;
134
+ });
135
+ }
136
+ return out;
137
+ }
138
+ /**
139
+ * Stage entities into the site's gwo- workspace. Policy: gtm.stage (approval required).
140
+ * Each entity is its own write with its own audit record. Returns the workspace diff and review token.
141
+ */
142
+ export async function gtmStage(siteIdOrHost, entities, opts = {}) {
143
+ const ids = await gtmContainerFor(siteIdOrHost);
144
+ const { site, accountId, containerId } = ids;
145
+ const approval = opts.approval ? { approval: opts.approval } : {};
146
+ for (const e of entities)
147
+ if (!e.body?.name)
148
+ throw new Error(`Every staged ${e.kind ?? 'entity'} needs a name.`);
149
+ const results = [];
150
+ // 1. Workspace. Creating one is the first mutation, so it is a write of its own.
151
+ let ws = await findOurWorkspace(ids, opts.workspaceId);
152
+ if (!ws) {
153
+ const name = `${prefix()}${new Date().toISOString().slice(0, 10)}`;
154
+ const w = await performWrite(site, {
155
+ action: 'gtm.stage', operation: 'gtm.workspace.create',
156
+ summary: `Created Tag Manager workspace "${name}" in ${ids.publicId ?? containerId}${opts.reason ? ` (${opts.reason})` : ''}`,
157
+ ...approval,
158
+ before: () => gtm.listWorkspaces(accountId, containerId),
159
+ async execute() { try {
160
+ await gtm.createWorkspace(accountId, containerId, name, `Google Web Operations staging. ${opts.reason ?? ''}`.trim());
161
+ }
162
+ catch (e) {
163
+ throw scopeHint(e, 'gtm_edit');
164
+ } },
165
+ after: () => gtm.listWorkspaces(accountId, containerId),
166
+ verify: (_b, after) => (after.some((x) => x.name === name) ? null : `workspace "${name}" not listed after create`),
167
+ summarise: (list) => list.map((x) => x.name),
168
+ });
169
+ if (w.status !== 'success') {
170
+ return { workspace: null, results: [{ kind: 'tag', name, op: 'none', status: w.status, verified: false, reason: w.reason }], diff: null, reviewToken: null,
171
+ rendered: `Workspace not created: ${w.reason ?? w.status}` };
172
+ }
173
+ ws = (w.after ?? []).find((x) => x.name === name) ?? null;
174
+ if (!ws)
175
+ throw new Error('workspace created but not found');
176
+ }
177
+ const wsId = ws.workspaceId;
178
+ // 2. Entities, one write each. Triggers before tags so tags can reference them by name.
179
+ const order = ['variable', 'trigger', 'tag'];
180
+ const sorted = [...entities].sort((a, b) => order.indexOf(a.kind) - order.indexOf(b.kind));
181
+ for (const { kind, body } of sorted) {
182
+ const list = () => gtm.listEntities(accountId, containerId, wsId, kind);
183
+ const existing = (await list()).find((x) => x.name === body.name) ?? null;
184
+ const idField = gtm.ID_FIELD[kind];
185
+ const op = existing ? 'update' : 'create';
186
+ let sent = body;
187
+ const r = await performWrite(site, {
188
+ action: 'gtm.stage', operation: `gtm.stage.${kind}`,
189
+ summary: `${op === 'create' ? 'Staged new' : 'Staged update to'} ${kind} "${body.name}" in workspace "${ws.name}"`,
190
+ ...approval,
191
+ before: async () => existing,
192
+ async execute(before) {
193
+ try {
194
+ const triggers = kind === 'tag' ? await gtm.listEntities(accountId, containerId, wsId, 'trigger') : [];
195
+ const resolved = kind === 'tag' ? resolveTriggerRefs(body, triggers) : body;
196
+ if (before) {
197
+ sent = gtm.mergeEntity(before, resolved);
198
+ await gtm.updateEntity(accountId, containerId, wsId, kind, before[idField], sent, before.fingerprint);
199
+ }
200
+ else {
201
+ sent = resolved;
202
+ await gtm.createEntity(accountId, containerId, wsId, kind, sent);
203
+ }
204
+ }
205
+ catch (e) {
206
+ throw scopeHint(e, 'gtm_edit');
207
+ }
208
+ },
209
+ after: async () => (await list()).find((x) => x.name === body.name) ?? null,
210
+ verify: (_b, after) => gtm.entityMatches(sent, after),
211
+ summarise: (e) => (e ? { id: e[idField] ?? null, name: e.name, type: e.type ?? null, paused: e.paused ?? false, firingTriggerId: e.firingTriggerId ?? [], parameter: e.parameter ?? [] } : null),
212
+ });
213
+ results.push({ kind, name: body.name, op, status: r.status, verified: r.verified, reason: r.reason });
214
+ if (r.status === 'blocked')
215
+ break; // same token for every entity; no point continuing
216
+ }
217
+ // 3. Diff and review token, so the operator sees exactly what would publish.
218
+ const blocked = results.some((r) => r.status === 'blocked');
219
+ const diff = blocked ? null : await workspaceDiff(ids, wsId);
220
+ const token = diff ? gtm.reviewToken(diff) : null;
221
+ if (diff)
222
+ storage.saveNamed(site.site_id, 'gtm-stage', { workspace: { id: wsId, name: ws.name }, results, diff, reviewToken: token });
223
+ const rendered = [
224
+ ...results.map((r) => `- ${r.status.toUpperCase()} ${r.op} ${r.kind} "${r.name}"${r.verified ? ' (verified)' : ''}${r.reason ? `: ${r.reason}` : ''}`),
225
+ '',
226
+ diff ? gtm.renderWorkspaceDiff(diff, { workspaceId: wsId, name: ws.name }) : 'Blocked: nothing staged.',
227
+ ].join('\n');
228
+ return { workspace: { id: wsId, name: ws.name }, results, diff, reviewToken: token, rendered };
229
+ }
230
+ async function workspaceDiff(ids, workspaceId) {
231
+ const [status, live] = await Promise.all([
232
+ gtm.workspaceStatus(ids.accountId, ids.containerId, workspaceId),
233
+ gtm.getLiveVersion(ids.accountId, ids.containerId),
234
+ ]);
235
+ return gtm.shapeWorkspaceDiff(status, live);
236
+ }
237
+ /** Diff a workspace (ours by default) against the live version. Read-only; returns the review token for gtmPublish. */
238
+ export async function gtmWorkspaceDiff(siteIdOrHost, workspaceId) {
239
+ const ids = await gtmContainerFor(siteIdOrHost);
240
+ const ws = await findOurWorkspace(ids, workspaceId);
241
+ if (!ws)
242
+ return { error: `No ${prefix()} workspace in container ${ids.publicId ?? ids.containerId}. Nothing is staged.` };
243
+ const diff = await workspaceDiff(ids, ws.workspaceId);
244
+ const token = gtm.reviewToken(diff);
245
+ storage.appendAudit({ siteId: ids.site.site_id, operation: 'gtm.workspace.diff', status: 'observed', risk: 'auto',
246
+ summary: `Reviewed workspace "${ws.name}": ${diff.counts.added} added, ${diff.counts.updated} updated, ${diff.counts.deleted} deleted; review ${token}`, data: diff.counts });
247
+ return { workspace: { id: ws.workspaceId, name: ws.name }, diff, reviewToken: token, rendered: gtm.renderWorkspaceDiff(diff, ws) };
248
+ }
249
+ /**
250
+ * Publish a reviewed workspace. Policy: gtm.publish (approval required) AND the review token
251
+ * from gtmWorkspaceDiff or gtmStage must match the workspace's current diff.
252
+ * Verified by reading the live version after and finding the new version id.
253
+ */
254
+ export async function gtmPublish(siteIdOrHost, opts) {
255
+ const ids = await gtmContainerFor(siteIdOrHost);
256
+ const { site, accountId, containerId } = ids;
257
+ const ws = await findOurWorkspace(ids, opts.workspaceId);
258
+ if (!ws)
259
+ throw new Error(`No ${prefix()} workspace in container ${ids.publicId ?? containerId}. Stage changes first.`);
260
+ const versionName = opts.versionName ?? `${ws.name} ${new Date().toISOString().slice(0, 16).replace('T', ' ')}`;
261
+ let created = null;
262
+ const state = async () => {
263
+ const diff = await workspaceDiff(ids, ws.workspaceId);
264
+ return { liveVersionId: diff.liveVersionId, pending: diff.changes.length, reviewToken: gtm.reviewToken(diff) };
265
+ };
266
+ const r = await performWrite(site, {
267
+ action: 'gtm.publish', operation: 'gtm.publish',
268
+ summary: `Published workspace "${ws.name}" of ${ids.publicId ?? containerId} as version "${versionName}"`,
269
+ ...(opts.approval ? { approval: opts.approval } : {}),
270
+ before: state,
271
+ async execute(before) {
272
+ // Preconditions live inside execute so a refusal is recorded as a failed write with its reason.
273
+ if (before.pending === 0)
274
+ throw new Error('workspace has no changes; nothing to publish');
275
+ if (before.reviewToken !== opts.review) {
276
+ throw new Error(`review token ${opts.review} does not match the workspace's current changes (${before.reviewToken}). Run gtm_workspace_diff, review, and publish with the new token.`);
277
+ }
278
+ try {
279
+ const sync = await gtm.syncWorkspace(accountId, containerId, ws.workspaceId);
280
+ if (sync.syncStatus?.mergeConflict || sync.mergeConflict?.length)
281
+ throw new Error('workspace has merge conflicts with the latest version; resolve them in the GTM UI, then diff and publish again');
282
+ if (sync.syncStatus?.syncError)
283
+ throw new Error('workspace sync failed');
284
+ // The sync may have changed the diff (someone else published); the token must still hold.
285
+ const now = await state();
286
+ if (now.reviewToken !== opts.review)
287
+ throw new Error(`live version moved to ${now.liveVersionId} during sync; the review token no longer matches. Diff and publish again.`);
288
+ const v = await gtm.createVersion(accountId, containerId, ws.workspaceId, { name: versionName, notes: opts.notes ?? 'Google Web Operations' });
289
+ const errs = Array.isArray(v.compilerError) ? v.compilerError : v.compilerError ? [{ message: 'compiler error' }] : [];
290
+ if (errs.length)
291
+ throw new Error(`container did not compile: ${errs.map((e) => `${e.entityName ?? ''} ${e.message ?? ''}`.trim()).join('; ')}`);
292
+ if (!v.containerVersion)
293
+ throw new Error('create_version returned no version');
294
+ created = v.containerVersion.containerVersionId;
295
+ const p = await gtm.publishVersion(accountId, containerId, created);
296
+ const perrs = Array.isArray(p.compilerError) ? p.compilerError : p.compilerError ? [{ message: 'compiler error' }] : [];
297
+ if (perrs.length)
298
+ throw new Error(`version ${created} created but publish failed: ${perrs.map((e) => e.message ?? '').join('; ')}`);
299
+ }
300
+ catch (e) {
301
+ throw scopeHint(e, 'gtm_publish');
302
+ }
303
+ },
304
+ after: async () => ({ liveVersionId: (await gtm.getLiveVersion(accountId, containerId)).containerVersionId }),
305
+ verify: (_b, after) => (created && after.liveVersionId === created ? null : `live version is ${after.liveVersionId}, expected ${created ?? 'a new version'}`),
306
+ });
307
+ // A successful publish is also a new live version: snapshot it so the daily diff attributes it to us.
308
+ if (r.status === 'success')
309
+ await gtmSnapshot(siteIdOrHost);
310
+ return { status: r.status, verified: r.verified, reason: r.reason, version: created, audit: r.audit };
311
+ }
@@ -0,0 +1,13 @@
1
+ /** Public surface of the core package. Apps import from here. */
2
+ export { operations, scanSite, inspectUrl, inspectRendered, diffLatest, classifyAction, submitSitemap } from "./operations.js";
3
+ export { performWrite } from "./writes.js";
4
+ export { setMetaDescription, setTitle, setSchema, validateMetaDescription, validateTitle, validateJsonLd, canonicalJson, TITLE_BOUNDS } from "./content.js";
5
+ export { setupStatus, installOauthClient, authLogin, discoverApply, addSite, updateSite, addSuppression, renderSiteFile, siteIdFor } from "./admin.js";
6
+ export { renderClientReport, composeClientReport, collectMonthly, monthWindows, defaultMonth } from "./report/monthly.js";
7
+ export { gtmStage, gtmWorkspaceDiff, gtmPublish, gtmWorkspaces, gtmContainerFor } from "./gtm-ops.js";
8
+ export { createKeyEvent } from "./operations.js";
9
+ export { adapterFor, NoAdapter } from "./adapters.js";
10
+ export { listSites, getSite, publicView } from "./registry.js";
11
+ export { renderScan } from "./scan/render.js";
12
+ export { pageRules, siteRules, rank, summarise, SEVERITY_ORDER } from "./scan/rules.js";
13
+ export * from "./paths.js";
@@ -0,0 +1,239 @@
1
+ /**
2
+ * Operations engine: the one place hosts, the worker and the CLI call into.
3
+ *
4
+ * Every operation here follows the loop from docs/operations-model:
5
+ * observe -> classify -> (authorise) -> execute -> verify -> record.
6
+ * Read operations record themselves as "observed". Write operations, when
7
+ * they arrive in phase 3, must call policy.enforce() first and record
8
+ * before/after/verified in the audit log.
9
+ */
10
+ import * as crawler from "../../connectors/web-crawler/src/index.js";
11
+ import { classify, approvalToken, limits } from "../../policy/src/index.js";
12
+ import * as storage from "../../storage/src/index.js";
13
+ import { getSite, listSites, publicView } from "./registry.js";
14
+ import { pageRules, siteRules, rank, summarise } from "./scan/rules.js";
15
+ import { renderScan } from "./scan/render.js";
16
+ import { applySuppressions } from "./scan/suppress.js";
17
+ import { performWrite } from "./writes.js";
18
+ import { adapterFor } from "./adapters.js";
19
+ import { setMetaDescription, setTitle, setSchema, validateMetaDescription, validateTitle, validateJsonLd } from "./content.js";
20
+ import { renderClientReport } from "./report/monthly.js";
21
+ import { gtmContainers, gtmVersions, gtmSnapshot, gtmDiff, gtmWorkspaces, gtmStage, gtmWorkspaceDiff, gtmPublish } from "./gtm-ops.js";
22
+ import { ga4, gsc } from "../../connectors/google/src/index.js";
23
+ function contextFor(site) {
24
+ return { canonicalHost: site.production_url, locales: site.locales, defaultLocale: site.default_locale };
25
+ }
26
+ /** Full site scan: robots, agent policy, llms.txt, host probe, sitemap, pages, rules. */
27
+ export async function scanSite(siteIdOrHost, opts = {}) {
28
+ const site = getSite(siteIdOrHost);
29
+ const lim = limits().crawl ?? {};
30
+ const maxPages = Math.min(opts.maxPages ?? 50, lim.max_pages_per_scan ?? 200);
31
+ const origin = new URL(site.production_url).origin;
32
+ const ctx = contextFor(site);
33
+ const t0 = Date.now();
34
+ const robots = await crawler.robots(origin);
35
+ const robotsChecks = await crawler.checkAgents(robots);
36
+ const llms = await crawler.get(new URL('/llms.txt', origin).toString(), { timeout: 15_000 });
37
+ const hostProbe = await crawler.probeHosts(site);
38
+ const sitemapUrl = new URL(site.seo.sitemap, origin).toString();
39
+ const sitemapResult = await crawler.sitemap(robots.sitemaps[0] ?? sitemapUrl, { timeout: 30_000 });
40
+ let urls = opts.urls?.length ? [...opts.urls] : sitemapResult.urls.map((u) => u.loc);
41
+ if (!urls.includes(origin + '/'))
42
+ urls.unshift(origin + '/');
43
+ urls = [...new Set(urls)].slice(0, maxPages);
44
+ const pages = await crawler.fetchPages(urls, {
45
+ concurrency: lim.concurrency ?? 4,
46
+ delayMs: lim.politeness_delay_ms ?? 250,
47
+ });
48
+ const findings = [];
49
+ for (const p of pages)
50
+ findings.push(...pageRules(p, ctx));
51
+ findings.push(...siteRules({ origin, robots, robotsChecks, llmsTxt: { ok: llms.ok }, sitemap: sitemapResult, pages, hostProbe }));
52
+ // Site decisions first (findings the owner declared intentional), then rank what is left.
53
+ const sup = applySuppressions(findings, site.findings?.suppress);
54
+ const ranked = rank(sup.kept);
55
+ const scan = {
56
+ siteId: site.site_id, origin, at: new Date().toISOString(), durationMs: Date.now() - t0,
57
+ pagesCrawled: pages.length,
58
+ sitemap: { url: sitemapResult.url, ok: sitemapResult.ok, status: sitemapResult.status, count: sitemapResult.urls.length, urls: sitemapResult.urls },
59
+ robots: { ok: robots.ok, status: robots.status, sitemaps: robots.sitemaps, groups: robots.groups },
60
+ robotsChecks, llmsTxt: { ok: llms.ok, status: llms.status, bytes: llms.bytes }, hostProbe,
61
+ summary: summarise(ranked), findings: ranked, suppressed: { count: sup.suppressed.length, rules: sup.rules }, pages,
62
+ };
63
+ const { scanPath } = storage.saveScan(scan);
64
+ storage.appendAudit({
65
+ siteId: site.site_id, operation: 'web.scan', status: 'observed', risk: 'auto',
66
+ summary: `Scanned ${pages.length} pages: ${scan.summary.blocker} blocker, ${scan.summary.high} high, ${scan.summary.medium} medium${sup.suppressed.length ? `, ${sup.suppressed.length} suppressed by site decisions` : ''}`,
67
+ data: { pages: pages.length, summary: scan.summary, suppressed: scan.suppressed, durationMs: scan.durationMs },
68
+ });
69
+ return { scan, scanPath };
70
+ }
71
+ /** One page: fetch, parse, page-level rules. Used for spot checks and post-fix verification. */
72
+ export async function inspectUrl(url, siteIdOrHost) {
73
+ const site = siteIdOrHost ? getSite(siteIdOrHost) : null;
74
+ const ctx = site ? contextFor(site) : { canonicalHost: new URL(url).origin, locales: [], defaultLocale: 'he' };
75
+ const page = await crawler.fetchPage(url);
76
+ return { page, findings: pageRules(page, ctx) };
77
+ }
78
+ /** Static HTML versus the DOM a browser renders. Needs Playwright; the error says how to install it. */
79
+ export async function inspectRendered(url, siteIdOrHost) {
80
+ const browser = await import("../../connectors/browser/src/index.js");
81
+ const site = siteIdOrHost ? getSite(siteIdOrHost) : null;
82
+ const r = await crawler.get(url, { timeout: 30_000 });
83
+ const c = await browser.inspectRendered(url, r.body ?? '');
84
+ if (site)
85
+ storage.appendAudit({ siteId: site.site_id, operation: 'web.inspect_rendered', status: 'observed', risk: 'auto', summary: `${url}: ${c.verdict} (${c.delta.words.static}/${c.delta.words.rendered} words)`, data: c.delta });
86
+ return { comparison: c, rendered: browser.renderComparison(c) };
87
+ }
88
+ /** Diff the two most recent snapshots of a site. */
89
+ export function diffLatest(siteIdOrHost) {
90
+ const site = getSite(siteIdOrHost);
91
+ const snaps = storage.listSnapshots(site.site_id);
92
+ if (snaps.length < 2)
93
+ return { error: `Only ${snaps.length} snapshot(s) for ${site.site_id}; two are needed.` };
94
+ const before = snaps.at(-2), after = snaps.at(-1);
95
+ const b = storage.loadSnapshot(site.site_id, before), a = storage.loadSnapshot(site.site_id, after);
96
+ if (!b || !a)
97
+ return { error: 'Snapshot file missing.' };
98
+ return { before, after, diff: storage.diffSnapshots(b, a) };
99
+ }
100
+ /** Policy answer for a proposed action, with the token the operator would need. */
101
+ export function classifyAction(action, siteIdOrHost) {
102
+ const site = siteIdOrHost ? getSite(siteIdOrHost) : undefined;
103
+ const d = classify(action, site);
104
+ return { ...d, approvalTokenIfNeeded: d.risk === 'approval_required' && site ? approvalToken(site.site_id, action) : null };
105
+ }
106
+ // ---------------------------------------------------------------- site adapters (read)
107
+ /** Connectivity check for a site's write adapter. */
108
+ export async function adapterPing(siteIdOrHost) {
109
+ const site = getSite(siteIdOrHost);
110
+ const a = await adapterFor(site);
111
+ const r = await a.ping();
112
+ return { kind: a.kind, target: a.describe(), ...r };
113
+ }
114
+ /** Pages the adapter can address. */
115
+ export async function adapterPages(siteIdOrHost) {
116
+ const site = getSite(siteIdOrHost);
117
+ const a = await adapterFor(site);
118
+ return { kind: a.kind, target: a.describe(), pages: await a.listPages() };
119
+ }
120
+ /** SEO fields of one page as the site stores them, plus what the live page shows, for comparison. */
121
+ export async function adapterPageSeo(siteIdOrHost, pagePath) {
122
+ const site = getSite(siteIdOrHost);
123
+ const a = await adapterFor(site);
124
+ const stored = await a.readPageSeo(pagePath);
125
+ const live = await crawler.fetchPage(new URL(pagePath, site.production_url).toString());
126
+ return {
127
+ kind: a.kind, path: pagePath, stored,
128
+ live: live.signals ? { status: live.status, title: live.signals.title, description: live.signals.description, canonical: live.signals.canonicalAbs } : { status: live.status },
129
+ };
130
+ }
131
+ /** Managed JSON-LD blocks of one page as the site stores them, next to every JSON-LD type the live page emits. */
132
+ export async function adapterPageSchema(siteIdOrHost, pagePath) {
133
+ const site = getSite(siteIdOrHost);
134
+ const a = await adapterFor(site);
135
+ const stored = await a.readPageSchema(pagePath);
136
+ const live = await crawler.fetchPage(new URL(pagePath, site.production_url).toString());
137
+ return {
138
+ kind: a.kind, path: pagePath,
139
+ stored: stored === null ? null : stored.map((b) => ({ type: b['@type'] ?? null, block: b })),
140
+ live: { status: live.status, jsonldTypes: live.signals?.jsonldTypes ?? [], blocks: live.signals?.jsonld ?? [] },
141
+ };
142
+ }
143
+ /**
144
+ * Mark a GA4 event as a key event. Policy: ga4.key_event.create (approval required).
145
+ * Verified by listing key events after and finding the name.
146
+ */
147
+ export async function createKeyEvent(siteIdOrHost, eventName, { approval } = {}) {
148
+ const site = getSite(siteIdOrHost);
149
+ const pid = site.google.ga4?.property_id;
150
+ if (!pid)
151
+ throw new Error(`Site ${site.site_id} has no google.ga4.property_id configured.`);
152
+ const read = async () => (await ga4.listKeyEvents(pid)).keyEvents ?? [];
153
+ return performWrite(site, {
154
+ action: 'ga4.key_event.create',
155
+ operation: 'ga4.create_key_event',
156
+ summary: `Marked "${eventName}" as a key event on GA4 property ${pid}`,
157
+ ...(approval ? { approval } : {}),
158
+ before: read,
159
+ async execute(before) {
160
+ if ((before ?? []).some((k) => k.eventName === eventName))
161
+ return; // idempotent
162
+ try {
163
+ await ga4.createKeyEvent(pid, eventName);
164
+ }
165
+ catch (e) {
166
+ const msg = e.message;
167
+ if (/403|PERMISSION_DENIED|insufficient/i.test(msg))
168
+ throw new Error(`${msg}\nThe stored authorisation lacks analytics.edit. Run: npm run cli -- auth login --scope ga4_write`);
169
+ throw e;
170
+ }
171
+ },
172
+ after: read,
173
+ verify: (_b, after) => ((after ?? []).some((k) => k.eventName === eventName) ? null : `"${eventName}" not present after create`),
174
+ summarise: (list) => (list ?? []).map((k) => k.eventName),
175
+ });
176
+ }
177
+ /**
178
+ * Submit the site's declared sitemap to Search Console. Policy: gsc.sitemap.submit (auto).
179
+ * Verified by reading the sitemap list back and finding the entry with a fresh lastSubmitted.
180
+ */
181
+ export async function submitSitemap(siteIdOrHost, { approval } = {}) {
182
+ const site = getSite(siteIdOrHost);
183
+ const property = site.google.search_console?.property;
184
+ if (!property)
185
+ throw new Error(`Site ${site.site_id} has no google.search_console.property configured.`);
186
+ const feedpath = new URL(site.seo.sitemap, site.production_url).toString();
187
+ const startedAt = Date.now();
188
+ const read = async () => (await gsc.listSitemaps(property)).sitemap ?? [];
189
+ const find = (list) => list.find((s) => s.path === feedpath) ?? null;
190
+ return performWrite(site, {
191
+ action: 'gsc.sitemap.submit',
192
+ operation: 'gsc.submit_sitemap',
193
+ summary: `Submitted sitemap ${feedpath} to ${property}`,
194
+ approval,
195
+ before: read,
196
+ execute: () => gsc.submitSitemap(property, feedpath),
197
+ after: read,
198
+ // Verify the intended end state: the sitemap is listed and was submitted
199
+ // within the last few minutes. A strict "timestamp advanced" check would
200
+ // fail a legitimate resubmit that Google coalesces into the same second.
201
+ verify(_before, after) {
202
+ const entry = find(after);
203
+ if (!entry)
204
+ return 'sitemap not present in Search Console after submit';
205
+ const submitted = entry.lastSubmitted ? Date.parse(entry.lastSubmitted) : 0;
206
+ if (!submitted)
207
+ return 'Search Console reports no lastSubmitted for the sitemap';
208
+ if (submitted < startedAt - 5 * 60_000)
209
+ return `lastSubmitted ${entry.lastSubmitted} predates this run`;
210
+ return null;
211
+ },
212
+ summarise: (list) => list.map((s) => ({ path: s.path, lastSubmitted: s.lastSubmitted, errors: s.errors, warnings: s.warnings })),
213
+ });
214
+ }
215
+ export const operations = {
216
+ submitSitemap, createKeyEvent,
217
+ setMetaDescription, setTitle, setSchema, validateMetaDescription, validateTitle, validateJsonLd,
218
+ gtmContainers, gtmVersions, gtmSnapshot, gtmDiff, gtmWorkspaces, gtmStage, gtmWorkspaceDiff, gtmPublish,
219
+ adapterPing, adapterPages, adapterPageSeo, adapterPageSchema,
220
+ renderClientReport,
221
+ listSites: (opts) => listSites(opts).map(publicView),
222
+ getSite: (id) => publicView(getSite(id)),
223
+ siteState: (id) => {
224
+ const site = getSite(id);
225
+ const scan = storage.loadScan(site.site_id);
226
+ return {
227
+ site: publicView(site),
228
+ lastScan: scan ? { at: scan.at, pages: scan.pagesCrawled, summary: scan.summary } : null,
229
+ snapshots: storage.listSnapshots(site.site_id).length,
230
+ };
231
+ },
232
+ scanSite,
233
+ inspectUrl,
234
+ inspectRendered,
235
+ diffLatest,
236
+ classifyAction,
237
+ auditLog: (id, limit = 50) => storage.readAudit(getSite(id).site_id, { limit }),
238
+ renderScan,
239
+ };