kennel 0.1.8
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 +21 -0
- package/README.md +203 -0
- package/bin/kennel.mjs +3 -0
- package/dist/automations/notifications.d.ts +40 -0
- package/dist/automations/notifications.js +220 -0
- package/dist/cli/dispatch.d.ts +1 -0
- package/dist/cli/dispatch.js +51 -0
- package/dist/cli/usage.d.ts +1 -0
- package/dist/cli/usage.js +14 -0
- package/dist/deploy/cloudflare.d.ts +6 -0
- package/dist/deploy/cloudflare.js +91 -0
- package/dist/deploy/environment.d.ts +2 -0
- package/dist/deploy/environment.js +47 -0
- package/dist/deploy/smoke-tests.d.ts +3 -0
- package/dist/deploy/smoke-tests.js +71 -0
- package/dist/deploy/trigger-dev.d.ts +2 -0
- package/dist/deploy/trigger-dev.js +255 -0
- package/dist/main.d.ts +1 -0
- package/dist/main.js +8 -0
- package/dist/mcp/agent-tools.d.ts +8 -0
- package/dist/mcp/agent-tools.js +247 -0
- package/dist/mcp/config.d.ts +25 -0
- package/dist/mcp/config.js +171 -0
- package/dist/mcp/data.d.ts +14 -0
- package/dist/mcp/data.js +120 -0
- package/dist/mcp/function-source.d.ts +7 -0
- package/dist/mcp/function-source.js +40 -0
- package/dist/mcp/generate.d.ts +1 -0
- package/dist/mcp/generate.js +47 -0
- package/dist/mcp/install-page.d.ts +1 -0
- package/dist/mcp/install-page.js +360 -0
- package/dist/notifications/email-worker.d.ts +8 -0
- package/dist/notifications/email-worker.js +137 -0
- package/dist/package/mcp-data.d.ts +1 -0
- package/dist/package/mcp-data.js +1 -0
- package/dist/package/mcp-email-worker.d.ts +1 -0
- package/dist/package/mcp-email-worker.js +1 -0
- package/dist/package/mcp-http.d.ts +1 -0
- package/dist/package/mcp-http.js +1 -0
- package/dist/package/mcp-stdio.d.ts +1 -0
- package/dist/package/mcp-stdio.js +1 -0
- package/dist/package/mcp.d.ts +1 -0
- package/dist/package/mcp.js +1 -0
- package/dist/publish/cloudflare-init.d.ts +12 -0
- package/dist/publish/cloudflare-init.js +98 -0
- package/dist/publish/config.d.ts +28 -0
- package/dist/publish/config.js +190 -0
- package/dist/publish/options.d.ts +13 -0
- package/dist/publish/options.js +78 -0
- package/dist/schema/doctor.d.ts +1 -0
- package/dist/schema/doctor.js +69 -0
- package/dist/schema/documents.d.ts +18 -0
- package/dist/schema/documents.js +127 -0
- package/dist/schema/pointers.d.ts +5 -0
- package/dist/schema/pointers.js +48 -0
- package/dist/schema/types.d.ts +36 -0
- package/dist/schema/types.js +1 -0
- package/dist/schema/validation.d.ts +8 -0
- package/dist/schema/validation.js +212 -0
- package/dist/shared/json.d.ts +4 -0
- package/dist/shared/json.js +56 -0
- package/dist/shared/object.d.ts +2 -0
- package/dist/shared/object.js +3 -0
- package/dist/shared/package-manager.d.ts +11 -0
- package/dist/shared/package-manager.js +39 -0
- package/dist/shared/path.d.ts +4 -0
- package/dist/shared/path.js +40 -0
- package/dist/shared/process.d.ts +7 -0
- package/dist/shared/process.js +12 -0
- package/dist/shared/project-config.d.ts +2 -0
- package/dist/shared/project-config.js +26 -0
- package/dist/site/dev.d.ts +10 -0
- package/dist/site/dev.js +128 -0
- package/dist/site/vitepress.d.ts +21 -0
- package/dist/site/vitepress.js +66 -0
- package/package.json +74 -0
- package/templates/cloudflare/email-code-worker.js +344 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { ChildCommand } from '../shared/process.js';
|
|
2
|
+
import type { PackageJson } from '../site/vitepress.js';
|
|
3
|
+
import type { PublishOptions } from './options.js';
|
|
4
|
+
export type DopplerTokenCommand = {
|
|
5
|
+
command: string;
|
|
6
|
+
args: string[];
|
|
7
|
+
};
|
|
8
|
+
export declare function publicationConfig(config: any): Record<string, any>;
|
|
9
|
+
export declare function publicationDomains(config: any): Record<string, any>;
|
|
10
|
+
export declare function publicationAccess(config: any): Record<string, any>;
|
|
11
|
+
export declare function publicationAdminMcp(config: any): Record<string, any>;
|
|
12
|
+
export declare function publishAuthDefaults(config: any): any;
|
|
13
|
+
export declare function splitCsv(value: unknown): string[];
|
|
14
|
+
export declare function publishAuthWithWranglerFallbacks(auth: any, vars: Record<string, unknown>): any;
|
|
15
|
+
export declare function publishHostname(config: any, options: Pick<PublishOptions, 'hostname'>): string;
|
|
16
|
+
export declare function publishHostnames(config: any, options: Pick<PublishOptions, 'hostname'>): string[];
|
|
17
|
+
export declare function publishProjectName(config: any, root: string): string;
|
|
18
|
+
export declare function publishWorkerName(config: any, root: string): string;
|
|
19
|
+
export declare function publishCloudflareConfig(config: any): Record<string, any>;
|
|
20
|
+
export declare function publishAccountId(config: any): string;
|
|
21
|
+
export declare function publishDopplerTokenConfig(config: any): Record<string, any>;
|
|
22
|
+
export declare function hasDopplerTokenConfig(config: any): boolean;
|
|
23
|
+
export declare function dopplerTokenCommand(config: any): DopplerTokenCommand | null;
|
|
24
|
+
export declare function publishBuildCommand(root: string, pkg: PackageJson): ChildCommand;
|
|
25
|
+
export declare function publishWranglerCommand(root: string): string;
|
|
26
|
+
export declare function publishWranglerArgs(root: string, args: string[]): string[];
|
|
27
|
+
export declare function publishTestUrl(hostname: string): string;
|
|
28
|
+
export declare function packageJsonPath(root: string): string;
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { isObject } from '../shared/object.js';
|
|
3
|
+
import { localBin, packageManagerFor, runArgsFor } from '../shared/package-manager.js';
|
|
4
|
+
import { normalizeHostname, slugifyHostname } from '../shared/path.js';
|
|
5
|
+
export function publicationConfig(config) {
|
|
6
|
+
if (isObject(config.publication))
|
|
7
|
+
return config.publication;
|
|
8
|
+
if (isObject(config.publish))
|
|
9
|
+
return config.publish;
|
|
10
|
+
return {};
|
|
11
|
+
}
|
|
12
|
+
export function publicationDomains(config) {
|
|
13
|
+
const publication = publicationConfig(config);
|
|
14
|
+
if (isObject(publication.domains))
|
|
15
|
+
return publication.domains;
|
|
16
|
+
return {};
|
|
17
|
+
}
|
|
18
|
+
export function publicationAccess(config) {
|
|
19
|
+
const publication = publicationConfig(config);
|
|
20
|
+
if (isObject(publication.access))
|
|
21
|
+
return publication.access;
|
|
22
|
+
if (isObject(publication.auth))
|
|
23
|
+
return publication.auth;
|
|
24
|
+
return {};
|
|
25
|
+
}
|
|
26
|
+
export function publicationAdminMcp(config) {
|
|
27
|
+
const publication = publicationConfig(config);
|
|
28
|
+
if (isObject(publication.adminMcp))
|
|
29
|
+
return publication.adminMcp;
|
|
30
|
+
if (isObject(publication.adminMCP))
|
|
31
|
+
return publication.adminMCP;
|
|
32
|
+
return {};
|
|
33
|
+
}
|
|
34
|
+
export function publishAuthDefaults(config) {
|
|
35
|
+
const existing = publicationAccess(config);
|
|
36
|
+
return {
|
|
37
|
+
mode: existing.mode ?? 'email-code-worker',
|
|
38
|
+
allowedDomains: Array.isArray(existing.allowedDomains) ? existing.allowedDomains : [],
|
|
39
|
+
allowedEmails: Array.isArray(existing.allowedEmails) ? existing.allowedEmails : [],
|
|
40
|
+
fromEmail: existing.fromEmail ?? '',
|
|
41
|
+
emailSubject: existing.emailSubject ?? 'Your sign-in code',
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
export function splitCsv(value) {
|
|
45
|
+
if (typeof value !== 'string')
|
|
46
|
+
return [];
|
|
47
|
+
return value
|
|
48
|
+
.split(',')
|
|
49
|
+
.map((item) => item.trim())
|
|
50
|
+
.filter(Boolean);
|
|
51
|
+
}
|
|
52
|
+
export function publishAuthWithWranglerFallbacks(auth, vars) {
|
|
53
|
+
const allowedDomains = auth.allowedDomains.length > 0
|
|
54
|
+
? auth.allowedDomains
|
|
55
|
+
: splitCsv(vars.KENNEL_AUTH_ALLOWED_DOMAINS ?? vars.CDP_AUTH_ALLOWED_DOMAINS);
|
|
56
|
+
const allowedEmails = auth.allowedEmails.length > 0
|
|
57
|
+
? auth.allowedEmails
|
|
58
|
+
: splitCsv(vars.KENNEL_AUTH_ALLOWED_EMAILS ?? vars.CDP_AUTH_ALLOWED_EMAILS);
|
|
59
|
+
const fromEmail = auth.fromEmail
|
|
60
|
+
|| vars.KENNEL_AUTH_FROM_EMAIL
|
|
61
|
+
|| vars.CDP_AUTH_FROM_EMAIL
|
|
62
|
+
|| '';
|
|
63
|
+
const emailSubject = auth.emailSubject
|
|
64
|
+
|| vars.KENNEL_AUTH_EMAIL_SUBJECT
|
|
65
|
+
|| vars.CDP_AUTH_EMAIL_SUBJECT
|
|
66
|
+
|| 'Your sign-in code';
|
|
67
|
+
return {
|
|
68
|
+
...auth,
|
|
69
|
+
allowedDomains,
|
|
70
|
+
allowedEmails,
|
|
71
|
+
fromEmail,
|
|
72
|
+
emailSubject,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
export function publishHostname(config, options) {
|
|
76
|
+
const publication = publicationConfig(config);
|
|
77
|
+
const domains = publicationDomains(config);
|
|
78
|
+
const hostname = options.hostname
|
|
79
|
+
|| domains.primary
|
|
80
|
+
|| publication.hostname
|
|
81
|
+
|| publication.domain
|
|
82
|
+
|| publication.url;
|
|
83
|
+
if (typeof hostname !== 'string' || hostname.trim() === '')
|
|
84
|
+
return '';
|
|
85
|
+
return normalizeHostname(hostname);
|
|
86
|
+
}
|
|
87
|
+
export function publishHostnames(config, options) {
|
|
88
|
+
const publication = publicationConfig(config);
|
|
89
|
+
const domains = publicationDomains(config);
|
|
90
|
+
const adminMcp = publicationAdminMcp(config);
|
|
91
|
+
const candidates = [
|
|
92
|
+
publishHostname(config, options),
|
|
93
|
+
typeof adminMcp.hostname === 'string' ? adminMcp.hostname : undefined,
|
|
94
|
+
...(Array.isArray(domains.aliases) ? domains.aliases : []),
|
|
95
|
+
...(Array.isArray(domains.hostnames) ? domains.hostnames : []),
|
|
96
|
+
...(Array.isArray(publication.hostnames) ? publication.hostnames : []),
|
|
97
|
+
...(Array.isArray(publication.aliases) ? publication.aliases : []),
|
|
98
|
+
];
|
|
99
|
+
return [...new Set(candidates.map(normalizeHostname).filter(Boolean))];
|
|
100
|
+
}
|
|
101
|
+
export function publishProjectName(config, root) {
|
|
102
|
+
const raw = config.name ?? config.project?.name ?? config.title ?? root.split('/').pop();
|
|
103
|
+
const slug = typeof raw === 'string' ? slugifyHostname(raw) : '';
|
|
104
|
+
return slug || 'kennel-docs';
|
|
105
|
+
}
|
|
106
|
+
export function publishWorkerName(config, root) {
|
|
107
|
+
const publication = publicationConfig(config);
|
|
108
|
+
if (typeof publication.workerName === 'string' && publication.workerName.trim())
|
|
109
|
+
return publication.workerName.trim();
|
|
110
|
+
return `${publishProjectName(config, root)}-gate`;
|
|
111
|
+
}
|
|
112
|
+
export function publishCloudflareConfig(config) {
|
|
113
|
+
const publication = publicationConfig(config);
|
|
114
|
+
return isObject(publication.cloudflare) ? publication.cloudflare : {};
|
|
115
|
+
}
|
|
116
|
+
export function publishAccountId(config) {
|
|
117
|
+
const publication = publicationConfig(config);
|
|
118
|
+
const cloudflare = publishCloudflareConfig(config);
|
|
119
|
+
if (typeof publication.accountId === 'string' && publication.accountId.trim())
|
|
120
|
+
return publication.accountId.trim();
|
|
121
|
+
if (typeof cloudflare.accountId === 'string' && cloudflare.accountId.trim())
|
|
122
|
+
return cloudflare.accountId.trim();
|
|
123
|
+
if (typeof process.env.KENNEL_CLOUDFLARE_ACCOUNT_ID === 'string' && process.env.KENNEL_CLOUDFLARE_ACCOUNT_ID.trim()) {
|
|
124
|
+
return process.env.KENNEL_CLOUDFLARE_ACCOUNT_ID.trim();
|
|
125
|
+
}
|
|
126
|
+
if (typeof process.env.CLOUDFLARE_ACCOUNT_ID === 'string' && process.env.CLOUDFLARE_ACCOUNT_ID.trim()) {
|
|
127
|
+
return process.env.CLOUDFLARE_ACCOUNT_ID.trim();
|
|
128
|
+
}
|
|
129
|
+
return '';
|
|
130
|
+
}
|
|
131
|
+
export function publishDopplerTokenConfig(config) {
|
|
132
|
+
const cloudflare = publishCloudflareConfig(config);
|
|
133
|
+
if (isObject(cloudflare.token))
|
|
134
|
+
return cloudflare.token;
|
|
135
|
+
if (isObject(cloudflare.dopplerToken))
|
|
136
|
+
return cloudflare.dopplerToken;
|
|
137
|
+
return {};
|
|
138
|
+
}
|
|
139
|
+
export function hasDopplerTokenConfig(config) {
|
|
140
|
+
const token = publishDopplerTokenConfig(config);
|
|
141
|
+
return typeof token.secret === 'string' && token.secret.trim() !== '';
|
|
142
|
+
}
|
|
143
|
+
export function dopplerTokenCommand(config) {
|
|
144
|
+
const token = publishDopplerTokenConfig(config);
|
|
145
|
+
const secret = typeof token.secret === 'string' && token.secret.trim() ? token.secret.trim() : '';
|
|
146
|
+
if (!secret)
|
|
147
|
+
return null;
|
|
148
|
+
const args = ['secrets', 'get', secret];
|
|
149
|
+
if (typeof token.scope === 'string' && token.scope.trim())
|
|
150
|
+
args.push('--scope', token.scope.trim());
|
|
151
|
+
if (typeof token.project === 'string' && token.project.trim())
|
|
152
|
+
args.push('--project', token.project.trim());
|
|
153
|
+
if (typeof token.config === 'string' && token.config.trim())
|
|
154
|
+
args.push('--config', token.config.trim());
|
|
155
|
+
args.push('--plain', '--raw');
|
|
156
|
+
return { command: 'doppler', args };
|
|
157
|
+
}
|
|
158
|
+
export function publishBuildCommand(root, pkg) {
|
|
159
|
+
const scripts = pkg.scripts ?? {};
|
|
160
|
+
const packageManager = packageManagerFor(root);
|
|
161
|
+
if (typeof scripts['docs:build'] === 'string') {
|
|
162
|
+
return { command: packageManager, args: runArgsFor(packageManager, 'docs:build', []) };
|
|
163
|
+
}
|
|
164
|
+
if (typeof scripts.build === 'string') {
|
|
165
|
+
return { command: packageManager, args: runArgsFor(packageManager, 'build', []) };
|
|
166
|
+
}
|
|
167
|
+
const vitepress = localBin(root, 'vitepress');
|
|
168
|
+
if (vitepress)
|
|
169
|
+
return { command: vitepress, args: ['build', '.'] };
|
|
170
|
+
if (packageManager === 'npm')
|
|
171
|
+
return { command: packageManager, args: ['exec', '--', 'vitepress', 'build', '.'] };
|
|
172
|
+
return { command: packageManager, args: ['x', 'vitepress', 'build', '.'] };
|
|
173
|
+
}
|
|
174
|
+
export function publishWranglerCommand(root) {
|
|
175
|
+
const wrangler = localBin(root, 'wrangler');
|
|
176
|
+
if (wrangler)
|
|
177
|
+
return wrangler;
|
|
178
|
+
return 'npx';
|
|
179
|
+
}
|
|
180
|
+
export function publishWranglerArgs(root, args) {
|
|
181
|
+
return localBin(root, 'wrangler') ? args : ['wrangler', ...args];
|
|
182
|
+
}
|
|
183
|
+
export function publishTestUrl(hostname) {
|
|
184
|
+
if (!hostname)
|
|
185
|
+
return '';
|
|
186
|
+
return hostname.startsWith('http://') || hostname.startsWith('https://') ? hostname : `https://${hostname}`;
|
|
187
|
+
}
|
|
188
|
+
export function packageJsonPath(root) {
|
|
189
|
+
return join(root, 'package.json');
|
|
190
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export type PublishOptions = {
|
|
2
|
+
targetPath: string;
|
|
3
|
+
configFile: string;
|
|
4
|
+
force: boolean;
|
|
5
|
+
dryRun: boolean;
|
|
6
|
+
skipTest: boolean;
|
|
7
|
+
hostname: string;
|
|
8
|
+
testEmail: string;
|
|
9
|
+
deploy: boolean;
|
|
10
|
+
build: boolean;
|
|
11
|
+
useDopplerToken: boolean;
|
|
12
|
+
};
|
|
13
|
+
export declare function splitPublishArgs(input: string[]): PublishOptions;
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { DEFAULT_PROJECT_CONFIG } from '../shared/project-config.js';
|
|
2
|
+
export function splitPublishArgs(input) {
|
|
3
|
+
const options = {
|
|
4
|
+
targetPath: '.',
|
|
5
|
+
configFile: DEFAULT_PROJECT_CONFIG,
|
|
6
|
+
force: false,
|
|
7
|
+
dryRun: false,
|
|
8
|
+
skipTest: false,
|
|
9
|
+
hostname: '',
|
|
10
|
+
testEmail: '',
|
|
11
|
+
deploy: true,
|
|
12
|
+
build: true,
|
|
13
|
+
useDopplerToken: false,
|
|
14
|
+
};
|
|
15
|
+
const positional = [];
|
|
16
|
+
for (let index = 0; index < input.length; index += 1) {
|
|
17
|
+
const arg = input[index];
|
|
18
|
+
if (arg === '--force') {
|
|
19
|
+
options.force = true;
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
if (arg === '--dry-run') {
|
|
23
|
+
options.dryRun = true;
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
if (arg === '--skip-test') {
|
|
27
|
+
options.skipTest = true;
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
if (arg === '--no-build') {
|
|
31
|
+
options.build = false;
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
if (arg === '--no-deploy') {
|
|
35
|
+
options.deploy = false;
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
if (arg === '--use-doppler-token') {
|
|
39
|
+
options.useDopplerToken = true;
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
if (arg === '--config') {
|
|
43
|
+
options.configFile = input[index + 1] ?? '';
|
|
44
|
+
index += 1;
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (arg.startsWith('--config=')) {
|
|
48
|
+
options.configFile = arg.slice('--config='.length);
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
if (arg === '--hostname') {
|
|
52
|
+
options.hostname = input[index + 1] ?? '';
|
|
53
|
+
index += 1;
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
if (arg.startsWith('--hostname=')) {
|
|
57
|
+
options.hostname = arg.slice('--hostname='.length);
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
if (arg === '--test-email') {
|
|
61
|
+
options.testEmail = input[index + 1] ?? '';
|
|
62
|
+
index += 1;
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
if (arg.startsWith('--test-email=')) {
|
|
66
|
+
options.testEmail = arg.slice('--test-email='.length);
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
if (arg.startsWith('-'))
|
|
70
|
+
throw new Error(`Unknown publish option: ${arg}`);
|
|
71
|
+
positional.push(arg);
|
|
72
|
+
}
|
|
73
|
+
if (positional.length > 0)
|
|
74
|
+
options.targetPath = positional[0];
|
|
75
|
+
if (!options.configFile)
|
|
76
|
+
throw new Error('publish requires a config file name');
|
|
77
|
+
return options;
|
|
78
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function doctor(targetPath: string): void;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { existsSync, readdirSync, statSync } from 'node:fs';
|
|
2
|
+
import { dirname, join, relative, resolve } from 'node:path';
|
|
3
|
+
import { isObject } from '../shared/object.js';
|
|
4
|
+
import { matchesDocumentSpec, matchesGlob, SCHEMA_FILES } from './documents.js';
|
|
5
|
+
import { cachedSchemaFor, candidateFiles, validateTarget } from './validation.js';
|
|
6
|
+
export function doctor(targetPath) {
|
|
7
|
+
const target = resolve(targetPath);
|
|
8
|
+
const entry = cachedSchemaFor(target);
|
|
9
|
+
if (!entry)
|
|
10
|
+
throw new Error(`No ${SCHEMA_FILES.join(' or ')} found from ${target} upward`);
|
|
11
|
+
const config = entry.schema['x-kennel'];
|
|
12
|
+
const specs = entry.documentSpecs;
|
|
13
|
+
const candidates = candidateFiles(target);
|
|
14
|
+
if (entry.usingDefaultDocuments) {
|
|
15
|
+
const docsHere = candidates.filter((candidate) => dirname(candidate.filePath) === target);
|
|
16
|
+
const label = docsHere.length > 0
|
|
17
|
+
? docsHere.map((candidate) => relative(target, candidate.filePath)).join(', ')
|
|
18
|
+
: 'client.yml or index.md';
|
|
19
|
+
console.log(`${docsHere.length > 0 ? 'ok' : 'missing'} ${label}`);
|
|
20
|
+
}
|
|
21
|
+
else {
|
|
22
|
+
for (const spec of specs) {
|
|
23
|
+
if (spec.path && !spec.path.includes('/')) {
|
|
24
|
+
const filePath = join(target, spec.path);
|
|
25
|
+
console.log(`${existsSync(filePath) && statSync(filePath).isFile() ? 'ok' : 'missing'} ${spec.path}`);
|
|
26
|
+
}
|
|
27
|
+
else {
|
|
28
|
+
const label = spec.path ?? spec.glob;
|
|
29
|
+
const matches = candidates.filter((candidate) => (matchesDocumentSpec(candidate.filePath, entry.schemaPath, spec)));
|
|
30
|
+
console.log(`${matches.length > 0 ? 'ok' : 'missing'} ${label}`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
if (isObject(config)) {
|
|
35
|
+
const requiredDirectories = Array.isArray(config.requiredDirectories) ? config.requiredDirectories : [];
|
|
36
|
+
for (const directoryName of requiredDirectories) {
|
|
37
|
+
if (typeof directoryName !== 'string')
|
|
38
|
+
continue;
|
|
39
|
+
const directoryPath = join(target, directoryName);
|
|
40
|
+
console.log(`${existsSync(directoryPath) && statSync(directoryPath).isDirectory() ? 'ok' : 'missing'} ${directoryName.replace(/\/?$/, '/')}`);
|
|
41
|
+
}
|
|
42
|
+
const requiredFiles = Array.isArray(config.requiredFiles) ? config.requiredFiles : [];
|
|
43
|
+
for (const fileName of requiredFiles) {
|
|
44
|
+
if (typeof fileName !== 'string')
|
|
45
|
+
continue;
|
|
46
|
+
const filePath = join(target, fileName);
|
|
47
|
+
console.log(`${existsSync(filePath) && statSync(filePath).isFile() ? 'ok' : 'missing'} ${fileName}`);
|
|
48
|
+
}
|
|
49
|
+
const directoryContents = Array.isArray(config.directoryContents) ? config.directoryContents : [];
|
|
50
|
+
for (const rule of directoryContents) {
|
|
51
|
+
if (!isObject(rule) || typeof rule.path !== 'string')
|
|
52
|
+
continue;
|
|
53
|
+
const directoryPath = join(target, rule.path);
|
|
54
|
+
const glob = typeof rule.glob === 'string' ? rule.glob : '*';
|
|
55
|
+
const minFiles = Number.isInteger(rule.minFiles) ? Number(rule.minFiles) : 1;
|
|
56
|
+
const label = `${rule.path.replace(/\/?$/, '/')} ${glob}`;
|
|
57
|
+
const fileCount = existsSync(directoryPath) && statSync(directoryPath).isDirectory()
|
|
58
|
+
? readdirSync(directoryPath, { withFileTypes: true })
|
|
59
|
+
.filter((item) => item.isFile() && matchesGlob(item.name, glob))
|
|
60
|
+
.length
|
|
61
|
+
: 0;
|
|
62
|
+
console.log(`${fileCount >= minFiles ? 'ok' : 'missing'} ${label} (${fileCount}/${minFiles})`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
const docsHere = candidates.filter((candidate) => dirname(candidate.filePath) === target);
|
|
66
|
+
for (const candidate of docsHere) {
|
|
67
|
+
validateTarget(candidate.filePath);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { DocumentConfig, DocumentSpec, ParserName } from './types.js';
|
|
2
|
+
export declare const SCHEMA_FILES: readonly ["schema.json", "_schema.json"];
|
|
3
|
+
export declare const DEFAULT_DOCUMENTS: DocumentSpec[];
|
|
4
|
+
export declare const IGNORED_DIRECTORIES: Set<string>;
|
|
5
|
+
export declare function findNearestSchema(startPath: string): string | null;
|
|
6
|
+
export declare function loadSchemaFor(targetPath: string): {
|
|
7
|
+
schemaPath: string;
|
|
8
|
+
schema: any;
|
|
9
|
+
};
|
|
10
|
+
export declare function parseMarkdownFrontmatter(content: string, filePath: string): any;
|
|
11
|
+
export declare function parseDocument(filePath: string, parser?: ParserName): any;
|
|
12
|
+
export declare function schemaRoot(schemaPath: string): string;
|
|
13
|
+
export declare function parserForPath(filePath: string): ParserName;
|
|
14
|
+
export declare function normalizeDocumentSpec(spec: unknown): DocumentSpec;
|
|
15
|
+
export declare function documentConfig(schema: any): DocumentConfig;
|
|
16
|
+
export declare function globToRegex(glob: string): RegExp;
|
|
17
|
+
export declare function matchesDocumentSpec(filePath: string, schemaPath: string, spec: DocumentSpec): boolean;
|
|
18
|
+
export declare function matchesGlob(name: string, glob: string): boolean;
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
2
|
+
import { dirname, join, relative, resolve } from 'node:path';
|
|
3
|
+
import YAML from 'yaml';
|
|
4
|
+
import { isObject } from '../shared/object.js';
|
|
5
|
+
export const SCHEMA_FILES = ['schema.json', '_schema.json'];
|
|
6
|
+
export const DEFAULT_DOCUMENTS = [
|
|
7
|
+
{ path: 'client.yml', glob: null, parser: 'yaml' },
|
|
8
|
+
{ path: 'index.md', glob: null, parser: 'markdown-frontmatter' },
|
|
9
|
+
];
|
|
10
|
+
export const IGNORED_DIRECTORIES = new Set(['.git', 'node_modules']);
|
|
11
|
+
export function findNearestSchema(startPath) {
|
|
12
|
+
let current = resolve(startPath);
|
|
13
|
+
if (existsSync(current) && statSync(current).isFile())
|
|
14
|
+
current = dirname(current);
|
|
15
|
+
while (true) {
|
|
16
|
+
for (const schemaFile of SCHEMA_FILES) {
|
|
17
|
+
const schemaPath = join(current, schemaFile);
|
|
18
|
+
if (existsSync(schemaPath))
|
|
19
|
+
return schemaPath;
|
|
20
|
+
}
|
|
21
|
+
const parent = dirname(current);
|
|
22
|
+
if (parent === current)
|
|
23
|
+
return null;
|
|
24
|
+
current = parent;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
export function loadSchemaFor(targetPath) {
|
|
28
|
+
const schemaPath = findNearestSchema(targetPath);
|
|
29
|
+
if (!schemaPath) {
|
|
30
|
+
throw new Error(`No ${SCHEMA_FILES.join(' or ')} found from ${resolve(targetPath)} upward`);
|
|
31
|
+
}
|
|
32
|
+
return {
|
|
33
|
+
schemaPath,
|
|
34
|
+
schema: JSON.parse(readFileSync(schemaPath, 'utf8')),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
export function parseMarkdownFrontmatter(content, filePath) {
|
|
38
|
+
if (!content.startsWith('---\n')) {
|
|
39
|
+
throw new Error(`${filePath} is missing YAML frontmatter`);
|
|
40
|
+
}
|
|
41
|
+
const end = content.indexOf('\n---', 4);
|
|
42
|
+
if (end === -1) {
|
|
43
|
+
throw new Error(`${filePath} has unterminated YAML frontmatter`);
|
|
44
|
+
}
|
|
45
|
+
const frontmatter = YAML.parse(content.slice(4, end));
|
|
46
|
+
if (!isObject(frontmatter)) {
|
|
47
|
+
throw new Error(`${filePath} frontmatter must be a YAML object`);
|
|
48
|
+
}
|
|
49
|
+
return frontmatter;
|
|
50
|
+
}
|
|
51
|
+
export function parseDocument(filePath, parser = parserForPath(filePath)) {
|
|
52
|
+
const content = readFileSync(filePath, 'utf8');
|
|
53
|
+
if (parser === 'markdown-frontmatter')
|
|
54
|
+
return parseMarkdownFrontmatter(content, filePath);
|
|
55
|
+
if (parser !== 'yaml')
|
|
56
|
+
throw new Error(`Unsupported parser "${parser}" for ${filePath}`);
|
|
57
|
+
return YAML.parse(content);
|
|
58
|
+
}
|
|
59
|
+
export function schemaRoot(schemaPath) {
|
|
60
|
+
return dirname(schemaPath);
|
|
61
|
+
}
|
|
62
|
+
export function parserForPath(filePath) {
|
|
63
|
+
return filePath.endsWith('.md') ? 'markdown-frontmatter' : 'yaml';
|
|
64
|
+
}
|
|
65
|
+
export function normalizeDocumentSpec(spec) {
|
|
66
|
+
if (typeof spec === 'string') {
|
|
67
|
+
return { path: spec, glob: null, parser: parserForPath(spec) };
|
|
68
|
+
}
|
|
69
|
+
if (!isObject(spec)) {
|
|
70
|
+
throw new Error('x-kennel.documents entries must be strings or objects');
|
|
71
|
+
}
|
|
72
|
+
const path = typeof spec.path === 'string' ? spec.path : null;
|
|
73
|
+
const glob = typeof spec.glob === 'string' ? spec.glob : null;
|
|
74
|
+
if (!path && !glob)
|
|
75
|
+
throw new Error('x-kennel.documents object entries require path or glob');
|
|
76
|
+
return {
|
|
77
|
+
path,
|
|
78
|
+
glob,
|
|
79
|
+
parser: typeof spec.parser === 'string' ? spec.parser : parserForPath(path ?? glob ?? ''),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
export function documentConfig(schema) {
|
|
83
|
+
const config = schema['x-kennel'];
|
|
84
|
+
if (isObject(config) && Array.isArray(config.documents)) {
|
|
85
|
+
return {
|
|
86
|
+
specs: config.documents.map(normalizeDocumentSpec),
|
|
87
|
+
usingDefault: false,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
return {
|
|
91
|
+
specs: DEFAULT_DOCUMENTS.map(normalizeDocumentSpec),
|
|
92
|
+
usingDefault: true,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
export function globToRegex(glob) {
|
|
96
|
+
let pattern = '^';
|
|
97
|
+
for (let index = 0; index < glob.length; index += 1) {
|
|
98
|
+
const char = glob[index];
|
|
99
|
+
if (char === '*') {
|
|
100
|
+
pattern += '.*';
|
|
101
|
+
}
|
|
102
|
+
else if ('\\^$+.?()|{}[]'.includes(char)) {
|
|
103
|
+
pattern += `\\${char}`;
|
|
104
|
+
}
|
|
105
|
+
else {
|
|
106
|
+
pattern += char;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return new RegExp(`${pattern}$`);
|
|
110
|
+
}
|
|
111
|
+
export function matchesDocumentSpec(filePath, schemaPath, spec) {
|
|
112
|
+
const relativeToSchema = relative(schemaRoot(schemaPath), filePath);
|
|
113
|
+
const fileName = relativeToSchema.split('/').pop() ?? '';
|
|
114
|
+
if (spec.path) {
|
|
115
|
+
return spec.path.includes('/')
|
|
116
|
+
? relativeToSchema === spec.path
|
|
117
|
+
: fileName === spec.path;
|
|
118
|
+
}
|
|
119
|
+
if (spec.glob) {
|
|
120
|
+
const target = spec.glob.includes('/') ? relativeToSchema : fileName;
|
|
121
|
+
return globToRegex(spec.glob).test(target);
|
|
122
|
+
}
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
export function matchesGlob(name, glob) {
|
|
126
|
+
return globToRegex(glob).test(name);
|
|
127
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export declare function decodePointerSegment(segment: string): string;
|
|
2
|
+
export declare function valuesAtPointer(value: any, pointer: unknown): any[];
|
|
3
|
+
export declare function pointerHasMinItems(document: any, condition: any): boolean;
|
|
4
|
+
export declare function pointerValueIn(document: any, condition: any): boolean;
|
|
5
|
+
export declare function shouldApplyRule(document: any, rule: any): boolean;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { isObject } from '../shared/object.js';
|
|
2
|
+
export function decodePointerSegment(segment) {
|
|
3
|
+
return segment.replace(/~1/g, '/').replace(/~0/g, '~');
|
|
4
|
+
}
|
|
5
|
+
export function valuesAtPointer(value, pointer) {
|
|
6
|
+
if (pointer === '')
|
|
7
|
+
return [value];
|
|
8
|
+
if (typeof pointer !== 'string' || !pointer.startsWith('/'))
|
|
9
|
+
return [];
|
|
10
|
+
const segments = pointer.slice(1).split('/').map(decodePointerSegment);
|
|
11
|
+
function walk(current, index) {
|
|
12
|
+
if (index === segments.length)
|
|
13
|
+
return [current];
|
|
14
|
+
if (current === undefined || current === null)
|
|
15
|
+
return [];
|
|
16
|
+
const segment = segments[index];
|
|
17
|
+
if (segment === '*') {
|
|
18
|
+
if (Array.isArray(current))
|
|
19
|
+
return current.flatMap((item) => walk(item, index + 1));
|
|
20
|
+
if (isObject(current))
|
|
21
|
+
return Object.values(current).flatMap((item) => walk(item, index + 1));
|
|
22
|
+
return [];
|
|
23
|
+
}
|
|
24
|
+
return walk(current[segment], index + 1);
|
|
25
|
+
}
|
|
26
|
+
return walk(value, 0);
|
|
27
|
+
}
|
|
28
|
+
export function pointerHasMinItems(document, condition) {
|
|
29
|
+
const pointer = typeof condition === 'string' ? condition : condition?.pointer;
|
|
30
|
+
const minItems = typeof condition === 'object' && Number.isInteger(condition.minItems)
|
|
31
|
+
? condition.minItems
|
|
32
|
+
: 1;
|
|
33
|
+
return valuesAtPointer(document, pointer).some((value) => Array.isArray(value) && value.length >= minItems);
|
|
34
|
+
}
|
|
35
|
+
export function pointerValueIn(document, condition) {
|
|
36
|
+
if (!isObject(condition) || typeof condition.pointer !== 'string' || !Array.isArray(condition.values))
|
|
37
|
+
return false;
|
|
38
|
+
return valuesAtPointer(document, condition.pointer).some((value) => condition.values.includes(value));
|
|
39
|
+
}
|
|
40
|
+
export function shouldApplyRule(document, rule) {
|
|
41
|
+
if (rule.whenPointerMinItems !== undefined && !pointerHasMinItems(document, rule.whenPointerMinItems)) {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
if (rule.whenPointerIn !== undefined && !pointerValueIn(document, rule.whenPointerIn)) {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export type ParserName = 'yaml' | 'markdown-frontmatter' | string;
|
|
2
|
+
export type DocumentSpec = {
|
|
3
|
+
path: string | null;
|
|
4
|
+
glob: string | null;
|
|
5
|
+
parser: ParserName;
|
|
6
|
+
};
|
|
7
|
+
export type DocumentConfig = {
|
|
8
|
+
specs: DocumentSpec[];
|
|
9
|
+
usingDefault: boolean;
|
|
10
|
+
};
|
|
11
|
+
export type KennelError = {
|
|
12
|
+
instancePath?: string;
|
|
13
|
+
message?: string;
|
|
14
|
+
};
|
|
15
|
+
export type Validator = {
|
|
16
|
+
(document: unknown): boolean;
|
|
17
|
+
errors?: KennelError[] | null;
|
|
18
|
+
};
|
|
19
|
+
export type SchemaEntry = {
|
|
20
|
+
schemaPath: string;
|
|
21
|
+
schema: any;
|
|
22
|
+
validate: Validator;
|
|
23
|
+
documentSpecs: DocumentSpec[];
|
|
24
|
+
usingDefaultDocuments: boolean;
|
|
25
|
+
};
|
|
26
|
+
export type CandidateFile = {
|
|
27
|
+
filePath: string;
|
|
28
|
+
schemaEntry: SchemaEntry;
|
|
29
|
+
parser: ParserName;
|
|
30
|
+
};
|
|
31
|
+
export type ValidationResult = {
|
|
32
|
+
filePath: string;
|
|
33
|
+
schemaPath: string;
|
|
34
|
+
valid: boolean;
|
|
35
|
+
errors: KennelError[];
|
|
36
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { CandidateFile, KennelError, ParserName, SchemaEntry, ValidationResult, Validator } from './types.js';
|
|
2
|
+
export declare function createAjv(schema: any): Validator;
|
|
3
|
+
export declare function cachedSchemaFor(targetPath: string): SchemaEntry | null;
|
|
4
|
+
export declare function validateFile(filePath: string, schemaPath: string, schema: any, validate: Validator, parser?: ParserName): ValidationResult;
|
|
5
|
+
export declare function validateKennelRequirements(filePath: string, schema: any, document: any): KennelError[];
|
|
6
|
+
export declare function candidateFiles(targetPath: string): CandidateFile[];
|
|
7
|
+
export declare function validateTarget(targetPath: string): boolean;
|
|
8
|
+
export declare function schemaFor(targetPath: string): void;
|