@skilltap/core 0.3.9 → 0.4.4
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 +1 -1
- package/src/debug.test.ts +102 -0
- package/src/debug.ts +67 -0
- package/src/git.ts +6 -10
- package/src/index.ts +2 -0
- package/src/install.ts +28 -6
- package/src/npm-registry.ts +8 -2
- package/src/remove.ts +16 -2
- package/src/self-update.ts +3 -2
- package/src/shell.test.ts +87 -0
- package/src/shell.ts +41 -0
- package/src/taps.ts +2 -3
- package/src/update.ts +34 -7
package/package.json
CHANGED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { debug, flushDebug, resetDebug } from "./debug";
|
|
6
|
+
|
|
7
|
+
describe("debug", () => {
|
|
8
|
+
let tmpDir: string;
|
|
9
|
+
const origDebug = process.env.SKILLTAP_DEBUG;
|
|
10
|
+
const origXdg = process.env.XDG_CONFIG_HOME;
|
|
11
|
+
|
|
12
|
+
afterEach(async () => {
|
|
13
|
+
resetDebug();
|
|
14
|
+
if (origDebug === undefined) delete process.env.SKILLTAP_DEBUG;
|
|
15
|
+
else process.env.SKILLTAP_DEBUG = origDebug;
|
|
16
|
+
if (origXdg === undefined) delete process.env.XDG_CONFIG_HOME;
|
|
17
|
+
else process.env.XDG_CONFIG_HOME = origXdg;
|
|
18
|
+
if (tmpDir) await rm(tmpDir, { recursive: true, force: true });
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("no-op when SKILLTAP_DEBUG is not set", async () => {
|
|
22
|
+
tmpDir = await mkdtemp(join(tmpdir(), "debug-test-"));
|
|
23
|
+
process.env.XDG_CONFIG_HOME = tmpDir;
|
|
24
|
+
delete process.env.SKILLTAP_DEBUG;
|
|
25
|
+
|
|
26
|
+
debug("should not write");
|
|
27
|
+
await flushDebug();
|
|
28
|
+
|
|
29
|
+
const logPath = join(tmpDir, "skilltap", "debug.log");
|
|
30
|
+
const exists = await Bun.file(logPath).exists();
|
|
31
|
+
expect(exists).toBe(false);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("writes to debug.log when SKILLTAP_DEBUG=1", async () => {
|
|
35
|
+
tmpDir = await mkdtemp(join(tmpdir(), "debug-test-"));
|
|
36
|
+
process.env.XDG_CONFIG_HOME = tmpDir;
|
|
37
|
+
process.env.SKILLTAP_DEBUG = "1";
|
|
38
|
+
|
|
39
|
+
debug("hello world");
|
|
40
|
+
await flushDebug();
|
|
41
|
+
|
|
42
|
+
const logPath = join(tmpDir, "skilltap", "debug.log");
|
|
43
|
+
const content = await readFile(logPath, "utf-8");
|
|
44
|
+
expect(content).toContain("hello world");
|
|
45
|
+
expect(content).toMatch(/^\[\d{4}-\d{2}-\d{2}T/);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("includes context as JSON", async () => {
|
|
49
|
+
tmpDir = await mkdtemp(join(tmpdir(), "debug-test-"));
|
|
50
|
+
process.env.XDG_CONFIG_HOME = tmpDir;
|
|
51
|
+
process.env.SKILLTAP_DEBUG = "1";
|
|
52
|
+
|
|
53
|
+
debug("test op", { exitCode: 18, stderr: "denied" });
|
|
54
|
+
await flushDebug();
|
|
55
|
+
|
|
56
|
+
const logPath = join(tmpDir, "skilltap", "debug.log");
|
|
57
|
+
const content = await readFile(logPath, "utf-8");
|
|
58
|
+
expect(content).toContain('"exitCode":18');
|
|
59
|
+
expect(content).toContain('"stderr":"denied"');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("rotates when log exceeds 1 MB", async () => {
|
|
63
|
+
tmpDir = await mkdtemp(join(tmpdir(), "debug-test-"));
|
|
64
|
+
process.env.XDG_CONFIG_HOME = tmpDir;
|
|
65
|
+
process.env.SKILLTAP_DEBUG = "1";
|
|
66
|
+
|
|
67
|
+
const logDir = join(tmpDir, "skilltap");
|
|
68
|
+
const logPath = join(logDir, "debug.log");
|
|
69
|
+
const { mkdir } = await import("node:fs/promises");
|
|
70
|
+
await mkdir(logDir, { recursive: true });
|
|
71
|
+
|
|
72
|
+
// Write >1 MB of content
|
|
73
|
+
const bigContent = "X".repeat(1_200_000);
|
|
74
|
+
await writeFile(logPath, bigContent);
|
|
75
|
+
|
|
76
|
+
debug("after rotation");
|
|
77
|
+
await flushDebug();
|
|
78
|
+
|
|
79
|
+
const content = await readFile(logPath, "utf-8");
|
|
80
|
+
// Should be truncated to ~500KB + new line
|
|
81
|
+
expect(content.length).toBeLessThan(600_000);
|
|
82
|
+
expect(content).toContain("after rotation");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("multiple writes are serialized", async () => {
|
|
86
|
+
tmpDir = await mkdtemp(join(tmpdir(), "debug-test-"));
|
|
87
|
+
process.env.XDG_CONFIG_HOME = tmpDir;
|
|
88
|
+
process.env.SKILLTAP_DEBUG = "1";
|
|
89
|
+
|
|
90
|
+
debug("line 1");
|
|
91
|
+
debug("line 2");
|
|
92
|
+
debug("line 3");
|
|
93
|
+
await flushDebug();
|
|
94
|
+
|
|
95
|
+
const logPath = join(tmpDir, "skilltap", "debug.log");
|
|
96
|
+
const content = await readFile(logPath, "utf-8");
|
|
97
|
+
const lines = content.trim().split("\n");
|
|
98
|
+
expect(lines).toHaveLength(3);
|
|
99
|
+
expect(lines[0]).toContain("line 1");
|
|
100
|
+
expect(lines[2]).toContain("line 3");
|
|
101
|
+
});
|
|
102
|
+
});
|
package/src/debug.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { appendFile, mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { getConfigDir } from "./config";
|
|
4
|
+
|
|
5
|
+
const MAX_LOG_SIZE = 1_048_576; // 1 MB
|
|
6
|
+
const KEEP_BYTES = 524_288; // 500 KB
|
|
7
|
+
|
|
8
|
+
let enabled: boolean | null = null;
|
|
9
|
+
let logPath: string | null = null;
|
|
10
|
+
let writeChain: Promise<void> = Promise.resolve();
|
|
11
|
+
let rotationDone = false;
|
|
12
|
+
|
|
13
|
+
function getLogPath(): string {
|
|
14
|
+
if (!logPath) logPath = join(getConfigDir(), "debug.log");
|
|
15
|
+
return logPath;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function rotateIfNeeded(): Promise<void> {
|
|
19
|
+
if (rotationDone) return;
|
|
20
|
+
rotationDone = true;
|
|
21
|
+
try {
|
|
22
|
+
const path = getLogPath();
|
|
23
|
+
const s = await stat(path).catch(() => null);
|
|
24
|
+
if (s && s.size > MAX_LOG_SIZE) {
|
|
25
|
+
const content = await readFile(path);
|
|
26
|
+
await writeFile(path, content.slice(content.length - KEEP_BYTES));
|
|
27
|
+
}
|
|
28
|
+
} catch {
|
|
29
|
+
/* ignore rotation errors */
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Write a timestamped debug message to the log file. No-op when SKILLTAP_DEBUG is not "1". */
|
|
34
|
+
export function debug(msg: string, context?: Record<string, unknown>): void {
|
|
35
|
+
if (enabled === null) enabled = process.env.SKILLTAP_DEBUG === "1";
|
|
36
|
+
if (!enabled) return;
|
|
37
|
+
|
|
38
|
+
const ts = new Date().toISOString();
|
|
39
|
+
const ctx = context ? ` ${JSON.stringify(context)}` : "";
|
|
40
|
+
const line = `[${ts}] ${msg}${ctx}\n`;
|
|
41
|
+
|
|
42
|
+
writeChain = writeChain.then(async () => {
|
|
43
|
+
await rotateIfNeeded();
|
|
44
|
+
const path = getLogPath();
|
|
45
|
+
await mkdir(join(path, ".."), { recursive: true }).catch(() => {});
|
|
46
|
+
await appendFile(path, line).catch(() => {});
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Flush pending debug writes. Only needed in tests — production code uses fire-and-forget.
|
|
52
|
+
* @internal
|
|
53
|
+
*/
|
|
54
|
+
export function flushDebug(): Promise<void> {
|
|
55
|
+
return writeChain;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Reset internal state. Only for tests.
|
|
60
|
+
* @internal
|
|
61
|
+
*/
|
|
62
|
+
export function resetDebug(): void {
|
|
63
|
+
enabled = null;
|
|
64
|
+
logPath = null;
|
|
65
|
+
writeChain = Promise.resolve();
|
|
66
|
+
rotationDone = false;
|
|
67
|
+
}
|
package/src/git.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { $ } from "bun";
|
|
2
|
+
import { debug } from "./debug";
|
|
3
|
+
import { extractStderr } from "./shell";
|
|
2
4
|
import type { Result } from "./types";
|
|
3
5
|
import { err, GitError, ok } from "./types";
|
|
4
6
|
|
|
@@ -13,15 +15,6 @@ export type CloneOptions = {
|
|
|
13
15
|
depth?: number;
|
|
14
16
|
};
|
|
15
17
|
|
|
16
|
-
function extractStderr(e: unknown): string {
|
|
17
|
-
if (e instanceof Error && "stderr" in e) {
|
|
18
|
-
const raw = (e as { stderr: unknown }).stderr;
|
|
19
|
-
if (raw instanceof Uint8Array) return new TextDecoder().decode(raw).trim();
|
|
20
|
-
return String(raw).trim();
|
|
21
|
-
}
|
|
22
|
-
return String(e);
|
|
23
|
-
}
|
|
24
|
-
|
|
25
18
|
async function wrapGit<T>(
|
|
26
19
|
fn: () => Promise<T>,
|
|
27
20
|
msg: string,
|
|
@@ -30,8 +23,10 @@ async function wrapGit<T>(
|
|
|
30
23
|
try {
|
|
31
24
|
return ok(await fn());
|
|
32
25
|
} catch (e) {
|
|
26
|
+
const stderr = extractStderr(e);
|
|
27
|
+
debug(msg, { stderr });
|
|
33
28
|
return err(
|
|
34
|
-
new GitError(`${msg}: ${
|
|
29
|
+
new GitError(`${msg}: ${stderr}`, hint ? { hint } : undefined),
|
|
35
30
|
);
|
|
36
31
|
}
|
|
37
32
|
}
|
|
@@ -49,6 +44,7 @@ export async function clone(
|
|
|
49
44
|
dest: string,
|
|
50
45
|
opts?: CloneOptions,
|
|
51
46
|
): Promise<Result<void, GitError>> {
|
|
47
|
+
debug("git clone", { url, dest, branch: opts?.branch });
|
|
52
48
|
const flags: string[] = ["--depth", String(opts?.depth ?? 1)];
|
|
53
49
|
if (opts?.branch) flags.push("--branch", opts.branch);
|
|
54
50
|
try {
|
package/src/index.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { version } from "../package.json";
|
|
2
2
|
export const VERSION: string = version;
|
|
3
|
+
export * from "./debug";
|
|
3
4
|
export * from "./doctor";
|
|
4
5
|
|
|
5
6
|
export * from "./adapters";
|
|
@@ -26,4 +27,5 @@ export * from "./trust";
|
|
|
26
27
|
export * from "./policy";
|
|
27
28
|
export * from "./validate";
|
|
28
29
|
export * from "./self-update";
|
|
30
|
+
export * from "./shell";
|
|
29
31
|
export * from "./types";
|
package/src/install.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { mkdir } from "node:fs/promises";
|
|
|
2
2
|
import { dirname, join, relative } from "node:path";
|
|
3
3
|
import { $ } from "bun";
|
|
4
4
|
import { resolveSource } from "./adapters";
|
|
5
|
+
import { debug } from "./debug";
|
|
5
6
|
import type { AgentAdapter } from "./agents/types";
|
|
6
7
|
import { loadInstalled, saveInstalled } from "./config";
|
|
7
8
|
import { makeTmpDir, removeTmpDir } from "./fs";
|
|
@@ -16,6 +17,7 @@ import type { StaticWarning } from "./security";
|
|
|
16
17
|
import { scanStatic } from "./security";
|
|
17
18
|
import type { SemanticWarning } from "./security/semantic";
|
|
18
19
|
import { scanSemantic } from "./security/semantic";
|
|
20
|
+
import { wrapShell } from "./shell";
|
|
19
21
|
import { createAgentSymlinks } from "./symlink";
|
|
20
22
|
import type { TapEntry } from "./taps";
|
|
21
23
|
import { loadTaps } from "./taps";
|
|
@@ -218,7 +220,15 @@ async function buildPlacements(params: {
|
|
|
218
220
|
// git multi-skill: move clone to cache first, then copy selected skills
|
|
219
221
|
const cacheRoot = skillCacheDir(resolvedUrl);
|
|
220
222
|
await mkdir(dirname(cacheRoot), { recursive: true });
|
|
221
|
-
|
|
223
|
+
const mvResult = await wrapShell(
|
|
224
|
+
() =>
|
|
225
|
+
$`cp -a ${contentDir} ${cacheRoot} && rm -rf ${contentDir}`
|
|
226
|
+
.quiet()
|
|
227
|
+
.then(() => undefined),
|
|
228
|
+
"Failed to move clone to cache",
|
|
229
|
+
"Check disk space and permissions.",
|
|
230
|
+
);
|
|
231
|
+
if (!mvResult.ok) throw mvResult.error;
|
|
222
232
|
for (const skill of selected) {
|
|
223
233
|
const skillSrcInCache = skill.path.replace(contentDir, cacheRoot);
|
|
224
234
|
placements.push({
|
|
@@ -251,11 +261,18 @@ async function executePlacements(params: {
|
|
|
251
261
|
|
|
252
262
|
for (const { skill, srcPath, relPath, destDir, useMove } of placements) {
|
|
253
263
|
await mkdir(dirname(destDir), { recursive: true });
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
264
|
+
const op = useMove ? "move" : "copy";
|
|
265
|
+
const shellResult = await wrapShell(
|
|
266
|
+
() =>
|
|
267
|
+
useMove
|
|
268
|
+
? $`cp -a ${srcPath} ${destDir} && rm -rf ${srcPath}`
|
|
269
|
+
.quiet()
|
|
270
|
+
.then(() => undefined)
|
|
271
|
+
: $`cp -r ${srcPath} ${destDir}`.quiet().then(() => undefined),
|
|
272
|
+
`Failed to ${op} skill '${skill.name}' to ${destDir}`,
|
|
273
|
+
"Check disk space and permissions.",
|
|
274
|
+
);
|
|
275
|
+
if (!shellResult.ok) throw shellResult.error;
|
|
259
276
|
await createAgentSymlinks(skill.name, destDir, also, options.scope, options.projectRoot);
|
|
260
277
|
records.push(
|
|
261
278
|
makeRecord(skill, resolved, sha, relPath, options, also, now, effectiveTap, finalRef, trust, sourceKey),
|
|
@@ -342,6 +359,7 @@ export async function installSkill(
|
|
|
342
359
|
source: string,
|
|
343
360
|
options: InstallOptions,
|
|
344
361
|
): Promise<Result<InstallResult, UserError | GitError | ScanError | NetworkError>> {
|
|
362
|
+
debug("installSkill", { source, scope: options.scope });
|
|
345
363
|
const also = options.also ?? [];
|
|
346
364
|
const allWarnings: StaticWarning[] = [];
|
|
347
365
|
const allSemanticWarnings: SemanticWarning[] = [];
|
|
@@ -409,6 +427,8 @@ export async function installSkill(
|
|
|
409
427
|
sha = shaResult.value;
|
|
410
428
|
}
|
|
411
429
|
|
|
430
|
+
debug("content fetched", { contentDir, sha, adapter: resolved.adapter });
|
|
431
|
+
|
|
412
432
|
// 5. Scan for skills
|
|
413
433
|
const scanned = await scan(contentDir, { onDeepScan: options.onDeepScan });
|
|
414
434
|
if (scanned.length === 0) {
|
|
@@ -539,6 +559,8 @@ export async function installSkill(
|
|
|
539
559
|
effectiveTap, finalRef, trust, sourceKey,
|
|
540
560
|
});
|
|
541
561
|
|
|
562
|
+
debug("placements complete", { installed: newRecords.map((r) => r.name) });
|
|
563
|
+
|
|
542
564
|
// 10. Save installed.json
|
|
543
565
|
installed.skills.push(...newRecords);
|
|
544
566
|
const saveResult = await saveInstalled(installed);
|
package/src/npm-registry.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { homedir } from "node:os";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { $ } from "bun";
|
|
4
|
+
import { debug } from "./debug";
|
|
5
|
+
import { extractStderr } from "./shell";
|
|
4
6
|
import type { Result } from "./types";
|
|
5
7
|
import { err, NetworkError, ok, UserError } from "./types";
|
|
6
8
|
|
|
@@ -156,6 +158,7 @@ export async function downloadAndExtract(
|
|
|
156
158
|
dest: string,
|
|
157
159
|
integrity?: string,
|
|
158
160
|
): Promise<Result<string, NetworkError>> {
|
|
161
|
+
debug("downloadAndExtract", { tarballUrl, dest });
|
|
159
162
|
let response: Response;
|
|
160
163
|
try {
|
|
161
164
|
response = await fetch(tarballUrl);
|
|
@@ -184,6 +187,7 @@ export async function downloadAndExtract(
|
|
|
184
187
|
const digest = hasher.digest("base64");
|
|
185
188
|
const expected = integrity.slice("sha512-".length);
|
|
186
189
|
if (digest !== expected) {
|
|
190
|
+
debug("integrity check failed", { expected, got: digest });
|
|
187
191
|
return err(
|
|
188
192
|
new NetworkError(
|
|
189
193
|
"Tarball integrity check failed. The download may be corrupted.",
|
|
@@ -199,8 +203,10 @@ export async function downloadAndExtract(
|
|
|
199
203
|
// Extract (npm tarballs always extract to a `package/` subdirectory)
|
|
200
204
|
try {
|
|
201
205
|
await $`tar -xzf ${tarPath} -C ${dest}`.quiet();
|
|
202
|
-
} catch {
|
|
203
|
-
|
|
206
|
+
} catch (e) {
|
|
207
|
+
const detail = extractStderr(e);
|
|
208
|
+
debug("tar extraction failed", { tarballUrl, error: detail });
|
|
209
|
+
return err(new NetworkError(`Failed to extract npm package tarball: ${detail}`));
|
|
204
210
|
}
|
|
205
211
|
|
|
206
212
|
return ok(join(dest, "package"));
|
package/src/remove.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { $ } from "bun";
|
|
2
2
|
import { loadInstalled, saveInstalled } from "./config";
|
|
3
|
+
import { debug } from "./debug";
|
|
3
4
|
import { skillCacheDir, skillInstallDir } from "./paths";
|
|
5
|
+
import { wrapShell } from "./shell";
|
|
4
6
|
import { removeAgentSymlinks } from "./symlink";
|
|
5
7
|
import type { Result } from "./types";
|
|
6
8
|
import { err, ok, UserError } from "./types";
|
|
@@ -14,6 +16,7 @@ export async function removeSkill(
|
|
|
14
16
|
name: string,
|
|
15
17
|
options: RemoveOptions = {},
|
|
16
18
|
): Promise<Result<void, UserError>> {
|
|
19
|
+
debug("removeSkill", { name, scope: options.scope });
|
|
17
20
|
const installedResult = await loadInstalled();
|
|
18
21
|
if (!installedResult.ok) return installedResult;
|
|
19
22
|
const installed = installedResult.value;
|
|
@@ -51,7 +54,12 @@ export async function removeSkill(
|
|
|
51
54
|
record.scope === "linked" ? "global" : record.scope,
|
|
52
55
|
options.projectRoot,
|
|
53
56
|
);
|
|
54
|
-
|
|
57
|
+
const rmResult = await wrapShell(
|
|
58
|
+
() => $`rm -rf ${installPath}`.quiet().then(() => undefined),
|
|
59
|
+
`Failed to remove skill directory '${name}'`,
|
|
60
|
+
"Check file permissions.",
|
|
61
|
+
);
|
|
62
|
+
if (!rmResult.ok) return rmResult;
|
|
55
63
|
|
|
56
64
|
// Remove cache if this was the last skill from the repo
|
|
57
65
|
if (record.path !== null && record.repo) {
|
|
@@ -60,7 +68,13 @@ export async function removeSkill(
|
|
|
60
68
|
);
|
|
61
69
|
if (remainingFromSameRepo.length === 0) {
|
|
62
70
|
const cacheRoot = skillCacheDir(record.repo);
|
|
63
|
-
|
|
71
|
+
const cacheResult = await wrapShell(
|
|
72
|
+
() => $`rm -rf ${cacheRoot}`.quiet().then(() => undefined),
|
|
73
|
+
`Failed to remove cache directory for '${name}'`,
|
|
74
|
+
);
|
|
75
|
+
if (!cacheResult.ok) {
|
|
76
|
+
debug("cache cleanup failed", { name, cacheRoot, error: cacheResult.error.message });
|
|
77
|
+
}
|
|
64
78
|
}
|
|
65
79
|
}
|
|
66
80
|
|
package/src/self-update.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { basename, join } from "node:path";
|
|
2
2
|
import { getConfigDir } from "./config";
|
|
3
|
+
import { extractStderr } from "./shell";
|
|
3
4
|
import { err, NetworkError, ok, type Result, UserError } from "./types";
|
|
4
5
|
|
|
5
6
|
export type UpdateType = "patch" | "minor" | "major";
|
|
@@ -163,13 +164,13 @@ export async function downloadAndInstall(
|
|
|
163
164
|
const buffer = await response.arrayBuffer();
|
|
164
165
|
await Bun.write(tmpPath, buffer);
|
|
165
166
|
await Bun.$`chmod +x ${tmpPath}`.quiet();
|
|
166
|
-
await Bun.$`
|
|
167
|
+
await Bun.$`cp -a ${tmpPath} ${execPath} && rm -f ${tmpPath}`.quiet();
|
|
167
168
|
} catch (e) {
|
|
168
169
|
// Clean up temp file if possible
|
|
169
170
|
Bun.$`rm -f ${tmpPath}`.quiet();
|
|
170
171
|
return err(
|
|
171
172
|
new UserError(
|
|
172
|
-
`Failed to replace binary: ${e}`,
|
|
173
|
+
`Failed to replace binary: ${extractStderr(e)}`,
|
|
173
174
|
"Try running with sudo, or install via npm: npm install -g skilltap",
|
|
174
175
|
),
|
|
175
176
|
);
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { extractExitCode, extractStderr, wrapShell } from "./shell";
|
|
3
|
+
|
|
4
|
+
describe("extractStderr", () => {
|
|
5
|
+
test("extracts Uint8Array stderr", () => {
|
|
6
|
+
const e = Object.assign(new Error("fail"), {
|
|
7
|
+
stderr: new TextEncoder().encode("permission denied"),
|
|
8
|
+
});
|
|
9
|
+
expect(extractStderr(e)).toBe("permission denied");
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
test("extracts string stderr", () => {
|
|
13
|
+
const e = Object.assign(new Error("fail"), { stderr: "not found" });
|
|
14
|
+
expect(extractStderr(e)).toBe("not found");
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("falls back to String(e) for plain errors", () => {
|
|
18
|
+
const e = new Error("something broke");
|
|
19
|
+
expect(extractStderr(e)).toBe("Error: something broke");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("handles non-Error values", () => {
|
|
23
|
+
expect(extractStderr("raw string")).toBe("raw string");
|
|
24
|
+
expect(extractStderr(42)).toBe("42");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("trims whitespace from stderr", () => {
|
|
28
|
+
const e = Object.assign(new Error("fail"), { stderr: " spaced \n" });
|
|
29
|
+
expect(extractStderr(e)).toBe("spaced");
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe("extractExitCode", () => {
|
|
34
|
+
test("extracts exitCode from ShellError-like object", () => {
|
|
35
|
+
const e = Object.assign(new Error("fail"), { exitCode: 18 });
|
|
36
|
+
expect(extractExitCode(e)).toBe(18);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("returns undefined for plain errors", () => {
|
|
40
|
+
expect(extractExitCode(new Error("nope"))).toBeUndefined();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("returns undefined for non-Error values", () => {
|
|
44
|
+
expect(extractExitCode("string")).toBeUndefined();
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe("wrapShell", () => {
|
|
49
|
+
test("returns ok on success", async () => {
|
|
50
|
+
const result = await wrapShell(() => Promise.resolve(42), "test op");
|
|
51
|
+
expect(result.ok).toBe(true);
|
|
52
|
+
if (result.ok) expect(result.value).toBe(42);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("returns err(UserError) with stderr on failure", async () => {
|
|
56
|
+
const shellError = Object.assign(new Error("Failed with exit code 1"), {
|
|
57
|
+
stderr: new TextEncoder().encode("No such file or directory"),
|
|
58
|
+
exitCode: 1,
|
|
59
|
+
});
|
|
60
|
+
const result = await wrapShell(
|
|
61
|
+
() => Promise.reject(shellError),
|
|
62
|
+
"Failed to copy skill",
|
|
63
|
+
"Check permissions.",
|
|
64
|
+
);
|
|
65
|
+
expect(result.ok).toBe(false);
|
|
66
|
+
if (!result.ok) {
|
|
67
|
+
expect(result.error.message).toContain("Failed to copy skill");
|
|
68
|
+
expect(result.error.message).toContain("No such file or directory");
|
|
69
|
+
expect(result.error.hint).toBe("Check permissions.");
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("falls back to exit code when no stderr", async () => {
|
|
74
|
+
const shellError = Object.assign(new Error("Failed with exit code 18"), {
|
|
75
|
+
stderr: new Uint8Array(0),
|
|
76
|
+
exitCode: 18,
|
|
77
|
+
});
|
|
78
|
+
const result = await wrapShell(
|
|
79
|
+
() => Promise.reject(shellError),
|
|
80
|
+
"mv failed",
|
|
81
|
+
);
|
|
82
|
+
expect(result.ok).toBe(false);
|
|
83
|
+
if (!result.ok) {
|
|
84
|
+
expect(result.error.message).toContain("exit code 18");
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
});
|
package/src/shell.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { debug } from "./debug";
|
|
2
|
+
import type { Result } from "./types";
|
|
3
|
+
import { err, ok, UserError } from "./types";
|
|
4
|
+
|
|
5
|
+
/** Extract stderr from a Bun ShellError. */
|
|
6
|
+
export function extractStderr(e: unknown): string {
|
|
7
|
+
if (e instanceof Error && "stderr" in e) {
|
|
8
|
+
const raw = (e as { stderr: unknown }).stderr;
|
|
9
|
+
if (raw instanceof Uint8Array) return new TextDecoder().decode(raw).trim();
|
|
10
|
+
return String(raw).trim();
|
|
11
|
+
}
|
|
12
|
+
return String(e);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Extract exit code from a Bun ShellError. */
|
|
16
|
+
export function extractExitCode(e: unknown): number | undefined {
|
|
17
|
+
if (e instanceof Error && "exitCode" in e) {
|
|
18
|
+
return (e as { exitCode: number }).exitCode;
|
|
19
|
+
}
|
|
20
|
+
return undefined;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Wrap a shell command with stderr extraction and debug logging.
|
|
25
|
+
* Returns Result<T, UserError> with a descriptive message on failure.
|
|
26
|
+
*/
|
|
27
|
+
export async function wrapShell<T>(
|
|
28
|
+
fn: () => Promise<T>,
|
|
29
|
+
msg: string,
|
|
30
|
+
hint?: string,
|
|
31
|
+
): Promise<Result<T, UserError>> {
|
|
32
|
+
try {
|
|
33
|
+
return ok(await fn());
|
|
34
|
+
} catch (e) {
|
|
35
|
+
const stderr = extractStderr(e);
|
|
36
|
+
const exitCode = extractExitCode(e);
|
|
37
|
+
debug(msg, { stderr, exitCode });
|
|
38
|
+
const detail = stderr || `exit code ${exitCode ?? "unknown"}`;
|
|
39
|
+
return err(new UserError(`${msg}: ${detail}`, hint));
|
|
40
|
+
}
|
|
41
|
+
}
|
package/src/taps.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { join } from "node:path";
|
|
|
3
3
|
import { $ } from "bun";
|
|
4
4
|
import { z } from "zod/v4";
|
|
5
5
|
import { getConfigDir, loadConfig, saveConfig } from "./config";
|
|
6
|
+
import { extractStderr } from "./shell";
|
|
6
7
|
import { checkGitInstalled, clone, pull } from "./git";
|
|
7
8
|
import type { RegistrySource } from "./registry";
|
|
8
9
|
import { detectTapType, fetchSkillList } from "./registry";
|
|
@@ -292,9 +293,7 @@ export async function initTap(name: string): Promise<Result<void, UserError>> {
|
|
|
292
293
|
return ok(undefined);
|
|
293
294
|
} catch (e) {
|
|
294
295
|
return err(
|
|
295
|
-
new UserError(
|
|
296
|
-
`Failed to initialize tap: ${e instanceof Error ? e.message : String(e)}`,
|
|
297
|
-
),
|
|
296
|
+
new UserError(`Failed to initialize tap: ${extractStderr(e)}`),
|
|
298
297
|
);
|
|
299
298
|
}
|
|
300
299
|
}
|
package/src/update.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { dirname, join } from "node:path";
|
|
|
3
3
|
import { $ } from "bun";
|
|
4
4
|
import type { AgentAdapter } from "./agents/types";
|
|
5
5
|
import { loadInstalled, saveInstalled } from "./config";
|
|
6
|
+
import { debug } from "./debug";
|
|
6
7
|
import { makeTmpDir, removeTmpDir } from "./fs";
|
|
7
8
|
import type { DiffStat } from "./git";
|
|
8
9
|
import { diff, diffStat, fetch, pull, revParse } from "./git";
|
|
@@ -18,6 +19,7 @@ import type { StaticWarning } from "./security";
|
|
|
18
19
|
import { scanDiff, scanStatic } from "./security";
|
|
19
20
|
import type { SemanticWarning } from "./security/semantic";
|
|
20
21
|
import { scanSemantic } from "./security/semantic";
|
|
22
|
+
import { wrapShell } from "./shell";
|
|
21
23
|
import { createAgentSymlinks, removeAgentSymlinks } from "./symlink";
|
|
22
24
|
import { parseGitHubRepo, resolveTrust } from "./trust";
|
|
23
25
|
import type { Result } from "./types";
|
|
@@ -94,17 +96,27 @@ async function recopyMultiSkill(
|
|
|
94
96
|
workDir: string,
|
|
95
97
|
record: InstalledSkill,
|
|
96
98
|
projectRoot?: string,
|
|
97
|
-
): Promise<void
|
|
98
|
-
if (record.path === null) return;
|
|
99
|
+
): Promise<Result<void, UserError>> {
|
|
100
|
+
if (record.path === null) return ok(undefined);
|
|
99
101
|
const skillSrc = join(workDir, record.path);
|
|
100
102
|
const destDir = skillInstallDir(
|
|
101
103
|
record.name,
|
|
102
104
|
record.scope as "global" | "project",
|
|
103
105
|
projectRoot,
|
|
104
106
|
);
|
|
105
|
-
|
|
107
|
+
const rmResult = await wrapShell(
|
|
108
|
+
() => $`rm -rf ${destDir}`.quiet().then(() => undefined),
|
|
109
|
+
`Failed to remove old skill directory '${record.name}'`,
|
|
110
|
+
);
|
|
111
|
+
if (!rmResult.ok) return rmResult;
|
|
112
|
+
|
|
106
113
|
await mkdir(dirname(destDir), { recursive: true });
|
|
107
|
-
|
|
114
|
+
|
|
115
|
+
return wrapShell(
|
|
116
|
+
() => $`cp -r ${skillSrc} ${destDir}`.quiet().then(() => undefined),
|
|
117
|
+
`Failed to copy updated skill '${record.name}'`,
|
|
118
|
+
"Check disk space and permissions.",
|
|
119
|
+
);
|
|
108
120
|
}
|
|
109
121
|
|
|
110
122
|
/** Remove and re-create agent symlinks for a skill (idempotent). */
|
|
@@ -225,9 +237,20 @@ async function updateNpmSkill(
|
|
|
225
237
|
record.scope as "global" | "project",
|
|
226
238
|
options.projectRoot,
|
|
227
239
|
);
|
|
228
|
-
|
|
240
|
+
const rmResult = await wrapShell(
|
|
241
|
+
() => $`rm -rf ${installDir}`.quiet().then(() => undefined),
|
|
242
|
+
`Failed to remove old skill directory '${record.name}'`,
|
|
243
|
+
);
|
|
244
|
+
if (!rmResult.ok) return rmResult;
|
|
245
|
+
|
|
229
246
|
await mkdir(dirname(installDir), { recursive: true });
|
|
230
|
-
|
|
247
|
+
|
|
248
|
+
const cpResult = await wrapShell(
|
|
249
|
+
() => $`cp -r ${newSkillDir} ${installDir}`.quiet().then(() => undefined),
|
|
250
|
+
`Failed to install updated skill '${record.name}'`,
|
|
251
|
+
"Check disk space and permissions.",
|
|
252
|
+
);
|
|
253
|
+
if (!cpResult.ok) return cpResult;
|
|
231
254
|
|
|
232
255
|
// Semantic scan on updated content
|
|
233
256
|
if (await runUpdateSemanticScan(installDir, record.name, options)) {
|
|
@@ -342,7 +365,10 @@ async function updateGitSkill(
|
|
|
342
365
|
const pullResult = await pull(workDir);
|
|
343
366
|
if (!pullResult.ok) return pullResult;
|
|
344
367
|
|
|
345
|
-
if (isMulti)
|
|
368
|
+
if (isMulti) {
|
|
369
|
+
const recopyResult = await recopyMultiSkill(workDir, record, options.projectRoot);
|
|
370
|
+
if (!recopyResult.ok) return recopyResult;
|
|
371
|
+
}
|
|
346
372
|
|
|
347
373
|
const installDir = skillInstallDir(
|
|
348
374
|
record.name,
|
|
@@ -387,6 +413,7 @@ async function updateGitSkill(
|
|
|
387
413
|
export async function updateSkill(
|
|
388
414
|
options: UpdateOptions = {},
|
|
389
415
|
): Promise<Result<UpdateResult, UserError | GitError | ScanError | NetworkError>> {
|
|
416
|
+
debug("updateSkill", { name: options.name ?? "all" });
|
|
390
417
|
const installedResult = await loadInstalled();
|
|
391
418
|
if (!installedResult.ok) return installedResult;
|
|
392
419
|
const installed = installedResult.value;
|