ppcos 0.3.0

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.
@@ -0,0 +1,480 @@
1
+ /**
2
+ * update command - Update base skills in all clients
3
+ *
4
+ * Usage: ppcos update [--client <name>] [--dry-run]
5
+ */
6
+
7
+ import { readFileSync, existsSync, readdirSync } from 'node:fs';
8
+ import { join, dirname } from 'node:path';
9
+ import { fileURLToPath } from 'node:url';
10
+ import { createInterface } from 'node:readline';
11
+ import {
12
+ readManifest,
13
+ writeManifest,
14
+ detectModifications,
15
+ addConflict,
16
+ manifestExists,
17
+ getManagedType,
18
+ isConfigFile
19
+ } from '../utils/manifest.js';
20
+ import { calculateChecksum } from '../utils/checksum.js';
21
+ import {
22
+ getAllFiles,
23
+ copyFileWithDirs,
24
+ ensureDir,
25
+ createBackupTimestamp
26
+ } from '../utils/fs-helpers.js';
27
+ import { fetchSkills } from '../utils/skills-fetcher.js';
28
+ import { requireAuth } from '../utils/auth.js';
29
+ import { mkdtempSync, rmSync } from 'node:fs';
30
+ import { tmpdir } from 'node:os';
31
+ import logger from '../utils/logger.js';
32
+ import ora from 'ora';
33
+
34
+ // Get package root directory
35
+ const __filename = fileURLToPath(import.meta.url);
36
+ const __dirname = dirname(__filename);
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
+ * Find all client directories with .managed.json
80
+ * @returns {string[]} Array of client names
81
+ */
82
+ function discoverClients() {
83
+ const clientsDir = getClientsDir();
84
+
85
+ if (!existsSync(clientsDir)) {
86
+ return [];
87
+ }
88
+
89
+ const entries = readdirSync(clientsDir, { withFileTypes: true });
90
+ const clients = [];
91
+
92
+ for (const entry of entries) {
93
+ if (entry.isDirectory()) {
94
+ const clientDir = join(clientsDir, entry.name);
95
+ if (manifestExists(clientDir)) {
96
+ clients.push(entry.name);
97
+ }
98
+ }
99
+ }
100
+
101
+ return clients;
102
+ }
103
+
104
+ /**
105
+ * Prompt user for conflict resolution
106
+ * @param {string[]} modifiedFiles - List of modified file paths
107
+ * @returns {Promise<'backup'|'skip'|'cancel'>}
108
+ */
109
+ async function promptConflictResolution(modifiedFiles) {
110
+ console.log('');
111
+ logger.warn('Modified files detected:');
112
+ for (const file of modifiedFiles) {
113
+ console.log(` - ${file}`);
114
+ }
115
+
116
+ console.log('');
117
+ console.log('Options:');
118
+ console.log(' [1] Backup and overwrite (files saved to .backup/)');
119
+ console.log(' [2] Skip modified files (keep your changes)');
120
+ console.log(' [3] Cancel update');
121
+ console.log('');
122
+
123
+ const rl = createInterface({
124
+ input: process.stdin,
125
+ output: process.stdout
126
+ });
127
+
128
+ return new Promise((resolve) => {
129
+ const ask = () => {
130
+ rl.question('Choice [1/2/3]: ', (answer) => {
131
+ const choice = answer.trim();
132
+ if (choice === '1') {
133
+ rl.close();
134
+ resolve('backup');
135
+ } else if (choice === '2') {
136
+ rl.close();
137
+ resolve('skip');
138
+ } else if (choice === '3') {
139
+ rl.close();
140
+ resolve('cancel');
141
+ } else {
142
+ console.log('Invalid choice. Please enter 1, 2, or 3.');
143
+ ask();
144
+ }
145
+ });
146
+ };
147
+ ask();
148
+ });
149
+ }
150
+
151
+ /**
152
+ * Backup modified files to .backup/<timestamp>/
153
+ * @param {string} clientDir - Path to client directory
154
+ * @param {string[]} files - Relative paths of files to backup
155
+ * @returns {Promise<string>} Backup folder path
156
+ */
157
+ async function backupFiles(clientDir, files) {
158
+ const timestamp = createBackupTimestamp();
159
+ const backupDir = join(clientDir, '.backup', timestamp);
160
+
161
+ for (const relativePath of files) {
162
+ const srcPath = join(clientDir, relativePath);
163
+ const destPath = join(backupDir, relativePath);
164
+ await copyFileWithDirs(srcPath, destPath);
165
+ }
166
+
167
+ // Write backup manifest
168
+ const manifestContent = [
169
+ `# Backup created during update`,
170
+ `# ${new Date().toISOString()}`,
171
+ '',
172
+ ...files
173
+ ].join('\n');
174
+ await ensureDir(backupDir);
175
+ const { writeFile } = await import('node:fs/promises');
176
+ await writeFile(join(backupDir, 'manifest.txt'), manifestContent);
177
+
178
+ return backupDir;
179
+ }
180
+
181
+ /**
182
+ * Update a single client
183
+ * @param {string} clientName - Client name
184
+ * @param {string|object} basePathOrOptions - Path to template files (temp dir or local), or options object for backward compat
185
+ * @param {object} options - Command options
186
+ * @param {boolean} options.dryRun - Dry run mode
187
+ * @returns {Promise<{status: 'updated'|'up-to-date'|'skipped'|'cancelled', details?: object}>}
188
+ */
189
+ async function updateClient(clientName, basePathOrOptions = {}, options = {}) {
190
+ // Backward compatibility: if second arg is object, it's options (old signature)
191
+ let basePath;
192
+ if (typeof basePathOrOptions === 'string') {
193
+ basePath = basePathOrOptions;
194
+ } else {
195
+ options = basePathOrOptions;
196
+ basePath = getBaseTemplatePath();
197
+ }
198
+
199
+ const clientsDir = getClientsDir();
200
+ const clientDir = join(clientsDir, clientName);
201
+ const packageVersion = getPackageVersion();
202
+
203
+ // Read manifest
204
+ const manifest = await readManifest(clientDir);
205
+ const currentVersion = manifest.baseVersion;
206
+
207
+ // Auto-create memory folder for existing clients (migration)
208
+ await ensureMemoryFolder(clientDir);
209
+
210
+ // Check if already up to date
211
+ if (currentVersion === packageVersion) {
212
+ return { status: 'up-to-date' };
213
+ }
214
+
215
+ // Get base template files
216
+ const baseFiles = await getAllFiles(basePath);
217
+
218
+ // Detect modifications
219
+ const mods = await detectModifications(clientDir, manifest, baseFiles);
220
+
221
+ // Dry run output
222
+ if (options.dryRun) {
223
+ return {
224
+ status: 'skipped',
225
+ details: {
226
+ fromVersion: currentVersion,
227
+ toVersion: packageVersion,
228
+ unchanged: mods.unchanged.length,
229
+ modified: mods.modified,
230
+ missing: mods.missing,
231
+ newInBase: mods.newInBase
232
+ }
233
+ };
234
+ }
235
+
236
+ // Handle modified files
237
+ let resolution = null;
238
+ let backedUpFiles = [];
239
+
240
+ if (mods.modified.length > 0) {
241
+ console.log(`Updating ${clientName} (v${currentVersion} → v${packageVersion})`);
242
+ resolution = await promptConflictResolution(mods.modified);
243
+
244
+ if (resolution === 'cancel') {
245
+ return { status: 'cancelled' };
246
+ }
247
+
248
+ if (resolution === 'backup') {
249
+ const backupDir = await backupFiles(clientDir, mods.modified);
250
+ backedUpFiles = mods.modified;
251
+ console.log(` Backed up ${mods.modified.length} modified files to ${backupDir.replace(clientDir + '/', '')}`);
252
+ }
253
+ }
254
+
255
+ // Determine which files to update
256
+ const filesToUpdate = [];
257
+ const filesToSkip = [];
258
+
259
+ for (const file of baseFiles) {
260
+ // Skip config files entirely (they're never updated)
261
+ if (isConfigFile(file)) {
262
+ continue;
263
+ }
264
+
265
+ if (mods.modified.includes(file) && resolution === 'skip') {
266
+ filesToSkip.push(file);
267
+ } else {
268
+ filesToUpdate.push(file);
269
+ }
270
+ }
271
+
272
+ // Apply updates
273
+ const updatedFiles = {};
274
+
275
+ for (const relativePath of filesToUpdate) {
276
+ const srcPath = join(basePath, relativePath);
277
+ const destPath = join(clientDir, relativePath);
278
+
279
+ await copyFileWithDirs(srcPath, destPath);
280
+
281
+ const checksum = await calculateChecksum(destPath);
282
+
283
+ // Preserve managedType from existing manifest or determine new
284
+ const existingEntry = manifest.managedFiles[relativePath];
285
+ const managedType = existingEntry?.managedType || getManagedType(relativePath);
286
+
287
+ updatedFiles[relativePath] = {
288
+ checksum,
289
+ version: packageVersion,
290
+ managedType
291
+ };
292
+ }
293
+
294
+ // Update manifest
295
+ manifest.baseVersion = packageVersion;
296
+ manifest.lastUpdated = new Date().toISOString();
297
+
298
+ // Update file entries
299
+ for (const [path, info] of Object.entries(updatedFiles)) {
300
+ manifest.managedFiles[path] = info;
301
+ }
302
+
303
+ // Record skipped files as conflicts
304
+ for (const file of filesToSkip) {
305
+ addConflict(manifest, file, 'User modified - skipped during update', packageVersion);
306
+ }
307
+
308
+ await writeManifest(clientDir, manifest);
309
+
310
+ return {
311
+ status: 'updated',
312
+ details: {
313
+ fromVersion: currentVersion,
314
+ toVersion: packageVersion,
315
+ updatedCount: filesToUpdate.length,
316
+ skippedCount: filesToSkip.length,
317
+ backedUpCount: backedUpFiles.length
318
+ }
319
+ };
320
+ }
321
+
322
+ /**
323
+ * Main update command handler
324
+ * @param {object} options - Command options
325
+ * @param {string} [options.client] - Update only this client
326
+ * @param {boolean} [options.dryRun] - Show changes without applying
327
+ */
328
+ export default async function update(options = {}) {
329
+ const packageVersion = getPackageVersion();
330
+
331
+ // Download latest skills from API to temp directory
332
+ const spinner = ora('Downloading latest skills from API...').start();
333
+ const tempDir = mkdtempSync(join(tmpdir(), 'ppcos-update-'));
334
+
335
+ let basePath;
336
+ try {
337
+ await fetchSkills(tempDir);
338
+ basePath = tempDir;
339
+ spinner.succeed('Skills downloaded');
340
+ } catch (error) {
341
+ spinner.fail('Failed to download skills');
342
+ logger.error(error.message);
343
+ logger.info('Falling back to local template if available...');
344
+
345
+ // Fallback to local template during migration period
346
+ basePath = getBaseTemplatePath();
347
+ if (!existsSync(basePath)) {
348
+ logger.error('No local template available. Cannot update.');
349
+ process.exitCode = 1;
350
+ rmSync(tempDir, { recursive: true, force: true });
351
+ return;
352
+ }
353
+ }
354
+
355
+ // Discover clients
356
+ let clients = discoverClients();
357
+
358
+ if (clients.length === 0) {
359
+ logger.info('No clients found.');
360
+ console.log('');
361
+ console.log('To create a client:');
362
+ console.log(' ppcos init <client-name>');
363
+ console.log('');
364
+ console.log('Or create main-config.json and run:');
365
+ console.log(' ppcos init-all');
366
+ return;
367
+ }
368
+
369
+ // Filter to specific client if requested
370
+ if (options.client) {
371
+ if (!clients.includes(options.client)) {
372
+ logger.error(`Client "${options.client}" not found.`);
373
+ process.exitCode = 1;
374
+ return;
375
+ }
376
+ clients = [options.client];
377
+ }
378
+
379
+ // Dry run header
380
+ if (options.dryRun) {
381
+ console.log('Dry run - no changes will be made');
382
+ console.log('');
383
+ }
384
+
385
+ // Process each client
386
+ const results = {
387
+ updated: 0,
388
+ upToDate: 0,
389
+ skipped: 0,
390
+ cancelled: 0
391
+ };
392
+
393
+ for (const clientName of clients) {
394
+ try {
395
+ const result = await updateClient(clientName, basePath, options);
396
+
397
+ if (options.dryRun && result.details) {
398
+ // Dry run output
399
+ const d = result.details;
400
+ console.log(`${clientName} (v${d.fromVersion} → v${d.toVersion}):`);
401
+
402
+ const updateCount = d.unchanged + d.missing.length + d.newInBase.length;
403
+ console.log(` Update: ${updateCount} files`);
404
+
405
+ if (d.modified.length > 0) {
406
+ console.log(` Modified (would prompt): ${d.modified.length} files`);
407
+ for (const file of d.modified) {
408
+ console.log(` - ${file}`);
409
+ }
410
+ }
411
+
412
+ if (d.newInBase.length > 0) {
413
+ console.log(` New: ${d.newInBase.length} files`);
414
+ for (const file of d.newInBase) {
415
+ console.log(` - ${file}`);
416
+ }
417
+ }
418
+
419
+ console.log('');
420
+ results.skipped++;
421
+ } else if (result.status === 'up-to-date') {
422
+ if (!options.dryRun) {
423
+ console.log(`Updating ${clientName}...`);
424
+ console.log(' Already up to date');
425
+ console.log('');
426
+ } else {
427
+ console.log(`${clientName} (v${packageVersion}):`);
428
+ console.log(' Already up to date');
429
+ console.log('');
430
+ }
431
+ results.upToDate++;
432
+ } else if (result.status === 'updated') {
433
+ const d = result.details;
434
+ console.log(` Updated ${d.updatedCount} files`);
435
+ if (d.skippedCount > 0) {
436
+ console.log(` Skipped ${d.skippedCount} modified files`);
437
+ }
438
+ logger.success('Complete');
439
+ console.log('');
440
+ results.updated++;
441
+ } else if (result.status === 'cancelled') {
442
+ console.log(' Update cancelled');
443
+ console.log('');
444
+ results.cancelled++;
445
+ }
446
+ } catch (err) {
447
+ logger.error(`Failed to update ${clientName}: ${err.message}`);
448
+ results.skipped++;
449
+ }
450
+ }
451
+
452
+ // Summary (only for non-dry-run with multiple clients)
453
+ if (!options.dryRun && clients.length > 1) {
454
+ console.log('Summary:');
455
+ if (results.updated > 0) {
456
+ console.log(` Updated: ${results.updated} client${results.updated !== 1 ? 's' : ''}`);
457
+ }
458
+ if (results.upToDate > 0) {
459
+ console.log(` Up to date: ${results.upToDate} client${results.upToDate !== 1 ? 's' : ''}`);
460
+ }
461
+ if (results.cancelled > 0) {
462
+ console.log(` Cancelled: ${results.cancelled} client${results.cancelled !== 1 ? 's' : ''}`);
463
+ }
464
+ }
465
+
466
+ // Clean up temp directory
467
+ if (tempDir && tempDir.includes('ppcos-update-')) {
468
+ rmSync(tempDir, { recursive: true, force: true });
469
+ }
470
+ }
471
+
472
+ // Export helpers for testing
473
+ export {
474
+ getPackageVersion,
475
+ getBaseTemplatePath,
476
+ getClientsDir,
477
+ discoverClients,
478
+ backupFiles,
479
+ updateClient
480
+ };
@@ -0,0 +1,42 @@
1
+ import logger from '../utils/logger.js';
2
+ import { readAuth } from '../utils/auth.js';
3
+ import chalk from 'chalk';
4
+
5
+ /**
6
+ * Show current authentication status
7
+ */
8
+ export async function whoami() {
9
+ const auth = readAuth();
10
+
11
+ if (!auth?.sessionToken) {
12
+ logger.info('Not logged in');
13
+ logger.info('Run: ppcos login');
14
+ return;
15
+ }
16
+
17
+ const expiresAt = new Date(auth.expiresAt);
18
+ const now = new Date();
19
+ const isExpired = expiresAt < now;
20
+
21
+ console.log();
22
+ console.log(chalk.bold('Authentication Status'));
23
+ console.log(chalk.gray('─'.repeat(50)));
24
+ console.log(`${chalk.bold('Email:')} ${auth.email}`);
25
+ console.log(`${chalk.bold('Status:')} ${isExpired ? chalk.red('Expired') : chalk.green('Active')}`);
26
+ console.log(`${chalk.bold('Expires:')} ${expiresAt.toLocaleString()}`);
27
+
28
+ if (isExpired) {
29
+ console.log();
30
+ console.log(chalk.yellow('Session expired. Run: ppcos login'));
31
+ } else {
32
+ const hoursLeft = Math.floor((expiresAt - now) / (1000 * 60 * 60));
33
+ const minutesLeft = Math.floor(((expiresAt - now) % (1000 * 60 * 60)) / (1000 * 60));
34
+
35
+ if (hoursLeft < 2) {
36
+ console.log();
37
+ console.log(chalk.yellow(`⚠ Session expires in ${hoursLeft}h ${minutesLeft}m`));
38
+ }
39
+ }
40
+
41
+ console.log();
42
+ }
@@ -0,0 +1,119 @@
1
+ import logger from './logger.js';
2
+
3
+ // API base URL - can be overridden with PPCOS_API_URL env var
4
+ const API_BASE_URL = process.env.PPCOS_API_URL || 'https://ppcos.vercel.app';
5
+
6
+ /**
7
+ * Send verification code to email
8
+ */
9
+ export async function sendVerificationCode(email) {
10
+ try {
11
+ const response = await fetch(`${API_BASE_URL}/api/auth/send-code`, {
12
+ method: 'POST',
13
+ headers: { 'Content-Type': 'application/json' },
14
+ body: JSON.stringify({ email })
15
+ });
16
+
17
+ if (!response.ok) {
18
+ const error = await response.json();
19
+ throw new Error(error.error || 'Failed to send code');
20
+ }
21
+
22
+ return await response.json();
23
+ } catch (error) {
24
+ logger.error(`API error: ${error.message}`);
25
+ throw error;
26
+ }
27
+ }
28
+
29
+ /**
30
+ * Verify OTP code and get session token
31
+ */
32
+ export async function verifyCode(email, code, token) {
33
+ try {
34
+ const response = await fetch(`${API_BASE_URL}/api/auth/verify-code`, {
35
+ method: 'POST',
36
+ headers: { 'Content-Type': 'application/json' },
37
+ body: JSON.stringify({ email, code, token })
38
+ });
39
+
40
+ if (!response.ok) {
41
+ const error = await response.json();
42
+ throw new Error(error.error || 'Verification failed');
43
+ }
44
+
45
+ return await response.json();
46
+ } catch (error) {
47
+ logger.error(`API error: ${error.message}`);
48
+ throw error;
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Validate current session
54
+ */
55
+ export async function validateSession(sessionToken) {
56
+ try {
57
+ const response = await fetch(`${API_BASE_URL}/api/auth/validate`, {
58
+ method: 'POST',
59
+ headers: {
60
+ 'Authorization': `Bearer ${sessionToken}`
61
+ }
62
+ });
63
+
64
+ if (!response.ok) {
65
+ return false;
66
+ }
67
+
68
+ const data = await response.json();
69
+ return data.valid;
70
+ } catch (error) {
71
+ return false;
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Get skills version info
77
+ */
78
+ export async function getSkillsVersion(sessionToken) {
79
+ try {
80
+ const response = await fetch(`${API_BASE_URL}/api/skills/version`, {
81
+ headers: {
82
+ 'Authorization': `Bearer ${sessionToken}`
83
+ }
84
+ });
85
+
86
+ if (!response.ok) {
87
+ const error = await response.json();
88
+ throw new Error(error.error || 'Failed to get version');
89
+ }
90
+
91
+ return await response.json();
92
+ } catch (error) {
93
+ logger.error(`API error: ${error.message}`);
94
+ throw error;
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Download skills as stream
100
+ */
101
+ export async function downloadSkills(sessionToken) {
102
+ try {
103
+ const response = await fetch(`${API_BASE_URL}/api/skills`, {
104
+ headers: {
105
+ 'Authorization': `Bearer ${sessionToken}`
106
+ }
107
+ });
108
+
109
+ if (!response.ok) {
110
+ const error = await response.json().catch(() => ({ error: 'Download failed' }));
111
+ throw new Error(error.error || 'Failed to download skills');
112
+ }
113
+
114
+ return response.body;
115
+ } catch (error) {
116
+ logger.error(`API error: ${error.message}`);
117
+ throw error;
118
+ }
119
+ }