skillex 0.3.1 → 0.4.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.
Files changed (51) hide show
  1. package/CHANGELOG.md +262 -1
  2. package/README.md +57 -10
  3. package/dist/auto-sync.d.ts +66 -0
  4. package/dist/auto-sync.js +91 -0
  5. package/dist/catalog.js +5 -29
  6. package/dist/cli.d.ts +13 -0
  7. package/dist/cli.js +247 -141
  8. package/dist/confirm.js +3 -1
  9. package/dist/direct-github.d.ts +60 -0
  10. package/dist/direct-github.js +177 -0
  11. package/dist/doctor.d.ts +31 -0
  12. package/dist/doctor.js +172 -0
  13. package/dist/downloader.d.ts +42 -0
  14. package/dist/downloader.js +41 -0
  15. package/dist/fs.d.ts +21 -1
  16. package/dist/fs.js +30 -3
  17. package/dist/http.d.ts +28 -7
  18. package/dist/http.js +143 -42
  19. package/dist/install.d.ts +23 -9
  20. package/dist/install.js +75 -348
  21. package/dist/lockfile.d.ts +46 -0
  22. package/dist/lockfile.js +169 -0
  23. package/dist/output.d.ts +11 -0
  24. package/dist/output.js +49 -0
  25. package/dist/recommended.d.ts +13 -0
  26. package/dist/recommended.js +21 -0
  27. package/dist/runner.js +9 -9
  28. package/dist/skill.d.ts +2 -0
  29. package/dist/skill.js +3 -0
  30. package/dist/sync.js +12 -9
  31. package/dist/types.d.ts +39 -0
  32. package/dist/types.js +28 -0
  33. package/dist/ui.js +1 -1
  34. package/dist/user-config.d.ts +5 -0
  35. package/dist/user-config.js +22 -1
  36. package/dist/web-ui.js +5 -0
  37. package/dist-ui/assets/CatalogPage-CbtMTkxd.js +1 -0
  38. package/dist-ui/assets/CatalogPage-W5MqylAz.css +1 -0
  39. package/dist-ui/assets/DoctorPage-oUZyX91t.js +1 -0
  40. package/dist-ui/assets/Skeleton-B_xm5L3P.js +1 -0
  41. package/dist-ui/assets/Skeleton-_Ooiw1nN.css +1 -0
  42. package/dist-ui/assets/SkillDetailPage-5JHQLq3q.js +1 -0
  43. package/dist-ui/assets/SkillDetailPage-CBAaWpcc.css +1 -0
  44. package/dist-ui/assets/{index-UBECch6X.css → index-CWm7zQTg.css} +1 -1
  45. package/dist-ui/assets/index-I0b-syhc.js +26 -0
  46. package/dist-ui/assets/recommended-D_i10hwH.js +1 -0
  47. package/dist-ui/index.html +2 -2
  48. package/package.json +2 -2
  49. package/dist-ui/assets/CatalogPage-B_qic36n.js +0 -1
  50. package/dist-ui/assets/SkillDetailPage-BJ3onKk4.js +0 -1
  51. package/dist-ui/assets/index-DN-z--cR.js +0 -25
@@ -0,0 +1,177 @@
1
+ /**
2
+ * Direct-GitHub install path. Parallels the catalog install path but reads
3
+ * `skill.json` (or `SKILL.md` frontmatter) directly from a GitHub
4
+ * repository, allowing users to install skills that are not yet published
5
+ * in any catalog source.
6
+ */
7
+ import { buildRawGitHubUrl } from "./catalog.js";
8
+ import { confirmAction } from "./confirm.js";
9
+ import { downloadSkillFiles, writeDownloadedManifest, } from "./downloader.js";
10
+ import { fetchOptionalJson, fetchOptionalText } from "./http.js";
11
+ import { parseSkillFrontmatter } from "./skill.js";
12
+ import * as path from "node:path";
13
+ import { CliError, InstallError } from "./types.js";
14
+ /** Refs in `owner/repo[@ref]` form must match this character set. */
15
+ const ALLOWED_REF_PATTERN = /^[A-Za-z0-9_.\-/]+$/;
16
+ /**
17
+ * Parses a direct GitHub install reference in `owner/repo[@ref]` format.
18
+ *
19
+ * The ref segment (when present) MUST match `^[A-Za-z0-9_.\-/]+$`. Empty
20
+ * refs (e.g. `owner/repo@`) and refs containing whitespace, newlines, or
21
+ * shell metacharacters are rejected with `CliError("INVALID_DIRECT_REF")`
22
+ * rather than silently defaulting to `main`.
23
+ *
24
+ * @param input - User-supplied install argument.
25
+ * @returns Parsed direct GitHub reference or `null` when the value is not a direct ref.
26
+ * @throws {CliError} When the value looks like a direct ref but the ref portion is invalid.
27
+ */
28
+ export function parseDirectGitHubRef(input) {
29
+ if (!input || input.startsWith("http://") || input.startsWith("https://")) {
30
+ return null;
31
+ }
32
+ const trimmed = input.trim();
33
+ // Detect "owner/repo[@maybeRef]" shape. The ref capture is greedy so we can
34
+ // validate exactly what the user typed (including empty values after `@`).
35
+ const shape = trimmed.match(/^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+?)(@.*)?$/);
36
+ if (!shape) {
37
+ return null;
38
+ }
39
+ const owner = shape[1];
40
+ const repo = shape[2];
41
+ const refSuffix = shape[3]; // e.g. "@v1.0.0" or "@" or undefined
42
+ let ref = "main";
43
+ if (refSuffix !== undefined) {
44
+ const rawRef = refSuffix.slice(1); // drop leading "@"
45
+ if (rawRef.length === 0) {
46
+ throw new CliError(`Invalid direct install ref: empty ref after "@" in "${trimmed}". Use owner/repo or owner/repo@<branch|tag>.`, "INVALID_DIRECT_REF");
47
+ }
48
+ if (!ALLOWED_REF_PATTERN.test(rawRef)) {
49
+ throw new CliError(`Invalid direct install ref: "${rawRef}" contains disallowed characters. Allowed: letters, digits, "_", ".", "-", "/".`, "INVALID_DIRECT_REF");
50
+ }
51
+ ref = rawRef;
52
+ }
53
+ return { owner, repo, ref };
54
+ }
55
+ /**
56
+ * Parses a `github:owner/repo@ref` source string from the lockfile back into a
57
+ * `DirectGitHubRef`.
58
+ */
59
+ export function parseGitHubSource(source) {
60
+ if (!source.startsWith("github:")) {
61
+ return null;
62
+ }
63
+ const withoutPrefix = source.slice("github:".length);
64
+ const separatorIndex = withoutPrefix.lastIndexOf("@");
65
+ if (separatorIndex <= 0) {
66
+ return null;
67
+ }
68
+ return parseDirectGitHubRef(`${withoutPrefix.slice(0, separatorIndex)}@${withoutPrefix.slice(separatorIndex + 1)}`);
69
+ }
70
+ /**
71
+ * Fetches the manifest for a direct-install skill, falling back to SKILL.md
72
+ * frontmatter when no `skill.json` is present at the repository root.
73
+ */
74
+ export async function fetchDirectGitHubSkill(reference) {
75
+ const repoId = `${reference.owner}/${reference.repo}`;
76
+ const manifestUrl = buildRawGitHubUrl(repoId, reference.ref, "skill.json");
77
+ const manifest = await fetchOptionalJson(manifestUrl, {
78
+ headers: { Accept: "application/json" },
79
+ });
80
+ if (manifest) {
81
+ return {
82
+ repo: repoId,
83
+ ref: reference.ref,
84
+ source: `github:${repoId}@${reference.ref}`,
85
+ manifest: normalizeDirectManifest(manifest, reference),
86
+ };
87
+ }
88
+ const skillMarkdown = await fetchOptionalText(buildRawGitHubUrl(repoId, reference.ref, "SKILL.md"), {
89
+ headers: { Accept: "text/plain" },
90
+ });
91
+ if (!skillMarkdown) {
92
+ throw new InstallError(`No skill.json or SKILL.md found at ${repoId}@${reference.ref}.`, "DIRECT_SKILL_NOT_FOUND");
93
+ }
94
+ const frontmatter = parseSkillFrontmatter(skillMarkdown);
95
+ return {
96
+ repo: repoId,
97
+ ref: reference.ref,
98
+ source: `github:${repoId}@${reference.ref}`,
99
+ manifest: {
100
+ id: normalizeRepoSkillId(reference.repo),
101
+ name: frontmatter.name || toTitleCase(reference.repo),
102
+ version: "0.1.0",
103
+ description: frontmatter.description || `Skill installed directly from ${repoId}.`,
104
+ author: reference.owner,
105
+ tags: [],
106
+ compatibility: [],
107
+ entry: "SKILL.md",
108
+ path: "",
109
+ files: ["SKILL.md"],
110
+ },
111
+ };
112
+ }
113
+ /**
114
+ * Downloads the resolved direct-install skill into the managed skills store.
115
+ */
116
+ export async function downloadDirectGitHubSkill(skill, skillsDirPath) {
117
+ const skillTargetDir = path.join(skillsDirPath, skill.manifest.id);
118
+ await downloadSkillFiles({
119
+ repo: skill.repo,
120
+ ref: skill.ref,
121
+ skillRelPath: skill.manifest.path,
122
+ files: skill.manifest.files,
123
+ targetDir: skillTargetDir,
124
+ });
125
+ const downloaded = {
126
+ ...skill.manifest,
127
+ source: {
128
+ repo: skill.repo,
129
+ ref: skill.ref,
130
+ path: skill.manifest.path,
131
+ },
132
+ };
133
+ await writeDownloadedManifest(skillTargetDir, downloaded);
134
+ }
135
+ /**
136
+ * Promotes a partial direct-install manifest into a fully-typed `SkillManifest`,
137
+ * providing safe defaults for missing fields.
138
+ */
139
+ export function normalizeDirectManifest(manifest, reference) {
140
+ return {
141
+ id: manifest.id || normalizeRepoSkillId(reference.repo),
142
+ name: manifest.name || toTitleCase(reference.repo),
143
+ version: manifest.version || "0.1.0",
144
+ description: manifest.description || `Skill installed directly from ${reference.owner}/${reference.repo}.`,
145
+ author: manifest.author || reference.owner,
146
+ tags: Array.isArray(manifest.tags) ? manifest.tags : [],
147
+ compatibility: Array.isArray(manifest.compatibility) ? manifest.compatibility : [],
148
+ entry: manifest.entry || "SKILL.md",
149
+ path: manifest.path || "",
150
+ files: Array.isArray(manifest.files) && manifest.files.length > 0 ? manifest.files : [manifest.entry || "SKILL.md"],
151
+ ...(manifest.scripts ? { scripts: manifest.scripts } : {}),
152
+ };
153
+ }
154
+ /**
155
+ * Prompts the user to confirm a direct GitHub install (skipped when the
156
+ * caller passes `trust: true`). Throws `InstallError` with code
157
+ * `INSTALL_CANCELLED` on rejection.
158
+ */
159
+ export async function confirmDirectInstall(skillRef, options) {
160
+ const warning = `Warning: ${skillRef} will be installed directly from GitHub and has not been verified by the active catalog.`;
161
+ (options.warn || console.error)(warning);
162
+ const confirm = options.confirm || (() => confirmAction("Continue with the direct install?"));
163
+ const accepted = await confirm();
164
+ if (!accepted) {
165
+ throw new InstallError("Direct install cancelled by user.", "INSTALL_CANCELLED");
166
+ }
167
+ }
168
+ function normalizeRepoSkillId(repo) {
169
+ return repo.trim().toLowerCase();
170
+ }
171
+ function toTitleCase(skillId) {
172
+ return skillId
173
+ .split("-")
174
+ .filter(Boolean)
175
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
176
+ .join(" ");
177
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Workspace and environment health checks.
3
+ *
4
+ * Used by both the CLI `skillex doctor` command and the Web UI's Doctor
5
+ * panel. Returns a structured `DoctorReport` so callers can render however
6
+ * they want (CLI text, JSON, Vue components).
7
+ */
8
+ import type { ProjectOptions } from "./types.js";
9
+ /** Status indicator for a single doctor check. */
10
+ export type DoctorStatus = "pass" | "warn" | "fail";
11
+ /** A single named health check result. */
12
+ export interface DoctorCheck {
13
+ name: string;
14
+ status: DoctorStatus;
15
+ message: string;
16
+ hint?: string | undefined;
17
+ }
18
+ /** Aggregate report shape returned by `runDoctorChecks`. */
19
+ export interface DoctorReport {
20
+ scope: "local" | "global";
21
+ stateDir: string;
22
+ checks: DoctorCheck[];
23
+ /** True when at least one check has status `"fail"`. */
24
+ hasFailures: boolean;
25
+ }
26
+ /**
27
+ * Runs the canonical six health checks and returns a structured report.
28
+ * No I/O beyond reading the lockfile, an HTTP HEAD probe to api.github.com,
29
+ * and a peek at the catalog cache directory.
30
+ */
31
+ export declare function runDoctorChecks(options?: ProjectOptions): Promise<DoctorReport>;
package/dist/doctor.js ADDED
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Workspace and environment health checks.
3
+ *
4
+ * Used by both the CLI `skillex doctor` command and the Web UI's Doctor
5
+ * panel. Returns a structured `DoctorReport` so callers can render however
6
+ * they want (CLI text, JSON, Vue components).
7
+ */
8
+ import * as path from "node:path";
9
+ import { listAdapters } from "./adapters.js";
10
+ import { computeCatalogCacheKey, readCatalogCache } from "./catalog.js";
11
+ import { DEFAULT_INSTALL_SCOPE, getScopedStatePaths } from "./config.js";
12
+ import { getInstalledSkills, resolveProjectSource, } from "./install.js";
13
+ /**
14
+ * Runs the canonical six health checks and returns a structured report.
15
+ * No I/O beyond reading the lockfile, an HTTP HEAD probe to api.github.com,
16
+ * and a peek at the catalog cache directory.
17
+ */
18
+ export async function runDoctorChecks(options = {}) {
19
+ const cwd = path.resolve(options.cwd ?? process.cwd());
20
+ const statePaths = getScopedStatePaths(cwd, {
21
+ scope: options.scope ?? DEFAULT_INSTALL_SCOPE,
22
+ baseDir: options.agentSkillsDir,
23
+ });
24
+ const opts = { ...options, cwd };
25
+ const checks = [];
26
+ // 1. Lockfile
27
+ const state = await getInstalledSkills(opts);
28
+ if (state) {
29
+ checks.push({
30
+ name: "lockfile",
31
+ status: "pass",
32
+ message: `Lockfile present at ${statePaths.lockfilePath}`,
33
+ });
34
+ }
35
+ else {
36
+ checks.push({
37
+ name: "lockfile",
38
+ status: "fail",
39
+ message: "No lockfile found",
40
+ hint: statePaths.scope === "global"
41
+ ? "Run: skillex init --global --adapter <id>"
42
+ : "Run: skillex init",
43
+ });
44
+ }
45
+ // 2. Source(s)
46
+ if ((state?.sources?.length ?? 0) > 0) {
47
+ const repos = state.sources.map((s) => `${s.repo}@${s.ref}`).join(", ");
48
+ checks.push({ name: "source", status: "pass", message: `Sources configured: ${repos}` });
49
+ }
50
+ else {
51
+ checks.push({
52
+ name: "source",
53
+ status: "fail",
54
+ message: "No catalog source configured",
55
+ hint: "Run: skillex source add <owner/repo>",
56
+ });
57
+ }
58
+ // 3. Adapter
59
+ const hasAdapter = Boolean(state?.adapters?.active || (state?.adapters?.detected?.length ?? 0) > 0);
60
+ if (hasAdapter) {
61
+ const adapter = state?.adapters?.active ?? state?.adapters?.detected?.[0];
62
+ checks.push({ name: "adapter", status: "pass", message: `Active: ${adapter}` });
63
+ }
64
+ else {
65
+ checks.push({
66
+ name: "adapter",
67
+ status: "fail",
68
+ message: "No adapter detected",
69
+ hint: `Use --adapter <id>. Available: ${listAdapters().map((a) => a.id).join(", ")}`,
70
+ });
71
+ }
72
+ // 4. GitHub reachable
73
+ try {
74
+ const response = await fetch("https://api.github.com", {
75
+ method: "HEAD",
76
+ headers: { "User-Agent": "skillex" },
77
+ signal: AbortSignal.timeout(5000),
78
+ });
79
+ if (response.status < 500) {
80
+ checks.push({ name: "github", status: "pass", message: "GitHub API is reachable" });
81
+ }
82
+ else {
83
+ checks.push({
84
+ name: "github",
85
+ status: "fail",
86
+ message: `GitHub returned a server error (status ${response.status})`,
87
+ hint: "Try again in a moment.",
88
+ });
89
+ }
90
+ }
91
+ catch (error) {
92
+ const cause = error?.cause?.code
93
+ ?? error?.code
94
+ ?? null;
95
+ const message = error instanceof Error ? error.message : String(error);
96
+ if (cause === "EAI_AGAIN" || cause === "ENOTFOUND") {
97
+ checks.push({
98
+ name: "github",
99
+ status: "fail",
100
+ message: "DNS lookup failed for api.github.com",
101
+ hint: `Check your network or DNS resolver. (${message})`,
102
+ });
103
+ }
104
+ else if (cause === "ECONNREFUSED") {
105
+ checks.push({
106
+ name: "github",
107
+ status: "fail",
108
+ message: "Connection refused by api.github.com",
109
+ hint: `Check your firewall or proxy. (${message})`,
110
+ });
111
+ }
112
+ else if (cause === "CERT_HAS_EXPIRED" || cause === "UNABLE_TO_VERIFY_LEAF_SIGNATURE") {
113
+ checks.push({
114
+ name: "github",
115
+ status: "fail",
116
+ message: "TLS handshake failed",
117
+ hint: `Check the system clock or any TLS-intercepting proxy. (${message})`,
118
+ });
119
+ }
120
+ else if (cause === "ETIMEDOUT" || error?.name === "TimeoutError") {
121
+ checks.push({
122
+ name: "github",
123
+ status: "fail",
124
+ message: "Connection to api.github.com timed out",
125
+ hint: `Check your network connectivity. (${message})`,
126
+ });
127
+ }
128
+ else {
129
+ checks.push({
130
+ name: "github",
131
+ status: "fail",
132
+ message: "GitHub API is unreachable",
133
+ hint: `Check your internet connection or proxy settings. (${message})`,
134
+ });
135
+ }
136
+ }
137
+ // 5. GitHub token (warning only — never fails)
138
+ const token = process.env.GITHUB_TOKEN;
139
+ checks.push({
140
+ name: "token",
141
+ status: "pass",
142
+ message: token
143
+ ? "GitHub token set (authenticated — 5,000 req/hr)"
144
+ : "No GitHub token (unauthenticated — 60 req/hr)",
145
+ });
146
+ // 6. Cache
147
+ const cacheDir = path.join(statePaths.stateDir, ".cache");
148
+ if ((state?.sources?.length ?? 0) > 0) {
149
+ const source = await resolveProjectSource(opts);
150
+ const cacheKey = computeCatalogCacheKey(source);
151
+ const cached = await readCatalogCache(cacheDir, cacheKey);
152
+ if (cached) {
153
+ checks.push({ name: "cache", status: "pass", message: "Catalog cache is fresh" });
154
+ }
155
+ else {
156
+ checks.push({
157
+ name: "cache",
158
+ status: "pass",
159
+ message: "No cached catalog (will fetch on next command)",
160
+ });
161
+ }
162
+ }
163
+ else {
164
+ checks.push({ name: "cache", status: "pass", message: "Cache not checked (no source configured)" });
165
+ }
166
+ return {
167
+ scope: statePaths.scope,
168
+ stateDir: statePaths.stateDir,
169
+ checks,
170
+ hasFailures: checks.some((c) => c.status === "fail"),
171
+ };
172
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Shared file-download helper used by both the catalog install path and the
3
+ * direct-GitHub install path. Centralizes the per-file fetch loop and the
4
+ * manifest write so retry / timeout / etag changes only have to land in
5
+ * one place.
6
+ */
7
+ import type { SkillManifest } from "./types.js";
8
+ /**
9
+ * A skill manifest plus the resolved remote source it was downloaded from.
10
+ * Persisted into `<skillTargetDir>/skill.json` so subsequent updates know
11
+ * where to fetch from.
12
+ */
13
+ export interface DownloadedSkillManifest extends SkillManifest {
14
+ source: {
15
+ repo: string;
16
+ ref: string;
17
+ path: string;
18
+ };
19
+ }
20
+ /**
21
+ * Downloads every file declared in a skill manifest into `targetDir`. The
22
+ * helper wipes the target directory beforehand to ensure a clean install
23
+ * even when a previous version had different files.
24
+ *
25
+ * @param args.repo - GitHub repository in `owner/repo` form.
26
+ * @param args.ref - Branch, tag, or commit SHA.
27
+ * @param args.skillRelPath - Path of the skill inside the repository (may be empty).
28
+ * @param args.files - File list relative to `skillRelPath`.
29
+ * @param args.targetDir - Local directory that will receive the files.
30
+ */
31
+ export declare function downloadSkillFiles(args: {
32
+ repo: string;
33
+ ref: string;
34
+ skillRelPath: string;
35
+ files: string[];
36
+ targetDir: string;
37
+ }): Promise<void>;
38
+ /**
39
+ * Persists the downloaded manifest (with resolved source) into the skill's
40
+ * target directory.
41
+ */
42
+ export declare function writeDownloadedManifest(skillTargetDir: string, manifest: DownloadedSkillManifest): Promise<void>;
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Shared file-download helper used by both the catalog install path and the
3
+ * direct-GitHub install path. Centralizes the per-file fetch loop and the
4
+ * manifest write so retry / timeout / etag changes only have to land in
5
+ * one place.
6
+ */
7
+ import * as path from "node:path";
8
+ import { buildRawGitHubUrl } from "./catalog.js";
9
+ import { ensureDir, removePath, writeJson, writeText } from "./fs.js";
10
+ import { fetchText } from "./http.js";
11
+ /**
12
+ * Downloads every file declared in a skill manifest into `targetDir`. The
13
+ * helper wipes the target directory beforehand to ensure a clean install
14
+ * even when a previous version had different files.
15
+ *
16
+ * @param args.repo - GitHub repository in `owner/repo` form.
17
+ * @param args.ref - Branch, tag, or commit SHA.
18
+ * @param args.skillRelPath - Path of the skill inside the repository (may be empty).
19
+ * @param args.files - File list relative to `skillRelPath`.
20
+ * @param args.targetDir - Local directory that will receive the files.
21
+ */
22
+ export async function downloadSkillFiles(args) {
23
+ await removePath(args.targetDir);
24
+ await ensureDir(args.targetDir);
25
+ await Promise.all(args.files.map(async (relativePath) => {
26
+ const remotePath = args.skillRelPath
27
+ ? path.posix.join(args.skillRelPath, relativePath)
28
+ : relativePath;
29
+ const rawUrl = buildRawGitHubUrl(args.repo, args.ref, remotePath);
30
+ const content = await fetchText(rawUrl, { headers: { Accept: "text/plain" } });
31
+ const localPath = path.join(args.targetDir, relativePath);
32
+ await writeText(localPath, content);
33
+ }));
34
+ }
35
+ /**
36
+ * Persists the downloaded manifest (with resolved source) into the skill's
37
+ * target directory.
38
+ */
39
+ export async function writeDownloadedManifest(skillTargetDir, manifest) {
40
+ await writeJson(path.join(skillTargetDir, "skill.json"), manifest);
41
+ }
package/dist/fs.d.ts CHANGED
@@ -1,4 +1,14 @@
1
1
  import type { CreateSymlinkResult } from "./types.js";
2
+ /** Optional safety constraints for `createSymlink`. */
3
+ export interface CreateSymlinkOptions {
4
+ /**
5
+ * When provided, the resolved symlink target MUST be inside this directory.
6
+ * Anything outside (including paths that escape via `..`) is rejected with
7
+ * a `ValidationError`. Use this to confine adapter symlinks to the
8
+ * managed skills store.
9
+ */
10
+ allowedRoot?: string | undefined;
11
+ }
2
12
  /**
3
13
  * Checks whether a file or directory exists.
4
14
  *
@@ -58,11 +68,21 @@ export declare function copyPath(sourcePath: string, targetPath: string): Promis
58
68
  /**
59
69
  * Creates a relative symlink and reports whether the caller should fall back to copy mode.
60
70
  *
71
+ * When `options.allowedRoot` is set, the resolved target MUST be inside it; targets
72
+ * outside the root raise `ValidationError("SYMLINK_TARGET_UNSAFE")` and no link is
73
+ * written. This prevents a tampered lockfile from pointing adapter symlinks at
74
+ * arbitrary filesystem locations.
75
+ *
61
76
  * @param targetPath - Absolute path the link should point to.
62
77
  * @param linkPath - Absolute path of the symlink to create.
78
+ * @param options - Optional safety constraints.
63
79
  * @returns Symlink creation result.
64
80
  */
65
- export declare function createSymlink(targetPath: string, linkPath: string): Promise<CreateSymlinkResult>;
81
+ export declare function createSymlink(targetPath: string, linkPath: string, options?: CreateSymlinkOptions): Promise<CreateSymlinkResult>;
82
+ /**
83
+ * Returns `true` when `candidate` resolves to a path inside `root` (or equals `root`).
84
+ */
85
+ export declare function isPathInside(candidate: string, root: string): boolean;
66
86
  /**
67
87
  * Removes a symlink without following it.
68
88
  *
package/dist/fs.js CHANGED
@@ -103,12 +103,24 @@ export async function copyPath(sourcePath, targetPath) {
103
103
  /**
104
104
  * Creates a relative symlink and reports whether the caller should fall back to copy mode.
105
105
  *
106
+ * When `options.allowedRoot` is set, the resolved target MUST be inside it; targets
107
+ * outside the root raise `ValidationError("SYMLINK_TARGET_UNSAFE")` and no link is
108
+ * written. This prevents a tampered lockfile from pointing adapter symlinks at
109
+ * arbitrary filesystem locations.
110
+ *
106
111
  * @param targetPath - Absolute path the link should point to.
107
112
  * @param linkPath - Absolute path of the symlink to create.
113
+ * @param options - Optional safety constraints.
108
114
  * @returns Symlink creation result.
109
115
  */
110
- export async function createSymlink(targetPath, linkPath) {
116
+ export async function createSymlink(targetPath, linkPath, options = {}) {
111
117
  const absoluteTarget = path.resolve(targetPath);
118
+ if (options.allowedRoot) {
119
+ const allowedRoot = path.resolve(options.allowedRoot);
120
+ if (!isPathInside(absoluteTarget, allowedRoot)) {
121
+ throw new ValidationError(`Refusing to symlink outside the managed root: target=${absoluteTarget} root=${allowedRoot}`, "SYMLINK_TARGET_UNSAFE");
122
+ }
123
+ }
112
124
  const relativeTarget = path.relative(path.dirname(linkPath), absoluteTarget) || ".";
113
125
  await ensureDir(path.dirname(linkPath));
114
126
  await removePath(linkPath);
@@ -135,6 +147,21 @@ export async function createSymlink(targetPath, linkPath) {
135
147
  throw error;
136
148
  }
137
149
  }
150
+ /**
151
+ * Returns `true` when `candidate` resolves to a path inside `root` (or equals `root`).
152
+ */
153
+ export function isPathInside(candidate, root) {
154
+ const candidateAbs = path.resolve(candidate);
155
+ const rootAbs = path.resolve(root);
156
+ if (candidateAbs === rootAbs) {
157
+ return true;
158
+ }
159
+ const rel = path.relative(rootAbs, candidateAbs);
160
+ if (!rel || rel.startsWith("..") || path.isAbsolute(rel)) {
161
+ return false;
162
+ }
163
+ return true;
164
+ }
138
165
  /**
139
166
  * Removes a symlink without following it.
140
167
  *
@@ -184,14 +211,14 @@ export async function readSymlink(linkPath) {
184
211
  */
185
212
  export function assertSafeRelativePath(relativePath) {
186
213
  if (!relativePath || relativePath.includes("\0")) {
187
- throw new ValidationError(`Caminho invalido: "${relativePath}"`);
214
+ throw new ValidationError(`Invalid path: "${relativePath}"`);
188
215
  }
189
216
  const normalized = path.posix.normalize(relativePath);
190
217
  if (normalized.startsWith("../") ||
191
218
  normalized === ".." ||
192
219
  path.isAbsolute(relativePath) ||
193
220
  normalized.startsWith("/")) {
194
- throw new ValidationError(`Caminho inseguro detectado: "${relativePath}"`);
221
+ throw new ValidationError(`Unsafe path detected: "${relativePath}"`);
195
222
  }
196
223
  return normalized;
197
224
  }
package/dist/http.d.ts CHANGED
@@ -1,17 +1,38 @@
1
1
  /**
2
2
  * HTTP fetch utilities with GitHub API support.
3
3
  *
4
- * Automatically attaches GitHub headers and, when `GITHUB_TOKEN` is set in
5
- * the environment, an `Authorization: Bearer` header to raise the API rate
6
- * limit from 60 to 5,000 requests per hour.
4
+ * Automatically attaches a default 30-second timeout, a `User-Agent`, and a
5
+ * GitHub `Accept` header. When `GITHUB_TOKEN` is set in the environment, an
6
+ * `Authorization: Bearer` header is attached **only for GitHub hosts** so the
7
+ * token does not leak to third-party `--catalog-url` targets.
8
+ *
9
+ * HTTP errors raise typed `HttpError` instances with codes that distinguish
10
+ * timeouts, rate limits, auth failures, and server errors.
11
+ */
12
+ /**
13
+ * Overrides the default HTTP timeout (in milliseconds) used when callers do
14
+ * not pass their own `init.signal`. Primarily for tests; production callers
15
+ * should rely on the default.
16
+ *
17
+ * @param ms - Positive integer in milliseconds.
18
+ */
19
+ export declare function setDefaultHttpTimeoutMs(ms: number): void;
20
+ /**
21
+ * Returns the current default HTTP timeout in milliseconds.
22
+ */
23
+ export declare function getDefaultHttpTimeoutMs(): number;
24
+ /**
25
+ * Returns `true` when the URL targets a GitHub-owned host (api or raw mirrors,
26
+ * including any `*.githubusercontent.com` subdomain).
7
27
  */
28
+ export declare function isGitHubHost(url: string): boolean;
8
29
  /**
9
30
  * Fetches a JSON document and parses it with default Skillex headers.
10
31
  *
11
32
  * @param url - Target URL.
12
33
  * @param init - Fetch init overrides.
13
34
  * @returns Parsed JSON payload.
14
- * @throws {Error} With an actionable message on HTTP errors.
35
+ * @throws {HttpError} On non-2xx responses or timeouts.
15
36
  */
16
37
  export declare function fetchJson<T>(url: string, init?: RequestInit): Promise<T>;
17
38
  /**
@@ -20,7 +41,7 @@ export declare function fetchJson<T>(url: string, init?: RequestInit): Promise<T
20
41
  * @param url - Target URL.
21
42
  * @param init - Fetch init overrides.
22
43
  * @returns Response text body.
23
- * @throws {Error} With an actionable message on HTTP errors.
44
+ * @throws {HttpError} On non-2xx responses or timeouts.
24
45
  */
25
46
  export declare function fetchText(url: string, init?: RequestInit): Promise<string>;
26
47
  /**
@@ -29,7 +50,7 @@ export declare function fetchText(url: string, init?: RequestInit): Promise<stri
29
50
  * @param url - Target URL.
30
51
  * @param init - Fetch init overrides.
31
52
  * @returns Response text body or `null` for HTTP 404.
32
- * @throws {Error} With an actionable message on non-404 HTTP errors.
53
+ * @throws {HttpError} On non-404 non-2xx responses or timeouts.
33
54
  */
34
55
  export declare function fetchOptionalText(url: string, init?: RequestInit): Promise<string | null>;
35
56
  /**
@@ -38,6 +59,6 @@ export declare function fetchOptionalText(url: string, init?: RequestInit): Prom
38
59
  * @param url - Target URL.
39
60
  * @param init - Fetch init overrides.
40
61
  * @returns Parsed JSON payload or `null` for HTTP 404.
41
- * @throws {Error} With an actionable message on non-404 HTTP errors.
62
+ * @throws {HttpError} On non-404 non-2xx responses or timeouts.
42
63
  */
43
64
  export declare function fetchOptionalJson<T>(url: string, init?: RequestInit): Promise<T | null>;