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
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 24 additions & 0 deletions sdk/src/v1.generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@ 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 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<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 }
Expand Down Expand Up @@ -187,6 +193,24 @@ export class InstructionsV1Client {
});
}

/** Add a config to a profile */
async addConfigToProfile(id: string, body: AddProfileConfigInput, init?: RequestInit): Promise<ProfileConfigAddedResponse> {
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<ProfileConfigRemovedResponse> {
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`, {
Expand Down
33 changes: 32 additions & 1 deletion src/cli/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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)`);
Expand Down
58 changes: 58 additions & 0 deletions src/cli/output.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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-");
Expand Down
70 changes: 70 additions & 0 deletions src/server/openapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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",
Expand Down
71 changes: 71 additions & 0 deletions src/server/profile-contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand All @@ -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");

Expand All @@ -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(");
});
});
Loading