Skip to content
Merged
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
31 changes: 25 additions & 6 deletions sdk/src/v1.generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
// Regenerate: bun run scripts/generate-sdk.ts

// @generated from OpenAPI by @hasna/contracts SDK generator — DO NOT EDIT.
// Source: Instructions V1 API 0.3.0
// Source: Instructions V1 API 0.4.19

export interface Config { "id"?: string; "name"?: string; "slug"?: string; "kind"?: string; "category"?: string; "agent"?: string; "target_path"?: string | null; "outputs"?: Array<Record<string, unknown>>; "format"?: string; "content"?: string; "description"?: string | null; "tags"?: Array<string>; "is_template"?: boolean; "version"?: number; "created_at"?: string; "updated_at"?: string; "synced_at"?: string | null }

Expand All @@ -14,6 +14,16 @@ export interface UpdateConfigInput { "name"?: string; "category"?: string; "agen

export interface CreateProfileInput { "name": string; "description"?: string; "selectors"?: Record<string, unknown>; "variables"?: Record<string, unknown> }

export interface ProfileWithConfigs { "id"?: string; "name"?: string; "slug"?: string; "description"?: string | null; "selectors"?: Record<string, unknown>; "variables"?: Record<string, unknown>; "created_at"?: string; "updated_at"?: string; "configs"?: Array<Config> }

export interface BoundedProfilePage { "profiles"?: Array<Profile>; "items": Array<Profile>; "count"?: number; "total": number; "limit": number; "cursor": number; "next_cursor": number | null; "has_more": boolean; "complete": boolean; "truncated": boolean; "source_bounded": boolean }

export interface BoundedConfigPage { "items": Array<Config>; "total": number; "limit": number; "cursor": number; "next_cursor": number | null; "has_more": boolean; "complete": boolean; "truncated": boolean; "source_bounded": boolean }

export interface ProfileShowResponse { "profile": ProfileWithConfigs; "configs": BoundedConfigPage }

export interface ProfileResolutionRead { "profile": Profile | null; "scanned": number | null; "total": number | null; "batch_limit": number | null; "source_bounded": boolean; "complete": boolean; "truncated": boolean }

export interface InstructionsV1ClientOptions {
/** Base URL, e.g. process.env.APP_API_URL. */
baseUrl: string;
Expand Down Expand Up @@ -132,11 +142,11 @@ export class InstructionsV1Client {
});
}

/** List profiles */
async listProfiles(init?: RequestInit): Promise<{ "profiles"?: Array<Profile>; "count"?: number }> {
/** List profiles with producer-side bounds */
async listProfiles(query?: { "limit"?: number; "cursor"?: number }, init?: RequestInit): Promise<BoundedProfilePage> {
return this.request("GET", `/v1/profiles`, {
body: undefined,
query: undefined,
query,
init,
});
}
Expand All @@ -150,11 +160,20 @@ export class InstructionsV1Client {
});
}

/** Resolve a machine profile by scanning producer-bounded batches */
async resolveProfile(query?: { "hostname"?: string; "os"?: string; "arch"?: string; "limit"?: number }, init?: RequestInit): Promise<ProfileResolutionRead> {
return this.request("GET", `/v1/profiles/resolve`, {
body: undefined,
query,
init,
});
}

/** Get a profile (with its configs) by id or slug */
async getProfile(id: string, init?: RequestInit): Promise<{ "profile"?: Profile }> {
async getProfile(id: string, query?: { "limit"?: number; "cursor"?: number }, init?: RequestInit): Promise<ProfileShowResponse> {
return this.request("GET", `/v1/profiles/${encodeURIComponent(String(id))}`, {
body: undefined,
query: undefined,
query,
init,
});
}
Expand Down
42 changes: 27 additions & 15 deletions src/cli/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -329,10 +329,12 @@ function formatProfileVariables(profile: Pick<Profile, "variables">): string {
async function getMachineProfileContext(
opts: { hostname?: string; os?: string; arch?: string },
store: ConfigStore,
readOptions: { limit?: unknown } = {},
) {
const machine = detectMachineContext({ hostname: opts.hostname, os: opts.os, arch: opts.arch });
const profile = await store.resolveProfileForMachine(machine);
return { machine, profile, vars: resolveProfileVariables(profile, machine) };
const resolution = await store.resolveProfileForMachineRead(machine, readOptions);
const profile = resolution.profile;
return { machine, profile, resolution, vars: resolveProfileVariables(profile, machine) };
}

// ── list ─────────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -830,24 +832,23 @@ profileCmd.command("list").description("List all profiles")
.option("-f, --format <fmt>", "compact|table|json", "compact")
.option("--verbose", "show expanded profile metadata")
.option("--json", "output full profiles as JSON")
.option("--limit <n>", `max rows for human output (default ${DEFAULT_LIST_LIMIT})`)
.option("--cursor <n>", "zero-based pagination cursor for human output")
.option("--limit <n>", `max rows requested from the source (default ${DEFAULT_LIST_LIMIT})`)
.option("--cursor <n>", "zero-based source pagination cursor")
.action(async (opts) => {
const fmt = opts.json ? "json" : opts.verbose ? "table" : opts.brief ? "compact" : opts.format;
const store = resolveConfigStore();
const profiles = await store.listProfiles();
if (fmt === "json") { printJson(profiles); return; }
if (profiles.length === 0) { console.log(chalk.dim("No profiles.")); return; }
const page = paginate(profiles, { limit: opts.limit, cursor: opts.cursor });
const page = await store.listProfilesPage({ limit: opts.limit, cursor: opts.cursor });
if (fmt === "json") { printJson(page); return; }
if (page.total === 0) { console.log(chalk.dim("No profiles.")); return; }
if (fmt === "compact") console.log(`${pad("slug", 28)} ${pad("configs", 8)} ${pad("match", 36)} vars`);
for (const p of page.items) {
const configCount = (await store.getProfileConfigsPage(p.id, { limit: 1 })).total;
if (fmt === "compact") {
const selectorSummary = formatProfileSelectorSummary(p);
console.log(`${pad(p.slug, 28)} ${pad(String((await store.getProfileConfigs(p.id)).length), 8)} ${pad(selectorSummary || "-", 36)} ${Object.keys(p.variables).length}`);
console.log(`${pad(p.slug, 28)} ${pad(String(configCount), 8)} ${pad(selectorSummary || "-", 36)} ${Object.keys(p.variables).length}`);
continue;
}
const configs = await store.getProfileConfigs(p.id);
console.log(`${chalk.bold(p.name)} ${chalk.dim(`(${p.slug})`)} — ${configs.length} config(s)`);
console.log(`${chalk.bold(p.name)} ${chalk.dim(`(${p.slug})`)} — ${configCount} config(s)`);
if (p.description) console.log(` ${chalk.dim(p.description)}`);
const selectorSummary = formatProfileSelectorSummary(p);
if (selectorSummary) console.log(` ${chalk.dim(`match: ${selectorSummary}`)}`);
Expand Down Expand Up @@ -902,19 +903,23 @@ profileCmd.command("update <id>").description("Update an existing profile's vari
profileCmd.command("show <id>").description("Show profile and its configs")
.option("--limit <n>", `max config rows (default ${DEFAULT_LIST_LIMIT})`)
.option("--cursor <n>", "zero-based pagination cursor")
.option("--json", "output the profile and bounded membership page as JSON")
.action(async (id, opts) => {
try {
const store = resolveConfigStore();
const p = await store.getProfile(id);
const configs = await store.getProfileConfigs(id);
const page = await store.getProfileConfigsPage(id, { limit: opts.limit, cursor: opts.cursor });
if (opts.json) {
printJson({ profile: p, configs: page });
return;
}
console.log(chalk.bold(p.name) + chalk.dim(` (${p.slug})`));
if (p.description) console.log(chalk.dim(p.description));
const selectorSummary = formatProfileSelectorSummary(p);
if (selectorSummary) console.log(chalk.dim(`match: ${selectorSummary}`));
const varSummary = formatProfileVariables(p);
if (varSummary) console.log(chalk.dim(`vars: ${varSummary}`));
console.log(chalk.cyan(`${configs.length} config(s):`));
const page = paginate(configs, { limit: opts.limit, cursor: opts.cursor });
console.log(chalk.cyan(`${page.total} config(s):`));
for (const c of page.items) console.log(` ${c.slug} ${chalk.dim(`[${c.category}/${c.agent}]`)}`);
if (page.has_more) {
console.log(chalk.dim(`Showing ${page.items.length} of ${page.total}. Next: configs profile show ${id} --cursor ${page.next_cursor} --limit ${page.limit}`));
Expand Down Expand Up @@ -996,9 +1001,16 @@ profileCmd.command("resolve").description("Resolve the matching machine-aware pr
.option("--hostname <hostname>", "override detected hostname")
.option("--os <os>", "override detected OS")
.option("--arch <arch>", "override detected arch")
.option("--limit <n>", `maximum profiles per source scan batch (default ${DEFAULT_LIST_LIMIT})`)
.option("--json", "output the complete bounded resolution read as JSON")
.action(async (opts) => {
const store = resolveConfigStore();
const { machine, profile, vars } = await getMachineProfileContext(opts, store);
const { machine, profile, resolution, vars } = await getMachineProfileContext(opts, store, { limit: opts.limit });
if (opts.json) {
printJson({ ...resolution, machine, vars });
if (!profile) process.exitCode = 1;
return;
}
if (!profile) {
console.log(chalk.yellow(`No matching profile for ${machine.hostname} ${machine.os_family}/${machine.arch}`));
process.exit(1);
Expand Down
173 changes: 173 additions & 0 deletions src/cli/profile-reads.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
import { afterEach, describe, expect, test } from "bun:test";
import { rmSync } from "node:fs";
import { dirname, join } from "node:path";
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import { createConfig } from "../db/configs";
import { getDatabase, resetDatabase } from "../db/database";
import { addConfigToProfile, createProfile } from "../db/profiles";
import { makeTempRoot } from "../lib/test-temp-root";

const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "../..");
const tempDirs: string[] = [];

function runCli(args: string[], dbPath: string) {
return spawnSync("bun", ["src/cli/index.tsx", ...args], {
cwd: repoRoot,
encoding: "utf8",
env: {
...process.env,
HASNA_INSTRUCTIONS_DB_PATH: dbPath,
HASNA_INSTRUCTIONS_API_URL: "",
HASNA_INSTRUCTIONS_API_KEY: "",
NO_COLOR: "1",
FORCE_COLOR: "0",
},
});
}

function seedProfileReads(): { dbPath: string; targetSlug: string } {
const root = makeTempRoot("instructions-profile-reads-");
tempDirs.push(root);
const dbPath = join(root, "instructions.db");
process.env["HASNA_INSTRUCTIONS_DB_PATH"] = dbPath;
process.env["HASNA_INSTRUCTIONS_API_URL"] = "";
process.env["HASNA_INSTRUCTIONS_API_KEY"] = "";
resetDatabase();
const db = getDatabase();

for (let i = 1; i <= 5; i++) {
createProfile({
name: `Profile ${String(i).padStart(2, "0")}`,
selectors: { hostnames: [`other-${i}`] },
}, db);
}
const target = createProfile({
name: "Z Target",
selectors: { hostnames: ["station02"], os: ["linux"], arch: ["x64"] },
}, db);
for (let i = 1; i <= 5; i++) {
const config = createConfig({
name: `Config ${String(i).padStart(2, "0")}`,
category: "rules",
content: `rule ${i}`,
}, db);
addConfigToProfile(target.id, config.id, db);
}

resetDatabase();
delete process.env["HASNA_INSTRUCTIONS_DB_PATH"];
delete process.env["HASNA_INSTRUCTIONS_API_URL"];
delete process.env["HASNA_INSTRUCTIONS_API_KEY"];
return { dbPath, targetSlug: target.slug };
}

afterEach(() => {
resetDatabase();
delete process.env["HASNA_INSTRUCTIONS_DB_PATH"];
delete process.env["HASNA_INSTRUCTIONS_API_URL"];
delete process.env["HASNA_INSTRUCTIONS_API_KEY"];
for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true });
});

describe("bounded profile reads", () => {
test("profile list JSON exposes bounded pages and an authoritative terminal page", () => {
const { dbPath } = seedProfileReads();
const first = runCli(["profile", "list", "--json", "--limit", "2"], dbPath);

expect(first.status).toBe(0);
const firstPage = JSON.parse(first.stdout) as {
items: unknown[];
total: number;
limit: number;
cursor: number;
next_cursor: number | null;
has_more: boolean;
complete: boolean;
truncated: boolean;
};
expect(firstPage).toMatchObject({
total: 6,
limit: 2,
cursor: 0,
next_cursor: 2,
has_more: true,
complete: false,
truncated: false,
});
expect(firstPage.items).toHaveLength(2);

const terminal = runCli(["profile", "list", "--json", "--limit", "2", "--cursor", "4"], dbPath);
expect(terminal.status).toBe(0);
expect(JSON.parse(terminal.stdout)).toMatchObject({
total: 6,
cursor: 4,
next_cursor: null,
has_more: false,
complete: true,
truncated: false,
});
});

test("profile show JSON returns a producer-bounded membership page", () => {
const { dbPath, targetSlug } = seedProfileReads();
const result = runCli(["profile", "show", targetSlug, "--json", "--limit", "2", "--cursor", "4"], dbPath);

expect(result.status).toBe(0);
const payload = JSON.parse(result.stdout) as {
profile: { slug: string };
configs: {
items: Array<{ slug: string }>;
total: number;
limit: number;
cursor: number;
complete: boolean;
truncated: boolean;
};
};
expect(payload.profile.slug).toBe(targetSlug);
expect(payload.configs).toMatchObject({
total: 5,
limit: 2,
cursor: 4,
complete: true,
truncated: false,
});
expect(payload.configs.items.map((config) => config.slug)).toEqual(["config-05"]);
});

test("profile resolve JSON scans the complete source in bounded batches", () => {
const { dbPath, targetSlug } = seedProfileReads();
const result = runCli([
"profile",
"resolve",
"--json",
"--limit",
"2",
"--hostname",
"station02",
"--os",
"linux",
"--arch",
"x64",
], dbPath);

expect(result.status).toBe(0);
const payload = JSON.parse(result.stdout) as {
profile: { slug: string };
scanned: number;
total: number;
batch_limit: number;
complete: boolean;
truncated: boolean;
};
expect(payload).toMatchObject({
scanned: 6,
total: 6,
batch_limit: 2,
complete: true,
truncated: false,
});
expect(payload.profile.slug).toBe(targetSlug);
});
});
5 changes: 3 additions & 2 deletions src/cli/profile-update.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,12 @@ function runCli(args: string[], root: string) {
function listProfiles(root: string) {
const result = runCli(["profile", "list", "--json"], root);
expect(result.status).toBe(0);
return JSON.parse(result.stdout) as Array<{
const page = JSON.parse(result.stdout) as { items: Array<{
id: string;
slug: string;
variables: Record<string, string>;
}>;
}> };
return page.items;
}

describe("instructions profile update", () => {
Expand Down
Loading
Loading