skillshop 4.0.0 → 4.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.
@@ -0,0 +1,282 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { createHash, randomUUID } from 'node:crypto';
5
+ import { execFileSync } from 'node:child_process';
6
+ import { fetchGitHubSkill, parseGitHubSkillReference } from './github-library.js';
7
+ import { atomicJson, assertRegularPath, assertInstallationOwner, readInstallationManifest, writeInstallationManifest, sameOwner } from './installation-state.js';
8
+
9
+ export const dependencyManifestName = 'skillshop.json';
10
+ export const dependencyLockName = 'skillshop-lock.json';
11
+ const object = (value) => value && typeof value === 'object' && !Array.isArray(value);
12
+ const sorted = (value) => Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b)));
13
+
14
+ export function parseDependencyReference(value) {
15
+ if (typeof value !== 'string') throw new Error('skill dependency must be @owner/skill or @owner/skill@ref');
16
+ const separator = value.lastIndexOf('@');
17
+ const reference = separator > 0 ? value.slice(0, separator) : value;
18
+ const ref = separator > 0 ? value.slice(separator + 1) : null;
19
+ if (ref !== null && (!ref || ref.startsWith('-') || !/^[A-Za-z0-9_./-]+$/.test(ref) || ref.includes('..') || ref.endsWith('/'))) {
20
+ throw new Error(`invalid skill dependency ref: ${ref}`);
21
+ }
22
+ return { ...parseGitHubSkillReference(reference), reference, ref, requested: value };
23
+ }
24
+
25
+ export function dependencyProjectRoot(cwd = process.cwd()) {
26
+ let current = path.resolve(cwd);
27
+ while (true) {
28
+ if (fs.existsSync(path.join(current, dependencyManifestName)) || fs.existsSync(path.join(current, '.git'))) return fs.realpathSync(current);
29
+ const parent = path.dirname(current);
30
+ if (parent === current) return fs.realpathSync(cwd);
31
+ current = parent;
32
+ }
33
+ }
34
+
35
+ function readJson(file) {
36
+ assertRegularPath(file);
37
+ if (!fs.existsSync(file)) return null;
38
+ try { return JSON.parse(fs.readFileSync(file, 'utf8')); }
39
+ catch (error) { throw new Error(`cannot parse ${file}: ${error.message}`); }
40
+ }
41
+
42
+ function skillsPath(root, relative) {
43
+ if (typeof relative !== 'string' || !relative || path.isAbsolute(relative) || relative.includes('\\')) throw new Error('dependency skillsDirectory must be a relative project directory');
44
+ const directory = path.resolve(root, relative);
45
+ const contained = path.relative(root, directory);
46
+ if (!contained || contained === '..' || contained.startsWith(`..${path.sep}`) || path.isAbsolute(contained)) throw new Error('dependency skillsDirectory escapes the project');
47
+ assertRegularPath(directory);
48
+ return directory;
49
+ }
50
+
51
+ function readManifest(root, { create = false } = {}) {
52
+ const manifest = readJson(path.join(root, dependencyManifestName));
53
+ if (!manifest) {
54
+ if (create) return { schemaVersion: 1, skillsDirectory: '.agents/skills', dependencies: {} };
55
+ throw new Error(`no ${dependencyManifestName}; add a dependency with skillshop dependency add @owner/skill@ref`);
56
+ }
57
+ if (manifest.schemaVersion !== 1 || !object(manifest.dependencies)) throw new Error(`${dependencyManifestName} is not a supported dependency manifest`);
58
+ skillsPath(root, manifest.skillsDirectory);
59
+ for (const [name, reference] of Object.entries(manifest.dependencies)) {
60
+ if (parseDependencyReference(reference).name !== name) throw new Error(`dependency ${name} must match its upstream skill name`);
61
+ }
62
+ return manifest;
63
+ }
64
+
65
+ function readLock(root) {
66
+ const lock = readJson(path.join(root, dependencyLockName));
67
+ if (!lock) return null;
68
+ if (lock.lockfileVersion !== 1 || !object(lock.dependencies)) throw new Error(`${dependencyLockName} is not a supported dependency lockfile`);
69
+ skillsPath(root, lock.skillsDirectory);
70
+ for (const [name, entry] of Object.entries(lock.dependencies)) {
71
+ if (!object(entry)) throw new Error(`invalid locked dependency ${name}`);
72
+ const parsed = parseDependencyReference(entry.reference);
73
+ if (parsed.name !== name || entry.repository !== parsed.repository || !/^[a-f0-9]{40,64}$/.test(entry.commit) || !/^sha256-[A-Za-z0-9+/]{43}=$/.test(entry.integrity)) {
74
+ throw new Error(`invalid locked dependency ${name}`);
75
+ }
76
+ }
77
+ return lock;
78
+ }
79
+
80
+ export function skillIntegrity(directory) {
81
+ assertRegularPath(directory);
82
+ const hash = createHash('sha256');
83
+ const visit = (relative = '') => {
84
+ const absolute = path.join(directory, relative);
85
+ const stat = fs.lstatSync(absolute);
86
+ if (stat.isSymbolicLink()) throw new Error(`skill contains a symlink: ${absolute}`);
87
+ if (stat.isDirectory()) {
88
+ hash.update(JSON.stringify(['directory', relative]));
89
+ for (const name of fs.readdirSync(absolute).sort()) visit(relative ? `${relative}/${name}` : name);
90
+ } else if (stat.isFile()) {
91
+ const contents = fs.readFileSync(absolute);
92
+ hash.update(JSON.stringify(['file', relative, Boolean(stat.mode & 0o111), contents.length]));
93
+ hash.update(contents);
94
+ } else throw new Error(`skill contains a non-regular entry: ${absolute}`);
95
+ };
96
+ visit();
97
+ return `sha256-${hash.digest('base64')}`;
98
+ }
99
+
100
+ function lockMatches(manifest, lock) {
101
+ return lock && lock.skillsDirectory === manifest.skillsDirectory &&
102
+ JSON.stringify(sorted(manifest.dependencies)) === JSON.stringify(sorted(Object.fromEntries(Object.entries(lock.dependencies).map(([name, entry]) => [name, entry.reference]))));
103
+ }
104
+
105
+ function snapshotFile(file) {
106
+ assertRegularPath(file);
107
+ return fs.existsSync(file) ? { bytes: fs.readFileSync(file), mode: fs.statSync(file).mode & 0o777 } : null;
108
+ }
109
+
110
+ function dependencyExclusion(root, manifest, directories) {
111
+ let gitRoot;
112
+ try { gitRoot = execFileSync('git', ['rev-parse', '--show-toplevel'], { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); }
113
+ catch { return null; }
114
+ const relativeRoot = path.relative(gitRoot, root);
115
+ if (relativeRoot === '..' || relativeRoot.startsWith(`..${path.sep}`) || path.isAbsolute(relativeRoot)) throw new Error('dependency project is outside its Git root');
116
+ const gitPath = execFileSync('git', ['rev-parse', '--git-path', 'info/exclude'], { cwd: root, encoding: 'utf8' }).trim();
117
+ const file = path.resolve(root, gitPath);
118
+ assertRegularPath(file);
119
+ const existing = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : '';
120
+ const begin = '# >>> skillshop project dependencies >>>';
121
+ const end = '# <<< skillshop project dependencies <<<';
122
+ const lines = existing.replace(/\r\n/g, '\n').split('\n');
123
+ const starts = lines.flatMap((line, i) => line === begin ? [i] : []);
124
+ const ends = lines.flatMap((line, i) => line === end ? [i] : []);
125
+ if (starts.length !== ends.length || starts.length > 1 || (starts.length && starts[0] >= ends[0])) throw new Error('dependency Git exclude markers are malformed');
126
+ const retained = starts.length ? [...lines.slice(0, starts[0]), ...lines.slice(ends[0] + 1)] : lines;
127
+ while (retained.at(-1) === '') retained.pop();
128
+ const pattern = (target) => `/${path.relative(gitRoot, target).split(path.sep).join('/').replaceAll('\\', '\\\\').replace(/([ !#*?\[\]])/g, '\\$1')}`;
129
+ const directory = skillsPath(root, manifest.skillsDirectory);
130
+ const paths = [...directories.keys()].map((installedDirectory) => pattern(path.join(installedDirectory, '.skillshop-lock')));
131
+ paths.push(...Object.keys(manifest.dependencies).map((name) => `${pattern(path.join(directory, name))}/`));
132
+ return { file, contents: `${[...retained, ...(retained.length ? [''] : []), begin, ...[...new Set(paths)].sort(), end].join('\n')}\n` };
133
+ }
134
+
135
+ // All downloads and ownership/integrity checks finish before mutating any
136
+ // installation. Backups keep replacement/removal and metadata writes recoverable.
137
+ function commitChanges(root, manifest, lock, changes, directoryStates, { saveManifest }) {
138
+ const token = randomUUID();
139
+ const exclusion = dependencyExclusion(root, manifest, directoryStates);
140
+ const files = [path.join(root, dependencyLockName), ...(saveManifest ? [path.join(root, dependencyManifestName)] : []),
141
+ ...[...directoryStates.keys()].map((directory) => path.join(directory, '.skillshop-lock')), ...(exclusion ? [exclusion.file] : [])];
142
+ const before = new Map(files.map((file) => [file, snapshotFile(file)]));
143
+ const applied = [];
144
+ try {
145
+ for (const change of changes) {
146
+ const target = path.join(change.directory, change.name);
147
+ const backup = `${target}.skillshop-backup-${token}`;
148
+ const temporary = `${target}.skillshop-tmp-${token}`;
149
+ fs.mkdirSync(change.directory, { recursive: true });
150
+ if (change.source) fs.cpSync(change.source, temporary, { recursive: true });
151
+ const existed = fs.existsSync(target);
152
+ if (existed) fs.renameSync(target, backup);
153
+ const step = { target, backup, temporary, existed };
154
+ applied.push(step);
155
+ if (change.source) fs.renameSync(temporary, target);
156
+ }
157
+ for (const [directory, state] of directoryStates) writeInstallationManifest(directory, state);
158
+ atomicJson(path.join(root, dependencyLockName), lock);
159
+ if (saveManifest) atomicJson(path.join(root, dependencyManifestName), { ...manifest, dependencies: sorted(manifest.dependencies) });
160
+ if (exclusion && (!before.get(exclusion.file) || before.get(exclusion.file).bytes.toString('utf8') !== exclusion.contents)) {
161
+ fs.mkdirSync(path.dirname(exclusion.file), { recursive: true });
162
+ fs.writeFileSync(exclusion.file, exclusion.contents);
163
+ }
164
+ } catch (error) {
165
+ for (const step of applied.reverse()) {
166
+ fs.rmSync(step.target, { recursive: true, force: true });
167
+ if (step.existed) fs.renameSync(step.backup, step.target);
168
+ }
169
+ for (const [file, previous] of before) {
170
+ if (previous) {
171
+ fs.mkdirSync(path.dirname(file), { recursive: true });
172
+ fs.writeFileSync(file, previous.bytes, { mode: previous.mode });
173
+ fs.chmodSync(file, previous.mode);
174
+ } else fs.rmSync(file, { force: true });
175
+ }
176
+ throw error;
177
+ } finally {
178
+ for (const change of changes) {
179
+ const target = path.join(change.directory, change.name);
180
+ fs.rmSync(`${target}.skillshop-tmp-${token}`, { recursive: true, force: true });
181
+ }
182
+ }
183
+ // Cleanup after the commit cannot roll back a successfully published install.
184
+ for (const step of applied) fs.rmSync(step.backup, { recursive: true, force: true });
185
+ }
186
+
187
+ function installManifest(root, manifest, previousLock, { frozen = false, saveManifest = false } = {}) {
188
+ if (frozen && !lockMatches(manifest, previousLock)) throw new Error(`${dependencyLockName} is missing or out of date with ${dependencyManifestName}; frozen install refused`);
189
+ const directory = skillsPath(root, manifest.skillsDirectory);
190
+ const owner = { kind: 'dependency', id: 'project' };
191
+ const directories = new Map([[directory, readInstallationManifest(directory)]]);
192
+ if (previousLock) {
193
+ const previousDirectory = skillsPath(root, previousLock.skillsDirectory);
194
+ if (!directories.has(previousDirectory)) directories.set(previousDirectory, readInstallationManifest(previousDirectory));
195
+ }
196
+ // Detect ownership conflicts before network requests, including removals.
197
+ for (const name of Object.keys(manifest.dependencies)) assertInstallationOwner(directory, name, owner);
198
+ const removals = [];
199
+ for (const [installedDirectory, state] of directories) {
200
+ for (const [name, entry] of Object.entries(state.skills)) {
201
+ if (sameOwner(entry.owner, owner) && (installedDirectory !== directory || !Object.hasOwn(manifest.dependencies, name))) {
202
+ assertInstallationOwner(installedDirectory, name, owner, { requireOwned: true });
203
+ removals.push({ directory: installedDirectory, name });
204
+ delete state.skills[name];
205
+ }
206
+ }
207
+ }
208
+ const temporaryRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'skillshop-dependencies-'));
209
+ try {
210
+ const dependencies = {};
211
+ const changes = [...removals];
212
+ for (const [name, reference] of Object.entries(sorted(manifest.dependencies))) {
213
+ const parsed = parseDependencyReference(reference);
214
+ const previous = previousLock?.dependencies[name];
215
+ const locked = previous?.reference === reference ? previous : null;
216
+ const target = path.join(directory, name);
217
+ const existing = directories.get(directory).skills[name];
218
+ let current = false;
219
+ if (locked && existing?.source?.commit === locked.commit && existing?.source?.integrity === locked.integrity && fs.existsSync(target)) {
220
+ current = skillIntegrity(target) === locked.integrity;
221
+ }
222
+ let resolved = locked;
223
+ if (!current) {
224
+ const staging = path.join(temporaryRoot, name);
225
+ fs.mkdirSync(staging);
226
+ const fetched = fetchGitHubSkill(parsed.reference, staging, locked ? { commit: locked.commit } : { ref: parsed.ref });
227
+ const integrity = skillIntegrity(fetched.skillDirectory);
228
+ if (locked && integrity !== locked.integrity) throw new Error(`integrity mismatch for locked dependency ${name}; installation unchanged`);
229
+ resolved = { reference, repository: parsed.repository, commit: fetched.origin.commit, integrity };
230
+ changes.push({ directory, name, source: fetched.skillDirectory });
231
+ }
232
+ dependencies[name] = resolved;
233
+ directories.get(directory).skills[name] = { owner, source: { kind: 'dependency', ...resolved } };
234
+ }
235
+ const lock = { lockfileVersion: 1, skillsDirectory: manifest.skillsDirectory, dependencies };
236
+ commitChanges(root, manifest, lock, changes, directories, { saveManifest });
237
+ return { root, lock, changed: changes.length };
238
+ } finally {
239
+ fs.rmSync(temporaryRoot, { recursive: true, force: true });
240
+ }
241
+ }
242
+
243
+ export function addDependency(reference, { cwd = process.cwd() } = {}) {
244
+ const parsed = parseDependencyReference(reference);
245
+ const root = dependencyProjectRoot(cwd);
246
+ const manifest = readManifest(root, { create: true });
247
+ manifest.dependencies[parsed.name] = reference;
248
+ return installManifest(root, manifest, readLock(root), { saveManifest: true });
249
+ }
250
+
251
+ export function installDependencies({ cwd = process.cwd(), frozen = false } = {}) {
252
+ const root = dependencyProjectRoot(cwd);
253
+ return installManifest(root, readManifest(root), readLock(root), { frozen });
254
+ }
255
+
256
+ export function updateDependencies(name = null, { cwd = process.cwd() } = {}) {
257
+ const root = dependencyProjectRoot(cwd);
258
+ const manifest = readManifest(root);
259
+ if (name !== null && !Object.hasOwn(manifest.dependencies, name)) throw new Error(`${name} is not a declared project skill dependency`);
260
+ const lock = readLock(root);
261
+ if (lock) {
262
+ for (const key of Object.keys(manifest.dependencies)) {
263
+ if (name === null || key === name) delete lock.dependencies[key];
264
+ }
265
+ }
266
+ return installManifest(root, manifest, lock);
267
+ }
268
+
269
+ export function removeDependency(name, { cwd = process.cwd() } = {}) {
270
+ const root = dependencyProjectRoot(cwd);
271
+ const manifest = readManifest(root);
272
+ if (!Object.hasOwn(manifest.dependencies, name)) throw new Error(`${name} is not a declared project skill dependency`);
273
+ const lock = readLock(root);
274
+ if (!lockMatches(manifest, lock)) throw new Error(`${dependencyLockName} is missing or out of date; install dependencies before removing one`);
275
+ const directory = skillsPath(root, manifest.skillsDirectory);
276
+ const state = assertInstallationOwner(directory, name, { kind: 'dependency', id: 'project' });
277
+ delete state.skills[name];
278
+ delete manifest.dependencies[name];
279
+ delete lock.dependencies[name];
280
+ commitChanges(root, manifest, lock, [{ directory, name }], new Map([[directory, state]]), { saveManifest: true });
281
+ return { root, lock, changed: 1 };
282
+ }
@@ -158,20 +158,28 @@ function stageSkill(repositoryDir, commit, skillPath, name, stagingRoot) {
158
158
  return skillDirectory;
159
159
  }
160
160
 
161
- function fetchSkill(reference, temporaryRoot) {
161
+ function fetchSkill(reference, temporaryRoot, { ref = null, commit: expectedCommit = null } = {}) {
162
162
  const bareRepository = path.join(temporaryRoot, 'repository.git');
163
163
  fs.mkdirSync(bareRepository, { recursive: true });
164
164
  git(['init', '--bare', '--quiet', bareRepository]);
165
- const symbolicRefs = git(['ls-remote', '--symref', reference.repository, 'HEAD']);
166
- const headReference = symbolicRefs.split(/\r?\n/).find((line) => line.startsWith('ref: ') && line.endsWith('\tHEAD'));
167
- const headObject = symbolicRefs.split(/\r?\n/).find((line) => /^[a-f0-9]{40,64}\tHEAD$/.test(line));
168
- if (!headReference || !headObject) throw fail(`could not discover the default branch for ${reference.repository}`);
169
- const branchRef = headReference.slice('ref: '.length, -'\tHEAD'.length);
170
- if (!branchRef.startsWith('refs/heads/')) throw fail('GitHub repository default is not a branch');
171
- git(['-C', bareRepository, 'check-ref-format', branchRef]);
172
- git(['-C', bareRepository, 'fetch', '--no-tags', '--no-recurse-submodules', '--depth=1', reference.repository, branchRef]);
165
+ let fetchRef = expectedCommit ?? ref;
166
+ if (expectedCommit && !/^[a-f0-9]{40,64}$/.test(expectedCommit)) throw fail('invalid locked commit identifier');
167
+ if (fetchRef) {
168
+ if (typeof fetchRef !== 'string' || fetchRef.startsWith('-')) throw fail('invalid Git skill ref');
169
+ if (!/^[a-f0-9]{40,64}$/.test(fetchRef)) git(['check-ref-format', '--allow-onelevel', fetchRef]);
170
+ } else {
171
+ const symbolicRefs = git(['ls-remote', '--symref', reference.repository, 'HEAD']);
172
+ const headReference = symbolicRefs.split(/\r?\n/).find((line) => line.startsWith('ref: ') && line.endsWith('\tHEAD'));
173
+ const headObject = symbolicRefs.split(/\r?\n/).find((line) => /^[a-f0-9]{40,64}\tHEAD$/.test(line));
174
+ if (!headReference || !headObject) throw fail(`could not discover the default branch for ${reference.repository}`);
175
+ fetchRef = headReference.slice('ref: '.length, -'\tHEAD'.length);
176
+ if (!fetchRef.startsWith('refs/heads/')) throw fail('GitHub repository default is not a branch');
177
+ git(['-C', bareRepository, 'check-ref-format', fetchRef]);
178
+ }
179
+ git(['-C', bareRepository, 'fetch', '--no-tags', '--no-recurse-submodules', '--depth=1', reference.repository, fetchRef]);
173
180
  const commit = git(['-C', bareRepository, 'rev-parse', '--verify', 'FETCH_HEAD^{commit}']).trim();
174
181
  if (!/^[a-f0-9]{40,64}$/.test(commit)) throw fail('Git returned an invalid commit identifier');
182
+ if (expectedCommit && commit !== expectedCommit) throw fail(`fetched commit does not match locked commit ${expectedCommit}`);
175
183
  const config = readRemoteConfig(bareRepository, commit);
176
184
  const skillPath = config.skillsDirectory === '.' ? reference.name : `${config.skillsDirectory}/${reference.name}`;
177
185
  if (config.skillsDirectory === '.' && reservedRootNames.has(reference.name)) {
@@ -181,6 +189,10 @@ function fetchSkill(reference, temporaryRoot) {
181
189
  return { skillDirectory, origin: { repository: reference.repository, commit } };
182
190
  }
183
191
 
192
+ export function fetchGitHubSkill(reference, temporaryRoot, options = {}) {
193
+ return fetchSkill(parseGitHubSkillReference(reference), temporaryRoot, options);
194
+ }
195
+
184
196
  function snapshotFile(filePath) {
185
197
  try {
186
198
  const stat = fs.lstatSync(filePath);
package/src/import.js CHANGED
@@ -5,6 +5,7 @@ import { createRequire } from 'node:module';
5
5
  import { pathToFileURL, fileURLToPath } from 'node:url';
6
6
  import { normalizeSkillSpecifier, parseSkillSpecifier, parseLibrarySkillReference } from './specifier.js';
7
7
  import { SKILLSHOP_MANIFEST_FILE, SKILLSHOP_SCHEMA_VERSION } from './library.js';
8
+ import { assertInstallationOwner, writeInstallationManifest } from './installation-state.js';
8
9
 
9
10
  async function importManifestFile(file) {
10
11
  // Content-based cache keys also invalidate in long-lived consumers when two
@@ -95,11 +96,23 @@ export async function importSkill({
95
96
  cwd = process.cwd(),
96
97
  overwrite = false,
97
98
  merge = false,
99
+ ownerKind = 'package-import',
98
100
  }) {
99
101
  if (targetDir !== undefined && (typeof targetDir !== 'string' || !targetDir.trim())) throw new Error('targetDir must be a nonempty path');
100
102
  if (merge && overwrite) throw new Error('merge and overwrite cannot both be enabled');
101
103
  const skill = await resolveSkill({ reference, library, specifier, cwd });
102
104
  const target = path.resolve(cwd, targetDir ?? path.join('.agents', 'skills', skill.name));
105
+ const source = path.resolve(skill.sourceDir);
106
+ if ([path.relative(source, target), path.relative(target, source)].some((relative) => relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative)))) {
107
+ throw new Error('skill import destination overlaps its source directory');
108
+ }
109
+ listSkillFiles(skill.sourceDir);
110
+ const owner = { kind: ownerKind, id: skill.packageName || skill.sourceDir };
111
+ const installation = assertInstallationOwner(path.dirname(target), path.basename(target), owner);
112
+ const saveOwnership = () => {
113
+ installation.skills[path.basename(target)] = { owner, source: { kind: 'package', package: skill.packageName, specifier: skill.specifier } };
114
+ writeInstallationManifest(path.dirname(target), installation);
115
+ };
103
116
  const exists = fs.existsSync(target);
104
117
 
105
118
  if (exists && merge) {
@@ -121,6 +134,7 @@ export async function importSkill({
121
134
  addedFiles += 1;
122
135
  }
123
136
  }
137
+ saveOwnership();
124
138
  return { ...skill, targetDir: target, installed: addedFiles > 0, updated: updatedFiles > 0, addedFiles, updatedFiles };
125
139
  }
126
140
 
@@ -132,6 +146,7 @@ export async function importSkill({
132
146
  fs.cpSync(skill.sourceDir, temporary, { recursive: true, force: true });
133
147
  if (overwrite) fs.rmSync(target, { recursive: true, force: true });
134
148
  fs.renameSync(temporary, target);
149
+ saveOwnership();
135
150
  } finally {
136
151
  fs.rmSync(temporary, { recursive: true, force: true });
137
152
  }
package/src/index.js CHANGED
@@ -3,3 +3,5 @@ export * from './library.js';
3
3
  export * from './export.js';
4
4
  export * from './import.js';
5
5
  export * from './user-install.js';
6
+ export * from './dependencies.js';
7
+ export * from './installation-state.js';
@@ -0,0 +1,113 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { randomUUID } from 'node:crypto';
5
+
6
+ export const installationManifestName = '.skillshop-lock';
7
+ const skillNamePattern = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
8
+
9
+ export function assertRegularPath(target) {
10
+ const absolute = path.resolve(target);
11
+ let current = path.parse(absolute).root;
12
+ for (const segment of absolute.slice(current.length).split(path.sep).filter(Boolean)) {
13
+ current = path.join(current, segment);
14
+ try {
15
+ // macOS exposes its system temporary directories through these aliases.
16
+ const systemAlias = process.platform === 'darwin' && ['/var', '/tmp'].includes(current);
17
+ if (fs.lstatSync(current).isSymbolicLink() && !systemAlias) throw new Error(`installation path contains a symlink: ${current}`);
18
+ } catch (error) {
19
+ if (error.code !== 'ENOENT') throw error;
20
+ }
21
+ }
22
+ }
23
+
24
+ export function atomicJson(file, value) {
25
+ assertRegularPath(file);
26
+ const contents = `${JSON.stringify(value, null, 2)}\n`;
27
+ if (fs.existsSync(file) && fs.readFileSync(file, 'utf8') === contents) return;
28
+ fs.mkdirSync(path.dirname(file), { recursive: true });
29
+ const temporary = `${file}.tmp-${randomUUID()}`;
30
+ try {
31
+ fs.writeFileSync(temporary, contents, { flag: 'wx' });
32
+ fs.renameSync(temporary, file);
33
+ } finally {
34
+ fs.rmSync(temporary, { force: true });
35
+ }
36
+ }
37
+
38
+ export function sameOwner(left, right) {
39
+ return left?.kind === right?.kind && left?.id === right?.id;
40
+ }
41
+
42
+ export function readInstallationManifest(directory) {
43
+ const file = path.join(directory, installationManifestName);
44
+ assertRegularPath(file);
45
+ if (!fs.existsSync(file)) return { manifestVersion: 1, skills: {} };
46
+ let manifest;
47
+ try { manifest = JSON.parse(fs.readFileSync(file, 'utf8')); }
48
+ catch (error) { throw new Error(`cannot parse ${file}: ${error.message}`); }
49
+ if (manifest?.manifestVersion !== 1 || !manifest.skills || typeof manifest.skills !== 'object' || Array.isArray(manifest.skills)) {
50
+ throw new Error(`${file} is not a supported Skillshop installation manifest`);
51
+ }
52
+ for (const [name, entry] of Object.entries(manifest.skills)) {
53
+ if (!skillNamePattern.test(name) || !entry || typeof entry !== 'object' ||
54
+ typeof entry.owner?.kind !== 'string' || !entry.owner.kind || typeof entry.owner?.id !== 'string' || !entry.owner.id) {
55
+ throw new Error(`${file} has an invalid ownership entry for ${name}`);
56
+ }
57
+ }
58
+ return manifest;
59
+ }
60
+
61
+ export function writeInstallationManifest(directory, manifest) {
62
+ atomicJson(path.join(directory, installationManifestName), {
63
+ manifestVersion: 1,
64
+ skills: Object.fromEntries(Object.entries(manifest.skills).sort(([a], [b]) => a.localeCompare(b)))
65
+ });
66
+ }
67
+
68
+ export function assertInstallationOwner(directory, name, owner, { requireOwned = false } = {}) {
69
+ if (!skillNamePattern.test(name)) throw new Error(`invalid skill name: ${name}`);
70
+ const target = path.join(directory, name);
71
+ assertRegularPath(target);
72
+ const manifest = readInstallationManifest(directory);
73
+ const existing = manifest.skills[name];
74
+ if (existing && !sameOwner(existing.owner, owner)) {
75
+ throw new Error(`${target} is owned by ${existing.owner.kind}:${existing.owner.id}; refusing to replace or remove it`);
76
+ }
77
+ if (!existing && (requireOwned || fs.existsSync(target))) {
78
+ throw new Error(`${target} exists or was requested for removal but is not a Skillshop-managed copy owned by ${owner.kind}:${owner.id}`);
79
+ }
80
+ return manifest;
81
+ }
82
+
83
+ export function stateHome() {
84
+ if (process.env.SKILLSHOP_HOME) return process.env.SKILLSHOP_HOME;
85
+ if (process.env.SKILLZ_HOME) return process.env.SKILLZ_HOME;
86
+ const current = path.join(os.homedir(), '.skillshop');
87
+ const legacy = path.join(os.homedir(), '.skillz');
88
+ return fs.existsSync(current) || !fs.existsSync(legacy) ? current : legacy;
89
+ }
90
+
91
+ export function readInstallationIndex(home = stateHome()) {
92
+ const file = path.join(home, 'installations.json');
93
+ assertRegularPath(file);
94
+ if (!fs.existsSync(file)) return [];
95
+ let index;
96
+ try { index = JSON.parse(fs.readFileSync(file, 'utf8')); }
97
+ catch (error) { throw new Error(`cannot parse ${file}: ${error.message}`); }
98
+ if (index?.indexVersion !== 1 || !Array.isArray(index.installations) || index.installations.some((entry) =>
99
+ !entry || !path.isAbsolute(entry.directory ?? '') || !['project', 'global'].includes(entry.scope) ||
100
+ (entry.scope === 'project' && !path.isAbsolute(entry.root ?? '')))) {
101
+ throw new Error(`${file} is not a supported installation discovery index`);
102
+ }
103
+ return index.installations;
104
+ }
105
+
106
+ export function registerInstallationDirectory(directory, { root = null, home = stateHome() } = {}) {
107
+ const entry = root ? { directory: path.resolve(directory), scope: 'project', root: path.resolve(root) }
108
+ : { directory: path.resolve(directory), scope: 'global' };
109
+ const entries = readInstallationIndex(home).filter((item) => item.directory !== entry.directory);
110
+ entries.push(entry);
111
+ entries.sort((a, b) => a.directory.localeCompare(b.directory));
112
+ atomicJson(path.join(home, 'installations.json'), { indexVersion: 1, installations: entries });
113
+ }