diff --git a/packages/opencode/src/altimate/workspace/skill-publish.ts b/packages/opencode/src/altimate/workspace/skill-publish.ts new file mode 100644 index 000000000..1856ec279 --- /dev/null +++ b/packages/opencode/src/altimate/workspace/skill-publish.ts @@ -0,0 +1,358 @@ +// altimate_change - new file +// +// Publishing a locally-authored skill to the linked workspace — the upload half +// of `skill-sync.ts`, which only ever pulls. +// +// Shaped so agents and commands can ride the same path later. A workspace skill +// is a NAMED BUNDLE OF FILES, and nothing below is skill-specific except the +// endpoint it posts to and the `SKILL.md` it reads a name out of. `collectBundle` +// and the binary guard take a directory, not a skill. +// +// Three rules this module exists to enforce, each of which is a bug if skipped: +// +// 1. Refuse non-UTF-8 files, naming the path. The wire format is +// `{path, content}` with content as a STRING — the server does +// `content.encode("utf-8")` on the way in and hands back a decoded string on +// the way out. A bundle carrying a PNG therefore cannot round-trip: the +// declared byte size stops matching after the re-encode and `skill-sync` +// skips the whole skill, logging a warning nobody sees. Caught here it is +// one clear local error; caught there it is a skill that silently vanishes +// from every OTHER machine, days later, with nothing tying the symptom to +// the cause. +// +// 2. Never publish from the managed snapshot. `.altimate-code/skill/_workspace` +// holds skills the workspace sent us, and it sits under the same +// `{skill,skills}/**​/SKILL.md` glob as the user's own — deliberately, since +// that is how they load. A publish that walked "every skill in this project" +// would upload the workspace's own skills back to it. +// +// 3. Remember the server's id after a first publish, so publishing again +// UPDATES rather than creating a second bundle. Names are unique per creator +// server-side, so a blind re-create answers 409 rather than duplicating — +// but that turns an ordinary second publish into an error the user has to +// interpret. +import fs from "fs/promises" +import path from "path" +import { realpathSync } from "fs" +import { Log } from "@/altimate/util/log" +import { Global } from "@/global" +import { Filesystem } from "@/util/filesystem" +import { AltimateApi } from "@/altimate/api/client" +import { ConflictError, NotFoundError, altimateRequest } from "./api-client" + +const log = Log.create({ service: "altimate-workspace-skill-publish" }) + +const SKILLS_BASE = "/skills" +/** Must stay in step with `skill-sync.ts`. Duplicated rather than exported from + * there because importing it would pull the whole sync module — and its + * process-global store — into every caller that only wants to publish. */ +const MANAGED_DIR = path.join(".altimate-code", "skill", "_workspace") + +/** Mirrors the server's own ceilings so an oversized bundle fails locally, with a + * usable message, instead of after a long upload. */ +const MAX_BUNDLE_BYTES = 10 * 1024 * 1024 +const MAX_BUNDLE_FILES = 200 + +export interface BundleFile { + path: string + content: string +} + +export class BinaryFileError extends Error { + constructor(readonly filePath: string) { + super( + `"${filePath}" is not UTF-8 text. Workspace skill bundles are transported as text, ` + + `so binary files cannot be published — remove it, or keep it outside the skill.`, + ) + this.name = "BinaryFileError" + } +} + +export class ManagedSkillError extends Error { + constructor(readonly filePath: string) { + super( + `"${filePath}" is a skill this workspace sent to you, not one you authored. ` + + `Publishing it would send the workspace's own skill back to it.`, + ) + this.name = "ManagedSkillError" + } +} + +export class BundleTooLargeError extends Error {} + +/** The workspace already has a skill of this name owned by this user, and this + * machine has no id for it — so it was published from somewhere else. + * + * A distinct type rather than re-raising the API's `ConflictError`: that one + * carries a structured server detail, and constructing a fake one to hold a + * client-authored sentence would misrepresent the envelope. */ +export class SkillNameConflictError extends Error { + constructor(readonly skillName: string) { + super( + `You already have a skill named "${skillName}" in this workspace. It was published ` + + `from somewhere else, so this machine cannot update it — rename this one, or edit ` + + `it in the workspace.`, + ) + this.name = "SkillNameConflictError" + } +} + +export interface PublishReport { + action: "created" | "updated" + publicId: string + name: string + files: number + bytes: number +} + +/** Read one directory into a bundle, refusing anything that cannot survive the + * transport. + * + * Strict decoding is the whole point: `TextDecoder` with `fatal: true` throws on + * an invalid sequence, where the default silently substitutes U+FFFD and would + * hand us a "valid" string that reassembles into a different file. */ +export async function collectBundle(dir: string): Promise { + const root = path.resolve(dir) + const files: BundleFile[] = [] + let bytes = 0 + + const walk = async (current: string): Promise => { + const entries = await fs.readdir(current, { withFileTypes: true }) + for (const entry of entries) { + const full = path.join(current, entry.name) + if (entry.isDirectory()) { + await walk(full) + continue + } + if (!entry.isFile()) continue + const relative = path.relative(root, full).split(path.sep).join("/") + // Size BEFORE read. `readFile` pulls the whole file into memory, so + // checking the running total afterwards let a single oversized file + // through the very guard meant to stop it — the limit was enforced only + // once the damage was done. The cumulative check below stays as the + // answer for many small files, and as a backstop if the file grew + // between this stat and the read. + const stat = await fs.stat(full) + if (bytes + stat.size > MAX_BUNDLE_BYTES) + throw new BundleTooLargeError(`This skill is larger than ${MAX_BUNDLE_BYTES / (1024 * 1024)}MB.`) + const raw = await fs.readFile(full) + let content: string + try { + content = new TextDecoder("utf-8", { fatal: true }).decode(raw) + } catch { + throw new BinaryFileError(relative) + } + bytes += raw.byteLength + files.push({ path: relative, content }) + if (files.length > MAX_BUNDLE_FILES) + throw new BundleTooLargeError(`This skill has more than ${MAX_BUNDLE_FILES} files.`) + if (bytes > MAX_BUNDLE_BYTES) + throw new BundleTooLargeError(`This skill is larger than ${MAX_BUNDLE_BYTES / (1024 * 1024)}MB.`) + } + } + + await walk(root) + files.sort((a, b) => a.path.localeCompare(b.path)) + return files +} + +/** True when this path lives inside the workspace-owned snapshot. */ +export function isManagedSkill(projectDirectory: string, skillDirectory: string): boolean { + // `path.resolve` is lexical: it normalises `..` and makes the path absolute, + // but it does not follow links. A skill directory that IS a symlink into the + // workspace-owned snapshot therefore resolved to its own link path, missed + // this check, and `collectBundle` then walked through the link and published + // the workspace's own skills back to it. Compare real paths where they exist. + const real = (p: string): string => { + try { + return realpathSync(p) + } catch { + // Absent or unreadable: fall back to the lexical form. A path that does + // not exist cannot be a link into the snapshot, and `collectBundle` will + // fail on it in a moment anyway. + return path.resolve(p) + } + } + const managed = real(path.resolve(projectDirectory, MANAGED_DIR)) + const candidate = real(skillDirectory) + return candidate === managed || candidate.startsWith(managed + path.sep) +} + +// --------------------------------------------------------------------------- +// Published-id bookkeeping +// +// A local file rather than `SKILL.md` frontmatter, deliberately. Frontmatter is +// committed, so the id would travel with the skill: a colleague cloning the repo +// and publishing would UPDATE the original author's bundle rather than create +// their own. It would also put a server identifier into a file the user edits by +// hand, and show up in every diff. The id is a fact about "this machine published +// this skill to this workspace", which is exactly the scope of local state. +// --------------------------------------------------------------------------- + +interface PublishedRecord { + publicId: string + tenant: string + apiUrl: string +} + +function ledgerPath(): string { + return path.join(Global.Path.state, "altimate-published-skills.json") +} + +/** Shape check, not a cast. The file comes off disk and could be anything — an + * older layout, hand-edited, half-written. A malformed row must be dropped rather + * than trusted into a PATCH against a garbage id. */ +function isPublishedRecord(value: unknown): value is PublishedRecord { + if (!value || typeof value !== "object") return false + const r = value as Record + return typeof r.publicId === "string" && typeof r.tenant === "string" && typeof r.apiUrl === "string" +} + +async function readLedger(): Promise> { + try { + const raw = await Filesystem.readText(ledgerPath()) + const parsed = JSON.parse(raw) as unknown + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {} + // Per-entry, so one corrupt row costs its own skill a re-create rather than + // discarding every other skill's id. + const out: Record = {} + for (const [key, value] of Object.entries(parsed as Record)) + if (isPublishedRecord(value)) out[key] = value + return out + } catch { + // Absent or unreadable both mean "nothing known", which costs a create that + // may 409 — recoverable — rather than an update against a guessed id. + return {} + } +} + +/** Keyed on the resolved skill directory AND the account it was published + * under, so a rename of the skill's *name* does not orphan its id, two skills in + * different projects cannot collide, and — the reason the account is in the key + * — publishing one directory to two accounts keeps an id for each. + * + * A bare directory key held one record, so switching accounts overwrote the + * previous account's id: switching back created a second skill and then 409'd on + * the name that was already there, with no way to reach the original. */ +function ledgerKey(skillDir: string, scope: { tenant: string; apiUrl: string }): string { + return `${scope.tenant}|${scope.apiUrl}|${path.resolve(skillDir)}` +} + +/** Serialises ledger writes, the same way `memory-index` serialises its own. + * Two publishes running at once each read, mutate and write the whole file, so + * the later write dropped the earlier one's id — and that skill's next publish + * created again and 409'd on its own name. */ +let ledgerWriteChain: Promise = Promise.resolve() + +async function recordPublished(skillDir: string, record: PublishedRecord): Promise { + const task = ledgerWriteChain.then(async () => { + try { + // Re-read INSIDE the chain: a copy read before the previous write landed + // would carry that write away again when this one persists. + const ledger = await readLedger() + ledger[ledgerKey(skillDir, { tenant: record.tenant, apiUrl: record.apiUrl })] = record + await Filesystem.writeJson(ledgerPath(), ledger) + } catch (err) { + // Best-effort. Losing the id costs a 409 on the next publish, not data. + log.warn("could not record the published skill id", { err: String(err) }) + } + }) + ledgerWriteChain = task.catch(() => {}) + return task +} + +async function knownPublicId(skillDir: string): Promise { + const creds = await AltimateApi.getCredentials().catch(() => null) + if (!creds) return null + const scope = { tenant: creds.altimateInstanceName, apiUrl: creds.altimateUrl } + const ledger = await readLedger() + // Composite key first; fall back to the old directory-only key so ids written + // by an earlier version are not stranded into a needless re-create. + const record = ledger[ledgerKey(skillDir, scope)] ?? ledger[path.resolve(skillDir)] + if (!record) return null + // Still checked, not implied by the key: the fallback lookup above can return + // a legacy row belonging to another account. + if (record.tenant !== scope.tenant || record.apiUrl !== scope.apiUrl) return null + return record.publicId +} + +/** Publish a skill directory to the workspace, creating it or updating the bundle + * already published from this machine. + * + * `privacy` is left unset: the server defaults to `private`. Publishing should + * attach a skill to a workspace, not disclose it to the whole organisation as a + * side effect of a command whose name says nothing about visibility. */ +export async function publishSkill(input: { + projectDirectory: string + skillDirectory: string + name: string + description: string +}): Promise { + if (isManagedSkill(input.projectDirectory, input.skillDirectory)) + throw new ManagedSkillError(input.skillDirectory) + + const files = await collectBundle(input.skillDirectory) + if (files.length === 0) throw new BundleTooLargeError("This skill directory has no files to publish.") + const bytes = files.reduce((n, f) => n + Buffer.byteLength(f.content, "utf8"), 0) + + const existing = await knownPublicId(input.skillDirectory) + if (existing) { + try { + await altimateRequest("PATCH", `/${encodeURIComponent(existing)}`, { + base: SKILLS_BASE, + body: { name: input.name, description: input.description, files }, + allowEmptyBody: true, + }) + return { action: "updated", publicId: existing, name: input.name, files: files.length, bytes } + } catch (err) { + // The skill was deleted in the workspace since we published it. Falling + // through to create is the useful answer; failing would strand the user + // with a local id they cannot see or clear. + // A PATCH that renames onto a name this creator already uses answers 409. + // Without this the raw server envelope reaches the caller — the exact + // thing the typed errors in this module exist to prevent — and only on + // the update path, so the create path looked correct in isolation. + if (err instanceof ConflictError) throw new SkillNameConflictError(input.name) + if (!(err instanceof NotFoundError)) throw err + log.info("published skill no longer exists in the workspace; creating it again", { + publicId: existing, + }) + } + } + + let created: unknown + try { + created = await altimateRequest("POST", "", { + base: SKILLS_BASE, + body: { name: input.name, description: input.description, files }, + }) + } catch (err) { + // Names are unique per creator server-side. Reached when the same skill was + // published from another machine, so this one holds no id for it. + if (err instanceof ConflictError) throw new SkillNameConflictError(input.name) + throw err + } + + const publicId = extractPublicId(created) + if (!publicId) throw new Error("The workspace accepted the skill but did not return an id for it.") + + const creds = await AltimateApi.getCredentials() + await recordPublished(input.skillDirectory, { + publicId, + tenant: creds.altimateInstanceName, + apiUrl: creds.altimateUrl, + }) + return { action: "created", publicId, name: input.name, files: files.length, bytes } +} + +/** Accepts the documented `{public_id}` and a `{skill: {public_id}}` envelope, so + * a compat wrapper on either side does not strand the id — the same tolerance + * `skill-sync` applies to the list and detail shapes. */ +function extractPublicId(payload: unknown): string | null { + if (!payload || typeof payload !== "object") return null + const direct = (payload as { public_id?: unknown }).public_id + if (typeof direct === "string" && direct) return direct + const nested = (payload as { skill?: { public_id?: unknown } }).skill?.public_id + if (typeof nested === "string" && nested) return nested + return null +} diff --git a/packages/opencode/test/altimate/workspace/skill-publish.test.ts b/packages/opencode/test/altimate/workspace/skill-publish.test.ts new file mode 100644 index 000000000..f5f730591 --- /dev/null +++ b/packages/opencode/test/altimate/workspace/skill-publish.test.ts @@ -0,0 +1,335 @@ +// altimate_change - new file +// +// Unit coverage for publishing a locally-authored skill (skill-publish.ts). +// +// House style, matching memory-sync.test.ts: no `mock.module()`. Real files in a +// real sandbox, network stubbed at `globalThis.fetch` so assertions are about the +// requests actually issued — method, path, body — rather than a mock's call log. +import { afterAll, afterEach, beforeEach, describe, expect, test } from "bun:test" +import { + closeSync, + ftruncateSync, + mkdirSync, + mkdtempSync, + openSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs" +import path from "node:path" +import fsp from "node:fs/promises" +import os from "node:os" + +const ORIGINAL_XDG_STATE_HOME = process.env.XDG_STATE_HOME +const SANDBOX = path.join(os.tmpdir(), `altimate-publish-${process.pid}-${Date.now()}`) +mkdirSync(path.join(SANDBOX, "state"), { recursive: true }) +process.env.XDG_STATE_HOME = path.join(SANDBOX, "state") + +afterAll(() => { + if (ORIGINAL_XDG_STATE_HOME === undefined) delete process.env.XDG_STATE_HOME + else process.env.XDG_STATE_HOME = ORIGINAL_XDG_STATE_HOME + try { + rmSync(SANDBOX, { recursive: true, force: true }) + } catch { + /* best effort */ + } +}) + +const { AltimateApi } = await import("../../../src/altimate/api/client") +const { + BinaryFileError, + ManagedSkillError, + SkillNameConflictError, + collectBundle, + isManagedSkill, + publishSkill, +} = await import("../../../src/altimate/workspace/skill-publish") + +type Creds = Awaited> +// Saved and restored. Bun runs every test file in one process, so a stub left in +// place here leaks into sibling suites — which is exactly what happened: 47 +// unrelated workspace tests failed until this was put back. +const originalIsConfigured = AltimateApi.isConfigured +const originalGetCreds = AltimateApi.getCredentials +;(AltimateApi as unknown as { isConfigured: () => Promise }).isConfigured = async () => true +;(AltimateApi as unknown as { getCredentials: () => Promise }).getCredentials = async () => + ({ altimateInstanceName: "acme", altimateUrl: "https://api.example.com", altimateApiKey: "k" }) as Creds + +afterAll(() => { + ;(AltimateApi as unknown as { isConfigured: typeof originalIsConfigured }).isConfigured = originalIsConfigured + ;(AltimateApi as unknown as { getCredentials: typeof originalGetCreds }).getCredentials = originalGetCreds +}) + +const originalFetch = globalThis.fetch +let requests: { method: string; url: string; body: any }[] = [] +/** Per-method status. `POST` 409 exercises the name conflict; `PATCH` 404 the + * published-then-deleted fallback. */ +let statuses: Record = {} + +let project = "" +let skillDir = "" + +beforeEach(() => { + requests = [] + statuses = {} + project = mkdtempSync(path.join(SANDBOX, "proj-")) + skillDir = path.join(project, "skills", "deploy") + mkdirSync(skillDir, { recursive: true }) + writeFileSync(path.join(skillDir, "SKILL.md"), "---\nname: deploy\n---\nrun it\n") + + globalThis.fetch = (async (input: any, init?: any) => { + const url = typeof input === "string" ? input : input.url + const method = (init?.method ?? "GET").toUpperCase() + let body: any = undefined + try { + body = init?.body ? JSON.parse(init.body) : undefined + } catch { + /* non-JSON bodies are not used here */ + } + requests.push({ method, url, body }) + const status = statuses[method] ?? (method === "POST" ? 201 : 200) + if (status >= 400) + return new Response(JSON.stringify({ detail: "nope" }), { + status, + headers: { "content-type": "application/json" }, + }) + return new Response(JSON.stringify({ public_id: "pub-1" }), { + status, + headers: { "content-type": "application/json" }, + }) + }) as typeof fetch +}) + +afterEach(() => { + globalThis.fetch = originalFetch +}) + +const publish = () => + publishSkill({ projectDirectory: project, skillDirectory: skillDir, name: "deploy", description: "d" }) + +describe("collectBundle", () => { + test("refuses a file that is not UTF-8, naming it", async () => { + // The wire format carries content as a string, so a binary file cannot + // round-trip. Caught here it is one local error; uncaught, the upload + // succeeds and the skill is skipped on every OTHER machine's pull. + writeFileSync(path.join(skillDir, "logo.png"), Buffer.from([0xff, 0xd8, 0xff, 0x00, 0x01])) + + const err = await collectBundle(skillDir).catch((e) => e) + + expect(err).toBeInstanceOf(BinaryFileError) + expect(String(err)).toContain("logo.png") + }) + + test("decodes strictly rather than substituting replacement characters", async () => { + // The default TextDecoder would turn an invalid sequence into U+FFFD and hand + // back a "valid" string, publishing a file that differs from the one on disk. + writeFileSync(path.join(skillDir, "notes.md"), Buffer.from([0x68, 0x69, 0xc3, 0x28])) + + await expect(collectBundle(skillDir)).rejects.toBeInstanceOf(BinaryFileError) + }) + + test("walks nested directories and reports posix-style relative paths", async () => { + mkdirSync(path.join(skillDir, "references"), { recursive: true }) + writeFileSync(path.join(skillDir, "references", "api.md"), "docs") + + const files = await collectBundle(skillDir) + + expect(files.map((f) => f.path).sort()).toEqual(["SKILL.md", "references/api.md"]) + }) +}) + +describe("isManagedSkill", () => { + test("recognises the workspace-owned snapshot", () => { + const managed = path.join(project, ".altimate-code", "skill", "_workspace", "theirs") + expect(isManagedSkill(project, managed)).toBe(true) + }) + + test("does not mistake a sibling path with the same prefix", () => { + // `_workspace-notes` starts with the managed path as a string but is not + // inside it — a plain `startsWith` without the separator would refuse it. + const sibling = path.join(project, ".altimate-code", "skill", "_workspace-notes") + expect(isManagedSkill(project, sibling)).toBe(false) + }) + + test("does not flag the user's own skills", () => { + expect(isManagedSkill(project, skillDir)).toBe(false) + }) +}) + +describe("publishSkill", () => { + test("refuses to publish a skill the workspace sent us", async () => { + const managed = path.join(project, ".altimate-code", "skill", "_workspace", "theirs") + mkdirSync(managed, { recursive: true }) + writeFileSync(path.join(managed, "SKILL.md"), "---\nname: theirs\n---\n") + + const err = await publishSkill({ + projectDirectory: project, + skillDirectory: managed, + name: "theirs", + description: "d", + }).catch((e) => e) + + expect(err).toBeInstanceOf(ManagedSkillError) + // And nothing was sent. A refusal that still uploaded would be worse than none. + expect(requests).toHaveLength(0) + }) + + test("creates on the first publish and carries the bundle", async () => { + const report = await publish() + + expect(report.action).toBe("created") + expect(report.publicId).toBe("pub-1") + const post = requests.find((r) => r.method === "POST")! + expect(post.body.name).toBe("deploy") + expect(post.body.files.map((f: any) => f.path)).toEqual(["SKILL.md"]) + // `privacy` is deliberately unset: the server defaults to private, and + // publishing should not disclose a skill org-wide as a side effect. + expect(post.body.privacy).toBeUndefined() + }) + + test("updates in place on the second publish rather than creating a duplicate", async () => { + await publish() + requests = [] + + const report = await publish() + + expect(report.action).toBe("updated") + expect(requests.filter((r) => r.method === "POST")).toHaveLength(0) + const patch = requests.find((r) => r.method === "PATCH")! + expect(patch.url).toContain("pub-1") + }) + + test("reports a name conflict as its own error, not a raw API conflict", async () => { + statuses.POST = 409 + + const err = await publish().catch((e) => e) + + expect(err).toBeInstanceOf(SkillNameConflictError) + expect(String(err)).toContain("published") + }) + + test("re-creates a skill that has been deleted in the workspace since we published it", async () => { + // Otherwise the user is stranded: a local id they cannot see, update or clear. + await publish() + requests = [] + statuses.PATCH = 404 + + const report = await publish() + + expect(report.action).toBe("created") + expect(requests.filter((r) => r.method === "POST")).toHaveLength(1) + }) +}) + +describe("the bundle size guard", () => { + test("refuses an oversized file WITHOUT reading it into memory", async () => { + // The guard checked the running total after `readFile`, so a single huge + // file was fully loaded before being rejected — the limit enforced only + // once the memory had already been spent. Asserting on the rejection alone + // does not test that: the post-read check rejects too, and the mutation + // survived. The property is that `readFile` is never called for the file. + const dir = path.join(SANDBOX, `oversize-${Math.random().toString(36).slice(2)}`) + mkdirSync(dir, { recursive: true }) + writeFileSync(path.join(dir, "SKILL.md"), "---\nname: big\n---\n") + const huge = path.join(dir, "huge.txt") + const fd = openSync(huge, "w") + try { + ftruncateSync(fd, 64 * 1024 * 1024) // sparse: past the limit, cheap on disk + } finally { + closeSync(fd) + } + + const read: string[] = [] + const originalReadFile = fsp.readFile + ;(fsp as unknown as { readFile: unknown }).readFile = ((...args: unknown[]) => { + read.push(String(args[0])) + return (originalReadFile as (...a: unknown[]) => unknown)(...args) + }) as unknown as typeof fsp.readFile + + try { + await expect(collectBundle(dir)).rejects.toThrow(/larger than/i) + } finally { + ;(fsp as unknown as { readFile: unknown }).readFile = originalReadFile + } + expect(read.some((r) => r.endsWith("huge.txt"))).toBe(false) + }) + + test("a symlinked skill directory into the managed snapshot is still managed", async () => { + // `path.resolve` is lexical, so a skill directory that IS a link into the + // workspace-owned snapshot resolved to its own path and passed the check — + // and the bundle walk then followed the link and would have published the + // workspace's own skills back to it. + const proj = mkdtempSync(path.join(SANDBOX, "symproj-")) + const managed = path.join(proj, ".altimate-code", "skill", "_workspace", "pub-a") + mkdirSync(managed, { recursive: true }) + const link = path.join(proj, "looks-local") + symlinkSync(managed, link) + + expect(isManagedSkill(proj, link)).toBe(true) + }) + + test("a rename that collides on the update path is a typed conflict", async () => { + // The create path mapped 409 to SkillNameConflictError; the update path did + // not, so a PATCH that renames onto an existing name surfaced the raw + // server envelope — the exact thing this module's typed errors exist to + // prevent. + await publish() // records the id, so the next call takes the PATCH branch + statuses.PATCH = 409 + + const err = await publish().catch((e) => e) + + expect(err).toBeInstanceOf(SkillNameConflictError) + }) +}) + +describe("the published-id ledger", () => { + test("keeps a separate id per account for the same skill directory", async () => { + // A bare directory key held ONE record, so publishing to a second account + // overwrote the first account's id. Switching back created a second skill + // and 409'd on the name already there, with no way to reach the original. + await publish() // account "acme" -> pub-1 + expect(requests.filter((r) => r.method === "POST")).toHaveLength(1) + + // Switch accounts, publish the same directory. + ;(AltimateApi as unknown as { getCredentials: () => Promise }).getCredentials = async () => + ({ altimateInstanceName: "other", altimateUrl: "https://api.example.com", altimateApiKey: "k" }) as Creds + requests = [] + await publish() // must CREATE for "other", not update acme's id + expect(requests.filter((r) => r.method === "POST")).toHaveLength(1) + + // Back to the first account: its id must still be there, so this UPDATES. + ;(AltimateApi as unknown as { getCredentials: () => Promise }).getCredentials = async () => + ({ altimateInstanceName: "acme", altimateUrl: "https://api.example.com", altimateApiKey: "k" }) as Creds + requests = [] + const report = await publish() + + expect(report.action).toBe("updated") + expect(requests.filter((r) => r.method === "POST")).toHaveLength(0) + }) + + test("concurrent publishes do not drop each other's id", async () => { + // Each publish read, mutated and wrote the whole ledger, so the later write + // carried the earlier one away and that skill re-created on its next run. + const other = path.join(project, "skills", "second") + mkdirSync(other, { recursive: true }) + writeFileSync(path.join(other, "SKILL.md"), "---\nname: second\n---\n") + + await Promise.all([ + publish(), + publishSkill({ projectDirectory: project, skillDirectory: other, name: "second", description: "d" }), + ]) + + // Both ids survived: neither directory creates again. + requests = [] + const a = await publish() + const b = await publishSkill({ + projectDirectory: project, + skillDirectory: other, + name: "second", + description: "d", + }) + expect(a.action).toBe("updated") + expect(b.action).toBe("updated") + expect(requests.filter((r) => r.method === "POST")).toHaveLength(0) + }) +})