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.
- package/LICENSE +9 -0
- package/README.md +246 -0
- package/bin/ppcos.js +91 -0
- package/lib/commands/init-all.js +247 -0
- package/lib/commands/init.js +229 -0
- package/lib/commands/login.js +85 -0
- package/lib/commands/logout.js +17 -0
- package/lib/commands/status.js +289 -0
- package/lib/commands/update.js +480 -0
- package/lib/commands/whoami.js +42 -0
- package/lib/utils/api-client.js +119 -0
- package/lib/utils/auth.js +117 -0
- package/lib/utils/checksum.js +61 -0
- package/lib/utils/fs-helpers.js +172 -0
- package/lib/utils/logger.js +51 -0
- package/lib/utils/manifest.js +212 -0
- package/lib/utils/skills-fetcher.js +50 -0
- package/lib/utils/validation.js +176 -0
- package/package.json +36 -0
|
@@ -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
|
+
}
|