@ainyc/canonry 1.41.0 → 1.45.0

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.
@@ -19,11 +19,13 @@ __export(schema_exports, {
19
19
  apiKeys: () => apiKeys,
20
20
  auditLog: () => auditLog,
21
21
  bingConnections: () => bingConnections,
22
+ bingCoverageSnapshots: () => bingCoverageSnapshots,
22
23
  bingKeywordStats: () => bingKeywordStats,
23
24
  bingUrlInspections: () => bingUrlInspections,
24
25
  competitors: () => competitors,
25
26
  gaAiReferrals: () => gaAiReferrals,
26
27
  gaConnections: () => gaConnections,
28
+ gaSocialReferrals: () => gaSocialReferrals,
27
29
  gaTrafficSnapshots: () => gaTrafficSnapshots,
28
30
  gaTrafficSummaries: () => gaTrafficSummaries,
29
31
  googleConnections: () => googleConnections,
@@ -237,6 +239,17 @@ var gscCoverageSnapshots = sqliteTable("gsc_coverage_snapshots", {
237
239
  index("idx_gsc_coverage_snap_project_date").on(table.projectId, table.date),
238
240
  index("idx_gsc_coverage_snap_run").on(table.syncRunId)
239
241
  ]);
242
+ var bingCoverageSnapshots = sqliteTable("bing_coverage_snapshots", {
243
+ id: text("id").primaryKey(),
244
+ projectId: text("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }),
245
+ date: text("date").notNull(),
246
+ indexed: integer("indexed").notNull().default(0),
247
+ notIndexed: integer("not_indexed").notNull().default(0),
248
+ unknown: integer("unknown").notNull().default(0),
249
+ createdAt: text("created_at").notNull()
250
+ }, (table) => [
251
+ uniqueIndex("idx_bing_coverage_snap_project_date").on(table.projectId, table.date)
252
+ ]);
240
253
  var bingConnections = sqliteTable("bing_connections", {
241
254
  id: text("id").primaryKey(),
242
255
  domain: text("domain").notNull(),
@@ -318,6 +331,22 @@ var gaAiReferrals = sqliteTable("ga_ai_referrals", {
318
331
  index("idx_ga_ai_ref_source").on(table.source),
319
332
  uniqueIndex("idx_ga_ai_ref_unique_v2").on(table.projectId, table.date, table.source, table.medium, table.sourceDimension)
320
333
  ]);
334
+ var gaSocialReferrals = sqliteTable("ga_social_referrals", {
335
+ id: text("id").primaryKey(),
336
+ projectId: text("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }),
337
+ date: text("date").notNull(),
338
+ source: text("source").notNull(),
339
+ medium: text("medium").notNull(),
340
+ /** GA4 default channel group (e.g. 'Organic Social', 'Paid Social') */
341
+ channelGroup: text("channel_group").notNull().default("Organic Social"),
342
+ sessions: integer("sessions").notNull().default(0),
343
+ users: integer("users").notNull().default(0),
344
+ syncedAt: text("synced_at").notNull()
345
+ }, (table) => [
346
+ index("idx_ga_social_ref_project_date").on(table.projectId, table.date),
347
+ index("idx_ga_social_ref_source").on(table.source),
348
+ uniqueIndex("idx_ga_social_ref_unique").on(table.projectId, table.date, table.source, table.medium, table.channelGroup)
349
+ ]);
321
350
  var gaTrafficSummaries = sqliteTable("ga_traffic_summaries", {
322
351
  id: text("id").primaryKey(),
323
352
  projectId: text("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }),
@@ -762,7 +791,34 @@ var MIGRATIONS = [
762
791
  `ALTER TABLE insights ADD COLUMN run_id TEXT REFERENCES runs(id) ON DELETE CASCADE`,
763
792
  `CREATE INDEX IF NOT EXISTS idx_insights_run ON insights(run_id)`,
764
793
  `ALTER TABLE health_snapshots ADD COLUMN run_id TEXT REFERENCES runs(id) ON DELETE CASCADE`,
765
- `CREATE INDEX IF NOT EXISTS idx_health_snapshots_run ON health_snapshots(run_id)`
794
+ `CREATE INDEX IF NOT EXISTS idx_health_snapshots_run ON health_snapshots(run_id)`,
795
+ // v25: Social media referral tracking — ga_social_referrals table
796
+ // Uses GA4's native sessionDefaultChannelGroup for social classification
797
+ `CREATE TABLE IF NOT EXISTS ga_social_referrals (
798
+ id TEXT PRIMARY KEY,
799
+ project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
800
+ date TEXT NOT NULL,
801
+ source TEXT NOT NULL,
802
+ medium TEXT NOT NULL,
803
+ channel_group TEXT NOT NULL DEFAULT 'Organic Social',
804
+ sessions INTEGER NOT NULL DEFAULT 0,
805
+ users INTEGER NOT NULL DEFAULT 0,
806
+ synced_at TEXT NOT NULL
807
+ )`,
808
+ `CREATE INDEX IF NOT EXISTS idx_ga_social_ref_project_date ON ga_social_referrals(project_id, date)`,
809
+ `CREATE INDEX IF NOT EXISTS idx_ga_social_ref_source ON ga_social_referrals(source)`,
810
+ `CREATE UNIQUE INDEX IF NOT EXISTS idx_ga_social_ref_unique ON ga_social_referrals(project_id, date, source, medium, channel_group)`,
811
+ // v26: Bing coverage snapshots for historical tracking (mirrors gsc_coverage_snapshots)
812
+ `CREATE TABLE IF NOT EXISTS bing_coverage_snapshots (
813
+ id TEXT PRIMARY KEY,
814
+ project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
815
+ date TEXT NOT NULL,
816
+ indexed INTEGER NOT NULL DEFAULT 0,
817
+ not_indexed INTEGER NOT NULL DEFAULT 0,
818
+ unknown INTEGER NOT NULL DEFAULT 0,
819
+ created_at TEXT NOT NULL
820
+ )`,
821
+ `CREATE UNIQUE INDEX IF NOT EXISTS idx_bing_coverage_snap_project_date ON bing_coverage_snapshots(project_id, date)`
766
822
  ];
767
823
  function isDuplicateColumnError(err) {
768
824
  if (!(err instanceof Error)) return false;
@@ -1206,9 +1262,11 @@ export {
1206
1262
  gscSearchData,
1207
1263
  gscUrlInspections,
1208
1264
  gscCoverageSnapshots,
1265
+ bingCoverageSnapshots,
1209
1266
  bingUrlInspections,
1210
1267
  gaTrafficSnapshots,
1211
1268
  gaAiReferrals,
1269
+ gaSocialReferrals,
1212
1270
  gaTrafficSummaries,
1213
1271
  usageCounters,
1214
1272
  insights,
package/dist/cli.js CHANGED
@@ -28,7 +28,7 @@ import {
28
28
  setGoogleAuthConfig,
29
29
  showFirstRunNotice,
30
30
  trackEvent
31
- } from "./chunk-TKMBOLZB.js";
31
+ } from "./chunk-B4EP44AR.js";
32
32
  import {
33
33
  apiKeys,
34
34
  competitors,
@@ -38,7 +38,7 @@ import {
38
38
  projects,
39
39
  querySnapshots,
40
40
  runs
41
- } from "./chunk-EUBC5EGC.js";
41
+ } from "./chunk-SVPQUYTG.js";
42
42
 
43
43
  // src/cli.ts
44
44
  import { pathToFileURL } from "url";
@@ -355,7 +355,7 @@ async function backfillAnswerVisibilityCommand(opts) {
355
355
  console.log(` Errors: ${providerErrors}`);
356
356
  }
357
357
  async function backfillInsightsCommand(project, opts) {
358
- const { IntelligenceService } = await import("./intelligence-service-DXGTIRF5.js");
358
+ const { IntelligenceService } = await import("./intelligence-service-TXWOESFH.js");
359
359
  const config = loadConfig();
360
360
  const db = createClient(config.database);
361
361
  migrate(db);
@@ -619,6 +619,7 @@ var ApiClient = class {
619
619
  const serializedBody = body != null ? JSON.stringify(body) : void 0;
620
620
  const headers = {
621
621
  "Authorization": `Bearer ${this.apiKey}`,
622
+ "Accept": "application/json",
622
623
  ...serializedBody != null ? { "Content-Type": "application/json" } : {}
623
624
  };
624
625
  let res;
@@ -860,6 +861,10 @@ var ApiClient = class {
860
861
  async bingCoverage(project) {
861
862
  return this.request("GET", `/projects/${encodeURIComponent(project)}/bing/coverage`);
862
863
  }
864
+ async bingCoverageHistory(project, params) {
865
+ const qs = params?.limit != null ? `?limit=${params.limit}` : "";
866
+ return this.request("GET", `/projects/${encodeURIComponent(project)}/bing/coverage/history${qs}`);
867
+ }
863
868
  async bingInspections(project, params) {
864
869
  const qs = params ? "?" + new URLSearchParams(params).toString() : "";
865
870
  return this.request("GET", `/projects/${encodeURIComponent(project)}/bing/inspections${qs}`);
@@ -907,6 +912,15 @@ var ApiClient = class {
907
912
  async gaAiReferralHistory(project) {
908
913
  return this.request("GET", `/projects/${encodeURIComponent(project)}/ga/ai-referral-history`);
909
914
  }
915
+ async gaSocialReferralHistory(project) {
916
+ return this.request("GET", `/projects/${encodeURIComponent(project)}/ga/social-referral-history`);
917
+ }
918
+ async gaSocialReferralTrend(project) {
919
+ return this.request("GET", `/projects/${encodeURIComponent(project)}/ga/social-referral-trend`);
920
+ }
921
+ async gaAttributionTrend(project) {
922
+ return this.request("GET", `/projects/${encodeURIComponent(project)}/ga/attribution-trend`);
923
+ }
910
924
  async gaSessionHistory(project) {
911
925
  return this.request("GET", `/projects/${encodeURIComponent(project)}/ga/session-history`);
912
926
  }
@@ -1143,6 +1157,26 @@ Bing Index Coverage for "${project}"
1143
1157
  console.log(` Last inspected: ${result.lastInspectedAt}`);
1144
1158
  }
1145
1159
  }
1160
+ async function bingCoverageHistory(project, opts) {
1161
+ const client = getClient();
1162
+ const rows = await client.bingCoverageHistory(project, { limit: opts.limit });
1163
+ if (opts.format === "json") {
1164
+ console.log(JSON.stringify(rows, null, 2));
1165
+ return;
1166
+ }
1167
+ if (rows.length === 0) {
1168
+ console.log('No coverage history found. Run "canonry bing coverage" or "canonry bing refresh" first to capture a snapshot.');
1169
+ return;
1170
+ }
1171
+ console.log(`
1172
+ Bing Coverage History for "${project}" (${rows.length} snapshots):
1173
+ `);
1174
+ console.log(` ${"DATE".padEnd(12)}${"INDEXED".padEnd(10)}${"NOT INDEXED".padEnd(14)}UNKNOWN`);
1175
+ console.log(` ${"\u2500".repeat(12)}${"\u2500".repeat(10)}${"\u2500".repeat(14)}${"\u2500".repeat(10)}`);
1176
+ for (const row of rows) {
1177
+ console.log(` ${row.date.padEnd(12)}${String(row.indexed).padEnd(10)}${String(row.notIndexed).padEnd(14)}${String(row.unknown)}`);
1178
+ }
1179
+ }
1146
1180
  async function bingInspect(project, url, format) {
1147
1181
  const client = getClient();
1148
1182
  const result = await client.bingInspectUrl(project, url);
@@ -1343,6 +1377,22 @@ var BING_CLI_COMMANDS = [
1343
1377
  await bingSetSite(project, siteUrl, input.format);
1344
1378
  }
1345
1379
  },
1380
+ {
1381
+ path: ["bing", "coverage-history"],
1382
+ usage: "canonry bing coverage-history <project> [--limit <n>] [--format json]",
1383
+ options: { limit: stringOption() },
1384
+ run: async (input) => {
1385
+ const project = requireProject(input, "bing.coverage-history", "canonry bing coverage-history <project> [--limit <n>] [--format json]");
1386
+ await bingCoverageHistory(project, {
1387
+ limit: parseIntegerOption(input, "limit", {
1388
+ command: "bing.coverage-history",
1389
+ message: "--limit must be a positive integer",
1390
+ usage: "canonry bing coverage-history <project> [--limit <n>] [--format json]"
1391
+ }),
1392
+ format: input.format
1393
+ });
1394
+ }
1395
+ },
1346
1396
  {
1347
1397
  path: ["bing", "coverage"],
1348
1398
  usage: "canonry bing coverage <project> [--format json]",
@@ -1422,12 +1472,12 @@ var BING_CLI_COMMANDS = [
1422
1472
  },
1423
1473
  {
1424
1474
  path: ["bing"],
1425
- usage: "canonry bing <connect|disconnect|status|sites|set-site|coverage|inspect|inspections|request-indexing|performance|refresh> <project> [args]",
1475
+ usage: "canonry bing <connect|disconnect|status|sites|set-site|coverage|coverage-history|inspect|inspections|request-indexing|performance|refresh> <project> [args]",
1426
1476
  run: async (input) => {
1427
1477
  unknownSubcommand(input.positionals[0], {
1428
1478
  command: "bing",
1429
- usage: "canonry bing <connect|disconnect|status|sites|set-site|coverage|inspect|inspections|request-indexing|performance|refresh> <project> [args]",
1430
- available: ["connect", "disconnect", "status", "sites", "set-site", "coverage", "inspect", "inspections", "request-indexing", "performance", "refresh"]
1479
+ usage: "canonry bing <connect|disconnect|status|sites|set-site|coverage|coverage-history|inspect|inspections|request-indexing|performance|refresh> <project> [args]",
1480
+ available: ["connect", "disconnect", "status", "sites", "set-site", "coverage", "coverage-history", "inspect", "inspections", "request-indexing", "performance", "refresh"]
1431
1481
  });
1432
1482
  }
1433
1483
  }
@@ -1709,14 +1759,21 @@ async function gaStatus(project, format) {
1709
1759
  }
1710
1760
  async function gaSync(project, opts) {
1711
1761
  const client = getClient3();
1712
- const result = await client.gaSync(project, { days: opts?.days });
1762
+ const body = {};
1763
+ if (opts?.days) body.days = opts.days;
1764
+ if (opts?.only) body.only = opts.only;
1765
+ const result = await client.gaSync(project, body);
1713
1766
  if (opts?.format === "json") {
1714
1767
  console.log(JSON.stringify(result, null, 2));
1715
1768
  return;
1716
1769
  }
1717
1770
  console.log(`GA4 sync complete for "${project}".`);
1771
+ if (result.syncedComponents) {
1772
+ console.log(` Components: ${result.syncedComponents.join(", ")}`);
1773
+ }
1718
1774
  console.log(` Page rows: ${result.rowCount}`);
1719
1775
  console.log(` AI rows: ${result.aiReferralCount}`);
1776
+ console.log(` Social rows: ${result.socialReferralCount}`);
1720
1777
  console.log(` Period: ${result.days} days`);
1721
1778
  console.log(` Synced at: ${result.syncedAt}`);
1722
1779
  }
@@ -1729,7 +1786,7 @@ async function gaTraffic(project, opts) {
1729
1786
  console.log(JSON.stringify(result, null, 2));
1730
1787
  return;
1731
1788
  }
1732
- if (result.topPages.length === 0 && result.aiReferrals.length === 0) {
1789
+ if (result.topPages.length === 0 && result.aiReferrals.length === 0 && result.socialReferrals.length === 0) {
1733
1790
  console.log('No GA4 traffic data. Run "canonry ga sync <project>" first.');
1734
1791
  return;
1735
1792
  }
@@ -1756,6 +1813,23 @@ async function gaTraffic(project, opts) {
1756
1813
  }
1757
1814
  console.log();
1758
1815
  }
1816
+ if (result.socialReferrals.length > 0) {
1817
+ const chanWidth = 12;
1818
+ if (result.socialSessions > 0) {
1819
+ const share = result.totalSessions > 0 ? Math.round(result.socialSessions / result.totalSessions * 100) : 0;
1820
+ console.log(` Social Sessions: ${result.socialSessions} (${share}% of total)`);
1821
+ }
1822
+ console.log(" SOCIAL REFERRAL SOURCES");
1823
+ console.log(` ${"SOURCE".padEnd(25)} ${"MEDIUM".padEnd(15)} ${"CHANNEL".padEnd(chanWidth)} ${"SESSIONS".padEnd(10)}${"USERS".padEnd(8)}`);
1824
+ console.log(` ${"\u2500".repeat(25)} ${"\u2500".repeat(15)} ${"\u2500".repeat(chanWidth)} ${"\u2500".repeat(10)}${"\u2500".repeat(8)}`);
1825
+ for (const ref of result.socialReferrals) {
1826
+ const chanLabel = ref.channelGroup === "Paid Social" ? "paid" : "organic";
1827
+ console.log(
1828
+ ` ${ref.source.padEnd(25)} ${ref.medium.padEnd(15)} ${chanLabel.padEnd(chanWidth)} ${String(ref.sessions).padEnd(10)}${String(ref.users).padEnd(8)}`
1829
+ );
1830
+ }
1831
+ console.log();
1832
+ }
1759
1833
  if (result.topPages.length > 0) {
1760
1834
  const pageWidth = Math.min(60, Math.max(15, ...result.topPages.map((r) => r.landingPage.length)));
1761
1835
  console.log(` TOP LANDING PAGES`);
@@ -1798,6 +1872,31 @@ async function gaAiReferralHistory(project, format) {
1798
1872
  );
1799
1873
  }
1800
1874
  }
1875
+ async function gaSocialReferralHistory(project, format) {
1876
+ const client = getClient3();
1877
+ const result = await client.gaSocialReferralHistory(project);
1878
+ if (format === "json") {
1879
+ console.log(JSON.stringify(result, null, 2));
1880
+ return;
1881
+ }
1882
+ if (result.length === 0) {
1883
+ console.log('No social referral history. Run "canonry ga sync <project>" first.');
1884
+ return;
1885
+ }
1886
+ const dateWidth = 12;
1887
+ const sourceWidth = Math.min(30, Math.max(10, ...result.map((r) => r.source.length)));
1888
+ const chanWidth = 12;
1889
+ console.log(`GA4 Social Referral History for "${project}":
1890
+ `);
1891
+ console.log(` ${"DATE".padEnd(dateWidth)} ${"SOURCE".padEnd(sourceWidth)} ${"CHANNEL".padEnd(chanWidth)} ${"SESSIONS".padEnd(10)}${"USERS".padEnd(8)}`);
1892
+ console.log(` ${"\u2500".repeat(dateWidth)} ${"\u2500".repeat(sourceWidth)} ${"\u2500".repeat(chanWidth)} ${"\u2500".repeat(10)}${"\u2500".repeat(8)}`);
1893
+ for (const row of result) {
1894
+ const chanLabel = row.channelGroup === "Paid Social" ? "paid" : "organic";
1895
+ console.log(
1896
+ ` ${row.date.padEnd(dateWidth)} ${row.source.padEnd(sourceWidth)} ${chanLabel.padEnd(chanWidth)} ${String(row.sessions).padEnd(10)}${String(row.users).padEnd(8)}`
1897
+ );
1898
+ }
1899
+ }
1801
1900
  async function gaCoverage(project, format) {
1802
1901
  const client = getClient3();
1803
1902
  const result = await client.gaCoverage(project);
@@ -1821,6 +1920,182 @@ async function gaCoverage(project, format) {
1821
1920
  );
1822
1921
  }
1823
1922
  }
1923
+ async function gaSocialReferralSummary(project, opts) {
1924
+ const client = getClient3();
1925
+ const traffic = await client.gaTraffic(project);
1926
+ if (opts?.trend) {
1927
+ const trend = await client.gaSocialReferralTrend(project);
1928
+ if (opts.format === "json") {
1929
+ console.log(JSON.stringify({
1930
+ socialSessions: traffic.socialSessions,
1931
+ socialUsers: traffic.socialUsers,
1932
+ totalSessions: traffic.totalSessions,
1933
+ socialSharePct: traffic.socialSharePct,
1934
+ topSources: traffic.socialReferrals.slice(0, 5).map((r) => ({ source: r.source, sessions: r.sessions, channel: r.channelGroup })),
1935
+ trend
1936
+ }, null, 2));
1937
+ return;
1938
+ }
1939
+ console.log(`Social Traffic Summary for "${project}"
1940
+ `);
1941
+ console.log(` Sessions: ${traffic.socialSessions} (${traffic.socialSharePct}% of ${traffic.totalSessions} total)`);
1942
+ console.log(` Users: ${traffic.socialUsers}`);
1943
+ console.log();
1944
+ const fmtTrend = (pct) => pct === null ? "n/a" : `${pct >= 0 ? "+" : ""}${pct}%`;
1945
+ console.log(` 7d trend: ${fmtTrend(trend.trend7dPct)} (${trend.socialSessions7d} vs ${trend.socialSessionsPrev7d})`);
1946
+ console.log(` 30d trend: ${fmtTrend(trend.trend30dPct)} (${trend.socialSessions30d} vs ${trend.socialSessionsPrev30d})`);
1947
+ if (trend.biggestMover) {
1948
+ const m = trend.biggestMover;
1949
+ console.log(` Mover: ${m.source} (${m.changePct >= 0 ? "+" : ""}${m.changePct}%, ${m.sessionsPrev7d}\u2192${m.sessions7d})`);
1950
+ }
1951
+ console.log();
1952
+ if (traffic.socialReferrals.length > 0) {
1953
+ console.log(" TOP SOURCES");
1954
+ for (const ref of traffic.socialReferrals.slice(0, 5)) {
1955
+ const chanLabel = ref.channelGroup === "Paid Social" ? "paid" : "organic";
1956
+ console.log(` ${ref.source.padEnd(20)} ${String(ref.sessions).padEnd(8)} sessions (${chanLabel})`);
1957
+ }
1958
+ }
1959
+ return;
1960
+ }
1961
+ if (opts?.format === "json") {
1962
+ console.log(JSON.stringify({
1963
+ socialSessions: traffic.socialSessions,
1964
+ socialUsers: traffic.socialUsers,
1965
+ totalSessions: traffic.totalSessions,
1966
+ socialSharePct: traffic.socialSharePct,
1967
+ topSources: traffic.socialReferrals.slice(0, 5).map((r) => ({ source: r.source, sessions: r.sessions, channel: r.channelGroup }))
1968
+ }, null, 2));
1969
+ return;
1970
+ }
1971
+ console.log(`Social Traffic Summary for "${project}"
1972
+ `);
1973
+ console.log(` Sessions: ${traffic.socialSessions} (${traffic.socialSharePct}% of ${traffic.totalSessions} total)`);
1974
+ console.log(` Users: ${traffic.socialUsers}`);
1975
+ if (traffic.socialReferrals.length > 0) {
1976
+ console.log();
1977
+ console.log(" TOP SOURCES");
1978
+ for (const ref of traffic.socialReferrals.slice(0, 5)) {
1979
+ const chanLabel = ref.channelGroup === "Paid Social" ? "paid" : "organic";
1980
+ console.log(` ${ref.source.padEnd(20)} ${String(ref.sessions).padEnd(8)} sessions (${chanLabel})`);
1981
+ }
1982
+ }
1983
+ }
1984
+ async function gaAttribution(project, opts) {
1985
+ const client = getClient3();
1986
+ const traffic = await client.gaTraffic(project);
1987
+ const fmtTrend = (pct) => pct === null ? "n/a" : `${pct >= 0 ? "+" : ""}${pct}%`;
1988
+ if (opts?.trend) {
1989
+ const trend = await client.gaAttributionTrend(project);
1990
+ if (opts.format === "json") {
1991
+ console.log(JSON.stringify({
1992
+ totalSessions: traffic.totalSessions,
1993
+ totalUsers: traffic.totalUsers,
1994
+ organicSessions: traffic.totalOrganicSessions,
1995
+ aiSessions: traffic.aiSessionsDeduped,
1996
+ aiUsers: traffic.aiUsersDeduped,
1997
+ socialSessions: traffic.socialSessions,
1998
+ socialUsers: traffic.socialUsers,
1999
+ aiSharePct: traffic.aiSharePct,
2000
+ socialSharePct: traffic.socialSharePct,
2001
+ organicSharePct: traffic.organicSharePct,
2002
+ aiReferrals: traffic.aiReferrals,
2003
+ socialReferrals: traffic.socialReferrals,
2004
+ trend
2005
+ }, null, 2));
2006
+ return;
2007
+ }
2008
+ if (traffic.totalSessions === 0) {
2009
+ console.log('No GA4 traffic data. Run "canonry ga sync <project>" first.');
2010
+ return;
2011
+ }
2012
+ console.log(`GA4 Attribution Overview for "${project}"
2013
+ `);
2014
+ console.log(` Total Sessions: ${traffic.totalSessions}`);
2015
+ console.log(` Total Users: ${traffic.totalUsers}`);
2016
+ console.log();
2017
+ console.log(" CHANNEL BREAKDOWN 7d trend 30d trend");
2018
+ console.log(` Organic Search: ${String(traffic.totalOrganicSessions).padEnd(6)} (${String(traffic.organicSharePct).padStart(2)}%) ${fmtTrend(trend.organic.trend7dPct).padEnd(12)} ${fmtTrend(trend.organic.trend30dPct)}`);
2019
+ console.log(` AI Referrals: ${String(traffic.aiSessionsDeduped).padEnd(6)} (${String(traffic.aiSharePct).padStart(2)}%) ${fmtTrend(trend.ai.trend7dPct).padEnd(12)} ${fmtTrend(trend.ai.trend30dPct)}`);
2020
+ console.log(` Social: ${String(traffic.socialSessions).padEnd(6)} (${String(traffic.socialSharePct).padStart(2)}%) ${fmtTrend(trend.social.trend7dPct).padEnd(12)} ${fmtTrend(trend.social.trend30dPct)}`);
2021
+ const otherSessions2 = traffic.totalSessions - traffic.totalOrganicSessions - traffic.aiSessionsDeduped - traffic.socialSessions;
2022
+ if (otherSessions2 > 0) {
2023
+ const otherPct = traffic.totalSessions > 0 ? Math.round(otherSessions2 / traffic.totalSessions * 100) : 0;
2024
+ console.log(` Other: ${String(otherSessions2).padEnd(6)} (${String(otherPct).padStart(2)}%)`);
2025
+ }
2026
+ console.log(` \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500`);
2027
+ console.log(` Total: ${String(traffic.totalSessions).padEnd(6)} ${fmtTrend(trend.total.trend7dPct).padEnd(12)} ${fmtTrend(trend.total.trend30dPct)}`);
2028
+ if (trend.aiBiggestMover) {
2029
+ const m = trend.aiBiggestMover;
2030
+ console.log(`
2031
+ AI Mover: ${m.source} (${m.changePct >= 0 ? "+" : ""}${m.changePct}%, ${m.sessionsPrev7d}\u2192${m.sessions7d} sessions/7d)`);
2032
+ }
2033
+ if (trend.socialBiggestMover) {
2034
+ const m = trend.socialBiggestMover;
2035
+ console.log(` Social Mover: ${m.source} (${m.changePct >= 0 ? "+" : ""}${m.changePct}%, ${m.sessionsPrev7d}\u2192${m.sessions7d} sessions/7d)`);
2036
+ }
2037
+ if (traffic.lastSyncedAt) {
2038
+ console.log(`
2039
+ Last synced: ${traffic.lastSyncedAt}`);
2040
+ }
2041
+ return;
2042
+ }
2043
+ if (opts?.format === "json") {
2044
+ console.log(JSON.stringify({
2045
+ totalSessions: traffic.totalSessions,
2046
+ totalUsers: traffic.totalUsers,
2047
+ organicSessions: traffic.totalOrganicSessions,
2048
+ aiSessions: traffic.aiSessionsDeduped,
2049
+ aiUsers: traffic.aiUsersDeduped,
2050
+ socialSessions: traffic.socialSessions,
2051
+ socialUsers: traffic.socialUsers,
2052
+ aiSharePct: traffic.aiSharePct,
2053
+ socialSharePct: traffic.socialSharePct,
2054
+ organicSharePct: traffic.organicSharePct,
2055
+ aiReferrals: traffic.aiReferrals,
2056
+ socialReferrals: traffic.socialReferrals
2057
+ }, null, 2));
2058
+ return;
2059
+ }
2060
+ if (traffic.totalSessions === 0) {
2061
+ console.log('No GA4 traffic data. Run "canonry ga sync <project>" first.');
2062
+ return;
2063
+ }
2064
+ console.log(`GA4 Attribution Overview for "${project}"
2065
+ `);
2066
+ console.log(` Total Sessions: ${traffic.totalSessions}`);
2067
+ console.log(` Total Users: ${traffic.totalUsers}`);
2068
+ console.log();
2069
+ console.log(" CHANNEL BREAKDOWN");
2070
+ console.log(` Organic Search: ${traffic.totalOrganicSessions} sessions (${traffic.organicSharePct}%)`);
2071
+ console.log(` AI Referrals: ${traffic.aiSessionsDeduped} sessions (${traffic.aiSharePct}%)`);
2072
+ console.log(` Social: ${traffic.socialSessions} sessions (${traffic.socialSharePct}%)`);
2073
+ const otherSessions = traffic.totalSessions - traffic.totalOrganicSessions - traffic.aiSessionsDeduped - traffic.socialSessions;
2074
+ if (otherSessions > 0) {
2075
+ const otherPct = traffic.totalSessions > 0 ? Math.round(otherSessions / traffic.totalSessions * 100) : 0;
2076
+ console.log(` Other: ${otherSessions} sessions (${otherPct}%)`);
2077
+ }
2078
+ if (traffic.aiReferrals.length > 0) {
2079
+ console.log();
2080
+ console.log(" AI SOURCES");
2081
+ for (const ref of traffic.aiReferrals.slice(0, 10)) {
2082
+ const dimLabel = ref.sourceDimension === "first_user" ? "first-visit" : ref.sourceDimension === "manual_utm" ? "utm" : "session";
2083
+ console.log(` ${ref.source.padEnd(25)} ${String(ref.sessions).padEnd(8)} sessions (${dimLabel})`);
2084
+ }
2085
+ }
2086
+ if (traffic.socialReferrals.length > 0) {
2087
+ console.log();
2088
+ console.log(" SOCIAL SOURCES");
2089
+ for (const ref of traffic.socialReferrals.slice(0, 10)) {
2090
+ const chanLabel = ref.channelGroup === "Paid Social" ? "paid" : "organic";
2091
+ console.log(` ${ref.source.padEnd(25)} ${String(ref.sessions).padEnd(8)} sessions (${chanLabel})`);
2092
+ }
2093
+ }
2094
+ if (traffic.lastSyncedAt) {
2095
+ console.log(`
2096
+ Last synced: ${traffic.lastSyncedAt}`);
2097
+ }
2098
+ }
1824
2099
 
1825
2100
  // src/cli-commands/ga.ts
1826
2101
  var GA_CLI_COMMANDS = [
@@ -1864,16 +2139,19 @@ var GA_CLI_COMMANDS = [
1864
2139
  },
1865
2140
  {
1866
2141
  path: ["ga", "sync"],
1867
- usage: "canonry ga sync <project> [--days 30] [--format json]",
2142
+ usage: "canonry ga sync <project> [--days 30] [--only traffic|ai|social] [--format json]",
1868
2143
  options: {
1869
- days: stringOption()
2144
+ days: stringOption(),
2145
+ only: stringOption()
1870
2146
  },
1871
2147
  run: async (input) => {
1872
- const project = requireProject(input, "ga.sync", "canonry ga sync <project> [--days 30] [--format json]");
2148
+ const project = requireProject(input, "ga.sync", "canonry ga sync <project> [--days 30] [--only traffic|ai|social] [--format json]");
1873
2149
  const daysStr = getString(input.values, "days");
1874
2150
  const days = daysStr ? parseInt(daysStr, 10) : void 0;
2151
+ const only = getString(input.values, "only");
1875
2152
  await gaSync(project, {
1876
2153
  days,
2154
+ only,
1877
2155
  format: input.format
1878
2156
  });
1879
2157
  }
@@ -1910,14 +2188,50 @@ var GA_CLI_COMMANDS = [
1910
2188
  await gaAiReferralHistory(project, input.format);
1911
2189
  }
1912
2190
  },
2191
+ {
2192
+ path: ["ga", "social-referral-history"],
2193
+ usage: "canonry ga social-referral-history <project> [--format json]",
2194
+ run: async (input) => {
2195
+ const project = requireProject(input, "ga.social-referral-history", "canonry ga social-referral-history <project> [--format json]");
2196
+ await gaSocialReferralHistory(project, input.format);
2197
+ }
2198
+ },
2199
+ {
2200
+ path: ["ga", "social-referral-summary"],
2201
+ usage: "canonry ga social-referral-summary <project> [--trend] [--format json]",
2202
+ options: {
2203
+ trend: { type: "boolean", default: false }
2204
+ },
2205
+ run: async (input) => {
2206
+ const project = requireProject(input, "ga.social-referral-summary", "canonry ga social-referral-summary <project> [--trend] [--format json]");
2207
+ await gaSocialReferralSummary(project, {
2208
+ trend: input.values.trend === true,
2209
+ format: input.format
2210
+ });
2211
+ }
2212
+ },
2213
+ {
2214
+ path: ["ga", "attribution"],
2215
+ usage: "canonry ga attribution <project> [--trend] [--format json]",
2216
+ options: {
2217
+ trend: { type: "boolean", default: false }
2218
+ },
2219
+ run: async (input) => {
2220
+ const project = requireProject(input, "ga.attribution", "canonry ga attribution <project> [--trend] [--format json]");
2221
+ await gaAttribution(project, {
2222
+ trend: input.values.trend === true,
2223
+ format: input.format
2224
+ });
2225
+ }
2226
+ },
1913
2227
  {
1914
2228
  path: ["ga"],
1915
- usage: "canonry ga <connect|disconnect|status|sync|traffic|coverage|ai-referral-history> <project> [args]",
2229
+ usage: "canonry ga <subcommand> <project> [args]",
1916
2230
  run: async (input) => {
1917
2231
  unknownSubcommand(input.positionals[0], {
1918
2232
  command: "ga",
1919
- usage: "canonry ga <connect|disconnect|status|sync|traffic|coverage|ai-referral-history> <project> [args]",
1920
- available: ["connect", "disconnect", "status", "sync", "traffic", "coverage", "ai-referral-history"]
2233
+ usage: "canonry ga <subcommand> <project> [args]",
2234
+ available: ["connect", "disconnect", "status", "sync", "traffic", "coverage", "ai-referral-history", "social-referral-history", "social-referral-summary", "attribution"]
1921
2235
  });
1922
2236
  }
1923
2237
  }
@@ -4016,7 +4330,7 @@ async function triggerRun(project, opts) {
4016
4330
  console.log(` ${loc} ${id} ${r.status}`);
4017
4331
  }
4018
4332
  if (opts?.wait) {
4019
- const pending = locationRuns.filter((r) => r.id && r.status !== "conflict");
4333
+ const pending = locationRuns.filter((r) => r.id && r.status !== "conflict" && !TERMINAL_STATUSES.has(r.status));
4020
4334
  if (pending.length > 0) {
4021
4335
  process.stderr.write(`Waiting for ${pending.length} run(s)`);
4022
4336
  await Promise.all(
@@ -4036,7 +4350,7 @@ async function triggerRun(project, opts) {
4036
4350
  return;
4037
4351
  }
4038
4352
  const run = response;
4039
- if (opts?.wait) {
4353
+ if (opts?.wait && run.id && !TERMINAL_STATUSES.has(run.status)) {
4040
4354
  process.stderr.write(`Run ${run.id} started`);
4041
4355
  const result = await pollRun(client, run.id);
4042
4356
  if (opts?.format === "json") {
@@ -4047,6 +4361,15 @@ async function triggerRun(project, opts) {
4047
4361
  }
4048
4362
  return;
4049
4363
  }
4364
+ if (opts?.wait && (TERMINAL_STATUSES.has(run.status) || !run.id)) {
4365
+ const result = run.id ? await client.getRun(run.id) : run;
4366
+ if (opts?.format === "json") {
4367
+ console.log(JSON.stringify(result, null, 2));
4368
+ } else {
4369
+ printRunDetail(result);
4370
+ }
4371
+ return;
4372
+ }
4050
4373
  if (opts?.format === "json") {
4051
4374
  console.log(JSON.stringify(run, null, 2));
4052
4375
  return;
@@ -5219,7 +5542,7 @@ var SNAPSHOT_CLI_COMMANDS = [
5219
5542
  // src/commands/insights.ts
5220
5543
  async function listInsights(project, opts) {
5221
5544
  const client = createApiClient();
5222
- const insights = await client.getInsights(project, { dismissed: opts.dismissed });
5545
+ const insights = await client.getInsights(project, { dismissed: opts.dismissed, runId: opts.runId });
5223
5546
  if (opts.format === "json") {
5224
5547
  console.log(JSON.stringify(insights, null, 2));
5225
5548
  return;
@@ -5296,15 +5619,17 @@ async function showHealth(project, opts) {
5296
5619
  var INTELLIGENCE_CLI_COMMANDS = [
5297
5620
  {
5298
5621
  path: ["insights"],
5299
- usage: "canonry insights <project> [--dismissed] [--format json]",
5622
+ usage: "canonry insights <project> [--dismissed] [--run-id <id>] [--format json]",
5300
5623
  options: {
5301
- dismissed: { type: "boolean" }
5624
+ dismissed: { type: "boolean" },
5625
+ "run-id": { type: "string" }
5302
5626
  },
5303
5627
  run: async (input) => {
5304
- const usage = "canonry insights <project> [--dismissed] [--format json]";
5628
+ const usage = "canonry insights <project> [--dismissed] [--run-id <id>] [--format json]";
5305
5629
  const project = requireProject(input, "insights", usage);
5306
5630
  const dismissed = input.values.dismissed === true;
5307
- await listInsights(project, { dismissed, format: input.format });
5631
+ const runId = getString(input.values, "run-id");
5632
+ await listInsights(project, { dismissed, runId, format: input.format });
5308
5633
  }
5309
5634
  },
5310
5635
  {
@@ -5329,8 +5654,11 @@ var INTELLIGENCE_CLI_COMMANDS = [
5329
5654
  const usage = "canonry health <project> [--history] [--limit <n>] [--format json]";
5330
5655
  const project = requireProject(input, "health", usage);
5331
5656
  const history = input.values.history === true;
5332
- const limitStr = getString(input.values, "limit");
5333
- const limit = limitStr ? Number.parseInt(limitStr, 10) : void 0;
5657
+ const limit = parseIntegerOption(input, "limit", {
5658
+ command: "health",
5659
+ usage,
5660
+ message: "--limit must be an integer"
5661
+ });
5334
5662
  await showHealth(project, { history, limit, format: input.format });
5335
5663
  }
5336
5664
  }
@@ -5937,15 +6265,23 @@ async function serveCommand(format = "text") {
5937
6265
  const db = createClient(config.database);
5938
6266
  migrate(db);
5939
6267
  const app = await createServer({ config, db });
5940
- const shutdown = () => {
6268
+ let shuttingDown = false;
6269
+ const shutdown = (signal) => {
6270
+ if (shuttingDown) return;
6271
+ shuttingDown = true;
6272
+ if (format === "text") {
6273
+ console.log(`
6274
+ Received ${signal}, stopping server...`);
6275
+ }
5941
6276
  app.close().then(() => {
5942
6277
  process.exit(0);
5943
- }).catch(() => {
6278
+ }).catch((err) => {
6279
+ console.error("Error during shutdown:", err);
5944
6280
  process.exit(1);
5945
6281
  });
5946
6282
  };
5947
- process.on("SIGTERM", shutdown);
5948
- process.on("SIGINT", shutdown);
6283
+ process.on("SIGTERM", () => shutdown("SIGTERM"));
6284
+ process.on("SIGINT", () => shutdown("SIGINT"));
5949
6285
  try {
5950
6286
  await app.listen({ host, port });
5951
6287
  const url = `http://${host === "0.0.0.0" ? "localhost" : host}:${port}`;