@klhapp/skillmux 0.4.4 → 0.5.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/src/sync.ts CHANGED
@@ -7,10 +7,12 @@ export const SKILLMUX_MARKER_FILENAME = ".skillmux";
7
7
  export const LEGACY_MARKER_FILENAME = ".skr";
8
8
 
9
9
  export interface SkillmuxMarker {
10
+ schema_version?: 1;
10
11
  managed_by: "skillmux" | "skr";
11
12
  role: "target" | "local_vault";
12
13
  target?: string;
13
14
  vault_path?: string;
15
+ managed_entries?: string[];
14
16
  created_at: string;
15
17
  }
16
18
 
@@ -18,30 +20,68 @@ function normalizeMarker(raw: Omit<SkillmuxMarker, "role"> & { role?: SkillmuxMa
18
20
  return { ...raw, role: raw.role ?? "target" };
19
21
  }
20
22
 
23
+ function validateMarker(marker: SkillmuxMarker, path: string): SkillmuxMarker {
24
+ if (marker.schema_version === undefined) return marker;
25
+ if (marker.schema_version !== 1) throw new Error(`${path}: unsupported marker schema_version`);
26
+ if (marker.managed_by !== "skillmux") throw new Error(`${path}: invalid managed_by`);
27
+ if (marker.role !== "target" && marker.role !== "local_vault") {
28
+ throw new Error(`${path}: invalid role`);
29
+ }
30
+ if (marker.role === "target") {
31
+ if (typeof marker.target !== "string" || marker.target.length === 0) {
32
+ throw new Error(`${path}: target marker is missing target`);
33
+ }
34
+ if (typeof marker.vault_path !== "string" || marker.vault_path.length === 0) {
35
+ throw new Error(`${path}: target marker is missing vault_path`);
36
+ }
37
+ if (!Array.isArray(marker.managed_entries) || marker.managed_entries.some((entry) => typeof entry !== "string")) {
38
+ throw new Error(`${path}: target marker is missing managed_entries`);
39
+ }
40
+ } else if (typeof marker.vault_path !== "string" || marker.vault_path.length === 0) {
41
+ throw new Error(`${path}: local_vault marker is missing vault_path`);
42
+ }
43
+ if (typeof marker.created_at !== "string") throw new Error(`${path}: marker is missing created_at`);
44
+ return marker;
45
+ }
46
+
21
47
  export function readSkillmuxMarker(dir: string): SkillmuxMarker | null {
22
48
  const newPath = join(dir, SKILLMUX_MARKER_FILENAME);
23
49
  if (existsSync(newPath)) {
24
- return normalizeMarker(JSON.parse(readFileSync(newPath, "utf-8")));
50
+ return validateMarker(normalizeMarker(JSON.parse(readFileSync(newPath, "utf-8"))), newPath);
25
51
  }
26
52
  const legacyPath = join(dir, LEGACY_MARKER_FILENAME);
27
53
  if (existsSync(legacyPath)) {
28
- return normalizeMarker(JSON.parse(readFileSync(legacyPath, "utf-8")));
54
+ return validateMarker(normalizeMarker(JSON.parse(readFileSync(legacyPath, "utf-8"))), legacyPath);
29
55
  }
30
56
  return null;
31
57
  }
32
58
 
33
- function writeSkillmuxMarker(dir: string, targetName: string): void {
59
+ function writeTargetMarker(
60
+ dir: string,
61
+ targetName: string,
62
+ vaultPath: string,
63
+ managedEntries: string[],
64
+ createdAt = new Date().toISOString(),
65
+ ): void {
34
66
  const marker: SkillmuxMarker = {
67
+ schema_version: 1,
35
68
  managed_by: "skillmux",
36
69
  role: "target",
37
70
  target: targetName,
38
- created_at: new Date().toISOString(),
71
+ vault_path: vaultPath,
72
+ managed_entries: managedEntries,
73
+ created_at: createdAt,
39
74
  };
40
- writeFileSync(join(dir, SKILLMUX_MARKER_FILENAME), JSON.stringify(marker, null, 2));
75
+ const markerPath = join(dir, SKILLMUX_MARKER_FILENAME);
76
+ const serialized = JSON.stringify(marker, null, 2);
77
+ if (!existsSync(markerPath) || readFileSync(markerPath, "utf-8") !== serialized) {
78
+ writeFileSync(markerPath, serialized);
79
+ }
41
80
  }
42
81
 
43
82
  export function writeLocalVaultMarker(dir: string, vaultPath: string): void {
44
83
  const marker: SkillmuxMarker = {
84
+ schema_version: 1,
45
85
  managed_by: "skillmux",
46
86
  role: "local_vault",
47
87
  vault_path: vaultPath,
@@ -78,23 +118,61 @@ export function syncTarget(params: SyncTargetParams, options: SyncTargetOptions
78
118
  for (const skillId of coreSkillIds) {
79
119
  symlinkSync(join(skillSource(skillId), skillId), join(targetDir, skillId));
80
120
  }
81
- writeSkillmuxMarker(targetDir, targetName);
121
+ writeTargetMarker(targetDir, targetName, vaultPath, coreSkillIds);
82
122
  return { added: [...coreSkillIds], removed: [] };
83
123
  }
84
124
 
85
- if (!readSkillmuxMarker(targetDir)) {
125
+ let marker = readSkillmuxMarker(targetDir);
126
+ if (!marker) {
86
127
  throw new Error(`not owned by skillmux — run skillmux init`);
87
128
  }
129
+ if (marker.role === "local_vault") {
130
+ throw new Error(`${targetDir} has a local_vault marker, not target ownership`);
131
+ }
132
+ if (marker.target !== targetName) {
133
+ throw new Error(`${targetDir} is owned by target "${marker.target}", not "${targetName}"`);
134
+ }
135
+ if (marker.schema_version === undefined) {
136
+ const legacyEntries = readdirSync(targetDir).filter(
137
+ (name) => name !== SKILLMUX_MARKER_FILENAME && name !== LEGACY_MARKER_FILENAME,
138
+ );
139
+ if (legacyEntries.length > 0) {
140
+ throw new Error(
141
+ `${targetDir} has a legacy marker with untracked entries; remove or migrate them before running skillmux sync`,
142
+ );
143
+ }
144
+ marker = {
145
+ schema_version: 1,
146
+ managed_by: "skillmux",
147
+ role: "target",
148
+ target: targetName,
149
+ vault_path: vaultPath,
150
+ managed_entries: [],
151
+ created_at: marker.created_at,
152
+ };
153
+ if (!dryRun) writeTargetMarker(targetDir, targetName, vaultPath, [], marker.created_at);
154
+ }
155
+ if (marker.vault_path !== vaultPath) {
156
+ throw new Error(
157
+ `${targetDir} marker recorded vault_path ${marker.vault_path}, currently configured vault_path is ${vaultPath}`,
158
+ );
159
+ }
88
160
 
89
161
  const desired = new Set(coreSkillIds);
90
162
  const existing = readdirSync(targetDir).filter((name) => name !== SKILLMUX_MARKER_FILENAME && name !== LEGACY_MARKER_FILENAME);
163
+ const managedEntries = new Set(marker.managed_entries ?? []);
164
+ const collisions = coreSkillIds.filter((skillId) => existing.includes(skillId) && !managedEntries.has(skillId));
165
+ if (collisions.length > 0) {
166
+ throw new Error(`unmanaged entry collisions in ${targetDir}: ${collisions.join(", ")}`);
167
+ }
91
168
 
92
- const removed = existing.filter((name) => !desired.has(name));
169
+ const removed = [...managedEntries].filter((name) => existing.includes(name) && !desired.has(name));
93
170
  const added = coreSkillIds.filter((skillId) => !existing.includes(skillId));
94
171
  if (dryRun) return { added, removed };
95
172
 
96
173
  for (const name of removed) unlinkSync(join(targetDir, name));
97
174
  for (const skillId of added) symlinkSync(join(skillSource(skillId), skillId), join(targetDir, skillId));
175
+ writeTargetMarker(targetDir, targetName, vaultPath, coreSkillIds, marker.created_at);
98
176
 
99
177
  return { added, removed };
100
178
  }
@@ -103,6 +181,22 @@ export interface AdoptTargetResult {
103
181
  adopted: boolean;
104
182
  }
105
183
 
184
+ export function preflightAdoptTarget(dir: string, targetName: string, vaultPath: string): void {
185
+ const marker = readSkillmuxMarker(dir);
186
+ if (marker?.role === "local_vault") {
187
+ throw new Error(`${dir} has a local_vault marker, not target ownership`);
188
+ }
189
+ if (!marker) return;
190
+ if (marker.target !== targetName) {
191
+ throw new Error(`${dir} is already owned by target "${marker.target}", not "${targetName}"`);
192
+ }
193
+ if (marker.schema_version !== undefined && marker.vault_path !== vaultPath) {
194
+ throw new Error(
195
+ `${dir} marker recorded vault_path ${marker.vault_path}, currently configured vault_path is ${vaultPath}`,
196
+ );
197
+ }
198
+ }
199
+
106
200
  /**
107
201
  * Marks an existing directory as skillmux-owned without touching its content —
108
202
  * the consented, one-time adoption skillmux init performs (see SkillmuxMarker in
@@ -110,9 +204,10 @@ export interface AdoptTargetResult {
110
204
  * previously-unmarked directory"). syncTarget's fresh-dir case handles the
111
205
  * "doesn't exist yet" side of that rule; this handles "already exists".
112
206
  */
113
- export function adoptTarget(dir: string, targetName: string): AdoptTargetResult {
207
+ export function adoptTarget(dir: string, targetName: string, vaultPath: string): AdoptTargetResult {
208
+ preflightAdoptTarget(dir, targetName, vaultPath);
114
209
  if (readSkillmuxMarker(dir)) return { adopted: false };
115
- writeSkillmuxMarker(dir, targetName);
210
+ writeTargetMarker(dir, targetName, vaultPath, []);
116
211
  return { adopted: true };
117
212
  }
118
213
 
@@ -121,26 +216,48 @@ export interface RestoreMonolithResult {
121
216
  }
122
217
 
123
218
  export function restoreMonolith(targetDir: string, vaultPath: string): RestoreMonolithResult {
124
- if (!readSkillmuxMarker(targetDir)) {
219
+ const marker = readSkillmuxMarker(targetDir);
220
+ if (!marker) {
125
221
  return { restored: false };
126
222
  }
223
+ if (marker.role === "local_vault") {
224
+ throw new Error(`${targetDir} has a local_vault marker, not target ownership`);
225
+ }
226
+ if (marker.schema_version === undefined) {
227
+ throw new Error(`${targetDir} has a legacy marker; migrate it before restoring full-vault delivery`);
228
+ }
229
+ if (marker.vault_path !== vaultPath) {
230
+ throw new Error(
231
+ `${targetDir} marker recorded vault_path ${marker.vault_path}, currently configured vault_path is ${vaultPath}`,
232
+ );
233
+ }
234
+ const managedEntries = new Set(marker.managed_entries ?? []);
235
+ const unmanagedEntries = readdirSync(targetDir).filter(
236
+ (name) =>
237
+ name !== SKILLMUX_MARKER_FILENAME &&
238
+ name !== LEGACY_MARKER_FILENAME &&
239
+ !managedEntries.has(name),
240
+ );
241
+ if (unmanagedEntries.length > 0) {
242
+ throw new Error(`refusing to restore over unmanaged entries in ${targetDir}: ${unmanagedEntries.join(", ")}`);
243
+ }
127
244
  rmSync(targetDir, { recursive: true, force: true });
128
245
  symlinkSync(vaultPath, targetDir);
129
246
  return { restored: true };
130
247
  }
131
248
 
132
- export function resolveProjectPinDir(targetDir: string, repo: string): string {
249
+ export function resolveProjectPinDir(targetDir: string, path: string): string {
133
250
  const rel = relative(homedir(), targetDir);
134
251
  if (rel === "" || rel.startsWith("..")) {
135
252
  throw new Error(
136
253
  `target dir "${targetDir}" must be inside $HOME to compute a project pin dir (got relative path "${rel}")`,
137
254
  );
138
255
  }
139
- return join(repo, rel);
256
+ return join(path, rel);
140
257
  }
141
258
 
142
259
  export interface ProjectGroupInput {
143
- repos: string[];
260
+ paths: string[];
144
261
  skills: string[];
145
262
  }
146
263
 
@@ -154,7 +271,7 @@ export interface SyncProjectTargetsParams {
154
271
 
155
272
  export interface ProjectPinSyncResult extends SyncTargetResult {
156
273
  group: string;
157
- repo: string;
274
+ path: string;
158
275
  pinDir: string;
159
276
  }
160
277
 
@@ -166,14 +283,14 @@ export function syncProjectTargets(
166
283
  const results: ProjectPinSyncResult[] = [];
167
284
 
168
285
  for (const [group, projectGroup] of Object.entries(projectGroups)) {
169
- for (const repo of projectGroup.repos) {
170
- if (!existsSync(repo)) continue;
171
- const pinDir = resolveProjectPinDir(targetDir, repo);
286
+ for (const path of projectGroup.paths) {
287
+ if (!existsSync(path)) continue;
288
+ const pinDir = resolveProjectPinDir(targetDir, path);
172
289
  const result = syncTarget(
173
290
  { vaultPath, targetDir: pinDir, targetName, coreSkillIds: projectGroup.skills, localVaultPaths },
174
291
  options,
175
292
  );
176
- results.push({ group, repo, pinDir, ...result });
293
+ results.push({ group, path, pinDir, ...result });
177
294
  }
178
295
  }
179
296