@nimblebrain/mpak 0.0.1 → 0.0.2
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/.claude/settings.local.json +3 -1
- package/CLAUDE.md +73 -34
- package/README.md +222 -57
- package/dist/commands/search.d.ts +12 -0
- package/dist/commands/search.d.ts.map +1 -0
- package/dist/commands/search.js +144 -0
- package/dist/commands/search.js.map +1 -0
- package/dist/commands/skills/index.d.ts +8 -0
- package/dist/commands/skills/index.d.ts.map +1 -0
- package/dist/commands/skills/index.js +8 -0
- package/dist/commands/skills/index.js.map +1 -0
- package/dist/commands/skills/install.d.ts +9 -0
- package/dist/commands/skills/install.d.ts.map +1 -0
- package/dist/commands/skills/install.js +110 -0
- package/dist/commands/skills/install.js.map +1 -0
- package/dist/commands/skills/list.d.ts +8 -0
- package/dist/commands/skills/list.d.ts.map +1 -0
- package/dist/commands/skills/list.js +89 -0
- package/dist/commands/skills/list.js.map +1 -0
- package/dist/commands/skills/pack.d.ts +22 -0
- package/dist/commands/skills/pack.d.ts.map +1 -0
- package/dist/commands/skills/pack.js +116 -0
- package/dist/commands/skills/pack.js.map +1 -0
- package/dist/commands/skills/pull.d.ts +9 -0
- package/dist/commands/skills/pull.d.ts.map +1 -0
- package/dist/commands/skills/pull.js +68 -0
- package/dist/commands/skills/pull.js.map +1 -0
- package/dist/commands/skills/search.d.ts +14 -0
- package/dist/commands/skills/search.d.ts.map +1 -0
- package/dist/commands/skills/search.js +53 -0
- package/dist/commands/skills/search.js.map +1 -0
- package/dist/commands/skills/show.d.ts +8 -0
- package/dist/commands/skills/show.d.ts.map +1 -0
- package/dist/commands/skills/show.js +64 -0
- package/dist/commands/skills/show.js.map +1 -0
- package/dist/commands/skills/validate.d.ts +25 -0
- package/dist/commands/skills/validate.d.ts.map +1 -0
- package/dist/commands/skills/validate.js +191 -0
- package/dist/commands/skills/validate.js.map +1 -0
- package/dist/lib/api/skills-client.d.ts +30 -0
- package/dist/lib/api/skills-client.d.ts.map +1 -0
- package/dist/lib/api/skills-client.js +110 -0
- package/dist/lib/api/skills-client.js.map +1 -0
- package/dist/program.d.ts +5 -1
- package/dist/program.d.ts.map +1 -1
- package/dist/program.js +98 -33
- package/dist/program.js.map +1 -1
- package/dist/schemas/generated/api-responses.d.ts +541 -0
- package/dist/schemas/generated/api-responses.d.ts.map +1 -0
- package/dist/schemas/generated/api-responses.js +313 -0
- package/dist/schemas/generated/api-responses.js.map +1 -0
- package/dist/schemas/generated/auth.d.ts +18 -0
- package/dist/schemas/generated/auth.d.ts.map +1 -0
- package/dist/schemas/generated/auth.js +18 -0
- package/dist/schemas/generated/auth.js.map +1 -0
- package/dist/schemas/generated/index.d.ts +5 -0
- package/dist/schemas/generated/index.d.ts.map +1 -0
- package/dist/schemas/generated/index.js +6 -0
- package/dist/schemas/generated/index.js.map +1 -0
- package/dist/schemas/generated/package.d.ts +43 -0
- package/dist/schemas/generated/package.d.ts.map +1 -0
- package/dist/schemas/generated/package.js +20 -0
- package/dist/schemas/generated/package.js.map +1 -0
- package/dist/schemas/generated/skill.d.ts +381 -0
- package/dist/schemas/generated/skill.d.ts.map +1 -0
- package/dist/schemas/generated/skill.js +216 -0
- package/dist/schemas/generated/skill.js.map +1 -0
- package/dist/utils/config-manager.d.ts +13 -1
- package/dist/utils/config-manager.d.ts.map +1 -1
- package/dist/utils/config-manager.js +76 -11
- package/dist/utils/config-manager.js.map +1 -1
- package/package.json +6 -2
- package/src/commands/search.ts +191 -0
- package/src/commands/skills/index.ts +7 -0
- package/src/commands/skills/install.ts +129 -0
- package/src/commands/skills/list.ts +116 -0
- package/src/commands/skills/pack.test.ts +260 -0
- package/src/commands/skills/pack.ts +145 -0
- package/src/commands/skills/pull.ts +88 -0
- package/src/commands/skills/search.ts +73 -0
- package/src/commands/skills/show.ts +72 -0
- package/src/commands/skills/validate.test.ts +466 -0
- package/src/commands/skills/validate.ts +227 -0
- package/src/lib/api/skills-client.ts +148 -0
- package/src/program.test.ts +1 -3
- package/src/program.ts +125 -35
- package/src/schemas/config.v1.schema.json +37 -0
- package/src/schemas/generated/api-responses.ts +386 -0
- package/src/schemas/generated/auth.ts +21 -0
- package/src/schemas/generated/index.ts +5 -0
- package/src/schemas/generated/package.ts +29 -0
- package/src/schemas/generated/skill.ts +271 -0
- package/src/utils/config-manager.test.ts +182 -2
- package/src/utils/config-manager.ts +126 -12
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
// =============================================================================
|
|
4
|
+
// Agent Skills Specification - Skill Frontmatter Schema
|
|
5
|
+
// https://agentskills.io/specification
|
|
6
|
+
// =============================================================================
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Skill name validation
|
|
10
|
+
* - 1-64 characters
|
|
11
|
+
* - Lowercase alphanumeric with single hyphens
|
|
12
|
+
* - Cannot start or end with hyphen
|
|
13
|
+
* - Must match directory name (validated separately)
|
|
14
|
+
*/
|
|
15
|
+
export const SkillNameSchema = z
|
|
16
|
+
.string()
|
|
17
|
+
.min(1)
|
|
18
|
+
.max(64)
|
|
19
|
+
.regex(
|
|
20
|
+
/^[a-z][a-z0-9]*(-[a-z0-9]+)*$/,
|
|
21
|
+
'Lowercase alphanumeric with single hyphens, cannot start/end with hyphen'
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Skill description
|
|
26
|
+
* - 1-1024 characters
|
|
27
|
+
* - Should describe what the skill does AND when to use it
|
|
28
|
+
*/
|
|
29
|
+
export const SkillDescriptionSchema = z.string().min(1).max(1024);
|
|
30
|
+
|
|
31
|
+
// =============================================================================
|
|
32
|
+
// Discovery Metadata Extension (via metadata: field)
|
|
33
|
+
// =============================================================================
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Category taxonomy for skill discovery
|
|
37
|
+
*/
|
|
38
|
+
export const SkillCategorySchema = z.enum([
|
|
39
|
+
'development', // Code, debugging, architecture
|
|
40
|
+
'writing', // Documentation, content, editing
|
|
41
|
+
'research', // Investigation, analysis, learning
|
|
42
|
+
'consulting', // Strategy, decisions, planning
|
|
43
|
+
'data', // Analysis, visualization, processing
|
|
44
|
+
'design', // UI/UX, visual, creative
|
|
45
|
+
'operations', // DevOps, infrastructure, automation
|
|
46
|
+
'security', // Auditing, compliance, protection
|
|
47
|
+
'other', // Uncategorized
|
|
48
|
+
]);
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Surfaces where the skill works
|
|
52
|
+
*/
|
|
53
|
+
export const SkillSurfaceSchema = z.enum([
|
|
54
|
+
'claude-code', // Claude Code CLI
|
|
55
|
+
'claude-api', // Claude API with code execution
|
|
56
|
+
'claude-ai', // claude.ai web interface
|
|
57
|
+
]);
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Author information for attribution
|
|
61
|
+
*/
|
|
62
|
+
export const SkillAuthorSchema = z.object({
|
|
63
|
+
name: z.string().min(1),
|
|
64
|
+
url: z.string().url().optional(),
|
|
65
|
+
email: z.string().email().optional(),
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Example usage for discovery
|
|
70
|
+
*/
|
|
71
|
+
export const SkillExampleSchema = z.object({
|
|
72
|
+
prompt: z.string().min(1),
|
|
73
|
+
context: z.string().optional(),
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Discovery metadata (via metadata: field in frontmatter)
|
|
78
|
+
* All fields optional - skills can start minimal and add discovery metadata later
|
|
79
|
+
*/
|
|
80
|
+
export const SkillDiscoveryMetadataSchema = z
|
|
81
|
+
.object({
|
|
82
|
+
// Discovery
|
|
83
|
+
tags: z.array(z.string().max(32)).max(10).optional(),
|
|
84
|
+
category: SkillCategorySchema.optional(),
|
|
85
|
+
triggers: z.array(z.string().max(128)).max(20).optional(),
|
|
86
|
+
keywords: z.array(z.string().max(32)).max(30).optional(),
|
|
87
|
+
surfaces: z.array(SkillSurfaceSchema).optional(),
|
|
88
|
+
|
|
89
|
+
// Attribution
|
|
90
|
+
author: SkillAuthorSchema.optional(),
|
|
91
|
+
|
|
92
|
+
// Version (for registry tracking)
|
|
93
|
+
version: z.string().optional(),
|
|
94
|
+
|
|
95
|
+
// Examples
|
|
96
|
+
examples: z.array(SkillExampleSchema).max(5).optional(),
|
|
97
|
+
})
|
|
98
|
+
.passthrough(); // Allow additional custom keys
|
|
99
|
+
|
|
100
|
+
// =============================================================================
|
|
101
|
+
// Complete SKILL.md Frontmatter Schema
|
|
102
|
+
// =============================================================================
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Complete SKILL.md frontmatter schema
|
|
106
|
+
* Combines official Agent Skills spec with discovery metadata extension
|
|
107
|
+
*/
|
|
108
|
+
export const SkillFrontmatterSchema = z.object({
|
|
109
|
+
// Required (official spec)
|
|
110
|
+
name: SkillNameSchema,
|
|
111
|
+
description: SkillDescriptionSchema,
|
|
112
|
+
|
|
113
|
+
// Optional (official spec - informational pass-through)
|
|
114
|
+
license: z.string().optional(),
|
|
115
|
+
compatibility: z.string().max(500).optional(),
|
|
116
|
+
'allowed-tools': z.string().optional(), // space-delimited, experimental
|
|
117
|
+
|
|
118
|
+
// Extensible metadata (official spec) - our discovery extension
|
|
119
|
+
metadata: SkillDiscoveryMetadataSchema.optional(),
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// =============================================================================
|
|
123
|
+
// Registry API Schemas
|
|
124
|
+
// =============================================================================
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Scoped skill name for registry (e.g., @nimblebraininc/strategic-thought-partner)
|
|
128
|
+
*/
|
|
129
|
+
export const ScopedSkillNameSchema = z
|
|
130
|
+
.string()
|
|
131
|
+
.regex(
|
|
132
|
+
/^@[a-z0-9][a-z0-9-]*\/[a-z0-9][a-z0-9-]*$/,
|
|
133
|
+
'Scoped name format: @scope/name'
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Skill artifact info for announce endpoint
|
|
138
|
+
*/
|
|
139
|
+
export const SkillArtifactSchema = z.object({
|
|
140
|
+
filename: z.string().regex(/\.skill$/, 'Must have .skill extension'),
|
|
141
|
+
sha256: z.string().length(64),
|
|
142
|
+
size: z.number().int().positive(),
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Announce request schema for POST /v1/skills/announce
|
|
147
|
+
*/
|
|
148
|
+
export const SkillAnnounceRequestSchema = z.object({
|
|
149
|
+
name: ScopedSkillNameSchema,
|
|
150
|
+
version: z.string(),
|
|
151
|
+
skill: SkillFrontmatterSchema,
|
|
152
|
+
release_tag: z.string(),
|
|
153
|
+
prerelease: z.boolean().optional().default(false),
|
|
154
|
+
artifact: SkillArtifactSchema,
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Announce response schema
|
|
159
|
+
*/
|
|
160
|
+
export const SkillAnnounceResponseSchema = z.object({
|
|
161
|
+
skill: z.string(),
|
|
162
|
+
version: z.string(),
|
|
163
|
+
status: z.enum(['created', 'exists']),
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// =============================================================================
|
|
167
|
+
// Search/List API Schemas
|
|
168
|
+
// =============================================================================
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Skill search parameters
|
|
172
|
+
*/
|
|
173
|
+
export const SkillSearchParamsSchema = z.object({
|
|
174
|
+
q: z.string().optional(),
|
|
175
|
+
tags: z.string().optional(), // comma-separated
|
|
176
|
+
category: SkillCategorySchema.optional(),
|
|
177
|
+
surface: SkillSurfaceSchema.optional(),
|
|
178
|
+
sort: z.enum(['downloads', 'recent', 'name']).optional(),
|
|
179
|
+
limit: z.union([z.string(), z.number()]).optional(),
|
|
180
|
+
offset: z.union([z.string(), z.number()]).optional(),
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Skill summary for search results
|
|
185
|
+
*/
|
|
186
|
+
export const SkillSummarySchema = z.object({
|
|
187
|
+
name: z.string(), // scoped name
|
|
188
|
+
description: z.string(),
|
|
189
|
+
latest_version: z.string(),
|
|
190
|
+
tags: z.array(z.string()).optional(),
|
|
191
|
+
category: SkillCategorySchema.optional(),
|
|
192
|
+
surfaces: z.array(SkillSurfaceSchema).optional(),
|
|
193
|
+
downloads: z.number(),
|
|
194
|
+
published_at: z.string(),
|
|
195
|
+
author: SkillAuthorSchema.optional(),
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Skill search response
|
|
200
|
+
*/
|
|
201
|
+
export const SkillSearchResponseSchema = z.object({
|
|
202
|
+
skills: z.array(SkillSummarySchema),
|
|
203
|
+
total: z.number(),
|
|
204
|
+
pagination: z.object({
|
|
205
|
+
limit: z.number(),
|
|
206
|
+
offset: z.number(),
|
|
207
|
+
has_more: z.boolean(),
|
|
208
|
+
}),
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Skill detail response
|
|
213
|
+
*/
|
|
214
|
+
export const SkillDetailSchema = z.object({
|
|
215
|
+
name: z.string(), // scoped name
|
|
216
|
+
description: z.string(),
|
|
217
|
+
latest_version: z.string(),
|
|
218
|
+
license: z.string().optional(),
|
|
219
|
+
compatibility: z.string().optional(),
|
|
220
|
+
allowed_tools: z.array(z.string()).optional(),
|
|
221
|
+
tags: z.array(z.string()).optional(),
|
|
222
|
+
category: SkillCategorySchema.optional(),
|
|
223
|
+
triggers: z.array(z.string()).optional(),
|
|
224
|
+
surfaces: z.array(SkillSurfaceSchema).optional(),
|
|
225
|
+
downloads: z.number(),
|
|
226
|
+
published_at: z.string(),
|
|
227
|
+
author: SkillAuthorSchema.optional(),
|
|
228
|
+
examples: z.array(SkillExampleSchema).optional(),
|
|
229
|
+
versions: z.array(
|
|
230
|
+
z.object({
|
|
231
|
+
version: z.string(),
|
|
232
|
+
published_at: z.string(),
|
|
233
|
+
downloads: z.number(),
|
|
234
|
+
})
|
|
235
|
+
),
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Skill download info response
|
|
240
|
+
*/
|
|
241
|
+
export const SkillDownloadInfoSchema = z.object({
|
|
242
|
+
url: z.string(),
|
|
243
|
+
skill: z.object({
|
|
244
|
+
name: z.string(),
|
|
245
|
+
version: z.string(),
|
|
246
|
+
sha256: z.string(),
|
|
247
|
+
size: z.number(),
|
|
248
|
+
}),
|
|
249
|
+
expires_at: z.string(),
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
// =============================================================================
|
|
253
|
+
// TypeScript Types
|
|
254
|
+
// =============================================================================
|
|
255
|
+
|
|
256
|
+
export type SkillName = z.infer<typeof SkillNameSchema>;
|
|
257
|
+
export type SkillCategory = z.infer<typeof SkillCategorySchema>;
|
|
258
|
+
export type SkillSurface = z.infer<typeof SkillSurfaceSchema>;
|
|
259
|
+
export type SkillAuthor = z.infer<typeof SkillAuthorSchema>;
|
|
260
|
+
export type SkillExample = z.infer<typeof SkillExampleSchema>;
|
|
261
|
+
export type SkillDiscoveryMetadata = z.infer<typeof SkillDiscoveryMetadataSchema>;
|
|
262
|
+
export type SkillFrontmatter = z.infer<typeof SkillFrontmatterSchema>;
|
|
263
|
+
export type ScopedSkillName = z.infer<typeof ScopedSkillNameSchema>;
|
|
264
|
+
export type SkillArtifact = z.infer<typeof SkillArtifactSchema>;
|
|
265
|
+
export type SkillAnnounceRequest = z.infer<typeof SkillAnnounceRequestSchema>;
|
|
266
|
+
export type SkillAnnounceResponse = z.infer<typeof SkillAnnounceResponseSchema>;
|
|
267
|
+
export type SkillSearchParams = z.infer<typeof SkillSearchParamsSchema>;
|
|
268
|
+
export type SkillSummary = z.infer<typeof SkillSummarySchema>;
|
|
269
|
+
export type SkillSearchResponse = z.infer<typeof SkillSearchResponseSchema>;
|
|
270
|
+
export type SkillDetail = z.infer<typeof SkillDetailSchema>;
|
|
271
|
+
export type SkillDownloadInfo = z.infer<typeof SkillDownloadInfoSchema>;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
-
import { ConfigManager } from './config-manager.js';
|
|
3
|
-
import { existsSync, rmSync } from 'fs';
|
|
2
|
+
import { ConfigManager, ConfigCorruptedError } from './config-manager.js';
|
|
3
|
+
import { existsSync, rmSync, writeFileSync, mkdirSync } from 'fs';
|
|
4
4
|
import { join } from 'path';
|
|
5
5
|
import { homedir } from 'os';
|
|
6
6
|
|
|
@@ -147,4 +147,184 @@ describe('ConfigManager', () => {
|
|
|
147
147
|
});
|
|
148
148
|
});
|
|
149
149
|
|
|
150
|
+
describe('config validation', () => {
|
|
151
|
+
beforeEach(() => {
|
|
152
|
+
// Ensure config directory exists for writing test files
|
|
153
|
+
if (!existsSync(testConfigDir)) {
|
|
154
|
+
mkdirSync(testConfigDir, { recursive: true, mode: 0o700 });
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('should throw ConfigCorruptedError for invalid JSON', () => {
|
|
159
|
+
writeFileSync(testConfigFile, 'not valid json {{{', { mode: 0o600 });
|
|
160
|
+
|
|
161
|
+
const manager = new ConfigManager();
|
|
162
|
+
expect(() => manager.loadConfig()).toThrow(ConfigCorruptedError);
|
|
163
|
+
expect(() => manager.loadConfig()).toThrow(/invalid JSON/);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('should throw ConfigCorruptedError when version is missing', () => {
|
|
167
|
+
writeFileSync(
|
|
168
|
+
testConfigFile,
|
|
169
|
+
JSON.stringify({ lastUpdated: '2024-01-01T00:00:00Z' }),
|
|
170
|
+
{ mode: 0o600 }
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
const manager = new ConfigManager();
|
|
174
|
+
expect(() => manager.loadConfig()).toThrow(ConfigCorruptedError);
|
|
175
|
+
expect(() => manager.loadConfig()).toThrow(/version/);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('should throw ConfigCorruptedError when lastUpdated is missing', () => {
|
|
179
|
+
writeFileSync(
|
|
180
|
+
testConfigFile,
|
|
181
|
+
JSON.stringify({ version: '1.0.0' }),
|
|
182
|
+
{ mode: 0o600 }
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
const manager = new ConfigManager();
|
|
186
|
+
expect(() => manager.loadConfig()).toThrow(ConfigCorruptedError);
|
|
187
|
+
expect(() => manager.loadConfig()).toThrow(/lastUpdated/);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('should throw ConfigCorruptedError when registryUrl is not a string', () => {
|
|
191
|
+
writeFileSync(
|
|
192
|
+
testConfigFile,
|
|
193
|
+
JSON.stringify({
|
|
194
|
+
version: '1.0.0',
|
|
195
|
+
lastUpdated: '2024-01-01T00:00:00Z',
|
|
196
|
+
registryUrl: 12345,
|
|
197
|
+
}),
|
|
198
|
+
{ mode: 0o600 }
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
const manager = new ConfigManager();
|
|
202
|
+
expect(() => manager.loadConfig()).toThrow(ConfigCorruptedError);
|
|
203
|
+
expect(() => manager.loadConfig()).toThrow(/registryUrl must be a string/);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('should throw ConfigCorruptedError when packages is not an object', () => {
|
|
207
|
+
writeFileSync(
|
|
208
|
+
testConfigFile,
|
|
209
|
+
JSON.stringify({
|
|
210
|
+
version: '1.0.0',
|
|
211
|
+
lastUpdated: '2024-01-01T00:00:00Z',
|
|
212
|
+
packages: 'not an object',
|
|
213
|
+
}),
|
|
214
|
+
{ mode: 0o600 }
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
const manager = new ConfigManager();
|
|
218
|
+
expect(() => manager.loadConfig()).toThrow(ConfigCorruptedError);
|
|
219
|
+
expect(() => manager.loadConfig()).toThrow(/packages must be an object/);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('should throw ConfigCorruptedError when package config is not an object', () => {
|
|
223
|
+
writeFileSync(
|
|
224
|
+
testConfigFile,
|
|
225
|
+
JSON.stringify({
|
|
226
|
+
version: '1.0.0',
|
|
227
|
+
lastUpdated: '2024-01-01T00:00:00Z',
|
|
228
|
+
packages: {
|
|
229
|
+
'@scope/pkg': 'not an object',
|
|
230
|
+
},
|
|
231
|
+
}),
|
|
232
|
+
{ mode: 0o600 }
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
const manager = new ConfigManager();
|
|
236
|
+
expect(() => manager.loadConfig()).toThrow(ConfigCorruptedError);
|
|
237
|
+
expect(() => manager.loadConfig()).toThrow(/packages.@scope\/pkg must be an object/);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it('should throw ConfigCorruptedError when package config value is not a string', () => {
|
|
241
|
+
writeFileSync(
|
|
242
|
+
testConfigFile,
|
|
243
|
+
JSON.stringify({
|
|
244
|
+
version: '1.0.0',
|
|
245
|
+
lastUpdated: '2024-01-01T00:00:00Z',
|
|
246
|
+
packages: {
|
|
247
|
+
'@scope/pkg': {
|
|
248
|
+
api_key: 12345,
|
|
249
|
+
},
|
|
250
|
+
},
|
|
251
|
+
}),
|
|
252
|
+
{ mode: 0o600 }
|
|
253
|
+
);
|
|
254
|
+
|
|
255
|
+
const manager = new ConfigManager();
|
|
256
|
+
expect(() => manager.loadConfig()).toThrow(ConfigCorruptedError);
|
|
257
|
+
expect(() => manager.loadConfig()).toThrow(/packages.@scope\/pkg.api_key must be a string/);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it('should throw ConfigCorruptedError for unknown fields', () => {
|
|
261
|
+
writeFileSync(
|
|
262
|
+
testConfigFile,
|
|
263
|
+
JSON.stringify({
|
|
264
|
+
version: '1.0.0',
|
|
265
|
+
lastUpdated: '2024-01-01T00:00:00Z',
|
|
266
|
+
unknownField: 'should not be here',
|
|
267
|
+
}),
|
|
268
|
+
{ mode: 0o600 }
|
|
269
|
+
);
|
|
270
|
+
|
|
271
|
+
const manager = new ConfigManager();
|
|
272
|
+
expect(() => manager.loadConfig()).toThrow(ConfigCorruptedError);
|
|
273
|
+
expect(() => manager.loadConfig()).toThrow(/unknown field: unknownField/);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it('should include config path in error', () => {
|
|
277
|
+
writeFileSync(testConfigFile, 'invalid json', { mode: 0o600 });
|
|
278
|
+
|
|
279
|
+
const manager = new ConfigManager();
|
|
280
|
+
try {
|
|
281
|
+
manager.loadConfig();
|
|
282
|
+
expect.fail('Should have thrown');
|
|
283
|
+
} catch (err) {
|
|
284
|
+
expect(err).toBeInstanceOf(ConfigCorruptedError);
|
|
285
|
+
expect((err as ConfigCorruptedError).configPath).toBe(testConfigFile);
|
|
286
|
+
}
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it('should load valid minimal config', () => {
|
|
290
|
+
writeFileSync(
|
|
291
|
+
testConfigFile,
|
|
292
|
+
JSON.stringify({
|
|
293
|
+
version: '1.0.0',
|
|
294
|
+
lastUpdated: '2024-01-01T00:00:00Z',
|
|
295
|
+
}),
|
|
296
|
+
{ mode: 0o600 }
|
|
297
|
+
);
|
|
298
|
+
|
|
299
|
+
const manager = new ConfigManager();
|
|
300
|
+
const config = manager.loadConfig();
|
|
301
|
+
expect(config.version).toBe('1.0.0');
|
|
302
|
+
expect(config.lastUpdated).toBe('2024-01-01T00:00:00Z');
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it('should load valid full config', () => {
|
|
306
|
+
writeFileSync(
|
|
307
|
+
testConfigFile,
|
|
308
|
+
JSON.stringify({
|
|
309
|
+
version: '1.0.0',
|
|
310
|
+
lastUpdated: '2024-01-01T00:00:00Z',
|
|
311
|
+
registryUrl: 'https://custom.registry.com',
|
|
312
|
+
packages: {
|
|
313
|
+
'@scope/pkg': {
|
|
314
|
+
api_key: 'secret',
|
|
315
|
+
other_key: 'value',
|
|
316
|
+
},
|
|
317
|
+
},
|
|
318
|
+
}),
|
|
319
|
+
{ mode: 0o600 }
|
|
320
|
+
);
|
|
321
|
+
|
|
322
|
+
const manager = new ConfigManager();
|
|
323
|
+
const config = manager.loadConfig();
|
|
324
|
+
expect(config.version).toBe('1.0.0');
|
|
325
|
+
expect(config.registryUrl).toBe('https://custom.registry.com');
|
|
326
|
+
expect(config.packages?.['@scope/pkg']?.api_key).toBe('secret');
|
|
327
|
+
});
|
|
328
|
+
});
|
|
329
|
+
|
|
150
330
|
});
|
|
@@ -2,6 +2,11 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
|
|
|
2
2
|
import { homedir } from 'os';
|
|
3
3
|
import { join } from 'path';
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* Current config schema version
|
|
7
|
+
*/
|
|
8
|
+
export const CONFIG_VERSION = '1.0.0';
|
|
9
|
+
|
|
5
10
|
/**
|
|
6
11
|
* Per-package user configuration (stores user_config values)
|
|
7
12
|
*/
|
|
@@ -10,7 +15,7 @@ export interface PackageConfig {
|
|
|
10
15
|
}
|
|
11
16
|
|
|
12
17
|
/**
|
|
13
|
-
* Configuration structure
|
|
18
|
+
* Configuration structure (v1.0.0)
|
|
14
19
|
*/
|
|
15
20
|
export interface MpakConfig {
|
|
16
21
|
version: string;
|
|
@@ -19,6 +24,102 @@ export interface MpakConfig {
|
|
|
19
24
|
packages?: Record<string, PackageConfig>;
|
|
20
25
|
}
|
|
21
26
|
|
|
27
|
+
/**
|
|
28
|
+
* Error thrown when config file is corrupted or invalid
|
|
29
|
+
*/
|
|
30
|
+
export class ConfigCorruptedError extends Error {
|
|
31
|
+
constructor(
|
|
32
|
+
message: string,
|
|
33
|
+
public readonly configPath: string,
|
|
34
|
+
public readonly cause?: Error
|
|
35
|
+
) {
|
|
36
|
+
super(message);
|
|
37
|
+
this.name = 'ConfigCorruptedError';
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Validates that a parsed object conforms to the MpakConfig schema
|
|
43
|
+
*/
|
|
44
|
+
function validateConfig(data: unknown, configPath: string): MpakConfig {
|
|
45
|
+
if (typeof data !== 'object' || data === null) {
|
|
46
|
+
throw new ConfigCorruptedError(
|
|
47
|
+
'Config file must be a JSON object',
|
|
48
|
+
configPath
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const obj = data as Record<string, unknown>;
|
|
53
|
+
|
|
54
|
+
// Required fields
|
|
55
|
+
if (typeof obj.version !== 'string') {
|
|
56
|
+
throw new ConfigCorruptedError(
|
|
57
|
+
'Config missing required field: version (string)',
|
|
58
|
+
configPath
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (typeof obj.lastUpdated !== 'string') {
|
|
63
|
+
throw new ConfigCorruptedError(
|
|
64
|
+
'Config missing required field: lastUpdated (string)',
|
|
65
|
+
configPath
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Optional fields with type validation
|
|
70
|
+
if (obj.registryUrl !== undefined && typeof obj.registryUrl !== 'string') {
|
|
71
|
+
throw new ConfigCorruptedError(
|
|
72
|
+
'Config field registryUrl must be a string',
|
|
73
|
+
configPath
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (obj.packages !== undefined) {
|
|
78
|
+
if (typeof obj.packages !== 'object' || obj.packages === null) {
|
|
79
|
+
throw new ConfigCorruptedError(
|
|
80
|
+
'Config field packages must be an object',
|
|
81
|
+
configPath
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Validate each package config
|
|
86
|
+
for (const [pkgName, pkgConfig] of Object.entries(
|
|
87
|
+
obj.packages as Record<string, unknown>
|
|
88
|
+
)) {
|
|
89
|
+
if (typeof pkgConfig !== 'object' || pkgConfig === null) {
|
|
90
|
+
throw new ConfigCorruptedError(
|
|
91
|
+
`Config packages.${pkgName} must be an object`,
|
|
92
|
+
configPath
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
for (const [key, value] of Object.entries(
|
|
97
|
+
pkgConfig as Record<string, unknown>
|
|
98
|
+
)) {
|
|
99
|
+
if (typeof value !== 'string') {
|
|
100
|
+
throw new ConfigCorruptedError(
|
|
101
|
+
`Config packages.${pkgName}.${key} must be a string`,
|
|
102
|
+
configPath
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Check for unknown fields (additionalProperties: false in schema)
|
|
110
|
+
const knownFields = new Set(['version', 'lastUpdated', 'registryUrl', 'packages']);
|
|
111
|
+
for (const key of Object.keys(obj)) {
|
|
112
|
+
if (!knownFields.has(key)) {
|
|
113
|
+
throw new ConfigCorruptedError(
|
|
114
|
+
`Config contains unknown field: ${key}`,
|
|
115
|
+
configPath
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return data as MpakConfig;
|
|
121
|
+
}
|
|
122
|
+
|
|
22
123
|
/**
|
|
23
124
|
* Configuration manager for CLI settings in ~/.mpak/config.json
|
|
24
125
|
*/
|
|
@@ -46,25 +147,38 @@ export class ConfigManager {
|
|
|
46
147
|
|
|
47
148
|
if (!existsSync(this.configFile)) {
|
|
48
149
|
this.config = {
|
|
49
|
-
version:
|
|
150
|
+
version: CONFIG_VERSION,
|
|
50
151
|
lastUpdated: new Date().toISOString(),
|
|
51
152
|
};
|
|
52
153
|
this.saveConfig();
|
|
53
154
|
return this.config;
|
|
54
155
|
}
|
|
55
156
|
|
|
157
|
+
let configJson: string;
|
|
56
158
|
try {
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
};
|
|
65
|
-
this.saveConfig();
|
|
66
|
-
return this.config;
|
|
159
|
+
configJson = readFileSync(this.configFile, 'utf8');
|
|
160
|
+
} catch (err) {
|
|
161
|
+
throw new ConfigCorruptedError(
|
|
162
|
+
`Failed to read config file: ${err instanceof Error ? err.message : String(err)}`,
|
|
163
|
+
this.configFile,
|
|
164
|
+
err instanceof Error ? err : undefined
|
|
165
|
+
);
|
|
67
166
|
}
|
|
167
|
+
|
|
168
|
+
let parsed: unknown;
|
|
169
|
+
try {
|
|
170
|
+
parsed = JSON.parse(configJson);
|
|
171
|
+
} catch (err) {
|
|
172
|
+
throw new ConfigCorruptedError(
|
|
173
|
+
`Config file contains invalid JSON: ${err instanceof Error ? err.message : String(err)}`,
|
|
174
|
+
this.configFile,
|
|
175
|
+
err instanceof Error ? err : undefined
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Validate structure against schema
|
|
180
|
+
this.config = validateConfig(parsed, this.configFile);
|
|
181
|
+
return this.config;
|
|
68
182
|
}
|
|
69
183
|
|
|
70
184
|
private saveConfig(): void {
|