@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.
Files changed (94) hide show
  1. package/.claude/settings.local.json +3 -1
  2. package/CLAUDE.md +73 -34
  3. package/README.md +222 -57
  4. package/dist/commands/search.d.ts +12 -0
  5. package/dist/commands/search.d.ts.map +1 -0
  6. package/dist/commands/search.js +144 -0
  7. package/dist/commands/search.js.map +1 -0
  8. package/dist/commands/skills/index.d.ts +8 -0
  9. package/dist/commands/skills/index.d.ts.map +1 -0
  10. package/dist/commands/skills/index.js +8 -0
  11. package/dist/commands/skills/index.js.map +1 -0
  12. package/dist/commands/skills/install.d.ts +9 -0
  13. package/dist/commands/skills/install.d.ts.map +1 -0
  14. package/dist/commands/skills/install.js +110 -0
  15. package/dist/commands/skills/install.js.map +1 -0
  16. package/dist/commands/skills/list.d.ts +8 -0
  17. package/dist/commands/skills/list.d.ts.map +1 -0
  18. package/dist/commands/skills/list.js +89 -0
  19. package/dist/commands/skills/list.js.map +1 -0
  20. package/dist/commands/skills/pack.d.ts +22 -0
  21. package/dist/commands/skills/pack.d.ts.map +1 -0
  22. package/dist/commands/skills/pack.js +116 -0
  23. package/dist/commands/skills/pack.js.map +1 -0
  24. package/dist/commands/skills/pull.d.ts +9 -0
  25. package/dist/commands/skills/pull.d.ts.map +1 -0
  26. package/dist/commands/skills/pull.js +68 -0
  27. package/dist/commands/skills/pull.js.map +1 -0
  28. package/dist/commands/skills/search.d.ts +14 -0
  29. package/dist/commands/skills/search.d.ts.map +1 -0
  30. package/dist/commands/skills/search.js +53 -0
  31. package/dist/commands/skills/search.js.map +1 -0
  32. package/dist/commands/skills/show.d.ts +8 -0
  33. package/dist/commands/skills/show.d.ts.map +1 -0
  34. package/dist/commands/skills/show.js +64 -0
  35. package/dist/commands/skills/show.js.map +1 -0
  36. package/dist/commands/skills/validate.d.ts +25 -0
  37. package/dist/commands/skills/validate.d.ts.map +1 -0
  38. package/dist/commands/skills/validate.js +191 -0
  39. package/dist/commands/skills/validate.js.map +1 -0
  40. package/dist/lib/api/skills-client.d.ts +30 -0
  41. package/dist/lib/api/skills-client.d.ts.map +1 -0
  42. package/dist/lib/api/skills-client.js +110 -0
  43. package/dist/lib/api/skills-client.js.map +1 -0
  44. package/dist/program.d.ts +5 -1
  45. package/dist/program.d.ts.map +1 -1
  46. package/dist/program.js +98 -33
  47. package/dist/program.js.map +1 -1
  48. package/dist/schemas/generated/api-responses.d.ts +541 -0
  49. package/dist/schemas/generated/api-responses.d.ts.map +1 -0
  50. package/dist/schemas/generated/api-responses.js +313 -0
  51. package/dist/schemas/generated/api-responses.js.map +1 -0
  52. package/dist/schemas/generated/auth.d.ts +18 -0
  53. package/dist/schemas/generated/auth.d.ts.map +1 -0
  54. package/dist/schemas/generated/auth.js +18 -0
  55. package/dist/schemas/generated/auth.js.map +1 -0
  56. package/dist/schemas/generated/index.d.ts +5 -0
  57. package/dist/schemas/generated/index.d.ts.map +1 -0
  58. package/dist/schemas/generated/index.js +6 -0
  59. package/dist/schemas/generated/index.js.map +1 -0
  60. package/dist/schemas/generated/package.d.ts +43 -0
  61. package/dist/schemas/generated/package.d.ts.map +1 -0
  62. package/dist/schemas/generated/package.js +20 -0
  63. package/dist/schemas/generated/package.js.map +1 -0
  64. package/dist/schemas/generated/skill.d.ts +381 -0
  65. package/dist/schemas/generated/skill.d.ts.map +1 -0
  66. package/dist/schemas/generated/skill.js +216 -0
  67. package/dist/schemas/generated/skill.js.map +1 -0
  68. package/dist/utils/config-manager.d.ts +13 -1
  69. package/dist/utils/config-manager.d.ts.map +1 -1
  70. package/dist/utils/config-manager.js +76 -11
  71. package/dist/utils/config-manager.js.map +1 -1
  72. package/package.json +6 -2
  73. package/src/commands/search.ts +191 -0
  74. package/src/commands/skills/index.ts +7 -0
  75. package/src/commands/skills/install.ts +129 -0
  76. package/src/commands/skills/list.ts +116 -0
  77. package/src/commands/skills/pack.test.ts +260 -0
  78. package/src/commands/skills/pack.ts +145 -0
  79. package/src/commands/skills/pull.ts +88 -0
  80. package/src/commands/skills/search.ts +73 -0
  81. package/src/commands/skills/show.ts +72 -0
  82. package/src/commands/skills/validate.test.ts +466 -0
  83. package/src/commands/skills/validate.ts +227 -0
  84. package/src/lib/api/skills-client.ts +148 -0
  85. package/src/program.test.ts +1 -3
  86. package/src/program.ts +125 -35
  87. package/src/schemas/config.v1.schema.json +37 -0
  88. package/src/schemas/generated/api-responses.ts +386 -0
  89. package/src/schemas/generated/auth.ts +21 -0
  90. package/src/schemas/generated/index.ts +5 -0
  91. package/src/schemas/generated/package.ts +29 -0
  92. package/src/schemas/generated/skill.ts +271 -0
  93. package/src/utils/config-manager.test.ts +182 -2
  94. 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: '1.0.0',
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
- const configJson = readFileSync(this.configFile, 'utf8');
58
- this.config = JSON.parse(configJson) as MpakConfig;
59
- return this.config;
60
- } catch {
61
- this.config = {
62
- version: '1.0.0',
63
- lastUpdated: new Date().toISOString(),
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 {