Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions packages/cli/src/utils/resolve.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,14 @@ import type { ForgeServer, ForgeSite } from "@studiometa/forge-api";
import { resolveServerId, resolveSiteId } from "./resolve.ts";
import { ValidationError } from "../errors.ts";

vi.mock("@studiometa/forge-core", () => ({
listServers: vi.fn(),
listSites: vi.fn(),
}));
vi.mock("@studiometa/forge-core", async (importOriginal) => {
const actual = await importOriginal<typeof import("@studiometa/forge-core")>();
return {
listServers: vi.fn(),
listSites: vi.fn(),
matchByName: actual.matchByName,
};
});

const mockServer = (id: number, name: string): ForgeServer =>
({
Expand Down
16 changes: 3 additions & 13 deletions packages/cli/src/utils/resolve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
* Accepts numeric IDs (used as-is) or plain strings (resolved by name/partial match).
*/

import { listServers, listSites } from "@studiometa/forge-core";
import { listServers, listSites, matchByName } from "@studiometa/forge-core";
import type { ExecutorContext } from "@studiometa/forge-core";
import type { ForgeServer, ForgeSite } from "@studiometa/forge-api";
import { ValidationError } from "../errors.ts";
Expand All @@ -25,17 +25,12 @@ export async function resolveServerId(value: string, execCtx: ExecutorContext):
const result = await listServers({}, execCtx);
const servers = result.data as ForgeServer[];

const lower = value.toLowerCase();
const { exact, partial } = matchByName(servers, value, (s) => s.name);

// Exact match by name
const exact = servers.filter((s) => s.name.toLowerCase() === lower);
if (exact.length === 1) {
return String(exact[0].id);
}

// Partial match
const partial = servers.filter((s) => s.name.toLowerCase().includes(lower));

if (partial.length === 0) {
const available = servers.map((s) => ` ${s.name} (${s.id})`).join("\n");
throw new ValidationError(`No server found matching "${value}"`, "server", [
Expand Down Expand Up @@ -75,17 +70,12 @@ export async function resolveSiteId(
const result = await listSites({ server_id: serverId }, execCtx);
const sites = result.data as ForgeSite[];

const lower = value.toLowerCase();
const { exact, partial } = matchByName(sites, value, (s) => s.name);

// Exact match by domain
const exact = sites.filter((s) => s.name.toLowerCase() === lower);
if (exact.length === 1) {
return String(exact[0].id);
}

// Partial match
const partial = sites.filter((s) => s.name.toLowerCase().includes(lower));

if (partial.length === 0) {
const available = sites.map((s) => ` ${s.name} (${s.id})`).join("\n");
throw new ValidationError(`No site found matching "${value}" on server ${serverId}`, "site", [
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export const ACTIONS = [
"restart",
"activate",
"run",
"resolve",
"help",
"schema",
] as const;
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/executors/servers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@ export { deleteServer } from "./delete.ts";
export { getServer } from "./get.ts";
export { listServers } from "./list.ts";
export { rebootServer } from "./reboot.ts";
export { resolveServers } from "./resolve.ts";
export type {
CreateServerOptions,
DeleteServerOptions,
GetServerOptions,
ListServersOptions,
RebootServerOptions,
} from "./types.ts";
export type { ResolveServersOptions, ResolveMatch, ResolveResult } from "./resolve.ts";
66 changes: 66 additions & 0 deletions packages/core/src/executors/servers/resolve.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { describe, expect, it } from "vitest";

import { createTestExecutorContext } from "../../context.ts";
import { resolveServers } from "./resolve.ts";

const mockServers = [
{ id: 1, name: "prod-web-1" },
{ id: 2, name: "prod-web-2" },
{ id: 3, name: "staging-web-1" },
];

function createCtx() {
return createTestExecutorContext({
client: {
get: async () => ({ servers: mockServers }),
} as never,
});
}

describe("resolveServers", () => {
it("should return partial matches", async () => {
const result = await resolveServers({ query: "prod" }, createCtx());
expect(result.data.query).toBe("prod");
expect(result.data.total).toBe(2);
expect(result.data.matches).toHaveLength(2);
expect(result.data.matches[0]!.name).toBe("prod-web-1");
expect(result.data.matches[1]!.name).toBe("prod-web-2");
});

it("should return exact match as single result", async () => {
const result = await resolveServers({ query: "prod-web-1" }, createCtx());
expect(result.data.total).toBe(1);
expect(result.data.matches).toHaveLength(1);
expect(result.data.matches[0]!.id).toBe(1);
expect(result.data.matches[0]!.name).toBe("prod-web-1");
});

it("should return empty for no matches", async () => {
const result = await resolveServers({ query: "nonexistent" }, createCtx());
expect(result.data.total).toBe(0);
expect(result.data.matches).toHaveLength(0);
});

it("should be case insensitive", async () => {
const result = await resolveServers({ query: "PROD" }, createCtx());
expect(result.data.total).toBe(2);
expect(result.data.matches[0]!.name).toBe("prod-web-1");
});

it("should return partial matches when multiple exact-like names exist", async () => {
const ctxWithDupes = createTestExecutorContext({
client: {
get: async () => ({
servers: [
{ id: 1, name: "prod-web-1" },
{ id: 2, name: "prod-web-1" },
],
}),
} as never,
});
// Two exact matches → fall through to partial match
const result = await resolveServers({ query: "prod-web-1" }, ctxWithDupes);
expect(result.data.total).toBe(2);
expect(result.data.matches).toHaveLength(2);
});
});
43 changes: 43 additions & 0 deletions packages/core/src/executors/servers/resolve.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import type { ServersResponse } from "@studiometa/forge-api";
import type { ExecutorContext, ExecutorResult } from "../../context.ts";
import { matchByName } from "../../utils/name-matcher.ts";

export interface ResolveServersOptions {
query: string;
}

export interface ResolveMatch {
id: number;
name: string;
}

export interface ResolveResult {
query: string;
matches: ResolveMatch[];
total: number;
}

/**
* Resolve servers by name — partial, case-insensitive match.
*
* If exactly one server matches the query exactly, it is returned as a single result.
* Otherwise all partial matches are returned.
*/
export async function resolveServers(
options: ResolveServersOptions,
ctx: ExecutorContext,
): Promise<ExecutorResult<ResolveResult>> {
const response = await ctx.client.get<ServersResponse>("/servers");
const servers = response.servers;

const match = matchByName(servers, options.query, (s) => s.name);
const matches = match.exact.length === 1 ? match.exact : match.partial;

return {
data: {
query: options.query,
matches: matches.map((s) => ({ id: s.id, name: s.name })),
total: matches.length,
},
};
}
2 changes: 2 additions & 0 deletions packages/core/src/executors/sites/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@ export { createSite } from "./create.ts";
export { deleteSite } from "./delete.ts";
export { getSite } from "./get.ts";
export { listSites } from "./list.ts";
export { resolveSites } from "./resolve.ts";
export type {
CreateSiteOptions,
DeleteSiteOptions,
GetSiteOptions,
ListSitesOptions,
} from "./types.ts";
export type { ResolveSitesOptions, ResolveSiteMatch, ResolveSiteResult } from "./resolve.ts";
79 changes: 79 additions & 0 deletions packages/core/src/executors/sites/resolve.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { describe, expect, it } from "vitest";

import { createTestExecutorContext } from "../../context.ts";
import { resolveSites } from "./resolve.ts";

const mockSites = [
{ id: 1, name: "example.com" },
{ id: 2, name: "api.example.com" },
{ id: 3, name: "staging.myapp.io" },
];

function createCtx() {
return createTestExecutorContext({
client: {
get: async () => ({ sites: mockSites }),
} as never,
});
}

describe("resolveSites", () => {
it("should return partial matches", async () => {
const result = await resolveSites({ server_id: "123", query: "example" }, createCtx());
expect(result.data.query).toBe("example");
expect(result.data.total).toBe(2);
expect(result.data.matches).toHaveLength(2);
expect(result.data.matches[0]!.name).toBe("example.com");
expect(result.data.matches[1]!.name).toBe("api.example.com");
});

it("should return exact match as single result", async () => {
const result = await resolveSites({ server_id: "123", query: "example.com" }, createCtx());
expect(result.data.total).toBe(1);
expect(result.data.matches).toHaveLength(1);
expect(result.data.matches[0]!.id).toBe(1);
expect(result.data.matches[0]!.name).toBe("example.com");
});

it("should return empty for no matches", async () => {
const result = await resolveSites({ server_id: "123", query: "nonexistent" }, createCtx());
expect(result.data.total).toBe(0);
expect(result.data.matches).toHaveLength(0);
});

it("should be case insensitive", async () => {
const result = await resolveSites({ server_id: "123", query: "EXAMPLE" }, createCtx());
expect(result.data.total).toBe(2);
});

it("should use server_id in the API path", async () => {
let capturedPath = "";
const ctx = createTestExecutorContext({
client: {
get: async (path: string) => {
capturedPath = path;
return { sites: [] };
},
} as never,
});
await resolveSites({ server_id: "456", query: "test" }, ctx);
expect(capturedPath).toBe("/servers/456/sites");
});

it("should return partial matches when multiple exact-like names exist", async () => {
const ctxWithDupes = createTestExecutorContext({
client: {
get: async () => ({
sites: [
{ id: 1, name: "example.com" },
{ id: 2, name: "example.com" },
],
}),
} as never,
});
// Two exact matches → fall through to partial match
const result = await resolveSites({ server_id: "123", query: "example.com" }, ctxWithDupes);
expect(result.data.total).toBe(2);
expect(result.data.matches).toHaveLength(2);
});
});
44 changes: 44 additions & 0 deletions packages/core/src/executors/sites/resolve.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import type { SitesResponse } from "@studiometa/forge-api";
import type { ExecutorContext, ExecutorResult } from "../../context.ts";
import { matchByName } from "../../utils/name-matcher.ts";

export interface ResolveSitesOptions {
server_id: string;
query: string;
}

export interface ResolveSiteMatch {
id: number;
name: string;
}

export interface ResolveSiteResult {
query: string;
matches: ResolveSiteMatch[];
total: number;
}

/**
* Resolve sites by domain name — partial, case-insensitive match.
*
* If exactly one site matches the query exactly, it is returned as a single result.
* Otherwise all partial matches are returned.
*/
export async function resolveSites(
options: ResolveSitesOptions,
ctx: ExecutorContext,
): Promise<ExecutorResult<ResolveSiteResult>> {
const response = await ctx.client.get<SitesResponse>(`/servers/${options.server_id}/sites`);
const sites = response.sites;

const match = matchByName(sites, options.query, (s) => s.name);
const matches = match.exact.length === 1 ? match.exact : match.partial;

return {
data: {
query: options.query,
matches: matches.map((s) => ({ id: s.id, name: s.name })),
total: matches.length,
},
};
}
19 changes: 18 additions & 1 deletion packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@
export { ACTIONS, RESOURCES } from "./constants.ts";
export type { Action, Resource } from "./constants.ts";

// Utilities
export { matchByName } from "./utils/name-matcher.ts";
export type { NameMatch } from "./utils/name-matcher.ts";

// Audit logging
export { createAuditLogger, sanitizeArgs, getAuditLogPath } from "./logger.ts";
export type { AuditLogger, AuditLogEntry } from "./logger.ts";
Expand All @@ -18,22 +22,35 @@ export {
getServer,
listServers,
rebootServer,
resolveServers,
} from "./executors/servers/index.ts";
export type {
CreateServerOptions,
DeleteServerOptions,
GetServerOptions,
ListServersOptions,
RebootServerOptions,
ResolveServersOptions,
ResolveMatch,
ResolveResult,
} from "./executors/servers/index.ts";

// Sites
export { createSite, deleteSite, getSite, listSites } from "./executors/sites/index.ts";
export {
createSite,
deleteSite,
getSite,
listSites,
resolveSites,
} from "./executors/sites/index.ts";
export type {
CreateSiteOptions,
DeleteSiteOptions,
GetSiteOptions,
ListSitesOptions,
ResolveSitesOptions,
ResolveSiteMatch,
ResolveSiteResult,
} from "./executors/sites/index.ts";

// Deployments
Expand Down
Loading