ppcos 1.0.5 → 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 -346
- package/lib/utils/conflicts.js +119 -0
- package/lib/utils/fs-helpers.js +11 -4
- 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,352 +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
|
-
* Find all client directories with .managed.json
|
|
223
|
-
* @returns {string[]} Array of client names
|
|
224
|
-
*/
|
|
225
|
-
function discoverClients() {
|
|
226
|
-
const clientsDir = getClientsDir();
|
|
227
|
-
|
|
228
|
-
if (!existsSync(clientsDir)) {
|
|
229
|
-
return [];
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
const entries = readdirSync(clientsDir, { withFileTypes: true });
|
|
233
|
-
const clients = [];
|
|
234
|
-
|
|
235
|
-
for (const entry of entries) {
|
|
236
|
-
if (entry.isDirectory()) {
|
|
237
|
-
const clientDir = join(clientsDir, entry.name);
|
|
238
|
-
if (manifestExists(clientDir)) {
|
|
239
|
-
clients.push(entry.name);
|
|
240
|
-
}
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
return clients;
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
/**
|
|
248
|
-
* Prompt user for conflict resolution
|
|
249
|
-
* @param {string[]} modifiedFiles - List of modified file paths
|
|
250
|
-
* @param {string} clientDir - Path to client directory
|
|
251
|
-
* @param {string} basePath - Path to base template directory
|
|
252
|
-
* @returns {Promise<'backup'|'overwrite'|'skip'|'cancel'>}
|
|
253
|
-
*/
|
|
254
|
-
async function promptConflictResolution(modifiedFiles, clientDir, basePath) {
|
|
255
|
-
console.log('');
|
|
256
|
-
logger.warn('Modified files detected:');
|
|
257
|
-
for (const file of modifiedFiles) {
|
|
258
|
-
console.log(` - ${file}`);
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
const showMenu = () => {
|
|
262
|
-
console.log('');
|
|
263
|
-
console.log('Options:');
|
|
264
|
-
console.log(' [d] View differences');
|
|
265
|
-
console.log(' [1] Backup and overwrite (files saved to .backup/)');
|
|
266
|
-
console.log(' [2] Overwrite without backup (discard your changes)');
|
|
267
|
-
console.log(' [3] Skip modified files (keep your changes)');
|
|
268
|
-
console.log(' [4] Cancel update');
|
|
269
|
-
console.log('');
|
|
270
|
-
};
|
|
271
|
-
|
|
272
|
-
showMenu();
|
|
273
|
-
|
|
274
|
-
const rl = createInterface({
|
|
275
|
-
input: process.stdin,
|
|
276
|
-
output: process.stdout
|
|
277
|
-
});
|
|
278
|
-
|
|
279
|
-
return new Promise((resolve) => {
|
|
280
|
-
const ask = () => {
|
|
281
|
-
rl.question('Choice [d/1/2/3/4]: ', (answer) => {
|
|
282
|
-
const choice = answer.trim().toLowerCase();
|
|
283
|
-
if (choice === 'd') {
|
|
284
|
-
displayDiffs(modifiedFiles, clientDir, basePath);
|
|
285
|
-
showMenu();
|
|
286
|
-
ask();
|
|
287
|
-
} else if (choice === '1') {
|
|
288
|
-
rl.close();
|
|
289
|
-
resolve('backup');
|
|
290
|
-
} else if (choice === '2') {
|
|
291
|
-
rl.close();
|
|
292
|
-
resolve('overwrite');
|
|
293
|
-
} else if (choice === '3') {
|
|
294
|
-
rl.close();
|
|
295
|
-
resolve('skip');
|
|
296
|
-
} else if (choice === '4') {
|
|
297
|
-
rl.close();
|
|
298
|
-
resolve('cancel');
|
|
299
|
-
} else {
|
|
300
|
-
console.log('Invalid choice. Please enter d, 1, 2, 3, or 4.');
|
|
301
|
-
ask();
|
|
302
|
-
}
|
|
303
|
-
});
|
|
304
|
-
};
|
|
305
|
-
ask();
|
|
306
|
-
});
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
/**
|
|
310
|
-
* Backup modified files to .backup/<timestamp>/
|
|
311
|
-
* @param {string} clientDir - Path to client directory
|
|
312
|
-
* @param {string[]} files - Relative paths of files to backup
|
|
313
|
-
* @returns {Promise<string>} Backup folder path
|
|
314
|
-
*/
|
|
315
|
-
async function backupFiles(clientDir, files) {
|
|
316
|
-
const timestamp = createBackupTimestamp();
|
|
317
|
-
const backupDir = join(clientDir, '.backup', timestamp);
|
|
318
|
-
|
|
319
|
-
for (const relativePath of files) {
|
|
320
|
-
const srcPath = join(clientDir, relativePath);
|
|
321
|
-
const destPath = join(backupDir, relativePath);
|
|
322
|
-
await copyFileWithDirs(srcPath, destPath);
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
// Write backup manifest
|
|
326
|
-
const manifestContent = [
|
|
327
|
-
`# Backup created during update`,
|
|
328
|
-
`# ${new Date().toISOString()}`,
|
|
329
|
-
'',
|
|
330
|
-
...files
|
|
331
|
-
].join('\n');
|
|
332
|
-
await ensureDir(backupDir);
|
|
333
|
-
const { writeFile } = await import('node:fs/promises');
|
|
334
|
-
await writeFile(join(backupDir, 'manifest.txt'), manifestContent);
|
|
335
|
-
|
|
336
|
-
// Verify all files were actually backed up before returning
|
|
337
|
-
const { readFileSync } = await import('node:fs');
|
|
338
|
-
for (const relativePath of files) {
|
|
339
|
-
const backedUpPath = join(backupDir, relativePath);
|
|
340
|
-
if (!existsSync(backedUpPath)) {
|
|
341
|
-
throw new Error(`Backup verification failed: ${relativePath} was not created in ${backupDir}`);
|
|
342
|
-
}
|
|
343
|
-
// Verify content matches source (guards against empty/placeholder files from cloud sync)
|
|
344
|
-
const srcContent = readFileSync(join(clientDir, relativePath));
|
|
345
|
-
const backupContent = readFileSync(backedUpPath);
|
|
346
|
-
if (!srcContent.equals(backupContent)) {
|
|
347
|
-
throw new Error(`Backup verification failed: ${relativePath} content mismatch (possible cloud sync interference)`);
|
|
348
|
-
}
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
return backupDir;
|
|
352
|
-
}
|
|
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']);
|
|
353
37
|
|
|
354
38
|
/**
|
|
355
39
|
* Update a single client
|
|
@@ -378,19 +62,15 @@ async function updateClient(clientName, basePathOrOptions = {}, options = {}) {
|
|
|
378
62
|
const currentVersion = manifest.baseVersion;
|
|
379
63
|
|
|
380
64
|
// Migrations for existing clients
|
|
381
|
-
await
|
|
382
|
-
migrateSettingsDenyRules(clientDir);
|
|
383
|
-
migrateSettingsHooks(clientDir);
|
|
384
|
-
migrateManifestKeys(manifest);
|
|
385
|
-
migrateConfigManagedTypes(manifest);
|
|
65
|
+
await runAllMigrations(clientDir, manifest);
|
|
386
66
|
|
|
387
67
|
// Check if already up to date
|
|
388
68
|
if (currentVersion === packageVersion) {
|
|
389
69
|
return { status: 'up-to-date' };
|
|
390
70
|
}
|
|
391
71
|
|
|
392
|
-
// Get base template files
|
|
393
|
-
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);
|
|
394
74
|
|
|
395
75
|
// Detect modifications
|
|
396
76
|
const mods = await detectModifications(clientDir, manifest, baseFiles);
|
|
@@ -612,6 +292,9 @@ export default async function update(options = {}) {
|
|
|
612
292
|
}
|
|
613
293
|
}
|
|
614
294
|
|
|
295
|
+
// Update hub-level skills (before client updates)
|
|
296
|
+
await updateHub(basePath, options);
|
|
297
|
+
|
|
615
298
|
// Discover clients
|
|
616
299
|
let clients = discoverClients();
|
|
617
300
|
|
|
@@ -742,11 +425,7 @@ export default async function update(options = {}) {
|
|
|
742
425
|
}
|
|
743
426
|
|
|
744
427
|
// Export helpers for testing
|
|
745
|
-
export {
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
discoverClients,
|
|
750
|
-
backupFiles,
|
|
751
|
-
updateClient
|
|
752
|
-
};
|
|
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/fs-helpers.js
CHANGED
|
@@ -7,6 +7,11 @@ import { readdirSync, statSync, mkdirSync, copyFileSync, existsSync, chmodSync }
|
|
|
7
7
|
import { join, relative, dirname } from 'node:path';
|
|
8
8
|
import { constants } from 'node:fs';
|
|
9
9
|
|
|
10
|
+
// Directories to skip during template file enumeration.
|
|
11
|
+
// tmp/ dirs hold generated runtime data (analysis output, caches)
|
|
12
|
+
// that must never be distributed to clients.
|
|
13
|
+
const EXCLUDED_DIR_NAMES = new Set(['tmp']);
|
|
14
|
+
|
|
10
15
|
/**
|
|
11
16
|
* Check if a file exists (async)
|
|
12
17
|
* @param {string} filePath - Path to check
|
|
@@ -36,14 +41,15 @@ export function fileExistsSync(filePath) {
|
|
|
36
41
|
* @param {string} [baseDir] - Base directory for relative paths
|
|
37
42
|
* @returns {Promise<string[]>} Array of relative file paths
|
|
38
43
|
*/
|
|
39
|
-
export async function getAllFiles(dir, baseDir = dir) {
|
|
44
|
+
export async function getAllFiles(dir, baseDir = dir, excludeDirs = EXCLUDED_DIR_NAMES) {
|
|
40
45
|
const files = [];
|
|
41
46
|
const entries = await readdir(dir, { withFileTypes: true });
|
|
42
47
|
|
|
43
48
|
for (const entry of entries) {
|
|
44
49
|
const fullPath = join(dir, entry.name);
|
|
45
50
|
if (entry.isDirectory()) {
|
|
46
|
-
|
|
51
|
+
if (excludeDirs.has(entry.name)) continue;
|
|
52
|
+
files.push(...await getAllFiles(fullPath, baseDir, excludeDirs));
|
|
47
53
|
} else {
|
|
48
54
|
files.push(relative(baseDir, fullPath).replace(/\\/g, '/'));
|
|
49
55
|
}
|
|
@@ -58,14 +64,15 @@ export async function getAllFiles(dir, baseDir = dir) {
|
|
|
58
64
|
* @param {string} [baseDir] - Base directory for relative paths
|
|
59
65
|
* @returns {string[]} Array of relative file paths
|
|
60
66
|
*/
|
|
61
|
-
export function getAllFilesSync(dir, baseDir = dir) {
|
|
67
|
+
export function getAllFilesSync(dir, baseDir = dir, excludeDirs = EXCLUDED_DIR_NAMES) {
|
|
62
68
|
const files = [];
|
|
63
69
|
const entries = readdirSync(dir, { withFileTypes: true });
|
|
64
70
|
|
|
65
71
|
for (const entry of entries) {
|
|
66
72
|
const fullPath = join(dir, entry.name);
|
|
67
73
|
if (entry.isDirectory()) {
|
|
68
|
-
|
|
74
|
+
if (excludeDirs.has(entry.name)) continue;
|
|
75
|
+
files.push(...getAllFilesSync(fullPath, baseDir, excludeDirs));
|
|
69
76
|
} else {
|
|
70
77
|
files.push(relative(baseDir, fullPath).replace(/\\/g, '/'));
|
|
71
78
|
}
|
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
|