ppcos 1.0.2 → 1.0.4

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.
@@ -85,8 +85,9 @@ function findCustomSkills(clientDir, manifest) {
85
85
  // Get managed skill directories from manifest
86
86
  const managedSkillDirs = new Set();
87
87
  for (const path of Object.keys(manifest.managedFiles)) {
88
- if (path.startsWith('.claude/skills/')) {
89
- const parts = path.split('/');
88
+ const normalized = path.replace(/\\/g, '/');
89
+ if (normalized.startsWith('.claude/skills/')) {
90
+ const parts = normalized.split('/');
90
91
  if (parts.length >= 3) {
91
92
  managedSkillDirs.add(parts[2]);
92
93
  }
@@ -5,7 +5,7 @@
5
5
  */
6
6
 
7
7
  import { readFileSync, writeFileSync, existsSync, readdirSync, unlinkSync } from 'node:fs';
8
- import { join, dirname } from 'node:path';
8
+ import { join, dirname, sep } from 'node:path';
9
9
  import { fileURLToPath } from 'node:url';
10
10
  import { createInterface } from 'node:readline';
11
11
  import {
@@ -105,6 +105,119 @@ function migrateSettingsDenyRules(clientDir) {
105
105
  }
106
106
  }
107
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
+
108
221
  /**
109
222
  * Find all client directories with .managed.json
110
223
  * @returns {string[]} Array of client names
@@ -220,6 +333,21 @@ async function backupFiles(clientDir, files) {
220
333
  const { writeFile } = await import('node:fs/promises');
221
334
  await writeFile(join(backupDir, 'manifest.txt'), manifestContent);
222
335
 
336
+ // Verify all files were actually backed up before returning
337
+ const { readFileSync } = await import('node:fs');
338
+ for (const relativePath of files) {
339
+ const backedUpPath = join(backupDir, relativePath);
340
+ if (!existsSync(backedUpPath)) {
341
+ throw new Error(`Backup verification failed: ${relativePath} was not created in ${backupDir}`);
342
+ }
343
+ // Verify content matches source (guards against empty/placeholder files from cloud sync)
344
+ const srcContent = readFileSync(join(clientDir, relativePath));
345
+ const backupContent = readFileSync(backedUpPath);
346
+ if (!srcContent.equals(backupContent)) {
347
+ throw new Error(`Backup verification failed: ${relativePath} content mismatch (possible cloud sync interference)`);
348
+ }
349
+ }
350
+
223
351
  return backupDir;
224
352
  }
225
353
 
@@ -252,6 +380,9 @@ async function updateClient(clientName, basePathOrOptions = {}, options = {}) {
252
380
  // Migrations for existing clients
253
381
  await ensureMemoryFolder(clientDir);
254
382
  migrateSettingsDenyRules(clientDir);
383
+ migrateSettingsHooks(clientDir);
384
+ migrateManifestKeys(manifest);
385
+ migrateConfigManagedTypes(manifest);
255
386
 
256
387
  // Check if already up to date
257
388
  if (currentVersion === packageVersion) {
@@ -315,9 +446,16 @@ async function updateClient(clientName, basePathOrOptions = {}, options = {}) {
315
446
  }
316
447
 
317
448
  if (resolution === 'backup') {
318
- const backupDir = await backupFiles(clientDir, modifiedNonOrphans);
319
- backedUpFiles = modifiedNonOrphans;
320
- console.log(` Backed up ${modifiedNonOrphans.length} modified files to ${backupDir.replace(clientDir + '/', '')}`);
449
+ try {
450
+ const backupDir = await backupFiles(clientDir, modifiedNonOrphans);
451
+ backedUpFiles = modifiedNonOrphans;
452
+ const relativePath = backupDir.replace(clientDir + sep, '');
453
+ console.log(` Backed up ${modifiedNonOrphans.length} modified files to ${relativePath}`);
454
+ } catch (err) {
455
+ logger.error(` Backup failed: ${err.message}`);
456
+ logger.error(' Update aborted — no files were overwritten.');
457
+ return { status: 'cancelled' };
458
+ }
321
459
  }
322
460
  }
323
461
 
@@ -345,16 +483,28 @@ async function updateClient(clientName, basePathOrOptions = {}, options = {}) {
345
483
  const srcPath = join(basePath, relativePath);
346
484
  const destPath = join(clientDir, relativePath);
347
485
 
486
+ const srcChecksum = await calculateChecksum(srcPath);
348
487
  await copyFileWithDirs(srcPath, destPath);
349
488
 
350
- const checksum = await calculateChecksum(destPath);
489
+ // Verify copy integrity (guards against cloud sync placeholder files)
490
+ let destChecksum = await calculateChecksum(destPath);
491
+ if (destChecksum !== srcChecksum) {
492
+ // Retry once — cloud sync may have interfered
493
+ await copyFileWithDirs(srcPath, destPath);
494
+ destChecksum = await calculateChecksum(destPath);
495
+ if (destChecksum !== srcChecksum) {
496
+ logger.error(` File write failed for ${relativePath} (possible cloud sync interference).`);
497
+ logger.error(' Update aborted — try running update again.');
498
+ return { status: 'cancelled' };
499
+ }
500
+ }
351
501
 
352
502
  // Preserve managedType from existing manifest or determine new
353
503
  const existingEntry = manifest.managedFiles[relativePath];
354
504
  const managedType = existingEntry?.managedType || getManagedType(relativePath);
355
505
 
356
506
  updatedFiles[relativePath] = {
357
- checksum,
507
+ checksum: srcChecksum,
358
508
  version: packageVersion,
359
509
  managedType
360
510
  };
@@ -366,8 +516,15 @@ async function updateClient(clientName, basePathOrOptions = {}, options = {}) {
366
516
  // Back up modified orphans before removing
367
517
  const modifiedOrphans = orphanedFiles.filter(f => mods.modified.includes(f));
368
518
  if (modifiedOrphans.length > 0) {
369
- const backupDir = await backupFiles(clientDir, modifiedOrphans);
370
- console.log(` Backed up ${modifiedOrphans.length} modified removed file${modifiedOrphans.length !== 1 ? 's' : ''} to ${backupDir.replace(clientDir + '/', '')}`);
519
+ try {
520
+ const backupDir = await backupFiles(clientDir, modifiedOrphans);
521
+ const relPath = backupDir.replace(clientDir + sep, '');
522
+ console.log(` Backed up ${modifiedOrphans.length} modified removed file${modifiedOrphans.length !== 1 ? 's' : ''} to ${relPath}`);
523
+ } catch (err) {
524
+ logger.error(` Backup of removed files failed: ${err.message}`);
525
+ logger.error(' Update aborted — no files were removed.');
526
+ return { status: 'cancelled' };
527
+ }
371
528
  }
372
529
 
373
530
  for (const relativePath of orphanedFiles) {
@@ -45,7 +45,7 @@ export async function getAllFiles(dir, baseDir = dir) {
45
45
  if (entry.isDirectory()) {
46
46
  files.push(...await getAllFiles(fullPath, baseDir));
47
47
  } else {
48
- files.push(relative(baseDir, fullPath));
48
+ files.push(relative(baseDir, fullPath).replace(/\\/g, '/'));
49
49
  }
50
50
  }
51
51
 
@@ -67,7 +67,7 @@ export function getAllFilesSync(dir, baseDir = dir) {
67
67
  if (entry.isDirectory()) {
68
68
  files.push(...getAllFilesSync(fullPath, baseDir));
69
69
  } else {
70
- files.push(relative(baseDir, fullPath));
70
+ files.push(relative(baseDir, fullPath).replace(/\\/g, '/'));
71
71
  }
72
72
  }
73
73
 
@@ -25,7 +25,8 @@ const CONFIG_ONLY_FILES = new Set([
25
25
  * @returns {boolean}
26
26
  */
27
27
  export function isConfigFile(relativePath) {
28
- return CONFIG_ONLY_FILES.has(relativePath);
28
+ // Normalize backslashes to forward slashes for Windows compatibility
29
+ return CONFIG_ONLY_FILES.has(relativePath.replace(/\\/g, '/'));
29
30
  }
30
31
 
31
32
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ppcos",
3
- "version": "1.0.2",
3
+ "version": "1.0.4",
4
4
  "description": "CLI tool to manage Google Ads AI workflow skills and agents for Claude Code",
5
5
  "type": "module",
6
6
  "bin": {