@akshar5/skillsync 0.1.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/README.md +131 -0
- package/package.json +36 -0
- package/src/cli.js +649 -0
- package/src/core/config.js +35 -0
- package/src/core/device.js +285 -0
- package/src/core/fs.js +98 -0
- package/src/core/git.js +75 -0
- package/src/core/registry.js +132 -0
- package/src/core/source.js +80 -0
- package/src/core/sync.js +19 -0
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import { cp, lstat, mkdir, readlink, readdir, realpath, rm, stat, symlink, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { hostname } from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
import { ensureDir, exists, expandHome, readJson, removePath, slugifySkillName, writeJson } from './fs.js';
|
|
6
|
+
import { ensureVault, loadRegistry } from './registry.js';
|
|
7
|
+
|
|
8
|
+
export function defaultDeviceId() {
|
|
9
|
+
return slugifySkillName(hostname()) || 'device';
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function newDevice(deviceId = defaultDeviceId()) {
|
|
13
|
+
return {
|
|
14
|
+
version: 1,
|
|
15
|
+
device_id: deviceId,
|
|
16
|
+
display_name: deviceId,
|
|
17
|
+
last_seen: new Date().toISOString(),
|
|
18
|
+
targets: {},
|
|
19
|
+
installed: {},
|
|
20
|
+
detected: {},
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function devicePath(vaultPath, deviceId) {
|
|
25
|
+
return path.join(vaultPath, 'devices', `${deviceId}.json`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function loadDevice(vaultPath, deviceId = defaultDeviceId()) {
|
|
29
|
+
await ensureVault(vaultPath);
|
|
30
|
+
const device = await readJson(devicePath(vaultPath, deviceId), newDevice(deviceId));
|
|
31
|
+
return normalizeDevice(device, deviceId);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function saveDevice(vaultPath, device) {
|
|
35
|
+
await ensureVault(vaultPath);
|
|
36
|
+
const normalized = normalizeDevice(device, device.device_id);
|
|
37
|
+
await writeJson(devicePath(vaultPath, normalized.device_id), normalized);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function normalizeDevice(device, deviceId) {
|
|
41
|
+
return {
|
|
42
|
+
version: 1,
|
|
43
|
+
device_id: device?.device_id || deviceId || defaultDeviceId(),
|
|
44
|
+
display_name: device?.display_name || device?.device_id || deviceId || defaultDeviceId(),
|
|
45
|
+
last_seen: device?.last_seen || new Date().toISOString(),
|
|
46
|
+
targets: normalizeTargets(device?.targets),
|
|
47
|
+
installed: device?.installed && typeof device.installed === 'object' ? device.installed : {},
|
|
48
|
+
detected: device?.detected && typeof device.detected === 'object' ? device.detected : {},
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function normalizeTargets(targets) {
|
|
53
|
+
if (!targets || typeof targets !== 'object') return {};
|
|
54
|
+
return Object.fromEntries(Object.entries(targets).map(([name, target]) => [
|
|
55
|
+
name,
|
|
56
|
+
{
|
|
57
|
+
path: target.path,
|
|
58
|
+
mode: target.mode || 'symlink',
|
|
59
|
+
...(target.scan_path ? { scan_path: target.scan_path } : {}),
|
|
60
|
+
},
|
|
61
|
+
]));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function touchDevice({ vaultPath, deviceId = defaultDeviceId() }) {
|
|
65
|
+
const device = await loadDevice(vaultPath, deviceId);
|
|
66
|
+
device.last_seen = new Date().toISOString();
|
|
67
|
+
await saveDevice(vaultPath, device);
|
|
68
|
+
return device;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function addTarget({ vaultPath, deviceId = defaultDeviceId(), name, targetPath, mode = 'symlink', scanPath }) {
|
|
72
|
+
if (!name) throw new Error('Target name is required');
|
|
73
|
+
if (!targetPath) throw new Error('Target path is required');
|
|
74
|
+
if (!['symlink', 'copy'].includes(mode)) throw new Error(`Unsupported target mode: ${mode}`);
|
|
75
|
+
const device = await loadDevice(vaultPath, deviceId);
|
|
76
|
+
device.targets[name] = { path: targetPath, mode, ...(scanPath ? { scan_path: scanPath } : {}) };
|
|
77
|
+
device.last_seen = new Date().toISOString();
|
|
78
|
+
await saveDevice(vaultPath, device);
|
|
79
|
+
return device;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export async function removeTarget({ vaultPath, deviceId = defaultDeviceId(), name }) {
|
|
83
|
+
const device = await loadDevice(vaultPath, deviceId);
|
|
84
|
+
delete device.targets[name];
|
|
85
|
+
delete device.detected[name];
|
|
86
|
+
for (const targets of Object.values(device.installed)) {
|
|
87
|
+
const index = targets.indexOf(name);
|
|
88
|
+
if (index >= 0) targets.splice(index, 1);
|
|
89
|
+
}
|
|
90
|
+
await saveDevice(vaultPath, device);
|
|
91
|
+
return device;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export async function installSkill({ vaultPath, deviceId = defaultDeviceId(), skillName, targets }) {
|
|
95
|
+
const registry = await loadRegistry(vaultPath);
|
|
96
|
+
if (!registry.skills[skillName]) throw new Error(`Skill not found in vault: ${skillName}`);
|
|
97
|
+
const device = await loadDevice(vaultPath, deviceId);
|
|
98
|
+
const selectedTargets = targets?.length ? targets : Object.keys(device.targets);
|
|
99
|
+
if (!selectedTargets.length) throw new Error('No targets configured. Add one with: skillsync target add <name> <path>');
|
|
100
|
+
for (const target of selectedTargets) {
|
|
101
|
+
if (!device.targets[target]) throw new Error(`Unknown target on this device: ${target}`);
|
|
102
|
+
}
|
|
103
|
+
device.installed[skillName] = [...new Set(selectedTargets)].sort();
|
|
104
|
+
device.last_seen = new Date().toISOString();
|
|
105
|
+
await saveDevice(vaultPath, device);
|
|
106
|
+
return device;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export async function uninstallSkill({ vaultPath, deviceId = defaultDeviceId(), skillName, targets }) {
|
|
110
|
+
const device = await loadDevice(vaultPath, deviceId);
|
|
111
|
+
if (!device.installed[skillName]) return device;
|
|
112
|
+
if (!targets?.length) {
|
|
113
|
+
delete device.installed[skillName];
|
|
114
|
+
} else {
|
|
115
|
+
device.installed[skillName] = device.installed[skillName].filter((target) => !targets.includes(target));
|
|
116
|
+
if (!device.installed[skillName].length) delete device.installed[skillName];
|
|
117
|
+
}
|
|
118
|
+
device.last_seen = new Date().toISOString();
|
|
119
|
+
await saveDevice(vaultPath, device);
|
|
120
|
+
return device;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export async function applyLinks({ vaultPath, deviceId = defaultDeviceId() }) {
|
|
124
|
+
const device = await loadDevice(vaultPath, deviceId);
|
|
125
|
+
const registry = await loadRegistry(vaultPath);
|
|
126
|
+
const desiredByTarget = new Map();
|
|
127
|
+
for (const [skillName, targets] of Object.entries(device.installed)) {
|
|
128
|
+
if (!registry.skills[skillName]) continue;
|
|
129
|
+
for (const targetName of targets) {
|
|
130
|
+
if (!device.targets[targetName]) continue;
|
|
131
|
+
if (!desiredByTarget.has(targetName)) desiredByTarget.set(targetName, new Set());
|
|
132
|
+
desiredByTarget.get(targetName).add(skillName);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
for (const [targetName, targetConfig] of Object.entries(device.targets)) {
|
|
137
|
+
const targetPath = expandHome(targetConfig.path);
|
|
138
|
+
await ensureDir(targetPath);
|
|
139
|
+
const desired = desiredByTarget.get(targetName) || new Set();
|
|
140
|
+
await removeStaleOwnedProjections({ vaultPath, targetPath, desired });
|
|
141
|
+
for (const skillName of desired) {
|
|
142
|
+
const source = path.join(vaultPath, registry.skills[skillName].path);
|
|
143
|
+
const destination = path.join(targetPath, skillName);
|
|
144
|
+
if (targetConfig.mode === 'copy') {
|
|
145
|
+
await createCopyProjection(source, destination, skillName, vaultPath);
|
|
146
|
+
} else {
|
|
147
|
+
await createSymlinkProjection(source, destination);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export async function scanTargets({ vaultPath, deviceId = defaultDeviceId() }) {
|
|
154
|
+
const device = await loadDevice(vaultPath, deviceId);
|
|
155
|
+
const registry = await loadRegistry(vaultPath);
|
|
156
|
+
const detected = {};
|
|
157
|
+
for (const [targetName, targetConfig] of Object.entries(device.targets)) {
|
|
158
|
+
const scanPath = expandHome(targetConfig.scan_path || targetConfig.path);
|
|
159
|
+
const skills = await findLocalSkills(scanPath);
|
|
160
|
+
detected[targetName] = skills.map((skill) => ({
|
|
161
|
+
...skill,
|
|
162
|
+
in_vault: Boolean(registry.skills[skill.name]),
|
|
163
|
+
}));
|
|
164
|
+
}
|
|
165
|
+
device.detected = detected;
|
|
166
|
+
await saveDevice(vaultPath, device);
|
|
167
|
+
return device;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async function findLocalSkills(scanPath) {
|
|
171
|
+
const root = path.resolve(scanPath);
|
|
172
|
+
if (!await exists(root)) return [];
|
|
173
|
+
const seen = new Set();
|
|
174
|
+
const skills = [];
|
|
175
|
+
|
|
176
|
+
async function walk(current) {
|
|
177
|
+
let canonical;
|
|
178
|
+
try {
|
|
179
|
+
canonical = await realpath(current);
|
|
180
|
+
} catch {
|
|
181
|
+
canonical = path.resolve(current);
|
|
182
|
+
}
|
|
183
|
+
if (seen.has(canonical)) return;
|
|
184
|
+
seen.add(canonical);
|
|
185
|
+
|
|
186
|
+
if (await exists(path.join(current, 'SKILL.md'))) {
|
|
187
|
+
skills.push({
|
|
188
|
+
name: slugifySkillName(path.basename(current)),
|
|
189
|
+
path: path.relative(root, current).split(path.sep).join(path.posix.sep) || '.',
|
|
190
|
+
});
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const entries = await readdir(current, { withFileTypes: true }).catch(() => []);
|
|
195
|
+
for (const entry of entries) {
|
|
196
|
+
if (entry.name.startsWith('.') || entry.name === 'node_modules') continue;
|
|
197
|
+
const child = path.join(current, entry.name);
|
|
198
|
+
if (entry.isDirectory() || await symlinkedDirectory(child, entry)) {
|
|
199
|
+
await walk(child);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
await walk(root);
|
|
205
|
+
const sorted = skills.sort((a, b) => a.name.localeCompare(b.name) || a.path.localeCompare(b.path));
|
|
206
|
+
return sorted.filter((skill, index) => sorted.findIndex((candidate) => candidate.name === skill.name) === index);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async function symlinkedDirectory(child, entry) {
|
|
210
|
+
if (!entry.isSymbolicLink()) return false;
|
|
211
|
+
try {
|
|
212
|
+
return (await stat(child)).isDirectory();
|
|
213
|
+
} catch {
|
|
214
|
+
return false;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async function removeStaleOwnedProjections({ vaultPath, targetPath, desired }) {
|
|
219
|
+
const entries = await readdir(targetPath, { withFileTypes: true }).catch(() => []);
|
|
220
|
+
for (const entry of entries) {
|
|
221
|
+
if (desired.has(entry.name)) continue;
|
|
222
|
+
const fullPath = path.join(targetPath, entry.name);
|
|
223
|
+
if (await isOwnedSymlink(fullPath, vaultPath) || await isOwnedCopy(fullPath)) {
|
|
224
|
+
await removePath(fullPath);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async function createSymlinkProjection(source, destination) {
|
|
230
|
+
if (await exists(destination)) {
|
|
231
|
+
if (await isOwnedSymlink(destination, path.dirname(path.dirname(source))) || await isOwnedCopy(destination)) {
|
|
232
|
+
await removePath(destination);
|
|
233
|
+
} else {
|
|
234
|
+
throw new Error(`Refusing to overwrite unmanaged target path: ${destination}`);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
const relativeSource = path.relative(path.dirname(destination), source);
|
|
238
|
+
await symlink(relativeSource, destination, 'dir');
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async function createCopyProjection(source, destination, skillName, vaultPath) {
|
|
242
|
+
if (await exists(destination)) {
|
|
243
|
+
if (await isOwnedCopy(destination) || await isOwnedSymlink(destination, vaultPath)) {
|
|
244
|
+
await removePath(destination);
|
|
245
|
+
} else {
|
|
246
|
+
throw new Error(`Refusing to overwrite unmanaged target path: ${destination}`);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
await cp(source, destination, { recursive: true, force: true, dereference: false });
|
|
250
|
+
await writeFile(path.join(destination, '.skillsync-owned.json'), JSON.stringify({ skill: skillName, vault: vaultPath }, null, 2));
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async function isOwnedSymlink(targetPath, vaultPath) {
|
|
254
|
+
try {
|
|
255
|
+
const info = await lstat(targetPath);
|
|
256
|
+
if (!info.isSymbolicLink()) return false;
|
|
257
|
+
const linked = await readlink(targetPath);
|
|
258
|
+
const resolved = path.resolve(path.dirname(targetPath), linked);
|
|
259
|
+
return resolved.startsWith(path.join(vaultPath, 'skills') + path.sep);
|
|
260
|
+
} catch (error) {
|
|
261
|
+
if (error.code === 'ENOENT') return false;
|
|
262
|
+
throw error;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
async function isOwnedCopy(targetPath) {
|
|
267
|
+
try {
|
|
268
|
+
const info = await lstat(path.join(targetPath, '.skillsync-owned.json'));
|
|
269
|
+
return info.isFile();
|
|
270
|
+
} catch (error) {
|
|
271
|
+
if (error.code === 'ENOENT' || error.code === 'ENOTDIR') return false;
|
|
272
|
+
throw error;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export async function listDevices(vaultPath) {
|
|
277
|
+
await mkdir(path.join(vaultPath, 'devices'), { recursive: true });
|
|
278
|
+
const files = await readdir(path.join(vaultPath, 'devices')).catch(() => []);
|
|
279
|
+
const devices = [];
|
|
280
|
+
for (const file of files) {
|
|
281
|
+
if (!file.endsWith('.json')) continue;
|
|
282
|
+
devices.push(await readJson(path.join(vaultPath, 'devices', file)));
|
|
283
|
+
}
|
|
284
|
+
return devices.sort((a, b) => String(a.display_name).localeCompare(String(b.display_name)));
|
|
285
|
+
}
|
package/src/core/fs.js
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { cp, mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
|
|
6
|
+
export function expandHome(value) {
|
|
7
|
+
if (!value) return value;
|
|
8
|
+
if (value === '~') return homedir();
|
|
9
|
+
if (value.startsWith('~/')) return path.join(homedir(), value.slice(2));
|
|
10
|
+
return value;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function exists(filePath) {
|
|
14
|
+
try {
|
|
15
|
+
await stat(filePath);
|
|
16
|
+
return true;
|
|
17
|
+
} catch (error) {
|
|
18
|
+
if (error.code === 'ENOENT') return false;
|
|
19
|
+
throw error;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function ensureDir(dirPath) {
|
|
24
|
+
await mkdir(dirPath, { recursive: true });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function readJson(filePath, fallback = undefined) {
|
|
28
|
+
try {
|
|
29
|
+
return JSON.parse(await readFile(filePath, 'utf8'));
|
|
30
|
+
} catch (error) {
|
|
31
|
+
if (error.code === 'ENOENT' && fallback !== undefined) return fallback;
|
|
32
|
+
throw error;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function writeJson(filePath, value) {
|
|
37
|
+
await ensureDir(path.dirname(filePath));
|
|
38
|
+
await writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function copyDir(source, destination) {
|
|
42
|
+
await rm(destination, { recursive: true, force: true });
|
|
43
|
+
await ensureDir(path.dirname(destination));
|
|
44
|
+
await cp(source, destination, { recursive: true, force: true, dereference: false });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function removePath(targetPath) {
|
|
48
|
+
await rm(targetPath, { recursive: true, force: true });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function slugifySkillName(name) {
|
|
52
|
+
return String(name)
|
|
53
|
+
.trim()
|
|
54
|
+
.toLowerCase()
|
|
55
|
+
.replace(/[^a-z0-9._-]+/g, '-')
|
|
56
|
+
.replace(/^-+|-+$/g, '');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function readSkillName(skillDir) {
|
|
60
|
+
const raw = await readFile(path.join(skillDir, 'SKILL.md'), 'utf8');
|
|
61
|
+
const frontmatter = raw.match(/^---\s*\n([\s\S]*?)\n---\s*\n?/);
|
|
62
|
+
if (frontmatter) {
|
|
63
|
+
const nameLine = frontmatter[1].split(/\r?\n/).find((line) => /^name\s*:/i.test(line));
|
|
64
|
+
if (nameLine) {
|
|
65
|
+
const [, value] = nameLine.split(/:(.*)/s);
|
|
66
|
+
const cleaned = value?.trim().replace(/^['"]|['"]$/g, '');
|
|
67
|
+
if (cleaned) return slugifySkillName(cleaned);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return slugifySkillName(path.basename(skillDir));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function walk(dirPath, root = dirPath) {
|
|
74
|
+
const entries = await import('node:fs/promises').then(({ readdir }) => readdir(dirPath, { withFileTypes: true }));
|
|
75
|
+
const files = [];
|
|
76
|
+
for (const entry of entries) {
|
|
77
|
+
if (entry.name === '.git' || entry.name === '.DS_Store') continue;
|
|
78
|
+
const fullPath = path.join(dirPath, entry.name);
|
|
79
|
+
if (entry.isDirectory()) {
|
|
80
|
+
files.push(...await walk(fullPath, root));
|
|
81
|
+
} else if (entry.isFile() || entry.isSymbolicLink()) {
|
|
82
|
+
files.push(path.relative(root, fullPath));
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return files.sort();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export async function hashDirectory(dirPath) {
|
|
89
|
+
const hash = createHash('sha256');
|
|
90
|
+
const files = await walk(dirPath);
|
|
91
|
+
for (const relativePath of files) {
|
|
92
|
+
hash.update(relativePath);
|
|
93
|
+
hash.update('\0');
|
|
94
|
+
hash.update(await readFile(path.join(dirPath, relativePath)));
|
|
95
|
+
hash.update('\0');
|
|
96
|
+
}
|
|
97
|
+
return `sha256:${hash.digest('hex')}`;
|
|
98
|
+
}
|
package/src/core/git.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { exists } from './fs.js';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
export function run(command, args, options = {}) {
|
|
6
|
+
return new Promise((resolve, reject) => {
|
|
7
|
+
const child = spawn(command, args, {
|
|
8
|
+
cwd: options.cwd,
|
|
9
|
+
env: { ...process.env, ...(options.env || {}) },
|
|
10
|
+
stdio: options.inherit ? 'inherit' : ['ignore', 'pipe', 'pipe'],
|
|
11
|
+
});
|
|
12
|
+
let stdout = '';
|
|
13
|
+
let stderr = '';
|
|
14
|
+
if (!options.inherit) {
|
|
15
|
+
child.stdout?.on('data', (chunk) => { stdout += chunk; });
|
|
16
|
+
child.stderr?.on('data', (chunk) => { stderr += chunk; });
|
|
17
|
+
}
|
|
18
|
+
child.on('error', reject);
|
|
19
|
+
child.on('close', (code) => {
|
|
20
|
+
if (code === 0) resolve({ stdout, stderr, code });
|
|
21
|
+
else reject(new Error(`${command} ${args.join(' ')} failed (${code})\n${stderr || stdout}`));
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function commandExists(command) {
|
|
27
|
+
try {
|
|
28
|
+
await run('sh', ['-lc', `command -v ${command}`]);
|
|
29
|
+
return true;
|
|
30
|
+
} catch {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function git(args, cwd, options = {}) {
|
|
36
|
+
return run('git', args, { cwd, ...options });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function gh(args, cwd, options = {}) {
|
|
40
|
+
return run('gh', args, { cwd, ...options });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function isGitRepo(repoPath) {
|
|
44
|
+
return exists(path.join(repoPath, '.git'));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function cloneRepo(repo, repoPath) {
|
|
48
|
+
if (await isGitRepo(repoPath)) return;
|
|
49
|
+
await run('git', ['clone', repo, repoPath], { inherit: true });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function pullRebase(repoPath) {
|
|
53
|
+
if (!await isGitRepo(repoPath)) return;
|
|
54
|
+
await git(['pull', '--rebase', '--autostash'], repoPath);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function push(repoPath) {
|
|
58
|
+
if (!await isGitRepo(repoPath)) return;
|
|
59
|
+
await git(['push'], repoPath);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function statusPorcelain(repoPath) {
|
|
63
|
+
if (!await isGitRepo(repoPath)) return '';
|
|
64
|
+
const { stdout } = await git(['status', '--porcelain'], repoPath);
|
|
65
|
+
return stdout.trim();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function commitAllIfChanged(repoPath, message) {
|
|
69
|
+
if (!await isGitRepo(repoPath)) return false;
|
|
70
|
+
if (!await statusPorcelain(repoPath)) return false;
|
|
71
|
+
await git(['add', 'skills', 'devices', 'registry.json', 'README.md'], repoPath);
|
|
72
|
+
if (!await statusPorcelain(repoPath)) return false;
|
|
73
|
+
await git(['commit', '-m', message], repoPath);
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { mkdir, readdir, rm, stat } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
import { copyDir, ensureDir, exists, hashDirectory, readJson, readSkillName, removePath, writeJson } from './fs.js';
|
|
5
|
+
|
|
6
|
+
export const REGISTRY_FILE = 'registry.json';
|
|
7
|
+
|
|
8
|
+
export function emptyRegistry() {
|
|
9
|
+
return { version: 1, skills: {} };
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export async function ensureVault(vaultPath) {
|
|
13
|
+
await ensureDir(path.join(vaultPath, 'skills'));
|
|
14
|
+
await ensureDir(path.join(vaultPath, 'devices'));
|
|
15
|
+
const registryPath = path.join(vaultPath, REGISTRY_FILE);
|
|
16
|
+
if (!await exists(registryPath)) {
|
|
17
|
+
await writeJson(registryPath, emptyRegistry());
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function loadRegistry(vaultPath) {
|
|
22
|
+
await ensureVault(vaultPath);
|
|
23
|
+
const registry = await readJson(path.join(vaultPath, REGISTRY_FILE), emptyRegistry());
|
|
24
|
+
return normalizeRegistry(registry);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function normalizeRegistry(registry) {
|
|
28
|
+
return {
|
|
29
|
+
version: 1,
|
|
30
|
+
skills: registry?.skills && typeof registry.skills === 'object' ? registry.skills : {},
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function saveRegistry(vaultPath, registry) {
|
|
35
|
+
await ensureVault(vaultPath);
|
|
36
|
+
await writeJson(path.join(vaultPath, REGISTRY_FILE), normalizeRegistry(registry));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function validateSkillFolder(sourcePath) {
|
|
40
|
+
const skillFile = path.join(sourcePath, 'SKILL.md');
|
|
41
|
+
try {
|
|
42
|
+
const info = await stat(skillFile);
|
|
43
|
+
if (!info.isFile()) throw new Error(`SKILL.md is not a file: ${skillFile}`);
|
|
44
|
+
} catch (error) {
|
|
45
|
+
if (error.code === 'ENOENT') {
|
|
46
|
+
throw new Error(`Skill folder must contain SKILL.md: ${sourcePath}`);
|
|
47
|
+
}
|
|
48
|
+
throw error;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function addSkillToVault({ vaultPath, sourcePath, name }) {
|
|
53
|
+
await ensureVault(vaultPath);
|
|
54
|
+
await validateSkillFolder(sourcePath);
|
|
55
|
+
const skillName = name || await readSkillName(sourcePath);
|
|
56
|
+
if (!skillName) throw new Error(`Could not derive a skill name from ${sourcePath}`);
|
|
57
|
+
const destination = path.join(vaultPath, 'skills', skillName);
|
|
58
|
+
await copyDir(sourcePath, destination);
|
|
59
|
+
const registry = await loadRegistry(vaultPath);
|
|
60
|
+
registry.skills[skillName] = await registryEntryForSkill(vaultPath, skillName);
|
|
61
|
+
await saveRegistry(vaultPath, registry);
|
|
62
|
+
return { name: skillName, path: destination };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function registryEntryForSkill(vaultPath, skillName) {
|
|
66
|
+
const skillPath = path.join(vaultPath, 'skills', skillName);
|
|
67
|
+
await validateSkillFolder(skillPath);
|
|
68
|
+
return {
|
|
69
|
+
path: path.posix.join('skills', skillName),
|
|
70
|
+
hash: await hashDirectory(skillPath),
|
|
71
|
+
updated_at: new Date().toISOString(),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export async function rebuildRegistry(vaultPath) {
|
|
76
|
+
await ensureVault(vaultPath);
|
|
77
|
+
const skillsDir = path.join(vaultPath, 'skills');
|
|
78
|
+
const entries = await readdir(skillsDir, { withFileTypes: true });
|
|
79
|
+
const registry = emptyRegistry();
|
|
80
|
+
for (const entry of entries) {
|
|
81
|
+
if (!entry.isDirectory()) continue;
|
|
82
|
+
const skillName = entry.name;
|
|
83
|
+
if (!await exists(path.join(skillsDir, skillName, 'SKILL.md'))) continue;
|
|
84
|
+
registry.skills[skillName] = await registryEntryForSkill(vaultPath, skillName);
|
|
85
|
+
}
|
|
86
|
+
await saveRegistry(vaultPath, registry);
|
|
87
|
+
return registry;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function refreshChangedRegistryEntries(vaultPath) {
|
|
91
|
+
const before = await loadRegistry(vaultPath);
|
|
92
|
+
const after = await rebuildRegistry(vaultPath);
|
|
93
|
+
for (const [name, entry] of Object.entries(after.skills)) {
|
|
94
|
+
if (before.skills[name]?.hash === entry.hash && before.skills[name]?.updated_at) {
|
|
95
|
+
after.skills[name].updated_at = before.skills[name].updated_at;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
await saveRegistry(vaultPath, after);
|
|
99
|
+
return after;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export async function deleteSkillFromVault({ vaultPath, skillName }) {
|
|
103
|
+
await ensureVault(vaultPath);
|
|
104
|
+
await removePath(path.join(vaultPath, 'skills', skillName));
|
|
105
|
+
const registry = await loadRegistry(vaultPath);
|
|
106
|
+
delete registry.skills[skillName];
|
|
107
|
+
await saveRegistry(vaultPath, registry);
|
|
108
|
+
|
|
109
|
+
const devicesDir = path.join(vaultPath, 'devices');
|
|
110
|
+
await mkdir(devicesDir, { recursive: true });
|
|
111
|
+
const devices = await readdir(devicesDir).catch(() => []);
|
|
112
|
+
for (const file of devices) {
|
|
113
|
+
if (!file.endsWith('.json')) continue;
|
|
114
|
+
const filePath = path.join(devicesDir, file);
|
|
115
|
+
const device = await readJson(filePath, null);
|
|
116
|
+
if (!device?.installed?.[skillName]) continue;
|
|
117
|
+
delete device.installed[skillName];
|
|
118
|
+
await writeJson(filePath, device);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export async function listSkillFolders(vaultPath) {
|
|
123
|
+
await ensureVault(vaultPath);
|
|
124
|
+
const skillsDir = path.join(vaultPath, 'skills');
|
|
125
|
+
const entries = await readdir(skillsDir, { withFileTypes: true });
|
|
126
|
+
return entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export async function resetVault(vaultPath) {
|
|
130
|
+
await rm(vaultPath, { recursive: true, force: true });
|
|
131
|
+
await ensureVault(vaultPath);
|
|
132
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { mkdtemp, readdir, rm } from 'node:fs/promises';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
import { exists, readSkillName } from './fs.js';
|
|
6
|
+
import { git } from './git.js';
|
|
7
|
+
|
|
8
|
+
const OWNER_REPO = /^[A-Za-z0-9][A-Za-z0-9_.-]*\/[A-Za-z0-9][A-Za-z0-9_.-]*(?:#.+)?$/;
|
|
9
|
+
|
|
10
|
+
export function isRemoteSkillSource(source) {
|
|
11
|
+
if (!source) return false;
|
|
12
|
+
return /^(https?:\/\/|git@|ssh:\/\/)/.test(source) || OWNER_REPO.test(source);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function splitSourceRef(rawSource) {
|
|
16
|
+
const hashIndex = rawSource.lastIndexOf('#');
|
|
17
|
+
if (hashIndex === -1) return { source: rawSource, ref: undefined };
|
|
18
|
+
return {
|
|
19
|
+
source: rawSource.slice(0, hashIndex),
|
|
20
|
+
ref: rawSource.slice(hashIndex + 1) || undefined,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function cloneUrlForSource(rawSource) {
|
|
25
|
+
const { source, ref } = splitSourceRef(rawSource);
|
|
26
|
+
let cloneUrl = source;
|
|
27
|
+
if (OWNER_REPO.test(source)) cloneUrl = `https://github.com/${source}.git`;
|
|
28
|
+
return { cloneUrl, ref };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function cloneSkillSource(rawSource) {
|
|
32
|
+
const { cloneUrl, ref } = cloneUrlForSource(rawSource);
|
|
33
|
+
const dir = await mkdtemp(path.join(tmpdir(), 'skillsync-source-'));
|
|
34
|
+
const args = ['clone', '--depth', '1'];
|
|
35
|
+
if (ref) args.push('--branch', ref);
|
|
36
|
+
args.push(cloneUrl, dir);
|
|
37
|
+
try {
|
|
38
|
+
await git(args);
|
|
39
|
+
return {
|
|
40
|
+
path: dir,
|
|
41
|
+
cleanup: () => rm(dir, { recursive: true, force: true }),
|
|
42
|
+
};
|
|
43
|
+
} catch (error) {
|
|
44
|
+
await rm(dir, { recursive: true, force: true }).catch(() => {});
|
|
45
|
+
throw error;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function discoverSkillFolders(root, { fullDepth = false } = {}) {
|
|
50
|
+
const results = [];
|
|
51
|
+
|
|
52
|
+
async function walk(dir, relative = '.') {
|
|
53
|
+
if (await exists(path.join(dir, 'SKILL.md'))) {
|
|
54
|
+
const name = await readSkillName(dir) || path.basename(dir);
|
|
55
|
+
results.push({ name, path: dir, relative });
|
|
56
|
+
if (!fullDepth) return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
|
|
60
|
+
for (const entry of entries) {
|
|
61
|
+
if (!entry.isDirectory() || entry.name.startsWith('.') || entry.name === 'node_modules') continue;
|
|
62
|
+
await walk(path.join(dir, entry.name), relative === '.' ? entry.name : path.posix.join(relative, entry.name));
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
await walk(root);
|
|
67
|
+
return results.sort((a, b) => a.name.localeCompare(b.name) || a.relative.localeCompare(b.relative));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function selectDiscoveredSkills(skills, wantedNames = []) {
|
|
71
|
+
if (!wantedNames.length) return skills;
|
|
72
|
+
const wanted = new Set(wantedNames);
|
|
73
|
+
const selected = skills.filter((skill) => wanted.has(skill.name) || wanted.has(path.basename(skill.path)));
|
|
74
|
+
const selectedNames = new Set(selected.map((skill) => skill.name));
|
|
75
|
+
const missing = wantedNames.filter((name) => !selectedNames.has(name) && !selected.some((skill) => path.basename(skill.path) === name));
|
|
76
|
+
if (missing.length) {
|
|
77
|
+
throw new Error(`Skill not found in source: ${missing.join(', ')}`);
|
|
78
|
+
}
|
|
79
|
+
return selected;
|
|
80
|
+
}
|
package/src/core/sync.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { applyLinks, scanTargets, touchDevice } from './device.js';
|
|
2
|
+
import { commitAllIfChanged, isGitRepo, pullRebase, push } from './git.js';
|
|
3
|
+
import { refreshChangedRegistryEntries } from './registry.js';
|
|
4
|
+
|
|
5
|
+
export async function syncVault({ vaultPath, deviceId, pushChanges = true, pull = true, heartbeat = false } = {}) {
|
|
6
|
+
if (pull && await isGitRepo(vaultPath)) {
|
|
7
|
+
await pullRebase(vaultPath);
|
|
8
|
+
}
|
|
9
|
+
await refreshChangedRegistryEntries(vaultPath);
|
|
10
|
+
if (deviceId && heartbeat) await touchDevice({ vaultPath, deviceId });
|
|
11
|
+
if (deviceId) await applyLinks({ vaultPath, deviceId });
|
|
12
|
+
if (deviceId) await scanTargets({ vaultPath, deviceId });
|
|
13
|
+
let committed = false;
|
|
14
|
+
if (pushChanges && await isGitRepo(vaultPath)) {
|
|
15
|
+
committed = await commitAllIfChanged(vaultPath, `sync: update skills from ${deviceId || 'device'}`);
|
|
16
|
+
if (committed) await push(vaultPath);
|
|
17
|
+
}
|
|
18
|
+
return { committed };
|
|
19
|
+
}
|