ppcos 1.0.6 → 1.0.7
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/lib/commands/init-all.js +14 -37
- package/lib/commands/init.js +78 -36
- package/lib/commands/status.js +46 -60
- package/lib/commands/update-hub.js +200 -0
- package/lib/commands/update.js +25 -369
- package/lib/utils/conflicts.js +119 -0
- package/lib/utils/manifest.js +39 -0
- package/lib/utils/migrations.js +204 -0
- package/lib/utils/paths.js +88 -0
- package/package.json +1 -1
package/lib/commands/update.js
CHANGED
|
@@ -4,374 +4,36 @@
|
|
|
4
4
|
* Usage: ppcos update [--client <name>] [--dry-run]
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import {
|
|
8
|
-
import { join,
|
|
9
|
-
import { fileURLToPath } from 'node:url';
|
|
10
|
-
import { createInterface } from 'node:readline';
|
|
7
|
+
import { existsSync, unlinkSync } from 'node:fs';
|
|
8
|
+
import { join, sep } from 'node:path';
|
|
11
9
|
import {
|
|
12
10
|
readManifest,
|
|
13
11
|
writeManifest,
|
|
14
12
|
detectModifications,
|
|
15
13
|
addConflict,
|
|
16
|
-
manifestExists,
|
|
17
14
|
getManagedType,
|
|
18
15
|
isConfigFile
|
|
19
16
|
} from '../utils/manifest.js';
|
|
20
17
|
import { calculateChecksum } from '../utils/checksum.js';
|
|
21
|
-
import {
|
|
22
|
-
getAllFiles,
|
|
23
|
-
copyFileWithDirs,
|
|
24
|
-
ensureDir,
|
|
25
|
-
createBackupTimestamp
|
|
26
|
-
} from '../utils/fs-helpers.js';
|
|
18
|
+
import { getAllFiles, copyFileWithDirs } from '../utils/fs-helpers.js';
|
|
27
19
|
import { fetchSkills } from '../utils/skills-fetcher.js';
|
|
28
20
|
import { mkdtempSync, rmSync } from 'node:fs';
|
|
29
21
|
import { tmpdir } from 'node:os';
|
|
30
|
-
import { displayDiffs } from '../utils/diff.js';
|
|
31
22
|
import logger from '../utils/logger.js';
|
|
32
23
|
import ora from 'ora';
|
|
24
|
+
import {
|
|
25
|
+
getPackageVersion,
|
|
26
|
+
getBaseTemplatePath,
|
|
27
|
+
getClientsDir,
|
|
28
|
+
discoverClients
|
|
29
|
+
} from '../utils/paths.js';
|
|
30
|
+
import { runAllMigrations } from '../utils/migrations.js';
|
|
31
|
+
import { promptConflictResolution, backupFiles } from '../utils/conflicts.js';
|
|
32
|
+
import updateHub from './update-hub.js';
|
|
33
33
|
|
|
34
|
-
//
|
|
35
|
-
|
|
36
|
-
const
|
|
37
|
-
const PACKAGE_ROOT = join(__dirname, '..', '..');
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* Get package version from package.json
|
|
41
|
-
* @returns {string}
|
|
42
|
-
*/
|
|
43
|
-
function getPackageVersion() {
|
|
44
|
-
const pkgPath = join(PACKAGE_ROOT, 'package.json');
|
|
45
|
-
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
|
|
46
|
-
return pkg.version;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* Get path to .claude-base template directory
|
|
51
|
-
* @returns {string}
|
|
52
|
-
*/
|
|
53
|
-
function getBaseTemplatePath() {
|
|
54
|
-
return join(PACKAGE_ROOT, '.claude-base');
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
/**
|
|
58
|
-
* Get clients directory path
|
|
59
|
-
* @returns {string}
|
|
60
|
-
*/
|
|
61
|
-
function getClientsDir() {
|
|
62
|
-
return join(process.cwd(), 'clients');
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
/**
|
|
66
|
-
* Ensure memory folder exists in client workspace
|
|
67
|
-
* Creates context/memory/ if it doesn't exist (for existing clients)
|
|
68
|
-
* @param {string} clientDir - Path to client directory
|
|
69
|
-
*/
|
|
70
|
-
async function ensureMemoryFolder(clientDir) {
|
|
71
|
-
const memoryDir = join(clientDir, 'context', 'memory');
|
|
72
|
-
if (!existsSync(memoryDir)) {
|
|
73
|
-
await ensureDir(memoryDir);
|
|
74
|
-
logger.info(' Created context/memory/ folder');
|
|
75
|
-
}
|
|
76
|
-
}
|
|
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: merge hooks config into settings.local.json
|
|
110
|
-
* Adds missing hook events without touching existing ones
|
|
111
|
-
*/
|
|
112
|
-
function migrateSettingsHooks(clientDir) {
|
|
113
|
-
const settingsPath = join(clientDir, '.claude', 'settings.local.json');
|
|
114
|
-
if (!existsSync(settingsPath)) return;
|
|
115
|
-
|
|
116
|
-
const requiredHooks = {
|
|
117
|
-
SessionStart: [
|
|
118
|
-
{
|
|
119
|
-
matcher: 'startup|resume|clear',
|
|
120
|
-
hooks: [
|
|
121
|
-
{
|
|
122
|
-
type: 'command',
|
|
123
|
-
command: '.claude/hooks/session-context-check.sh',
|
|
124
|
-
timeout: 30,
|
|
125
|
-
statusMessage: 'Checking context freshness...'
|
|
126
|
-
}
|
|
127
|
-
]
|
|
128
|
-
}
|
|
129
|
-
],
|
|
130
|
-
Stop: [
|
|
131
|
-
{
|
|
132
|
-
hooks: [
|
|
133
|
-
{
|
|
134
|
-
type: 'command',
|
|
135
|
-
command: '.claude/hooks/stop-memory-check.sh',
|
|
136
|
-
timeout: 10,
|
|
137
|
-
statusMessage: 'Checking memory log...'
|
|
138
|
-
}
|
|
139
|
-
]
|
|
140
|
-
}
|
|
141
|
-
],
|
|
142
|
-
PostCompact: [
|
|
143
|
-
{
|
|
144
|
-
hooks: [
|
|
145
|
-
{
|
|
146
|
-
type: 'command',
|
|
147
|
-
command: '.claude/hooks/post-compact-reminder.sh',
|
|
148
|
-
timeout: 30,
|
|
149
|
-
statusMessage: 'Restoring context after compaction...'
|
|
150
|
-
}
|
|
151
|
-
]
|
|
152
|
-
}
|
|
153
|
-
]
|
|
154
|
-
};
|
|
155
|
-
|
|
156
|
-
try {
|
|
157
|
-
const settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
|
|
158
|
-
if (!settings.hooks) settings.hooks = {};
|
|
159
|
-
|
|
160
|
-
const added = [];
|
|
161
|
-
for (const [event, config] of Object.entries(requiredHooks)) {
|
|
162
|
-
if (!settings.hooks[event]) {
|
|
163
|
-
settings.hooks[event] = config;
|
|
164
|
-
added.push(event);
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
if (added.length === 0) return;
|
|
169
|
-
|
|
170
|
-
writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
|
|
171
|
-
logger.info(` Added ${added.length} hook${added.length !== 1 ? 's' : ''} to settings.local.json (${added.join(', ')})`);
|
|
172
|
-
} catch {
|
|
173
|
-
// Don't fail update over migration
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
/**
|
|
178
|
-
* One-time migration: normalize backslash manifest keys to forward slashes.
|
|
179
|
-
* On Windows, getAllFiles() returned backslash paths which were stored as manifest keys.
|
|
180
|
-
* This normalizes them so lookups work cross-platform.
|
|
181
|
-
*/
|
|
182
|
-
function migrateManifestKeys(manifest) {
|
|
183
|
-
let fixed = 0;
|
|
184
|
-
for (const [key, value] of Object.entries(manifest.managedFiles)) {
|
|
185
|
-
const normalized = key.replace(/\\/g, '/');
|
|
186
|
-
if (normalized !== key) {
|
|
187
|
-
delete manifest.managedFiles[key];
|
|
188
|
-
manifest.managedFiles[normalized] = value;
|
|
189
|
-
fixed++;
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
|
-
for (const conflict of (manifest.conflicts || [])) {
|
|
193
|
-
if (conflict.file) {
|
|
194
|
-
conflict.file = conflict.file.replace(/\\/g, '/');
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
if (fixed > 0) {
|
|
198
|
-
logger.info(` Normalized ${fixed} manifest key${fixed !== 1 ? 's' : ''}`);
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
/**
|
|
203
|
-
* One-time migration: fix managedType for config files.
|
|
204
|
-
* On Windows, isConfigFile() failed due to backslash paths, so config files
|
|
205
|
-
* got stored with managedType 'update' instead of 'config'.
|
|
206
|
-
* This corrects existing manifests in-place (before detectModifications runs).
|
|
207
|
-
*/
|
|
208
|
-
function migrateConfigManagedTypes(manifest) {
|
|
209
|
-
let fixed = 0;
|
|
210
|
-
for (const [path, info] of Object.entries(manifest.managedFiles)) {
|
|
211
|
-
if (isConfigFile(path) && info.managedType !== 'config') {
|
|
212
|
-
info.managedType = 'config';
|
|
213
|
-
fixed++;
|
|
214
|
-
}
|
|
215
|
-
}
|
|
216
|
-
if (fixed > 0) {
|
|
217
|
-
logger.info(` Fixed managedType for ${fixed} config file${fixed !== 1 ? 's' : ''}`);
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
/**
|
|
222
|
-
* One-time migration: remove leaked data files from skill tmp/ directories.
|
|
223
|
-
* Data files were accidentally included in .claude-base.zip and distributed
|
|
224
|
-
* to clients. This removes them and cleans up the manifest.
|
|
225
|
-
*/
|
|
226
|
-
function migrateTmpDataFiles(clientDir, manifest) {
|
|
227
|
-
const keepFiles = new Set(['.gitkeep', '.gitignore']);
|
|
228
|
-
let removed = 0;
|
|
229
|
-
for (const [path] of Object.entries(manifest.managedFiles)) {
|
|
230
|
-
if (!/\/tmp\//.test(path)) continue;
|
|
231
|
-
const filename = path.split('/').pop();
|
|
232
|
-
if (keepFiles.has(filename)) continue;
|
|
233
|
-
const fullPath = join(clientDir, path);
|
|
234
|
-
if (existsSync(fullPath)) unlinkSync(fullPath);
|
|
235
|
-
delete manifest.managedFiles[path];
|
|
236
|
-
removed++;
|
|
237
|
-
}
|
|
238
|
-
if (removed > 0) {
|
|
239
|
-
logger.info(` Removed ${removed} tmp data file${removed !== 1 ? 's' : ''} from tmp/`);
|
|
240
|
-
}
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
/**
|
|
244
|
-
* Find all client directories with .managed.json
|
|
245
|
-
* @returns {string[]} Array of client names
|
|
246
|
-
*/
|
|
247
|
-
function discoverClients() {
|
|
248
|
-
const clientsDir = getClientsDir();
|
|
249
|
-
|
|
250
|
-
if (!existsSync(clientsDir)) {
|
|
251
|
-
return [];
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
const entries = readdirSync(clientsDir, { withFileTypes: true });
|
|
255
|
-
const clients = [];
|
|
256
|
-
|
|
257
|
-
for (const entry of entries) {
|
|
258
|
-
if (entry.isDirectory()) {
|
|
259
|
-
const clientDir = join(clientsDir, entry.name);
|
|
260
|
-
if (manifestExists(clientDir)) {
|
|
261
|
-
clients.push(entry.name);
|
|
262
|
-
}
|
|
263
|
-
}
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
return clients;
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
/**
|
|
270
|
-
* Prompt user for conflict resolution
|
|
271
|
-
* @param {string[]} modifiedFiles - List of modified file paths
|
|
272
|
-
* @param {string} clientDir - Path to client directory
|
|
273
|
-
* @param {string} basePath - Path to base template directory
|
|
274
|
-
* @returns {Promise<'backup'|'overwrite'|'skip'|'cancel'>}
|
|
275
|
-
*/
|
|
276
|
-
async function promptConflictResolution(modifiedFiles, clientDir, basePath) {
|
|
277
|
-
console.log('');
|
|
278
|
-
logger.warn('Modified files detected:');
|
|
279
|
-
for (const file of modifiedFiles) {
|
|
280
|
-
console.log(` - ${file}`);
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
const showMenu = () => {
|
|
284
|
-
console.log('');
|
|
285
|
-
console.log('Options:');
|
|
286
|
-
console.log(' [d] View differences');
|
|
287
|
-
console.log(' [1] Backup and overwrite (files saved to .backup/)');
|
|
288
|
-
console.log(' [2] Overwrite without backup (discard your changes)');
|
|
289
|
-
console.log(' [3] Skip modified files (keep your changes)');
|
|
290
|
-
console.log(' [4] Cancel update');
|
|
291
|
-
console.log('');
|
|
292
|
-
};
|
|
293
|
-
|
|
294
|
-
showMenu();
|
|
295
|
-
|
|
296
|
-
const rl = createInterface({
|
|
297
|
-
input: process.stdin,
|
|
298
|
-
output: process.stdout
|
|
299
|
-
});
|
|
300
|
-
|
|
301
|
-
return new Promise((resolve) => {
|
|
302
|
-
const ask = () => {
|
|
303
|
-
rl.question('Choice [d/1/2/3/4]: ', (answer) => {
|
|
304
|
-
const choice = answer.trim().toLowerCase();
|
|
305
|
-
if (choice === 'd') {
|
|
306
|
-
displayDiffs(modifiedFiles, clientDir, basePath);
|
|
307
|
-
showMenu();
|
|
308
|
-
ask();
|
|
309
|
-
} else if (choice === '1') {
|
|
310
|
-
rl.close();
|
|
311
|
-
resolve('backup');
|
|
312
|
-
} else if (choice === '2') {
|
|
313
|
-
rl.close();
|
|
314
|
-
resolve('overwrite');
|
|
315
|
-
} else if (choice === '3') {
|
|
316
|
-
rl.close();
|
|
317
|
-
resolve('skip');
|
|
318
|
-
} else if (choice === '4') {
|
|
319
|
-
rl.close();
|
|
320
|
-
resolve('cancel');
|
|
321
|
-
} else {
|
|
322
|
-
console.log('Invalid choice. Please enter d, 1, 2, 3, or 4.');
|
|
323
|
-
ask();
|
|
324
|
-
}
|
|
325
|
-
});
|
|
326
|
-
};
|
|
327
|
-
ask();
|
|
328
|
-
});
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
/**
|
|
332
|
-
* Backup modified files to .backup/<timestamp>/
|
|
333
|
-
* @param {string} clientDir - Path to client directory
|
|
334
|
-
* @param {string[]} files - Relative paths of files to backup
|
|
335
|
-
* @returns {Promise<string>} Backup folder path
|
|
336
|
-
*/
|
|
337
|
-
async function backupFiles(clientDir, files) {
|
|
338
|
-
const timestamp = createBackupTimestamp();
|
|
339
|
-
const backupDir = join(clientDir, '.backup', timestamp);
|
|
340
|
-
|
|
341
|
-
for (const relativePath of files) {
|
|
342
|
-
const srcPath = join(clientDir, relativePath);
|
|
343
|
-
const destPath = join(backupDir, relativePath);
|
|
344
|
-
await copyFileWithDirs(srcPath, destPath);
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
// Write backup manifest
|
|
348
|
-
const manifestContent = [
|
|
349
|
-
`# Backup created during update`,
|
|
350
|
-
`# ${new Date().toISOString()}`,
|
|
351
|
-
'',
|
|
352
|
-
...files
|
|
353
|
-
].join('\n');
|
|
354
|
-
await ensureDir(backupDir);
|
|
355
|
-
const { writeFile } = await import('node:fs/promises');
|
|
356
|
-
await writeFile(join(backupDir, 'manifest.txt'), manifestContent);
|
|
357
|
-
|
|
358
|
-
// Verify all files were actually backed up before returning
|
|
359
|
-
const { readFileSync } = await import('node:fs');
|
|
360
|
-
for (const relativePath of files) {
|
|
361
|
-
const backedUpPath = join(backupDir, relativePath);
|
|
362
|
-
if (!existsSync(backedUpPath)) {
|
|
363
|
-
throw new Error(`Backup verification failed: ${relativePath} was not created in ${backupDir}`);
|
|
364
|
-
}
|
|
365
|
-
// Verify content matches source (guards against empty/placeholder files from cloud sync)
|
|
366
|
-
const srcContent = readFileSync(join(clientDir, relativePath));
|
|
367
|
-
const backupContent = readFileSync(backedUpPath);
|
|
368
|
-
if (!srcContent.equals(backupContent)) {
|
|
369
|
-
throw new Error(`Backup verification failed: ${relativePath} content mismatch (possible cloud sync interference)`);
|
|
370
|
-
}
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
return backupDir;
|
|
374
|
-
}
|
|
34
|
+
// Directories to exclude when enumerating client template files.
|
|
35
|
+
// hub-base/ contains hub-level skills that are handled separately.
|
|
36
|
+
const CLIENT_TEMPLATE_EXCLUDE_DIRS = new Set(['tmp', 'hub-base']);
|
|
375
37
|
|
|
376
38
|
/**
|
|
377
39
|
* Update a single client
|
|
@@ -400,20 +62,15 @@ async function updateClient(clientName, basePathOrOptions = {}, options = {}) {
|
|
|
400
62
|
const currentVersion = manifest.baseVersion;
|
|
401
63
|
|
|
402
64
|
// Migrations for existing clients
|
|
403
|
-
await
|
|
404
|
-
migrateSettingsDenyRules(clientDir);
|
|
405
|
-
migrateSettingsHooks(clientDir);
|
|
406
|
-
migrateManifestKeys(manifest);
|
|
407
|
-
migrateConfigManagedTypes(manifest);
|
|
408
|
-
migrateTmpDataFiles(clientDir, manifest);
|
|
65
|
+
await runAllMigrations(clientDir, manifest);
|
|
409
66
|
|
|
410
67
|
// Check if already up to date
|
|
411
68
|
if (currentVersion === packageVersion) {
|
|
412
69
|
return { status: 'up-to-date' };
|
|
413
70
|
}
|
|
414
71
|
|
|
415
|
-
// Get base template files
|
|
416
|
-
const baseFiles = await getAllFiles(basePath);
|
|
72
|
+
// Get base template files (exclude hub-base/ which is handled separately)
|
|
73
|
+
const baseFiles = await getAllFiles(basePath, basePath, CLIENT_TEMPLATE_EXCLUDE_DIRS);
|
|
417
74
|
|
|
418
75
|
// Detect modifications
|
|
419
76
|
const mods = await detectModifications(clientDir, manifest, baseFiles);
|
|
@@ -635,6 +292,9 @@ export default async function update(options = {}) {
|
|
|
635
292
|
}
|
|
636
293
|
}
|
|
637
294
|
|
|
295
|
+
// Update hub-level skills (before client updates)
|
|
296
|
+
await updateHub(basePath, options);
|
|
297
|
+
|
|
638
298
|
// Discover clients
|
|
639
299
|
let clients = discoverClients();
|
|
640
300
|
|
|
@@ -765,11 +425,7 @@ export default async function update(options = {}) {
|
|
|
765
425
|
}
|
|
766
426
|
|
|
767
427
|
// Export helpers for testing
|
|
768
|
-
export {
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
discoverClients,
|
|
773
|
-
backupFiles,
|
|
774
|
-
updateClient
|
|
775
|
-
};
|
|
428
|
+
export { updateClient };
|
|
429
|
+
export { default as updateHub } from './update-hub.js';
|
|
430
|
+
export { backupFiles } from '../utils/conflicts.js';
|
|
431
|
+
export { getPackageVersion, getBaseTemplatePath, getHubBasePath, getClientsDir, discoverClients } from '../utils/paths.js';
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Conflict resolution and file backup utilities.
|
|
3
|
+
*
|
|
4
|
+
* Used by update commands when managed files have been
|
|
5
|
+
* modified by the user since the last update.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
9
|
+
import { writeFile } from 'node:fs/promises';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
import { createInterface } from 'node:readline';
|
|
12
|
+
import { copyFileWithDirs, ensureDir, createBackupTimestamp } from './fs-helpers.js';
|
|
13
|
+
import { displayDiffs } from './diff.js';
|
|
14
|
+
import logger from './logger.js';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Prompt user for conflict resolution
|
|
18
|
+
* @param {string[]} modifiedFiles - List of modified file paths
|
|
19
|
+
* @param {string} clientDir - Path to client directory
|
|
20
|
+
* @param {string} basePath - Path to base template directory
|
|
21
|
+
* @returns {Promise<'backup'|'overwrite'|'skip'|'cancel'>}
|
|
22
|
+
*/
|
|
23
|
+
export async function promptConflictResolution(modifiedFiles, clientDir, basePath) {
|
|
24
|
+
console.log('');
|
|
25
|
+
logger.warn('Modified files detected:');
|
|
26
|
+
for (const file of modifiedFiles) {
|
|
27
|
+
console.log(` - ${file}`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const showMenu = () => {
|
|
31
|
+
console.log('');
|
|
32
|
+
console.log('Options:');
|
|
33
|
+
console.log(' [d] View differences');
|
|
34
|
+
console.log(' [1] Backup and overwrite (files saved to .backup/)');
|
|
35
|
+
console.log(' [2] Overwrite without backup (discard your changes)');
|
|
36
|
+
console.log(' [3] Skip modified files (keep your changes)');
|
|
37
|
+
console.log(' [4] Cancel update');
|
|
38
|
+
console.log('');
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
showMenu();
|
|
42
|
+
|
|
43
|
+
const rl = createInterface({
|
|
44
|
+
input: process.stdin,
|
|
45
|
+
output: process.stdout
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
return new Promise((resolve) => {
|
|
49
|
+
const ask = () => {
|
|
50
|
+
rl.question('Choice [d/1/2/3/4]: ', (answer) => {
|
|
51
|
+
const choice = answer.trim().toLowerCase();
|
|
52
|
+
if (choice === 'd') {
|
|
53
|
+
displayDiffs(modifiedFiles, clientDir, basePath);
|
|
54
|
+
showMenu();
|
|
55
|
+
ask();
|
|
56
|
+
} else if (choice === '1') {
|
|
57
|
+
rl.close();
|
|
58
|
+
resolve('backup');
|
|
59
|
+
} else if (choice === '2') {
|
|
60
|
+
rl.close();
|
|
61
|
+
resolve('overwrite');
|
|
62
|
+
} else if (choice === '3') {
|
|
63
|
+
rl.close();
|
|
64
|
+
resolve('skip');
|
|
65
|
+
} else if (choice === '4') {
|
|
66
|
+
rl.close();
|
|
67
|
+
resolve('cancel');
|
|
68
|
+
} else {
|
|
69
|
+
console.log('Invalid choice. Please enter d, 1, 2, 3, or 4.');
|
|
70
|
+
ask();
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
};
|
|
74
|
+
ask();
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Backup modified files to .backup/<timestamp>/
|
|
80
|
+
* @param {string} clientDir - Path to client directory
|
|
81
|
+
* @param {string[]} files - Relative paths of files to backup
|
|
82
|
+
* @returns {Promise<string>} Backup folder path
|
|
83
|
+
*/
|
|
84
|
+
export async function backupFiles(clientDir, files) {
|
|
85
|
+
const timestamp = createBackupTimestamp();
|
|
86
|
+
const backupDir = join(clientDir, '.backup', timestamp);
|
|
87
|
+
|
|
88
|
+
for (const relativePath of files) {
|
|
89
|
+
const srcPath = join(clientDir, relativePath);
|
|
90
|
+
const destPath = join(backupDir, relativePath);
|
|
91
|
+
await copyFileWithDirs(srcPath, destPath);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Write backup manifest
|
|
95
|
+
const manifestContent = [
|
|
96
|
+
`# Backup created during update`,
|
|
97
|
+
`# ${new Date().toISOString()}`,
|
|
98
|
+
'',
|
|
99
|
+
...files
|
|
100
|
+
].join('\n');
|
|
101
|
+
await ensureDir(backupDir);
|
|
102
|
+
await writeFile(join(backupDir, 'manifest.txt'), manifestContent);
|
|
103
|
+
|
|
104
|
+
// Verify all files were actually backed up before returning
|
|
105
|
+
for (const relativePath of files) {
|
|
106
|
+
const backedUpPath = join(backupDir, relativePath);
|
|
107
|
+
if (!existsSync(backedUpPath)) {
|
|
108
|
+
throw new Error(`Backup verification failed: ${relativePath} was not created in ${backupDir}`);
|
|
109
|
+
}
|
|
110
|
+
// Verify content matches source (guards against empty/placeholder files from cloud sync)
|
|
111
|
+
const srcContent = readFileSync(join(clientDir, relativePath));
|
|
112
|
+
const backupContent = readFileSync(backedUpPath);
|
|
113
|
+
if (!srcContent.equals(backupContent)) {
|
|
114
|
+
throw new Error(`Backup verification failed: ${relativePath} content mismatch (possible cloud sync interference)`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return backupDir;
|
|
119
|
+
}
|
package/lib/utils/manifest.js
CHANGED
|
@@ -9,6 +9,7 @@ import { calculateChecksum } from './checksum.js';
|
|
|
9
9
|
import { fileExists } from './fs-helpers.js';
|
|
10
10
|
|
|
11
11
|
const MANIFEST_FILENAME = '.managed.json';
|
|
12
|
+
const HUB_MANIFEST_FILENAME = '.managed-hub.json';
|
|
12
13
|
const SCHEMA_VERSION = '1.0';
|
|
13
14
|
|
|
14
15
|
// Files that should be config-only (init once, never update)
|
|
@@ -117,6 +118,44 @@ export function manifestExists(clientDir) {
|
|
|
117
118
|
return existsSync(join(clientDir, MANIFEST_FILENAME));
|
|
118
119
|
}
|
|
119
120
|
|
|
121
|
+
// --- Hub manifest functions ---
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Read .managed-hub.json from the hub root directory (async)
|
|
125
|
+
* @param {string} hubDir - Path to hub root directory
|
|
126
|
+
* @returns {Promise<object>} Parsed manifest
|
|
127
|
+
*/
|
|
128
|
+
export async function readHubManifest(hubDir) {
|
|
129
|
+
const manifestPath = join(hubDir, HUB_MANIFEST_FILENAME);
|
|
130
|
+
const content = await readFile(manifestPath, 'utf8');
|
|
131
|
+
const manifest = JSON.parse(content);
|
|
132
|
+
|
|
133
|
+
if (manifest.schemaVersion !== SCHEMA_VERSION) {
|
|
134
|
+
throw new Error(`Unsupported hub manifest schema version: ${manifest.schemaVersion}`);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return manifest;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Write .managed-hub.json to the hub root directory (async)
|
|
142
|
+
* @param {string} hubDir - Path to hub root directory
|
|
143
|
+
* @param {object} manifest - Manifest object to write
|
|
144
|
+
*/
|
|
145
|
+
export async function writeHubManifest(hubDir, manifest) {
|
|
146
|
+
const manifestPath = join(hubDir, HUB_MANIFEST_FILENAME);
|
|
147
|
+
await writeFile(manifestPath, JSON.stringify(manifest, null, 2) + '\n');
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Check if a hub manifest exists
|
|
152
|
+
* @param {string} hubDir - Path to hub root directory
|
|
153
|
+
* @returns {boolean}
|
|
154
|
+
*/
|
|
155
|
+
export function hubManifestExists(hubDir) {
|
|
156
|
+
return existsSync(join(hubDir, HUB_MANIFEST_FILENAME));
|
|
157
|
+
}
|
|
158
|
+
|
|
120
159
|
/**
|
|
121
160
|
* Detect modifications to managed files
|
|
122
161
|
* @param {string} clientDir - Path to client directory
|