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 +6 -4
- package/lib/commands/init.js +6 -5
- package/lib/commands/status.js +5 -27
- package/lib/commands/update.js +242 -40
- package/lib/utils/diff.js +74 -0
- package/lib/utils/fs-helpers.js +2 -2
- package/lib/utils/manifest.js +2 -1
- 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}
|
|
@@ -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
|
-
|
|
98
|
-
|
|
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
|
-
//
|
|
130
|
-
const
|
|
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,
|
package/lib/commands/update.js
CHANGED
|
@@ -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
|
-
* @
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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 === '
|
|
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('
|
|
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
|
|
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
|
-
//
|
|
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 (
|
|
370
|
+
if (modifiedNonOrphans.length > 0) {
|
|
241
371
|
console.log(`Updating ${clientName} (v${currentVersion} → v${packageVersion})`);
|
|
242
|
-
resolution = await promptConflictResolution(
|
|
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
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|
package/lib/utils/fs-helpers.js
CHANGED
|
@@ -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
|
|
package/lib/utils/manifest.js
CHANGED
|
@@ -25,7 +25,8 @@ const CONFIG_ONLY_FILES = new Set([
|
|
|
25
25
|
* @returns {boolean}
|
|
26
26
|
*/
|
|
27
27
|
export function isConfigFile(relativePath) {
|
|
28
|
-
|
|
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.
|
|
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
|
},
|