@ainyc/canonry 1.0.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/dist/index.js ADDED
@@ -0,0 +1,8 @@
1
+ import {
2
+ createServer,
3
+ loadConfig
4
+ } from "./chunk-ONZDY6Q4.js";
5
+ export {
6
+ createServer,
7
+ loadConfig
8
+ };
package/package.json ADDED
@@ -0,0 +1,58 @@
1
+ {
2
+ "name": "@ainyc/canonry",
3
+ "version": "1.0.0",
4
+ "type": "module",
5
+ "license": "AGPL-3.0-only",
6
+ "bin": {
7
+ "canonry": "./bin/canonry.mjs"
8
+ },
9
+ "exports": {
10
+ ".": {
11
+ "types": "./src/index.ts",
12
+ "default": "./dist/index.js"
13
+ }
14
+ },
15
+ "types": "./src/index.ts",
16
+ "files": [
17
+ "bin/",
18
+ "dist/",
19
+ "assets/",
20
+ "src/"
21
+ ],
22
+ "engines": {
23
+ "node": ">=20"
24
+ },
25
+ "dependencies": {
26
+ "@anthropic-ai/sdk": "^0.78.0",
27
+ "@fastify/static": "^8.1.0",
28
+ "@google/generative-ai": "^0.24.1",
29
+ "better-sqlite3": "^12.6.2",
30
+ "drizzle-orm": "^0.45.1",
31
+ "fastify": "^5.4.0",
32
+ "node-cron": "^4.2.1",
33
+ "openai": "^4.85.0",
34
+ "pino-pretty": "^13.1.3",
35
+ "yaml": "^2.7.1",
36
+ "zod": "^4.1.12"
37
+ },
38
+ "devDependencies": {
39
+ "@types/better-sqlite3": "^7.6.13",
40
+ "@types/node-cron": "^3.0.11",
41
+ "tsup": "^8.5.1",
42
+ "tsx": "^4.19.0",
43
+ "@ainyc/canonry-api-routes": "0.0.0",
44
+ "@ainyc/canonry-contracts": "0.0.0",
45
+ "@ainyc/canonry-provider-claude": "0.0.0",
46
+ "@ainyc/canonry-provider-gemini": "0.0.0",
47
+ "@ainyc/canonry-db": "0.0.0",
48
+ "@ainyc/canonry-provider-local": "0.0.0",
49
+ "@ainyc/canonry-provider-openai": "0.0.0"
50
+ },
51
+ "scripts": {
52
+ "build": "tsup && tsx build-web.ts",
53
+ "build:web": "tsx build-web.ts",
54
+ "typecheck": "tsc --noEmit -p tsconfig.json",
55
+ "test": "tsx --test test/*.test.ts",
56
+ "lint": "eslint src/ test/"
57
+ }
58
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,470 @@
1
+ #!/usr/bin/env node --import tsx
2
+ import { parseArgs } from 'node:util'
3
+ import { initCommand } from './commands/init.js'
4
+ import { serveCommand } from './commands/serve.js'
5
+ import { createProject, listProjects, showProject, deleteProject } from './commands/project.js'
6
+ import { addKeywords, listKeywords, importKeywords } from './commands/keyword.js'
7
+ import { addCompetitors, listCompetitors } from './commands/competitor.js'
8
+ import { triggerRun, listRuns } from './commands/run.js'
9
+ import { showStatus } from './commands/status.js'
10
+ import { showEvidence } from './commands/evidence.js'
11
+ import { showHistory } from './commands/history.js'
12
+ import { applyConfig } from './commands/apply.js'
13
+ import { exportProject } from './commands/export-cmd.js'
14
+ import { showSettings, setProvider } from './commands/settings.js'
15
+ import { setSchedule, showSchedule, enableSchedule, disableSchedule, removeSchedule } from './commands/schedule.js'
16
+ import { addNotification, listNotifications, removeNotification, testNotification } from './commands/notify.js'
17
+
18
+ const USAGE = `
19
+ canonry — AEO monitoring CLI
20
+
21
+ Usage:
22
+ canonry init Initialize config and database
23
+ canonry serve Start the local server
24
+ canonry project create <name> Create a project
25
+ canonry project list List all projects
26
+ canonry project show <name> Show project details
27
+ canonry project delete <name> Delete a project
28
+ canonry keyword add <project> <kw> Add keywords to a project
29
+ canonry keyword list <project> List keywords for a project
30
+ canonry keyword import <project> <file> Import keywords from file
31
+ canonry competitor add <project> <domain> Add competitors
32
+ canonry competitor list <project> List competitors
33
+ canonry run <project> Trigger a run (all providers)
34
+ canonry run <project> --provider <name> Trigger a run for a specific provider
35
+ canonry runs <project> List runs for a project
36
+ canonry status <project> Show project summary
37
+ canonry evidence <project> Show keyword-level results
38
+ canonry history <project> Show audit trail
39
+ canonry export <project> Export project as YAML
40
+ canonry apply <file> Apply declarative config
41
+ canonry schedule set <project> Set schedule (--preset or --cron)
42
+ canonry schedule show <project> Show schedule
43
+ canonry schedule enable <project> Enable schedule
44
+ canonry schedule disable <project> Disable schedule
45
+ canonry schedule remove <project> Remove schedule
46
+ canonry notify add <project> Add webhook notification
47
+ canonry notify list <project> List notifications
48
+ canonry notify remove <project> <id> Remove notification
49
+ canonry notify test <project> <id> Send test webhook
50
+ canonry settings Show active provider and quota settings
51
+ canonry settings provider <name> Update a provider config (--api-key, --base-url, --model)
52
+ canonry --help Show this help
53
+ canonry --version Show version
54
+
55
+ Options:
56
+ --port <port> Server port (default: 4100)
57
+ --domain <domain> Canonical domain for project create
58
+ --country <code> Country code (default: US)
59
+ --language <lang> Language code (default: en)
60
+ --provider <name> Provider to use (gemini, openai, claude)
61
+ --include-results Include results in export
62
+ --preset <preset> Schedule preset (daily, weekly, twice-daily, daily@HH, weekly@DAY)
63
+ --cron <expr> Cron expression for schedule
64
+ --timezone <tz> IANA timezone for schedule (default: UTC)
65
+ --webhook <url> Webhook URL for notifications
66
+ --events <list> Comma-separated notification events
67
+ `.trim()
68
+
69
+ const VERSION = '0.1.0'
70
+
71
+ async function main() {
72
+ const args = process.argv.slice(2)
73
+
74
+ if (args.length === 0 || args.includes('--help') || args.includes('-h')) {
75
+ console.log(USAGE)
76
+ return
77
+ }
78
+
79
+ if (args.includes('--version') || args.includes('-v')) {
80
+ console.log(VERSION)
81
+ return
82
+ }
83
+
84
+ const command = args[0]!
85
+
86
+ try {
87
+ switch (command) {
88
+ case 'init':
89
+ await initCommand()
90
+ break
91
+
92
+ case 'serve': {
93
+ const { values } = parseArgs({
94
+ args: args.slice(1),
95
+ options: {
96
+ port: { type: 'string', short: 'p', default: '4100' },
97
+ },
98
+ allowPositionals: false,
99
+ })
100
+ process.env.CANONRY_PORT = values.port
101
+ await serveCommand()
102
+ break
103
+ }
104
+
105
+ case 'project': {
106
+ const subcommand = args[1]
107
+ switch (subcommand) {
108
+ case 'create': {
109
+ const name = args[2]
110
+ if (!name) {
111
+ console.error('Error: project name is required')
112
+ process.exit(1)
113
+ }
114
+ const { values } = parseArgs({
115
+ args: args.slice(3),
116
+ options: {
117
+ domain: { type: 'string', short: 'd' },
118
+ country: { type: 'string', default: 'US' },
119
+ language: { type: 'string', default: 'en' },
120
+ 'display-name': { type: 'string' },
121
+ },
122
+ allowPositionals: false,
123
+ })
124
+ await createProject(name, {
125
+ domain: values.domain ?? name,
126
+ country: values.country ?? 'US',
127
+ language: values.language ?? 'en',
128
+ displayName: values['display-name'] ?? name,
129
+ })
130
+ break
131
+ }
132
+ case 'list':
133
+ await listProjects()
134
+ break
135
+ case 'show': {
136
+ const name = args[2]
137
+ if (!name) {
138
+ console.error('Error: project name is required')
139
+ process.exit(1)
140
+ }
141
+ await showProject(name)
142
+ break
143
+ }
144
+ case 'delete': {
145
+ const name = args[2]
146
+ if (!name) {
147
+ console.error('Error: project name is required')
148
+ process.exit(1)
149
+ }
150
+ await deleteProject(name)
151
+ break
152
+ }
153
+ default:
154
+ console.error(`Unknown project subcommand: ${subcommand ?? '(none)'}`)
155
+ console.log('Available: create, list, show, delete')
156
+ process.exit(1)
157
+ }
158
+ break
159
+ }
160
+
161
+ case 'keyword': {
162
+ const subcommand = args[1]
163
+ switch (subcommand) {
164
+ case 'add': {
165
+ const project = args[2]
166
+ const kws = args.slice(3)
167
+ if (!project || kws.length === 0) {
168
+ console.error('Error: project name and at least one keyword required')
169
+ process.exit(1)
170
+ }
171
+ await addKeywords(project, kws)
172
+ break
173
+ }
174
+ case 'list': {
175
+ const project = args[2]
176
+ if (!project) {
177
+ console.error('Error: project name is required')
178
+ process.exit(1)
179
+ }
180
+ await listKeywords(project)
181
+ break
182
+ }
183
+ case 'import': {
184
+ const project = args[2]
185
+ const filePath = args[3]
186
+ if (!project || !filePath) {
187
+ console.error('Error: project name and file path required')
188
+ process.exit(1)
189
+ }
190
+ await importKeywords(project, filePath)
191
+ break
192
+ }
193
+ default:
194
+ console.error(`Unknown keyword subcommand: ${subcommand ?? '(none)'}`)
195
+ console.log('Available: add, list, import')
196
+ process.exit(1)
197
+ }
198
+ break
199
+ }
200
+
201
+ case 'competitor': {
202
+ const subcommand = args[1]
203
+ switch (subcommand) {
204
+ case 'add': {
205
+ const project = args[2]
206
+ const domains = args.slice(3)
207
+ if (!project || domains.length === 0) {
208
+ console.error('Error: project name and at least one domain required')
209
+ process.exit(1)
210
+ }
211
+ await addCompetitors(project, domains)
212
+ break
213
+ }
214
+ case 'list': {
215
+ const project = args[2]
216
+ if (!project) {
217
+ console.error('Error: project name is required')
218
+ process.exit(1)
219
+ }
220
+ await listCompetitors(project)
221
+ break
222
+ }
223
+ default:
224
+ console.error(`Unknown competitor subcommand: ${subcommand ?? '(none)'}`)
225
+ console.log('Available: add, list')
226
+ process.exit(1)
227
+ }
228
+ break
229
+ }
230
+
231
+ case 'run': {
232
+ const project = args[1]
233
+ if (!project) {
234
+ console.error('Error: project name is required')
235
+ process.exit(1)
236
+ }
237
+ const runParsed = parseArgs({
238
+ args: args.slice(2),
239
+ options: {
240
+ provider: { type: 'string' },
241
+ },
242
+ allowPositionals: false,
243
+ })
244
+ await triggerRun(project, { provider: runParsed.values.provider })
245
+ break
246
+ }
247
+
248
+ case 'runs': {
249
+ const project = args[1]
250
+ if (!project) {
251
+ console.error('Error: project name is required')
252
+ process.exit(1)
253
+ }
254
+ await listRuns(project)
255
+ break
256
+ }
257
+
258
+ case 'status': {
259
+ const project = args[1]
260
+ if (!project) {
261
+ console.error('Error: project name is required')
262
+ process.exit(1)
263
+ }
264
+ await showStatus(project)
265
+ break
266
+ }
267
+
268
+ case 'evidence': {
269
+ const project = args[1]
270
+ if (!project) {
271
+ console.error('Error: project name is required')
272
+ process.exit(1)
273
+ }
274
+ await showEvidence(project)
275
+ break
276
+ }
277
+
278
+ case 'history': {
279
+ const project = args[1]
280
+ if (!project) {
281
+ console.error('Error: project name is required')
282
+ process.exit(1)
283
+ }
284
+ await showHistory(project)
285
+ break
286
+ }
287
+
288
+ case 'export': {
289
+ const project = args[1]
290
+ if (!project) {
291
+ console.error('Error: project name is required')
292
+ process.exit(1)
293
+ }
294
+ const includeResults = args.includes('--include-results')
295
+ await exportProject(project, { includeResults })
296
+ break
297
+ }
298
+
299
+ case 'apply': {
300
+ const filePath = args[1]
301
+ if (!filePath) {
302
+ console.error('Error: file path is required')
303
+ process.exit(1)
304
+ }
305
+ await applyConfig(filePath)
306
+ break
307
+ }
308
+
309
+ case 'schedule': {
310
+ const subcommand = args[1]
311
+ const schedProject = args[2]
312
+ if (!schedProject && subcommand !== undefined) {
313
+ console.error('Error: project name is required')
314
+ process.exit(1)
315
+ }
316
+ switch (subcommand) {
317
+ case 'set': {
318
+ const { values } = parseArgs({
319
+ args: args.slice(3),
320
+ options: {
321
+ preset: { type: 'string' },
322
+ cron: { type: 'string' },
323
+ timezone: { type: 'string' },
324
+ provider: { type: 'string', multiple: true },
325
+ },
326
+ allowPositionals: false,
327
+ })
328
+ if (!values.preset && !values.cron) {
329
+ console.error('Error: --preset or --cron is required')
330
+ process.exit(1)
331
+ }
332
+ await setSchedule(schedProject!, {
333
+ preset: values.preset,
334
+ cron: values.cron,
335
+ timezone: values.timezone,
336
+ providers: values.provider,
337
+ })
338
+ break
339
+ }
340
+ case 'show':
341
+ await showSchedule(schedProject!)
342
+ break
343
+ case 'enable':
344
+ await enableSchedule(schedProject!)
345
+ break
346
+ case 'disable':
347
+ await disableSchedule(schedProject!)
348
+ break
349
+ case 'remove':
350
+ await removeSchedule(schedProject!)
351
+ break
352
+ default:
353
+ console.error(`Unknown schedule subcommand: ${subcommand ?? '(none)'}`)
354
+ console.log('Available: set, show, enable, disable, remove')
355
+ process.exit(1)
356
+ }
357
+ break
358
+ }
359
+
360
+ case 'notify': {
361
+ const notifSubcommand = args[1]
362
+ const notifProject = args[2]
363
+ if (!notifProject && notifSubcommand !== undefined) {
364
+ console.error('Error: project name is required')
365
+ process.exit(1)
366
+ }
367
+ switch (notifSubcommand) {
368
+ case 'add': {
369
+ const { values } = parseArgs({
370
+ args: args.slice(3),
371
+ options: {
372
+ webhook: { type: 'string' },
373
+ events: { type: 'string' },
374
+ },
375
+ allowPositionals: false,
376
+ })
377
+ if (!values.webhook) {
378
+ console.error('Error: --webhook is required')
379
+ process.exit(1)
380
+ }
381
+ if (!values.events) {
382
+ console.error('Error: --events is required (comma-separated)')
383
+ process.exit(1)
384
+ }
385
+ await addNotification(notifProject!, {
386
+ webhook: values.webhook,
387
+ events: values.events.split(',').map(e => e.trim()),
388
+ })
389
+ break
390
+ }
391
+ case 'list':
392
+ await listNotifications(notifProject!)
393
+ break
394
+ case 'remove': {
395
+ const notifId = args[3]
396
+ if (!notifId) {
397
+ console.error('Error: notification ID is required')
398
+ process.exit(1)
399
+ }
400
+ await removeNotification(notifProject!, notifId)
401
+ break
402
+ }
403
+ case 'test': {
404
+ const testId = args[3]
405
+ if (!testId) {
406
+ console.error('Error: notification ID is required')
407
+ process.exit(1)
408
+ }
409
+ await testNotification(notifProject!, testId)
410
+ break
411
+ }
412
+ default:
413
+ console.error(`Unknown notify subcommand: ${notifSubcommand ?? '(none)'}`)
414
+ console.log('Available: add, list, remove, test')
415
+ process.exit(1)
416
+ }
417
+ break
418
+ }
419
+
420
+ case 'settings': {
421
+ const subcommand = args[1]
422
+ if (subcommand === 'provider') {
423
+ const name = args[2]
424
+ if (!name) {
425
+ console.error('Error: provider name is required (gemini, openai, claude, local)')
426
+ process.exit(1)
427
+ }
428
+ const { values } = parseArgs({
429
+ args: args.slice(3),
430
+ options: {
431
+ 'api-key': { type: 'string' },
432
+ 'base-url': { type: 'string' },
433
+ model: { type: 'string' },
434
+ },
435
+ allowPositionals: false,
436
+ })
437
+ if (name === 'local') {
438
+ if (!values['base-url']) {
439
+ console.error('Error: --base-url is required for the local provider')
440
+ process.exit(1)
441
+ }
442
+ } else {
443
+ if (!values['api-key']) {
444
+ console.error('Error: --api-key is required')
445
+ process.exit(1)
446
+ }
447
+ }
448
+ await setProvider(name, { apiKey: values['api-key'], baseUrl: values['base-url'], model: values.model })
449
+ } else {
450
+ await showSettings()
451
+ }
452
+ break
453
+ }
454
+
455
+ default:
456
+ console.error(`Unknown command: ${command}`)
457
+ console.log('Run "canonry --help" for usage.')
458
+ process.exit(1)
459
+ }
460
+ } catch (err: unknown) {
461
+ if (err instanceof Error) {
462
+ console.error(`Error: ${err.message}`)
463
+ } else {
464
+ console.error('An unexpected error occurred')
465
+ }
466
+ process.exit(1)
467
+ }
468
+ }
469
+
470
+ main()
package/src/client.ts ADDED
@@ -0,0 +1,152 @@
1
+ export class ApiClient {
2
+ private baseUrl: string
3
+ private apiKey: string
4
+
5
+ constructor(baseUrl: string, apiKey: string) {
6
+ this.baseUrl = baseUrl.replace(/\/$/, '') + '/api/v1'
7
+ this.apiKey = apiKey
8
+ }
9
+
10
+ private async request<T>(method: string, path: string, body?: unknown): Promise<T> {
11
+ const url = `${this.baseUrl}${path}`
12
+ const headers: Record<string, string> = {
13
+ 'Authorization': `Bearer ${this.apiKey}`,
14
+ 'Content-Type': 'application/json',
15
+ }
16
+
17
+ const res = await fetch(url, {
18
+ method,
19
+ headers,
20
+ body: body != null ? JSON.stringify(body) : undefined,
21
+ })
22
+
23
+ if (!res.ok) {
24
+ let errorBody: unknown
25
+ try {
26
+ errorBody = await res.json()
27
+ } catch {
28
+ errorBody = { error: { code: 'UNKNOWN', message: res.statusText } }
29
+ }
30
+ const msg =
31
+ errorBody &&
32
+ typeof errorBody === 'object' &&
33
+ 'error' in errorBody &&
34
+ errorBody.error &&
35
+ typeof errorBody.error === 'object' &&
36
+ 'message' in errorBody.error
37
+ ? String((errorBody.error as { message: string }).message)
38
+ : `HTTP ${res.status}: ${res.statusText}`
39
+ throw new Error(msg)
40
+ }
41
+
42
+ if (res.status === 204) {
43
+ return undefined as T
44
+ }
45
+
46
+ return (await res.json()) as T
47
+ }
48
+
49
+ async putProject(name: string, body: object): Promise<object> {
50
+ return this.request<object>('PUT', `/projects/${encodeURIComponent(name)}`, body)
51
+ }
52
+
53
+ async listProjects(): Promise<object[]> {
54
+ return this.request<object[]>('GET', '/projects')
55
+ }
56
+
57
+ async getProject(name: string): Promise<object> {
58
+ return this.request<object>('GET', `/projects/${encodeURIComponent(name)}`)
59
+ }
60
+
61
+ async deleteProject(name: string): Promise<void> {
62
+ await this.request<void>('DELETE', `/projects/${encodeURIComponent(name)}`)
63
+ }
64
+
65
+ async putKeywords(project: string, keywords: string[]): Promise<void> {
66
+ await this.request<unknown>('PUT', `/projects/${encodeURIComponent(project)}/keywords`, { keywords })
67
+ }
68
+
69
+ async listKeywords(project: string): Promise<object[]> {
70
+ return this.request<object[]>('GET', `/projects/${encodeURIComponent(project)}/keywords`)
71
+ }
72
+
73
+ async appendKeywords(project: string, keywords: string[]): Promise<void> {
74
+ await this.request<unknown>('POST', `/projects/${encodeURIComponent(project)}/keywords`, { keywords })
75
+ }
76
+
77
+ async putCompetitors(project: string, competitors: string[]): Promise<void> {
78
+ await this.request<unknown>('PUT', `/projects/${encodeURIComponent(project)}/competitors`, { competitors })
79
+ }
80
+
81
+ async listCompetitors(project: string): Promise<object[]> {
82
+ return this.request<object[]>('GET', `/projects/${encodeURIComponent(project)}/competitors`)
83
+ }
84
+
85
+ async triggerRun(project: string, body?: Record<string, unknown>): Promise<object> {
86
+ return this.request<object>('POST', `/projects/${encodeURIComponent(project)}/runs`, body ?? {})
87
+ }
88
+
89
+ async listRuns(project: string): Promise<object[]> {
90
+ return this.request<object[]>('GET', `/projects/${encodeURIComponent(project)}/runs`)
91
+ }
92
+
93
+ async getRun(id: string): Promise<object> {
94
+ return this.request<object>('GET', `/runs/${encodeURIComponent(id)}`)
95
+ }
96
+
97
+ async getTimeline(project: string): Promise<object[]> {
98
+ return this.request<object[]>('GET', `/projects/${encodeURIComponent(project)}/timeline`)
99
+ }
100
+
101
+ async getHistory(project: string): Promise<object[]> {
102
+ return this.request<object[]>('GET', `/projects/${encodeURIComponent(project)}/history`)
103
+ }
104
+
105
+ async getExport(project: string): Promise<object> {
106
+ return this.request<object>('GET', `/projects/${encodeURIComponent(project)}/export`)
107
+ }
108
+
109
+ async apply(config: object): Promise<object> {
110
+ return this.request<object>('POST', '/apply', config)
111
+ }
112
+
113
+ async getStatus(project: string): Promise<object> {
114
+ return this.request<object>('GET', `/projects/${encodeURIComponent(project)}`)
115
+ }
116
+
117
+ async getSettings(): Promise<object> {
118
+ return this.request<object>('GET', '/settings')
119
+ }
120
+
121
+ async updateProvider(name: string, body: { apiKey?: string; baseUrl?: string; model?: string }): Promise<object> {
122
+ return this.request<object>('PUT', `/settings/providers/${encodeURIComponent(name)}`, body)
123
+ }
124
+
125
+ async putSchedule(project: string, body: object): Promise<object> {
126
+ return this.request<object>('PUT', `/projects/${encodeURIComponent(project)}/schedule`, body)
127
+ }
128
+
129
+ async getSchedule(project: string): Promise<object> {
130
+ return this.request<object>('GET', `/projects/${encodeURIComponent(project)}/schedule`)
131
+ }
132
+
133
+ async deleteSchedule(project: string): Promise<void> {
134
+ await this.request<void>('DELETE', `/projects/${encodeURIComponent(project)}/schedule`)
135
+ }
136
+
137
+ async createNotification(project: string, body: object): Promise<object> {
138
+ return this.request<object>('POST', `/projects/${encodeURIComponent(project)}/notifications`, body)
139
+ }
140
+
141
+ async listNotifications(project: string): Promise<object[]> {
142
+ return this.request<object[]>('GET', `/projects/${encodeURIComponent(project)}/notifications`)
143
+ }
144
+
145
+ async deleteNotification(project: string, id: string): Promise<void> {
146
+ await this.request<void>('DELETE', `/projects/${encodeURIComponent(project)}/notifications/${encodeURIComponent(id)}`)
147
+ }
148
+
149
+ async testNotification(project: string, id: string): Promise<object> {
150
+ return this.request<object>('POST', `/projects/${encodeURIComponent(project)}/notifications/${encodeURIComponent(id)}/test`)
151
+ }
152
+ }