@mohammadhprp/system-prompt 0.12.6 → 0.13.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mohammadhprp/system-prompt",
3
- "version": "0.12.6",
3
+ "version": "0.13.0",
4
4
  "description": "AI Coding Agent Framework — interactive bootstrap CLI",
5
5
  "keywords": [
6
6
  "ai",
package/src/cli.js CHANGED
@@ -2,7 +2,7 @@ import { intro, outro, confirm, multiselect, spinner, isCancel } from '@clack/pr
2
2
  import { resolve } from 'node:path';
3
3
 
4
4
  import { categories } from './catalog.js';
5
- import { getPackageVersion, install, loadLockFile } from './installer.js';
5
+ import { getPackageVersion, install, loadLockFile, lockToSelections } from './installer.js';
6
6
  import { doctor } from './doctor.js';
7
7
 
8
8
  const CATEGORY_FLAGS = new Set(Object.keys(categories));
@@ -71,7 +71,7 @@ function buildSummary(selections) {
71
71
  }
72
72
 
73
73
  function computeDiff(oldLock, selections) {
74
- const oldSels = oldLock.selections;
74
+ const oldSels = lockToSelections(oldLock);
75
75
  const added = {};
76
76
  const removed = {};
77
77
  const kept = {};
@@ -161,7 +161,7 @@ export async function main(argv = process.argv.slice(2)) {
161
161
  agentType,
162
162
  selections,
163
163
  includeAgentsMd: args.includeAgentsMd,
164
- oldSelections: oldLock?.selections,
164
+ oldSelections: oldLock ? lockToSelections(oldLock) : undefined,
165
165
  oldLock,
166
166
  force: args.force,
167
167
  dryRun: args.dryRun,
@@ -315,7 +315,7 @@ export async function main(argv = process.argv.slice(2)) {
315
315
  agentType,
316
316
  selections,
317
317
  includeAgentsMd,
318
- oldSelections: oldLock?.selections,
318
+ oldSelections: oldLock ? lockToSelections(oldLock) : undefined,
319
319
  oldLock,
320
320
  force: args.force,
321
321
  dryRun: args.dryRun,
package/src/doctor.js CHANGED
@@ -3,7 +3,7 @@ import { access, readFile } from 'node:fs/promises';
3
3
  import { createHash } from 'node:crypto';
4
4
 
5
5
  import { categories } from './catalog.js';
6
- import { loadLockFile } from './installer.js';
6
+ import { loadLockFile, lockToSelections } from './installer.js';
7
7
 
8
8
  async function exists(path) {
9
9
  try {
@@ -31,17 +31,32 @@ export async function inspectInstallation(targetDir) {
31
31
  return { targetDir: absTarget, issues };
32
32
  }
33
33
 
34
- for (const [path, expected] of Object.entries(lock.managedFiles || {})) {
34
+ for (const [path, entry] of Object.entries(lock.generated || {})) {
35
35
  try {
36
36
  const actual = createHash('sha256').update(await readFile(resolve(absTarget, path))).digest('hex');
37
- if (actual !== expected) issues.push(`Modified managed file: ${path}`);
37
+ if (actual !== entry.computedHash) issues.push(`Modified managed file: ${path}`);
38
38
  } catch (error) {
39
39
  if (error.code === 'ENOENT') issues.push(`Missing managed file: ${path}`);
40
40
  else throw error;
41
41
  }
42
42
  }
43
43
 
44
- for (const [category, ids] of Object.entries(lock.selections)) {
44
+ for (const [category, entries] of Object.entries(lock)) {
45
+ if (!categories[category]) continue;
46
+ for (const [id, entry] of Object.entries(entries)) {
47
+ for (const [path, expected] of Object.entries(entry.files || {})) {
48
+ try {
49
+ const actual = createHash('sha256').update(await readFile(resolve(absTarget, path))).digest('hex');
50
+ if (actual !== expected) issues.push(`Modified managed file: ${path}`);
51
+ } catch (error) {
52
+ if (error.code === 'ENOENT') issues.push(`Missing managed file: ${path}`);
53
+ else throw error;
54
+ }
55
+ }
56
+ }
57
+ }
58
+
59
+ for (const [category, ids] of Object.entries(lockToSelections(lock))) {
45
60
  const config = categories[category];
46
61
  for (const id of ids) {
47
62
  const item = config.items.find(entry => entry.id === id);
package/src/installer.js CHANGED
@@ -86,6 +86,47 @@ package-lock.json
86
86
  bun.lock
87
87
  `;
88
88
 
89
+ export const LOCK_VERSION = 1;
90
+ export const LOCK_CATEGORIES = ['skills', 'agents', 'commands', 'mcps', 'plugins', 'styles', 'modes', 'memory', 'standards', 'templates'];
91
+
92
+ const FILE_BASED = new Set(['agents', 'commands', 'memory', 'modes', 'standards', 'templates']);
93
+
94
+ export function lockToSelections(lock) {
95
+ const selections = {};
96
+ if (!lock) return selections;
97
+ for (const category of LOCK_CATEGORIES) {
98
+ const ids = Object.keys(lock[category] || {});
99
+ if (ids.length) selections[category] = ids;
100
+ }
101
+ return selections;
102
+ }
103
+
104
+ function itemSourcePath(category, id) {
105
+ const config = categories[category];
106
+ if (FILE_BASED.has(category)) return `${config.sourceDir}/${id}.md`;
107
+ return `${config.sourceDir}/${id}`;
108
+ }
109
+
110
+ function buildComputedHash(files, fallbackId) {
111
+ const keys = Object.keys(files).sort();
112
+ if (keys.length === 0) return hash(fallbackId);
113
+ if (keys.length === 1) return files[keys[0]];
114
+ return hash(keys.map(path => `${path}:${files[path]}`).join('\n'));
115
+ }
116
+
117
+ function getExpectedHash(oldLock, relativePath) {
118
+ const generated = oldLock?.generated?.[relativePath]?.computedHash;
119
+ if (generated) return generated;
120
+ for (const category of LOCK_CATEGORIES) {
121
+ const entries = oldLock?.[category];
122
+ if (!entries) continue;
123
+ for (const entry of Object.values(entries)) {
124
+ const value = entry?.files?.[relativePath];
125
+ if (value) return value;
126
+ }
127
+ }
128
+ return undefined;
129
+ }
89
130
  export async function getPackageVersion() {
90
131
  try {
91
132
  const pkg = JSON.parse(await readFile(resolve(packageRoot, 'package.json'), 'utf-8'));
@@ -114,26 +155,63 @@ function validateLock(lock) {
114
155
  if (!lock || typeof lock !== 'object' || Array.isArray(lock)) {
115
156
  throw new Error('Invalid system-prompt lock: expected an object');
116
157
  }
117
- if (!lock.selections || typeof lock.selections !== 'object' || Array.isArray(lock.selections)) {
118
- throw new Error('Invalid system-prompt lock: selections must be an object');
158
+ if (lock.selections !== undefined || lock.managedFiles !== undefined) {
159
+ throw new Error('Invalid system-prompt lock: legacy lock format no longer supported');
119
160
  }
120
- for (const [category, ids] of Object.entries(lock.selections)) {
121
- const config = categories[category];
122
- if (!config || !Array.isArray(ids) || ids.some(id => typeof id !== 'string')) {
161
+ if (lock.version !== LOCK_VERSION) {
162
+ throw new Error('Invalid system-prompt lock: unsupported version');
163
+ }
164
+ if (typeof lock.agentType !== 'string' || typeof lock.installedAt !== 'string') {
165
+ throw new Error('Invalid system-prompt lock: missing installation metadata');
166
+ }
167
+ if (typeof lock.includeAgentsMd !== 'boolean') {
168
+ throw new Error('Invalid system-prompt lock: includeAgentsMd must be a boolean');
169
+ }
170
+ const allowed = new Set(['version', 'agentType', 'installedAt', 'includeAgentsMd', 'generated', ...LOCK_CATEGORIES]);
171
+ for (const key of Object.keys(lock)) {
172
+ if (!allowed.has(key)) {
173
+ throw new Error(`Invalid system-prompt lock: unknown key ${key}`);
174
+ }
175
+ }
176
+ for (const category of LOCK_CATEGORIES) {
177
+ const entries = lock[category];
178
+ if (entries === undefined) continue;
179
+ if (!entries || typeof entries !== 'object' || Array.isArray(entries)) {
123
180
  throw new Error(`Invalid system-prompt lock: invalid ${category} selection`);
124
181
  }
182
+ const config = categories[category];
125
183
  const knownIds = new Set(config.items.map(item => item.id));
126
- if (ids.some(id => !knownIds.has(id))) {
127
- throw new Error(`Invalid system-prompt lock: unknown ${category} item`);
184
+ for (const [id, entry] of Object.entries(entries)) {
185
+ if (!knownIds.has(id)) {
186
+ throw new Error(`Invalid system-prompt lock: unknown ${category} item`);
187
+ }
188
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
189
+ throw new Error(`Invalid system-prompt lock: invalid ${category} entry`);
190
+ }
191
+ if (typeof entry.source !== 'string' || typeof entry.sourceType !== 'string' || typeof entry.itemPath !== 'string') {
192
+ throw new Error(`Invalid system-prompt lock: invalid ${category} entry source`);
193
+ }
194
+ if (!/^[a-f0-9]{64}$/.test(entry.computedHash || '')) {
195
+ throw new Error(`Invalid system-prompt lock: invalid ${category} entry hash`);
196
+ }
197
+ if (!entry.files || typeof entry.files !== 'object' || Array.isArray(entry.files)) {
198
+ throw new Error(`Invalid system-prompt lock: invalid ${category} entry files`);
199
+ }
200
+ for (const [path, checksum] of Object.entries(entry.files)) {
201
+ if (path.startsWith('/') || path.split('/').includes('..') || !/^[a-f0-9]{64}$/.test(checksum)) {
202
+ throw new Error('Invalid system-prompt lock: unsafe managed file entry');
203
+ }
204
+ }
128
205
  }
129
206
  }
130
- if (lock.managedFiles !== undefined) {
131
- if (!lock.managedFiles || typeof lock.managedFiles !== 'object' || Array.isArray(lock.managedFiles)) {
132
- throw new Error('Invalid system-prompt lock: managedFiles must be an object');
207
+ const generated = lock.generated;
208
+ if (generated !== undefined) {
209
+ if (!generated || typeof generated !== 'object' || Array.isArray(generated)) {
210
+ throw new Error('Invalid system-prompt lock: generated must be an object');
133
211
  }
134
- for (const [path, checksum] of Object.entries(lock.managedFiles)) {
135
- if (path.startsWith('/') || path.split('/').includes('..') || !/^[a-f0-9]{64}$/.test(checksum)) {
136
- throw new Error('Invalid system-prompt lock: unsafe managed file entry');
212
+ for (const [path, entry] of Object.entries(generated)) {
213
+ if (path.startsWith('/') || path.split('/').includes('..') || !/^[a-f0-9]{64}$/.test(entry?.computedHash || '')) {
214
+ throw new Error('Invalid system-prompt lock: unsafe generated file entry');
137
215
  }
138
216
  }
139
217
  }
@@ -152,7 +230,7 @@ async function canWrite(destFile, relativePath, oldLock, force) {
152
230
  if (force) return true;
153
231
  try {
154
232
  const existing = await readFile(destFile);
155
- const previousHash = oldLock?.managedFiles?.[relativePath];
233
+ const previousHash = getExpectedHash(oldLock, relativePath);
156
234
  return Boolean(previousHash && previousHash === hash(existing));
157
235
  } catch (error) {
158
236
  if (error.code === 'ENOENT') return true;
@@ -160,7 +238,7 @@ async function canWrite(destFile, relativePath, oldLock, force) {
160
238
  }
161
239
  }
162
240
 
163
- async function writeManagedFile(destFile, content, relativePath, options) {
241
+ async function writeManagedFile(destFile, content, relativePath, options, owner) {
164
242
  await assertSafeDestination(options.targetDir, destFile);
165
243
  if (!options.allowExistingMerge && !(await canWrite(destFile, relativePath, options.oldLock, options.force))) {
166
244
  console.warn(` ⚠ Preserving existing file: ${relativePath}`);
@@ -169,10 +247,11 @@ async function writeManagedFile(destFile, content, relativePath, options) {
169
247
  if (!options.dryRun) await mkdir(dirname(destFile), { recursive: true });
170
248
  if (!options.dryRun) await writeFile(destFile, content);
171
249
  options.managedFiles[relativePath] = hash(content);
250
+ if (owner) options.fileOwners[relativePath] = owner;
172
251
  return true;
173
252
  }
174
253
 
175
- async function copyDir(src, dest, relativeDir, options) {
254
+ async function copyDir(src, dest, relativeDir, options, owner) {
176
255
  if (!options.dryRun) await mkdir(dest, { recursive: true });
177
256
  const entries = await readdir(src, { withFileTypes: true });
178
257
 
@@ -183,10 +262,10 @@ async function copyDir(src, dest, relativeDir, options) {
183
262
  const relativePath = `${relativeDir}/${entry.name}`;
184
263
 
185
264
  if (entry.isDirectory()) {
186
- await copyDir(srcPath, destPath, relativePath, options);
265
+ await copyDir(srcPath, destPath, relativePath, options, owner);
187
266
  } else if (entry.isFile()) {
188
267
  const content = await readFile(srcPath);
189
- await writeManagedFile(destPath, content, relativePath, options);
268
+ await writeManagedFile(destPath, content, relativePath, options, owner);
190
269
  }
191
270
  }
192
271
  }
@@ -241,7 +320,7 @@ async function copySelectedDirs(targetDir, category, selectedIds, options) {
241
320
 
242
321
  try {
243
322
  await stat(srcPath);
244
- await copyDir(srcPath, destPath, `${relativeDir}/${id}`, options);
323
+ await copyDir(srcPath, destPath, `${relativeDir}/${id}`, options, { category, id });
245
324
  } catch (error) {
246
325
  if (sourceMissing(error)) {
247
326
  console.warn(` ⚠ Source not found: ${catConfig.sourceDir}/${id}`);
@@ -268,7 +347,7 @@ async function copySelectedFiles(targetDir, category, selectedIds, options) {
268
347
  await assertSafeDestination(targetDir, destFile);
269
348
  try {
270
349
  const content = await readFile(srcFile);
271
- await writeManagedFile(destFile, content, `${relativeDir}/${id}.md`, options);
350
+ await writeManagedFile(destFile, content, `${relativeDir}/${id}.md`, options, { category, id });
272
351
  } catch (error) {
273
352
  if (sourceMissing(error)) {
274
353
  console.warn(` ⚠ Source not found: ${catConfig.sourceDir}/${id}.md`);
@@ -283,7 +362,6 @@ async function deleteSelectedItems(absTarget, category, ids, oldLock, force, dry
283
362
  const catConfig = categories[category];
284
363
  if (!catConfig || !ids?.length) return;
285
364
 
286
- const FILE_BASED = new Set(['agents', 'commands', 'memory', 'modes', 'standards', 'templates']);
287
365
  if (!FILE_BASED.has(category) && category !== 'skills' && category !== 'styles') return;
288
366
 
289
367
  const relativeDir = targetSubdir(catConfig.sourceDir);
@@ -294,8 +372,8 @@ async function deleteSelectedItems(absTarget, category, ids, oldLock, force, dry
294
372
  const destPath = resolve(absTarget, relativePath);
295
373
  await assertSafeDestination(absTarget, destPath);
296
374
  if (!force && oldLock) {
297
- const managedEntries = Object.entries(oldLock.managedFiles || {})
298
- .filter(([path]) => path === relativePath || path.startsWith(`${relativePath}/`));
375
+ const oldFiles = oldLock?.[category]?.[id]?.files || {};
376
+ const managedEntries = Object.entries(oldFiles);
299
377
  if (managedEntries.length === 0) {
300
378
  console.warn(` ⚠ Preserving unmanaged item: ${relativePath}`);
301
379
  continue;
@@ -363,7 +441,7 @@ async function mergeJsonFile(destFile, generated, relativePath, options, previou
363
441
  return writeManagedFile(destFile, Buffer.from(JSON.stringify(merged, null, 4)), relativePath, {
364
442
  ...options,
365
443
  allowExistingMerge: !options.oldLock,
366
- });
444
+ }, { generated: true });
367
445
  }
368
446
 
369
447
  async function mergeGitignore(destFile, options) {
@@ -378,7 +456,7 @@ async function mergeGitignore(destFile, options) {
378
456
  return writeManagedFile(destFile, Buffer.from(`${[...lines].join('\n')}\n`), '.gitignore', {
379
457
  ...options,
380
458
  allowExistingMerge: !options.oldLock,
381
- });
459
+ }, { generated: true });
382
460
  }
383
461
 
384
462
  export function validateSelections(selections) {
@@ -405,8 +483,27 @@ export async function install({ targetDir, agentType, selections, includeAgentsM
405
483
  oldLock,
406
484
  force,
407
485
  dryRun,
408
- managedFiles: { ...(oldLock?.managedFiles || {}) },
486
+ managedFiles: {},
487
+ fileOwners: {},
409
488
  };
489
+ if (oldLock) {
490
+ for (const category of LOCK_CATEGORIES) {
491
+ for (const [id, entry] of Object.entries(oldLock[category] || {})) {
492
+ for (const [path, checksum] of Object.entries(entry.files || {})) {
493
+ if (!(path in options.managedFiles)) {
494
+ options.managedFiles[path] = checksum;
495
+ options.fileOwners[path] = { category, id };
496
+ }
497
+ }
498
+ }
499
+ }
500
+ for (const [path, entry] of Object.entries(oldLock.generated || {})) {
501
+ if (!(path in options.managedFiles)) {
502
+ options.managedFiles[path] = entry.computedHash;
503
+ options.fileOwners[path] = { generated: true };
504
+ }
505
+ }
506
+ }
410
507
 
411
508
  // Delete items that are no longer selected.
412
509
  if (oldSelections) {
@@ -428,7 +525,7 @@ export async function install({ targetDir, agentType, selections, includeAgentsM
428
525
  await Promise.all(tasks);
429
526
 
430
527
  if (writeAgentsMd) {
431
- await writeManagedFile(resolve(absTarget, 'AGENTS.md'), Buffer.from(AGENTS_MD), 'AGENTS.md', options);
528
+ await writeManagedFile(resolve(absTarget, 'AGENTS.md'), Buffer.from(AGENTS_MD), 'AGENTS.md', options, { generated: true });
432
529
  }
433
530
 
434
531
  if (agentType === 'opencode') {
@@ -437,14 +534,15 @@ export async function install({ targetDir, agentType, selections, includeAgentsM
437
534
  let previousOpenCodeConfig;
438
535
  let previousTuiConfig;
439
536
  if (oldLock) {
537
+ const oldSelections = lockToSelections(oldLock);
440
538
  let previousMcpEntries = {};
441
- if (oldLock.selections.mcps?.length) previousMcpEntries = await loadMcpConfigs(oldLock.selections.mcps);
539
+ if (oldSelections.mcps?.length) previousMcpEntries = await loadMcpConfigs(oldSelections.mcps);
442
540
  previousOpenCodeConfig = JSON.parse(generateOpenCodeConfig({
443
- selections: oldLock.selections,
541
+ selections: oldSelections,
444
542
  mcpEntries: previousMcpEntries,
445
543
  includeAgentsMd: oldLock.includeAgentsMd ?? true,
446
544
  }));
447
- previousTuiConfig = JSON.parse(generateTuiConfig({ selections: oldLock.selections }));
545
+ previousTuiConfig = JSON.parse(generateTuiConfig({ selections: oldSelections }));
448
546
  }
449
547
  const configJson = generateOpenCodeConfig({ selections, mcpEntries, includeAgentsMd });
450
548
  await mergeJsonFile(resolve(absTarget, 'opencode.json'), JSON.parse(configJson), 'opencode.json', options, previousOpenCodeConfig);
@@ -458,16 +556,38 @@ export async function install({ targetDir, agentType, selections, includeAgentsM
458
556
  }
459
557
 
460
558
  if (dryRun) return absTarget;
461
- const version = await getPackageVersion();
559
+ const pkgVersion = await getPackageVersion();
560
+ const source = `system-prompt@${pkgVersion}`;
462
561
  const lockData = {
463
- version,
562
+ version: LOCK_VERSION,
464
563
  agentType,
465
- targetDir,
466
564
  installedAt: new Date().toISOString(),
467
- selections: Object.fromEntries(Object.entries(selections).map(([k, v]) => [k, [...v]])),
468
565
  includeAgentsMd,
469
- managedFiles: options.managedFiles,
470
566
  };
567
+ for (const category of LOCK_CATEGORIES) {
568
+ const entries = {};
569
+ for (const id of selections[category] || []) {
570
+ if (await isRemoved(category, id)) continue;
571
+ const files = {};
572
+ for (const [path, checksum] of Object.entries(options.managedFiles)) {
573
+ const owner = options.fileOwners[path];
574
+ if (owner?.category === category && owner?.id === id) files[path] = checksum;
575
+ }
576
+ entries[id] = {
577
+ source,
578
+ sourceType: 'bundled',
579
+ itemPath: itemSourcePath(category, id),
580
+ computedHash: buildComputedHash(files, id),
581
+ files,
582
+ };
583
+ }
584
+ lockData[category] = entries;
585
+ }
586
+ const generated = {};
587
+ for (const [path, checksum] of Object.entries(options.managedFiles)) {
588
+ if (options.fileOwners[path]?.generated) generated[path] = { computedHash: checksum };
589
+ }
590
+ lockData.generated = generated;
471
591
  await writeFile(resolve(absTarget, 'system-prompt-lock.json'), JSON.stringify(lockData, null, 2));
472
592
  return absTarget;
473
593
  }
@@ -548,4 +668,5 @@ async function writeMergedEnv(absTarget, examples, options) {
548
668
  const content = Buffer.from(`${existingContent}${separator}${additions.join('\n')}\n`);
549
669
  if (!options.dryRun) await writeFile(envPath, content);
550
670
  options.managedFiles['.env'] = hash(content);
671
+ options.fileOwners['.env'] = { generated: true };
551
672
  }