ppcos 1.0.1 → 1.0.3

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/bin/ppcos.js CHANGED
@@ -25,10 +25,12 @@ const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8
25
25
  */
26
26
  function gated(fn) {
27
27
  return async (...args) => {
28
- const auth = requireAuth();
29
- if (!auth) {
30
- process.exitCode = 1;
31
- return;
28
+ if (!process.env.PPCOS_DEV) {
29
+ const auth = requireAuth();
30
+ if (!auth) {
31
+ process.exitCode = 1;
32
+ return;
33
+ }
32
34
  }
33
35
  return fn(...args);
34
36
  };
@@ -4,7 +4,7 @@
4
4
  * Usage: ppcos init <client-name> [--skip-config]
5
5
  */
6
6
 
7
- import { readFileSync, existsSync } from 'node:fs';
7
+ import { readFileSync, existsSync, rmSync } from 'node:fs';
8
8
  import { readFile, writeFile } from 'node:fs/promises';
9
9
  import { join, dirname } from 'node:path';
10
10
  import { fileURLToPath } from 'node:url';
@@ -13,7 +13,6 @@ import { calculateChecksum } from '../utils/checksum.js';
13
13
  import { createManifest, writeManifest, manifestExists, getManagedType } from '../utils/manifest.js';
14
14
  import { getAllFiles, copyFileWithDirs, ensureDir, fileExists } from '../utils/fs-helpers.js';
15
15
  import { fetchSkills } from '../utils/skills-fetcher.js';
16
- import { requireAuth } from '../utils/auth.js';
17
16
  import logger from '../utils/logger.js';
18
17
  import ora from 'ora';
19
18
 
@@ -152,14 +151,17 @@ export default async function init(clientName, options = {}) {
152
151
 
153
152
  // 3. Fetch skills from API (with local fallback for dev/testing)
154
153
  const spinner = ora('Fetching skills from API...').start();
155
- let usedLocalFallback = false;
156
-
157
154
  try {
158
155
  await fetchSkills(clientDir);
159
156
  spinner.succeed('Skills downloaded');
160
157
  } catch (error) {
161
158
  spinner.fail('Failed to fetch skills');
162
159
 
160
+ // Clean up partial directory from failed download
161
+ if (existsSync(clientDir)) {
162
+ rmSync(clientDir, { recursive: true, force: true });
163
+ }
164
+
163
165
  // Fallback to local .claude-base for development/testing
164
166
  const basePath = getBaseTemplatePath();
165
167
  if (existsSync(basePath)) {
@@ -171,7 +173,6 @@ export default async function init(clientName, options = {}) {
171
173
  const destPath = join(clientDir, relativePath);
172
174
  await copyFileWithDirs(srcPath, destPath);
173
175
  }
174
- usedLocalFallback = true;
175
176
  } else {
176
177
  logger.error(error.message);
177
178
  logger.error('No local fallback available. Run: ppcos login');
@@ -12,7 +12,6 @@ import {
12
12
  manifestExists,
13
13
  detectModifications
14
14
  } from '../utils/manifest.js';
15
- import { getAllFiles } from '../utils/fs-helpers.js';
16
15
  import { readAuth } from '../utils/auth.js';
17
16
  import logger from '../utils/logger.js';
18
17
  import chalk from 'chalk';
@@ -32,14 +31,6 @@ function getPackageVersion() {
32
31
  return pkg.version;
33
32
  }
34
33
 
35
- /**
36
- * Get path to .claude-base template directory
37
- * @returns {string}
38
- */
39
- function getBaseTemplatePath() {
40
- return join(PACKAGE_ROOT, '.claude-base');
41
- }
42
-
43
34
  /**
44
35
  * Get clients directory path
45
36
  * @returns {string}
@@ -94,8 +85,9 @@ function findCustomSkills(clientDir, manifest) {
94
85
  // Get managed skill directories from manifest
95
86
  const managedSkillDirs = new Set();
96
87
  for (const path of Object.keys(manifest.managedFiles)) {
97
- if (path.startsWith('.claude/skills/')) {
98
- const parts = path.split('/');
88
+ const normalized = path.replace(/\\/g, '/');
89
+ if (normalized.startsWith('.claude/skills/')) {
90
+ const parts = normalized.split('/');
99
91
  if (parts.length >= 3) {
100
92
  managedSkillDirs.add(parts[2]);
101
93
  }
@@ -119,18 +111,14 @@ function findCustomSkills(clientDir, manifest) {
119
111
  async function getClientStatus(clientName) {
120
112
  const clientsDir = getClientsDir();
121
113
  const clientDir = join(clientsDir, clientName);
122
- const basePath = getBaseTemplatePath();
123
114
  const packageVersion = getPackageVersion();
124
115
 
125
116
  // Read manifest
126
117
  const manifest = await readManifest(clientDir);
127
118
  const currentVersion = manifest.baseVersion;
128
119
 
129
- // Get base template files for detecting modifications
130
- const baseFiles = await getAllFiles(basePath);
131
-
132
- // Detect modifications
133
- const mods = await detectModifications(clientDir, manifest, baseFiles);
120
+ // Detect modifications (no base files needed status only uses manifest checksums)
121
+ const mods = await detectModifications(clientDir, manifest);
134
122
 
135
123
  // Find custom skills
136
124
  const customSkills = findCustomSkills(clientDir, manifest);
@@ -164,15 +152,6 @@ async function getClientStatus(clientName) {
164
152
  */
165
153
  export default async function status(options = {}) {
166
154
  const packageVersion = getPackageVersion();
167
- const basePath = getBaseTemplatePath();
168
-
169
- // Check base template exists
170
- if (!existsSync(basePath)) {
171
- logger.error('Template directory not found. Package may be corrupted.');
172
- process.exitCode = 1;
173
- return;
174
- }
175
-
176
155
  // Discover clients
177
156
  let clients = discoverClients();
178
157
 
@@ -281,7 +260,6 @@ export default async function status(options = {}) {
281
260
  // Export helpers for testing
282
261
  export {
283
262
  getPackageVersion,
284
- getBaseTemplatePath,
285
263
  getClientsDir,
286
264
  discoverClients,
287
265
  findCustomSkills,
@@ -4,8 +4,8 @@
4
4
  * Usage: ppcos update [--client <name>] [--dry-run]
5
5
  */
6
6
 
7
- import { readFileSync, existsSync, readdirSync } from 'node:fs';
8
- import { join, dirname } from 'node:path';
7
+ import { readFileSync, writeFileSync, existsSync, readdirSync, unlinkSync } from 'node:fs';
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 {
@@ -25,9 +25,9 @@ import {
25
25
  createBackupTimestamp
26
26
  } from '../utils/fs-helpers.js';
27
27
  import { fetchSkills } from '../utils/skills-fetcher.js';
28
- import { requireAuth } from '../utils/auth.js';
29
28
  import { mkdtempSync, rmSync } from 'node:fs';
30
29
  import { tmpdir } from 'node:os';
30
+ import { displayDiffs } from '../utils/diff.js';
31
31
  import logger from '../utils/logger.js';
32
32
  import ora from 'ora';
33
33
 
@@ -75,6 +75,80 @@ async function ensureMemoryFolder(clientDir) {
75
75
  }
76
76
  }
77
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: normalize backslash manifest keys to forward slashes.
110
+ * On Windows, getAllFiles() returned backslash paths which were stored as manifest keys.
111
+ * This normalizes them so lookups work cross-platform.
112
+ */
113
+ function migrateManifestKeys(manifest) {
114
+ let fixed = 0;
115
+ for (const [key, value] of Object.entries(manifest.managedFiles)) {
116
+ const normalized = key.replace(/\\/g, '/');
117
+ if (normalized !== key) {
118
+ delete manifest.managedFiles[key];
119
+ manifest.managedFiles[normalized] = value;
120
+ fixed++;
121
+ }
122
+ }
123
+ for (const conflict of (manifest.conflicts || [])) {
124
+ if (conflict.file) {
125
+ conflict.file = conflict.file.replace(/\\/g, '/');
126
+ }
127
+ }
128
+ if (fixed > 0) {
129
+ logger.info(` Normalized ${fixed} manifest key${fixed !== 1 ? 's' : ''}`);
130
+ }
131
+ }
132
+
133
+ /**
134
+ * One-time migration: fix managedType for config files.
135
+ * On Windows, isConfigFile() failed due to backslash paths, so config files
136
+ * got stored with managedType 'update' instead of 'config'.
137
+ * This corrects existing manifests in-place (before detectModifications runs).
138
+ */
139
+ function migrateConfigManagedTypes(manifest) {
140
+ let fixed = 0;
141
+ for (const [path, info] of Object.entries(manifest.managedFiles)) {
142
+ if (isConfigFile(path) && info.managedType !== 'config') {
143
+ info.managedType = 'config';
144
+ fixed++;
145
+ }
146
+ }
147
+ if (fixed > 0) {
148
+ logger.info(` Fixed managedType for ${fixed} config file${fixed !== 1 ? 's' : ''}`);
149
+ }
150
+ }
151
+
78
152
  /**
79
153
  * Find all client directories with .managed.json
80
154
  * @returns {string[]} Array of client names
@@ -104,21 +178,29 @@ function discoverClients() {
104
178
  /**
105
179
  * Prompt user for conflict resolution
106
180
  * @param {string[]} modifiedFiles - List of modified file paths
107
- * @returns {Promise<'backup'|'skip'|'cancel'>}
181
+ * @param {string} clientDir - Path to client directory
182
+ * @param {string} basePath - Path to base template directory
183
+ * @returns {Promise<'backup'|'overwrite'|'skip'|'cancel'>}
108
184
  */
109
- async function promptConflictResolution(modifiedFiles) {
185
+ async function promptConflictResolution(modifiedFiles, clientDir, basePath) {
110
186
  console.log('');
111
187
  logger.warn('Modified files detected:');
112
188
  for (const file of modifiedFiles) {
113
189
  console.log(` - ${file}`);
114
190
  }
115
191
 
116
- console.log('');
117
- console.log('Options:');
118
- console.log(' [1] Backup and overwrite (files saved to .backup/)');
119
- console.log(' [2] Skip modified files (keep your changes)');
120
- console.log(' [3] Cancel update');
121
- console.log('');
192
+ const showMenu = () => {
193
+ console.log('');
194
+ console.log('Options:');
195
+ console.log(' [d] View differences');
196
+ console.log(' [1] Backup and overwrite (files saved to .backup/)');
197
+ console.log(' [2] Overwrite without backup (discard your changes)');
198
+ console.log(' [3] Skip modified files (keep your changes)');
199
+ console.log(' [4] Cancel update');
200
+ console.log('');
201
+ };
202
+
203
+ showMenu();
122
204
 
123
205
  const rl = createInterface({
124
206
  input: process.stdin,
@@ -127,19 +209,26 @@ async function promptConflictResolution(modifiedFiles) {
127
209
 
128
210
  return new Promise((resolve) => {
129
211
  const ask = () => {
130
- rl.question('Choice [1/2/3]: ', (answer) => {
131
- const choice = answer.trim();
132
- if (choice === '1') {
212
+ rl.question('Choice [d/1/2/3/4]: ', (answer) => {
213
+ const choice = answer.trim().toLowerCase();
214
+ if (choice === 'd') {
215
+ displayDiffs(modifiedFiles, clientDir, basePath);
216
+ showMenu();
217
+ ask();
218
+ } else if (choice === '1') {
133
219
  rl.close();
134
220
  resolve('backup');
135
221
  } else if (choice === '2') {
136
222
  rl.close();
137
- resolve('skip');
223
+ resolve('overwrite');
138
224
  } else if (choice === '3') {
225
+ rl.close();
226
+ resolve('skip');
227
+ } else if (choice === '4') {
139
228
  rl.close();
140
229
  resolve('cancel');
141
230
  } else {
142
- console.log('Invalid choice. Please enter 1, 2, or 3.');
231
+ console.log('Invalid choice. Please enter d, 1, 2, 3, or 4.');
143
232
  ask();
144
233
  }
145
234
  });
@@ -175,6 +264,21 @@ async function backupFiles(clientDir, files) {
175
264
  const { writeFile } = await import('node:fs/promises');
176
265
  await writeFile(join(backupDir, 'manifest.txt'), manifestContent);
177
266
 
267
+ // Verify all files were actually backed up before returning
268
+ const { readFileSync } = await import('node:fs');
269
+ for (const relativePath of files) {
270
+ const backedUpPath = join(backupDir, relativePath);
271
+ if (!existsSync(backedUpPath)) {
272
+ throw new Error(`Backup verification failed: ${relativePath} was not created in ${backupDir}`);
273
+ }
274
+ // Verify content matches source (guards against empty/placeholder files from cloud sync)
275
+ const srcContent = readFileSync(join(clientDir, relativePath));
276
+ const backupContent = readFileSync(backedUpPath);
277
+ if (!srcContent.equals(backupContent)) {
278
+ throw new Error(`Backup verification failed: ${relativePath} content mismatch (possible cloud sync interference)`);
279
+ }
280
+ }
281
+
178
282
  return backupDir;
179
283
  }
180
284
 
@@ -204,8 +308,11 @@ async function updateClient(clientName, basePathOrOptions = {}, options = {}) {
204
308
  const manifest = await readManifest(clientDir);
205
309
  const currentVersion = manifest.baseVersion;
206
310
 
207
- // Auto-create memory folder for existing clients (migration)
311
+ // Migrations for existing clients
208
312
  await ensureMemoryFolder(clientDir);
313
+ migrateSettingsDenyRules(clientDir);
314
+ migrateManifestKeys(manifest);
315
+ migrateConfigManagedTypes(manifest);
209
316
 
210
317
  // Check if already up to date
211
318
  if (currentVersion === packageVersion) {
@@ -218,6 +325,28 @@ async function updateClient(clientName, basePathOrOptions = {}, options = {}) {
218
325
  // Detect modifications
219
326
  const mods = await detectModifications(clientDir, manifest, baseFiles);
220
327
 
328
+ // Guard: if downloaded base is empty or incomplete, something went wrong — abort
329
+ const managedUpdateFiles = Object.keys(manifest.managedFiles).filter(f => {
330
+ const managedType = manifest.managedFiles[f].managedType || 'update';
331
+ return managedType !== 'config';
332
+ });
333
+
334
+ if (managedUpdateFiles.length > 0 && baseFiles.length < managedUpdateFiles.length * 0.5) {
335
+ logger.error(` Downloaded template looks incomplete (${baseFiles.length} files vs ${managedUpdateFiles.length} expected) — aborting update for ${clientName}`);
336
+ return { status: 'cancelled' };
337
+ }
338
+
339
+ // Detect files removed from base template (orphans)
340
+ const baseFileSet = new Set(baseFiles);
341
+ const orphanedFiles = Object.keys(manifest.managedFiles).filter(f => {
342
+ const managedType = manifest.managedFiles[f].managedType || 'update';
343
+ return managedType !== 'config' && !baseFileSet.has(f);
344
+ });
345
+
346
+ // Separate modified files that are orphaned (handled differently)
347
+ const orphanSet = new Set(orphanedFiles);
348
+ const modifiedNonOrphans = mods.modified.filter(f => !orphanSet.has(f));
349
+
221
350
  // Dry run output
222
351
  if (options.dryRun) {
223
352
  return {
@@ -228,27 +357,35 @@ async function updateClient(clientName, basePathOrOptions = {}, options = {}) {
228
357
  unchanged: mods.unchanged.length,
229
358
  modified: mods.modified,
230
359
  missing: mods.missing,
231
- newInBase: mods.newInBase
360
+ newInBase: mods.newInBase,
361
+ removedFromBase: orphanedFiles
232
362
  }
233
363
  };
234
364
  }
235
365
 
236
- // Handle modified files
366
+ // Handle modified files (excluding orphans, which are handled separately)
237
367
  let resolution = null;
238
368
  let backedUpFiles = [];
239
369
 
240
- if (mods.modified.length > 0) {
370
+ if (modifiedNonOrphans.length > 0) {
241
371
  console.log(`Updating ${clientName} (v${currentVersion} → v${packageVersion})`);
242
- resolution = await promptConflictResolution(mods.modified);
372
+ resolution = await promptConflictResolution(modifiedNonOrphans, clientDir, basePath);
243
373
 
244
374
  if (resolution === 'cancel') {
245
375
  return { status: 'cancelled' };
246
376
  }
247
377
 
248
378
  if (resolution === 'backup') {
249
- const backupDir = await backupFiles(clientDir, mods.modified);
250
- backedUpFiles = mods.modified;
251
- console.log(` Backed up ${mods.modified.length} modified files to ${backupDir.replace(clientDir + '/', '')}`);
379
+ try {
380
+ const backupDir = await backupFiles(clientDir, modifiedNonOrphans);
381
+ backedUpFiles = modifiedNonOrphans;
382
+ const relativePath = backupDir.replace(clientDir + sep, '');
383
+ console.log(` Backed up ${modifiedNonOrphans.length} modified files to ${relativePath}`);
384
+ } catch (err) {
385
+ logger.error(` Backup failed: ${err.message}`);
386
+ logger.error(' Update aborted — no files were overwritten.');
387
+ return { status: 'cancelled' };
388
+ }
252
389
  }
253
390
  }
254
391
 
@@ -262,7 +399,7 @@ async function updateClient(clientName, basePathOrOptions = {}, options = {}) {
262
399
  continue;
263
400
  }
264
401
 
265
- if (mods.modified.includes(file) && resolution === 'skip') {
402
+ if (modifiedNonOrphans.includes(file) && resolution === 'skip') {
266
403
  filesToSkip.push(file);
267
404
  } else {
268
405
  filesToUpdate.push(file);
@@ -276,31 +413,71 @@ async function updateClient(clientName, basePathOrOptions = {}, options = {}) {
276
413
  const srcPath = join(basePath, relativePath);
277
414
  const destPath = join(clientDir, relativePath);
278
415
 
416
+ const srcChecksum = await calculateChecksum(srcPath);
279
417
  await copyFileWithDirs(srcPath, destPath);
280
418
 
281
- const checksum = await calculateChecksum(destPath);
419
+ // Verify copy integrity (guards against cloud sync placeholder files)
420
+ let destChecksum = await calculateChecksum(destPath);
421
+ if (destChecksum !== srcChecksum) {
422
+ // Retry once — cloud sync may have interfered
423
+ await copyFileWithDirs(srcPath, destPath);
424
+ destChecksum = await calculateChecksum(destPath);
425
+ if (destChecksum !== srcChecksum) {
426
+ logger.error(` File write failed for ${relativePath} (possible cloud sync interference).`);
427
+ logger.error(' Update aborted — try running update again.');
428
+ return { status: 'cancelled' };
429
+ }
430
+ }
282
431
 
283
432
  // Preserve managedType from existing manifest or determine new
284
433
  const existingEntry = manifest.managedFiles[relativePath];
285
434
  const managedType = existingEntry?.managedType || getManagedType(relativePath);
286
435
 
287
436
  updatedFiles[relativePath] = {
288
- checksum,
437
+ checksum: srcChecksum,
289
438
  version: packageVersion,
290
439
  managedType
291
440
  };
292
441
  }
293
442
 
443
+ // Remove orphaned files (removed from base template)
444
+ let removedCount = 0;
445
+ if (orphanedFiles.length > 0) {
446
+ // Back up modified orphans before removing
447
+ const modifiedOrphans = orphanedFiles.filter(f => mods.modified.includes(f));
448
+ if (modifiedOrphans.length > 0) {
449
+ try {
450
+ const backupDir = await backupFiles(clientDir, modifiedOrphans);
451
+ const relPath = backupDir.replace(clientDir + sep, '');
452
+ console.log(` Backed up ${modifiedOrphans.length} modified removed file${modifiedOrphans.length !== 1 ? 's' : ''} to ${relPath}`);
453
+ } catch (err) {
454
+ logger.error(` Backup of removed files failed: ${err.message}`);
455
+ logger.error(' Update aborted — no files were removed.');
456
+ return { status: 'cancelled' };
457
+ }
458
+ }
459
+
460
+ for (const relativePath of orphanedFiles) {
461
+ const fullPath = join(clientDir, relativePath);
462
+ if (existsSync(fullPath)) {
463
+ unlinkSync(fullPath);
464
+ removedCount++;
465
+ }
466
+ delete manifest.managedFiles[relativePath];
467
+ }
468
+ }
469
+
294
470
  // Update manifest
295
471
  manifest.baseVersion = packageVersion;
296
472
  manifest.lastUpdated = new Date().toISOString();
473
+ manifest.conflicts = []; // Clear stale conflicts from previous updates
297
474
 
298
475
  // Update file entries
299
476
  for (const [path, info] of Object.entries(updatedFiles)) {
300
477
  manifest.managedFiles[path] = info;
301
478
  }
302
479
 
303
- // Record skipped files as conflicts
480
+ // Record skipped files as conflicts (only from this update)
304
481
  for (const file of filesToSkip) {
305
482
  addConflict(manifest, file, 'User modified - skipped during update', packageVersion);
306
483
  }
@@ -314,7 +491,8 @@ async function updateClient(clientName, basePathOrOptions = {}, options = {}) {
314
491
  toVersion: packageVersion,
315
492
  updatedCount: filesToUpdate.length,
316
493
  skippedCount: filesToSkip.length,
317
- backedUpCount: backedUpFiles.length
494
+ backedUpCount: backedUpFiles.length,
495
+ removedCount
318
496
  }
319
497
  };
320
498
  }
@@ -333,19 +511,31 @@ export default async function update(options = {}) {
333
511
  const tempDir = mkdtempSync(join(tmpdir(), 'ppcos-update-'));
334
512
 
335
513
  let basePath;
336
- try {
337
- await fetchSkills(tempDir);
338
- basePath = tempDir;
339
- spinner.succeed('Skills downloaded');
340
- } catch (error) {
341
- spinner.fail('Failed to download skills');
342
- logger.error(error.message);
343
- logger.info('Falling back to local template if available...');
344
-
345
- // Fallback to local template during migration period
514
+ if (process.env.PPCOS_DEV) {
515
+ // Dev mode: use local .claude-base instead of API
346
516
  basePath = getBaseTemplatePath();
347
517
  if (!existsSync(basePath)) {
348
- logger.error('No local template available. Cannot update.');
518
+ spinner.fail('No local .claude-base found');
519
+ rmSync(tempDir, { recursive: true, force: true });
520
+ return;
521
+ }
522
+ spinner.succeed('Using local .claude-base (dev mode)');
523
+ rmSync(tempDir, { recursive: true, force: true });
524
+ } else {
525
+ try {
526
+ await fetchSkills(tempDir);
527
+ basePath = tempDir;
528
+ spinner.succeed('Skills downloaded');
529
+ } catch (error) {
530
+ spinner.fail('Failed to download skills');
531
+ logger.error(error.message);
532
+
533
+ if (error.message.includes('Not authenticated') || error.message.includes('401') || error.message.includes('expired')) {
534
+ logger.info('Try: ppcos login');
535
+ } else {
536
+ logger.info('Check your internet connection and try again.');
537
+ }
538
+
349
539
  process.exitCode = 1;
350
540
  rmSync(tempDir, { recursive: true, force: true });
351
541
  return;
@@ -363,6 +553,7 @@ export default async function update(options = {}) {
363
553
  console.log('');
364
554
  console.log('Or create main-config.json and run:');
365
555
  console.log(' ppcos init-all');
556
+ rmSync(tempDir, { recursive: true, force: true });
366
557
  return;
367
558
  }
368
559
 
@@ -371,6 +562,7 @@ export default async function update(options = {}) {
371
562
  if (!clients.includes(options.client)) {
372
563
  logger.error(`Client "${options.client}" not found.`);
373
564
  process.exitCode = 1;
565
+ rmSync(tempDir, { recursive: true, force: true });
374
566
  return;
375
567
  }
376
568
  clients = [options.client];
@@ -416,6 +608,13 @@ export default async function update(options = {}) {
416
608
  }
417
609
  }
418
610
 
611
+ if (d.removedFromBase.length > 0) {
612
+ console.log(` Removed: ${d.removedFromBase.length} files`);
613
+ for (const file of d.removedFromBase) {
614
+ console.log(` - ${file}`);
615
+ }
616
+ }
617
+
419
618
  console.log('');
420
619
  results.skipped++;
421
620
  } else if (result.status === 'up-to-date') {
@@ -432,6 +631,9 @@ export default async function update(options = {}) {
432
631
  } else if (result.status === 'updated') {
433
632
  const d = result.details;
434
633
  console.log(` Updated ${d.updatedCount} files`);
634
+ if (d.removedCount > 0) {
635
+ console.log(` Removed ${d.removedCount} orphaned files`);
636
+ }
435
637
  if (d.skippedCount > 0) {
436
638
  console.log(` Skipped ${d.skippedCount} modified files`);
437
639
  }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Diff utilities - Show file differences in terminal
3
+ */
4
+ import { readFileSync } from 'node:fs';
5
+ import { join } from 'node:path';
6
+ import { createTwoFilesPatch } from 'diff';
7
+ import chalk from 'chalk';
8
+
9
+ const MAX_DIFF_LINES = 200;
10
+
11
+ /**
12
+ * Generate a colored inline diff between two files
13
+ * @param {string} oldPath - Path to current (user-modified) file
14
+ * @param {string} newPath - Path to incoming (base template) file
15
+ * @param {string} label - Display name for the file
16
+ * @param {number} [maxLines] - Max diff lines to show
17
+ * @returns {string} Formatted diff string for terminal
18
+ */
19
+ export function formatFileDiff(oldPath, newPath, label, maxLines = MAX_DIFF_LINES) {
20
+ const oldContent = readFileSync(oldPath, 'utf8');
21
+ const newContent = readFileSync(newPath, 'utf8');
22
+
23
+ const patch = createTwoFilesPatch(
24
+ `your version: ${label}`,
25
+ `incoming update: ${label}`,
26
+ oldContent,
27
+ newContent,
28
+ '', '',
29
+ { context: 3 }
30
+ );
31
+
32
+ const lines = patch.split('\n');
33
+ const coloredLines = lines.map(line => {
34
+ if (line.startsWith('+') && !line.startsWith('+++')) {
35
+ return chalk.green(line);
36
+ } else if (line.startsWith('-') && !line.startsWith('---')) {
37
+ return chalk.red(line);
38
+ } else if (line.startsWith('@@')) {
39
+ return chalk.cyan(line);
40
+ }
41
+ return line;
42
+ });
43
+
44
+ if (coloredLines.length > maxLines) {
45
+ const truncated = coloredLines.slice(0, maxLines);
46
+ truncated.push('');
47
+ truncated.push(chalk.gray(` ... ${coloredLines.length - maxLines} more lines (truncated)`));
48
+ return truncated.join('\n');
49
+ }
50
+
51
+ return coloredLines.join('\n');
52
+ }
53
+
54
+ /**
55
+ * Display diffs for all modified files
56
+ * @param {string[]} modifiedFiles - Relative paths of modified files
57
+ * @param {string} clientDir - Path to client directory
58
+ * @param {string} basePath - Path to base template directory
59
+ */
60
+ export function displayDiffs(modifiedFiles, clientDir, basePath) {
61
+ console.log('');
62
+ console.log(chalk.gray(` ${chalk.red('- red')} = your version (will be replaced)`));
63
+ console.log(chalk.gray(` ${chalk.green('+ green')} = incoming update`));
64
+
65
+ for (const file of modifiedFiles) {
66
+ const clientFile = join(clientDir, file);
67
+ const baseFile = join(basePath, file);
68
+
69
+ console.log('');
70
+ console.log(chalk.bold.underline(file));
71
+ console.log(formatFileDiff(clientFile, baseFile, file));
72
+ }
73
+ console.log('');
74
+ }
@@ -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.1",
3
+ "version": "1.0.3",
4
4
  "description": "CLI tool to manage Google Ads AI workflow skills and agents for Claude Code",
5
5
  "type": "module",
6
6
  "bin": {
@@ -18,8 +18,9 @@
18
18
  },
19
19
  "license": "SEE LICENSE IN LICENSE",
20
20
  "dependencies": {
21
- "commander": "^12.0.0",
22
21
  "chalk": "^5.3.0",
22
+ "commander": "^12.0.0",
23
+ "diff": "^8.0.3",
23
24
  "ora": "^8.0.0",
24
25
  "unzipper": "^0.12.3"
25
26
  },