@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/LICENSE +661 -0
- package/assets/assets/index-CkNSldWM.css +1 -0
- package/assets/assets/index-DHoyZdlF.js +63 -0
- package/assets/index.html +17 -0
- package/bin/canonry.mjs +2 -0
- package/dist/chunk-ONZDY6Q4.js +3706 -0
- package/dist/cli.js +1101 -0
- package/dist/index.js +8 -0
- package/package.json +58 -0
- package/src/cli.ts +470 -0
- package/src/client.ts +152 -0
- package/src/commands/apply.ts +25 -0
- package/src/commands/competitor.ts +36 -0
- package/src/commands/evidence.ts +41 -0
- package/src/commands/export-cmd.ts +40 -0
- package/src/commands/history.ts +41 -0
- package/src/commands/init.ts +122 -0
- package/src/commands/keyword.ts +54 -0
- package/src/commands/notify.ts +70 -0
- package/src/commands/project.ts +89 -0
- package/src/commands/run.ts +54 -0
- package/src/commands/schedule.ts +90 -0
- package/src/commands/serve.ts +24 -0
- package/src/commands/settings.ts +45 -0
- package/src/commands/status.ts +52 -0
- package/src/config.ts +90 -0
- package/src/index.ts +2 -0
- package/src/job-runner.ts +368 -0
- package/src/notifier.ts +227 -0
- package/src/provider-registry.ts +55 -0
- package/src/scheduler.ts +161 -0
- package/src/server.ts +249 -0
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import { parse } from 'yaml'
|
|
3
|
+
import { loadConfig } from '../config.js'
|
|
4
|
+
import { ApiClient } from '../client.js'
|
|
5
|
+
|
|
6
|
+
export async function applyConfig(filePath: string): Promise<void> {
|
|
7
|
+
if (!fs.existsSync(filePath)) {
|
|
8
|
+
throw new Error(`File not found: ${filePath}`)
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const content = fs.readFileSync(filePath, 'utf-8')
|
|
12
|
+
const config = parse(content) as object
|
|
13
|
+
|
|
14
|
+
const clientConfig = loadConfig()
|
|
15
|
+
const client = new ApiClient(clientConfig.apiUrl, clientConfig.apiKey)
|
|
16
|
+
|
|
17
|
+
const result = await client.apply(config) as {
|
|
18
|
+
id: string
|
|
19
|
+
name: string
|
|
20
|
+
displayName: string
|
|
21
|
+
configRevision: number
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
console.log(`Applied config for "${result.name}" (revision ${result.configRevision})`)
|
|
25
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { loadConfig } from '../config.js'
|
|
2
|
+
import { ApiClient } from '../client.js'
|
|
3
|
+
|
|
4
|
+
function getClient(): ApiClient {
|
|
5
|
+
const config = loadConfig()
|
|
6
|
+
return new ApiClient(config.apiUrl, config.apiKey)
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export async function addCompetitors(project: string, domains: string[]): Promise<void> {
|
|
10
|
+
// First get existing competitors, then put the combined list
|
|
11
|
+
const client = getClient()
|
|
12
|
+
const existing = await client.listCompetitors(project) as Array<{ domain: string }>
|
|
13
|
+
const existingDomains = existing.map(c => c.domain)
|
|
14
|
+
const allDomains = [...new Set([...existingDomains, ...domains])]
|
|
15
|
+
await client.putCompetitors(project, allDomains)
|
|
16
|
+
console.log(`Added ${domains.length} competitor(s) to "${project}".`)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function listCompetitors(project: string): Promise<void> {
|
|
20
|
+
const client = getClient()
|
|
21
|
+
const comps = await client.listCompetitors(project) as Array<{
|
|
22
|
+
id: string
|
|
23
|
+
domain: string
|
|
24
|
+
createdAt: string
|
|
25
|
+
}>
|
|
26
|
+
|
|
27
|
+
if (comps.length === 0) {
|
|
28
|
+
console.log(`No competitors found for "${project}".`)
|
|
29
|
+
return
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
console.log(`Competitors for "${project}" (${comps.length}):\n`)
|
|
33
|
+
for (const c of comps) {
|
|
34
|
+
console.log(` ${c.domain}`)
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { loadConfig } from '../config.js'
|
|
2
|
+
import { ApiClient } from '../client.js'
|
|
3
|
+
|
|
4
|
+
type TimelineEntry = {
|
|
5
|
+
keyword: string
|
|
6
|
+
runs: {
|
|
7
|
+
runId: string
|
|
8
|
+
createdAt: string
|
|
9
|
+
citationState: string
|
|
10
|
+
transition: string
|
|
11
|
+
}[]
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function getClient(): ApiClient {
|
|
15
|
+
const config = loadConfig()
|
|
16
|
+
return new ApiClient(config.apiUrl, config.apiKey)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function showEvidence(project: string): Promise<void> {
|
|
20
|
+
const client = getClient()
|
|
21
|
+
const timeline = await client.getTimeline(project) as TimelineEntry[]
|
|
22
|
+
|
|
23
|
+
if (timeline.length === 0) {
|
|
24
|
+
console.log('No keyword evidence yet. Trigger a run first with "canonry run".')
|
|
25
|
+
return
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
console.log(`Evidence: ${project}\n`)
|
|
29
|
+
|
|
30
|
+
for (const entry of timeline) {
|
|
31
|
+
const latest = entry.runs[entry.runs.length - 1]
|
|
32
|
+
if (!latest) continue
|
|
33
|
+
const state = latest.citationState === 'cited' ? '✓ cited' : '✗ not-cited'
|
|
34
|
+
const transition = latest.transition !== latest.citationState ? ` (${latest.transition})` : ''
|
|
35
|
+
console.log(` ${state}${transition} ${entry.keyword}`)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
console.log(`\n Keywords: ${timeline.length}`)
|
|
39
|
+
const cited = timeline.filter(e => e.runs[e.runs.length - 1]?.citationState === 'cited').length
|
|
40
|
+
console.log(` Cited: ${cited} / ${timeline.length}`)
|
|
41
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { stringify } from 'yaml'
|
|
2
|
+
import { loadConfig } from '../config.js'
|
|
3
|
+
import { ApiClient } from '../client.js'
|
|
4
|
+
|
|
5
|
+
export async function exportProject(
|
|
6
|
+
project: string,
|
|
7
|
+
opts: { includeResults?: boolean },
|
|
8
|
+
): Promise<void> {
|
|
9
|
+
const config = loadConfig()
|
|
10
|
+
const client = new ApiClient(config.apiUrl, config.apiKey)
|
|
11
|
+
|
|
12
|
+
const data = await client.getExport(project) as {
|
|
13
|
+
apiVersion: string
|
|
14
|
+
kind: string
|
|
15
|
+
metadata: { name: string; labels: Record<string, string> }
|
|
16
|
+
spec: {
|
|
17
|
+
displayName: string
|
|
18
|
+
canonicalDomain: string
|
|
19
|
+
country: string
|
|
20
|
+
language: string
|
|
21
|
+
keywords: string[]
|
|
22
|
+
competitors: string[]
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (opts.includeResults) {
|
|
27
|
+
// Fetch latest run data and include as annotation
|
|
28
|
+
try {
|
|
29
|
+
const runs = await client.listRuns(project) as Array<{ id: string }>
|
|
30
|
+
if (runs.length > 0) {
|
|
31
|
+
const latestRun = await client.getRun(runs[runs.length - 1]!.id)
|
|
32
|
+
;(data as Record<string, unknown>).results = latestRun
|
|
33
|
+
}
|
|
34
|
+
} catch {
|
|
35
|
+
// Results not available, skip
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
console.log(stringify(data))
|
|
40
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { loadConfig } from '../config.js'
|
|
2
|
+
import { ApiClient } from '../client.js'
|
|
3
|
+
|
|
4
|
+
function getClient(): ApiClient {
|
|
5
|
+
const config = loadConfig()
|
|
6
|
+
return new ApiClient(config.apiUrl, config.apiKey)
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export async function showHistory(project: string): Promise<void> {
|
|
10
|
+
const client = getClient()
|
|
11
|
+
|
|
12
|
+
try {
|
|
13
|
+
const entries = await client.getHistory(project) as Array<{
|
|
14
|
+
id: string
|
|
15
|
+
actor: string
|
|
16
|
+
action: string
|
|
17
|
+
entityType: string
|
|
18
|
+
entityId: string | null
|
|
19
|
+
createdAt: string
|
|
20
|
+
}>
|
|
21
|
+
|
|
22
|
+
if (entries.length === 0) {
|
|
23
|
+
console.log(`No audit history for "${project}".`)
|
|
24
|
+
return
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
console.log(`Audit history for "${project}" (${entries.length}):\n`)
|
|
28
|
+
console.log(' TIMESTAMP ACTION ENTITY TYPE ACTOR')
|
|
29
|
+
console.log(' ─────────────────────── ────────────────── ─────────── ─────')
|
|
30
|
+
|
|
31
|
+
for (const entry of entries) {
|
|
32
|
+
console.log(
|
|
33
|
+
` ${entry.createdAt.padEnd(23)} ${entry.action.padEnd(18)} ${entry.entityType.padEnd(11)} ${entry.actor}`,
|
|
34
|
+
)
|
|
35
|
+
}
|
|
36
|
+
} catch (err: unknown) {
|
|
37
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
38
|
+
console.error(`Failed to fetch history: ${message}`)
|
|
39
|
+
process.exit(1)
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import crypto from 'node:crypto'
|
|
2
|
+
import fs from 'node:fs'
|
|
3
|
+
import readline from 'node:readline'
|
|
4
|
+
import { getConfigDir, getConfigPath, configExists, saveConfig } from '../config.js'
|
|
5
|
+
import type { CanonryConfig, ProviderConfigEntry } from '../config.js'
|
|
6
|
+
import { createClient, migrate } from '@ainyc/canonry-db'
|
|
7
|
+
import { apiKeys } from '@ainyc/canonry-db'
|
|
8
|
+
import path from 'node:path'
|
|
9
|
+
|
|
10
|
+
function prompt(question: string): Promise<string> {
|
|
11
|
+
const rl = readline.createInterface({
|
|
12
|
+
input: process.stdin,
|
|
13
|
+
output: process.stdout,
|
|
14
|
+
})
|
|
15
|
+
return new Promise(resolve => {
|
|
16
|
+
rl.question(question, answer => {
|
|
17
|
+
rl.close()
|
|
18
|
+
resolve(answer.trim())
|
|
19
|
+
})
|
|
20
|
+
})
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const DEFAULT_QUOTA = {
|
|
24
|
+
maxConcurrency: 2,
|
|
25
|
+
maxRequestsPerMinute: 10,
|
|
26
|
+
maxRequestsPerDay: 500,
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function initCommand(): Promise<void> {
|
|
30
|
+
console.log('Initializing canonry...\n')
|
|
31
|
+
|
|
32
|
+
if (configExists()) {
|
|
33
|
+
console.log(`Config already exists at ${getConfigPath()}`)
|
|
34
|
+
console.log('To reinitialize, delete the config file first.')
|
|
35
|
+
return
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Create config directory
|
|
39
|
+
const configDir = getConfigDir()
|
|
40
|
+
if (!fs.existsSync(configDir)) {
|
|
41
|
+
fs.mkdirSync(configDir, { recursive: true })
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Prompt for provider API keys
|
|
45
|
+
const providers: CanonryConfig['providers'] = {}
|
|
46
|
+
|
|
47
|
+
console.log('Configure AI providers (at least one required):\n')
|
|
48
|
+
|
|
49
|
+
// Gemini
|
|
50
|
+
const geminiApiKey = await prompt('Gemini API key (press Enter to skip): ')
|
|
51
|
+
if (geminiApiKey) {
|
|
52
|
+
const geminiModel = await prompt(' Gemini model [gemini-2.5-flash]: ') || 'gemini-2.5-flash'
|
|
53
|
+
providers.gemini = { apiKey: geminiApiKey, model: geminiModel, quota: DEFAULT_QUOTA }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// OpenAI
|
|
57
|
+
const openaiApiKey = await prompt('OpenAI API key (press Enter to skip): ')
|
|
58
|
+
if (openaiApiKey) {
|
|
59
|
+
const openaiModel = await prompt(' OpenAI model [gpt-4o]: ') || 'gpt-4o'
|
|
60
|
+
providers.openai = { apiKey: openaiApiKey, model: openaiModel, quota: DEFAULT_QUOTA }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Claude
|
|
64
|
+
const claudeApiKey = await prompt('Anthropic API key (press Enter to skip): ')
|
|
65
|
+
if (claudeApiKey) {
|
|
66
|
+
const claudeModel = await prompt(' Claude model [claude-sonnet-4-6]: ') || 'claude-sonnet-4-6'
|
|
67
|
+
providers.claude = { apiKey: claudeApiKey, model: claudeModel, quota: DEFAULT_QUOTA }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Local LLM
|
|
71
|
+
console.log('\nLocal LLM (Ollama, LM Studio, llama.cpp, vLLM — any OpenAI-compatible API):')
|
|
72
|
+
const localBaseUrl = await prompt('Local LLM base URL (press Enter to skip, e.g. http://localhost:11434/v1): ')
|
|
73
|
+
if (localBaseUrl) {
|
|
74
|
+
const localModel = await prompt(' Model name [llama3]: ') || 'llama3'
|
|
75
|
+
const localApiKey = await prompt(' API key (press Enter if not needed): ') || undefined
|
|
76
|
+
providers.local = { baseUrl: localBaseUrl, apiKey: localApiKey, model: localModel, quota: DEFAULT_QUOTA }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Validate at least one provider
|
|
80
|
+
const hasProvider = providers.gemini || providers.openai || providers.claude || providers.local
|
|
81
|
+
if (!hasProvider) {
|
|
82
|
+
console.error('\nAt least one provider is required.')
|
|
83
|
+
process.exit(1)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Generate random API key for the local server
|
|
87
|
+
const rawApiKey = `cnry_${crypto.randomBytes(16).toString('hex')}`
|
|
88
|
+
const keyHash = crypto.createHash('sha256').update(rawApiKey).digest('hex')
|
|
89
|
+
const keyPrefix = rawApiKey.slice(0, 9)
|
|
90
|
+
|
|
91
|
+
// Database path
|
|
92
|
+
const databasePath = path.join(configDir, 'data.db')
|
|
93
|
+
|
|
94
|
+
// Create and migrate database
|
|
95
|
+
const db = createClient(databasePath)
|
|
96
|
+
migrate(db)
|
|
97
|
+
|
|
98
|
+
// Insert the API key
|
|
99
|
+
db.insert(apiKeys).values({
|
|
100
|
+
id: crypto.randomUUID(),
|
|
101
|
+
name: 'default',
|
|
102
|
+
keyHash,
|
|
103
|
+
keyPrefix,
|
|
104
|
+
scopes: '["*"]',
|
|
105
|
+
createdAt: new Date().toISOString(),
|
|
106
|
+
}).run()
|
|
107
|
+
|
|
108
|
+
// Save config
|
|
109
|
+
saveConfig({
|
|
110
|
+
apiUrl: 'http://localhost:4100',
|
|
111
|
+
database: databasePath,
|
|
112
|
+
apiKey: rawApiKey,
|
|
113
|
+
providers,
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
const providerNames = Object.keys(providers).join(', ')
|
|
117
|
+
console.log(`\nConfig saved to ${getConfigPath()}`)
|
|
118
|
+
console.log(`Database created at ${databasePath}`)
|
|
119
|
+
console.log(`API key: ${rawApiKey}`)
|
|
120
|
+
console.log(`Providers: ${providerNames}`)
|
|
121
|
+
console.log('\nRun "canonry serve" to start the server.')
|
|
122
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import { loadConfig } from '../config.js'
|
|
3
|
+
import { ApiClient } from '../client.js'
|
|
4
|
+
|
|
5
|
+
function getClient(): ApiClient {
|
|
6
|
+
const config = loadConfig()
|
|
7
|
+
return new ApiClient(config.apiUrl, config.apiKey)
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export async function addKeywords(project: string, keywords: string[]): Promise<void> {
|
|
11
|
+
const client = getClient()
|
|
12
|
+
await client.appendKeywords(project, keywords)
|
|
13
|
+
console.log(`Added ${keywords.length} keyword(s) to "${project}".`)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function listKeywords(project: string): Promise<void> {
|
|
17
|
+
const client = getClient()
|
|
18
|
+
const kws = await client.listKeywords(project) as Array<{
|
|
19
|
+
id: string
|
|
20
|
+
keyword: string
|
|
21
|
+
createdAt: string
|
|
22
|
+
}>
|
|
23
|
+
|
|
24
|
+
if (kws.length === 0) {
|
|
25
|
+
console.log(`No keywords found for "${project}".`)
|
|
26
|
+
return
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
console.log(`Keywords for "${project}" (${kws.length}):\n`)
|
|
30
|
+
for (const kw of kws) {
|
|
31
|
+
console.log(` ${kw.keyword}`)
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function importKeywords(project: string, filePath: string): Promise<void> {
|
|
36
|
+
if (!fs.existsSync(filePath)) {
|
|
37
|
+
throw new Error(`File not found: ${filePath}`)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const content = fs.readFileSync(filePath, 'utf-8')
|
|
41
|
+
const keywords = content
|
|
42
|
+
.split('\n')
|
|
43
|
+
.map(line => line.trim())
|
|
44
|
+
.filter(line => line.length > 0 && !line.startsWith('#'))
|
|
45
|
+
|
|
46
|
+
if (keywords.length === 0) {
|
|
47
|
+
console.log('No keywords found in file.')
|
|
48
|
+
return
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const client = getClient()
|
|
52
|
+
await client.appendKeywords(project, keywords)
|
|
53
|
+
console.log(`Imported ${keywords.length} keyword(s) to "${project}".`)
|
|
54
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { loadConfig } from '../config.js'
|
|
2
|
+
import { ApiClient } from '../client.js'
|
|
3
|
+
|
|
4
|
+
function getClient(): ApiClient {
|
|
5
|
+
const config = loadConfig()
|
|
6
|
+
return new ApiClient(config.apiUrl, config.apiKey)
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
interface NotificationResponse {
|
|
10
|
+
id: string
|
|
11
|
+
projectId: string
|
|
12
|
+
channel: string
|
|
13
|
+
url: string
|
|
14
|
+
events: string[]
|
|
15
|
+
enabled: boolean
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function addNotification(project: string, opts: {
|
|
19
|
+
webhook: string
|
|
20
|
+
events: string[]
|
|
21
|
+
}): Promise<void> {
|
|
22
|
+
const client = getClient()
|
|
23
|
+
const result = await client.createNotification(project, {
|
|
24
|
+
channel: 'webhook',
|
|
25
|
+
url: opts.webhook,
|
|
26
|
+
events: opts.events,
|
|
27
|
+
}) as NotificationResponse
|
|
28
|
+
|
|
29
|
+
console.log(`Notification created for "${project}":`)
|
|
30
|
+
printNotification(result)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function listNotifications(project: string): Promise<void> {
|
|
34
|
+
const client = getClient()
|
|
35
|
+
const results = await client.listNotifications(project) as NotificationResponse[]
|
|
36
|
+
if (results.length === 0) {
|
|
37
|
+
console.log(`No notifications configured for "${project}"`)
|
|
38
|
+
return
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
console.log(`Notifications for "${project}":\n`)
|
|
42
|
+
for (const n of results) {
|
|
43
|
+
printNotification(n)
|
|
44
|
+
console.log()
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function removeNotification(project: string, id: string): Promise<void> {
|
|
49
|
+
const client = getClient()
|
|
50
|
+
await client.deleteNotification(project, id)
|
|
51
|
+
console.log(`Notification ${id} removed from "${project}"`)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function testNotification(project: string, id: string): Promise<void> {
|
|
55
|
+
const client = getClient()
|
|
56
|
+
const result = await client.testNotification(project, id) as { status: number; ok: boolean }
|
|
57
|
+
if (result.ok) {
|
|
58
|
+
console.log(`Test webhook delivered successfully (HTTP ${result.status})`)
|
|
59
|
+
} else {
|
|
60
|
+
console.error(`Test webhook failed: HTTP ${result.status}`)
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function printNotification(n: NotificationResponse): void {
|
|
65
|
+
console.log(` ID: ${n.id}`)
|
|
66
|
+
console.log(` Channel: ${n.channel}`)
|
|
67
|
+
console.log(` URL: ${n.url}`)
|
|
68
|
+
console.log(` Events: ${n.events.join(', ')}`)
|
|
69
|
+
console.log(` Enabled: ${n.enabled ? 'yes' : 'no'}`)
|
|
70
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { loadConfig } from '../config.js'
|
|
2
|
+
import { ApiClient } from '../client.js'
|
|
3
|
+
|
|
4
|
+
function getClient(): ApiClient {
|
|
5
|
+
const config = loadConfig()
|
|
6
|
+
return new ApiClient(config.apiUrl, config.apiKey)
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export async function createProject(
|
|
10
|
+
name: string,
|
|
11
|
+
opts: { domain: string; country: string; language: string; displayName: string },
|
|
12
|
+
): Promise<void> {
|
|
13
|
+
const client = getClient()
|
|
14
|
+
const result = await client.putProject(name, {
|
|
15
|
+
displayName: opts.displayName,
|
|
16
|
+
canonicalDomain: opts.domain,
|
|
17
|
+
country: opts.country,
|
|
18
|
+
language: opts.language,
|
|
19
|
+
}) as { id: string; name: string }
|
|
20
|
+
console.log(`Project created: ${result.name} (${result.id})`)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function listProjects(): Promise<void> {
|
|
24
|
+
const client = getClient()
|
|
25
|
+
const projects = await client.listProjects() as Array<{
|
|
26
|
+
name: string
|
|
27
|
+
canonicalDomain: string
|
|
28
|
+
country: string
|
|
29
|
+
language: string
|
|
30
|
+
}>
|
|
31
|
+
|
|
32
|
+
if (projects.length === 0) {
|
|
33
|
+
console.log('No projects found.')
|
|
34
|
+
return
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
console.log('Projects:\n')
|
|
38
|
+
const nameWidth = Math.max(4, ...projects.map(p => p.name.length))
|
|
39
|
+
const domainWidth = Math.max(6, ...projects.map(p => p.canonicalDomain.length))
|
|
40
|
+
|
|
41
|
+
console.log(
|
|
42
|
+
` ${'NAME'.padEnd(nameWidth)} ${'DOMAIN'.padEnd(domainWidth)} COUNTRY LANGUAGE`,
|
|
43
|
+
)
|
|
44
|
+
console.log(` ${'─'.repeat(nameWidth)} ${'─'.repeat(domainWidth)} ─────── ────────`)
|
|
45
|
+
|
|
46
|
+
for (const p of projects) {
|
|
47
|
+
console.log(
|
|
48
|
+
` ${p.name.padEnd(nameWidth)} ${p.canonicalDomain.padEnd(domainWidth)} ${p.country.padEnd(7)} ${p.language}`,
|
|
49
|
+
)
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function showProject(name: string): Promise<void> {
|
|
54
|
+
const client = getClient()
|
|
55
|
+
const project = await client.getProject(name) as {
|
|
56
|
+
id: string
|
|
57
|
+
name: string
|
|
58
|
+
displayName: string
|
|
59
|
+
canonicalDomain: string
|
|
60
|
+
country: string
|
|
61
|
+
language: string
|
|
62
|
+
tags: string[]
|
|
63
|
+
labels: Record<string, string>
|
|
64
|
+
configSource: string
|
|
65
|
+
configRevision: number
|
|
66
|
+
createdAt: string
|
|
67
|
+
updatedAt: string
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
console.log(`Project: ${project.displayName}\n`)
|
|
71
|
+
console.log(` Name: ${project.name}`)
|
|
72
|
+
console.log(` ID: ${project.id}`)
|
|
73
|
+
console.log(` Domain: ${project.canonicalDomain}`)
|
|
74
|
+
console.log(` Country: ${project.country}`)
|
|
75
|
+
console.log(` Language: ${project.language}`)
|
|
76
|
+
console.log(` Config source: ${project.configSource}`)
|
|
77
|
+
console.log(` Config revision: ${project.configRevision}`)
|
|
78
|
+
console.log(` Tags: ${project.tags.length > 0 ? project.tags.join(', ') : '(none)'}`)
|
|
79
|
+
const labelEntries = Object.entries(project.labels)
|
|
80
|
+
console.log(` Labels: ${labelEntries.length > 0 ? labelEntries.map(([k, v]) => `${k}=${v}`).join(', ') : '(none)'}`)
|
|
81
|
+
console.log(` Created: ${project.createdAt}`)
|
|
82
|
+
console.log(` Updated: ${project.updatedAt}`)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export async function deleteProject(name: string): Promise<void> {
|
|
86
|
+
const client = getClient()
|
|
87
|
+
await client.deleteProject(name)
|
|
88
|
+
console.log(`Project deleted: ${name}`)
|
|
89
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { loadConfig } from '../config.js'
|
|
2
|
+
import { ApiClient } from '../client.js'
|
|
3
|
+
|
|
4
|
+
function getClient(): ApiClient {
|
|
5
|
+
const config = loadConfig()
|
|
6
|
+
return new ApiClient(config.apiUrl, config.apiKey)
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export async function triggerRun(project: string, opts?: { provider?: string }): Promise<void> {
|
|
10
|
+
const client = getClient()
|
|
11
|
+
const body: Record<string, unknown> = {}
|
|
12
|
+
if (opts?.provider) {
|
|
13
|
+
body.providers = [opts.provider]
|
|
14
|
+
}
|
|
15
|
+
const run = await client.triggerRun(project, body) as {
|
|
16
|
+
id: string
|
|
17
|
+
status: string
|
|
18
|
+
kind: string
|
|
19
|
+
}
|
|
20
|
+
console.log(`Run created: ${run.id}`)
|
|
21
|
+
console.log(` Kind: ${run.kind}`)
|
|
22
|
+
console.log(` Status: ${run.status}`)
|
|
23
|
+
if (opts?.provider) {
|
|
24
|
+
console.log(` Provider: ${opts.provider}`)
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function listRuns(project: string): Promise<void> {
|
|
29
|
+
const client = getClient()
|
|
30
|
+
const runs = await client.listRuns(project) as Array<{
|
|
31
|
+
id: string
|
|
32
|
+
status: string
|
|
33
|
+
kind: string
|
|
34
|
+
trigger: string
|
|
35
|
+
startedAt: string | null
|
|
36
|
+
finishedAt: string | null
|
|
37
|
+
createdAt: string
|
|
38
|
+
}>
|
|
39
|
+
|
|
40
|
+
if (runs.length === 0) {
|
|
41
|
+
console.log(`No runs found for "${project}".`)
|
|
42
|
+
return
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
console.log(`Runs for "${project}" (${runs.length}):\n`)
|
|
46
|
+
console.log(' ID STATUS KIND TRIGGER CREATED')
|
|
47
|
+
console.log(' ──────────────────────────────────── ────────── ────────────────── ───────── ───────────────────────')
|
|
48
|
+
|
|
49
|
+
for (const run of runs) {
|
|
50
|
+
console.log(
|
|
51
|
+
` ${run.id} ${run.status.padEnd(10)} ${run.kind.padEnd(18)} ${run.trigger.padEnd(9)} ${run.createdAt}`,
|
|
52
|
+
)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { loadConfig } from '../config.js'
|
|
2
|
+
import { ApiClient } from '../client.js'
|
|
3
|
+
|
|
4
|
+
function getClient(): ApiClient {
|
|
5
|
+
const config = loadConfig()
|
|
6
|
+
return new ApiClient(config.apiUrl, config.apiKey)
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
interface ScheduleResponse {
|
|
10
|
+
id: string
|
|
11
|
+
projectId: string
|
|
12
|
+
cronExpr: string
|
|
13
|
+
preset: string | null
|
|
14
|
+
timezone: string
|
|
15
|
+
enabled: boolean
|
|
16
|
+
providers: string[]
|
|
17
|
+
lastRunAt: string | null
|
|
18
|
+
nextRunAt: string | null
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function setSchedule(project: string, opts: {
|
|
22
|
+
preset?: string
|
|
23
|
+
cron?: string
|
|
24
|
+
timezone?: string
|
|
25
|
+
providers?: string[]
|
|
26
|
+
}): Promise<void> {
|
|
27
|
+
const client = getClient()
|
|
28
|
+
const body: Record<string, unknown> = {}
|
|
29
|
+
if (opts.preset) body.preset = opts.preset
|
|
30
|
+
if (opts.cron) body.cron = opts.cron
|
|
31
|
+
if (opts.timezone) body.timezone = opts.timezone
|
|
32
|
+
if (opts.providers?.length) body.providers = opts.providers
|
|
33
|
+
|
|
34
|
+
const result = await client.putSchedule(project, body) as ScheduleResponse
|
|
35
|
+
console.log(`Schedule set for "${project}":`)
|
|
36
|
+
printSchedule(result)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function showSchedule(project: string): Promise<void> {
|
|
40
|
+
const client = getClient()
|
|
41
|
+
const result = await client.getSchedule(project) as ScheduleResponse
|
|
42
|
+
printSchedule(result)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function enableSchedule(project: string): Promise<void> {
|
|
46
|
+
const client = getClient()
|
|
47
|
+
const current = await client.getSchedule(project) as ScheduleResponse
|
|
48
|
+
const body: Record<string, unknown> = { timezone: current.timezone }
|
|
49
|
+
if (current.preset) body.preset = current.preset
|
|
50
|
+
else body.cron = current.cronExpr
|
|
51
|
+
if (current.providers.length) body.providers = current.providers
|
|
52
|
+
|
|
53
|
+
await client.putSchedule(project, body)
|
|
54
|
+
console.log(`Schedule enabled for "${project}"`)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function disableSchedule(project: string): Promise<void> {
|
|
58
|
+
const client = getClient()
|
|
59
|
+
const current = await client.getSchedule(project) as ScheduleResponse
|
|
60
|
+
const body: Record<string, unknown> = { timezone: current.timezone, enabled: false }
|
|
61
|
+
if (current.preset) body.preset = current.preset
|
|
62
|
+
else body.cron = current.cronExpr
|
|
63
|
+
if (current.providers.length) body.providers = current.providers
|
|
64
|
+
|
|
65
|
+
await client.putSchedule(project, body)
|
|
66
|
+
console.log(`Schedule disabled for "${project}"`)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function removeSchedule(project: string): Promise<void> {
|
|
70
|
+
const client = getClient()
|
|
71
|
+
await client.deleteSchedule(project)
|
|
72
|
+
console.log(`Schedule removed for "${project}"`)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function printSchedule(s: ScheduleResponse): void {
|
|
76
|
+
const label = s.preset ?? s.cronExpr
|
|
77
|
+
console.log(` Schedule: ${label}`)
|
|
78
|
+
console.log(` Cron: ${s.cronExpr}`)
|
|
79
|
+
console.log(` Timezone: ${s.timezone}`)
|
|
80
|
+
console.log(` Enabled: ${s.enabled ? 'yes' : 'no'}`)
|
|
81
|
+
if (s.providers.length) {
|
|
82
|
+
console.log(` Providers: ${s.providers.join(', ')}`)
|
|
83
|
+
}
|
|
84
|
+
if (s.lastRunAt) {
|
|
85
|
+
console.log(` Last run: ${s.lastRunAt}`)
|
|
86
|
+
}
|
|
87
|
+
if (s.nextRunAt) {
|
|
88
|
+
console.log(` Next run: ${s.nextRunAt}`)
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { loadConfig } from '../config.js'
|
|
2
|
+
import { createClient, migrate } from '@ainyc/canonry-db'
|
|
3
|
+
import { createServer } from '../server.js'
|
|
4
|
+
|
|
5
|
+
export async function serveCommand(): Promise<void> {
|
|
6
|
+
const config = loadConfig()
|
|
7
|
+
const port = parseInt(process.env.CANONRY_PORT ?? '4100', 10)
|
|
8
|
+
|
|
9
|
+
// Create DB client and run migrations
|
|
10
|
+
const db = createClient(config.database)
|
|
11
|
+
migrate(db)
|
|
12
|
+
|
|
13
|
+
// Create and start server
|
|
14
|
+
const app = await createServer({ config, db })
|
|
15
|
+
|
|
16
|
+
try {
|
|
17
|
+
await app.listen({ host: '0.0.0.0', port })
|
|
18
|
+
console.log(`\nCanonry server running at http://localhost:${port}`)
|
|
19
|
+
console.log('Press Ctrl+C to stop.\n')
|
|
20
|
+
} catch (err) {
|
|
21
|
+
app.log.error(err)
|
|
22
|
+
process.exit(1)
|
|
23
|
+
}
|
|
24
|
+
}
|