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,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
|
+
}
|