diff --git a/README.md b/README.md index 2b72f2f..a54b996 100644 --- a/README.md +++ b/README.md @@ -10,8 +10,9 @@ release. ## Current Status This scaffold establishes the architecture, packaging path, OpenAPI fetch task, -public operation registry generation, release automation, and output/error -runtime contract. It does not yet implement full API command execution. +public operation registry generation, release automation, output/error runtime +contract, and local auth/config token handling. It does not yet implement full +API command execution. ## Development @@ -40,10 +41,13 @@ mise run generate # regenerate public command registry mise run generate:check # verify generated registry is current ``` -Implemented scaffold commands: +Implemented commands: ```sh akua # show compact registry status +akua auth login --token # save a local API token +akua auth status # show effective auth source +akua auth logout # remove the saved local API token akua commands # list first 20 generated public commands akua commands --resource workspaces # filter by generated resource akua commands --operation-id workspaces.list @@ -52,6 +56,15 @@ akua --help # also -h akua --version # also -v or -V ``` +## Authentication + +`AKUA_API_TOKEN` is the primary noninteractive credential and takes precedence +over any stored token. `akua auth login --token ` writes the token to +`~/.config/akua/config.json`, preserving unrelated config keys and setting the +Akua config directory to `0700` and file to `0600`. `akua auth logout` removes +only the stored token; it does not clear `AKUA_API_TOKEN`. Browser/device login +is not implemented in this MVP slice. + ## OpenAPI Source The live production source of truth is: diff --git a/docs/architecture.md b/docs/architecture.md index 30c3022..f9fce06 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,6 +1,6 @@ # Akua Cloud CLI Architecture -Status: initial greenfield spec and scaffold. +Status: greenfield scaffold with local auth/config MVP. ## Decisions And Non-Goals @@ -9,7 +9,7 @@ Status: initial greenfield spec and scaffold. - Repository: standalone open-source `akua-dev/cli`. - Packaging: Bun self-contained executable via `bun build --compile`. - API source of truth: `https://api.akua.dev/v1/openapi.json`. -- First release: public API commands only. +- First release: local auth/config plus public API commands only. - Compatibility: no `cnap` binary, Go module, config path, env var, or command compatibility unless a later captain decision changes this. - No live infrastructure mutation is required for development or tests. The @@ -26,6 +26,7 @@ openapi/public.json fetched public OpenAPI snapshot scripts/fetch-openapi.ts guarded production spec fetcher scripts/generate-commands.ts operationId-driven command registry generator src/bin/akua.ts executable entrypoint +src/commands/auth.ts local auth/config command implementation src/runtime/ output, errors, exit codes, command contracts src/generated/commands.gen.ts generated public command registry .github/workflows/update-openapi.yml @@ -36,7 +37,7 @@ src/generated/commands.gen.ts generated public command registry release-please-config.json Release Please manifest-mode config .release-please-manifest.json Release Please root package version manifest docs/architecture.md this spec -test/ Bun tests for scaffold contracts +test/ Bun tests for scaffold and auth/config contracts ``` ## OpenAPI And Command Generation @@ -105,6 +106,7 @@ Authentication: - bearer tokens use `Authorization: Bearer sk_akua_...`; - `AKUA_API_TOKEN` is the primary noninteractive credential env var; +- `AKUA_API_TOKEN` takes precedence over stored credentials; - broad tokens select workspace/scope with the `Akua-Context` header; - workspace-owned tokens may imply workspace context. @@ -114,6 +116,11 @@ Configuration should live under the Akua namespace: ~/.config/akua/config.json ``` +The implemented config file is JSON. The local auth MVP stores a `token` string +there while preserving unrelated keys. Writes create `~/.config/akua` with +user-only `0700` permissions and `config.json` with user-only `0600` +permissions. + Recommended config precedence: 1. command flags such as `--api-url`, `--workspace`, and `--profile`; @@ -121,9 +128,19 @@ Recommended config precedence: 3. profile config; 4. built-in production defaults. -The first release should implement `akua auth login --token `, `akua auth -status`, and `akua auth logout` before browser/device login. Tokens must be -stored with user-only file permissions. +The first implemented local auth/config slice is: + +```sh +akua auth login --token # save a token in ~/.config/akua/config.json +akua auth status # show whether auth comes from env, config, or none +akua auth logout # remove only the stored config token +``` + +`auth login` requires `HOME` so it can locate the config file. `auth status` +honors `AKUA_API_TOKEN` even when `HOME` is unset, and otherwise reads the +stored token. `auth logout` leaves `AKUA_API_TOKEN` untouched and reports env +auth as still active when that variable is set. Browser/device login remains out +of scope. ## Output And UX Modes @@ -155,10 +172,13 @@ Human mode can use tables and prose, but should stay content-first. A no-args `akua` invocation should show live state once API execution exists; the scaffold currently shows registry state and next-step commands. -The implemented scaffold command surface is intentionally small: +The implemented command surface is intentionally small: ```sh akua # registry status home view +akua auth login --token # save a local API token +akua auth status # show effective auth source +akua auth logout # remove the saved local API token akua commands # first 20 generated public commands akua commands --resource workspaces # resource filter akua commands --operation-id workspaces.list @@ -206,14 +226,14 @@ This can be simplified later, but it must remain deterministic and tested. ## Public-Only First Release -The first release command surface is generated only from public operations. +The first release API command surface is generated only from public operations. Internal, admin, preview, trusted-partner, and private operations must be absent from generated commands and docs unless a separate build target is deliberately added later. Recommended MVP order: -1. `auth` and config/profile commands; +1. local `auth` and token config commands (implemented); 2. workspace/context commands; 3. read-only `list` and `get` commands for public resources; 4. operations status/watch commands; @@ -261,6 +281,8 @@ uploading the Linux x64 binary artifact. Current tests cover: - CLI routing and usage validation for the scaffold commands; +- local auth login/status/logout behavior, env credential precedence, config + preservation, malformed config handling, and user-only config permissions; - output mode detection; - agent and JSON rendering; - structured error payloads; @@ -274,7 +296,7 @@ registry drift. Next tests should add: - golden command output by mode; -- mocked API calls for auth, workspace, list/get, and operation flows; +- mocked API calls for workspace, list/get, and operation flows; - destructive command refusal tests in CI/non-TTY/agent modes; - packaging smoke test for `dist/akua --help`. diff --git a/src/bin/akua.ts b/src/bin/akua.ts index 6380fbc..34020c7 100644 --- a/src/bin/akua.ts +++ b/src/bin/akua.ts @@ -1,4 +1,5 @@ #!/usr/bin/env bun +import { authView } from "../commands/auth"; import { buildHomeView } from "../commands/home"; import { commandRegistry } from "../generated/commands.gen"; import { AkuaCliError, commandNotImplemented, usageError } from "../runtime/errors"; @@ -11,7 +12,7 @@ export async function main(argv = process.argv.slice(2), env = process.env): Pro let mode: OutputMode = fallbackErrorMode(argv); try { mode = detectOutputMode({ argv, env, stdoutIsTTY: process.stdout.isTTY }); - const command = route(stripGlobalFlags(argv)); + const command = await route(stripGlobalFlags(argv), env); process.stdout.write(renderSuccess(command, mode)); return 0; } catch (error) { @@ -21,7 +22,7 @@ export async function main(argv = process.argv.slice(2), env = process.env): Pro } } -function route(argv: readonly string[]): RenderEnvelope { +async function route(argv: readonly string[], env: Record): Promise { if (argv.length === 0) { return buildHomeView(); } @@ -42,6 +43,10 @@ function route(argv: readonly string[]): RenderEnvelope { return commandsView(argv.slice(1)); } + if (argv[0] === "auth") { + return authView(argv.slice(1), env); + } + const unknownFlag = argv.find((arg) => arg.startsWith("-")); if (unknownFlag) { throw usageError(`Unknown flag: ${flagName(unknownFlag)}`); @@ -87,6 +92,9 @@ function helpView(): RenderEnvelope { "Usage: akua [--output human|agent|json|quiet] ", "Commands:", " akua Show compact home view", + " akua auth login Save a local API token", + " akua auth status Show local authentication status", + " akua auth logout Remove the saved local API token", " akua commands List generated public OpenAPI command registry", " akua --help Show help", " akua --version Show version", diff --git a/src/commands/auth.ts b/src/commands/auth.ts new file mode 100644 index 0000000..64fd13e --- /dev/null +++ b/src/commands/auth.ts @@ -0,0 +1,309 @@ +import { randomUUID } from "node:crypto"; +import { chmod, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; + +import { AkuaCliError, usageError } from "../runtime/errors"; +import type { RenderEnvelope } from "../runtime/render"; + +const CONFIG_FILE_MODE = 0o600; +const CONFIG_DIR_MODE = 0o700; + +type AkuaConfig = Record & { + token?: string; +}; + +type CredentialSource = "env" | "config" | "none"; + +interface AuthStatus { + authenticated: boolean; + source: CredentialSource; + config_path?: string; +} + +class ConfigParseError extends AkuaCliError {} + +export async function authView(argv: readonly string[], env: Record): Promise { + const subcommand = argv[0]; + if (subcommand === undefined) { + throw usageError("Missing auth subcommand."); + } + + if (subcommand === "login") { + return loginView(argv.slice(1), env); + } + if (subcommand === "status") { + return statusView(argv.slice(1), env); + } + if (subcommand === "logout") { + return logoutView(argv.slice(1), env); + } + + throw usageError("Unknown auth subcommand."); +} + +async function loginView(argv: readonly string[], env: Record): Promise { + const token = parseLoginFlags(argv); + const configPath = resolveConfigPath(env); + await saveStoredToken(configPath, token); + + return { + command: "akua auth login", + observations: ["Authentication token saved."], + data: { + authenticated: true, + source: "config", + config_path: configPath, + } satisfies AuthStatus, + next_steps: [{ command: "akua auth status" }], + }; +} + +async function statusView(argv: readonly string[], env: Record): Promise { + rejectUnexpectedAuthArgs("status", argv); + const configPath = optionalConfigPath(env); + const source = hasEnvToken(env) ? "env" : await storedCredentialSource(configPath ?? resolveConfigPath(env)); + const authenticated = source !== "none"; + + return { + command: "akua auth status", + observations: [statusObservation(source)], + data: { + authenticated, + source, + config_path: configPath, + } satisfies AuthStatus, + next_steps: authenticated ? undefined : [{ command: "akua auth login --token " }], + }; +} + +async function logoutView(argv: readonly string[], env: Record): Promise { + rejectUnexpectedAuthArgs("logout", argv); + const configPath = resolveConfigPath(env); + const hadStoredToken = await removeStoredToken(configPath); + const envStillAuthenticated = hasEnvToken(env); + + return { + command: "akua auth logout", + observations: [logoutObservation(hadStoredToken, envStillAuthenticated)], + data: { + authenticated: envStillAuthenticated, + source: envStillAuthenticated ? "env" : "none", + config_path: configPath, + } satisfies AuthStatus, + next_steps: envStillAuthenticated ? [{ command: "unset AKUA_API_TOKEN" }] : [{ command: "akua auth login --token " }], + }; +} + +function parseLoginFlags(argv: readonly string[]): string { + let token: string | undefined; + for (let index = 0; index < argv.length; index += 1) { + const value = argv[index]; + if (!value.startsWith("-")) { + throw usageError("Unexpected argument for auth login."); + } + + const name = flagName(value); + if (name !== "--token") { + throw usageError(`Unknown flag: ${name}`); + } + + const raw = readFlagValue(argv, index, name); + if (raw.value === undefined || raw.value === "") { + throw usageError("Missing value for --token."); + } + token = raw.value; + if (raw.consumedNext) { + index += 1; + } + } + + if (token === undefined) { + throw usageError("Missing required --token flag."); + } + return token; +} + +function rejectUnexpectedAuthArgs(subcommand: string, argv: readonly string[]): void { + if (argv.length > 0) { + const first = argv[0]; + throw first.startsWith("-") + ? usageError(`Unknown flag: ${flagName(first)}`) + : usageError(`Unexpected argument for auth ${subcommand}.`); + } +} + +async function storedCredentialSource(configPath: string): Promise { + if (hasStoredToken(await readConfig(configPath))) { + return "config"; + } + return "none"; +} + +function hasEnvToken(env: Record): boolean { + return env.AKUA_API_TOKEN !== undefined && env.AKUA_API_TOKEN !== ""; +} + +function resolveConfigPath(env: Record): string { + const home = env.HOME; + if (home === undefined || home === "") { + throw usageError("HOME is required to locate ~/.config/akua/config.json."); + } + return join(home, ".config", "akua", "config.json"); +} + +function optionalConfigPath(env: Record): string | undefined { + const home = env.HOME; + return home === undefined || home === "" ? undefined : join(home, ".config", "akua", "config.json"); +} + +async function readConfig(configPath: string): Promise { + const raw = await readConfigText(configPath); + if (raw === undefined) { + return {}; + } + return parseConfig(raw, configPath); +} + +async function readConfigText(configPath: string): Promise { + try { + return await readFile(configPath, "utf8"); + } catch (error) { + if (error instanceof Error && "code" in error && error.code === "ENOENT") { + return undefined; + } + throw configError("read", configPath, error); + } +} + +function parseConfig(raw: string, configPath: string): AkuaConfig { + try { + const parsed = JSON.parse(raw) as unknown; + if (isConfigObject(parsed)) { + return parsed; + } + throw new Error("Akua config must be a JSON object."); + } catch (error) { + throw new ConfigParseError({ + type: "runtime_error", + code: "AKUA_CONFIG_ERROR", + message: `Failed to read Akua config at ${configPath}: ${errorMessage(error)}`, + }); + } +} + +async function saveStoredToken(configPath: string, token: string): Promise { + const config = await readConfig(configPath); + await writeConfig(configPath, { ...config, token }); +} + +async function writeConfig(configPath: string, config: AkuaConfig): Promise { + const configDir = dirname(configPath); + const tempPath = join(configDir, `.config.json.${randomUUID()}.tmp`); + + try { + await mkdir(configDir, { recursive: true, mode: CONFIG_DIR_MODE }); + await chmod(configDir, CONFIG_DIR_MODE); + await writeFile(tempPath, `${JSON.stringify(config, null, 2)}\n`, { mode: CONFIG_FILE_MODE, flag: "wx" }); + await chmod(tempPath, CONFIG_FILE_MODE); + await rename(tempPath, configPath); + await chmod(configPath, CONFIG_FILE_MODE); + } catch (error) { + await rm(tempPath, { force: true }).catch(() => undefined); + throw configError("write", configPath, error); + } +} + +async function removeStoredToken(configPath: string): Promise { + const raw = await readConfigText(configPath); + if (raw === undefined) { + return false; + } + + let config: AkuaConfig; + try { + config = parseConfig(raw, configPath); + } catch (error) { + if (error instanceof ConfigParseError) { + try { + await rm(configPath, { force: true }); + return true; + } catch (removeError) { + throw configError("remove", configPath, removeError); + } + } + throw configError("remove", configPath, error); + } + + const hadStoredToken = hasStoredToken(config); + if (!hasOwnToken(config)) { + return false; + } + + delete config.token; + await writeConfig(configPath, config); + return hadStoredToken; +} + +function isConfigObject(value: unknown): value is AkuaConfig { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function hasStoredToken(config: AkuaConfig): boolean { + return typeof config.token === "string" && config.token !== ""; +} + +function hasOwnToken(config: AkuaConfig): boolean { + return Object.prototype.hasOwnProperty.call(config, "token"); +} + +function configError(operation: "read" | "write" | "remove", configPath: string, error: unknown): AkuaCliError { + return new AkuaCliError({ + type: "runtime_error", + code: "AKUA_CONFIG_ERROR", + message: `Failed to ${operation} Akua config at ${configPath}: ${errorMessage(error)}`, + }); +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function statusObservation(source: CredentialSource): string { + if (source === "env") { + return "Authenticated with AKUA_API_TOKEN."; + } + if (source === "config") { + return "Authenticated with stored token."; + } + return "No Akua authentication token found."; +} + +function logoutObservation(hadStoredToken: boolean, envStillAuthenticated: boolean): string { + if (envStillAuthenticated) { + return hadStoredToken + ? "Stored authentication token removed. AKUA_API_TOKEN is still active." + : "No stored authentication token found. AKUA_API_TOKEN is still active."; + } + return hadStoredToken ? "Stored authentication token removed." : "No stored authentication token found."; +} + +function readFlagValue( + argv: readonly string[], + index: number, + flag: string, +): { value: string | undefined; consumedNext: boolean } { + const value = argv[index]; + if (value === flag) { + const next = argv[index + 1]; + if (next === undefined || next.startsWith("-")) { + return { value: undefined, consumedNext: false }; + } + return { value: next, consumedNext: true }; + } + + return { value: value.slice(flag.length + 1), consumedNext: false }; +} + +function flagName(value: string): string { + return value.includes("=") ? value.slice(0, value.indexOf("=")) : value; +} diff --git a/test/cli.test.ts b/test/cli.test.ts index a7d6951..6484209 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -1,4 +1,9 @@ import { describe, expect, test } from "bun:test"; +import { chmod, mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { authView } from "../src/commands/auth"; +import { renderSuccess } from "../src/runtime/render"; describe("akua entrypoint", () => { test("fails loudly on unknown flags", async () => { @@ -100,6 +105,315 @@ describe("akua entrypoint", () => { }, }); }); + + test("auth login stores a token with user-only permissions", async () => { + const home = await makeTempHome(); + try { + const token = "sk_akua_test_login"; + const { stdout, exitCode } = await runAkua(["auth", "login", "--token", token, "--json"], { HOME: home }); + const payload = JSON.parse(stdout); + const configPath = join(home, ".config", "akua", "config.json"); + + expect(exitCode).toBe(0); + expect(stdout).not.toContain(token); + expect(payload).toMatchObject({ + status: "ok", + command: "akua auth login", + data: { + authenticated: true, + source: "config", + config_path: configPath, + }, + }); + expect(JSON.parse(await readFile(configPath, "utf8"))).toEqual({ token }); + expect((await stat(join(home, ".config", "akua"))).mode & 0o777).toBe(0o700); + expect((await stat(configPath)).mode & 0o777).toBe(0o600); + } finally { + await rm(home, { recursive: true, force: true }); + } + }); + + test("auth login replaces an existing protected config file", async () => { + const home = await makeTempHome(); + try { + const configPath = join(home, ".config", "akua", "config.json"); + await runAkua(["auth", "login", "--token", "sk_akua_old", "--quiet"], { HOME: home }); + await chmod(configPath, 0o444); + + const { stdout, exitCode } = await runAkua(["auth", "login", "--token", "sk_akua_new", "--json"], { HOME: home }); + + expect(exitCode).toBe(0); + expect(JSON.parse(stdout)).toMatchObject({ + status: "ok", + command: "akua auth login", + }); + expect(JSON.parse(await readFile(configPath, "utf8"))).toEqual({ token: "sk_akua_new" }); + expect((await stat(configPath)).mode & 0o777).toBe(0o600); + } finally { + await rm(home, { recursive: true, force: true }); + } + }); + + test("auth login preserves unrelated config keys", async () => { + const home = await makeTempHome(); + try { + const configDir = join(home, ".config", "akua"); + const configPath = join(configDir, "config.json"); + await mkdir(configDir, { recursive: true }); + await writeFile( + configPath, + `${JSON.stringify({ profile: "dev", endpoint: "https://api.example.test", token: "sk_akua_old" }, null, 2)}\n`, + ); + + const { exitCode } = await runAkua(["auth", "login", "--token", "sk_akua_new", "--json"], { HOME: home }); + + expect(exitCode).toBe(0); + expect(JSON.parse(await readFile(configPath, "utf8"))).toEqual({ + profile: "dev", + endpoint: "https://api.example.test", + token: "sk_akua_new", + }); + expect((await stat(configPath)).mode & 0o777).toBe(0o600); + } finally { + await rm(home, { recursive: true, force: true }); + } + }); + + test("auth status gives AKUA_API_TOKEN precedence over stored tokens", async () => { + const home = await makeTempHome(); + try { + await runAkua(["auth", "login", "--token", "sk_akua_stored", "--quiet"], { HOME: home }); + const { stdout, exitCode } = await runAkua(["auth", "status", "--json"], { + HOME: home, + AKUA_API_TOKEN: "sk_akua_env", + }); + + expect(exitCode).toBe(0); + expect(JSON.parse(stdout)).toMatchObject({ + status: "ok", + command: "akua auth status", + observations: ["Authenticated with AKUA_API_TOKEN."], + data: { + authenticated: true, + source: "env", + }, + }); + expect(stdout).not.toContain("sk_akua_env"); + expect(stdout).not.toContain("sk_akua_stored"); + } finally { + await rm(home, { recursive: true, force: true }); + } + }); + + test("auth status honors AKUA_API_TOKEN without HOME", async () => { + for (const home of [undefined, ""]) { + const envelope = await authView(["status"], { + HOME: home, + AKUA_API_TOKEN: "sk_akua_env", + }); + const stdout = renderSuccess(envelope, "json"); + const payload = JSON.parse(stdout); + + expect(payload).toMatchObject({ + status: "ok", + command: "akua auth status", + observations: ["Authenticated with AKUA_API_TOKEN."], + data: { + authenticated: true, + source: "env", + }, + }); + expect(payload.data).not.toHaveProperty("config_path"); + expect(stdout).not.toContain("sk_akua_env"); + } + }); + + test("auth logout removes stored token without clearing AKUA_API_TOKEN", async () => { + const home = await makeTempHome(); + try { + await runAkua(["auth", "login", "--token", "sk_akua_stored", "--quiet"], { HOME: home }); + const { stdout, exitCode } = await runAkua(["auth", "logout", "--json"], { + HOME: home, + AKUA_API_TOKEN: "sk_akua_env", + }); + const status = await runAkua(["auth", "status", "--json"], { HOME: home }); + + expect(exitCode).toBe(0); + expect(JSON.parse(stdout)).toMatchObject({ + observations: ["Stored authentication token removed. AKUA_API_TOKEN is still active."], + data: { + authenticated: true, + source: "env", + }, + }); + expect(JSON.parse(status.stdout)).toMatchObject({ + data: { + authenticated: false, + source: "none", + }, + }); + } finally { + await rm(home, { recursive: true, force: true }); + } + }); + + test("auth logout removes only the stored token", async () => { + const home = await makeTempHome(); + try { + const configDir = join(home, ".config", "akua"); + const configPath = join(configDir, "config.json"); + await mkdir(configDir, { recursive: true }); + await writeFile( + configPath, + `${JSON.stringify({ profile: "dev", endpoint: "https://api.example.test", token: "sk_akua_stored" }, null, 2)}\n`, + ); + + const { stdout, exitCode } = await runAkua(["auth", "logout", "--json"], { HOME: home }); + const status = await runAkua(["auth", "status", "--json"], { HOME: home }); + + expect(exitCode).toBe(0); + expect(JSON.parse(stdout)).toMatchObject({ + observations: ["Stored authentication token removed."], + data: { + authenticated: false, + source: "none", + }, + }); + expect(JSON.parse(await readFile(configPath, "utf8"))).toEqual({ + profile: "dev", + endpoint: "https://api.example.test", + }); + expect(JSON.parse(status.stdout)).toMatchObject({ + data: { + authenticated: false, + source: "none", + }, + }); + expect((await stat(configPath)).mode & 0o777).toBe(0o600); + } finally { + await rm(home, { recursive: true, force: true }); + } + }); + + test("auth status reports malformed config as a runtime error", async () => { + const home = await makeTempHome(); + try { + const configPath = join(home, ".config", "akua", "config.json"); + await runAkua(["auth", "login", "--token", "sk_akua_stored", "--quiet"], { HOME: home }); + await writeFile(configPath, "{not json\n"); + + const { stdout, exitCode } = await runAkua(["auth", "status", "--json"], { HOME: home }); + + expect(exitCode).toBe(1); + expect(JSON.parse(stdout)).toMatchObject({ + error: { + type: "runtime_error", + code: "AKUA_CONFIG_ERROR", + }, + }); + expect(stdout).toContain("Failed to read Akua config"); + expect(stdout).not.toContain("akua --help"); + } finally { + await rm(home, { recursive: true, force: true }); + } + }); + + test("auth logout removes malformed stored config", async () => { + const home = await makeTempHome(); + try { + const configPath = join(home, ".config", "akua", "config.json"); + await runAkua(["auth", "login", "--token", "sk_akua_stored", "--quiet"], { HOME: home }); + await writeFile(configPath, "{not json\n"); + + const { stdout, exitCode } = await runAkua(["auth", "logout", "--json"], { HOME: home }); + const status = await runAkua(["auth", "status", "--json"], { HOME: home }); + + expect(exitCode).toBe(0); + expect(JSON.parse(stdout)).toMatchObject({ + observations: ["Stored authentication token removed."], + data: { + authenticated: false, + source: "none", + }, + }); + expect(JSON.parse(status.stdout)).toMatchObject({ + data: { + authenticated: false, + source: "none", + }, + }); + } finally { + await rm(home, { recursive: true, force: true }); + } + }); + + test("auth login requires an explicit token flag", async () => { + const home = await makeTempHome(); + try { + const missingFlag = await runAkua(["auth", "login", "--json"], { HOME: home }); + expect(missingFlag.exitCode).toBe(2); + expect(JSON.parse(missingFlag.stdout)).toMatchObject({ + error: { + message: "Missing required --token flag.", + }, + }); + + const missingValue = await runAkua(["auth", "login", "--token", "--json"], { HOME: home }); + expect(missingValue.exitCode).toBe(2); + expect(JSON.parse(missingValue.stdout)).toMatchObject({ + error: { + message: "Missing value for --token.", + }, + }); + + const tokenLikePositional = "sk_akua_secret_positional"; + const positional = await runAkua(["auth", "login", tokenLikePositional, "--json"], { HOME: home }); + expect(positional.exitCode).toBe(2); + expect(JSON.parse(positional.stdout)).toMatchObject({ + error: { + message: "Unexpected argument for auth login.", + }, + }); + expect(positional.stdout).not.toContain(tokenLikePositional); + } finally { + await rm(home, { recursive: true, force: true }); + } + }); + + test("auth positional usage errors do not echo token-like values", async () => { + const home = await makeTempHome(); + try { + const tokenLikeValue = "sk_akua_secret_positional"; + const unknownSubcommand = await runAkua(["auth", tokenLikeValue, "--json"], { HOME: home }); + expect(unknownSubcommand.exitCode).toBe(2); + expect(JSON.parse(unknownSubcommand.stdout)).toMatchObject({ + error: { + message: "Unknown auth subcommand.", + }, + }); + expect(unknownSubcommand.stdout).not.toContain(tokenLikeValue); + + const statusExtra = await runAkua(["auth", "status", tokenLikeValue, "--json"], { HOME: home }); + expect(statusExtra.exitCode).toBe(2); + expect(JSON.parse(statusExtra.stdout)).toMatchObject({ + error: { + message: "Unexpected argument for auth status.", + }, + }); + expect(statusExtra.stdout).not.toContain(tokenLikeValue); + + const logoutExtra = await runAkua(["auth", "logout", tokenLikeValue, "--json"], { HOME: home }); + expect(logoutExtra.exitCode).toBe(2); + expect(JSON.parse(logoutExtra.stdout)).toMatchObject({ + error: { + message: "Unexpected argument for auth logout.", + }, + }); + expect(logoutExtra.stdout).not.toContain(tokenLikeValue); + } finally { + await rm(home, { recursive: true, force: true }); + } + }); }); async function runAkua(args: readonly string[], env: Record = {}) { @@ -107,6 +421,9 @@ async function runAkua(args: readonly string[], env: Record = {} if (!("AKUA_OUTPUT" in env)) { delete childEnv.AKUA_OUTPUT; } + if (!("AKUA_API_TOKEN" in env)) { + delete childEnv.AKUA_API_TOKEN; + } const proc = Bun.spawn({ cmd: ["bun", "src/bin/akua.ts", ...args], @@ -122,3 +439,7 @@ async function runAkua(args: readonly string[], env: Record = {} ]); return { stdout, stderr, exitCode }; } + +async function makeTempHome(): Promise { + return mkdtemp(join(process.cwd(), ".tmp-akua-home-")); +}