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,117 @@
1
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
2
+ import { join, dirname } from 'path';
3
+ import { homedir } from 'os';
4
+ import logger from './logger.js';
5
+
6
+ /**
7
+ * Get path to PPCOS config file
8
+ */
9
+ export function getConfigPath() {
10
+ return join(homedir(), '.ppcos', 'config.json');
11
+ }
12
+
13
+ /**
14
+ * Ensure config directory exists
15
+ */
16
+ function ensureConfigDir() {
17
+ const configPath = getConfigPath();
18
+ const dir = dirname(configPath);
19
+
20
+ if (!existsSync(dir)) {
21
+ mkdirSync(dir, { recursive: true });
22
+ }
23
+ }
24
+
25
+ /**
26
+ * Read entire config file
27
+ */
28
+ export function readConfig() {
29
+ try {
30
+ const configPath = getConfigPath();
31
+ if (!existsSync(configPath)) {
32
+ return {};
33
+ }
34
+ return JSON.parse(readFileSync(configPath, 'utf8'));
35
+ } catch (error) {
36
+ logger.error(`Failed to read config: ${error.message}`);
37
+ return {};
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Write entire config file
43
+ */
44
+ export function writeConfig(config) {
45
+ try {
46
+ ensureConfigDir();
47
+ const configPath = getConfigPath();
48
+ writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf8');
49
+ } catch (error) {
50
+ logger.error(`Failed to write config: ${error.message}`);
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Read auth data from config
56
+ */
57
+ export function readAuth() {
58
+ const config = readConfig();
59
+ return config.auth || null;
60
+ }
61
+
62
+ /**
63
+ * Write auth data to config
64
+ */
65
+ export function writeAuth(authData) {
66
+ const config = readConfig();
67
+ config.auth = authData;
68
+ writeConfig(config);
69
+ }
70
+
71
+ /**
72
+ * Clear auth data from config
73
+ */
74
+ export function clearAuth() {
75
+ const config = readConfig();
76
+ delete config.auth;
77
+ writeConfig(config);
78
+ }
79
+
80
+ /**
81
+ * Check if user is authenticated with valid session
82
+ * Returns auth data if valid, false otherwise
83
+ */
84
+ export function requireAuth() {
85
+ const auth = readAuth();
86
+
87
+ if (!auth?.sessionToken) {
88
+ logger.error('Authentication required. Run: ppcos login');
89
+ return false;
90
+ }
91
+
92
+ if (!auth.expiresAt) {
93
+ logger.error('Invalid session. Run: ppcos login');
94
+ return false;
95
+ }
96
+
97
+ const expiresAt = new Date(auth.expiresAt);
98
+ if (expiresAt < new Date()) {
99
+ logger.error('Session expired. Run: ppcos login');
100
+ return false;
101
+ }
102
+
103
+ return auth;
104
+ }
105
+
106
+ /**
107
+ * Check if session is about to expire (less than 2 hours left)
108
+ */
109
+ export function isSessionExpiring() {
110
+ const auth = readAuth();
111
+ if (!auth?.expiresAt) return false;
112
+
113
+ const expiresAt = new Date(auth.expiresAt);
114
+ const twoHoursFromNow = new Date(Date.now() + 2 * 60 * 60 * 1000);
115
+
116
+ return expiresAt < twoHoursFromNow;
117
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Checksum utilities - SHA256 calculations for files
3
+ */
4
+
5
+ import { createHash } from 'node:crypto';
6
+ import { readFile } from 'node:fs/promises';
7
+ import { readFileSync } from 'node:fs';
8
+
9
+ /**
10
+ * Calculate SHA256 checksum of a file (async)
11
+ * @param {string} filePath - Path to file
12
+ * @returns {Promise<string>} Checksum with "sha256:" prefix
13
+ */
14
+ export async function calculateChecksum(filePath) {
15
+ const content = await readFile(filePath);
16
+ const hash = createHash('sha256').update(content).digest('hex');
17
+ return `sha256:${hash}`;
18
+ }
19
+
20
+ /**
21
+ * Calculate SHA256 checksum of a file (sync)
22
+ * @param {string} filePath - Path to file
23
+ * @returns {string} Checksum with "sha256:" prefix
24
+ */
25
+ export function calculateChecksumSync(filePath) {
26
+ const content = readFileSync(filePath);
27
+ const hash = createHash('sha256').update(content).digest('hex');
28
+ return `sha256:${hash}`;
29
+ }
30
+
31
+ /**
32
+ * Calculate SHA256 checksum from content
33
+ * @param {string|Buffer} content - Content to hash
34
+ * @returns {string} Checksum with "sha256:" prefix
35
+ */
36
+ export function checksumFromContent(content) {
37
+ const hash = createHash('sha256').update(content).digest('hex');
38
+ return `sha256:${hash}`;
39
+ }
40
+
41
+ /**
42
+ * Verify a file's checksum matches expected value
43
+ * @param {string} filePath - Path to file
44
+ * @param {string} expectedChecksum - Expected checksum (with sha256: prefix)
45
+ * @returns {Promise<boolean>} True if checksums match
46
+ */
47
+ export async function verifyChecksum(filePath, expectedChecksum) {
48
+ const actualChecksum = await calculateChecksum(filePath);
49
+ return actualChecksum === expectedChecksum;
50
+ }
51
+
52
+ /**
53
+ * Verify a file's checksum matches expected value (sync)
54
+ * @param {string} filePath - Path to file
55
+ * @param {string} expectedChecksum - Expected checksum (with sha256: prefix)
56
+ * @returns {boolean} True if checksums match
57
+ */
58
+ export function verifyChecksumSync(filePath, expectedChecksum) {
59
+ const actualChecksum = calculateChecksumSync(filePath);
60
+ return actualChecksum === expectedChecksum;
61
+ }
@@ -0,0 +1,172 @@
1
+ /**
2
+ * File system helper utilities
3
+ */
4
+
5
+ import { readdir, stat, mkdir, copyFile, access } from 'node:fs/promises';
6
+ import { readdirSync, statSync, mkdirSync, copyFileSync, existsSync } from 'node:fs';
7
+ import { join, relative, dirname } from 'node:path';
8
+ import { constants } from 'node:fs';
9
+
10
+ /**
11
+ * Check if a file exists (async)
12
+ * @param {string} filePath - Path to check
13
+ * @returns {Promise<boolean>}
14
+ */
15
+ export async function fileExists(filePath) {
16
+ try {
17
+ await access(filePath, constants.F_OK);
18
+ return true;
19
+ } catch {
20
+ return false;
21
+ }
22
+ }
23
+
24
+ /**
25
+ * Check if a file exists (sync)
26
+ * @param {string} filePath - Path to check
27
+ * @returns {boolean}
28
+ */
29
+ export function fileExistsSync(filePath) {
30
+ return existsSync(filePath);
31
+ }
32
+
33
+ /**
34
+ * Recursively get all files in a directory (async)
35
+ * @param {string} dir - Directory to scan
36
+ * @param {string} [baseDir] - Base directory for relative paths
37
+ * @returns {Promise<string[]>} Array of relative file paths
38
+ */
39
+ export async function getAllFiles(dir, baseDir = dir) {
40
+ const files = [];
41
+ const entries = await readdir(dir, { withFileTypes: true });
42
+
43
+ for (const entry of entries) {
44
+ const fullPath = join(dir, entry.name);
45
+ if (entry.isDirectory()) {
46
+ files.push(...await getAllFiles(fullPath, baseDir));
47
+ } else {
48
+ files.push(relative(baseDir, fullPath));
49
+ }
50
+ }
51
+
52
+ return files;
53
+ }
54
+
55
+ /**
56
+ * Recursively get all files in a directory (sync)
57
+ * @param {string} dir - Directory to scan
58
+ * @param {string} [baseDir] - Base directory for relative paths
59
+ * @returns {string[]} Array of relative file paths
60
+ */
61
+ export function getAllFilesSync(dir, baseDir = dir) {
62
+ const files = [];
63
+ const entries = readdirSync(dir, { withFileTypes: true });
64
+
65
+ for (const entry of entries) {
66
+ const fullPath = join(dir, entry.name);
67
+ if (entry.isDirectory()) {
68
+ files.push(...getAllFilesSync(fullPath, baseDir));
69
+ } else {
70
+ files.push(relative(baseDir, fullPath));
71
+ }
72
+ }
73
+
74
+ return files;
75
+ }
76
+
77
+ /**
78
+ * Copy a file, creating parent directories as needed (async)
79
+ * @param {string} src - Source file path
80
+ * @param {string} dest - Destination file path
81
+ */
82
+ export async function copyFileWithDirs(src, dest) {
83
+ const destDir = dirname(dest);
84
+ await mkdir(destDir, { recursive: true });
85
+ await copyFile(src, dest);
86
+ }
87
+
88
+ /**
89
+ * Copy a file, creating parent directories as needed (sync)
90
+ * @param {string} src - Source file path
91
+ * @param {string} dest - Destination file path
92
+ */
93
+ export function copyFileWithDirsSync(src, dest) {
94
+ const destDir = dirname(dest);
95
+ if (!existsSync(destDir)) {
96
+ mkdirSync(destDir, { recursive: true });
97
+ }
98
+ copyFileSync(src, dest);
99
+ }
100
+
101
+ /**
102
+ * Ensure a directory exists (async)
103
+ * @param {string} dir - Directory path
104
+ */
105
+ export async function ensureDir(dir) {
106
+ await mkdir(dir, { recursive: true });
107
+ }
108
+
109
+ /**
110
+ * Ensure a directory exists (sync)
111
+ * @param {string} dir - Directory path
112
+ */
113
+ export function ensureDirSync(dir) {
114
+ if (!existsSync(dir)) {
115
+ mkdirSync(dir, { recursive: true });
116
+ }
117
+ }
118
+
119
+ /**
120
+ * Check if a path is a directory
121
+ * @param {string} path - Path to check
122
+ * @returns {Promise<boolean>}
123
+ */
124
+ export async function isDirectory(path) {
125
+ try {
126
+ const stats = await stat(path);
127
+ return stats.isDirectory();
128
+ } catch {
129
+ return false;
130
+ }
131
+ }
132
+
133
+ /**
134
+ * Check if a path is a directory (sync)
135
+ * @param {string} path - Path to check
136
+ * @returns {boolean}
137
+ */
138
+ export function isDirectorySync(path) {
139
+ try {
140
+ return statSync(path).isDirectory();
141
+ } catch {
142
+ return false;
143
+ }
144
+ }
145
+
146
+ /**
147
+ * Copy all files from source to destination directory
148
+ * @param {string} srcDir - Source directory
149
+ * @param {string} destDir - Destination directory
150
+ * @returns {Promise<string[]>} List of copied relative paths
151
+ */
152
+ export async function copyDirectory(srcDir, destDir) {
153
+ const files = await getAllFiles(srcDir);
154
+ const copied = [];
155
+
156
+ for (const relativePath of files) {
157
+ const src = join(srcDir, relativePath);
158
+ const dest = join(destDir, relativePath);
159
+ await copyFileWithDirs(src, dest);
160
+ copied.push(relativePath);
161
+ }
162
+
163
+ return copied;
164
+ }
165
+
166
+ /**
167
+ * Create a timestamped backup directory name
168
+ * @returns {string} ISO timestamp formatted for filesystem (no colons)
169
+ */
170
+ export function createBackupTimestamp() {
171
+ return new Date().toISOString().replace(/:/g, '').replace(/\.\d{3}Z$/, '');
172
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Logger utilities - Colored console output
3
+ *
4
+ * Implementation: Phase 4
5
+ */
6
+
7
+ import chalk from 'chalk';
8
+
9
+ /**
10
+ * Log a success message
11
+ * @param {string} message
12
+ */
13
+ export function success(message) {
14
+ console.log(chalk.green('✓'), message);
15
+ }
16
+
17
+ /**
18
+ * Log an error message
19
+ * @param {string} message
20
+ */
21
+ export function error(message) {
22
+ console.log(chalk.red('✗'), message);
23
+ }
24
+
25
+ /**
26
+ * Log a warning message
27
+ * @param {string} message
28
+ */
29
+ export function warn(message) {
30
+ console.log(chalk.yellow('⚠'), message);
31
+ }
32
+
33
+ /**
34
+ * Log an info message
35
+ * @param {string} message
36
+ */
37
+ export function info(message) {
38
+ console.log(chalk.blue('ℹ'), message);
39
+ }
40
+
41
+ /**
42
+ * Log a debug message (only if DEBUG env var is set)
43
+ * @param {string} message
44
+ */
45
+ export function debug(message) {
46
+ if (process.env.DEBUG) {
47
+ console.log(chalk.gray('[debug]'), message);
48
+ }
49
+ }
50
+
51
+ export default { success, error, warn, info, debug };
@@ -0,0 +1,212 @@
1
+ /**
2
+ * Manifest utilities - .managed.json handling
3
+ */
4
+
5
+ import { readFile, writeFile } from 'node:fs/promises';
6
+ import { readFileSync, writeFileSync, existsSync } from 'node:fs';
7
+ import { join } from 'node:path';
8
+ import { calculateChecksum } from './checksum.js';
9
+ import { fileExists } from './fs-helpers.js';
10
+
11
+ const MANIFEST_FILENAME = '.managed.json';
12
+ const SCHEMA_VERSION = '1.0';
13
+
14
+ // Files that should be config-only (init once, never update)
15
+ const CONFIG_ONLY_FILES = new Set([
16
+ 'config/ads-context.config.json',
17
+ '.claude/settings.local.json',
18
+ 'config/.env'
19
+ ]);
20
+
21
+ /**
22
+ * Check if a file should be treated as config-only
23
+ * @param {string} relativePath - Relative path from client root
24
+ * @returns {boolean}
25
+ */
26
+ export function isConfigFile(relativePath) {
27
+ return CONFIG_ONLY_FILES.has(relativePath);
28
+ }
29
+
30
+ /**
31
+ * Get the managed type for a file
32
+ * @param {string} relativePath - Relative path from client root
33
+ * @returns {'update'|'config'}
34
+ */
35
+ export function getManagedType(relativePath) {
36
+ return isConfigFile(relativePath) ? 'config' : 'update';
37
+ }
38
+
39
+ /**
40
+ * Read .managed.json from a client directory (async)
41
+ * @param {string} clientDir - Path to client directory
42
+ * @returns {Promise<object>} Parsed manifest
43
+ * @throws {Error} If manifest doesn't exist or is invalid
44
+ */
45
+ export async function readManifest(clientDir) {
46
+ const manifestPath = join(clientDir, MANIFEST_FILENAME);
47
+ const content = await readFile(manifestPath, 'utf8');
48
+ const manifest = JSON.parse(content);
49
+
50
+ if (manifest.schemaVersion !== SCHEMA_VERSION) {
51
+ throw new Error(`Unsupported manifest schema version: ${manifest.schemaVersion}`);
52
+ }
53
+
54
+ return manifest;
55
+ }
56
+
57
+ /**
58
+ * Read .managed.json from a client directory (sync)
59
+ * @param {string} clientDir - Path to client directory
60
+ * @returns {object|null} Parsed manifest or null if not found
61
+ */
62
+ export function readManifestSync(clientDir) {
63
+ const manifestPath = join(clientDir, MANIFEST_FILENAME);
64
+ if (!existsSync(manifestPath)) {
65
+ return null;
66
+ }
67
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
68
+ return manifest;
69
+ }
70
+
71
+ /**
72
+ * Write .managed.json to a client directory (async)
73
+ * @param {string} clientDir - Path to client directory
74
+ * @param {object} manifest - Manifest object to write
75
+ */
76
+ export async function writeManifest(clientDir, manifest) {
77
+ const manifestPath = join(clientDir, MANIFEST_FILENAME);
78
+ await writeFile(manifestPath, JSON.stringify(manifest, null, 2) + '\n');
79
+ }
80
+
81
+ /**
82
+ * Write .managed.json to a client directory (sync)
83
+ * @param {string} clientDir - Path to client directory
84
+ * @param {object} manifest - Manifest object to write
85
+ */
86
+ export function writeManifestSync(clientDir, manifest) {
87
+ const manifestPath = join(clientDir, MANIFEST_FILENAME);
88
+ writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n');
89
+ }
90
+
91
+ /**
92
+ * Create a new manifest object
93
+ * @param {string} version - Base version (semver)
94
+ * @param {Object<string, {checksum: string, version: string}>} files - Map of relative paths to file info
95
+ * @returns {object} New manifest object
96
+ */
97
+ export function createManifest(version, files) {
98
+ const now = new Date().toISOString();
99
+ return {
100
+ schemaVersion: SCHEMA_VERSION,
101
+ baseVersion: version,
102
+ installedAt: now,
103
+ lastUpdated: now,
104
+ managedFiles: files,
105
+ conflicts: []
106
+ };
107
+ }
108
+
109
+ /**
110
+ * Check if a manifest exists for a client
111
+ * @param {string} clientDir - Path to client directory
112
+ * @returns {boolean}
113
+ */
114
+ export function manifestExists(clientDir) {
115
+ return existsSync(join(clientDir, MANIFEST_FILENAME));
116
+ }
117
+
118
+ /**
119
+ * Detect modifications to managed files
120
+ * @param {string} clientDir - Path to client directory
121
+ * @param {object} manifest - The manifest object
122
+ * @param {string[]} [baseFiles] - Files in base template (for detecting new files)
123
+ * @returns {Promise<{unchanged: string[], modified: string[], missing: string[], newInBase: string[], configSkipped: string[]}>}
124
+ */
125
+ export async function detectModifications(clientDir, manifest, baseFiles = []) {
126
+ const results = {
127
+ unchanged: [],
128
+ modified: [],
129
+ missing: [],
130
+ newInBase: [],
131
+ configSkipped: []
132
+ };
133
+
134
+ // Check existing managed files
135
+ for (const [relativePath, info] of Object.entries(manifest.managedFiles)) {
136
+ const fullPath = join(clientDir, relativePath);
137
+
138
+ // Skip config files entirely (never update them)
139
+ const managedType = info.managedType || 'update';
140
+ if (managedType === 'config') {
141
+ results.configSkipped.push(relativePath);
142
+ continue;
143
+ }
144
+
145
+ if (!await fileExists(fullPath)) {
146
+ results.missing.push(relativePath);
147
+ continue;
148
+ }
149
+
150
+ const currentChecksum = await calculateChecksum(fullPath);
151
+ if (currentChecksum === info.checksum) {
152
+ results.unchanged.push(relativePath);
153
+ } else {
154
+ results.modified.push(relativePath);
155
+ }
156
+ }
157
+
158
+ // Check for new files in base template
159
+ for (const relativePath of baseFiles) {
160
+ // Skip config files when checking for new files
161
+ if (isConfigFile(relativePath)) {
162
+ continue;
163
+ }
164
+
165
+ if (!manifest.managedFiles[relativePath]) {
166
+ results.newInBase.push(relativePath);
167
+ }
168
+ }
169
+
170
+ return results;
171
+ }
172
+
173
+ /**
174
+ * Check if a file is a custom (non-managed) file
175
+ * @param {string} relativePath - Relative path from client root
176
+ * @param {object} manifest - The manifest object
177
+ * @returns {boolean}
178
+ */
179
+ export function isCustomFile(relativePath, manifest) {
180
+ return !Object.prototype.hasOwnProperty.call(manifest.managedFiles, relativePath);
181
+ }
182
+
183
+ /**
184
+ * Add a conflict to the manifest
185
+ * @param {object} manifest - The manifest object (mutated)
186
+ * @param {string} file - File path
187
+ * @param {string} reason - Reason for conflict
188
+ * @param {string} skippedVersion - Version that was skipped
189
+ */
190
+ export function addConflict(manifest, file, reason, skippedVersion) {
191
+ manifest.conflicts.push({
192
+ file,
193
+ reason,
194
+ skippedVersion,
195
+ skippedAt: new Date().toISOString()
196
+ });
197
+ }
198
+
199
+ /**
200
+ * Update manifest after successful update
201
+ * @param {object} manifest - The manifest object (mutated)
202
+ * @param {string} newVersion - New base version
203
+ * @param {Object<string, {checksum: string, version: string}>} updatedFiles - Updated file info
204
+ */
205
+ export function updateManifest(manifest, newVersion, updatedFiles) {
206
+ manifest.baseVersion = newVersion;
207
+ manifest.lastUpdated = new Date().toISOString();
208
+
209
+ for (const [path, info] of Object.entries(updatedFiles)) {
210
+ manifest.managedFiles[path] = info;
211
+ }
212
+ }
@@ -0,0 +1,50 @@
1
+ import { createWriteStream, existsSync, mkdirSync } from 'fs';
2
+ import { pipeline } from 'stream/promises';
3
+ import { Extract } from 'unzipper';
4
+ import { Readable } from 'stream';
5
+ import { downloadSkills } from './api-client.js';
6
+ import { readAuth } from './auth.js';
7
+ import logger from './logger.js';
8
+
9
+ /**
10
+ * Fetch skills from API and extract to target directory
11
+ * @param {string} targetDir - Directory to extract skills into
12
+ */
13
+ export async function fetchSkills(targetDir) {
14
+ const auth = readAuth();
15
+
16
+ if (!auth?.sessionToken) {
17
+ throw new Error('Not authenticated');
18
+ }
19
+
20
+ // Ensure target directory exists
21
+ if (!existsSync(targetDir)) {
22
+ mkdirSync(targetDir, { recursive: true });
23
+ }
24
+
25
+ // Download skills
26
+ const stream = await downloadSkills(auth.sessionToken);
27
+
28
+ // Convert web stream to Node stream
29
+ const nodeStream = Readable.fromWeb(stream);
30
+
31
+ // Extract zip to target directory
32
+ await pipeline(
33
+ nodeStream,
34
+ Extract({ path: targetDir })
35
+ );
36
+ }
37
+
38
+ /**
39
+ * Update existing skills directory with latest from API
40
+ * @param {string} targetDir - Directory to update
41
+ */
42
+ export async function updateSkills(targetDir) {
43
+ if (!existsSync(targetDir)) {
44
+ throw new Error(`Directory does not exist: ${targetDir}`);
45
+ }
46
+
47
+ // For now, just fetch (which will overwrite)
48
+ // Later we can add checksum comparison to only update changed files
49
+ await fetchSkills(targetDir);
50
+ }