Repository Public MIT
1import { afterAll, beforeAll, describe, expect, it } from "vitest";2import { spawn, spawnSync } from "node:child_process";3import { createHash } from "node:crypto";4import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";5import { createServer, type Server } from "node:http";6import { tmpdir } from "node:os";7import { join, resolve } from "node:path";8import { PUBLISHER_SCOPES } from "@skills/registry-core";9import { run, LOCK_FILE, type IO } from "@skills/cli";10import { handleApi } from "../../apps/web/src/server/api";11import { setupTestEnv, tokenFor, type TestEnv } from "../helpers";1213let env: TestEnv;14let server: Server;15let origin: string;16let home: string;17let work: string;18let token: string;19let tokenId: string;20/** Every byte the CLI wrote, across all commands, to prove the token never leaks. */21const transcript: string[] = [];22const EXAMPLES = resolve(__dirname, "../../examples");2324interface Result {25 code: number;26 out: string;27 err: string;28}29async function skills(argv: string[], opts: { stdin?: string; env?: Record<string, string>; fetch?: typeof fetch; cwd?: string; record?: boolean } = {}): Promise<Result> {30 let out = "";31 let err = "";32 const io: Partial<IO> = {33 stdout: (s) => void (out += s),34 stderr: (s) => void (err += s),35 stdin: async () => opts.stdin ?? "",36 isTTY: false,37 prompt: async () => {38 throw new Error("the CLI prompted in a non-interactive test");39 },40 env: { PATH: process.env.PATH, SKILLS_REGISTRY: origin, SKILLS_CREDENTIAL_STORE: `file:${join(home, "store", "credentials.json")}`, ...(opts.env ?? {}) },41 cwd: opts.cwd ?? work,42 home,43 platform: process.platform,44 fetch: opts.fetch,45 };46 const code = await run(argv, io);47 if (opts.record !== false) transcript.push(out, err);48 return { code, out, err };49}50const storedToken = () => JSON.parse(readFileSync(join(home, "store", "credentials.json"), "utf8"))[new URL(origin).host] as string;51const counts = async () => {52 const [r] = await env.sql`SELECT (SELECT count(*)::int FROM artifacts) AS artifacts, (SELECT count(*)::int FROM uploads) AS uploads, (SELECT count(*)::int FROM releases) AS releases`;53 return r as unknown as { artifacts: number; uploads: number; releases: number };54};55const sha = (b: Buffer) => createHash("sha256").update(b).digest("hex");5657beforeAll(async () => {58 env = await setupTestEnv();59 await env.sql`INSERT INTO namespaces (name, owner_user_id, identity_verified) VALUES ('spboucher', ${env.owner.id}, true)`;60 server = createServer(async (req, res) => {61 const chunks: Buffer[] = [];62 for await (const c of req) chunks.push(c as Buffer);63 const headers = new Headers();64 for (const [k, v] of Object.entries(req.headers)) if (typeof v === "string") headers.set(k, v);65 const body = chunks.length ? Buffer.concat(chunks) : undefined;66 const r = await handleApi(new Request(`${origin}${req.url}`, { method: req.method, headers, body: body && req.method !== "GET" && req.method !== "HEAD" ? new Uint8Array(body) : undefined }));67 res.writeHead(r.status, Object.fromEntries(r.headers));68 res.end(Buffer.from(await r.arrayBuffer()));69 });70 await new Promise<void>((r) => server.listen(0, "127.0.0.1", () => r()));71 const addr = server.address() as { port: number };72 origin = `http://127.0.0.1:${addr.port}`;73 process.env.PUBLIC_URL = origin;74 home = mkdtempSync(join(tmpdir(), "skills-cli-home-"));75 work = mkdtempSync(join(tmpdir(), "skills-cli-work-"));76 const t = await tokenFor(env, env.owner, ["research", "spboucher"], [...PUBLISHER_SCOPES, "releases:revoke", "artifacts:deprecate", "tokens:read"]);77 token = t.token;78 tokenId = t.id;79});80afterAll(async () => {81 await new Promise<void>((r) => server.close(() => r()));82 rmSync(home, { recursive: true, force: true });83 rmSync(work, { recursive: true, force: true });84 await env.close();85});8687describe("skills CLI end to end", () => {88 it("prints help and version, and rejects unknown commands", async () => {89 expect((await skills(["--help"])).out).toContain("skills publish".replace("skills ", ""));90 expect((await skills(["--version"])).out.trim()).toMatch(/^\d+\.\d+\.\d+$/);91 expect((await skills(["frobnicate"])).code).toBe(1);92 expect((await skills(["whoami", "--registry", "http://example.org"])).err).toContain("https");93 });9495 it("refuses to act without a credential", async () => {96 const r = await skills(["whoami"]);97 expect(r.code).toBe(3);98 expect(r.err).toContain("skills login");99 });100101 it("logs in from stdin, verifies the token and stores it without echoing it", async () => {102 const bad = await skills(["login", "--token-stdin"], { stdin: "not-a-token\n" });103 expect(bad.code).toBe(3);104 const forged = await skills(["login", "--token-stdin"], { stdin: `skr_aaaaaaaaaa_${"A".repeat(43)}\n` });105 expect(forged.code).toBe(3);106 expect(forged.err).toContain("invalid_token");107 expect(existsSync(join(home, "store", "credentials.json"))).toBe(false);108109 const ok = await skills(["login", "--token-stdin"], { stdin: token + "\n" });110 expect(ok.code).toBe(0);111 expect(ok.out).toContain("owner@example.test");112 expect(ok.out).toContain("research, spboucher");113 expect(storedToken()).toBe(token);114 expect(statSync(join(home, "store", "credentials.json")).mode & 0o077).toBe(0);115116 const who = await skills(["whoami", "--json"]);117 expect(JSON.parse(who.out)).toMatchObject({ source: "file", credential: { kind: "token", namespaces: ["research", "spboucher"] } });118 const status = await skills(["auth", "status"]);119 expect(status.out).toMatch(/Credential: +file /);120 expect(status.out).toMatch(/\d+ days left/);121 });122123 it("prefers SKILLS_TOKEN over the stored credential", async () => {124 const r = await skills(["whoami", "--json"], { env: { SKILLS_TOKEN: `skr_bbbbbbbbbb_${"B".repeat(43)}` }, record: false });125 expect(r.code).toBe(3);126 expect(r.err).toContain("hint");127 });128129 it("scaffolds, validates and packs deterministically", async () => {130 const init = await skills(["init", "my-skill", "--type", "skill", "--name", "@research/my-skill", "--description", "Summarises a document in three bullet points when the user asks for a summary."]);131 expect(init.code).toBe(0);132 expect(readdirSync(join(work, "my-skill")).sort()).toEqual(["CHANGELOG.md", "LICENSE", "README.md", "SKILL.md", "agent-registry.json"]);133 expect((await skills(["init", "my-skill", "--type", "skill", "--name", "@research/my-skill"])).err).toContain("Refusing to overwrite");134 expect((await skills(["init", "x", "--type", "plugin", "--name", "@research/x"])).code).toBe(1);135136 const val = await skills(["validate", "my-skill"]);137 expect(val.code).toBe(0);138 expect(val.out).toContain("Valid: no blocking findings.");139140 // A symlink and ignored files never enter the package.141 mkdirSync(join(work, "my-skill", ".git"));142 writeFileSync(join(work, "my-skill", ".git", "config"), "[core]");143 spawnSync("ln", ["-s", "/etc/hosts", join(work, "my-skill", "hosts-link")]);144 const j = JSON.parse((await skills(["validate", "my-skill", "--json"])).out);145 expect(j.files.map((f: any) => f.path)).not.toContain("hosts-link");146 expect(j.skipped.map((s: any) => s.path)).toEqual(expect.arrayContaining([".git", "hosts-link"]));147148 const p1 = JSON.parse((await skills(["pack", "my-skill", "-o", "a.tar.gz", "--json"])).out);149 const p2 = JSON.parse((await skills(["pack", "my-skill", "-o", "b.tar.gz", "--json"])).out);150 expect(p1.sha256).toBe(p2.sha256);151 expect(readFileSync(join(work, "a.tar.gz")).equals(readFileSync(join(work, "b.tar.gz")))).toBe(true);152 expect(sha(readFileSync(join(work, "a.tar.gz")))).toBe(p1.sha256);153 });154155 it("catches a secret before anything leaves the machine", async () => {156 mkdirSync(join(work, "leaky"));157 for (const f of readdirSync(join(work, "my-skill"))) {158 if (statSync(join(work, "my-skill", f)).isFile() && f !== "hosts-link") writeFileSync(join(work, "leaky", f), readFileSync(join(work, "my-skill", f)));159 }160 const secret = `ghp_${"Q7".repeat(18)}`;161 writeFileSync(join(work, "leaky", "notes.txt"), `token: ${secret}\n`);162 const before = await counts();163 const v = await skills(["validate", "leaky"]);164 expect(v.code).toBe(2);165 expect(v.out).toContain("secret-github-token");166 expect(v.out).not.toContain(secret);167 const pub = await skills(["publish", "leaky", "--yes"]);168 expect(pub.code).toBe(2);169 expect(await counts()).toEqual(before);170 });171172 it("dry-run reports the plan and writes nothing on the registry", async () => {173 const before = await counts();174 const [auditBefore] = await env.sql`SELECT count(*)::int AS n FROM audit_events WHERE action NOT LIKE 'auth.%'`;175 const r = await skills(["publish", "my-skill", "--visibility", "public", "--dry-run"]);176 expect(r.code).toBe(0);177 expect(r.out).toContain("@research/my-skill@0.1.0");178 expect(r.out).toContain("does not exist yet");179 expect(r.out).toContain("SKILL.md");180 expect(r.out).toMatch(/sha256 [0-9a-f]{64}/);181 expect(r.out).toContain("no changes were made");182 const j = JSON.parse((await skills(["publish", "my-skill", "--dry-run", "--json"])).out);183 expect(j).toMatchObject({ dryRun: true, ok: true, visibility: "private", artifactExists: false, firstRelease: true });184 expect(await counts()).toEqual(before);185 const [auditAfter] = await env.sql`SELECT count(*)::int AS n FROM audit_events WHERE action NOT LIKE 'auth.%'`;186 expect(auditAfter!.n).toBe(auditBefore!.n);187 });188189 it("requires confirmation for a first public release, then publishes and verifies", async () => {190 const noYes = await skills(["publish", "my-skill", "--visibility", "public"]);191 expect(noYes.code).toBe(1);192 expect(noYes.err).toContain("--yes");193 expect((await counts()).artifacts).toBe(0);194195 const r = await skills(["publish", "my-skill", "--visibility", "public", "--yes", "--notes", "First release."]);196 expect(r.code).toBe(0);197 expect(r.out).toContain("Published @research/my-skill@0.1.0");198 expect(r.out).toContain(`${origin}/@research/my-skill`);199 expect(r.out).toContain("download re-hashed ✓");200 const again = await skills(["publish", "my-skill", "--yes"]);201 expect(again.code).toBe(1);202 expect(again.err).toContain("immutable");203 });204205 it("lists, inspects and searches", async () => {206 expect((await skills(["versions", "@research/my-skill"])).out).toMatch(/^0\.1\.0 +published/);207 const ins = await skills(["inspect", "@research/my-skill"]);208 expect(ins.out).toContain("identity verified");209 expect(ins.out).toContain("claude-code (publisher claim)");210 expect((await skills(["inspect", "@research/my-skill", "--file", "SKILL.md"])).out).toContain("name: my-skill");211 expect((await skills(["inspect", "@research/my-skill", "--file", "../../etc/passwd"])).code).toBe(1);212 const s = await skills(["search", "summarises document", "--type", "skill"]);213 expect(s.out).toContain("@research/my-skill@0.1.0");214 expect(JSON.parse((await skills(["search", "zzzznothing", "--json"])).out).total).toBe(0);215 expect((await skills(["inspect", "@research/nope"])).code).toBe(1);216 });217218 it("downloads with checksum verification and refuses tampered bytes", async () => {219 const d = JSON.parse((await skills(["download", "@research/my-skill", "-o", "dl.tar.gz", "--json"])).out);220 expect(d.verified).toBe(true);221 expect(sha(readFileSync(join(work, "dl.tar.gz")))).toBe(d.sha256);222 const ex = await skills(["download", "@research/my-skill@0.1.0", "--extract", "unpacked"]);223 expect(ex.code).toBe(0);224 expect(existsSync(join(work, "unpacked", "SKILL.md"))).toBe(true);225 expect((await skills(["download", "@research/my-skill", "--extract", "unpacked"])).err).toContain("not empty");226227 const tamper: typeof fetch = async (input, init) => {228 const res = await fetch(input, init);229 if (!String(input).endsWith("/download")) return res;230 const bytes = new Uint8Array(await res.arrayBuffer());231 bytes[bytes.length - 12] = bytes[bytes.length - 12]! ^ 0x01;232 return new Response(bytes, { status: res.status, headers: res.headers });233 };234 const bad = await skills(["download", "@research/my-skill", "-o", "tampered.tar.gz"], { fetch: tamper });235 expect(bad.code).toBe(1);236 expect(bad.err).toContain("Checksum mismatch");237 expect(existsSync(join(work, "tampered.tar.gz"))).toBe(false);238 const badInstall = await skills(["install", "@research/my-skill", "--client", "cursor", "--yes"], { fetch: tamper });239 expect(badInstall.code).toBe(1);240 expect(existsSync(join(home, ".cursor", "skills", "my-skill"))).toBe(false);241 });242243 it("installs skills where each client expects them, without executing anything", async () => {244 // Publish a skill whose script would leave a marker if anything ran it.245 const marker = join(work, "EXECUTED");246 mkdirSync(join(work, "scripted", "scripts"), { recursive: true });247 await skills(["init", "scripted", "--name", "@research/scripted", "--description", "Skill with a bundled script used to prove installs never execute code."]);248 writeFileSync(join(work, "scripted", "scripts", "run.sh"), `#!/bin/sh\ntouch '${marker}'\n`);249 chmodSync(join(work, "scripted", "scripts", "run.sh"), 0o755);250 expect((await skills(["publish", "scripted", "--visibility", "public", "--yes"])).code).toBe(0);251252 const needsConfirm = await skills(["install", "@research/scripted", "--client", "claude-code"]);253 expect(needsConfirm.code).toBe(1);254 const cc = await skills(["install", "@research/scripted", "--client", "claude-code", "--yes"]);255 expect(cc.code).toBe(0);256 const dest = join(home, ".claude", "skills", "scripted");257 expect(cc.out).toContain(dest);258 expect(cc.out).toContain("nothing executed");259 expect(readdirSync(dest).sort()).toEqual([LOCK_FILE, "CHANGELOG.md", "LICENSE", "README.md", "SKILL.md", "agent-registry.json", "scripts"].sort());260 expect(statSync(join(dest, "scripts", "run.sh")).mode & 0o111).toBeTruthy();261 expect(statSync(join(dest, "SKILL.md")).mode & 0o111).toBe(0);262 const lock = JSON.parse(readFileSync(join(dest, LOCK_FILE), "utf8"));263 expect(lock).toMatchObject({ name: "@research/scripted", version: "0.1.0", registry: origin, client: "claude-code" });264 expect(lock.sha256).toMatch(/^[0-9a-f]{64}$/);265 expect(existsSync(marker)).toBe(false);266267 const proj = mkdtempSync(join(tmpdir(), "skills-cli-proj-"));268 const cx = await skills(["install", "@research/scripted@0.1.0", "--client", "codex", "--project", "--yes"], { cwd: proj });269 expect(cx.code).toBe(0);270 expect(existsSync(join(proj, ".agents", "skills", "scripted", "SKILL.md"))).toBe(true);271 expect((await skills(["install", "@research/scripted", "--client", "codex", "--yes"])).out).toContain(join(home, ".agents", "skills", "scripted"));272 expect((await skills(["install", "@research/scripted", "--client", "generic-mcp", "--yes"])).code).toBe(1);273 expect((await skills(["install", "@research/scripted", "--yes"])).err).toContain("--client is required");274 expect(existsSync(marker)).toBe(false);275 rmSync(proj, { recursive: true, force: true });276 });277278 it("refuses to overwrite an install unless forced, and backs up the old copy", async () => {279 const dest = join(home, ".claude", "skills", "scripted");280 writeFileSync(join(dest, "local-notes.txt"), "my edits");281 const clash = await skills(["install", "@research/scripted", "--client", "claude-code", "--yes"]);282 expect(clash.code).toBe(1);283 expect(clash.err).toContain("already exists");284 const forced = await skills(["install", "@research/scripted", "--client", "claude-code", "--yes", "--force"]);285 expect(forced.code).toBe(0);286 expect(forced.out).toContain("Roll back with:");287 expect(existsSync(join(dest, "local-notes.txt"))).toBe(false);288 const backups = readdirSync(join(home, ".skills-registry", "backups"));289 expect(backups).toHaveLength(1);290 expect(readFileSync(join(home, ".skills-registry", "backups", backups[0]!, "local-notes.txt"), "utf8")).toBe("my edits");291 });292293 it("updates to a new version and flags new permissions", async () => {294 expect((await skills(["update", "@research/scripted", "--client", "claude-code", "--yes"])).out).toContain("up to date");295 const mf = join(work, "scripted", "agent-registry.json");296 const m = JSON.parse(readFileSync(mf, "utf8"));297 writeFileSync(mf, JSON.stringify({ ...m, version: "0.2.0", permissions: ["shell"] }, null, 2));298 const pub = await skills(["publish", "scripted", "--yes"]);299 expect(pub.code).toBe(0); // existing public artifact, no visibility change300 expect(pub.out).toContain("Published @research/scripted@0.2.0");301 const blocked = await skills(["update", "@research/scripted", "--client", "claude-code"]);302 expect(blocked.code).toBe(1);303 expect(blocked.out).toContain("NEW: shell");304 const up = await skills(["update", "@research/scripted", "--client", "claude-code", "--yes"]);305 expect(up.code).toBe(0);306 expect(up.out).toContain("0.1.0 → 0.2.0");307 expect(JSON.parse(readFileSync(join(home, ".claude", "skills", "scripted", LOCK_FILE), "utf8")).version).toBe("0.2.0");308 expect(readdirSync(join(home, ".skills-registry", "backups"))).toHaveLength(2);309 expect((await skills(["update", "@research/my-skill", "--client", "claude-code", "--yes"])).err).toContain("not installed");310 expect(existsSync(join(work, "EXECUTED"))).toBe(false);311 });312313 it("publishes the bundled examples; the echo server speaks MCP over stdio", async () => {314 for (const ex of ["hello-skill", "citation-check", "echo-mcp-server"]) {315 const r = await skills(["publish", join(EXAMPLES, ex), "--visibility", "public", "--yes", "--json"]);316 expect(r.code, r.err + r.out).toBe(0);317 expect(JSON.parse(r.out)).toMatchObject({ ok: true, name: `@spboucher/${ex}`, state: "published", verification: { metadata: true, download: true } });318 }319 const mcp = await skills(["install", "@spboucher/echo-mcp-server", "--client", "claude-code", "--yes"]);320 expect(mcp.code).toBe(0);321 expect(mcp.out).toContain("Nothing was downloaded, written or executed");322 expect(mcp.out).toContain("claude mcp add --transport stdio echo-mcp-server -- node server.mjs");323 expect(existsSync(join(home, ".claude", "skills", "echo-mcp-server"))).toBe(false);324325 // Our own example, run here deliberately to prove it is a working MCP server.326 const child = spawn(process.execPath, [join(EXAMPLES, "echo-mcp-server", "server.mjs")], { stdio: ["pipe", "pipe", "inherit"] });327 let buf = "";328 child.stdout.on("data", (d) => (buf += d));329 const send = (m: unknown) => child.stdin.write(JSON.stringify(m) + "\n");330 send({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "vitest", version: "0" } } });331 send({ jsonrpc: "2.0", method: "notifications/initialized" });332 send({ jsonrpc: "2.0", id: 2, method: "tools/list" });333 send({ jsonrpc: "2.0", id: 3, method: "tools/call", params: { name: "echo", arguments: { text: "bonjour" } } });334 send({ jsonrpc: "2.0", id: 4, method: "nope" });335 child.stdin.end();336 await new Promise<void>((r) => child.on("close", () => r()));337 const msgs = buf.trim().split("\n").map((l) => JSON.parse(l));338 expect(msgs).toHaveLength(4);339 expect(msgs[0].result).toMatchObject({ protocolVersion: "2025-06-18", serverInfo: { name: "echo-mcp-server" } });340 expect(msgs[1].result.tools.map((t: any) => t.name)).toEqual(["echo"]);341 expect(msgs[2].result).toEqual({ content: [{ type: "text", text: "bonjour" }], isError: false });342 expect(msgs[3].error.code).toBe(-32601);343 });344345 it("the citation-check script works on real input", () => {346 const d = mkdtempSync(join(tmpdir(), "skills-cite-"));347 writeFileSync(join(d, "p.tex"), "See \\citep[p.~3]{engle1982, bollerslev1986} and \\citet{ghost2020}.\n% \\cite{commented}\n");348 writeFileSync(join(d, "n.md"), "As [@engle1982; @nelson1991] show; mail me@example.org. `@code`\n");349 writeFileSync(join(d, "r.bib"), "@article{engle1982,\n title={ARCH}\n}\n@article{bollerslev1986,\n}\n@book{nelson1991,\n}\n@misc{unused2001,\n}\n@string{x = \"y\"}\n");350 const r = spawnSync("python3", [join(EXAMPLES, "citation-check", "scripts", "check_citations.py"), join(d, "p.tex"), join(d, "n.md"), "--bib", join(d, "r.bib"), "--json"], { encoding: "utf8" });351 if (r.error) return; // python3 not installed on this machine352 expect(r.status).toBe(1);353 const rep = JSON.parse(r.stdout);354 expect(rep.missing_entries.map((x: any) => x.key)).toEqual(["ghost2020"]);355 expect(rep.uncited_entries.map((x: any) => x.key)).toEqual(["unused2001"]);356 rmSync(d, { recursive: true, force: true });357 });358359 it("deprecates an artifact and revokes a release with confirmation", async () => {360 expect((await skills(["deprecate", "@research/my-skill", "--reason", "Superseded by scripted."])).code).toBe(1);361 expect((await skills(["deprecate", "@research/my-skill", "--reason", "Superseded by scripted.", "--yes"])).out).toContain("Deprecated @research/my-skill");362 expect((await skills(["revoke", "@research/scripted@0.2.0", "--yes"])).err).toContain("--reason");363 const rv = await skills(["revoke", "@research/scripted@0.2.0", "--reason", "Declared shell permission by mistake.", "--yes"]);364 expect(rv.out).toContain("Revoked @research/scripted@0.2.0");365 expect((await skills(["download", "@research/scripted@0.2.0"])).err).toContain("release_revoked");366 expect((await skills(["versions", "@research/scripted"])).out).toMatch(/0\.2\.0 +revoked/);367 });368369 it("creates tokens: printed once, or stored without printing", async () => {370 // The publisher token has no tokens:write scope.371 const denied = await skills(["auth", "token", "create", "--name", "ci", "--scopes", "artifacts:read", "--namespace", "research"]);372 expect(denied.code).toBe(3);373 expect(denied.err).toContain('lacks the "tokens:write" scope');374375 const minter = await tokenFor(env, env.owner, ["research"], ["tokens:write", "tokens:read", "artifacts:read"]);376 const made = await skills(["auth", "token", "create", "--name", "ci-reader", "--scopes", "artifacts:read", "--namespace", "research", "--expires-in", "12h"], { env: { SKILLS_TOKEN: minter.token }, record: false });377 expect(made.code).toBe(0);378 expect(made.out.trim()).toMatch(/^skr_[a-z0-9]{10}_[A-Za-z0-9_-]{43}$/);379 expect(made.err).not.toContain(made.out.trim());380 expect(made.err).not.toContain(minter.token);381382 const home2 = join(home, "second");383 const stored = await skills(["auth", "token", "create", "--name", "ci-stored", "--scopes", "artifacts:read", "--namespace", "research", "--store"], { env: { SKILLS_TOKEN: minter.token, SKILLS_CREDENTIAL_STORE: `file:${join(home2, "c.json")}` }, record: false });384 expect(stored.code).toBe(0);385 expect(stored.out).toBe("");386 const saved = JSON.parse(readFileSync(join(home2, "c.json"), "utf8"))[new URL(origin).host];387 expect(saved).toMatch(/^skr_/);388 expect(stored.err).not.toContain(saved);389 expect(stored.err).not.toContain(minter.token);390391 const list = await skills(["token", "list", "--json"]);392 expect(JSON.parse(list.out).map((t: any) => t.name)).toEqual(expect.arrayContaining(["ci-reader", "ci-stored"]));393 expect(list.out).not.toContain("token_hash");394 const esc = await skills(["auth", "token", "create", "--name", "esc", "--scopes", "releases:publish", "--namespace", "research"], { env: { SKILLS_TOKEN: minter.token }, record: false });395 expect(esc.code).toBe(3);396 expect(esc.err).toContain("scope_escalation");397 });398399 it("rotates the current token: the old secret dies, the stored one works", async () => {400 const needsYes = await skills(["auth", "token", "rotate"]);401 expect(needsYes.code).toBe(1);402 const rot = await skills(["auth", "token", "rotate", "--yes"]);403 expect(rot.code).toBe(0);404 expect(rot.out).toContain("It was not displayed");405 const fresh = storedToken();406 expect(fresh).not.toBe(token);407 expect(rot.out + rot.err).not.toContain(fresh);408 const old = await skills(["whoami"], { env: { SKILLS_TOKEN: token }, record: false });409 expect(old.code).toBe(3);410 expect(old.err).toContain("token_revoked");411 expect((await skills(["whoami"])).code).toBe(0);412 const [row] = await env.sql`SELECT rotated_from FROM api_tokens WHERE revoked_at IS NULL AND name = (SELECT name FROM api_tokens WHERE id = ${tokenId})`;413 expect(row!.rotated_from).toBe(tokenId);414 transcript.push(`[fresh:${fresh.length}]`);415 });416417 it("after revocation, publishing fails with an authentication exit code", async () => {418 const current = JSON.parse((await skills(["whoami", "--json"])).out).credential;419 const rv = await skills(["auth", "token", "revoke", current.prefix, "--yes"]);420 expect(rv.code).toBe(0);421 const mf = join(work, "my-skill", "agent-registry.json");422 writeFileSync(mf, JSON.stringify({ ...JSON.parse(readFileSync(mf, "utf8")), version: "0.1.1" }, null, 2));423 const before = await counts();424 const pub = await skills(["publish", "my-skill", "--yes"]);425 expect(pub.code).toBe(3);426 expect(pub.err).toContain("token_revoked");427 expect(pub.err).toContain("skills login");428 expect(await counts()).toEqual(before);429 // Anonymous reads still work without a valid credential once logged out.430 expect((await skills(["logout"])).out).toContain("Removed the stored token");431 expect((await skills(["search", "hello"])).out).toContain("@spboucher/hello-skill");432 expect((await skills(["whoami"])).code).toBe(3);433 });434435 it("never printed a stored or rotated token anywhere", () => {436 const all = transcript.join("\n");437 expect(all).not.toContain(token);438 expect(all).not.toContain(token.split("_")[2]!);439 expect(all).not.toMatch(/skr_[a-z0-9]{10}_[A-Za-z0-9_-]{43}/);440 });441});442