@klhapp/skillmux 0.4.5 → 0.6.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/setup.ts ADDED
@@ -0,0 +1,145 @@
1
+ import {
2
+ existsSync,
3
+ linkSync,
4
+ lstatSync,
5
+ mkdirSync,
6
+ readdirSync,
7
+ realpathSync,
8
+ statSync,
9
+ unlinkSync,
10
+ rmSync,
11
+ writeFileSync,
12
+ } from "node:fs";
13
+ import { basename, dirname, join } from "node:path";
14
+ import { SKILL_ID_PATTERN } from "./vault";
15
+
16
+ export interface VaultHealth {
17
+ path: string;
18
+ state: "missing" | "broken-symlink" | "not-directory" | "empty" | "ready";
19
+ ok: boolean;
20
+ skillCount: number;
21
+ message: string;
22
+ }
23
+
24
+ export interface ConfigInitPlan {
25
+ configPath: string;
26
+ vaultPath: string;
27
+ action: "create" | "preserve";
28
+ content?: string;
29
+ }
30
+
31
+ export function inspectVault(path: string): VaultHealth {
32
+ let stat;
33
+ try {
34
+ stat = lstatSync(path);
35
+ } catch (error) {
36
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
37
+ return {
38
+ path,
39
+ state: "missing",
40
+ ok: false,
41
+ skillCount: 0,
42
+ message: `vault does not exist: ${path}`,
43
+ };
44
+ }
45
+ throw error;
46
+ }
47
+
48
+ if (stat.isSymbolicLink()) {
49
+ try {
50
+ realpathSync(path);
51
+ } catch (error) {
52
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
53
+ return {
54
+ path,
55
+ state: "broken-symlink",
56
+ ok: false,
57
+ skillCount: 0,
58
+ message: `vault is a dangling symlink: ${path}`,
59
+ };
60
+ }
61
+ throw error;
62
+ }
63
+ }
64
+
65
+ const resolvedStat = stat.isSymbolicLink() ? statSync(path) : stat;
66
+ if (!resolvedStat.isDirectory()) {
67
+ return {
68
+ path,
69
+ state: "not-directory",
70
+ ok: false,
71
+ skillCount: 0,
72
+ message: `vault is not a directory: ${path}`,
73
+ };
74
+ }
75
+
76
+ const skillCount = readdirSync(path, { withFileTypes: true }).filter(
77
+ (entry) =>
78
+ entry.isDirectory() &&
79
+ SKILL_ID_PATTERN.test(entry.name) &&
80
+ existsSync(join(path, entry.name, "SKILL.md")),
81
+ ).length;
82
+ if (skillCount === 0) {
83
+ return {
84
+ path,
85
+ state: "empty",
86
+ ok: false,
87
+ skillCount,
88
+ message: `vault contains no skill directories: ${path}`,
89
+ };
90
+ }
91
+
92
+ return {
93
+ path,
94
+ state: "ready",
95
+ ok: true,
96
+ skillCount,
97
+ message: `vault ready: ${path} (${skillCount} ${skillCount === 1 ? "skill" : "skills"})`,
98
+ };
99
+ }
100
+
101
+ export function planConfigInit(configPath: string, vaultPath: string): ConfigInitPlan {
102
+ if (existsSync(configPath)) {
103
+ return { configPath, vaultPath, action: "preserve" };
104
+ }
105
+
106
+ const vaultHealth = inspectVault(vaultPath);
107
+ if (!vaultHealth.ok) {
108
+ throw new Error(vaultHealth.message);
109
+ }
110
+
111
+ return {
112
+ configPath,
113
+ vaultPath,
114
+ action: "create",
115
+ content: `vault_path = ${JSON.stringify(vaultPath)}\n`,
116
+ };
117
+ }
118
+
119
+ export function applyConfigInit(plan: ConfigInitPlan): "created" | "preserved" {
120
+ if (plan.action === "preserve" || existsSync(plan.configPath)) {
121
+ return "preserved";
122
+ }
123
+
124
+ mkdirSync(dirname(plan.configPath), { recursive: true });
125
+ const temporaryPath = join(
126
+ dirname(plan.configPath),
127
+ `.${basename(plan.configPath)}.${process.pid}.${crypto.randomUUID()}.tmp`,
128
+ );
129
+ writeFileSync(temporaryPath, plan.content as string, { encoding: "utf8", mode: 0o600 });
130
+ try {
131
+ linkSync(temporaryPath, plan.configPath);
132
+ return "created";
133
+ } catch (error) {
134
+ if ((error as NodeJS.ErrnoException).code === "EEXIST") {
135
+ return "preserved";
136
+ }
137
+ throw error;
138
+ } finally {
139
+ unlinkSync(temporaryPath);
140
+ }
141
+ }
142
+
143
+ export function rollbackConfigInit(plan: ConfigInitPlan): void {
144
+ if (plan.action === "create") rmSync(plan.configPath, { force: true });
145
+ }
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,9 +216,31 @@ 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 };