showdar-skills 0.2.2 → 0.2.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "showdar-skills",
3
- "version": "0.2.2",
3
+ "version": "0.2.3",
4
4
  "description": "Production-grade software engineering lifecycle skills for coding agents.",
5
5
  "type": "module",
6
6
  "bin": { "showdar": "./bin/showdar.js" },
@@ -1,5 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
  import { spawnSync } from 'node:child_process';
3
3
  const cwd=process.argv[2]??process.cwd(); const base=process.argv[3]??'HEAD';
4
- const run=a=>{const r=spawnSync('git',a,{cwd,encoding:'utf8'});return r.status===0?r.stdout.trim():null};
5
- console.log(JSON.stringify({base,status:run(['status','--short']),files:run(['diff','--name-only',base])?.split('\n').filter(Boolean)??[],stat:run(['diff','--stat',base]),diff:run(['diff','--no-ext-diff','--unified=3',base])},null,2));
4
+ const run=(args)=>{const r=spawnSync('git',args,{cwd,encoding:'utf8',shell:false});if(r.error)throw r.error;if(r.status!==0)throw new Error((r.stderr||r.stdout).trim()||`git ${args.join(' ')} failed`);return r.stdout.trim()};
5
+ try {
6
+ if (base.startsWith('-')) throw new Error('revision must not begin with "-"');
7
+ const resolved=run(['rev-parse','--verify',`${base}^{commit}`]);
8
+ console.log(JSON.stringify({base,status:run(['status','--short']),files:run(['diff','--no-ext-diff','--name-only',resolved])?.split('\n').filter(Boolean)??[],stat:run(['diff','--no-ext-diff','--stat',resolved]),diff:run(['diff','--no-ext-diff','--unified=3',resolved])},null,2));
9
+ } catch (error) {
10
+ console.error(`collect-diff: ${error.message}`);
11
+ process.exitCode=1;
12
+ }
@@ -0,0 +1,39 @@
1
+ import { lstat } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+
4
+ export function safeOwnedPath(baseRoot, relative, allowedRoots = []) {
5
+ if (typeof relative !== 'string' || !relative) return null;
6
+ const roots = [baseRoot, ...allowedRoots].map((root) => path.resolve(root));
7
+ const target = path.resolve(relative.startsWith(path.sep) ? relative : path.join(roots[0], relative));
8
+ return roots.some((root) => target === root || target.startsWith(`${root}${path.sep}`)) ? target : null;
9
+ }
10
+
11
+ export async function lstatWithoutSymlink(target) {
12
+ const info = await lstat(target);
13
+ if (info.isSymbolicLink()) throw new Error(`Refusing managed path through symlink: ${target}`);
14
+ return info;
15
+ }
16
+
17
+ export async function assertSafeManagedPath(baseRoot, target, allowedRoots = []) {
18
+ const root = path.resolve(baseRoot);
19
+ const resolved = safeOwnedPath(root, target, allowedRoots);
20
+ if (!resolved) throw new Error(`Invalid managed path: ${target}`);
21
+
22
+ try {
23
+ await lstatWithoutSymlink(root);
24
+ } catch (error) {
25
+ if (error?.code === 'ENOENT') return resolved;
26
+ throw error;
27
+ }
28
+ let current = root;
29
+ for (const component of path.relative(root, resolved).split(path.sep).filter(Boolean)) {
30
+ current = path.join(current, component);
31
+ try {
32
+ await lstatWithoutSymlink(current);
33
+ } catch (error) {
34
+ if (error?.code === 'ENOENT') break;
35
+ throw error;
36
+ }
37
+ }
38
+ return resolved;
39
+ }
package/src/project.js CHANGED
@@ -1,8 +1,9 @@
1
- import { access, cp, mkdir, readFile, readdir, rename, rm, stat, writeFile } from 'node:fs/promises';
1
+ import { access, cp, mkdir, readFile, readdir, rename, rm, writeFile } from 'node:fs/promises';
2
2
  import { createHash } from 'node:crypto';
3
3
  import { homedir } from 'node:os';
4
4
  import path from 'node:path';
5
5
  import { globalCommandRootFor, globalSkillRootFor, NATIVE_TARGETS, opencodeCommandRoot, resolveTargets, skillRootFor } from './adapters.js';
6
+ import { assertSafeManagedPath, lstatWithoutSymlink, safeOwnedPath } from './path-safety.js';
6
7
 
7
8
  const PROJECT_MANIFEST = '.showdar.json';
8
9
  const START = '<!-- showdar-skills:start -->';
@@ -19,7 +20,7 @@ async function exists(target) {
19
20
  async function hashTree(target) {
20
21
  const h = createHash('sha256');
21
22
  async function walk(current, relative = '') {
22
- const info = await stat(current);
23
+ const info = await lstatWithoutSymlink(current);
23
24
  if (info.isDirectory()) {
24
25
  const entries = await readdir(current, { withFileTypes: true });
25
26
  entries.sort((a, b) => a.name.localeCompare(b.name));
@@ -35,15 +36,20 @@ async function hashTree(target) {
35
36
  return h.digest('hex');
36
37
  }
37
38
 
38
- async function readManifest(manifestPath) {
39
+ async function readManifest(manifestPath, baseRoot) {
40
+ await assertSafeManagedPath(baseRoot, manifestPath);
39
41
  if (!(await exists(manifestPath))) return null;
40
42
  try { return JSON.parse(await readFile(manifestPath, 'utf8')); }
41
43
  catch (error) { throw new Error(`Invalid Showdar manifest: ${error.message}`); }
42
44
  }
43
45
 
44
46
  async function writeJsonAtomic(target, value) {
47
+ await writeTextAtomic(target, `${JSON.stringify(value, null, 2)}\n`);
48
+ }
49
+
50
+ async function writeTextAtomic(target, value) {
45
51
  const tmp = `${target}.tmp-${process.pid}-${Date.now()}`;
46
- await writeFile(tmp, `${JSON.stringify(value, null, 2)}\n`);
52
+ await writeFile(tmp, value, { flag: 'wx' });
47
53
  await rename(tmp, target);
48
54
  }
49
55
 
@@ -81,18 +87,20 @@ function stripManagedBlock(content) {
81
87
 
82
88
  async function writeAgentsBlock(projectRoot, skillIds) {
83
89
  const target = path.join(projectRoot, 'AGENTS.md');
90
+ await assertSafeManagedPath(projectRoot, target);
84
91
  const current = (await exists(target)) ? await readFile(target, 'utf8') : '';
85
92
  const clean = stripManagedBlock(current).trimEnd();
86
93
  const block = managedBlock(skillIds);
87
- await writeFile(target, clean ? `${clean}\n\n${block}\n` : `${block}\n`);
94
+ await writeTextAtomic(target, clean ? `${clean}\n\n${block}\n` : `${block}\n`);
88
95
  }
89
96
 
90
97
  async function removeAgentsBlock(projectRoot) {
91
98
  const target = path.join(projectRoot, 'AGENTS.md');
99
+ await assertSafeManagedPath(projectRoot, target);
92
100
  if (!(await exists(target))) return;
93
101
  const current = await readFile(target, 'utf8');
94
102
  const clean = stripManagedBlock(current).trim();
95
- if (clean) await writeFile(target, `${clean}\n`);
103
+ if (clean) await writeTextAtomic(target, `${clean}\n`);
96
104
  else await rm(target, { force: true });
97
105
  }
98
106
 
@@ -109,20 +117,13 @@ function uniqueRoots(targets, resolveRoot) {
109
117
  return [...roots.values()];
110
118
  }
111
119
 
112
- function safeOwnedPath(baseRoot, relative, allowedRoots = []) {
113
- if (typeof relative !== 'string' || !relative) return null;
114
- const roots = [baseRoot, ...allowedRoots].map((root) => path.resolve(root));
115
- const target = path.resolve(relative.startsWith(path.sep) ? relative : path.join(roots[0], relative));
116
- if (!roots.some((root) => target === root || target.startsWith(`${root}${path.sep}`))) return null;
117
- return target;
118
- }
119
-
120
120
  function manifestPathFor(baseRoot, destination) {
121
121
  const relative = path.relative(baseRoot, destination);
122
122
  return relative.replaceAll(path.sep, '/');
123
123
  }
124
124
 
125
- async function copyOwned({ baseRoot, source, destination, priorOwned, newFiles }) {
125
+ async function copyOwned({ baseRoot, source, destination, priorOwned, newFiles, managedRoots = [] }) {
126
+ await assertSafeManagedPath(baseRoot, destination, managedRoots);
126
127
  const relative = manifestPathFor(baseRoot, destination);
127
128
  if ((await exists(destination)) && !priorOwned.has(relative)) {
128
129
  throw new Error(`Refusing to overwrite existing non-Showdar-managed skill or command: ${destination}`);
@@ -135,14 +136,17 @@ async function copyOwned({ baseRoot, source, destination, priorOwned, newFiles }
135
136
 
136
137
  async function initInstallation({ baseRoot, manifestPath, agentsRoot, packageRoot, profile, ai, skillIds, commandNames = [], packageVersion = '0.2.0', scope, skillRootForTarget, commandRoot, managedRoots = [], homeRoot = homedir(), globalSkillRootForTarget = null }) {
137
138
  await mkdir(baseRoot, { recursive: true });
139
+ await assertSafeManagedPath(baseRoot, baseRoot);
140
+ await assertSafeManagedPath(baseRoot, manifestPath);
141
+ if (agentsRoot) await assertSafeManagedPath(agentsRoot, path.join(agentsRoot, 'AGENTS.md'));
138
142
  const targets = resolveTargets(ai);
139
- const prior = await readManifest(manifestPath);
143
+ const prior = await readManifest(manifestPath, baseRoot);
140
144
  const priorOwned = ownedPathSet(prior);
141
145
  const desiredPaths = new Set();
142
146
 
143
147
  const skillRoots = uniqueRoots(targets, skillRootForTarget);
144
148
  const globalManifest = scope === 'project' && globalSkillRootForTarget
145
- ? await readManifest(globalManifestPath(homeRoot))
149
+ ? await readManifest(globalManifestPath(homeRoot), homeRoot)
146
150
  : null;
147
151
  const globalOwned = ownedPathSet(globalManifest);
148
152
  const globalSatisfaction = [];
@@ -158,9 +162,11 @@ async function initInstallation({ baseRoot, manifestPath, agentsRoot, packageRoo
158
162
  for (const { root, target } of skillRoots) {
159
163
  for (const skillId of skillIds) {
160
164
  const destination = path.join(root, skillId);
165
+ await assertSafeManagedPath(baseRoot, destination, managedRoots);
161
166
  const relative = manifestPathFor(baseRoot, destination);
162
167
  const destinationExists = await exists(destination);
163
168
  const globalPath = globalSkillRootForTarget ? path.join(globalSkillRootForTarget(target), skillId) : null;
169
+ if (globalPath) await assertSafeManagedPath(homeRoot, globalPath);
164
170
  const globalRelative = globalPath ? manifestPathFor(homeRoot, globalPath) : null;
165
171
  const globalAvailable = Boolean(globalPath && globalOwned.has(globalRelative) && await exists(globalPath));
166
172
  if (scope === 'project' && globalAvailable && !destinationExists) {
@@ -174,18 +180,28 @@ async function initInstallation({ baseRoot, manifestPath, agentsRoot, packageRoo
174
180
  }
175
181
  }
176
182
  if (targets.includes('opencode')) {
177
- for (const name of commandNames) desiredPaths.add(manifestPathFor(baseRoot, path.join(commandRoot(), `${name}.md`)));
183
+ const root = commandRoot();
184
+ for (const name of commandNames) {
185
+ const destination = path.join(root, `${name}.md`);
186
+ await assertSafeManagedPath(baseRoot, destination, managedRoots);
187
+ desiredPaths.add(manifestPathFor(baseRoot, destination));
188
+ }
178
189
  }
179
190
 
191
+ const staleTargets = [];
180
192
  for (const entry of prior?.files ?? []) {
181
193
  const target = safeOwnedPath(baseRoot, entry.path, managedRoots);
182
- if (target && !desiredPaths.has(entry.path)) await rm(target, { recursive: true, force: true });
194
+ if (target && !desiredPaths.has(entry.path)) {
195
+ await assertSafeManagedPath(baseRoot, target, managedRoots);
196
+ staleTargets.push(target);
197
+ }
183
198
  }
199
+ for (const target of staleTargets) await rm(target, { recursive: true, force: true });
184
200
 
185
201
  const files = [];
186
202
  for (const { destination, skillId } of skillDestinations) {
187
203
  const source = path.join(packageRoot, 'skills', skillId);
188
- await copyOwned({ baseRoot, source, destination, priorOwned, newFiles: files });
204
+ await copyOwned({ baseRoot, source, destination, priorOwned, newFiles: files, managedRoots });
189
205
  installedSkillIds.add(skillId);
190
206
  }
191
207
 
@@ -194,7 +210,7 @@ async function initInstallation({ baseRoot, manifestPath, agentsRoot, packageRoo
194
210
  for (const name of commandNames) {
195
211
  const source = path.join(packageRoot, 'commands', 'opencode', 'showdar', `${name}.md`);
196
212
  if (!(await exists(source))) throw new Error(`OpenCode command asset not found: ${name}`);
197
- await copyOwned({ baseRoot, source, destination: path.join(root, `${name}.md`), priorOwned, newFiles: files });
213
+ await copyOwned({ baseRoot, source, destination: path.join(root, `${name}.md`), priorOwned, newFiles: files, managedRoots });
198
214
  }
199
215
  }
200
216
 
@@ -210,6 +226,7 @@ async function initInstallation({ baseRoot, manifestPath, agentsRoot, packageRoo
210
226
  commands: targets.includes('opencode') ? [...commandNames] : [],
211
227
  files,
212
228
  };
229
+ await assertSafeManagedPath(baseRoot, manifestPath);
213
230
  await mkdir(path.dirname(manifestPath), { recursive: true });
214
231
  await writeJsonAtomic(manifestPath, manifest);
215
232
  if (agentsRoot) await writeAgentsBlock(agentsRoot, skillIds);
@@ -266,7 +283,7 @@ export async function initGlobal({ homeRoot = homedir(), packageRoot, profile, a
266
283
 
267
284
  async function inspectInstallation({ baseRoot, manifestPath, agentsRoot, scope, managedRoots = [], homeRoot = homedir(), globalSkillRootForTarget = null }) {
268
285
  let manifest;
269
- try { manifest = await readManifest(manifestPath); }
286
+ try { manifest = await readManifest(manifestPath, baseRoot); }
270
287
  catch (error) { return { installed: true, healthy: false, scope, profile: null, ai: null, targets: [], skills: 0, requestedSkills: 0, installedSkills: 0, satisfiedByGlobal: 0, commands: 0, issues: [error.message], warnings: [] }; }
271
288
  if (!manifest) return { installed: false, healthy: false, scope, profile: null, ai: null, targets: [], skills: 0, requestedSkills: 0, installedSkills: 0, satisfiedByGlobal: 0, commands: 0, issues: ['Showdar is not installed.'], warnings: [] };
272
289
 
@@ -276,7 +293,7 @@ async function inspectInstallation({ baseRoot, manifestPath, agentsRoot, scope,
276
293
  const targets = manifest.targets?.length ? manifest.targets : manifest.ai ? resolveTargets(manifest.ai) : [];
277
294
  const skillIds = manifest.skills ?? [];
278
295
  const globalManifest = scope === 'project' && globalSkillRootForTarget
279
- ? await readManifest(globalManifestPath(homeRoot))
296
+ ? await readManifest(globalManifestPath(homeRoot), homeRoot)
280
297
  : null;
281
298
  const globalOwned = ownedPathSet(globalManifest);
282
299
  const globalSatisfiedPaths = new Set();
@@ -294,7 +311,11 @@ async function inspectInstallation({ baseRoot, manifestPath, agentsRoot, scope,
294
311
  if (!entry.path.startsWith(prefix)) continue;
295
312
  const skillId = entry.path.slice(prefix.length).split('/')[0];
296
313
  if (!skillId.startsWith('showdar-')) continue;
297
- if (globalOwned.has(entry.path) && await exists(path.join(homeRoot, entry.path))) globalSkills.add(skillId);
314
+ const globalTarget = safeOwnedPath(homeRoot, entry.path);
315
+ if (globalTarget && globalTarget.startsWith(`${root}${path.sep}`) && globalOwned.has(entry.path)) {
316
+ await assertSafeManagedPath(homeRoot, globalTarget);
317
+ if (await exists(globalTarget)) globalSkills.add(skillId);
318
+ }
298
319
  }
299
320
  }
300
321
  const extra = [...globalSkills].filter((skillId) => !skillIds.includes(skillId)).sort();
@@ -308,6 +329,8 @@ async function inspectInstallation({ baseRoot, manifestPath, agentsRoot, scope,
308
329
  const projectIsOwned = projectOwned.has(projectRelative);
309
330
  const globalPath = path.join(globalSkillRootForTarget(target), skillId);
310
331
  const globalRelative = manifestPathFor(homeRoot, globalPath);
332
+ await assertSafeManagedPath(baseRoot, projectPath);
333
+ await assertSafeManagedPath(homeRoot, globalPath);
311
334
  const globalExists = await exists(globalPath);
312
335
  const globalIsOwned = globalOwned.has(globalRelative);
313
336
 
@@ -333,6 +356,7 @@ async function inspectInstallation({ baseRoot, manifestPath, agentsRoot, scope,
333
356
  for (const entry of manifest.files ?? []) {
334
357
  const target = safeOwnedPath(baseRoot, entry.path, managedRoots);
335
358
  if (!target) { issues.push(`Invalid managed path: ${entry.path}`); continue; }
359
+ await assertSafeManagedPath(baseRoot, target, managedRoots);
336
360
  if (!(await exists(target))) {
337
361
  if (!globalSatisfiedPaths.has(entry.path)) issues.push(`Missing managed path: ${entry.path}`);
338
362
  continue;
@@ -343,6 +367,7 @@ async function inspectInstallation({ baseRoot, manifestPath, agentsRoot, scope,
343
367
 
344
368
  if (agentsRoot) {
345
369
  const agentsFile = path.join(agentsRoot, 'AGENTS.md');
370
+ await assertSafeManagedPath(agentsRoot, agentsFile);
346
371
  if (!(await exists(agentsFile))) issues.push('Missing AGENTS.md routing block');
347
372
  else {
348
373
  const text = await readFile(agentsFile, 'utf8');
@@ -368,13 +393,18 @@ async function inspectInstallation({ baseRoot, manifestPath, agentsRoot, scope,
368
393
  }
369
394
 
370
395
  async function removeInstallation({ baseRoot, manifestPath, agentsRoot, managedRoots = [] }) {
396
+ await assertSafeManagedPath(baseRoot, manifestPath);
371
397
  let manifest;
372
- try { manifest = await readManifest(manifestPath); }
398
+ try { manifest = await readManifest(manifestPath, baseRoot); }
373
399
  catch { manifest = null; }
374
400
  for (const entry of manifest?.files ?? []) {
375
401
  const target = safeOwnedPath(baseRoot, entry.path, managedRoots);
376
- if (target) await rm(target, { recursive: true, force: true });
402
+ if (target) {
403
+ await assertSafeManagedPath(baseRoot, target, managedRoots);
404
+ await rm(target, { recursive: true, force: true });
405
+ }
377
406
  }
407
+ await assertSafeManagedPath(baseRoot, manifestPath);
378
408
  await rm(manifestPath, { force: true });
379
409
  if (agentsRoot) await removeAgentsBlock(agentsRoot);
380
410
  }