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
14 changes: 14 additions & 0 deletions .changeset/config-command.md
Original file line number Diff line number Diff line change
@@ -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 <key> <value>` 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 <file>` shows how a local JSON config file
differs from the instance, and `config apply <file>` 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`.
231 changes: 231 additions & 0 deletions src/commands/config.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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 <get|set|roles|diff|apply>");
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<void> {
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<void> {
const positional = rest.filter((arg) => !arg.startsWith("--"));
const [key, ...valueParts] = positional;
if (!key || valueParts.length === 0) {
console.error(kleur.red("Usage: seamless config set <key> <value>"));
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<void> {
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<void> {
const file = rest.find((arg) => !arg.startsWith("--"));
if (!file) {
console.error(kleur.red("Usage: seamless config diff <file>"));
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<void> {
const dryRun = rest.includes("--dry-run");
const file = rest.find((arg) => !arg.startsWith("--"));
if (!file) {
console.error(kleur.red("Usage: seamless config apply <file> [--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)}`));
}
}
21 changes: 21 additions & 0 deletions src/commands/help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ USAGE
seamless logout [--all] [--profile <name>]
seamless sessions [list]
seamless sessions revoke <id | --all>
seamless config <get|set|roles|diff|apply>
seamless --help
seamless --version

Expand Down Expand Up @@ -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 <get|set|roles|diff|apply>
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 <key> <value>
• 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 <file>
• Show how a local JSON config file differs from the instance

config apply <file> [--dry-run]
• Apply a local JSON config file after a confirmation prompt

check
Validate project setup, Docker, and running services

Expand Down
Loading
Loading