diff --git a/.changeset/config-command.md b/.changeset/config-command.md new file mode 100644 index 0000000..ef1579c --- /dev/null +++ b/.changeset/config-command.md @@ -0,0 +1,14 @@ +--- +"seamless-cli": minor +--- + +Add `seamless config`, config-as-code for an instance's system configuration +(requires an admin role). `config get [key] [--json]` reads the config from `GET +/system-config/admin`, `config set ` writes one key via `PATCH +/system-config/admin` (the value is parsed as JSON, falling back to a string, so +TTLs, arrays, booleans, and numbers all work), and `config roles` lists the +instance's roles. `config diff ` shows how a local JSON config file +differs from the instance, and `config apply ` applies the delta after a +confirmation prompt, with `--dry-run` to preview. Read-only or unknown keys in a +file are ignored on apply, and a non-admin user gets a clear permission error. +Accepts `--profile` and honors `SEAMLESS_PROFILE`. diff --git a/src/commands/config.ts b/src/commands/config.ts new file mode 100644 index 0000000..fdbf3f1 --- /dev/null +++ b/src/commands/config.ts @@ -0,0 +1,231 @@ +import fs from "fs"; +import { confirm, isCancel } from "@clack/prompts"; +import kleur from "kleur"; +import { extractFlag } from "../core/args.js"; +import { createAuthClient, ReauthRequiredError, type AuthClient } from "../core/authClient.js"; +import { + ConfigApiError, + diffConfig, + filterWritable, + getRoles, + getSystemConfig, + parseValue, + patchSystemConfig, + PermissionError, + type ConfigChange, + type SystemConfig, +} from "../core/systemConfig.js"; + +export async function runConfig(args: string[]): Promise { + const sub = args[0]; + const { value: profileFlag, rest } = extractFlag(args.slice(1), "profile"); + + try { + const client = await createAuthClient({ profileFlag }); + switch (sub) { + case "get": + await configGet(client, rest); + return; + case "set": + await configSet(client, rest); + return; + case "roles": + await configRoles(client, rest); + return; + case "diff": + await configDiff(client, rest); + return; + case "apply": + await configApply(client, rest); + return; + default: + console.error(kleur.red(`Unknown config subcommand: ${sub ?? "(none)"}`)); + console.log("Usage: seamless config "); + process.exit(1); + } + } catch (err) { + if (err instanceof ReauthRequiredError) { + console.log(kleur.yellow(err.message)); + process.exit(1); + } + if (err instanceof PermissionError || err instanceof ConfigApiError) { + console.error(kleur.red(err.message)); + process.exit(1); + } + throw err; + } +} + +async function configGet(client: AuthClient, rest: string[]): Promise { + const json = rest.includes("--json"); + const key = rest.find((arg) => !arg.startsWith("--")); + const config = await getSystemConfig(client); + + if (key) { + if (!(key in config)) { + console.error(kleur.red(`No such config key: ${key}`)); + process.exit(1); + } + const value = config[key]; + console.log( + json + ? JSON.stringify(value, null, 2) + : typeof value === "string" + ? value + : JSON.stringify(value, null, 2), + ); + return; + } + + if (json) { + console.log(JSON.stringify(config, null, 2)); + return; + } + + printConfig(config); +} + +async function configSet(client: AuthClient, rest: string[]): Promise { + const positional = rest.filter((arg) => !arg.startsWith("--")); + const [key, ...valueParts] = positional; + if (!key || valueParts.length === 0) { + console.error(kleur.red("Usage: seamless config set ")); + process.exit(1); + } + + const value = parseValue(valueParts.join(" ")); + const result = await patchSystemConfig(client, { [key]: value }); + + if (result.updatedKeys.length) { + console.log(kleur.green(`Updated: ${result.updatedKeys.join(", ")}`)); + } else { + console.log(kleur.dim("No changes.")); + } +} + +async function configRoles(client: AuthClient, rest: string[]): Promise { + const json = rest.includes("--json"); + const roles = await getRoles(client); + + if (json) { + console.log(JSON.stringify(roles, null, 2)); + return; + } + if (roles.length === 0) { + console.log(kleur.dim("No roles.")); + return; + } + for (const role of roles) { + console.log(" " + role); + } +} + +async function configDiff(client: AuthClient, rest: string[]): Promise { + const file = rest.find((arg) => !arg.startsWith("--")); + if (!file) { + console.error(kleur.red("Usage: seamless config diff ")); + process.exit(1); + } + + const local = readConfigFile(file); + const remote = await getSystemConfig(client); + const changes = diffConfig(local, remote); + + if (changes.length === 0) { + console.log(kleur.green("In sync. No differences.")); + return; + } + printChanges(changes); +} + +async function configApply(client: AuthClient, rest: string[]): Promise { + const dryRun = rest.includes("--dry-run"); + const file = rest.find((arg) => !arg.startsWith("--")); + if (!file) { + console.error(kleur.red("Usage: seamless config apply [--dry-run]")); + process.exit(1); + } + + const local = readConfigFile(file); + const { patch, dropped } = filterWritable(local); + if (dropped.length) { + console.log( + kleur.dim(`Ignoring read-only or unknown keys: ${dropped.join(", ")}`), + ); + } + + const remote = await getSystemConfig(client); + const changes = diffConfig(patch, remote); + + if (changes.length === 0) { + console.log(kleur.green("Already in sync. Nothing to apply.")); + return; + } + + printChanges(changes); + + if (dryRun) { + console.log(kleur.dim("Dry run: no changes applied.")); + return; + } + + const proceed = await confirm({ + message: `Apply ${changes.length} change${ + changes.length === 1 ? "" : "s" + } to ${client.profile.instanceUrl}?`, + initialValue: false, + }); + if (isCancel(proceed) || !proceed) { + console.log("Cancelled."); + return; + } + + const body = Object.fromEntries(changes.map((change) => [change.key, change.to])); + const result = await patchSystemConfig(client, body); + console.log( + kleur.green( + `Applied. Updated: ${result.updatedKeys.join(", ") || "(none reported)"}`, + ), + ); +} + +function readConfigFile(file: string): SystemConfig { + let raw: string; + try { + raw = fs.readFileSync(file, "utf-8"); + } catch { + throw new ConfigApiError(`Could not read file: ${file}`); + } + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + throw new ConfigApiError(`${file} is not valid JSON.`); + } + + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new ConfigApiError(`${file} must contain a JSON object.`); + } + return parsed as SystemConfig; +} + +function inline(value: unknown): string { + return value === undefined ? "(unset)" : JSON.stringify(value); +} + +function printConfig(config: SystemConfig): void { + const keys = Object.keys(config).sort(); + const width = keys.reduce((max, key) => Math.max(max, key.length), 0); + for (const key of keys) { + console.log(kleur.dim(key.padEnd(width) + " ") + inline(config[key])); + } +} + +function printChanges(changes: ConfigChange[]): void { + for (const change of changes) { + console.log(kleur.bold(change.key)); + console.log(" " + kleur.red(`- ${inline(change.from)}`)); + console.log(" " + kleur.green(`+ ${inline(change.to)}`)); + } +} diff --git a/src/commands/help.ts b/src/commands/help.ts index 8f3eaaf..89bf0dc 100644 --- a/src/commands/help.ts +++ b/src/commands/help.ts @@ -20,6 +20,7 @@ USAGE seamless logout [--all] [--profile ] seamless sessions [list] seamless sessions revoke + seamless config seamless --help seamless --version @@ -86,6 +87,26 @@ COMMANDS Revoke one session by id, or every session with --all. Revoking the current session (or --all) prompts for confirmation and then clears local tokens. + config + Read and write the instance system configuration (requires an admin role). + + config get [key] [--json] + • Print the whole config or a single key + + config set + • Update one key; the value is parsed as JSON, falling back to a string + (for example: config set access_token_ttl 15m, + config set login_methods '["email_otp","passkey"]') + + config roles [--json] + • List the instance's available roles + + config diff + • Show how a local JSON config file differs from the instance + + config apply [--dry-run] + • Apply a local JSON config file after a confirmation prompt + check Validate project setup, Docker, and running services diff --git a/src/core/systemConfig.test.ts b/src/core/systemConfig.test.ts new file mode 100644 index 0000000..86bb2ea --- /dev/null +++ b/src/core/systemConfig.test.ts @@ -0,0 +1,168 @@ +import { describe, expect, it } from "vitest"; +import type { AuthClient } from "./authClient.js"; +import type { ApiResponse } from "./http.js"; +import { + ConfigApiError, + deepEqual, + diffConfig, + filterWritable, + getRoles, + getSystemConfig, + parseValue, + patchSystemConfig, + PermissionError, +} from "./systemConfig.js"; + +function response(status: number, data: T | null): ApiResponse { + return { ok: status >= 200 && status < 300, status, data, headers: new Headers() }; +} + +interface Recorded { + method: string; + path: string; + body?: unknown; +} + +function fakeClient( + handler: (rec: Recorded) => ApiResponse, +): { client: AuthClient; calls: Recorded[] } { + const calls: Recorded[] = []; + const record = (method: string, path: string, init?: RequestInit): ApiResponse => { + const rec: Recorded = { + method, + path, + body: init?.body ? JSON.parse(init.body as string) : undefined, + }; + calls.push(rec); + return handler(rec); + }; + return { + calls, + client: { + profile: { name: "default", instanceUrl: "https://auth.example.com" }, + get: async (path) => record("GET", path) as never, + post: async (path) => record("POST", path) as never, + request: async (path, init) => + record((init?.method ?? "GET").toUpperCase(), path, init) as never, + }, + }; +} + +describe("getSystemConfig", () => { + it("returns the config from /system-config/admin", async () => { + const { client } = fakeClient(({ method, path }) => { + expect(`${method} ${path}`).toBe("GET /system-config/admin"); + return response(200, { app_name: "Acme", rate_limit: 100 }); + }); + expect(await getSystemConfig(client)).toEqual({ + app_name: "Acme", + rate_limit: 100, + }); + }); + + it("maps 403 to a PermissionError", async () => { + const { client } = fakeClient(() => response(403, { error: "Forbidden" })); + await expect(getSystemConfig(client)).rejects.toBeInstanceOf(PermissionError); + }); + + it("throws a ConfigApiError on other failures", async () => { + const { client } = fakeClient(() => response(500, null)); + await expect(getSystemConfig(client)).rejects.toBeInstanceOf(ConfigApiError); + }); +}); + +describe("patchSystemConfig", () => { + it("PATCHes the admin endpoint and returns updatedKeys", async () => { + const { client, calls } = fakeClient(() => + response(200, { success: true, updatedKeys: ["app_name"] }), + ); + + const result = await patchSystemConfig(client, { app_name: "Renamed" }); + expect(result).toEqual({ success: true, updatedKeys: ["app_name"] }); + expect(calls[0]).toEqual({ + method: "PATCH", + path: "/system-config/admin", + body: { app_name: "Renamed" }, + }); + }); + + it("surfaces 400 validation details", async () => { + const { client } = fakeClient(() => + response(400, { error: "Invalid payload", details: { rpid: "required" } }), + ); + await expect(patchSystemConfig(client, { rpid: "" })).rejects.toThrow( + /Invalid payload.*rpid/, + ); + }); + + it("maps 403 to a PermissionError", async () => { + const { client } = fakeClient(() => response(403, { error: "Forbidden" })); + await expect( + patchSystemConfig(client, { app_name: "x" }), + ).rejects.toBeInstanceOf(PermissionError); + }); +}); + +describe("getRoles", () => { + it("returns the roles array", async () => { + const { client } = fakeClient(({ path }) => { + expect(path).toBe("/system-config/roles"); + return response(200, { roles: ["admin", "user"] }); + }); + expect(await getRoles(client)).toEqual(["admin", "user"]); + }); +}); + +describe("parseValue", () => { + it("parses JSON scalars, arrays, and objects, and falls back to string", () => { + expect(parseValue("true")).toBe(true); + expect(parseValue("100")).toBe(100); + expect(parseValue('["email_otp","passkey"]')).toEqual([ + "email_otp", + "passkey", + ]); + expect(parseValue("15m")).toBe("15m"); + expect(parseValue("My App")).toBe("My App"); + }); +}); + +describe("filterWritable", () => { + it("keeps writable keys and reports the rest", () => { + const { patch, dropped } = filterWritable({ + app_name: "Acme", + rpid: "auth.example.com", + frontend_url: "https://app.example.com", + bogus: 1, + }); + expect(patch).toEqual({ app_name: "Acme", rpid: "auth.example.com" }); + expect(dropped.sort()).toEqual(["bogus", "frontend_url"]); + }); +}); + +describe("deepEqual and diffConfig", () => { + it("compares nested structures", () => { + expect(deepEqual({ a: [1, 2], b: { c: 3 } }, { a: [1, 2], b: { c: 3 } })).toBe( + true, + ); + expect(deepEqual([1, 2], [2, 1])).toBe(false); + expect(deepEqual({ a: 1 }, { a: 1, b: 2 })).toBe(false); + }); + + it("reports only changed and added keys from the local file", () => { + const remote = { + app_name: "Acme", + rate_limit: 100, + origins: ["https://a.example.com"], + }; + const local = { + app_name: "Acme", + rate_limit: 250, + login_methods: ["email_otp"], + }; + + expect(diffConfig(local, remote)).toEqual([ + { key: "rate_limit", from: 100, to: 250 }, + { key: "login_methods", from: undefined, to: ["email_otp"] }, + ]); + }); +}); diff --git a/src/core/systemConfig.ts b/src/core/systemConfig.ts new file mode 100644 index 0000000..fc794e5 --- /dev/null +++ b/src/core/systemConfig.ts @@ -0,0 +1,168 @@ +import type { AuthClient } from "./authClient.js"; + +export type SystemConfig = Record; + +export const WRITABLE_KEYS = [ + "app_name", + "default_roles", + "available_roles", + "login_methods", + "passkey_login_fallback_enabled", + "oauth_providers", + "lockout_policy", + "access_token_ttl", + "refresh_token_ttl", + "rate_limit", + "delay_after", + "rpid", + "origins", +] as const; + +const WRITABLE = new Set(WRITABLE_KEYS); + +export class PermissionError extends Error { + constructor( + message = "You do not have permission for this action. It requires an admin role on the instance.", + ) { + super(message); + this.name = "PermissionError"; + } +} + +export class ConfigApiError extends Error { + constructor(message: string) { + super(message); + this.name = "ConfigApiError"; + } +} + +export async function getSystemConfig( + client: AuthClient, +): Promise { + const res = await client.get("/system-config/admin"); + if (res.status === 403) throw new PermissionError(); + if (!res.ok || !res.data) { + throw new ConfigApiError(`Could not read system config (${res.status}).`); + } + return res.data; +} + +export async function getRoles(client: AuthClient): Promise { + const res = await client.get<{ roles?: unknown[] }>("/system-config/roles"); + if (res.status === 403) throw new PermissionError(); + if (!res.ok) { + throw new ConfigApiError(`Could not read roles (${res.status}).`); + } + return Array.isArray(res.data?.roles) + ? res.data.roles.filter((role): role is string => typeof role === "string") + : []; +} + +export interface PatchResult { + success: boolean; + updatedKeys: string[]; +} + +export async function patchSystemConfig( + client: AuthClient, + patch: SystemConfig, +): Promise { + const res = await client.request<{ + success?: boolean; + updatedKeys?: string[]; + error?: string; + details?: unknown; + }>("/system-config/admin", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(patch), + }); + + if (res.status === 403) throw new PermissionError(); + if (res.status === 400) { + const reason = res.data?.error ?? "Invalid configuration"; + const details = res.data?.details + ? ` ${JSON.stringify(res.data.details)}` + : ""; + throw new ConfigApiError(`${reason}.${details}`); + } + if (!res.ok) { + throw new ConfigApiError(`Could not update system config (${res.status}).`); + } + + return { + success: res.data?.success ?? true, + updatedKeys: Array.isArray(res.data?.updatedKeys) + ? res.data.updatedKeys + : [], + }; +} + +export function parseValue(raw: string): unknown { + try { + return JSON.parse(raw.trim()); + } catch { + return raw; + } +} + +export function filterWritable(config: SystemConfig): { + patch: SystemConfig; + dropped: string[]; +} { + const patch: SystemConfig = {}; + const dropped: string[] = []; + for (const [key, value] of Object.entries(config)) { + if (WRITABLE.has(key)) patch[key] = value; + else dropped.push(key); + } + return { patch, dropped }; +} + +export function deepEqual(a: unknown, b: unknown): boolean { + if (a === b) return true; + if (a === null || b === null) return a === b; + if (typeof a !== typeof b) return false; + + if (Array.isArray(a) || Array.isArray(b)) { + if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) { + return false; + } + return a.every((item, i) => deepEqual(item, b[i])); + } + + if (typeof a === "object" && typeof b === "object") { + const aKeys = Object.keys(a as object); + const bKeys = Object.keys(b as object); + if (aKeys.length !== bKeys.length) return false; + return aKeys.every( + (key) => + Object.prototype.hasOwnProperty.call(b, key) && + deepEqual( + (a as Record)[key], + (b as Record)[key], + ), + ); + } + + return false; +} + +export interface ConfigChange { + key: string; + from: unknown; + to: unknown; +} + +export function diffConfig( + local: SystemConfig, + remote: SystemConfig, +): ConfigChange[] { + const changes: ConfigChange[] = []; + for (const [key, to] of Object.entries(local)) { + if (!deepEqual(remote[key], to)) { + changes.push({ key, from: remote[key], to }); + } + } + return changes; +} diff --git a/src/index.ts b/src/index.ts index 4be579d..437b1ba 100755 --- a/src/index.ts +++ b/src/index.ts @@ -11,6 +11,7 @@ import { runLogin } from "./commands/login.js"; import { runWhoami } from "./commands/whoami.js"; import { runLogout } from "./commands/logout.js"; import { runSessions } from "./commands/sessions.js"; +import { runConfig } from "./commands/config.js"; export const VERSION = pkg.version; const args = process.argv.slice(2); @@ -84,6 +85,11 @@ async function main() { return; } + if (command === "config") { + await runConfig(args.slice(1)); + return; + } + await runCLI(command); }