From 2020c16d15a04a2263b28679ad5472bb9e5bcdf4 Mon Sep 17 00:00:00 2001 From: Andrei Hasna Date: Tue, 11 Aug 2026 07:34:06 +0300 Subject: [PATCH] fix: restore instructions report and profile contracts Agent: livianus --- README.md | 6 +++ sdk/src/v1.generated.ts | 24 ++++++++++ src/cli/index.tsx | 33 +++++++++++++- src/cli/output.test.ts | 58 +++++++++++++++++++++++ src/server/openapi.ts | 70 ++++++++++++++++++++++++++++ src/server/profile-contract.test.ts | 71 +++++++++++++++++++++++++++++ 6 files changed, 261 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 130f46b..811f86c 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,12 @@ small. Human output is capped at 20 rows unless you pass `--limit`; use - `--json` preserves full machine-readable records for automation. - `show`/`inspect` and `snapshot show` print full config or snapshot content. +`instructions report --json` emits the stable `schema_version: 1` report +envelope. Its top-level fields are `configs`, `profiles`, `drift`, `secrets`, +`by_agent`, and `by_category`. The nested count fields are numeric, and +`secrets.policy` is `redacted_on_ingest`. Run `instructions report` without +`--json` for the human-readable report. + ## Package-Manager Secret Guard `instructions package-manager-scan` blocks package-manager credential ingress without diff --git a/sdk/src/v1.generated.ts b/sdk/src/v1.generated.ts index ec7ec94..88d9272 100644 --- a/sdk/src/v1.generated.ts +++ b/sdk/src/v1.generated.ts @@ -14,6 +14,12 @@ export interface UpdateConfigInput { "name"?: string; "category"?: string; "agen export interface CreateProfileInput { "name": string; "description"?: string; "selectors"?: Record; "variables"?: Record } +export interface AddProfileConfigInput { "config_id": string } + +export interface ProfileConfigAddedResponse { "added": boolean } + +export interface ProfileConfigRemovedResponse { "removed": boolean } + export interface ProfileWithConfigs { "id"?: string; "name"?: string; "slug"?: string; "description"?: string | null; "selectors"?: Record; "variables"?: Record; "created_at"?: string; "updated_at"?: string; "configs"?: Array } export interface BoundedProfilePage { "profiles"?: Array; "items": Array; "count"?: number; "total": number; "limit": number; "cursor": number; "next_cursor": number | null; "has_more": boolean; "complete": boolean; "truncated": boolean; "source_bounded": boolean } @@ -187,6 +193,24 @@ export class InstructionsV1Client { }); } + /** Add a config to a profile */ + async addConfigToProfile(id: string, body: AddProfileConfigInput, init?: RequestInit): Promise { + return this.request("POST", `/v1/profiles/${encodeURIComponent(String(id))}/configs`, { + body, + query: undefined, + init, + }); + } + + /** Remove a config from a profile */ + async removeConfigFromProfile(id: string, configId: string, init?: RequestInit): Promise { + return this.request("DELETE", `/v1/profiles/${encodeURIComponent(String(id))}/configs/${encodeURIComponent(String(configId))}`, { + body: undefined, + query: undefined, + init, + }); + } + /** Aggregate config counts by category */ async getStats(init?: RequestInit): Promise<{ "total"?: number }> { return this.request("GET", `/v1/stats`, { diff --git a/src/cli/index.tsx b/src/cli/index.tsx index 047d99e..41c42e4 100644 --- a/src/cli/index.tsx +++ b/src/cli/index.tsx @@ -2045,7 +2045,7 @@ program .description("Summary of stored configs, drift, and ecosystem health") .option("--json", "output as JSON") .option("--markdown", "output as markdown") - .action(async () => { + .action(async (opts) => { const store = resolveConfigStore(); const stats = await store.getConfigStats(); const allConfigs = await store.listConfigs(); @@ -2072,6 +2072,37 @@ program // Project configs const projectConfigs = allConfigs.filter((c) => c.target_path && !c.target_path.startsWith("~/.")); + if (opts.json) { + printJson({ + schema_version: 1, + configs: { + total: allConfigs.length, + files: fileConfigs.length, + references: refConfigs.length, + templates: templates.length, + project: projectConfigs.length, + }, + profiles: { + total: profiles.length, + }, + drift: { + drifted, + missing, + }, + secrets: { + findings: 0, + policy: "redacted_on_ingest", + }, + by_agent: byAgent, + by_category: Object.fromEntries( + Object.entries(stats) + .filter(([key]) => key !== "total") + .map(([key, value]) => [key, Number(value)]), + ), + }); + return; + } + console.log(chalk.bold("configs report\n")); console.log(` Total: ${allConfigs.length} configs (${fileConfigs.length} files, ${refConfigs.length} references)`); console.log(` Templates: ${templates.length} (with {{VAR}} placeholders)`); diff --git a/src/cli/output.test.ts b/src/cli/output.test.ts index 5d10339..65a484a 100644 --- a/src/cli/output.test.ts +++ b/src/cli/output.test.ts @@ -94,6 +94,64 @@ describe("configs list output", () => { }); }); +describe("configs report output", () => { + test("json output is parseable and follows the stable report schema", () => { + const home = makeTempRoot("open-configs-report-json-"); + tempDirs.push(home); + const dbPath = join(home, "configs.db"); + const result = runCli(["report", "--json"], dbPath, home); + + expect(result.status).toBe(0); + expect(result.stderr).toBe(""); + expect(JSON.parse(result.stdout)).toEqual({ + schema_version: 1, + configs: { + total: 0, + files: 0, + references: 0, + templates: 0, + project: 0, + }, + profiles: { + total: 0, + }, + drift: { + drifted: 0, + missing: 0, + }, + secrets: { + findings: 0, + policy: "redacted_on_ingest", + }, + by_agent: {}, + by_category: {}, + }); + }); + + test("the no-flag report preserves the existing human surface", () => { + const home = makeTempRoot("open-configs-report-human-"); + tempDirs.push(home); + const dbPath = join(home, "configs.db"); + const result = runCli(["report"], dbPath, home); + + expect(result.status).toBe(0); + expect(result.stderr).toBe(""); + expect(result.stdout).toBe( + "configs report\n" + + "\n" + + " Total: 0 configs (0 files, 0 references)\n" + + " Templates: 0 (with {{VAR}} placeholders)\n" + + " Profiles: 0\n" + + " Drift: 0 ✓ drifted, 0 missing\n" + + " Secrets: 0 ✓ (redacted on ingest)\n" + + "\n" + + " By agent:\n" + + "\n" + + " By category:\n", + ); + }); +}); + describe("configs apply ownership output", () => { test("CLI direct and profile dry-runs report owned instructions and preserve OpenCode settings", () => { const home = makeTempRoot("open-configs-apply-cli-"); diff --git a/src/server/openapi.ts b/src/server/openapi.ts index 6a3ca5f..28e79be 100644 --- a/src/server/openapi.ts +++ b/src/server/openapi.ts @@ -97,6 +97,27 @@ export function buildV1OpenApiDocument(version = getPackageVersion()) { variables: { type: "object" }, }, }, + AddProfileConfigInput: { + type: "object", + required: ["config_id"], + properties: { + config_id: { type: "string" }, + }, + }, + ProfileConfigAddedResponse: { + type: "object", + required: ["added"], + properties: { + added: { type: "boolean", const: true }, + }, + }, + ProfileConfigRemovedResponse: { + type: "object", + required: ["removed"], + properties: { + removed: { type: "boolean", const: true }, + }, + }, ProfileWithConfigs: { type: "object", properties: { @@ -334,6 +355,55 @@ export function buildV1OpenApiDocument(version = getPackageVersion()) { responses: { "200": { content: { "application/json": { schema: { type: "object", properties: { deleted: { type: "boolean" }, id: { type: "string" } } } } } } }, }, }, + "/v1/profiles/{id}/configs": { + post: { + operationId: "addConfigToProfile", + summary: "Add a config to a profile", + description: "Requires an API key with the `instructions:write` scope.", + security: [{ apiKey: [] }], + parameters: [ + { name: "id", in: "path", required: true, schema: { type: "string" } }, + ], + requestBody: { + required: true, + content: { + "application/json": { + schema: { $ref: "#/components/schemas/AddProfileConfigInput" }, + }, + }, + }, + responses: { + "200": { + content: { + "application/json": { + schema: { $ref: "#/components/schemas/ProfileConfigAddedResponse" }, + }, + }, + }, + }, + }, + }, + "/v1/profiles/{id}/configs/{configId}": { + delete: { + operationId: "removeConfigFromProfile", + summary: "Remove a config from a profile", + description: "Requires an API key with the `instructions:write` scope.", + security: [{ apiKey: [] }], + parameters: [ + { name: "id", in: "path", required: true, schema: { type: "string" } }, + { name: "configId", in: "path", required: true, schema: { type: "string" } }, + ], + responses: { + "200": { + content: { + "application/json": { + schema: { $ref: "#/components/schemas/ProfileConfigRemovedResponse" }, + }, + }, + }, + }, + }, + }, "/v1/stats": { get: { operationId: "getStats", diff --git a/src/server/profile-contract.test.ts b/src/server/profile-contract.test.ts index 9490eaa..3300f80 100644 --- a/src/server/profile-contract.test.ts +++ b/src/server/profile-contract.test.ts @@ -110,6 +110,33 @@ describe("mixed-version profile HTTP compatibility", () => { expect(payload.configs.items).toHaveLength(25); expect(payload.configs.complete).toBe(true); }); + + test("profile membership handlers match the documented success statuses and envelopes", async () => { + mockCloudBoundary(); + const add = track(spyOn(store, "addConfigToProfile").mockResolvedValue(undefined)); + const remove = track(spyOn(store, "removeConfigFromProfile").mockResolvedValue(undefined)); + + const addResponse = await handleV1Request( + new Request("https://instructions.hasna.xyz/v1/profiles/profile-1/configs", { + method: "POST", + body: JSON.stringify({ config_id: "config-2" }), + }), + new URL("https://instructions.hasna.xyz/v1/profiles/profile-1/configs"), + ); + const removeResponse = await handleV1Request( + new Request("https://instructions.hasna.xyz/v1/profiles/profile-1/configs/config-2", { + method: "DELETE", + }), + new URL("https://instructions.hasna.xyz/v1/profiles/profile-1/configs/config-2"), + ); + + expect(addResponse?.status).toBe(200); + expect(await addResponse?.json()).toEqual({ added: true }); + expect(add).toHaveBeenCalledWith(expect.anything(), "profile-1", "config-2"); + expect(removeResponse?.status).toBe(200); + expect(await removeResponse?.json()).toEqual({ removed: true }); + expect(remove).toHaveBeenCalledWith(expect.anything(), "profile-1", "config-2"); + }); }); describe("profile OpenAPI and generated SDK contract", () => { @@ -130,6 +157,39 @@ describe("profile OpenAPI and generated SDK contract", () => { expect(resolveResponse.$ref).toBe("#/components/schemas/ProfileResolutionRead"); }); + test("documents authenticated profile membership add and remove operations", () => { + const spec = buildV1OpenApiDocument("test") as any; + const addPath = spec.paths["/v1/profiles/{id}/configs"]; + const removePath = spec.paths["/v1/profiles/{id}/configs/{configId}"]; + const add = addPath.post; + const remove = removePath.delete; + + expect(spec.security).toEqual([{ apiKey: [] }]); + expect(add.security).toEqual([{ apiKey: [] }]); + expect(add.parameters).toEqual([ + { name: "id", in: "path", required: true, schema: { type: "string" } }, + ]); + expect(add.requestBody.required).toBe(true); + expect(add.requestBody.content["application/json"].schema.$ref).toBe( + "#/components/schemas/AddProfileConfigInput", + ); + expect(add.responses["200"].content["application/json"].schema.$ref).toBe( + "#/components/schemas/ProfileConfigAddedResponse", + ); + + expect(remove.security).toEqual([{ apiKey: [] }]); + expect(remove.parameters).toEqual([ + { name: "id", in: "path", required: true, schema: { type: "string" } }, + { name: "configId", in: "path", required: true, schema: { type: "string" } }, + ]); + expect(remove.responses["200"].content["application/json"].schema.$ref).toBe( + "#/components/schemas/ProfileConfigRemovedResponse", + ); + + expect(addPath.get).toBeUndefined(); + expect(removePath.post).toBeUndefined(); + }); + test("tracked generated SDK exposes bounded profile list, show, and resolve methods", () => { const generated = readFileSync(join(import.meta.dir, "../../sdk/src/v1.generated.ts"), "utf8"); @@ -143,4 +203,15 @@ describe("profile OpenAPI and generated SDK contract", () => { expect(generated).toContain('"scanned": number | null'); expect(generated).toContain('"batch_limit": number | null'); }); + + test("tracked generated SDK exposes only the implemented profile membership mutations", () => { + const generated = readFileSync(join(import.meta.dir, "../../sdk/src/v1.generated.ts"), "utf8"); + + expect(generated).toContain("export interface AddProfileConfigInput"); + expect(generated).toContain("export interface ProfileConfigAddedResponse"); + expect(generated).toContain("export interface ProfileConfigRemovedResponse"); + expect(generated).toContain("async addConfigToProfile(id: string, body: AddProfileConfigInput"); + expect(generated).toContain("async removeConfigFromProfile(id: string, configId: string"); + expect(generated).not.toContain("async replaceConfigInProfile("); + }); });