SPB Git

Repository Public MIT

cli.test.ts 25.5 KB 442 lines typescript

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
Files main
Menu

Theme