@ainyc/canonry 1.1.0 → 1.3.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.
package/README.md CHANGED
@@ -6,6 +6,8 @@
6
6
 
7
7
  AEO (Answer Engine Optimization) is the practice of ensuring your content is accurately represented in AI-generated answers. As search shifts from links to synthesized responses, monitoring your visibility across answer engines is essential.
8
8
 
9
+ ![Canonry Dashboard](docs/images/dashboard.png)
10
+
9
11
  ## Quick Start
10
12
 
11
13
  ```bash
@@ -32,6 +34,7 @@ Open [http://localhost:4100](http://localhost:4100) to access the web dashboard.
32
34
 
33
35
  ```bash
34
36
  canonry init # Initialize config and database
37
+ canonry bootstrap # Bootstrap hosted config/database from env vars
35
38
  canonry serve # Start server (API + web dashboard)
36
39
  canonry settings # View/edit configuration
37
40
  ```
@@ -237,6 +240,73 @@ pnpm run lint
237
240
  pnpm run dev:web # Run SPA in dev mode
238
241
  ```
239
242
 
243
+ ## Docker Deployment
244
+
245
+ Canonry currently deploys as a **single Node.js service with a SQLite file on persistent disk**.
246
+
247
+ The repo includes a production `Dockerfile` and entry script. The default container entrypoint runs `canonry bootstrap` and then `canonry serve`.
248
+
249
+ ```bash
250
+ docker build -t canonry .
251
+
252
+ docker run --rm \
253
+ -p 4100:4100 \
254
+ -e PORT=4100 \
255
+ -e CANONRY_CONFIG_DIR=/data/canonry \
256
+ -e GEMINI_API_KEY=your-key \
257
+ -v canonry-data:/data \
258
+ canonry
259
+ ```
260
+
261
+ Keep the container to a single replica and mount persistent storage at `/data` so SQLite and `config.yaml` survive restarts.
262
+
263
+ No CORS configuration is required for this Docker setup. The dashboard and API are served by the same Canonry process on the same origin. CORS only becomes relevant if you split the frontend and API onto different domains.
264
+
265
+ ## Deploy on Railway or Render
266
+
267
+ Use the **repo root** as the service root. `@ainyc/canonry` depends on shared workspace packages under `packages/*`, so deploying from a subdirectory will break the build.
268
+
269
+ ### Hosted environment variables
270
+
271
+ Set at least one provider:
272
+
273
+ - `GEMINI_API_KEY`
274
+ - `OPENAI_API_KEY`
275
+ - `ANTHROPIC_API_KEY`
276
+ - `LOCAL_BASE_URL` (plus optional `LOCAL_API_KEY` and `LOCAL_MODEL`)
277
+
278
+ Set these for hosted persistence/bootstrap:
279
+
280
+ - `CANONRY_CONFIG_DIR=/data/canonry`
281
+ - Optional `CANONRY_API_KEY=cnry_...` to pin the generated API key instead of letting bootstrap create one
282
+ - Optional `CANONRY_DATABASE_PATH=/data/canonry/data.db`
283
+
284
+ The hosted bootstrap command is idempotent. It creates `config.yaml`, creates or migrates the SQLite database, and inserts the API key row the server expects.
285
+
286
+ ### Railway
287
+
288
+ Create one service from this repo using the checked-in `Dockerfile`, then attach a persistent volume mounted at `/data`.
289
+
290
+ - Add the provider and Canonry env vars in the service's **Variables** tab. Railway can also bulk import them from `.env` files or the Raw Editor.
291
+ - Leave the start command unset so Railway uses the image `ENTRYPOINT`.
292
+ - Health check: `/health`
293
+ - Recommended env: `CANONRY_CONFIG_DIR=/data/canonry`
294
+
295
+ SQLite should live on the mounted volume, so keep the service to a single instance.
296
+
297
+ ### Render
298
+
299
+ Create one **Web Service** from this repo with runtime **Docker**, then attach a persistent disk mounted at `/data`.
300
+
301
+ - Add the provider and Canonry env vars in the service's **Environment** settings or an Environment Group.
302
+ - Leave the start command unset so Render uses the image `ENTRYPOINT`.
303
+ - Health check path: `/health`
304
+ - Recommended env: `CANONRY_CONFIG_DIR=/data/canonry`
305
+
306
+ Render makes Docker service env vars available at runtime and also exposes them to Docker builds as build args. This image does not use `ARG` for provider secrets, so those values are only consumed at runtime by the entry script and Canonry process.
307
+
308
+ SQLite should live on the persistent disk, so keep the service to a single instance.
309
+
240
310
  ## Contributing
241
311
 
242
312
  Contributions are welcome. See [CONTRIBUTING.md](./CONTRIBUTING.md) for setup instructions.
@@ -10,6 +10,10 @@ import path from "path";
10
10
  import os from "os";
11
11
  import { parse, stringify } from "yaml";
12
12
  function getConfigDir() {
13
+ const override = process.env.CANONRY_CONFIG_DIR?.trim();
14
+ if (override) {
15
+ return override;
16
+ }
13
17
  return path.join(os.homedir(), ".canonry");
14
18
  }
15
19
  function getConfigPath() {
@@ -3697,13 +3701,14 @@ async function createServer(opts) {
3697
3701
  }
3698
3702
 
3699
3703
  export {
3704
+ providerQuotaPolicySchema,
3705
+ apiKeys,
3706
+ createClient,
3707
+ migrate,
3700
3708
  getConfigDir,
3701
3709
  getConfigPath,
3702
3710
  loadConfig,
3703
3711
  saveConfig,
3704
3712
  configExists,
3705
- apiKeys,
3706
- createClient,
3707
- migrate,
3708
3713
  createServer
3709
3714
  };
package/dist/cli.js CHANGED
@@ -8,17 +8,169 @@ import {
8
8
  getConfigPath,
9
9
  loadConfig,
10
10
  migrate,
11
+ providerQuotaPolicySchema,
11
12
  saveConfig
12
- } from "./chunk-W6AJ2472.js";
13
+ } from "./chunk-GUHHQZQM.js";
13
14
 
14
15
  // src/cli.ts
15
16
  import { parseArgs } from "util";
16
17
 
17
- // src/commands/init.ts
18
+ // src/commands/bootstrap.ts
18
19
  import crypto from "crypto";
20
+ import path from "path";
21
+ import { eq } from "drizzle-orm";
22
+
23
+ // ../config/src/index.ts
24
+ import { z } from "zod";
25
+ var envSchema = z.object({
26
+ DATABASE_URL: z.string().default("postgresql://aeo:aeo@postgres:5432/aeo_platform"),
27
+ API_PORT: z.coerce.number().int().positive().default(3e3),
28
+ WORKER_PORT: z.coerce.number().int().positive().default(3001),
29
+ WEB_PORT: z.coerce.number().int().positive().default(4173),
30
+ BOOTSTRAP_SECRET: z.string().default("change-me"),
31
+ // Gemini
32
+ GEMINI_API_KEY: z.string().optional(),
33
+ GEMINI_MODEL: z.string().optional(),
34
+ GEMINI_MAX_CONCURRENCY: z.coerce.number().int().positive().default(2),
35
+ GEMINI_MAX_REQUESTS_PER_MINUTE: z.coerce.number().int().positive().default(10),
36
+ GEMINI_MAX_REQUESTS_PER_DAY: z.coerce.number().int().positive().default(1e3),
37
+ // OpenAI
38
+ OPENAI_API_KEY: z.string().optional(),
39
+ OPENAI_MODEL: z.string().optional(),
40
+ OPENAI_MAX_CONCURRENCY: z.coerce.number().int().positive().default(2),
41
+ OPENAI_MAX_REQUESTS_PER_MINUTE: z.coerce.number().int().positive().default(10),
42
+ OPENAI_MAX_REQUESTS_PER_DAY: z.coerce.number().int().positive().default(1e3),
43
+ // Anthropic / Claude
44
+ ANTHROPIC_API_KEY: z.string().optional(),
45
+ ANTHROPIC_MODEL: z.string().optional(),
46
+ ANTHROPIC_MAX_CONCURRENCY: z.coerce.number().int().positive().default(2),
47
+ ANTHROPIC_MAX_REQUESTS_PER_MINUTE: z.coerce.number().int().positive().default(10),
48
+ ANTHROPIC_MAX_REQUESTS_PER_DAY: z.coerce.number().int().positive().default(1e3)
49
+ });
50
+ var bootstrapEnvSchema = z.object({
51
+ CANONRY_API_KEY: z.string().optional(),
52
+ CANONRY_API_URL: z.string().optional(),
53
+ CANONRY_DATABASE_PATH: z.string().optional(),
54
+ GEMINI_API_KEY: z.string().optional(),
55
+ GEMINI_MODEL: z.string().optional(),
56
+ OPENAI_API_KEY: z.string().optional(),
57
+ OPENAI_MODEL: z.string().optional(),
58
+ ANTHROPIC_API_KEY: z.string().optional(),
59
+ ANTHROPIC_MODEL: z.string().optional(),
60
+ LOCAL_BASE_URL: z.string().optional(),
61
+ LOCAL_API_KEY: z.string().optional(),
62
+ LOCAL_MODEL: z.string().optional()
63
+ });
64
+ function getBootstrapEnv(source) {
65
+ const parsed = bootstrapEnvSchema.parse(source);
66
+ const providers = {};
67
+ if (parsed.GEMINI_API_KEY) {
68
+ providers.gemini = {
69
+ apiKey: parsed.GEMINI_API_KEY,
70
+ model: parsed.GEMINI_MODEL || "gemini-2.5-flash",
71
+ quota: providerQuotaPolicySchema.parse({
72
+ maxConcurrency: 2,
73
+ maxRequestsPerMinute: 10,
74
+ maxRequestsPerDay: 500
75
+ })
76
+ };
77
+ }
78
+ if (parsed.OPENAI_API_KEY) {
79
+ providers.openai = {
80
+ apiKey: parsed.OPENAI_API_KEY,
81
+ model: parsed.OPENAI_MODEL || "gpt-4o",
82
+ quota: providerQuotaPolicySchema.parse({
83
+ maxConcurrency: 2,
84
+ maxRequestsPerMinute: 10,
85
+ maxRequestsPerDay: 500
86
+ })
87
+ };
88
+ }
89
+ if (parsed.ANTHROPIC_API_KEY) {
90
+ providers.claude = {
91
+ apiKey: parsed.ANTHROPIC_API_KEY,
92
+ model: parsed.ANTHROPIC_MODEL || "claude-sonnet-4-6",
93
+ quota: providerQuotaPolicySchema.parse({
94
+ maxConcurrency: 2,
95
+ maxRequestsPerMinute: 10,
96
+ maxRequestsPerDay: 500
97
+ })
98
+ };
99
+ }
100
+ if (parsed.LOCAL_BASE_URL) {
101
+ providers.local = {
102
+ baseUrl: parsed.LOCAL_BASE_URL,
103
+ apiKey: parsed.LOCAL_API_KEY,
104
+ model: parsed.LOCAL_MODEL || "llama3",
105
+ quota: providerQuotaPolicySchema.parse({
106
+ maxConcurrency: 2,
107
+ maxRequestsPerMinute: 10,
108
+ maxRequestsPerDay: 500
109
+ })
110
+ };
111
+ }
112
+ return {
113
+ apiKey: parsed.CANONRY_API_KEY,
114
+ apiUrl: parsed.CANONRY_API_URL,
115
+ databasePath: parsed.CANONRY_DATABASE_PATH,
116
+ providers
117
+ };
118
+ }
119
+
120
+ // src/commands/bootstrap.ts
121
+ async function bootstrapCommand(_opts) {
122
+ const env = getBootstrapEnv(process.env);
123
+ const providers = env.providers;
124
+ const hasProvider = providers?.gemini || providers?.openai || providers?.claude || providers?.local;
125
+ if (!hasProvider) {
126
+ throw new Error(
127
+ "Bootstrap requires at least one provider env var. Set GEMINI_API_KEY, OPENAI_API_KEY, ANTHROPIC_API_KEY, or LOCAL_BASE_URL."
128
+ );
129
+ }
130
+ const configDir = getConfigDir();
131
+ const databasePath = env.databasePath || path.join(configDir, "data.db");
132
+ const existing = configExists();
133
+ let rawApiKey;
134
+ let generatedApiKey;
135
+ if (env.apiKey) {
136
+ rawApiKey = env.apiKey;
137
+ } else if (existing) {
138
+ rawApiKey = loadConfig().apiKey;
139
+ } else {
140
+ generatedApiKey = `cnry_${crypto.randomBytes(16).toString("hex")}`;
141
+ rawApiKey = generatedApiKey;
142
+ }
143
+ const keyHash = crypto.createHash("sha256").update(rawApiKey).digest("hex");
144
+ const keyPrefix = rawApiKey.slice(0, 9);
145
+ const db = createClient(databasePath);
146
+ migrate(db);
147
+ db.delete(apiKeys).where(eq(apiKeys.name, "default")).run();
148
+ db.insert(apiKeys).values({
149
+ id: crypto.randomUUID(),
150
+ name: "default",
151
+ keyHash,
152
+ keyPrefix,
153
+ scopes: '["*"]',
154
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
155
+ }).run();
156
+ saveConfig({
157
+ apiUrl: env.apiUrl || `http://localhost:${process.env.CANONRY_PORT || "4100"}`,
158
+ database: databasePath,
159
+ apiKey: rawApiKey,
160
+ providers
161
+ });
162
+ console.log(`Bootstrap complete. Config saved to ${getConfigPath()}`);
163
+ console.log(`SQLite database path: ${databasePath}`);
164
+ if (generatedApiKey) {
165
+ console.log(`API key: ${generatedApiKey}`);
166
+ }
167
+ }
168
+
169
+ // src/commands/init.ts
170
+ import crypto2 from "crypto";
19
171
  import fs from "fs";
20
172
  import readline from "readline";
21
- import path from "path";
173
+ import path2 from "path";
22
174
  function prompt(question) {
23
175
  const rl = readline.createInterface({
24
176
  input: process.stdin,
@@ -76,14 +228,14 @@ async function initCommand(opts) {
76
228
  console.error("\nAt least one provider is required.");
77
229
  process.exit(1);
78
230
  }
79
- const rawApiKey = `cnry_${crypto.randomBytes(16).toString("hex")}`;
80
- const keyHash = crypto.createHash("sha256").update(rawApiKey).digest("hex");
231
+ const rawApiKey = `cnry_${crypto2.randomBytes(16).toString("hex")}`;
232
+ const keyHash = crypto2.createHash("sha256").update(rawApiKey).digest("hex");
81
233
  const keyPrefix = rawApiKey.slice(0, 9);
82
- const databasePath = path.join(configDir, "data.db");
234
+ const databasePath = path2.join(configDir, "data.db");
83
235
  const db = createClient(databasePath);
84
236
  migrate(db);
85
237
  db.insert(apiKeys).values({
86
- id: crypto.randomUUID(),
238
+ id: crypto2.randomUUID(),
87
239
  name: "default",
88
240
  keyHash,
89
241
  keyPrefix,
@@ -132,8 +284,8 @@ var ApiClient = class {
132
284
  this.baseUrl = baseUrl.replace(/\/$/, "") + "/api/v1";
133
285
  this.apiKey = apiKey;
134
286
  }
135
- async request(method, path2, body) {
136
- const url = `${this.baseUrl}${path2}`;
287
+ async request(method, path3, body) {
288
+ const url = `${this.baseUrl}${path3}`;
137
289
  const headers = {
138
290
  "Authorization": `Bearer ${this.apiKey}`,
139
291
  "Content-Type": "application/json"
@@ -675,6 +827,7 @@ canonry \u2014 AEO monitoring CLI
675
827
 
676
828
  Usage:
677
829
  canonry init [--force] Initialize config and database
830
+ canonry bootstrap [--force] Bootstrap config/database from env vars
678
831
  canonry serve Start the local server
679
832
  canonry project create <name> Create a project
680
833
  canonry project list List all projects
@@ -741,6 +894,11 @@ async function main() {
741
894
  await initCommand({ force: initForce });
742
895
  break;
743
896
  }
897
+ case "bootstrap": {
898
+ const bootstrapForce = args.includes("--force") || args.includes("-f");
899
+ await bootstrapCommand({ force: bootstrapForce });
900
+ break;
901
+ }
744
902
  case "serve": {
745
903
  const { values } = parseArgs({
746
904
  args: args.slice(1),
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  createServer,
3
3
  loadConfig
4
- } from "./chunk-W6AJ2472.js";
4
+ } from "./chunk-GUHHQZQM.js";
5
5
  export {
6
6
  createServer,
7
7
  loadConfig
package/package.json CHANGED
@@ -1,8 +1,18 @@
1
1
  {
2
2
  "name": "@ainyc/canonry",
3
- "version": "1.1.0",
3
+ "version": "1.3.0",
4
4
  "type": "module",
5
+ "description": "The ultimate open-source AEO monitoring tool - track how answer engines cite your domain",
5
6
  "license": "AGPL-3.0-only",
7
+ "homepage": "https://ainyc.ai",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "https://github.com/AINYC/canonry.git",
11
+ "directory": "packages/canonry"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/AINYC/canonry/issues"
15
+ },
6
16
  "bin": {
7
17
  "canonry": "./bin/canonry.mjs"
8
18
  },
@@ -42,12 +52,13 @@
42
52
  "tsup": "^8.5.1",
43
53
  "tsx": "^4.19.0",
44
54
  "@ainyc/canonry-api-routes": "0.0.0",
55
+ "@ainyc/canonry-config": "0.0.0",
45
56
  "@ainyc/canonry-contracts": "0.0.0",
46
- "@ainyc/canonry-provider-claude": "0.0.0",
47
- "@ainyc/canonry-provider-gemini": "0.0.0",
57
+ "@ainyc/canonry-db": "0.0.0",
48
58
  "@ainyc/canonry-provider-local": "0.0.0",
49
- "@ainyc/canonry-provider-openai": "0.0.0",
50
- "@ainyc/canonry-db": "0.0.0"
59
+ "@ainyc/canonry-provider-gemini": "0.0.0",
60
+ "@ainyc/canonry-provider-claude": "0.0.0",
61
+ "@ainyc/canonry-provider-openai": "0.0.0"
51
62
  },
52
63
  "scripts": {
53
64
  "build": "tsup && tsx build-web.ts",