Skip to content
Open
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
22 changes: 21 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
Bun/TypeScript executable for humans, automation, and coding agents. The current
MVP implements local token authentication, adaptive structured output, and
discovery of the public operationId-driven command registry. Generated API
commands are discoverable but not yet executable unless `akua --help` says so.
commands are discovery-only unless listed as executable by `akua --help`.

The canonical executable is `akua`; there is no `cnap` compatibility binary.

Expand Down Expand Up @@ -139,6 +139,26 @@ akua auth status
akua auth logout
```

## Executable capacity overlays

Five reviewed public API overlays are executable in addition to the
discovery-only generated registry:

```sh
akua clusters get --id <clu_id> [--workspace <ws_id>]
akua compute-configs list --view full [--workspace <ws_id>]
akua compute list-instance-types --config <config-name>
akua machines list --cluster-id <clu_id> --view full [--workspace <ws_id>]
akua machines create --cluster-id <clu_id> --compute-config-id <config_id> --instance-type <exact-type> --idempotency-key <stable-key> [--workspace <ws_id>] --yes
```

They use the fixed production API, validate canonical resource IDs and bounded
names locally, and authenticate with `AKUA_API_TOKEN` before the protected local
config. Machine creation targets only canonical `POST /v1/machines`, submits
once, and requires explicit `--yes` plus a caller-visible idempotency key. All
other generated mutations remain unavailable; registry discovery does not imply
execution support.

`AKUA_API_TOKEN` takes precedence over a stored token. Login writes
`~/.config/akua/config.json`; the directory is forced to `0700` and the file to
`0600`. Login replaces only `token` and preserves unknown config keys. Logout
Expand Down
23 changes: 16 additions & 7 deletions docs/architecture.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Akua Cloud CLI Architecture

Status: greenfield scaffold with local auth/config MVP.
Status: local auth/config MVP with bounded executable capacity overlays.

## Decisions And Non-Goals

Expand Down Expand Up @@ -128,12 +128,16 @@ segment becomes the action, and single-segment operationIds fall back to the
HTTP method as the action. The generator assumes OpenAPI operationIds are
unique; it does not currently enforce uniqueness itself.

The generator deliberately produces a registry, not hand-written API coverage.
Execution is stubbed until the API client and request/body binding layer lands.
The next implementation step should add a small CLI overlay file for exceptions
that cannot be inferred safely from OpenAPI alone, such as preferred aliases,
default list fields, destructive-command confirmation labels, and resource-
specific next steps.
The generator deliberately produces a discovery registry, not generic API
execution. A small hand-written capacity overlay executes only reviewed
contracts: `clusters get`, `compute-configs list --view full`,
`compute list-instance-types --config <config-name>`,
`machines list --cluster-id <clu_id> --view full`, and canonical
`machines create --cluster-id <clu_id> --compute-config-id <config_id>
--instance-type <exact-type> --idempotency-key <stable-key> [--workspace
<ws_id>] --yes`. All other generated rows are discovery-only. The create
overlay binds a closed three-field body to `POST /v1/machines`; it does not
enable the legacy untyped `compute.createMachine` `node_claim` contract.

Generation tasks:

Expand Down Expand Up @@ -239,6 +243,11 @@ akua # registry status home view
akua auth login --token <token> # save a local API token
akua auth status # show effective auth source
akua auth logout # remove the saved local API token
akua clusters get --id <clu_id> # executable reviewed API overlay
akua compute-configs list --view full # executable reviewed API overlay
akua compute list-instance-types --config <config-name>
akua machines list --cluster-id <clu_id> --view full
akua machines create --cluster-id <clu_id> --compute-config-id <config_id> --instance-type <exact-type> --idempotency-key <stable-key> [--workspace <ws_id>] --yes
akua commands # first 20 generated public commands
akua commands --resource workspaces # resource filter
akua commands --operation-id workspaces.list
Expand Down
21 changes: 21 additions & 0 deletions src/bin/akua.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
#!/usr/bin/env bun
import { authView } from "../commands/auth";
import { agentOsView } from "../commands/agent-os";
import { capacityView } from "../commands/capacity";
import { buildHomeView } from "../commands/home";
import { commandRegistry } from "../generated/commands.gen";
import { AkuaCliError, commandNotImplemented, usageError } from "../runtime/errors";
Expand Down Expand Up @@ -52,6 +53,10 @@ async function route(argv: readonly string[], env: Record<string, string | undef
return agentOsView(argv.slice(1), env);
}

if (isCapacityOverlay(argv)) {
return capacityView(argv, env);
}

const unknownFlag = argv.find((arg) => arg.startsWith("-"));
if (unknownFlag) {
throw usageError(`Unknown flag: ${flagName(unknownFlag)}`);
Expand Down Expand Up @@ -101,6 +106,11 @@ function helpView(): RenderEnvelope {
" akua auth status Show local authentication status",
" akua auth logout Remove the saved local API token",
" akua agent-os load-hcloud-provider --workspace <exact-name-or-ws_id> --token-file <absolute-path> [--expected-ssh-key-fingerprint <fingerprint> [--expected-ssh-key-name <name>]]",
" akua clusters get --id <clu_id> [--workspace <ws_id>]",
" akua compute-configs list --view full [--workspace <ws_id>]",
" akua compute list-instance-types --config <config-name>",
" akua machines list --cluster-id <clu_id> --view full [--workspace <ws_id>]",
" akua machines create --cluster-id <clu_id> --compute-config-id <config_id> --instance-type <exact-type> --idempotency-key <stable-key> [--workspace <ws_id>] --yes",
" akua commands List generated public OpenAPI command registry",
" akua --help Show help",
" akua --version Show version",
Expand All @@ -112,6 +122,17 @@ function helpView(): RenderEnvelope {
};
}

function isCapacityOverlay(argv: readonly string[]): boolean {
const command = `${argv[0] ?? ""} ${argv[1] ?? ""}`;
return (
command === "clusters get" ||
command === "compute-configs list" ||
command === "compute list-instance-types" ||
command === "machines list" ||
command === "machines create"
);
}

function stripGlobalFlags(argv: readonly string[]): string[] {
const stripped: string[] = [];
for (let index = 0; index < argv.length; index += 1) {
Expand Down
17 changes: 17 additions & 0 deletions src/commands/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,23 @@ export async function readProtectedCallerToken(env: Record<string, string | unde
return token;
}

export async function readPublicApiToken(env: Record<string, string | undefined>): Promise<string> {
if (hasEnvToken(env)) {
return env.AKUA_API_TOKEN as string;
}

const token = (await readConfig(resolveConfigPath(env))).token;
if (typeof token !== "string" || token === "") {
throw new AkuaCliError({
type: "authentication_error",
code: "AKUA_PUBLIC_API_AUTH_REQUIRED",
message: "An Akua API token is required.",
exitCode: 3,
});
}
return token;
}

async function loginView(argv: readonly string[], env: Record<string, string | undefined>): Promise<RenderEnvelope> {
const token = parseLoginFlags(argv);
const configPath = resolveConfigPath(env);
Expand Down
201 changes: 201 additions & 0 deletions src/commands/capacity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
import { usageError } from "../runtime/errors";
import { PublicApiClient, type ApiFetch } from "../runtime/public-api-client";
import type { RenderEnvelope } from "../runtime/render";
import { readPublicApiToken } from "./auth";

export interface CapacityDependencies {
readToken(env: Record<string, string | undefined>): Promise<string>;
fetch: ApiFetch;
}

const productionDependencies: CapacityDependencies = {
readToken: readPublicApiToken,
fetch,
};

interface ParsedCommand {
command: string;
path?: string;
workspace?: string;
create?: {
cluster_id: string;
compute_config_id: string;
instance_type: string;
idempotency_key: string;
};
}

export async function capacityView(
argv: readonly string[],
env: Record<string, string | undefined>,
dependencies: CapacityDependencies = productionDependencies,
): Promise<RenderEnvelope> {
const parsed = parseCommand(argv);
const token = await dependencies.readToken(env);
const client = new PublicApiClient(token, dependencies.fetch);
if (parsed.create !== undefined) {
const { idempotency_key, ...body } = parsed.create;
const operation = await client.createMachine(body, idempotency_key, parsed.workspace);
return {
command: parsed.command,
observations: ["Machine creation accepted. No automatic retry was attempted."],
data: { ...operation, idempotency_key },
};
}
const data = await client.get(parsed.path as string, parsed.workspace);
return { command: parsed.command, data };
}

function parseCommand(argv: readonly string[]): ParsedCommand {
const operation = `${argv[0] ?? ""} ${argv[1] ?? ""}`;
const flags = parseFlags(argv.slice(2));

if (operation === "clusters get") {
allowOnly(flags, ["--id", "--workspace"]);
const id = requiredCanonicalId(flags, "--id", "clu");
const workspace = optionalCanonicalId(flags, "--workspace", "ws");
return { command: "akua clusters get", path: `/v1/clusters/${id}`, workspace };
}

if (operation === "compute-configs list") {
allowOnly(flags, ["--view", "--workspace"]);
requireExactValue(flags, "--view", "full");
const workspace = optionalCanonicalId(flags, "--workspace", "ws");
return { command: "akua compute-configs list", path: "/v1/compute_configs?view=full", workspace };
}

if (operation === "compute list-instance-types") {
allowOnly(flags, ["--config"]);
const config = requiredBoundedValue(flags, "--config", 54);
return {
command: "akua compute list-instance-types",
path: `/v1/compute/instance_types?config=${encodeURIComponent(config)}`,
};
}

if (operation === "machines list") {
allowOnly(flags, ["--cluster-id", "--view", "--workspace"]);
const cluster = requiredCanonicalId(flags, "--cluster-id", "clu");
requireExactValue(flags, "--view", "full");
const workspace = optionalCanonicalId(flags, "--workspace", "ws");
return {
command: "akua machines list",
path: `/v1/machines?cluster_id=${cluster}&view=full`,
workspace,
};
}

if (operation === "machines create") {
allowOnly(flags, ["--cluster-id", "--compute-config-id", "--instance-type", "--idempotency-key", "--workspace", "--yes"]);
const cluster_id = requiredCanonicalId(flags, "--cluster-id", "clu");
const compute_config_id = requiredOpaquePublicId(flags, "--compute-config-id");
const instance_type = requiredBoundedValue(flags, "--instance-type", 120);
const idempotency_key = requiredBoundedValue(flags, "--idempotency-key", 64);
const workspace = optionalCanonicalId(flags, "--workspace", "ws");
if (flags.get("--yes") !== true) {
throw usageError("machines create requires explicit --yes confirmation.");
}
return {
command: "akua machines create",
workspace,
create: { cluster_id, compute_config_id, instance_type, idempotency_key },
};
}

throw usageError("Unknown capacity command.");
}

function parseFlags(argv: readonly string[]): Map<string, string | true> {
const flags = new Map<string, string | true>();
for (let index = 0; index < argv.length; index += 1) {
const raw = argv[index];
if (!raw.startsWith("--")) {
throw usageError("Unexpected capacity command argument.");
}
const equals = raw.indexOf("=");
const name = equals === -1 ? raw : raw.slice(0, equals);
if (flags.has(name)) {
throw usageError(`Flag ${name} may be specified only once.`);
}
if (name === "--yes") {
if (equals !== -1) {
throw usageError("--yes does not accept a value.");
}
flags.set(name, true);
continue;
}
const value = equals === -1 ? argv[index + 1] : raw.slice(equals + 1);
if (value === undefined || value === "" || value.startsWith("--")) {
throw usageError(`Missing value for ${name}.`);
}
flags.set(name, value);
if (equals === -1) {
index += 1;
}
}
return flags;
}

function allowOnly(flags: ReadonlyMap<string, string | true>, allowed: readonly string[]): void {
for (const name of flags.keys()) {
if (!allowed.includes(name)) {
throw usageError(`Unknown flag: ${name}`);
}
}
}

function requiredCanonicalId(flags: ReadonlyMap<string, string | true>, name: string, prefix: string): string {
const value = requiredValue(flags, name);
if (!new RegExp(`^${prefix}_[a-z0-9]{32}$`).test(value)) {
throw usageError(`${name} must be a canonical ${prefix}_ ID.`);
}
return value;
}

function optionalCanonicalId(
flags: ReadonlyMap<string, string | true>,
name: string,
prefix: string,
): string | undefined {
if (!flags.has(name)) {
return undefined;
}
return requiredCanonicalId(flags, name, prefix);
}

function requiredValue(flags: ReadonlyMap<string, string | true>, name: string): string {
const value = flags.get(name);
if (typeof value !== "string" || value === "") {
throw usageError(`Missing required ${name} flag.`);
}
return value;
}

function requiredBoundedValue(
flags: ReadonlyMap<string, string | true>,
name: string,
maxLength: number,
): string {
const value = requiredValue(flags, name);
if (value.length > maxLength || /[\u0000-\u001f\u007f]/.test(value)) {
throw usageError(`${name} must be at most ${maxLength} characters and contain no control characters.`);
}
return value;
}

function requiredOpaquePublicId(flags: ReadonlyMap<string, string | true>, name: string): string {
const value = requiredValue(flags, name);
// Public IDs have an opaque 32-character payload and a resource prefix. The
// OpenAPI contract does not assign a literal prefix to compute configs.
if (value.length > 54 || !/^[a-z][a-z0-9]{0,20}_[a-z0-9]{32}$/.test(value)) {
throw usageError(`${name} must be a prefixed opaque public ID.`);
}
return value;
}

function requireExactValue(flags: ReadonlyMap<string, string | true>, name: string, expected: string): void {
const value = requiredValue(flags, name);
if (value !== expected) {
throw usageError(`${name} must be ${expected}.`);
}
}
Loading
Loading