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 +6 -4
- package/lib/commands/init.js +6 -5
- package/lib/commands/status.js +2 -25
- package/lib/commands/update.js +152 -37
- package/lib/utils/api-client.js +1 -1
- package/lib/utils/diff.js +74 -0
- package/lib/utils/skills-fetcher.js +20 -18
- package/package.json +3 -2
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
};
|
package/lib/commands/init.js
CHANGED
|
@@ -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');
|
package/lib/commands/status.js
CHANGED
|
@@ -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
|
-
//
|
|
130
|
-
const
|
|
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,
|
package/lib/commands/update.js
CHANGED
|
@@ -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
|
-
* @
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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 === '
|
|
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('
|
|
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
|
|
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
|
-
//
|
|
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 (
|
|
309
|
+
if (modifiedNonOrphans.length > 0) {
|
|
241
310
|
console.log(`Updating ${clientName} (v${currentVersion} → v${packageVersion})`);
|
|
242
|
-
resolution = await promptConflictResolution(
|
|
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,
|
|
250
|
-
backedUpFiles =
|
|
251
|
-
console.log(` Backed up ${
|
|
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 (
|
|
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
|
-
|
|
337
|
-
|
|
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
|
-
|
|
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
|
}
|
package/lib/utils/api-client.js
CHANGED
|
@@ -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
|
|
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 {
|
|
3
|
-
import {
|
|
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
|
|
26
|
-
|
|
27
|
-
//
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
//
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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.
|
|
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
|
},
|