feat(cli): add client commands and home-based local runtime defaults
This commit is contained in:
98
cli/src/__tests__/common.test.ts
Normal file
98
cli/src/__tests__/common.test.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { writeContext } from "../client/context.js";
|
||||
import { resolveCommandContext } from "../commands/client/common.js";
|
||||
|
||||
const ORIGINAL_ENV = { ...process.env };
|
||||
|
||||
function createTempPath(name: string): string {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-cli-common-"));
|
||||
return path.join(dir, name);
|
||||
}
|
||||
|
||||
describe("resolveCommandContext", () => {
|
||||
beforeEach(() => {
|
||||
process.env = { ...ORIGINAL_ENV };
|
||||
delete process.env.PAPERCLIP_API_URL;
|
||||
delete process.env.PAPERCLIP_API_KEY;
|
||||
delete process.env.PAPERCLIP_COMPANY_ID;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...ORIGINAL_ENV };
|
||||
});
|
||||
|
||||
it("uses profile defaults when options/env are not provided", () => {
|
||||
const contextPath = createTempPath("context.json");
|
||||
|
||||
writeContext(
|
||||
{
|
||||
version: 1,
|
||||
currentProfile: "ops",
|
||||
profiles: {
|
||||
ops: {
|
||||
apiBase: "http://127.0.0.1:9999",
|
||||
companyId: "company-profile",
|
||||
apiKeyEnvVarName: "AGENT_KEY",
|
||||
},
|
||||
},
|
||||
},
|
||||
contextPath,
|
||||
);
|
||||
process.env.AGENT_KEY = "key-from-env";
|
||||
|
||||
const resolved = resolveCommandContext({ context: contextPath }, { requireCompany: true });
|
||||
expect(resolved.api.apiBase).toBe("http://127.0.0.1:9999");
|
||||
expect(resolved.companyId).toBe("company-profile");
|
||||
expect(resolved.api.apiKey).toBe("key-from-env");
|
||||
});
|
||||
|
||||
it("prefers explicit options over profile values", () => {
|
||||
const contextPath = createTempPath("context.json");
|
||||
writeContext(
|
||||
{
|
||||
version: 1,
|
||||
currentProfile: "default",
|
||||
profiles: {
|
||||
default: {
|
||||
apiBase: "http://profile:3100",
|
||||
companyId: "company-profile",
|
||||
},
|
||||
},
|
||||
},
|
||||
contextPath,
|
||||
);
|
||||
|
||||
const resolved = resolveCommandContext(
|
||||
{
|
||||
context: contextPath,
|
||||
apiBase: "http://override:3200",
|
||||
apiKey: "direct-token",
|
||||
companyId: "company-override",
|
||||
},
|
||||
{ requireCompany: true },
|
||||
);
|
||||
|
||||
expect(resolved.api.apiBase).toBe("http://override:3200");
|
||||
expect(resolved.companyId).toBe("company-override");
|
||||
expect(resolved.api.apiKey).toBe("direct-token");
|
||||
});
|
||||
|
||||
it("throws when company is required but unresolved", () => {
|
||||
const contextPath = createTempPath("context.json");
|
||||
writeContext(
|
||||
{
|
||||
version: 1,
|
||||
currentProfile: "default",
|
||||
profiles: { default: {} },
|
||||
},
|
||||
contextPath,
|
||||
);
|
||||
|
||||
expect(() =>
|
||||
resolveCommandContext({ context: contextPath, apiBase: "http://localhost:3100" }, { requireCompany: true }),
|
||||
).toThrow(/Company ID is required/);
|
||||
});
|
||||
});
|
||||
70
cli/src/__tests__/context.test.ts
Normal file
70
cli/src/__tests__/context.test.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
defaultClientContext,
|
||||
readContext,
|
||||
setCurrentProfile,
|
||||
upsertProfile,
|
||||
writeContext,
|
||||
} from "../client/context.js";
|
||||
|
||||
function createTempContextPath(): string {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-cli-context-"));
|
||||
return path.join(dir, "context.json");
|
||||
}
|
||||
|
||||
describe("client context store", () => {
|
||||
it("returns default context when file does not exist", () => {
|
||||
const contextPath = createTempContextPath();
|
||||
const context = readContext(contextPath);
|
||||
expect(context).toEqual(defaultClientContext());
|
||||
});
|
||||
|
||||
it("upserts profile values and switches current profile", () => {
|
||||
const contextPath = createTempContextPath();
|
||||
|
||||
upsertProfile(
|
||||
"work",
|
||||
{
|
||||
apiBase: "http://localhost:3100",
|
||||
companyId: "company-123",
|
||||
apiKeyEnvVarName: "PAPERCLIP_AGENT_TOKEN",
|
||||
},
|
||||
contextPath,
|
||||
);
|
||||
|
||||
setCurrentProfile("work", contextPath);
|
||||
const context = readContext(contextPath);
|
||||
|
||||
expect(context.currentProfile).toBe("work");
|
||||
expect(context.profiles.work).toEqual({
|
||||
apiBase: "http://localhost:3100",
|
||||
companyId: "company-123",
|
||||
apiKeyEnvVarName: "PAPERCLIP_AGENT_TOKEN",
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes invalid file content to safe defaults", () => {
|
||||
const contextPath = createTempContextPath();
|
||||
writeContext(
|
||||
{
|
||||
version: 1,
|
||||
currentProfile: "x",
|
||||
profiles: {
|
||||
x: {
|
||||
apiBase: " ",
|
||||
companyId: " ",
|
||||
apiKeyEnvVarName: " ",
|
||||
},
|
||||
},
|
||||
},
|
||||
contextPath,
|
||||
);
|
||||
|
||||
const context = readContext(contextPath);
|
||||
expect(context.currentProfile).toBe("x");
|
||||
expect(context.profiles.x).toEqual({});
|
||||
});
|
||||
});
|
||||
44
cli/src/__tests__/home-paths.test.ts
Normal file
44
cli/src/__tests__/home-paths.test.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
describeLocalInstancePaths,
|
||||
expandHomePrefix,
|
||||
resolvePaperclipHomeDir,
|
||||
resolvePaperclipInstanceId,
|
||||
} from "../config/home.js";
|
||||
|
||||
const ORIGINAL_ENV = { ...process.env };
|
||||
|
||||
describe("home path resolution", () => {
|
||||
afterEach(() => {
|
||||
process.env = { ...ORIGINAL_ENV };
|
||||
});
|
||||
|
||||
it("defaults to ~/.paperclip and default instance", () => {
|
||||
delete process.env.PAPERCLIP_HOME;
|
||||
delete process.env.PAPERCLIP_INSTANCE_ID;
|
||||
|
||||
const paths = describeLocalInstancePaths();
|
||||
expect(paths.homeDir).toBe(path.resolve(os.homedir(), ".paperclip"));
|
||||
expect(paths.instanceId).toBe("default");
|
||||
expect(paths.configPath).toBe(path.resolve(os.homedir(), ".paperclip", "instances", "default", "config.json"));
|
||||
});
|
||||
|
||||
it("supports PAPERCLIP_HOME and explicit instance ids", () => {
|
||||
process.env.PAPERCLIP_HOME = "~/paperclip-home";
|
||||
|
||||
const home = resolvePaperclipHomeDir();
|
||||
expect(home).toBe(path.resolve(os.homedir(), "paperclip-home"));
|
||||
expect(resolvePaperclipInstanceId("dev_1")).toBe("dev_1");
|
||||
});
|
||||
|
||||
it("rejects invalid instance ids", () => {
|
||||
expect(() => resolvePaperclipInstanceId("bad/id")).toThrow(/Invalid instance id/);
|
||||
});
|
||||
|
||||
it("expands ~ prefixes", () => {
|
||||
expect(expandHomePrefix("~")).toBe(os.homedir());
|
||||
expect(expandHomePrefix("~/x/y")).toBe(path.resolve(os.homedir(), "x/y"));
|
||||
});
|
||||
});
|
||||
61
cli/src/__tests__/http.test.ts
Normal file
61
cli/src/__tests__/http.test.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { ApiRequestError, PaperclipApiClient } from "../client/http.js";
|
||||
|
||||
describe("PaperclipApiClient", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("adds authorization and run-id headers", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ ok: true }), { status: 200 }),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const client = new PaperclipApiClient({
|
||||
apiBase: "http://localhost:3100",
|
||||
apiKey: "token-123",
|
||||
runId: "run-abc",
|
||||
});
|
||||
|
||||
await client.post("/api/test", { hello: "world" });
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const call = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
expect(call[0]).toContain("/api/test");
|
||||
|
||||
const headers = call[1].headers as Record<string, string>;
|
||||
expect(headers.authorization).toBe("Bearer token-123");
|
||||
expect(headers["x-paperclip-run-id"]).toBe("run-abc");
|
||||
expect(headers["content-type"]).toBe("application/json");
|
||||
});
|
||||
|
||||
it("returns null on ignoreNotFound", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ error: "Not found" }), { status: 404 }),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const client = new PaperclipApiClient({ apiBase: "http://localhost:3100" });
|
||||
const result = await client.get("/api/missing", { ignoreNotFound: true });
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("throws ApiRequestError with details", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({ error: "Issue checkout conflict", details: { issueId: "1" } }),
|
||||
{ status: 409 },
|
||||
),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const client = new PaperclipApiClient({ apiBase: "http://localhost:3100" });
|
||||
|
||||
await expect(client.post("/api/issues/1/checkout", {})).rejects.toMatchObject({
|
||||
status: 409,
|
||||
message: "Issue checkout conflict",
|
||||
details: { issueId: "1" },
|
||||
} satisfies Partial<ApiRequestError>);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user