@ainyc/canonry 1.10.1 → 1.11.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.
@@ -9,6 +9,16 @@ import fs from "fs";
9
9
  import path from "path";
10
10
  import os from "os";
11
11
  import { parse, stringify } from "yaml";
12
+ function normalizeGoogleConfig(config) {
13
+ if (!config.google) return;
14
+ config.google.connections = (config.google.connections ?? []).map((connection) => ({
15
+ ...connection,
16
+ propertyId: connection.propertyId ?? null,
17
+ refreshToken: connection.refreshToken ?? null,
18
+ tokenExpiresAt: connection.tokenExpiresAt ?? null,
19
+ scopes: connection.scopes ?? []
20
+ }));
21
+ }
12
22
  function getConfigDir() {
13
23
  const override = process.env.CANONRY_CONFIG_DIR?.trim();
14
24
  if (override) {
@@ -25,7 +35,7 @@ function loadConfig() {
25
35
  throw new Error(
26
36
  `Config not found at ${configPath}.
27
37
  Run "canonry init" to set up interactively, or "canonry init --gemini-key <key>" for non-interactive setup.
28
- For CI/Docker, use "canonry bootstrap" with env vars (GEMINI_API_KEY, OPENAI_API_KEY, ANTHROPIC_API_KEY).`
38
+ For CI/Docker, use "canonry bootstrap" with env vars (GEMINI_API_KEY, OPENAI_API_KEY, ANTHROPIC_API_KEY, GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET).`
29
39
  );
30
40
  }
31
41
  const raw = fs.readFileSync(configPath, "utf-8");
@@ -39,7 +49,7 @@ For CI/Docker, use "canonry bootstrap" with env vars (GEMINI_API_KEY, OPENAI_API
39
49
  throw new Error(
40
50
  `Invalid config at ${configPath} \u2014 missing: ${missing}.
41
51
  These fields are auto-generated. Run "canonry init" (or "canonry init --gemini-key <key>" for non-interactive setup) to create a valid config.
42
- Do not write config.yaml by hand; use "canonry init" or "canonry bootstrap" instead.`
52
+ Do not write config.yaml by hand; use "canonry init", "canonry settings", or "canonry bootstrap" instead.`
43
53
  );
44
54
  }
45
55
  if (parsed.geminiApiKey && !parsed.providers?.gemini) {
@@ -52,6 +62,7 @@ Do not write config.yaml by hand; use "canonry init" or "canonry bootstrap" inst
52
62
  }
53
63
  };
54
64
  }
65
+ normalizeGoogleConfig(parsed);
55
66
  return parsed;
56
67
  }
57
68
  function saveConfig(config) {
@@ -1548,7 +1559,7 @@ function resolveProjectSafe3(app, name, reply) {
1548
1559
 
1549
1560
  // ../api-routes/src/apply.ts
1550
1561
  import crypto9 from "crypto";
1551
- import { eq as eq8, and as and3 } from "drizzle-orm";
1562
+ import { eq as eq8 } from "drizzle-orm";
1552
1563
 
1553
1564
  // ../api-routes/src/schedule-utils.ts
1554
1565
  var DAY_MAP = {
@@ -1986,11 +1997,7 @@ async function applyRoutes(app, opts) {
1986
1997
  });
1987
1998
  }
1988
1999
  if ("google" in rawSpec && config.spec.google?.gsc?.propertyUrl) {
1989
- const domain = config.spec.canonicalDomain;
1990
- const conn = app.db.select().from(googleConnections).where(and3(eq8(googleConnections.domain, domain), eq8(googleConnections.connectionType, "gsc"))).get();
1991
- if (conn) {
1992
- app.db.update(googleConnections).set({ propertyId: config.spec.google.gsc.propertyUrl, updatedAt: now }).where(eq8(googleConnections.id, conn.id)).run();
1993
- }
2000
+ opts?.onGoogleConnectionPropertyUpdated?.(config.spec.canonicalDomain, "gsc", config.spec.google.gsc.propertyUrl);
1994
2001
  }
1995
2002
  const project = app.db.select().from(projects).where(eq8(projects.id, projectId)).get();
1996
2003
  return reply.status(200).send({
@@ -2887,7 +2894,8 @@ function buildOperationId(method, path3) {
2887
2894
  // ../api-routes/src/settings.ts
2888
2895
  async function settingsRoutes(app, opts) {
2889
2896
  app.get("/settings", async () => ({
2890
- providers: opts.providerSummary ?? []
2897
+ providers: opts.providerSummary ?? [],
2898
+ google: opts.google ?? { configured: false }
2891
2899
  }));
2892
2900
  app.put("/settings/providers/:name", async (request, reply) => {
2893
2901
  const providerName = parseProviderName(request.params.name);
@@ -2935,6 +2943,22 @@ async function settingsRoutes(app, opts) {
2935
2943
  }
2936
2944
  return result;
2937
2945
  });
2946
+ app.put("/settings/google", async (request, reply) => {
2947
+ const { clientId, clientSecret } = request.body ?? {};
2948
+ if (!clientId || typeof clientId !== "string" || !clientSecret || typeof clientSecret !== "string") {
2949
+ return reply.status(400).send({
2950
+ error: { code: "VALIDATION_ERROR", message: "clientId and clientSecret are required" }
2951
+ });
2952
+ }
2953
+ if (!opts.onGoogleUpdate) {
2954
+ return reply.status(501).send({ error: "Google OAuth configuration updates are not supported in this deployment" });
2955
+ }
2956
+ const result = opts.onGoogleUpdate(clientId, clientSecret);
2957
+ if (!result) {
2958
+ return reply.status(500).send({ error: "Failed to update Google OAuth configuration" });
2959
+ }
2960
+ return result;
2961
+ });
2938
2962
  }
2939
2963
 
2940
2964
  // ../api-routes/src/telemetry.ts
@@ -3248,7 +3272,7 @@ function resolveProjectSafe6(app, name, reply) {
3248
3272
 
3249
3273
  // ../api-routes/src/google.ts
3250
3274
  import crypto12 from "crypto";
3251
- import { eq as eq12, and as and4, desc as desc2, sql as sql2 } from "drizzle-orm";
3275
+ import { eq as eq12, and as and3, desc as desc2, sql as sql2 } from "drizzle-orm";
3252
3276
 
3253
3277
  // ../integration-google/src/constants.ts
3254
3278
  var GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
@@ -3423,8 +3447,8 @@ function verifySignedState(encoded, secret) {
3423
3447
  return null;
3424
3448
  }
3425
3449
  }
3426
- async function getValidToken(app, domain, connectionType, clientId, clientSecret) {
3427
- const conn = app.db.select().from(googleConnections).where(and4(eq12(googleConnections.domain, domain), eq12(googleConnections.connectionType, connectionType))).get();
3450
+ async function getValidToken(store, domain, connectionType, clientId, clientSecret) {
3451
+ const conn = store.getConnection(domain, connectionType);
3428
3452
  if (!conn) {
3429
3453
  throw notFound("Google connection", connectionType);
3430
3454
  }
@@ -3436,37 +3460,51 @@ async function getValidToken(app, domain, connectionType, clientId, clientSecret
3436
3460
  if (Date.now() > expiresAt - fiveMinutes) {
3437
3461
  const tokens = await refreshAccessToken(clientId, clientSecret, conn.refreshToken);
3438
3462
  const newExpiresAt = new Date(Date.now() + tokens.expires_in * 1e3).toISOString();
3439
- app.db.update(googleConnections).set({
3463
+ const updated = store.updateConnection(domain, connectionType, {
3440
3464
  accessToken: tokens.access_token,
3441
3465
  tokenExpiresAt: newExpiresAt,
3442
3466
  updatedAt: (/* @__PURE__ */ new Date()).toISOString()
3443
- }).where(eq12(googleConnections.id, conn.id)).run();
3444
- return { accessToken: tokens.access_token, connectionId: conn.id, propertyId: conn.propertyId };
3467
+ });
3468
+ return {
3469
+ accessToken: tokens.access_token,
3470
+ connectionId: `${domain}:${connectionType}`,
3471
+ propertyId: updated?.propertyId ?? conn.propertyId ?? null
3472
+ };
3445
3473
  }
3446
- return { accessToken: conn.accessToken, connectionId: conn.id, propertyId: conn.propertyId };
3474
+ return {
3475
+ accessToken: conn.accessToken,
3476
+ connectionId: `${domain}:${connectionType}`,
3477
+ propertyId: conn.propertyId ?? null
3478
+ };
3447
3479
  }
3448
3480
  async function googleRoutes(app, opts) {
3449
- const { googleClientId, googleClientSecret } = opts;
3450
3481
  const stateSecret = opts.googleStateSecret ?? "insecure-default-secret";
3482
+ function getAuthConfig() {
3483
+ return opts.getGoogleAuthConfig?.() ?? {};
3484
+ }
3485
+ function requireConnectionStore(reply) {
3486
+ if (opts.googleConnectionStore) return opts.googleConnectionStore;
3487
+ const err = validationError("Google auth storage is not configured for this deployment");
3488
+ reply.status(err.statusCode).send(err.toJSON());
3489
+ return null;
3490
+ }
3451
3491
  app.get("/projects/:name/google/connections", async (request) => {
3452
3492
  const project = resolveProject(app.db, request.params.name);
3453
- const conns = app.db.select({
3454
- id: googleConnections.id,
3455
- domain: googleConnections.domain,
3456
- connectionType: googleConnections.connectionType,
3457
- propertyId: googleConnections.propertyId,
3458
- scopes: googleConnections.scopes,
3459
- createdAt: googleConnections.createdAt,
3460
- updatedAt: googleConnections.updatedAt
3461
- }).from(googleConnections).where(eq12(googleConnections.domain, project.canonicalDomain)).all();
3462
- return conns.map((c) => ({
3463
- ...c,
3464
- scopes: JSON.parse(c.scopes)
3493
+ const conns = opts.googleConnectionStore?.listConnections(project.canonicalDomain) ?? [];
3494
+ return conns.map((connection) => ({
3495
+ id: `${connection.domain}:${connection.connectionType}`,
3496
+ domain: connection.domain,
3497
+ connectionType: connection.connectionType,
3498
+ propertyId: connection.propertyId ?? null,
3499
+ scopes: connection.scopes ?? [],
3500
+ createdAt: connection.createdAt,
3501
+ updatedAt: connection.updatedAt
3465
3502
  }));
3466
3503
  });
3467
3504
  app.post("/projects/:name/google/connect", async (request, reply) => {
3505
+ const { clientId: googleClientId, clientSecret: googleClientSecret } = getAuthConfig();
3468
3506
  if (!googleClientId || !googleClientSecret) {
3469
- const err = validationError("Google OAuth is not configured. Set GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET environment variables.");
3507
+ const err = validationError("Google OAuth is not configured. Set Google OAuth credentials in the local Canonry config.");
3470
3508
  return reply.status(err.statusCode).send(err.toJSON());
3471
3509
  }
3472
3510
  const { type, propertyId } = request.body ?? {};
@@ -3487,9 +3525,12 @@ async function googleRoutes(app, opts) {
3487
3525
  return { authUrl };
3488
3526
  });
3489
3527
  app.get("/projects/:name/google/callback", async (request, reply) => {
3528
+ const { clientId: googleClientId, clientSecret: googleClientSecret } = getAuthConfig();
3490
3529
  if (!googleClientId || !googleClientSecret) {
3491
3530
  return reply.status(500).send("Google OAuth not configured");
3492
3531
  }
3532
+ const store = requireConnectionStore(reply);
3533
+ if (!store) return;
3493
3534
  const { code, state, error } = request.query;
3494
3535
  if (error) {
3495
3536
  const safeError = String(error).replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
@@ -3506,30 +3547,18 @@ async function googleRoutes(app, opts) {
3506
3547
  const tokens = await exchangeCode(googleClientId, googleClientSecret, code, redirectUri);
3507
3548
  const now = (/* @__PURE__ */ new Date()).toISOString();
3508
3549
  const expiresAt = new Date(Date.now() + tokens.expires_in * 1e3).toISOString();
3509
- const existing = app.db.select().from(googleConnections).where(and4(eq12(googleConnections.domain, domain), eq12(googleConnections.connectionType, type))).get();
3510
- if (existing) {
3511
- app.db.update(googleConnections).set({
3512
- accessToken: tokens.access_token,
3513
- refreshToken: tokens.refresh_token ?? existing.refreshToken,
3514
- tokenExpiresAt: expiresAt,
3515
- propertyId: propertyId ?? existing.propertyId,
3516
- scopes: JSON.stringify(tokens.scope?.split(" ") ?? []),
3517
- updatedAt: now
3518
- }).where(eq12(googleConnections.id, existing.id)).run();
3519
- } else {
3520
- app.db.insert(googleConnections).values({
3521
- id: crypto12.randomUUID(),
3522
- domain,
3523
- connectionType: type,
3524
- propertyId: propertyId ?? null,
3525
- accessToken: tokens.access_token,
3526
- refreshToken: tokens.refresh_token ?? null,
3527
- tokenExpiresAt: expiresAt,
3528
- scopes: JSON.stringify(tokens.scope?.split(" ") ?? []),
3529
- createdAt: now,
3530
- updatedAt: now
3531
- }).run();
3532
- }
3550
+ const existing = store.getConnection(domain, type);
3551
+ store.upsertConnection({
3552
+ domain,
3553
+ connectionType: type,
3554
+ propertyId: propertyId ?? existing?.propertyId ?? null,
3555
+ accessToken: tokens.access_token,
3556
+ refreshToken: tokens.refresh_token ?? existing?.refreshToken ?? null,
3557
+ tokenExpiresAt: expiresAt,
3558
+ scopes: tokens.scope?.split(" ") ?? [],
3559
+ createdAt: existing?.createdAt ?? now,
3560
+ updatedAt: now
3561
+ });
3533
3562
  writeAuditLog(app.db, {
3534
3563
  projectId: null,
3535
3564
  actor: "oauth",
@@ -3547,9 +3576,11 @@ async function googleRoutes(app, opts) {
3547
3576
  );
3548
3577
  });
3549
3578
  app.delete("/projects/:name/google/connections/:type", async (request, reply) => {
3579
+ const store = requireConnectionStore(reply);
3580
+ if (!store) return;
3550
3581
  const project = resolveProject(app.db, request.params.name);
3551
- const deleted = app.db.delete(googleConnections).where(and4(eq12(googleConnections.domain, project.canonicalDomain), eq12(googleConnections.connectionType, request.params.type))).run();
3552
- if (deleted.changes === 0) {
3582
+ const deleted = store.deleteConnection(project.canonicalDomain, request.params.type);
3583
+ if (!deleted) {
3553
3584
  const err = notFound("Google connection", request.params.type);
3554
3585
  return reply.status(err.statusCode).send(err.toJSON());
3555
3586
  }
@@ -3563,18 +3594,23 @@ async function googleRoutes(app, opts) {
3563
3594
  return reply.status(204).send();
3564
3595
  });
3565
3596
  app.get("/projects/:name/google/properties", async (request, reply) => {
3597
+ const { clientId: googleClientId, clientSecret: googleClientSecret } = getAuthConfig();
3566
3598
  if (!googleClientId || !googleClientSecret) {
3567
3599
  const err = validationError("Google OAuth is not configured");
3568
3600
  return reply.status(err.statusCode).send(err.toJSON());
3569
3601
  }
3602
+ const store = requireConnectionStore(reply);
3603
+ if (!store) return;
3570
3604
  const project = resolveProject(app.db, request.params.name);
3571
- const { accessToken } = await getValidToken(app, project.canonicalDomain, "gsc", googleClientId, googleClientSecret);
3605
+ const { accessToken } = await getValidToken(store, project.canonicalDomain, "gsc", googleClientId, googleClientSecret);
3572
3606
  const sites = await listSites(accessToken);
3573
3607
  return { sites };
3574
3608
  });
3575
3609
  app.post("/projects/:name/google/gsc/sync", async (request, reply) => {
3610
+ const store = requireConnectionStore(reply);
3611
+ if (!store) return;
3576
3612
  const project = resolveProject(app.db, request.params.name);
3577
- const conn = app.db.select().from(googleConnections).where(and4(eq12(googleConnections.domain, project.canonicalDomain), eq12(googleConnections.connectionType, "gsc"))).get();
3613
+ const conn = store.getConnection(project.canonicalDomain, "gsc");
3578
3614
  if (!conn) {
3579
3615
  const err = validationError('No GSC connection found for this domain. Run "canonry google connect" first.');
3580
3616
  return reply.status(err.statusCode).send(err.toJSON());
@@ -3604,7 +3640,7 @@ async function googleRoutes(app, opts) {
3604
3640
  if (endDate) conditions.push(sql2`${gscSearchData.date} <= ${endDate}`);
3605
3641
  if (query) conditions.push(sql2`${gscSearchData.query} LIKE ${"%" + query + "%"}`);
3606
3642
  if (page) conditions.push(sql2`${gscSearchData.page} LIKE ${"%" + page + "%"}`);
3607
- const rows = app.db.select().from(gscSearchData).where(and4(...conditions)).orderBy(desc2(gscSearchData.date)).limit(parseInt(limit ?? "500", 10)).all();
3643
+ const rows = app.db.select().from(gscSearchData).where(and3(...conditions)).orderBy(desc2(gscSearchData.date)).limit(parseInt(limit ?? "500", 10)).all();
3608
3644
  return rows.map((r) => ({
3609
3645
  date: r.date,
3610
3646
  query: r.query,
@@ -3618,17 +3654,20 @@ async function googleRoutes(app, opts) {
3618
3654
  }));
3619
3655
  });
3620
3656
  app.post("/projects/:name/google/gsc/inspect", async (request, reply) => {
3657
+ const { clientId: googleClientId, clientSecret: googleClientSecret } = getAuthConfig();
3621
3658
  if (!googleClientId || !googleClientSecret) {
3622
3659
  const err = validationError("Google OAuth is not configured");
3623
3660
  return reply.status(err.statusCode).send(err.toJSON());
3624
3661
  }
3662
+ const store = requireConnectionStore(reply);
3663
+ if (!store) return;
3625
3664
  const project = resolveProject(app.db, request.params.name);
3626
3665
  const { url } = request.body ?? {};
3627
3666
  if (!url) {
3628
3667
  const err = validationError("url is required");
3629
3668
  return reply.status(err.statusCode).send(err.toJSON());
3630
3669
  }
3631
- const { accessToken, propertyId } = await getValidToken(app, project.canonicalDomain, "gsc", googleClientId, googleClientSecret);
3670
+ const { accessToken, propertyId } = await getValidToken(store, project.canonicalDomain, "gsc", googleClientId, googleClientSecret);
3632
3671
  if (!propertyId) {
3633
3672
  const err = validationError("No GSC property configured for this connection");
3634
3673
  return reply.status(err.statusCode).send(err.toJSON());
@@ -3679,7 +3718,7 @@ async function googleRoutes(app, opts) {
3679
3718
  const { url, limit } = request.query;
3680
3719
  const conditions = [eq12(gscUrlInspections.projectId, project.id)];
3681
3720
  if (url) conditions.push(eq12(gscUrlInspections.url, url));
3682
- const rows = app.db.select().from(gscUrlInspections).where(and4(...conditions)).orderBy(desc2(gscUrlInspections.inspectedAt)).limit(parseInt(limit ?? "100", 10)).all();
3721
+ const rows = app.db.select().from(gscUrlInspections).where(and3(...conditions)).orderBy(desc2(gscUrlInspections.inspectedAt)).limit(parseInt(limit ?? "100", 10)).all();
3683
3722
  return rows.map((r) => ({
3684
3723
  id: r.id,
3685
3724
  url: r.url,
@@ -3725,18 +3764,23 @@ async function googleRoutes(app, opts) {
3725
3764
  return deindexed;
3726
3765
  });
3727
3766
  app.put("/projects/:name/google/connections/:type/property", async (request, reply) => {
3767
+ const store = requireConnectionStore(reply);
3768
+ if (!store) return;
3728
3769
  const project = resolveProject(app.db, request.params.name);
3729
3770
  const { propertyId } = request.body ?? {};
3730
3771
  if (!propertyId) {
3731
3772
  const err = validationError("propertyId is required");
3732
3773
  return reply.status(err.statusCode).send(err.toJSON());
3733
3774
  }
3734
- const conn = app.db.select().from(googleConnections).where(and4(eq12(googleConnections.domain, project.canonicalDomain), eq12(googleConnections.connectionType, request.params.type))).get();
3775
+ const conn = store.updateConnection(
3776
+ project.canonicalDomain,
3777
+ request.params.type,
3778
+ { propertyId, updatedAt: (/* @__PURE__ */ new Date()).toISOString() }
3779
+ );
3735
3780
  if (!conn) {
3736
3781
  const err = notFound("Google connection", request.params.type);
3737
3782
  return reply.status(err.statusCode).send(err.toJSON());
3738
3783
  }
3739
- app.db.update(googleConnections).set({ propertyId, updatedAt: (/* @__PURE__ */ new Date()).toISOString() }).where(eq12(googleConnections.id, conn.id)).run();
3740
3784
  return { propertyId };
3741
3785
  });
3742
3786
  }
@@ -3758,12 +3802,20 @@ async function apiRoutes(app, opts) {
3758
3802
  await api.register(competitorRoutes);
3759
3803
  await api.register(runRoutes, { onRunCreated: opts.onRunCreated });
3760
3804
  await api.register(applyRoutes, {
3761
- onScheduleUpdated: opts.onScheduleUpdated
3805
+ onScheduleUpdated: opts.onScheduleUpdated,
3806
+ onGoogleConnectionPropertyUpdated: (domain, connectionType, propertyId) => {
3807
+ opts.googleConnectionStore?.updateConnection(domain, connectionType, {
3808
+ propertyId,
3809
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
3810
+ });
3811
+ }
3762
3812
  });
3763
3813
  await api.register(historyRoutes);
3764
3814
  await api.register(settingsRoutes, {
3765
3815
  providerSummary: opts.providerSummary,
3766
- onProviderUpdate: opts.onProviderUpdate
3816
+ onProviderUpdate: opts.onProviderUpdate,
3817
+ google: opts.googleSettingsSummary,
3818
+ onGoogleUpdate: opts.onGoogleSettingsUpdate
3767
3819
  });
3768
3820
  await api.register(scheduleRoutes, {
3769
3821
  onScheduleUpdated: opts.onScheduleUpdated
@@ -3774,8 +3826,8 @@ async function apiRoutes(app, opts) {
3774
3826
  setTelemetryEnabled: opts.setTelemetryEnabled
3775
3827
  });
3776
3828
  await api.register(googleRoutes, {
3777
- googleClientId: opts.googleClientId,
3778
- googleClientSecret: opts.googleClientSecret,
3829
+ getGoogleAuthConfig: opts.getGoogleAuthConfig,
3830
+ googleConnectionStore: opts.googleConnectionStore,
3779
3831
  googleStateSecret: opts.googleStateSecret,
3780
3832
  onGscSyncRequested: opts.onGscSyncRequested
3781
3833
  });
@@ -4698,6 +4750,77 @@ var localAdapter = {
4698
4750
  }
4699
4751
  };
4700
4752
 
4753
+ // src/google-config.ts
4754
+ function ensureConnections(config) {
4755
+ if (!config.google) config.google = {};
4756
+ if (!config.google.connections) config.google.connections = [];
4757
+ return config.google.connections;
4758
+ }
4759
+ function getGoogleAuthConfig(config) {
4760
+ return {
4761
+ clientId: config.google?.clientId,
4762
+ clientSecret: config.google?.clientSecret
4763
+ };
4764
+ }
4765
+ function setGoogleAuthConfig(config, auth) {
4766
+ const hasValues = Boolean(auth.clientId || auth.clientSecret || config.google?.connections?.length);
4767
+ if (!hasValues) {
4768
+ delete config.google;
4769
+ return;
4770
+ }
4771
+ if (!config.google) config.google = {};
4772
+ config.google.clientId = auth.clientId;
4773
+ config.google.clientSecret = auth.clientSecret;
4774
+ config.google.connections = config.google.connections ?? [];
4775
+ }
4776
+ function listGoogleConnections(config, domain) {
4777
+ return (config.google?.connections ?? []).filter((connection) => connection.domain === domain);
4778
+ }
4779
+ function getGoogleConnection(config, domain, connectionType) {
4780
+ return (config.google?.connections ?? []).find((connection) => connection.domain === domain && connection.connectionType === connectionType);
4781
+ }
4782
+ function upsertGoogleConnection(config, connection) {
4783
+ const connections = ensureConnections(config);
4784
+ const index2 = connections.findIndex((entry) => entry.domain === connection.domain && entry.connectionType === connection.connectionType);
4785
+ const normalized = {
4786
+ ...connection,
4787
+ propertyId: connection.propertyId ?? null,
4788
+ refreshToken: connection.refreshToken ?? null,
4789
+ tokenExpiresAt: connection.tokenExpiresAt ?? null,
4790
+ scopes: connection.scopes ?? []
4791
+ };
4792
+ if (index2 === -1) {
4793
+ connections.push(normalized);
4794
+ return normalized;
4795
+ }
4796
+ connections[index2] = normalized;
4797
+ return normalized;
4798
+ }
4799
+ function patchGoogleConnection(config, domain, connectionType, patch) {
4800
+ const existing = getGoogleConnection(config, domain, connectionType);
4801
+ if (!existing) return void 0;
4802
+ return upsertGoogleConnection(config, {
4803
+ ...existing,
4804
+ ...patch,
4805
+ propertyId: Object.prototype.hasOwnProperty.call(patch, "propertyId") ? patch.propertyId ?? null : existing.propertyId ?? null,
4806
+ refreshToken: Object.prototype.hasOwnProperty.call(patch, "refreshToken") ? patch.refreshToken ?? null : existing.refreshToken ?? null,
4807
+ tokenExpiresAt: Object.prototype.hasOwnProperty.call(patch, "tokenExpiresAt") ? patch.tokenExpiresAt ?? null : existing.tokenExpiresAt ?? null,
4808
+ scopes: patch.scopes ?? existing.scopes ?? []
4809
+ });
4810
+ }
4811
+ function removeGoogleConnection(config, domain, connectionType) {
4812
+ const connections = config.google?.connections;
4813
+ if (!connections?.length) return false;
4814
+ const next = connections.filter((connection) => connection.domain !== domain || connection.connectionType !== connectionType);
4815
+ if (next.length === connections.length) return false;
4816
+ if (!config.google) return false;
4817
+ config.google.connections = next;
4818
+ if (!config.google.clientId && !config.google.clientSecret && next.length === 0) {
4819
+ delete config.google;
4820
+ }
4821
+ return true;
4822
+ }
4823
+
4701
4824
  // src/job-runner.ts
4702
4825
  import crypto13 from "crypto";
4703
4826
  import { eq as eq13, inArray as inArray2 } from "drizzle-orm";
@@ -4961,7 +5084,7 @@ function computeCompetitorOverlap(normalized, competitorDomains) {
4961
5084
 
4962
5085
  // src/gsc-sync.ts
4963
5086
  import crypto14 from "crypto";
4964
- import { eq as eq14, and as and5, sql as sql3 } from "drizzle-orm";
5087
+ import { eq as eq14, and as and4, sql as sql3 } from "drizzle-orm";
4965
5088
  function formatDate(d) {
4966
5089
  return d.toISOString().split("T")[0];
4967
5090
  }
@@ -4974,11 +5097,15 @@ async function executeGscSync(db, runId, projectId, opts) {
4974
5097
  const now = (/* @__PURE__ */ new Date()).toISOString();
4975
5098
  db.update(runs).set({ status: "running", startedAt: now }).where(eq14(runs.id, runId)).run();
4976
5099
  try {
5100
+ const { clientId: googleClientId, clientSecret: googleClientSecret } = getGoogleAuthConfig(opts.config);
5101
+ if (!googleClientId || !googleClientSecret) {
5102
+ throw new Error("Google OAuth is not configured in the local Canonry config");
5103
+ }
4977
5104
  const project = db.select().from(projects).where(eq14(projects.id, projectId)).get();
4978
5105
  if (!project) {
4979
5106
  throw new Error(`Project not found: ${projectId}`);
4980
5107
  }
4981
- const conn = db.select().from(googleConnections).where(and5(eq14(googleConnections.domain, project.canonicalDomain), eq14(googleConnections.connectionType, "gsc"))).get();
5108
+ const conn = getGoogleConnection(opts.config, project.canonicalDomain, "gsc");
4982
5109
  if (!conn || !conn.refreshToken) {
4983
5110
  throw new Error("No GSC connection found or connection is incomplete");
4984
5111
  }
@@ -4988,13 +5115,14 @@ async function executeGscSync(db, runId, projectId, opts) {
4988
5115
  let accessToken = conn.accessToken;
4989
5116
  const expiresAt = conn.tokenExpiresAt ? new Date(conn.tokenExpiresAt).getTime() : 0;
4990
5117
  if (Date.now() > expiresAt - 5 * 60 * 1e3) {
4991
- const tokens = await refreshAccessToken(opts.googleClientId, opts.googleClientSecret, conn.refreshToken);
5118
+ const tokens = await refreshAccessToken(googleClientId, googleClientSecret, conn.refreshToken);
4992
5119
  accessToken = tokens.access_token;
4993
- db.update(googleConnections).set({
5120
+ patchGoogleConnection(opts.config, project.canonicalDomain, "gsc", {
4994
5121
  accessToken: tokens.access_token,
4995
5122
  tokenExpiresAt: new Date(Date.now() + tokens.expires_in * 1e3).toISOString(),
4996
5123
  updatedAt: (/* @__PURE__ */ new Date()).toISOString()
4997
- }).where(eq14(googleConnections.id, conn.id)).run();
5124
+ });
5125
+ saveConfig(opts.config);
4998
5126
  }
4999
5127
  const lagOffset = GSC_DATA_LAG_DAYS;
5000
5128
  const endDate = formatDate(daysAgo(lagOffset));
@@ -5007,7 +5135,7 @@ async function executeGscSync(db, runId, projectId, opts) {
5007
5135
  });
5008
5136
  console.log(`[GSC Sync] Received ${rows.length} rows`);
5009
5137
  db.delete(gscSearchData).where(
5010
- and5(
5138
+ and4(
5011
5139
  eq14(gscSearchData.projectId, projectId),
5012
5140
  sql3`${gscSearchData.date} >= ${startDate}`,
5013
5141
  sql3`${gscSearchData.date} <= ${endDate}`
@@ -5155,8 +5283,7 @@ var Scheduler = class {
5155
5283
  /** Stop all cron tasks for graceful shutdown. */
5156
5284
  stop() {
5157
5285
  for (const [projectId, task] of this.tasks) {
5158
- task.stop();
5159
- console.log(`[Scheduler] Stopped task for project ${projectId}`);
5286
+ this.stopTask(projectId, task, "Stopped");
5160
5287
  }
5161
5288
  this.tasks.clear();
5162
5289
  }
@@ -5164,7 +5291,7 @@ var Scheduler = class {
5164
5291
  upsert(projectId) {
5165
5292
  const existing = this.tasks.get(projectId);
5166
5293
  if (existing) {
5167
- existing.stop();
5294
+ this.stopTask(projectId, existing, "Stopped");
5168
5295
  this.tasks.delete(projectId);
5169
5296
  }
5170
5297
  const schedule = this.db.select().from(schedules).where(eq15(schedules.projectId, projectId)).get();
@@ -5176,11 +5303,15 @@ var Scheduler = class {
5176
5303
  remove(projectId) {
5177
5304
  const existing = this.tasks.get(projectId);
5178
5305
  if (existing) {
5179
- existing.stop();
5306
+ this.stopTask(projectId, existing, "Removed");
5180
5307
  this.tasks.delete(projectId);
5181
- console.log(`[Scheduler] Removed task for project ${projectId}`);
5182
5308
  }
5183
5309
  }
5310
+ stopTask(projectId, task, verb) {
5311
+ task.stop();
5312
+ task.destroy();
5313
+ console.log(`[Scheduler] ${verb} task for project ${projectId}`);
5314
+ }
5184
5315
  registerCronTask(schedule) {
5185
5316
  const { id: scheduleId, projectId, cronExpr, timezone } = schedule;
5186
5317
  if (!cron.validate(cronExpr)) {
@@ -5244,7 +5375,7 @@ var Scheduler = class {
5244
5375
  };
5245
5376
 
5246
5377
  // src/notifier.ts
5247
- import { eq as eq16, desc as desc3, and as and6, or as or2 } from "drizzle-orm";
5378
+ import { eq as eq16, desc as desc3, and as and5, or as or2 } from "drizzle-orm";
5248
5379
  import crypto15 from "crypto";
5249
5380
  var Notifier = class {
5250
5381
  db;
@@ -5307,7 +5438,7 @@ var Notifier = class {
5307
5438
  }
5308
5439
  computeTransitions(runId, projectId) {
5309
5440
  const recentRuns = this.db.select().from(runs).where(
5310
- and6(
5441
+ and5(
5311
5442
  eq16(runs.projectId, projectId),
5312
5443
  or2(eq16(runs.status, "completed"), eq16(runs.status, "partial"))
5313
5444
  )
@@ -5515,18 +5646,19 @@ var DEFAULT_QUOTA = {
5515
5646
  maxRequestsPerDay: 1e3
5516
5647
  };
5517
5648
  async function createServer(opts) {
5518
- const app = Fastify({
5519
- logger: {
5520
- transport: {
5521
- target: "pino-pretty",
5522
- options: {
5523
- colorize: true,
5524
- translateTime: "HH:MM:ss",
5525
- ignore: "pid,hostname,reqId",
5526
- messageFormat: "{msg} {req.method} {req.url}"
5527
- }
5649
+ const logger = opts.logger === false ? false : process.stdout.isTTY ? {
5650
+ transport: {
5651
+ target: "pino-pretty",
5652
+ options: {
5653
+ colorize: true,
5654
+ translateTime: "HH:MM:ss",
5655
+ ignore: "pid,hostname,reqId",
5656
+ messageFormat: "{msg} {req.method} {req.url}"
5528
5657
  }
5529
5658
  }
5659
+ } : true;
5660
+ const app = Fastify({
5661
+ logger
5530
5662
  });
5531
5663
  const registry = new ProviderRegistry();
5532
5664
  const providers = opts.config.providers ?? {};
@@ -5593,25 +5725,45 @@ async function createServer(opts) {
5593
5725
  configured: !!registry.get(name),
5594
5726
  quota: registry.get(name)?.config.quotaPolicy
5595
5727
  }));
5728
+ const googleSettingsSummary = {
5729
+ configured: Boolean(opts.config.google?.clientId && opts.config.google?.clientSecret)
5730
+ };
5596
5731
  const adapterMap = { gemini: geminiAdapter, openai: openaiAdapter, claude: claudeAdapter, local: localAdapter };
5597
- const googleClientId = process.env.GOOGLE_CLIENT_ID;
5598
- const googleClientSecret = process.env.GOOGLE_CLIENT_SECRET;
5599
5732
  const googleStateSecret = process.env.GOOGLE_STATE_SECRET ?? crypto16.randomBytes(32).toString("hex");
5733
+ const googleConnectionStore = {
5734
+ listConnections: (domain) => listGoogleConnections(opts.config, domain),
5735
+ getConnection: (domain, connectionType) => getGoogleConnection(opts.config, domain, connectionType),
5736
+ upsertConnection: (connection) => {
5737
+ const updated = upsertGoogleConnection(opts.config, connection);
5738
+ saveConfig(opts.config);
5739
+ return updated;
5740
+ },
5741
+ updateConnection: (domain, connectionType, patch) => {
5742
+ const updated = patchGoogleConnection(opts.config, domain, connectionType, patch);
5743
+ if (updated) saveConfig(opts.config);
5744
+ return updated;
5745
+ },
5746
+ deleteConnection: (domain, connectionType) => {
5747
+ const removed = removeGoogleConnection(opts.config, domain, connectionType);
5748
+ if (removed) saveConfig(opts.config);
5749
+ return removed;
5750
+ }
5751
+ };
5600
5752
  await app.register(apiRoutes, {
5601
5753
  db: opts.db,
5602
5754
  skipAuth: false,
5603
- googleClientId,
5604
- googleClientSecret,
5755
+ getGoogleAuthConfig: () => getGoogleAuthConfig(opts.config),
5756
+ googleConnectionStore,
5605
5757
  googleStateSecret,
5606
5758
  onGscSyncRequested: (runId, projectId, syncOpts) => {
5759
+ const { clientId: googleClientId, clientSecret: googleClientSecret } = getGoogleAuthConfig(opts.config);
5607
5760
  if (!googleClientId || !googleClientSecret) {
5608
- app.log.error("GSC sync requested but GOOGLE_CLIENT_ID/SECRET not configured");
5761
+ app.log.error("GSC sync requested but Google OAuth credentials are not configured in the local config");
5609
5762
  return;
5610
5763
  }
5611
5764
  executeGscSync(opts.db, runId, projectId, {
5612
5765
  ...syncOpts,
5613
- googleClientId,
5614
- googleClientSecret
5766
+ config: opts.config
5615
5767
  }).catch((err) => {
5616
5768
  app.log.error({ runId, err }, "GSC sync failed");
5617
5769
  });
@@ -5621,6 +5773,7 @@ async function createServer(opts) {
5621
5773
  version: PKG_VERSION
5622
5774
  },
5623
5775
  providerSummary,
5776
+ googleSettingsSummary,
5624
5777
  onRunCreated: (runId, projectId, providers2) => {
5625
5778
  jobRunner.executeRun(runId, projectId, providers2).catch((err) => {
5626
5779
  app.log.error({ runId, err }, "Job runner failed");
@@ -5665,6 +5818,17 @@ async function createServer(opts) {
5665
5818
  quota
5666
5819
  };
5667
5820
  },
5821
+ onGoogleSettingsUpdate: (clientId, clientSecret) => {
5822
+ try {
5823
+ setGoogleAuthConfig(opts.config, { clientId, clientSecret });
5824
+ saveConfig(opts.config);
5825
+ googleSettingsSummary.configured = true;
5826
+ return { ...googleSettingsSummary };
5827
+ } catch (err) {
5828
+ app.log.error({ err }, "Failed to save Google OAuth config");
5829
+ return null;
5830
+ }
5831
+ },
5668
5832
  onScheduleUpdated: (action, projectId) => {
5669
5833
  if (action === "upsert") scheduler.upsert(projectId);
5670
5834
  if (action === "delete") scheduler.remove(projectId);
@@ -5814,5 +5978,6 @@ export {
5814
5978
  isFirstRun,
5815
5979
  showFirstRunNotice,
5816
5980
  trackEvent,
5981
+ setGoogleAuthConfig,
5817
5982
  createServer
5818
5983
  };