ppcos 1.0.6 → 1.0.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.
@@ -4,374 +4,36 @@
4
4
  * Usage: ppcos update [--client <name>] [--dry-run]
5
5
  */
6
6
 
7
- import { readFileSync, writeFileSync, existsSync, readdirSync, unlinkSync } from 'node:fs';
8
- import { join, dirname, sep } from 'node:path';
9
- import { fileURLToPath } from 'node:url';
10
- import { createInterface } from 'node:readline';
7
+ import { existsSync, unlinkSync } from 'node:fs';
8
+ import { join, sep } from 'node:path';
11
9
  import {
12
10
  readManifest,
13
11
  writeManifest,
14
12
  detectModifications,
15
13
  addConflict,
16
- manifestExists,
17
14
  getManagedType,
18
15
  isConfigFile
19
16
  } from '../utils/manifest.js';
20
17
  import { calculateChecksum } from '../utils/checksum.js';
21
- import {
22
- getAllFiles,
23
- copyFileWithDirs,
24
- ensureDir,
25
- createBackupTimestamp
26
- } from '../utils/fs-helpers.js';
18
+ import { getAllFiles, copyFileWithDirs } from '../utils/fs-helpers.js';
27
19
  import { fetchSkills } from '../utils/skills-fetcher.js';
28
20
  import { mkdtempSync, rmSync } from 'node:fs';
29
21
  import { tmpdir } from 'node:os';
30
- import { displayDiffs } from '../utils/diff.js';
31
22
  import logger from '../utils/logger.js';
32
23
  import ora from 'ora';
24
+ import {
25
+ getPackageVersion,
26
+ getBaseTemplatePath,
27
+ getClientsDir,
28
+ discoverClients
29
+ } from '../utils/paths.js';
30
+ import { runAllMigrations } from '../utils/migrations.js';
31
+ import { promptConflictResolution, backupFiles } from '../utils/conflicts.js';
32
+ import updateHub from './update-hub.js';
33
33
 
34
- // Get package root directory
35
- const __filename = fileURLToPath(import.meta.url);
36
- const __dirname = dirname(__filename);
37
- const PACKAGE_ROOT = join(__dirname, '..', '..');
38
-
39
- /**
40
- * Get package version from package.json
41
- * @returns {string}
42
- */
43
- function getPackageVersion() {
44
- const pkgPath = join(PACKAGE_ROOT, 'package.json');
45
- const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
46
- return pkg.version;
47
- }
48
-
49
- /**
50
- * Get path to .claude-base template directory
51
- * @returns {string}
52
- */
53
- function getBaseTemplatePath() {
54
- return join(PACKAGE_ROOT, '.claude-base');
55
- }
56
-
57
- /**
58
- * Get clients directory path
59
- * @returns {string}
60
- */
61
- function getClientsDir() {
62
- return join(process.cwd(), 'clients');
63
- }
64
-
65
- /**
66
- * Ensure memory folder exists in client workspace
67
- * Creates context/memory/ if it doesn't exist (for existing clients)
68
- * @param {string} clientDir - Path to client directory
69
- */
70
- async function ensureMemoryFolder(clientDir) {
71
- const memoryDir = join(clientDir, 'context', 'memory');
72
- if (!existsSync(memoryDir)) {
73
- await ensureDir(memoryDir);
74
- logger.info(' Created context/memory/ folder');
75
- }
76
- }
77
-
78
- /**
79
- * One-time migration: merge deny rules into settings.local.json
80
- * Adds missing deny entries without touching existing config
81
- */
82
- function migrateSettingsDenyRules(clientDir) {
83
- const settingsPath = join(clientDir, '.claude', 'settings.local.json');
84
- if (!existsSync(settingsPath)) return;
85
-
86
- const requiredDeny = [
87
- 'Read(./.env)',
88
- 'Read(./.env.*)',
89
- 'Read(./secrets/**)'
90
- ];
91
-
92
- try {
93
- const settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
94
- if (!settings.permissions) settings.permissions = {};
95
- const existing = settings.permissions.deny || [];
96
- const toAdd = requiredDeny.filter(rule => !existing.includes(rule));
97
-
98
- if (toAdd.length === 0) return;
99
-
100
- settings.permissions.deny = [...existing, ...toAdd];
101
- writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
102
- logger.info(` Added ${toAdd.length} deny rule${toAdd.length !== 1 ? 's' : ''} to settings.local.json`);
103
- } catch {
104
- // Don't fail update over migration
105
- }
106
- }
107
-
108
- /**
109
- * One-time migration: merge hooks config into settings.local.json
110
- * Adds missing hook events without touching existing ones
111
- */
112
- function migrateSettingsHooks(clientDir) {
113
- const settingsPath = join(clientDir, '.claude', 'settings.local.json');
114
- if (!existsSync(settingsPath)) return;
115
-
116
- const requiredHooks = {
117
- SessionStart: [
118
- {
119
- matcher: 'startup|resume|clear',
120
- hooks: [
121
- {
122
- type: 'command',
123
- command: '.claude/hooks/session-context-check.sh',
124
- timeout: 30,
125
- statusMessage: 'Checking context freshness...'
126
- }
127
- ]
128
- }
129
- ],
130
- Stop: [
131
- {
132
- hooks: [
133
- {
134
- type: 'command',
135
- command: '.claude/hooks/stop-memory-check.sh',
136
- timeout: 10,
137
- statusMessage: 'Checking memory log...'
138
- }
139
- ]
140
- }
141
- ],
142
- PostCompact: [
143
- {
144
- hooks: [
145
- {
146
- type: 'command',
147
- command: '.claude/hooks/post-compact-reminder.sh',
148
- timeout: 30,
149
- statusMessage: 'Restoring context after compaction...'
150
- }
151
- ]
152
- }
153
- ]
154
- };
155
-
156
- try {
157
- const settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
158
- if (!settings.hooks) settings.hooks = {};
159
-
160
- const added = [];
161
- for (const [event, config] of Object.entries(requiredHooks)) {
162
- if (!settings.hooks[event]) {
163
- settings.hooks[event] = config;
164
- added.push(event);
165
- }
166
- }
167
-
168
- if (added.length === 0) return;
169
-
170
- writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
171
- logger.info(` Added ${added.length} hook${added.length !== 1 ? 's' : ''} to settings.local.json (${added.join(', ')})`);
172
- } catch {
173
- // Don't fail update over migration
174
- }
175
- }
176
-
177
- /**
178
- * One-time migration: normalize backslash manifest keys to forward slashes.
179
- * On Windows, getAllFiles() returned backslash paths which were stored as manifest keys.
180
- * This normalizes them so lookups work cross-platform.
181
- */
182
- function migrateManifestKeys(manifest) {
183
- let fixed = 0;
184
- for (const [key, value] of Object.entries(manifest.managedFiles)) {
185
- const normalized = key.replace(/\\/g, '/');
186
- if (normalized !== key) {
187
- delete manifest.managedFiles[key];
188
- manifest.managedFiles[normalized] = value;
189
- fixed++;
190
- }
191
- }
192
- for (const conflict of (manifest.conflicts || [])) {
193
- if (conflict.file) {
194
- conflict.file = conflict.file.replace(/\\/g, '/');
195
- }
196
- }
197
- if (fixed > 0) {
198
- logger.info(` Normalized ${fixed} manifest key${fixed !== 1 ? 's' : ''}`);
199
- }
200
- }
201
-
202
- /**
203
- * One-time migration: fix managedType for config files.
204
- * On Windows, isConfigFile() failed due to backslash paths, so config files
205
- * got stored with managedType 'update' instead of 'config'.
206
- * This corrects existing manifests in-place (before detectModifications runs).
207
- */
208
- function migrateConfigManagedTypes(manifest) {
209
- let fixed = 0;
210
- for (const [path, info] of Object.entries(manifest.managedFiles)) {
211
- if (isConfigFile(path) && info.managedType !== 'config') {
212
- info.managedType = 'config';
213
- fixed++;
214
- }
215
- }
216
- if (fixed > 0) {
217
- logger.info(` Fixed managedType for ${fixed} config file${fixed !== 1 ? 's' : ''}`);
218
- }
219
- }
220
-
221
- /**
222
- * One-time migration: remove leaked data files from skill tmp/ directories.
223
- * Data files were accidentally included in .claude-base.zip and distributed
224
- * to clients. This removes them and cleans up the manifest.
225
- */
226
- function migrateTmpDataFiles(clientDir, manifest) {
227
- const keepFiles = new Set(['.gitkeep', '.gitignore']);
228
- let removed = 0;
229
- for (const [path] of Object.entries(manifest.managedFiles)) {
230
- if (!/\/tmp\//.test(path)) continue;
231
- const filename = path.split('/').pop();
232
- if (keepFiles.has(filename)) continue;
233
- const fullPath = join(clientDir, path);
234
- if (existsSync(fullPath)) unlinkSync(fullPath);
235
- delete manifest.managedFiles[path];
236
- removed++;
237
- }
238
- if (removed > 0) {
239
- logger.info(` Removed ${removed} tmp data file${removed !== 1 ? 's' : ''} from tmp/`);
240
- }
241
- }
242
-
243
- /**
244
- * Find all client directories with .managed.json
245
- * @returns {string[]} Array of client names
246
- */
247
- function discoverClients() {
248
- const clientsDir = getClientsDir();
249
-
250
- if (!existsSync(clientsDir)) {
251
- return [];
252
- }
253
-
254
- const entries = readdirSync(clientsDir, { withFileTypes: true });
255
- const clients = [];
256
-
257
- for (const entry of entries) {
258
- if (entry.isDirectory()) {
259
- const clientDir = join(clientsDir, entry.name);
260
- if (manifestExists(clientDir)) {
261
- clients.push(entry.name);
262
- }
263
- }
264
- }
265
-
266
- return clients;
267
- }
268
-
269
- /**
270
- * Prompt user for conflict resolution
271
- * @param {string[]} modifiedFiles - List of modified file paths
272
- * @param {string} clientDir - Path to client directory
273
- * @param {string} basePath - Path to base template directory
274
- * @returns {Promise<'backup'|'overwrite'|'skip'|'cancel'>}
275
- */
276
- async function promptConflictResolution(modifiedFiles, clientDir, basePath) {
277
- console.log('');
278
- logger.warn('Modified files detected:');
279
- for (const file of modifiedFiles) {
280
- console.log(` - ${file}`);
281
- }
282
-
283
- const showMenu = () => {
284
- console.log('');
285
- console.log('Options:');
286
- console.log(' [d] View differences');
287
- console.log(' [1] Backup and overwrite (files saved to .backup/)');
288
- console.log(' [2] Overwrite without backup (discard your changes)');
289
- console.log(' [3] Skip modified files (keep your changes)');
290
- console.log(' [4] Cancel update');
291
- console.log('');
292
- };
293
-
294
- showMenu();
295
-
296
- const rl = createInterface({
297
- input: process.stdin,
298
- output: process.stdout
299
- });
300
-
301
- return new Promise((resolve) => {
302
- const ask = () => {
303
- rl.question('Choice [d/1/2/3/4]: ', (answer) => {
304
- const choice = answer.trim().toLowerCase();
305
- if (choice === 'd') {
306
- displayDiffs(modifiedFiles, clientDir, basePath);
307
- showMenu();
308
- ask();
309
- } else if (choice === '1') {
310
- rl.close();
311
- resolve('backup');
312
- } else if (choice === '2') {
313
- rl.close();
314
- resolve('overwrite');
315
- } else if (choice === '3') {
316
- rl.close();
317
- resolve('skip');
318
- } else if (choice === '4') {
319
- rl.close();
320
- resolve('cancel');
321
- } else {
322
- console.log('Invalid choice. Please enter d, 1, 2, 3, or 4.');
323
- ask();
324
- }
325
- });
326
- };
327
- ask();
328
- });
329
- }
330
-
331
- /**
332
- * Backup modified files to .backup/<timestamp>/
333
- * @param {string} clientDir - Path to client directory
334
- * @param {string[]} files - Relative paths of files to backup
335
- * @returns {Promise<string>} Backup folder path
336
- */
337
- async function backupFiles(clientDir, files) {
338
- const timestamp = createBackupTimestamp();
339
- const backupDir = join(clientDir, '.backup', timestamp);
340
-
341
- for (const relativePath of files) {
342
- const srcPath = join(clientDir, relativePath);
343
- const destPath = join(backupDir, relativePath);
344
- await copyFileWithDirs(srcPath, destPath);
345
- }
346
-
347
- // Write backup manifest
348
- const manifestContent = [
349
- `# Backup created during update`,
350
- `# ${new Date().toISOString()}`,
351
- '',
352
- ...files
353
- ].join('\n');
354
- await ensureDir(backupDir);
355
- const { writeFile } = await import('node:fs/promises');
356
- await writeFile(join(backupDir, 'manifest.txt'), manifestContent);
357
-
358
- // Verify all files were actually backed up before returning
359
- const { readFileSync } = await import('node:fs');
360
- for (const relativePath of files) {
361
- const backedUpPath = join(backupDir, relativePath);
362
- if (!existsSync(backedUpPath)) {
363
- throw new Error(`Backup verification failed: ${relativePath} was not created in ${backupDir}`);
364
- }
365
- // Verify content matches source (guards against empty/placeholder files from cloud sync)
366
- const srcContent = readFileSync(join(clientDir, relativePath));
367
- const backupContent = readFileSync(backedUpPath);
368
- if (!srcContent.equals(backupContent)) {
369
- throw new Error(`Backup verification failed: ${relativePath} content mismatch (possible cloud sync interference)`);
370
- }
371
- }
372
-
373
- return backupDir;
374
- }
34
+ // Directories to exclude when enumerating client template files.
35
+ // hub-base/ contains hub-level skills that are handled separately.
36
+ const CLIENT_TEMPLATE_EXCLUDE_DIRS = new Set(['tmp', 'hub-base']);
375
37
 
376
38
  /**
377
39
  * Update a single client
@@ -400,20 +62,15 @@ async function updateClient(clientName, basePathOrOptions = {}, options = {}) {
400
62
  const currentVersion = manifest.baseVersion;
401
63
 
402
64
  // Migrations for existing clients
403
- await ensureMemoryFolder(clientDir);
404
- migrateSettingsDenyRules(clientDir);
405
- migrateSettingsHooks(clientDir);
406
- migrateManifestKeys(manifest);
407
- migrateConfigManagedTypes(manifest);
408
- migrateTmpDataFiles(clientDir, manifest);
65
+ await runAllMigrations(clientDir, manifest);
409
66
 
410
67
  // Check if already up to date
411
68
  if (currentVersion === packageVersion) {
412
69
  return { status: 'up-to-date' };
413
70
  }
414
71
 
415
- // Get base template files
416
- const baseFiles = await getAllFiles(basePath);
72
+ // Get base template files (exclude hub-base/ which is handled separately)
73
+ const baseFiles = await getAllFiles(basePath, basePath, CLIENT_TEMPLATE_EXCLUDE_DIRS);
417
74
 
418
75
  // Detect modifications
419
76
  const mods = await detectModifications(clientDir, manifest, baseFiles);
@@ -635,6 +292,9 @@ export default async function update(options = {}) {
635
292
  }
636
293
  }
637
294
 
295
+ // Update hub-level skills (before client updates)
296
+ await updateHub(basePath, options);
297
+
638
298
  // Discover clients
639
299
  let clients = discoverClients();
640
300
 
@@ -765,11 +425,7 @@ export default async function update(options = {}) {
765
425
  }
766
426
 
767
427
  // Export helpers for testing
768
- export {
769
- getPackageVersion,
770
- getBaseTemplatePath,
771
- getClientsDir,
772
- discoverClients,
773
- backupFiles,
774
- updateClient
775
- };
428
+ export { updateClient };
429
+ export { default as updateHub } from './update-hub.js';
430
+ export { backupFiles } from '../utils/conflicts.js';
431
+ export { getPackageVersion, getBaseTemplatePath, getHubBasePath, getClientsDir, discoverClients } from '../utils/paths.js';
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Conflict resolution and file backup utilities.
3
+ *
4
+ * Used by update commands when managed files have been
5
+ * modified by the user since the last update.
6
+ */
7
+
8
+ import { existsSync, readFileSync } from 'node:fs';
9
+ import { writeFile } from 'node:fs/promises';
10
+ import { join } from 'node:path';
11
+ import { createInterface } from 'node:readline';
12
+ import { copyFileWithDirs, ensureDir, createBackupTimestamp } from './fs-helpers.js';
13
+ import { displayDiffs } from './diff.js';
14
+ import logger from './logger.js';
15
+
16
+ /**
17
+ * Prompt user for conflict resolution
18
+ * @param {string[]} modifiedFiles - List of modified file paths
19
+ * @param {string} clientDir - Path to client directory
20
+ * @param {string} basePath - Path to base template directory
21
+ * @returns {Promise<'backup'|'overwrite'|'skip'|'cancel'>}
22
+ */
23
+ export async function promptConflictResolution(modifiedFiles, clientDir, basePath) {
24
+ console.log('');
25
+ logger.warn('Modified files detected:');
26
+ for (const file of modifiedFiles) {
27
+ console.log(` - ${file}`);
28
+ }
29
+
30
+ const showMenu = () => {
31
+ console.log('');
32
+ console.log('Options:');
33
+ console.log(' [d] View differences');
34
+ console.log(' [1] Backup and overwrite (files saved to .backup/)');
35
+ console.log(' [2] Overwrite without backup (discard your changes)');
36
+ console.log(' [3] Skip modified files (keep your changes)');
37
+ console.log(' [4] Cancel update');
38
+ console.log('');
39
+ };
40
+
41
+ showMenu();
42
+
43
+ const rl = createInterface({
44
+ input: process.stdin,
45
+ output: process.stdout
46
+ });
47
+
48
+ return new Promise((resolve) => {
49
+ const ask = () => {
50
+ rl.question('Choice [d/1/2/3/4]: ', (answer) => {
51
+ const choice = answer.trim().toLowerCase();
52
+ if (choice === 'd') {
53
+ displayDiffs(modifiedFiles, clientDir, basePath);
54
+ showMenu();
55
+ ask();
56
+ } else if (choice === '1') {
57
+ rl.close();
58
+ resolve('backup');
59
+ } else if (choice === '2') {
60
+ rl.close();
61
+ resolve('overwrite');
62
+ } else if (choice === '3') {
63
+ rl.close();
64
+ resolve('skip');
65
+ } else if (choice === '4') {
66
+ rl.close();
67
+ resolve('cancel');
68
+ } else {
69
+ console.log('Invalid choice. Please enter d, 1, 2, 3, or 4.');
70
+ ask();
71
+ }
72
+ });
73
+ };
74
+ ask();
75
+ });
76
+ }
77
+
78
+ /**
79
+ * Backup modified files to .backup/<timestamp>/
80
+ * @param {string} clientDir - Path to client directory
81
+ * @param {string[]} files - Relative paths of files to backup
82
+ * @returns {Promise<string>} Backup folder path
83
+ */
84
+ export async function backupFiles(clientDir, files) {
85
+ const timestamp = createBackupTimestamp();
86
+ const backupDir = join(clientDir, '.backup', timestamp);
87
+
88
+ for (const relativePath of files) {
89
+ const srcPath = join(clientDir, relativePath);
90
+ const destPath = join(backupDir, relativePath);
91
+ await copyFileWithDirs(srcPath, destPath);
92
+ }
93
+
94
+ // Write backup manifest
95
+ const manifestContent = [
96
+ `# Backup created during update`,
97
+ `# ${new Date().toISOString()}`,
98
+ '',
99
+ ...files
100
+ ].join('\n');
101
+ await ensureDir(backupDir);
102
+ await writeFile(join(backupDir, 'manifest.txt'), manifestContent);
103
+
104
+ // Verify all files were actually backed up before returning
105
+ for (const relativePath of files) {
106
+ const backedUpPath = join(backupDir, relativePath);
107
+ if (!existsSync(backedUpPath)) {
108
+ throw new Error(`Backup verification failed: ${relativePath} was not created in ${backupDir}`);
109
+ }
110
+ // Verify content matches source (guards against empty/placeholder files from cloud sync)
111
+ const srcContent = readFileSync(join(clientDir, relativePath));
112
+ const backupContent = readFileSync(backedUpPath);
113
+ if (!srcContent.equals(backupContent)) {
114
+ throw new Error(`Backup verification failed: ${relativePath} content mismatch (possible cloud sync interference)`);
115
+ }
116
+ }
117
+
118
+ return backupDir;
119
+ }
@@ -9,6 +9,7 @@ import { calculateChecksum } from './checksum.js';
9
9
  import { fileExists } from './fs-helpers.js';
10
10
 
11
11
  const MANIFEST_FILENAME = '.managed.json';
12
+ const HUB_MANIFEST_FILENAME = '.managed-hub.json';
12
13
  const SCHEMA_VERSION = '1.0';
13
14
 
14
15
  // Files that should be config-only (init once, never update)
@@ -16,7 +17,8 @@ const CONFIG_ONLY_FILES = new Set([
16
17
  'config/ads-context.config.json',
17
18
  '.claude/settings.local.json',
18
19
  'config/.env',
19
- 'context/business.md'
20
+ 'context/business.md',
21
+ '.claude/skills/report-generator/reference/html-base-template.md'
20
22
  ]);
21
23
 
22
24
  /**
@@ -117,6 +119,44 @@ export function manifestExists(clientDir) {
117
119
  return existsSync(join(clientDir, MANIFEST_FILENAME));
118
120
  }
119
121
 
122
+ // --- Hub manifest functions ---
123
+
124
+ /**
125
+ * Read .managed-hub.json from the hub root directory (async)
126
+ * @param {string} hubDir - Path to hub root directory
127
+ * @returns {Promise<object>} Parsed manifest
128
+ */
129
+ export async function readHubManifest(hubDir) {
130
+ const manifestPath = join(hubDir, HUB_MANIFEST_FILENAME);
131
+ const content = await readFile(manifestPath, 'utf8');
132
+ const manifest = JSON.parse(content);
133
+
134
+ if (manifest.schemaVersion !== SCHEMA_VERSION) {
135
+ throw new Error(`Unsupported hub manifest schema version: ${manifest.schemaVersion}`);
136
+ }
137
+
138
+ return manifest;
139
+ }
140
+
141
+ /**
142
+ * Write .managed-hub.json to the hub root directory (async)
143
+ * @param {string} hubDir - Path to hub root directory
144
+ * @param {object} manifest - Manifest object to write
145
+ */
146
+ export async function writeHubManifest(hubDir, manifest) {
147
+ const manifestPath = join(hubDir, HUB_MANIFEST_FILENAME);
148
+ await writeFile(manifestPath, JSON.stringify(manifest, null, 2) + '\n');
149
+ }
150
+
151
+ /**
152
+ * Check if a hub manifest exists
153
+ * @param {string} hubDir - Path to hub root directory
154
+ * @returns {boolean}
155
+ */
156
+ export function hubManifestExists(hubDir) {
157
+ return existsSync(join(hubDir, HUB_MANIFEST_FILENAME));
158
+ }
159
+
120
160
  /**
121
161
  * Detect modifications to managed files
122
162
  * @param {string} clientDir - Path to client directory