|
|
|
@@ -471,20 +471,20 @@ function parseFrontmatterMarkdown(raw: string): { frontmatter: Record<string, un
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function fetchText(url: string) {
|
|
|
|
|
const response = await ghFetch(url);
|
|
|
|
|
async function fetchText(url: string, authToken?: string) {
|
|
|
|
|
const response = await ghFetch(url, undefined, authToken);
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
throw unprocessable(`Failed to fetch ${url}: ${response.status}`);
|
|
|
|
|
}
|
|
|
|
|
return response.text();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function fetchJson<T>(url: string): Promise<T> {
|
|
|
|
|
async function fetchJson<T>(url: string, authToken?: string): Promise<T> {
|
|
|
|
|
const response = await ghFetch(url, {
|
|
|
|
|
headers: {
|
|
|
|
|
accept: "application/vnd.github+json",
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
}, authToken);
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
throw unprocessable(`Failed to fetch ${url}: ${response.status}`);
|
|
|
|
|
}
|
|
|
|
@@ -492,16 +492,18 @@ async function fetchJson<T>(url: string): Promise<T> {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async function resolveGitHubDefaultBranch(owner: string, repo: string, apiBase: string) {
|
|
|
|
|
async function resolveGitHubDefaultBranch(owner: string, repo: string, apiBase: string, authToken?: string) {
|
|
|
|
|
const response = await fetchJson<{ default_branch?: string }>(
|
|
|
|
|
`${apiBase}/repos/${owner}/${repo}`,
|
|
|
|
|
authToken,
|
|
|
|
|
);
|
|
|
|
|
return asString(response.default_branch) ?? "main";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function resolveGitHubCommitSha(owner: string, repo: string, ref: string, apiBase: string) {
|
|
|
|
|
async function resolveGitHubCommitSha(owner: string, repo: string, ref: string, apiBase: string, authToken?: string) {
|
|
|
|
|
const response = await fetchJson<{ sha?: string }>(
|
|
|
|
|
`${apiBase}/repos/${owner}/${repo}/commits/${encodeURIComponent(ref)}`,
|
|
|
|
|
authToken,
|
|
|
|
|
);
|
|
|
|
|
const sha = asString(response.sha);
|
|
|
|
|
if (!sha) {
|
|
|
|
@@ -538,7 +540,7 @@ function parseGitHubSourceUrl(rawUrl: string) {
|
|
|
|
|
return { hostname: url.hostname, owner, repo, ref, basePath, filePath, explicitRef };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function resolveGitHubPinnedRef(parsed: ReturnType<typeof parseGitHubSourceUrl>) {
|
|
|
|
|
async function resolveGitHubPinnedRef(parsed: ReturnType<typeof parseGitHubSourceUrl>, authToken?: string) {
|
|
|
|
|
const apiBase = gitHubApiBase(parsed.hostname);
|
|
|
|
|
if (/^[0-9a-f]{40}$/i.test(parsed.ref.trim())) {
|
|
|
|
|
return {
|
|
|
|
@@ -549,8 +551,8 @@ async function resolveGitHubPinnedRef(parsed: ReturnType<typeof parseGitHubSourc
|
|
|
|
|
|
|
|
|
|
const trackingRef = parsed.explicitRef
|
|
|
|
|
? parsed.ref
|
|
|
|
|
: await resolveGitHubDefaultBranch(parsed.owner, parsed.repo, apiBase);
|
|
|
|
|
const pinnedRef = await resolveGitHubCommitSha(parsed.owner, parsed.repo, trackingRef, apiBase);
|
|
|
|
|
: await resolveGitHubDefaultBranch(parsed.owner, parsed.repo, apiBase, authToken);
|
|
|
|
|
const pinnedRef = await resolveGitHubCommitSha(parsed.owner, parsed.repo, trackingRef, apiBase, authToken);
|
|
|
|
|
return { pinnedRef, trackingRef };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
@@ -981,6 +983,7 @@ async function readUrlSkillImports(
|
|
|
|
|
companyId: string,
|
|
|
|
|
sourceUrl: string,
|
|
|
|
|
requestedSkillSlug: string | null = null,
|
|
|
|
|
authToken?: string,
|
|
|
|
|
): Promise<{ skills: ImportedSkill[]; warnings: string[] }> {
|
|
|
|
|
const url = sourceUrl.trim();
|
|
|
|
|
const warnings: string[] = [];
|
|
|
|
@@ -995,10 +998,11 @@ async function readUrlSkillImports(
|
|
|
|
|
if (looksLikeRepoUrl) {
|
|
|
|
|
const parsed = parseGitHubSourceUrl(url);
|
|
|
|
|
const apiBase = gitHubApiBase(parsed.hostname);
|
|
|
|
|
const { pinnedRef, trackingRef } = await resolveGitHubPinnedRef(parsed);
|
|
|
|
|
const { pinnedRef, trackingRef } = await resolveGitHubPinnedRef(parsed, authToken);
|
|
|
|
|
let ref = pinnedRef;
|
|
|
|
|
const tree = await fetchJson<{ tree?: Array<{ path: string; type: string }> }>(
|
|
|
|
|
`${apiBase}/repos/${parsed.owner}/${parsed.repo}/git/trees/${ref}?recursive=1`,
|
|
|
|
|
authToken,
|
|
|
|
|
).catch(() => {
|
|
|
|
|
throw unprocessable(`Failed to read GitHub tree for ${url}`);
|
|
|
|
|
});
|
|
|
|
@@ -1025,7 +1029,7 @@ async function readUrlSkillImports(
|
|
|
|
|
const skills: ImportedSkill[] = [];
|
|
|
|
|
for (const relativeSkillPath of skillPaths) {
|
|
|
|
|
const repoSkillPath = basePrefix ? `${basePrefix}${relativeSkillPath}` : relativeSkillPath;
|
|
|
|
|
const markdown = await fetchText(resolveRawGitHubUrl(parsed.hostname, parsed.owner, parsed.repo, ref, repoSkillPath));
|
|
|
|
|
const markdown = await fetchText(resolveRawGitHubUrl(parsed.hostname, parsed.owner, parsed.repo, ref, repoSkillPath), authToken);
|
|
|
|
|
const parsedMarkdown = parseFrontmatterMarkdown(markdown);
|
|
|
|
|
const skillDir = path.posix.dirname(relativeSkillPath);
|
|
|
|
|
const slug = deriveImportedSkillSlug(parsedMarkdown.frontmatter, path.posix.basename(skillDir));
|
|
|
|
@@ -1087,7 +1091,7 @@ async function readUrlSkillImports(
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (url.startsWith("http://") || url.startsWith("https://")) {
|
|
|
|
|
const markdown = await fetchText(url);
|
|
|
|
|
const markdown = await fetchText(url, authToken);
|
|
|
|
|
const parsedMarkdown = parseFrontmatterMarkdown(markdown);
|
|
|
|
|
const urlObj = new URL(url);
|
|
|
|
|
const fileName = path.posix.basename(urlObj.pathname);
|
|
|
|
@@ -1459,6 +1463,22 @@ export function companySkillService(db: Db) {
|
|
|
|
|
const projects = projectService(db);
|
|
|
|
|
const secretsSvc = secretService(db);
|
|
|
|
|
|
|
|
|
|
/** Resolve the GitHub auth token from a skill's metadata, if stored. */
|
|
|
|
|
async function resolveSkillAuthToken(
|
|
|
|
|
companyId: string,
|
|
|
|
|
skill: { metadata: Record<string, unknown> | null },
|
|
|
|
|
): Promise<string | undefined> {
|
|
|
|
|
const meta = skill.metadata;
|
|
|
|
|
if (!meta) return undefined;
|
|
|
|
|
const secretId = typeof meta.sourceAuthSecretId === "string" ? meta.sourceAuthSecretId.trim() : "";
|
|
|
|
|
if (!secretId) return undefined;
|
|
|
|
|
try {
|
|
|
|
|
return await secretsSvc.resolveSecretValue(companyId, secretId, "latest");
|
|
|
|
|
} catch {
|
|
|
|
|
return undefined;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function ensureBundledSkills(companyId: string) {
|
|
|
|
|
for (const skillsRoot of resolveBundledSkillsRoot()) {
|
|
|
|
|
const stats = await fs.stat(skillsRoot).catch(() => null);
|
|
|
|
@@ -1656,7 +1676,8 @@ export function companySkillService(db: Db) {
|
|
|
|
|
|
|
|
|
|
const hostname = asString(metadata.hostname) || "github.com";
|
|
|
|
|
const apiBase = gitHubApiBase(hostname);
|
|
|
|
|
const latestRef = await resolveGitHubCommitSha(owner, repo, trackingRef, apiBase);
|
|
|
|
|
const authToken = await resolveSkillAuthToken(companyId, skill);
|
|
|
|
|
const latestRef = await resolveGitHubCommitSha(owner, repo, trackingRef, apiBase, authToken);
|
|
|
|
|
return {
|
|
|
|
|
supported: true,
|
|
|
|
|
reason: null,
|
|
|
|
@@ -1700,8 +1721,9 @@ export function companySkillService(db: Db) {
|
|
|
|
|
if (!owner || !repo) {
|
|
|
|
|
throw unprocessable("Skill source metadata is incomplete.");
|
|
|
|
|
}
|
|
|
|
|
const authToken = await resolveSkillAuthToken(companyId, skill);
|
|
|
|
|
const repoPath = normalizePortablePath(path.posix.join(repoSkillDir, normalizedPath));
|
|
|
|
|
content = await fetchText(resolveRawGitHubUrl(hostname, owner, repo, ref, repoPath));
|
|
|
|
|
content = await fetchText(resolveRawGitHubUrl(hostname, owner, repo, ref, repoPath), authToken);
|
|
|
|
|
} else if (skill.sourceType === "url") {
|
|
|
|
|
if (normalizedPath !== "SKILL.md") {
|
|
|
|
|
throw notFound("This skill source only exposes SKILL.md");
|
|
|
|
@@ -1818,7 +1840,8 @@ export function companySkillService(db: Db) {
|
|
|
|
|
throw unprocessable("Skill source locator is missing.");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const result = await readUrlSkillImports(companyId, skill.sourceLocator, skill.slug);
|
|
|
|
|
const authToken = await resolveSkillAuthToken(companyId, skill);
|
|
|
|
|
const result = await readUrlSkillImports(companyId, skill.sourceLocator, skill.slug, authToken);
|
|
|
|
|
const matching = result.skills.find((entry) => entry.key === skill.key) ?? result.skills[0] ?? null;
|
|
|
|
|
if (!matching) {
|
|
|
|
|
throw unprocessable(`Skill ${skill.key} could not be re-imported from its source.`);
|
|
|
|
@@ -2230,6 +2253,10 @@ export function companySkillService(db: Db) {
|
|
|
|
|
const metadata = {
|
|
|
|
|
...(skill.metadata ?? {}),
|
|
|
|
|
skillKey: skill.key,
|
|
|
|
|
// Preserve auth secret reference across re-imports/updates
|
|
|
|
|
...(existing?.metadata && typeof (existing.metadata as Record<string, unknown>).sourceAuthSecretId === "string"
|
|
|
|
|
? { sourceAuthSecretId: (existing.metadata as Record<string, unknown>).sourceAuthSecretId }
|
|
|
|
|
: {}),
|
|
|
|
|
};
|
|
|
|
|
const values = {
|
|
|
|
|
companyId,
|
|
|
|
@@ -2265,7 +2292,7 @@ export function companySkillService(db: Db) {
|
|
|
|
|
return out;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function importFromSource(companyId: string, source: string): Promise<CompanySkillImportResult> {
|
|
|
|
|
async function importFromSource(companyId: string, source: string, authToken?: string): Promise<CompanySkillImportResult> {
|
|
|
|
|
await ensureSkillInventoryCurrent(companyId);
|
|
|
|
|
const parsed = parseSkillImportSourceInput(source);
|
|
|
|
|
const local = !/^https?:\/\//i.test(parsed.resolvedSource);
|
|
|
|
@@ -2275,7 +2302,7 @@ export function companySkillService(db: Db) {
|
|
|
|
|
.filter((skill) => !parsed.requestedSkillSlug || skill.slug === parsed.requestedSkillSlug),
|
|
|
|
|
warnings: parsed.warnings,
|
|
|
|
|
}
|
|
|
|
|
: await readUrlSkillImports(companyId, parsed.resolvedSource, parsed.requestedSkillSlug)
|
|
|
|
|
: await readUrlSkillImports(companyId, parsed.resolvedSource, parsed.requestedSkillSlug, authToken)
|
|
|
|
|
.then((result) => ({
|
|
|
|
|
skills: result.skills,
|
|
|
|
|
warnings: [...parsed.warnings, ...result.warnings],
|
|
|
|
@@ -2302,6 +2329,35 @@ export function companySkillService(db: Db) {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
const imported = await upsertImportedSkills(companyId, filteredSkills);
|
|
|
|
|
|
|
|
|
|
// Store the auth token as an encrypted company secret and link to imported skills
|
|
|
|
|
if (authToken && imported.length > 0) {
|
|
|
|
|
for (const skill of imported) {
|
|
|
|
|
const secretName = `skill-pat:${skill.id}`;
|
|
|
|
|
let secretId: string;
|
|
|
|
|
const existing = await secretsSvc.getByName(companyId, secretName);
|
|
|
|
|
if (existing) {
|
|
|
|
|
await secretsSvc.rotate(existing.id, { value: authToken });
|
|
|
|
|
secretId = existing.id;
|
|
|
|
|
} else {
|
|
|
|
|
const created = await secretsSvc.create(companyId, {
|
|
|
|
|
name: secretName,
|
|
|
|
|
provider: "local_encrypted",
|
|
|
|
|
value: authToken,
|
|
|
|
|
description: `GitHub PAT for skill ${skill.slug}`,
|
|
|
|
|
});
|
|
|
|
|
secretId = created.id;
|
|
|
|
|
}
|
|
|
|
|
// Store the secret ID in skill metadata
|
|
|
|
|
const meta = (skill.metadata ?? {}) as Record<string, unknown>;
|
|
|
|
|
meta.sourceAuthSecretId = secretId;
|
|
|
|
|
await db
|
|
|
|
|
.update(companySkills)
|
|
|
|
|
.set({ metadata: meta, updatedAt: new Date() })
|
|
|
|
|
.where(eq(companySkills.id, skill.id));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return { imported, warnings };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
@@ -2344,6 +2400,68 @@ export function companySkillService(db: Db) {
|
|
|
|
|
return skill;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function updateSkillAuth(
|
|
|
|
|
companyId: string,
|
|
|
|
|
skillId: string,
|
|
|
|
|
authToken: string | null,
|
|
|
|
|
): Promise<CompanySkill | null> {
|
|
|
|
|
const skill = await getById(skillId);
|
|
|
|
|
if (!skill || skill.companyId !== companyId) return null;
|
|
|
|
|
|
|
|
|
|
const meta = (skill.metadata ?? {}) as Record<string, unknown>;
|
|
|
|
|
const existingSecretId = typeof meta.sourceAuthSecretId === "string" ? meta.sourceAuthSecretId : null;
|
|
|
|
|
|
|
|
|
|
if (authToken) {
|
|
|
|
|
// Set or update the PAT
|
|
|
|
|
const secretName = `skill-pat:${skill.id}`;
|
|
|
|
|
let secretId: string;
|
|
|
|
|
// Check metadata reference first, then fall back to name lookup
|
|
|
|
|
// (metadata ref may have been lost during a skill update/re-import)
|
|
|
|
|
const existingSecret = existingSecretId
|
|
|
|
|
? await secretsSvc.getById(existingSecretId)
|
|
|
|
|
: await secretsSvc.getByName(companyId, secretName);
|
|
|
|
|
if (existingSecret) {
|
|
|
|
|
await secretsSvc.rotate(existingSecret.id, { value: authToken });
|
|
|
|
|
secretId = existingSecret.id;
|
|
|
|
|
} else {
|
|
|
|
|
const created = await secretsSvc.create(companyId, {
|
|
|
|
|
name: secretName,
|
|
|
|
|
provider: "local_encrypted",
|
|
|
|
|
value: authToken,
|
|
|
|
|
description: `GitHub PAT for skill ${skill.slug}`,
|
|
|
|
|
});
|
|
|
|
|
secretId = created.id;
|
|
|
|
|
}
|
|
|
|
|
meta.sourceAuthSecretId = secretId;
|
|
|
|
|
} else {
|
|
|
|
|
// Clear the PAT
|
|
|
|
|
delete meta.sourceAuthSecretId;
|
|
|
|
|
// Note: we don't delete the secret itself — it may be referenced in audit logs
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const [updated] = await db
|
|
|
|
|
.update(companySkills)
|
|
|
|
|
.set({ metadata: meta, updatedAt: new Date() })
|
|
|
|
|
.where(and(eq(companySkills.id, skillId), eq(companySkills.companyId, companyId)))
|
|
|
|
|
.returning();
|
|
|
|
|
return updated ? toCompanySkill(updated) : null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function deleteBySource(companyId: string, sourceLocator: string): Promise<CompanySkill[]> {
|
|
|
|
|
const rows = await db
|
|
|
|
|
.select()
|
|
|
|
|
.from(companySkills)
|
|
|
|
|
.where(and(eq(companySkills.companyId, companyId), eq(companySkills.sourceLocator, sourceLocator)));
|
|
|
|
|
if (rows.length === 0) return [];
|
|
|
|
|
|
|
|
|
|
const deleted: CompanySkill[] = [];
|
|
|
|
|
for (const row of rows) {
|
|
|
|
|
const result = await deleteSkill(companyId, row.id);
|
|
|
|
|
if (result) deleted.push(result);
|
|
|
|
|
}
|
|
|
|
|
return deleted;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
list,
|
|
|
|
|
listFull,
|
|
|
|
@@ -2359,7 +2477,9 @@ export function companySkillService(db: Db) {
|
|
|
|
|
updateFile,
|
|
|
|
|
createLocalSkill,
|
|
|
|
|
deleteSkill,
|
|
|
|
|
deleteBySource,
|
|
|
|
|
importFromSource,
|
|
|
|
|
updateSkillAuth,
|
|
|
|
|
scanProjectWorkspaces,
|
|
|
|
|
importPackageFiles,
|
|
|
|
|
installUpdate,
|
|
|
|
|