ppcos 1.0.0 → 1.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.
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}
@@ -119,18 +110,14 @@ function findCustomSkills(clientDir, manifest) {
119
110
  async function getClientStatus(clientName) {
120
111
  const clientsDir = getClientsDir();
121
112
  const clientDir = join(clientsDir, clientName);
122
- const basePath = getBaseTemplatePath();
123
113
  const packageVersion = getPackageVersion();
124
114
 
125
115
  // Read manifest
126
116
  const manifest = await readManifest(clientDir);
127
117
  const currentVersion = manifest.baseVersion;
128
118
 
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);
119
+ // Detect modifications (no base files needed status only uses manifest checksums)
120
+ const mods = await detectModifications(clientDir, manifest);
134
121
 
135
122
  // Find custom skills
136
123
  const customSkills = findCustomSkills(clientDir, manifest);
@@ -164,15 +151,6 @@ async function getClientStatus(clientName) {
164
151
  */
165
152
  export default async function status(options = {}) {
166
153
  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
154
  // Discover clients
177
155
  let clients = discoverClients();
178
156
 
@@ -281,7 +259,6 @@ export default async function status(options = {}) {
281
259
  // Export helpers for testing
282
260
  export {
283
261
  getPackageVersion,
284
- getBaseTemplatePath,
285
262
  getClientsDir,
286
263
  discoverClients,
287
264
  findCustomSkills,
@@ -4,7 +4,7 @@
4
4
  * Usage: ppcos update [--client <name>] [--dry-run]
5
5
  */
6
6
 
7
- import { readFileSync, existsSync, readdirSync } from 'node:fs';
7
+ import { readFileSync, writeFileSync, existsSync, readdirSync, unlinkSync } from 'node:fs';
8
8
  import { join, dirname } from 'node:path';
9
9
  import { fileURLToPath } from 'node:url';
10
10
  import { createInterface } from 'node:readline';
@@ -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,36 @@ 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
+
78
108
  /**
79
109
  * Find all client directories with .managed.json
80
110
  * @returns {string[]} Array of client names
@@ -104,21 +134,29 @@ function discoverClients() {
104
134
  /**
105
135
  * Prompt user for conflict resolution
106
136
  * @param {string[]} modifiedFiles - List of modified file paths
107
- * @returns {Promise<'backup'|'skip'|'cancel'>}
137
+ * @param {string} clientDir - Path to client directory
138
+ * @param {string} basePath - Path to base template directory
139
+ * @returns {Promise<'backup'|'overwrite'|'skip'|'cancel'>}
108
140
  */
109
- async function promptConflictResolution(modifiedFiles) {
141
+ async function promptConflictResolution(modifiedFiles, clientDir, basePath) {
110
142
  console.log('');
111
143
  logger.warn('Modified files detected:');
112
144
  for (const file of modifiedFiles) {
113
145
  console.log(` - ${file}`);
114
146
  }
115
147
 
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('');
148
+ const showMenu = () => {
149
+ console.log('');
150
+ console.log('Options:');
151
+ console.log(' [d] View differences');
152
+ console.log(' [1] Backup and overwrite (files saved to .backup/)');
153
+ console.log(' [2] Overwrite without backup (discard your changes)');
154
+ console.log(' [3] Skip modified files (keep your changes)');
155
+ console.log(' [4] Cancel update');
156
+ console.log('');
157
+ };
158
+
159
+ showMenu();
122
160
 
123
161
  const rl = createInterface({
124
162
  input: process.stdin,
@@ -127,19 +165,26 @@ async function promptConflictResolution(modifiedFiles) {
127
165
 
128
166
  return new Promise((resolve) => {
129
167
  const ask = () => {
130
- rl.question('Choice [1/2/3]: ', (answer) => {
131
- const choice = answer.trim();
132
- if (choice === '1') {
168
+ rl.question('Choice [d/1/2/3/4]: ', (answer) => {
169
+ const choice = answer.trim().toLowerCase();
170
+ if (choice === 'd') {
171
+ displayDiffs(modifiedFiles, clientDir, basePath);
172
+ showMenu();
173
+ ask();
174
+ } else if (choice === '1') {
133
175
  rl.close();
134
176
  resolve('backup');
135
177
  } else if (choice === '2') {
136
178
  rl.close();
137
- resolve('skip');
179
+ resolve('overwrite');
138
180
  } else if (choice === '3') {
181
+ rl.close();
182
+ resolve('skip');
183
+ } else if (choice === '4') {
139
184
  rl.close();
140
185
  resolve('cancel');
141
186
  } else {
142
- console.log('Invalid choice. Please enter 1, 2, or 3.');
187
+ console.log('Invalid choice. Please enter d, 1, 2, 3, or 4.');
143
188
  ask();
144
189
  }
145
190
  });
@@ -204,8 +249,9 @@ async function updateClient(clientName, basePathOrOptions = {}, options = {}) {
204
249
  const manifest = await readManifest(clientDir);
205
250
  const currentVersion = manifest.baseVersion;
206
251
 
207
- // Auto-create memory folder for existing clients (migration)
252
+ // Migrations for existing clients
208
253
  await ensureMemoryFolder(clientDir);
254
+ migrateSettingsDenyRules(clientDir);
209
255
 
210
256
  // Check if already up to date
211
257
  if (currentVersion === packageVersion) {
@@ -218,6 +264,28 @@ async function updateClient(clientName, basePathOrOptions = {}, options = {}) {
218
264
  // Detect modifications
219
265
  const mods = await detectModifications(clientDir, manifest, baseFiles);
220
266
 
267
+ // Guard: if downloaded base is empty or incomplete, something went wrong — abort
268
+ const managedUpdateFiles = Object.keys(manifest.managedFiles).filter(f => {
269
+ const managedType = manifest.managedFiles[f].managedType || 'update';
270
+ return managedType !== 'config';
271
+ });
272
+
273
+ if (managedUpdateFiles.length > 0 && baseFiles.length < managedUpdateFiles.length * 0.5) {
274
+ logger.error(` Downloaded template looks incomplete (${baseFiles.length} files vs ${managedUpdateFiles.length} expected) — aborting update for ${clientName}`);
275
+ return { status: 'cancelled' };
276
+ }
277
+
278
+ // Detect files removed from base template (orphans)
279
+ const baseFileSet = new Set(baseFiles);
280
+ const orphanedFiles = Object.keys(manifest.managedFiles).filter(f => {
281
+ const managedType = manifest.managedFiles[f].managedType || 'update';
282
+ return managedType !== 'config' && !baseFileSet.has(f);
283
+ });
284
+
285
+ // Separate modified files that are orphaned (handled differently)
286
+ const orphanSet = new Set(orphanedFiles);
287
+ const modifiedNonOrphans = mods.modified.filter(f => !orphanSet.has(f));
288
+
221
289
  // Dry run output
222
290
  if (options.dryRun) {
223
291
  return {
@@ -228,27 +296,28 @@ async function updateClient(clientName, basePathOrOptions = {}, options = {}) {
228
296
  unchanged: mods.unchanged.length,
229
297
  modified: mods.modified,
230
298
  missing: mods.missing,
231
- newInBase: mods.newInBase
299
+ newInBase: mods.newInBase,
300
+ removedFromBase: orphanedFiles
232
301
  }
233
302
  };
234
303
  }
235
304
 
236
- // Handle modified files
305
+ // Handle modified files (excluding orphans, which are handled separately)
237
306
  let resolution = null;
238
307
  let backedUpFiles = [];
239
308
 
240
- if (mods.modified.length > 0) {
309
+ if (modifiedNonOrphans.length > 0) {
241
310
  console.log(`Updating ${clientName} (v${currentVersion} → v${packageVersion})`);
242
- resolution = await promptConflictResolution(mods.modified);
311
+ resolution = await promptConflictResolution(modifiedNonOrphans, clientDir, basePath);
243
312
 
244
313
  if (resolution === 'cancel') {
245
314
  return { status: 'cancelled' };
246
315
  }
247
316
 
248
317
  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 + '/', '')}`);
318
+ const backupDir = await backupFiles(clientDir, modifiedNonOrphans);
319
+ backedUpFiles = modifiedNonOrphans;
320
+ console.log(` Backed up ${modifiedNonOrphans.length} modified files to ${backupDir.replace(clientDir + '/', '')}`);
252
321
  }
253
322
  }
254
323
 
@@ -262,7 +331,7 @@ async function updateClient(clientName, basePathOrOptions = {}, options = {}) {
262
331
  continue;
263
332
  }
264
333
 
265
- if (mods.modified.includes(file) && resolution === 'skip') {
334
+ if (modifiedNonOrphans.includes(file) && resolution === 'skip') {
266
335
  filesToSkip.push(file);
267
336
  } else {
268
337
  filesToUpdate.push(file);
@@ -291,16 +360,37 @@ async function updateClient(clientName, basePathOrOptions = {}, options = {}) {
291
360
  };
292
361
  }
293
362
 
363
+ // Remove orphaned files (removed from base template)
364
+ let removedCount = 0;
365
+ if (orphanedFiles.length > 0) {
366
+ // Back up modified orphans before removing
367
+ const modifiedOrphans = orphanedFiles.filter(f => mods.modified.includes(f));
368
+ 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 + '/', '')}`);
371
+ }
372
+
373
+ for (const relativePath of orphanedFiles) {
374
+ const fullPath = join(clientDir, relativePath);
375
+ if (existsSync(fullPath)) {
376
+ unlinkSync(fullPath);
377
+ removedCount++;
378
+ }
379
+ delete manifest.managedFiles[relativePath];
380
+ }
381
+ }
382
+
294
383
  // Update manifest
295
384
  manifest.baseVersion = packageVersion;
296
385
  manifest.lastUpdated = new Date().toISOString();
386
+ manifest.conflicts = []; // Clear stale conflicts from previous updates
297
387
 
298
388
  // Update file entries
299
389
  for (const [path, info] of Object.entries(updatedFiles)) {
300
390
  manifest.managedFiles[path] = info;
301
391
  }
302
392
 
303
- // Record skipped files as conflicts
393
+ // Record skipped files as conflicts (only from this update)
304
394
  for (const file of filesToSkip) {
305
395
  addConflict(manifest, file, 'User modified - skipped during update', packageVersion);
306
396
  }
@@ -314,7 +404,8 @@ async function updateClient(clientName, basePathOrOptions = {}, options = {}) {
314
404
  toVersion: packageVersion,
315
405
  updatedCount: filesToUpdate.length,
316
406
  skippedCount: filesToSkip.length,
317
- backedUpCount: backedUpFiles.length
407
+ backedUpCount: backedUpFiles.length,
408
+ removedCount
318
409
  }
319
410
  };
320
411
  }
@@ -333,19 +424,31 @@ export default async function update(options = {}) {
333
424
  const tempDir = mkdtempSync(join(tmpdir(), 'ppcos-update-'));
334
425
 
335
426
  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
427
+ if (process.env.PPCOS_DEV) {
428
+ // Dev mode: use local .claude-base instead of API
346
429
  basePath = getBaseTemplatePath();
347
430
  if (!existsSync(basePath)) {
348
- logger.error('No local template available. Cannot update.');
431
+ spinner.fail('No local .claude-base found');
432
+ rmSync(tempDir, { recursive: true, force: true });
433
+ return;
434
+ }
435
+ spinner.succeed('Using local .claude-base (dev mode)');
436
+ rmSync(tempDir, { recursive: true, force: true });
437
+ } else {
438
+ try {
439
+ await fetchSkills(tempDir);
440
+ basePath = tempDir;
441
+ spinner.succeed('Skills downloaded');
442
+ } catch (error) {
443
+ spinner.fail('Failed to download skills');
444
+ logger.error(error.message);
445
+
446
+ if (error.message.includes('Not authenticated') || error.message.includes('401') || error.message.includes('expired')) {
447
+ logger.info('Try: ppcos login');
448
+ } else {
449
+ logger.info('Check your internet connection and try again.');
450
+ }
451
+
349
452
  process.exitCode = 1;
350
453
  rmSync(tempDir, { recursive: true, force: true });
351
454
  return;
@@ -363,6 +466,7 @@ export default async function update(options = {}) {
363
466
  console.log('');
364
467
  console.log('Or create main-config.json and run:');
365
468
  console.log(' ppcos init-all');
469
+ rmSync(tempDir, { recursive: true, force: true });
366
470
  return;
367
471
  }
368
472
 
@@ -371,6 +475,7 @@ export default async function update(options = {}) {
371
475
  if (!clients.includes(options.client)) {
372
476
  logger.error(`Client "${options.client}" not found.`);
373
477
  process.exitCode = 1;
478
+ rmSync(tempDir, { recursive: true, force: true });
374
479
  return;
375
480
  }
376
481
  clients = [options.client];
@@ -416,6 +521,13 @@ export default async function update(options = {}) {
416
521
  }
417
522
  }
418
523
 
524
+ if (d.removedFromBase.length > 0) {
525
+ console.log(` Removed: ${d.removedFromBase.length} files`);
526
+ for (const file of d.removedFromBase) {
527
+ console.log(` - ${file}`);
528
+ }
529
+ }
530
+
419
531
  console.log('');
420
532
  results.skipped++;
421
533
  } else if (result.status === 'up-to-date') {
@@ -432,6 +544,9 @@ export default async function update(options = {}) {
432
544
  } else if (result.status === 'updated') {
433
545
  const d = result.details;
434
546
  console.log(` Updated ${d.updatedCount} files`);
547
+ if (d.removedCount > 0) {
548
+ console.log(` Removed ${d.removedCount} orphaned files`);
549
+ }
435
550
  if (d.skippedCount > 0) {
436
551
  console.log(` Skipped ${d.skippedCount} modified files`);
437
552
  }
@@ -111,7 +111,7 @@ export async function downloadSkills(sessionToken) {
111
111
  throw new Error(error.error || 'Failed to download skills');
112
112
  }
113
113
 
114
- return response.body;
114
+ return response;
115
115
  } catch (error) {
116
116
  logger.error(`API error: ${error.message}`);
117
117
  throw error;
@@ -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
+ }
@@ -1,6 +1,7 @@
1
- import { existsSync, mkdirSync } from 'fs';
2
- import { Extract } from 'unzipper';
3
- import { Readable } from 'stream';
1
+ import { existsSync, mkdirSync, writeFileSync, unlinkSync } from 'fs';
2
+ import { Open } from 'unzipper';
3
+ import { join } from 'path';
4
+ import { tmpdir } from 'os';
4
5
  import { downloadSkills } from './api-client.js';
5
6
  import { readAuth } from './auth.js';
6
7
  import logger from './logger.js';
@@ -22,21 +23,22 @@ export async function fetchSkills(targetDir) {
22
23
  }
23
24
 
24
25
  // Download skills
25
- const stream = await downloadSkills(auth.sessionToken);
26
-
27
- // Convert web stream to Node stream
28
- const nodeStream = Readable.fromWeb(stream);
29
-
30
- // Extract zip use .pipe() with close event instead of pipeline.
31
- // unzipper's Extract doesn't properly signal completion through pipeline,
32
- // causing rmSync to delete the temp dir while files are still being written.
33
- await new Promise((resolve, reject) => {
34
- nodeStream
35
- .pipe(Extract({ path: targetDir }))
36
- .on('close', resolve)
37
- .on('error', reject);
38
- nodeStream.on('error', reject);
39
- });
26
+ const response = await downloadSkills(auth.sessionToken);
27
+
28
+ // Write zip to temp file first, then extract from disk.
29
+ // Streaming extraction (Extract) silently drops files when HTTP chunks
30
+ // don't align with zip entry boundaries. Open.file() reads the central
31
+ // directory and extracts every entry reliably.
32
+ const tempZip = join(tmpdir(), `ppcos-skills-${Date.now()}.zip`);
33
+ try {
34
+ const buffer = Buffer.from(await response.arrayBuffer());
35
+ writeFileSync(tempZip, buffer);
36
+
37
+ const directory = await Open.file(tempZip);
38
+ await directory.extract({ path: targetDir });
39
+ } finally {
40
+ try { unlinkSync(tempZip); } catch {}
41
+ }
40
42
  }
41
43
 
42
44
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ppcos",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
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
  },