@ainyc/canonry 1.4.3 → 1.5.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.
@@ -57,15 +57,87 @@ function configExists() {
57
57
  return fs.existsSync(getConfigPath());
58
58
  }
59
59
 
60
- // src/server.ts
60
+ // src/telemetry.ts
61
+ import crypto from "crypto";
61
62
  import { createRequire } from "module";
63
+ var _require = createRequire(import.meta.url);
64
+ var { version: VERSION } = _require("../package.json");
65
+ var TELEMETRY_ENDPOINT = "https://ainyc.ai/api/telemetry";
66
+ var TIMEOUT_MS = 3e3;
67
+ function isTelemetryEnabled() {
68
+ if (process.env.CANONRY_TELEMETRY_DISABLED === "1") return false;
69
+ if (process.env.DO_NOT_TRACK === "1") return false;
70
+ if (process.env.CI) return false;
71
+ if (!configExists()) return true;
72
+ try {
73
+ const config = loadConfig();
74
+ return config.telemetry !== false;
75
+ } catch {
76
+ return true;
77
+ }
78
+ }
79
+ function getOrCreateAnonymousId() {
80
+ if (!configExists()) return void 0;
81
+ try {
82
+ const config = loadConfig();
83
+ if (config.anonymousId) return config.anonymousId;
84
+ const id = crypto.randomUUID();
85
+ config.anonymousId = id;
86
+ saveConfig(config);
87
+ return id;
88
+ } catch {
89
+ return void 0;
90
+ }
91
+ }
92
+ function isFirstRun() {
93
+ if (!configExists()) return false;
94
+ try {
95
+ const config = loadConfig();
96
+ return !config.anonymousId;
97
+ } catch {
98
+ return false;
99
+ }
100
+ }
101
+ function showFirstRunNotice() {
102
+ process.stderr.write(
103
+ "\nCanonry collects anonymous telemetry to prioritize features.\nDisable any time: canonry telemetry disable\nLearn more: https://ainyc.ai/telemetry\n\n"
104
+ );
105
+ }
106
+ function trackEvent(event, properties) {
107
+ if (!isTelemetryEnabled()) return;
108
+ const anonymousId = getOrCreateAnonymousId();
109
+ if (!anonymousId) return;
110
+ const payload = {
111
+ anonymousId,
112
+ event,
113
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
114
+ version: VERSION,
115
+ nodeVersion: process.versions.node,
116
+ os: process.platform,
117
+ arch: process.arch,
118
+ properties
119
+ };
120
+ const controller = new AbortController();
121
+ const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS);
122
+ timeout.unref();
123
+ fetch(TELEMETRY_ENDPOINT, {
124
+ method: "POST",
125
+ headers: { "Content-Type": "application/json" },
126
+ body: JSON.stringify(payload),
127
+ signal: controller.signal
128
+ }).catch(() => {
129
+ }).finally(() => clearTimeout(timeout));
130
+ }
131
+
132
+ // src/server.ts
133
+ import { createRequire as createRequire2 } from "module";
62
134
  import fs2 from "fs";
63
135
  import path2 from "path";
64
136
  import { fileURLToPath } from "url";
65
137
  import Fastify from "fastify";
66
138
 
67
139
  // ../api-routes/src/auth.ts
68
- import crypto from "crypto";
140
+ import crypto2 from "crypto";
69
141
  import { eq } from "drizzle-orm";
70
142
 
71
143
  // ../db/src/client.ts
@@ -575,7 +647,7 @@ var scheduleDtoSchema = z6.object({
575
647
 
576
648
  // ../api-routes/src/auth.ts
577
649
  function hashKey(key) {
578
- return crypto.createHash("sha256").update(key).digest("hex");
650
+ return crypto2.createHash("sha256").update(key).digest("hex");
579
651
  }
580
652
  var SKIP_PATHS = ["/health"];
581
653
  function shouldSkipAuth(url) {
@@ -609,11 +681,11 @@ async function authPlugin(app) {
609
681
  }
610
682
 
611
683
  // ../api-routes/src/projects.ts
612
- import crypto3 from "crypto";
684
+ import crypto4 from "crypto";
613
685
  import { eq as eq3 } from "drizzle-orm";
614
686
 
615
687
  // ../api-routes/src/helpers.ts
616
- import crypto2 from "crypto";
688
+ import crypto3 from "crypto";
617
689
  import { eq as eq2, and } from "drizzle-orm";
618
690
  function resolveProject(db, name) {
619
691
  const project = db.select().from(projects).where(eq2(projects.name, name)).get();
@@ -625,7 +697,7 @@ function resolveProject(db, name) {
625
697
  function writeAuditLog(db, entry) {
626
698
  const now = (/* @__PURE__ */ new Date()).toISOString();
627
699
  db.insert(auditLog).values({
628
- id: crypto2.randomUUID(),
700
+ id: crypto3.randomUUID(),
629
701
  projectId: entry.projectId ?? null,
630
702
  actor: entry.actor,
631
703
  action: entry.action,
@@ -670,7 +742,7 @@ async function projectRoutes(app, opts) {
670
742
  const updated = app.db.select().from(projects).where(eq3(projects.id, existing.id)).get();
671
743
  return reply.status(200).send(formatProject(updated));
672
744
  }
673
- const id = crypto3.randomUUID();
745
+ const id = crypto4.randomUUID();
674
746
  app.db.insert(projects).values({
675
747
  id,
676
748
  name,
@@ -803,7 +875,7 @@ function formatProject(row) {
803
875
  }
804
876
 
805
877
  // ../api-routes/src/keywords.ts
806
- import crypto4 from "crypto";
878
+ import crypto5 from "crypto";
807
879
  import { eq as eq4 } from "drizzle-orm";
808
880
  async function keywordRoutes(app, opts) {
809
881
  app.get("/projects/:name/keywords", async (request, reply) => {
@@ -825,7 +897,7 @@ async function keywordRoutes(app, opts) {
825
897
  tx.delete(keywords).where(eq4(keywords.projectId, project.id)).run();
826
898
  for (const kw of body.keywords) {
827
899
  tx.insert(keywords).values({
828
- id: crypto4.randomUUID(),
900
+ id: crypto5.randomUUID(),
829
901
  projectId: project.id,
830
902
  keyword: kw,
831
903
  createdAt: now
@@ -857,7 +929,7 @@ async function keywordRoutes(app, opts) {
857
929
  for (const kw of body.keywords) {
858
930
  if (!existingSet.has(kw)) {
859
931
  app.db.insert(keywords).values({
860
- id: crypto4.randomUUID(),
932
+ id: crypto5.randomUUID(),
861
933
  projectId: project.id,
862
934
  keyword: kw,
863
935
  createdAt: now
@@ -932,7 +1004,7 @@ function resolveProjectSafe(app, name, reply) {
932
1004
  }
933
1005
 
934
1006
  // ../api-routes/src/competitors.ts
935
- import crypto5 from "crypto";
1007
+ import crypto6 from "crypto";
936
1008
  import { eq as eq5 } from "drizzle-orm";
937
1009
  async function competitorRoutes(app) {
938
1010
  app.get("/projects/:name/competitors", async (request, reply) => {
@@ -954,7 +1026,7 @@ async function competitorRoutes(app) {
954
1026
  tx.delete(competitors).where(eq5(competitors.projectId, project.id)).run();
955
1027
  for (const domain of body.competitors) {
956
1028
  tx.insert(competitors).values({
957
- id: crypto5.randomUUID(),
1029
+ id: crypto6.randomUUID(),
958
1030
  projectId: project.id,
959
1031
  domain,
960
1032
  createdAt: now
@@ -989,13 +1061,13 @@ function resolveProjectSafe2(app, name, reply) {
989
1061
  import { eq as eq7, asc } from "drizzle-orm";
990
1062
 
991
1063
  // ../api-routes/src/run-queue.ts
992
- import crypto6 from "crypto";
1064
+ import crypto7 from "crypto";
993
1065
  import { and as and2, eq as eq6, or } from "drizzle-orm";
994
1066
  function queueRunIfProjectIdle(db, params) {
995
1067
  const createdAt = params.createdAt ?? (/* @__PURE__ */ new Date()).toISOString();
996
1068
  const kind = params.kind ?? "answer-visibility";
997
1069
  const trigger = params.trigger ?? "manual";
998
- const runId = crypto6.randomUUID();
1070
+ const runId = crypto7.randomUUID();
999
1071
  return db.transaction((tx) => {
1000
1072
  const activeRun = tx.select().from(runs).where(
1001
1073
  and2(
@@ -1152,7 +1224,7 @@ function resolveProjectSafe3(app, name, reply) {
1152
1224
  }
1153
1225
 
1154
1226
  // ../api-routes/src/apply.ts
1155
- import crypto8 from "crypto";
1227
+ import crypto9 from "crypto";
1156
1228
  import { eq as eq8 } from "drizzle-orm";
1157
1229
 
1158
1230
  // ../api-routes/src/schedule-utils.ts
@@ -1244,7 +1316,7 @@ function isValidTimezone(tz) {
1244
1316
  }
1245
1317
 
1246
1318
  // ../api-routes/src/webhooks.ts
1247
- import crypto7 from "crypto";
1319
+ import crypto8 from "crypto";
1248
1320
  import dns from "dns/promises";
1249
1321
  import http from "http";
1250
1322
  import https from "https";
@@ -1296,7 +1368,7 @@ async function deliverWebhook(target, payload, webhookSecret) {
1296
1368
  "User-Agent": "Canonry/0.1.0"
1297
1369
  };
1298
1370
  if (webhookSecret) {
1299
- headers["X-Canonry-Signature"] = "sha256=" + crypto7.createHmac("sha256", webhookSecret).update(body).digest("hex");
1371
+ headers["X-Canonry-Signature"] = "sha256=" + crypto8.createHmac("sha256", webhookSecret).update(body).digest("hex");
1300
1372
  }
1301
1373
  return await new Promise((resolve) => {
1302
1374
  const requestOptions = {
@@ -1438,7 +1510,7 @@ async function applyRoutes(app, opts) {
1438
1510
  entityId: projectId
1439
1511
  });
1440
1512
  } else {
1441
- projectId = crypto8.randomUUID();
1513
+ projectId = crypto9.randomUUID();
1442
1514
  app.db.insert(projects).values({
1443
1515
  id: projectId,
1444
1516
  name,
@@ -1466,7 +1538,7 @@ async function applyRoutes(app, opts) {
1466
1538
  tx.delete(keywords).where(eq8(keywords.projectId, projectId)).run();
1467
1539
  for (const kw of config.spec.keywords) {
1468
1540
  tx.insert(keywords).values({
1469
- id: crypto8.randomUUID(),
1541
+ id: crypto9.randomUUID(),
1470
1542
  projectId,
1471
1543
  keyword: kw,
1472
1544
  createdAt: now
@@ -1482,7 +1554,7 @@ async function applyRoutes(app, opts) {
1482
1554
  tx.delete(competitors).where(eq8(competitors.projectId, projectId)).run();
1483
1555
  for (const domain of config.spec.competitors) {
1484
1556
  tx.insert(competitors).values({
1485
- id: crypto8.randomUUID(),
1557
+ id: crypto9.randomUUID(),
1486
1558
  projectId,
1487
1559
  domain,
1488
1560
  createdAt: now
@@ -1538,7 +1610,7 @@ async function applyRoutes(app, opts) {
1538
1610
  }).where(eq8(schedules.id, existingSched.id)).run();
1539
1611
  } else {
1540
1612
  app.db.insert(schedules).values({
1541
- id: crypto8.randomUUID(),
1613
+ id: crypto9.randomUUID(),
1542
1614
  projectId,
1543
1615
  cronExpr,
1544
1616
  preset,
@@ -1570,11 +1642,11 @@ async function applyRoutes(app, opts) {
1570
1642
  app.db.delete(notifications).where(eq8(notifications.projectId, projectId)).run();
1571
1643
  for (const notif of config.spec.notifications) {
1572
1644
  app.db.insert(notifications).values({
1573
- id: crypto8.randomUUID(),
1645
+ id: crypto9.randomUUID(),
1574
1646
  projectId,
1575
1647
  channel: notif.channel,
1576
1648
  config: JSON.stringify({ url: notif.url, events: notif.events }),
1577
- webhookSecret: crypto8.randomBytes(32).toString("hex"),
1649
+ webhookSecret: crypto9.randomBytes(32).toString("hex"),
1578
1650
  enabled: 1,
1579
1651
  createdAt: now,
1580
1652
  updatedAt: now
@@ -1848,8 +1920,37 @@ async function settingsRoutes(app, opts) {
1848
1920
  });
1849
1921
  }
1850
1922
 
1923
+ // ../api-routes/src/telemetry.ts
1924
+ async function telemetryRoutes(app, opts) {
1925
+ app.get("/telemetry", async (_request, reply) => {
1926
+ if (!opts.getTelemetryStatus) {
1927
+ return reply.status(501).send({ error: "Telemetry status is not available in this deployment" });
1928
+ }
1929
+ const status = opts.getTelemetryStatus();
1930
+ return {
1931
+ enabled: status.enabled,
1932
+ anonymousId: status.anonymousId ? status.anonymousId.slice(0, 8) + "..." : void 0
1933
+ };
1934
+ });
1935
+ app.put("/telemetry", async (request, reply) => {
1936
+ if (!opts.setTelemetryEnabled) {
1937
+ return reply.status(501).send({ error: "Telemetry configuration is not available in this deployment" });
1938
+ }
1939
+ const { enabled } = request.body ?? {};
1940
+ if (typeof enabled !== "boolean") {
1941
+ return reply.status(400).send({ error: "enabled (boolean) is required" });
1942
+ }
1943
+ opts.setTelemetryEnabled(enabled);
1944
+ const status = opts.getTelemetryStatus?.();
1945
+ return {
1946
+ enabled: status?.enabled ?? enabled,
1947
+ anonymousId: status?.anonymousId ? status.anonymousId.slice(0, 8) + "..." : void 0
1948
+ };
1949
+ });
1950
+ }
1951
+
1851
1952
  // ../api-routes/src/schedules.ts
1852
- import crypto9 from "crypto";
1953
+ import crypto10 from "crypto";
1853
1954
  import { eq as eq10 } from "drizzle-orm";
1854
1955
  async function scheduleRoutes(app, opts) {
1855
1956
  app.put("/projects/:name/schedule", async (request, reply) => {
@@ -1896,7 +1997,7 @@ async function scheduleRoutes(app, opts) {
1896
1997
  }).where(eq10(schedules.id, existing.id)).run();
1897
1998
  } else {
1898
1999
  app.db.insert(schedules).values({
1899
- id: crypto9.randomUUID(),
2000
+ id: crypto10.randomUUID(),
1900
2001
  projectId: project.id,
1901
2002
  cronExpr,
1902
2003
  preset: preset ?? null,
@@ -1975,7 +2076,7 @@ function resolveProjectSafe5(app, name, reply) {
1975
2076
  }
1976
2077
 
1977
2078
  // ../api-routes/src/notifications.ts
1978
- import crypto10 from "crypto";
2079
+ import crypto11 from "crypto";
1979
2080
  import { eq as eq11 } from "drizzle-orm";
1980
2081
  var VALID_EVENTS = ["citation.lost", "citation.gained", "run.completed", "run.failed"];
1981
2082
  async function notificationRoutes(app) {
@@ -2006,8 +2107,8 @@ async function notificationRoutes(app) {
2006
2107
  });
2007
2108
  }
2008
2109
  const now = (/* @__PURE__ */ new Date()).toISOString();
2009
- const id = crypto10.randomUUID();
2010
- const webhookSecret = crypto10.randomBytes(32).toString("hex");
2110
+ const id = crypto11.randomUUID();
2111
+ const webhookSecret = crypto11.randomBytes(32).toString("hex");
2011
2112
  app.db.insert(notifications).values({
2012
2113
  id,
2013
2114
  projectId: project.id,
@@ -2152,6 +2253,10 @@ async function apiRoutes(app, opts) {
2152
2253
  onScheduleUpdated: opts.onScheduleUpdated
2153
2254
  });
2154
2255
  await api.register(notificationRoutes);
2256
+ await api.register(telemetryRoutes, {
2257
+ getTelemetryStatus: opts.getTelemetryStatus,
2258
+ setTelemetryEnabled: opts.setTelemetryEnabled
2259
+ });
2155
2260
  }, { prefix: "/api/v1" });
2156
2261
  }
2157
2262
 
@@ -3072,7 +3177,7 @@ var localAdapter = {
3072
3177
  };
3073
3178
 
3074
3179
  // src/job-runner.ts
3075
- import crypto11 from "crypto";
3180
+ import crypto12 from "crypto";
3076
3181
  import { eq as eq12, inArray as inArray2 } from "drizzle-orm";
3077
3182
  var JobRunner = class {
3078
3183
  db;
@@ -3093,6 +3198,7 @@ var JobRunner = class {
3093
3198
  }
3094
3199
  async executeRun(runId, projectId, providerOverride) {
3095
3200
  const now = (/* @__PURE__ */ new Date()).toISOString();
3201
+ const startTime = Date.now();
3096
3202
  try {
3097
3203
  this.db.update(runs).set({ status: "running", startedAt: now }).where(eq12(runs.id, runId)).run();
3098
3204
  const project = this.db.select().from(projects).where(eq12(projects.id, projectId)).get();
@@ -3148,7 +3254,7 @@ var JobRunner = class {
3148
3254
  const citationState = determineCitationState(normalized, project.canonicalDomain);
3149
3255
  const overlap = computeCompetitorOverlap(normalized, competitorDomains);
3150
3256
  this.db.insert(querySnapshots).values({
3151
- id: crypto11.randomUUID(),
3257
+ id: crypto12.randomUUID(),
3152
3258
  runId,
3153
3259
  keywordId: kw.id,
3154
3260
  provider: providerName,
@@ -3185,6 +3291,14 @@ var JobRunner = class {
3185
3291
  } else {
3186
3292
  this.db.update(runs).set({ status: "completed", finishedAt: (/* @__PURE__ */ new Date()).toISOString() }).where(eq12(runs.id, runId)).run();
3187
3293
  }
3294
+ const finalStatus = allFailed ? "failed" : someFailed ? "partial" : "completed";
3295
+ trackEvent("run.completed", {
3296
+ status: finalStatus,
3297
+ providerCount: activeProviders.length,
3298
+ providers: activeProviders.map((p) => p.adapter.name),
3299
+ keywordCount: projectKeywords.length,
3300
+ durationMs: Date.now() - startTime
3301
+ });
3188
3302
  for (const p of activeProviders) {
3189
3303
  this.incrementUsage(`${projectId}:${p.adapter.name}`, "queries", queriesPerProvider);
3190
3304
  }
@@ -3201,6 +3315,13 @@ var JobRunner = class {
3201
3315
  finishedAt: (/* @__PURE__ */ new Date()).toISOString(),
3202
3316
  error: errorMessage
3203
3317
  }).where(eq12(runs.id, runId)).run();
3318
+ trackEvent("run.completed", {
3319
+ status: "failed",
3320
+ providerCount: 0,
3321
+ providers: [],
3322
+ keywordCount: 0,
3323
+ durationMs: Date.now() - startTime
3324
+ });
3204
3325
  if (this.onRunCompleted) {
3205
3326
  this.onRunCompleted(runId, projectId).catch((notifErr) => {
3206
3327
  console.error("[JobRunner] Notification callback failed:", notifErr);
@@ -3229,7 +3350,7 @@ var JobRunner = class {
3229
3350
  incrementUsage(scope, metric, count) {
3230
3351
  const now = /* @__PURE__ */ new Date();
3231
3352
  const period = `${now.getUTCFullYear()}-${String(now.getUTCMonth() + 1).padStart(2, "0")}`;
3232
- const id = crypto11.randomUUID();
3353
+ const id = crypto12.randomUUID();
3233
3354
  const existing = this.db.select().from(usageCounters).where(eq12(usageCounters.scope, scope)).all().find((r) => r.period === period && r.metric === metric);
3234
3355
  if (existing) {
3235
3356
  this.db.update(usageCounters).set({ count: existing.count + count, updatedAt: now.toISOString() }).where(eq12(usageCounters.id, existing.id)).run();
@@ -3479,7 +3600,7 @@ var Scheduler = class {
3479
3600
 
3480
3601
  // src/notifier.ts
3481
3602
  import { eq as eq14, desc as desc2, and as and3, or as or2 } from "drizzle-orm";
3482
- import crypto12 from "crypto";
3603
+ import crypto13 from "crypto";
3483
3604
  var Notifier = class {
3484
3605
  db;
3485
3606
  serverUrl;
@@ -3617,7 +3738,7 @@ var Notifier = class {
3617
3738
  }
3618
3739
  logDelivery(projectId, notificationId, event, status, error) {
3619
3740
  this.db.insert(auditLog).values({
3620
- id: crypto12.randomUUID(),
3741
+ id: crypto13.randomUUID(),
3621
3742
  projectId,
3622
3743
  actor: "scheduler",
3623
3744
  action: `notification.${status}`,
@@ -3741,8 +3862,8 @@ function stripHtml(html) {
3741
3862
  }
3742
3863
 
3743
3864
  // src/server.ts
3744
- var _require = createRequire(import.meta.url);
3745
- var { version: PKG_VERSION } = _require("../package.json");
3865
+ var _require2 = createRequire2(import.meta.url);
3866
+ var { version: PKG_VERSION } = _require2("../package.json");
3746
3867
  var DEFAULT_QUOTA = {
3747
3868
  maxConcurrency: 2,
3748
3869
  maxRequestsPerMinute: 10,
@@ -3882,6 +4003,21 @@ async function createServer(opts) {
3882
4003
  onProjectDeleted: (projectId) => {
3883
4004
  scheduler.remove(projectId);
3884
4005
  },
4006
+ getTelemetryStatus: () => {
4007
+ const enabled = isTelemetryEnabled();
4008
+ return {
4009
+ enabled,
4010
+ // Only read/create the anonymous ID if telemetry is enabled.
4011
+ // Don't mutate config for opted-out users.
4012
+ anonymousId: enabled ? getOrCreateAnonymousId() : void 0
4013
+ };
4014
+ },
4015
+ setTelemetryEnabled: (enabled) => {
4016
+ const config = loadConfig();
4017
+ config.telemetry = enabled;
4018
+ saveConfig(config);
4019
+ opts.config.telemetry = enabled;
4020
+ },
3885
4021
  onGenerateKeywords: async (providerName, count, project) => {
3886
4022
  const provider = registry.get(providerName);
3887
4023
  if (!provider) throw new Error(`Provider "${providerName}" is not configured`);
@@ -4001,5 +4137,10 @@ export {
4001
4137
  loadConfig,
4002
4138
  saveConfig,
4003
4139
  configExists,
4140
+ isTelemetryEnabled,
4141
+ getOrCreateAnonymousId,
4142
+ isFirstRun,
4143
+ showFirstRunNotice,
4144
+ trackEvent,
4004
4145
  createServer
4005
4146
  };
package/dist/cli.js CHANGED
@@ -6,11 +6,16 @@ import {
6
6
  createServer,
7
7
  getConfigDir,
8
8
  getConfigPath,
9
+ getOrCreateAnonymousId,
10
+ isFirstRun,
11
+ isTelemetryEnabled,
9
12
  loadConfig,
10
13
  migrate,
11
14
  providerQuotaPolicySchema,
12
- saveConfig
13
- } from "./chunk-67L2E6T5.js";
15
+ saveConfig,
16
+ showFirstRunNotice,
17
+ trackEvent
18
+ } from "./chunk-CKC26GOH.js";
14
19
 
15
20
  // src/cli.ts
16
21
  import { parseArgs } from "util";
@@ -254,13 +259,18 @@ async function initCommand(opts) {
254
259
  apiKey: rawApiKey,
255
260
  providers
256
261
  });
257
- const providerNames = Object.keys(providers).join(", ");
262
+ const providerNames = Object.keys(providers);
258
263
  console.log(`
259
264
  Config saved to ${getConfigPath()}`);
260
265
  console.log(`Database created at ${databasePath}`);
261
266
  console.log(`API key: ${rawApiKey}`);
262
- console.log(`Providers: ${providerNames}`);
263
- console.log('\nRun "canonry serve" to start the server.');
267
+ console.log(`Providers: ${providerNames.join(", ")}`);
268
+ showFirstRunNotice();
269
+ console.log('Run "canonry serve" to start the server.');
270
+ trackEvent("cli.init", {
271
+ providerCount: providerNames.length,
272
+ providers: providerNames
273
+ });
264
274
  }
265
275
 
266
276
  // src/commands/serve.ts
@@ -276,6 +286,13 @@ async function serveCommand() {
276
286
  console.log(`
277
287
  Canonry server running at http://${host === "0.0.0.0" ? "localhost" : host}:${port}`);
278
288
  console.log("Press Ctrl+C to stop.\n");
289
+ const providerNames = Object.keys(config.providers ?? {}).filter(
290
+ (k) => config.providers?.[k]?.apiKey || config.providers?.[k]?.baseUrl
291
+ );
292
+ trackEvent("serve.started", {
293
+ providerCount: providerNames.length,
294
+ providers: providerNames
295
+ });
279
296
  } catch (err) {
280
297
  app.log.error(err);
281
298
  process.exit(1);
@@ -394,6 +411,12 @@ var ApiClient = class {
394
411
  async testNotification(project, id) {
395
412
  return this.request("POST", `/projects/${encodeURIComponent(project)}/notifications/${encodeURIComponent(id)}/test`);
396
413
  }
414
+ async getTelemetry() {
415
+ return this.request("GET", "/telemetry");
416
+ }
417
+ async updateTelemetry(enabled) {
418
+ return this.request("PUT", "/telemetry", { enabled });
419
+ }
397
420
  async generateKeywords(project, provider, count) {
398
421
  return this.request(
399
422
  "POST",
@@ -869,6 +892,64 @@ function printNotification(n) {
869
892
  console.log(` Enabled: ${n.enabled ? "yes" : "no"}`);
870
893
  }
871
894
 
895
+ // src/commands/telemetry.ts
896
+ function telemetryCommand(subcommand) {
897
+ switch (subcommand) {
898
+ case "status": {
899
+ if (process.env.CANONRY_TELEMETRY_DISABLED === "1") {
900
+ console.log("Telemetry: disabled (CANONRY_TELEMETRY_DISABLED=1)");
901
+ return;
902
+ }
903
+ if (process.env.DO_NOT_TRACK === "1") {
904
+ console.log("Telemetry: disabled (DO_NOT_TRACK=1)");
905
+ return;
906
+ }
907
+ if (process.env.CI) {
908
+ console.log("Telemetry: disabled (CI environment detected)");
909
+ return;
910
+ }
911
+ if (!configExists()) {
912
+ console.log('Telemetry: enabled (no config yet \u2014 run "canonry init" first)');
913
+ return;
914
+ }
915
+ const config = loadConfig();
916
+ const enabled = config.telemetry !== false;
917
+ console.log(`Telemetry: ${enabled ? "enabled" : "disabled"}`);
918
+ if (config.anonymousId) {
919
+ const masked = config.anonymousId.slice(0, 8) + "...";
920
+ console.log(`Anonymous ID: ${masked}`);
921
+ }
922
+ break;
923
+ }
924
+ case "enable": {
925
+ if (!configExists()) {
926
+ console.error('No config found. Run "canonry init" first.');
927
+ process.exit(1);
928
+ }
929
+ const config = loadConfig();
930
+ config.telemetry = true;
931
+ saveConfig(config);
932
+ console.log("Telemetry enabled.");
933
+ break;
934
+ }
935
+ case "disable": {
936
+ if (!configExists()) {
937
+ console.error('No config found. Run "canonry init" first.');
938
+ process.exit(1);
939
+ }
940
+ const config = loadConfig();
941
+ config.telemetry = false;
942
+ saveConfig(config);
943
+ console.log("Telemetry disabled. No data will be sent.");
944
+ break;
945
+ }
946
+ default:
947
+ console.error(`Unknown telemetry subcommand: ${subcommand ?? "(none)"}`);
948
+ console.log("Available: status, enable, disable");
949
+ process.exit(1);
950
+ }
951
+ }
952
+
872
953
  // src/cli.ts
873
954
  import { createRequire } from "module";
874
955
  var USAGE = `
@@ -907,6 +988,9 @@ Usage:
907
988
  canonry notify test <project> <id> Send test webhook
908
989
  canonry settings Show active provider and quota settings
909
990
  canonry settings provider <name> Update a provider config (--api-key, --base-url, --model)
991
+ canonry telemetry status Show telemetry status
992
+ canonry telemetry enable Enable anonymous telemetry
993
+ canonry telemetry disable Disable anonymous telemetry
910
994
  canonry --help Show this help
911
995
  canonry --version Show version
912
996
 
@@ -937,6 +1021,15 @@ async function main() {
937
1021
  return;
938
1022
  }
939
1023
  const command = args[0];
1024
+ if (command !== "telemetry" && command !== "init" && isTelemetryEnabled() && isFirstRun()) {
1025
+ showFirstRunNotice();
1026
+ getOrCreateAnonymousId();
1027
+ }
1028
+ const SUBCOMMAND_COMMANDS = /* @__PURE__ */ new Set(["project", "keyword", "competitor", "schedule", "notify", "settings", "telemetry"]);
1029
+ const resolvedCommand = SUBCOMMAND_COMMANDS.has(command) && args[1] && !args[1].startsWith("-") ? `${command}.${args[1]}` : command;
1030
+ if (command !== "telemetry") {
1031
+ trackEvent("cli.command", { command: resolvedCommand });
1032
+ }
940
1033
  try {
941
1034
  switch (command) {
942
1035
  case "init": {
@@ -1337,6 +1430,10 @@ async function main() {
1337
1430
  }
1338
1431
  break;
1339
1432
  }
1433
+ case "telemetry": {
1434
+ telemetryCommand(args[1]);
1435
+ break;
1436
+ }
1340
1437
  default:
1341
1438
  console.error(`Unknown command: ${command}`);
1342
1439
  console.log('Run "canonry --help" for usage.');
package/dist/index.d.ts CHANGED
@@ -22,6 +22,8 @@ interface CanonryConfig {
22
22
  claude?: ProviderConfigEntry;
23
23
  local?: ProviderConfigEntry;
24
24
  };
25
+ telemetry?: boolean;
26
+ anonymousId?: string;
25
27
  }
26
28
  declare function loadConfig(): CanonryConfig;
27
29
 
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  createServer,
3
3
  loadConfig
4
- } from "./chunk-67L2E6T5.js";
4
+ } from "./chunk-CKC26GOH.js";
5
5
  export {
6
6
  createServer,
7
7
  loadConfig
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ainyc/canonry",
3
- "version": "1.4.3",
3
+ "version": "1.5.0",
4
4
  "type": "module",
5
5
  "description": "The ultimate open-source AEO monitoring tool - track how answer engines cite your domain",
6
6
  "license": "FSL-1.1-ALv2",
@@ -51,13 +51,13 @@
51
51
  "@types/node-cron": "^3.0.11",
52
52
  "tsup": "^8.5.1",
53
53
  "tsx": "^4.19.0",
54
- "@ainyc/canonry-api-routes": "0.0.0",
55
54
  "@ainyc/canonry-config": "0.0.0",
56
- "@ainyc/canonry-provider-claude": "0.0.0",
57
55
  "@ainyc/canonry-contracts": "0.0.0",
56
+ "@ainyc/canonry-api-routes": "0.0.0",
57
+ "@ainyc/canonry-provider-claude": "0.0.0",
58
58
  "@ainyc/canonry-db": "0.0.0",
59
- "@ainyc/canonry-provider-gemini": "0.0.0",
60
59
  "@ainyc/canonry-provider-local": "0.0.0",
60
+ "@ainyc/canonry-provider-gemini": "0.0.0",
61
61
  "@ainyc/canonry-provider-openai": "0.0.0"
62
62
  },
63
63
  "scripts": {