/** * `celilo api ...` — manage remote-API principals (Slice 2a). * * grant / list / revoke operate on the api_principals table; authorized-keys * renders the forced-command file for the API account. See openspec/changes/replace-ssh-cli-api/proposal.md. */ import { existsSync, mkdirSync, readFileSync } from 'node:fs'; import { homedir } from 'node:os'; import { dirname, join } from 'node:path'; import { grantPrincipal, listPrincipals, renderAuthorizedKeys, revokePrincipal, validatePrincipalName, } from '../../services/api-access'; import { celiloIntro } from '../prompts'; import type { CommandResult } from '../types'; function errMsg(error: unknown): string { return error instanceof Error ? error.message : String(error); } /** `celilo api grant --key --can ` */ export async function handleApiGrant( args: string[], flags: Record = {}, ): Promise { try { const name = args[0]; if (!name) { return { success: false, error: 'Usage: celilo api grant --key --can ', }; } const keyArg = typeof flags.key === 'string' ? flags.key : ''; if (!keyArg) { return { success: false, error: '--key is required (e.g. ~/.ssh/id_ed25519.pub)', }; } const canArg = typeof flags.can === 'string' ? flags.can : ''; if (!canArg) { return { success: false, error: '--can is required (e.g. --can module:deploy,service:*)', }; } const publicKey = existsSync(keyArg) ? readFileSync(keyArg, 'utf8').trim() : keyArg.trim(); const grants = canArg .split(',') .map((g) => g.trim()) .filter(Boolean); const { principal, created } = await grantPrincipal({ name, publicKey, grants }); return { success: true, message: `${created ? 'Granted' : 'Updated'} API access for "${principal.name}" → ${grants.join(', ')}\n\nInstall the forced-command line on celilo-mgr with:\n celilo api authorized-keys >> ~celilo-api/.ssh/authorized_keys`, }; } catch (error) { return { success: false, error: `Failed to grant API access: ${errMsg(error)}` }; } } /** `celilo api list` */ export async function handleApiList(): Promise { try { celiloIntro('API Principals'); const principals = await listPrincipals(); if (principals.length === 0) { console.log('No API principals.\n'); console.log('Grant access:'); console.log(' celilo api grant --key ~/.ssh/id_ed25519.pub --can module:deploy'); return { success: true, message: 'No API principals found' }; } console.log(''); for (const p of principals) { const parts = p.publicKey.split(/\s+/); const keyType = parts[0] ?? ''; const comment = parts.length > 2 ? parts.slice(2).join(' ') : ''; console.log(`${p.name}`); console.log(` Grants: ${p.grants.join(', ') || '(none)'}`); console.log(` Key: ${keyType}${comment ? ` (${comment})` : ''}`); console.log(''); } console.log(`Total: ${principals.length} principal${principals.length === 1 ? '' : 's'}\n`); return { success: true, message: `Found ${principals.length} principal(s)` }; } catch (error) { return { success: false, error: `Failed to list API principals: ${errMsg(error)}` }; } } /** `celilo api revoke ` */ export async function handleApiRevoke(args: string[]): Promise { try { const name = args[0]; if (!name) { return { success: false, error: 'Usage: celilo api revoke ' }; } const removed = await revokePrincipal(name); if (!removed) { return { success: false, error: `No API principal named "${name}".` }; } return { success: true, message: `Revoked API access for "${name}".\n\nRe-render celilo-mgr's authorized_keys to drop the line:\n celilo api authorized-keys > ~celilo-api/.ssh/authorized_keys`, }; } catch (error) { return { success: false, error: `Failed to revoke API access: ${errMsg(error)}` }; } } /** `celilo api authorized-keys` — print the forced-command file for the API account. */ export async function handleApiAuthorizedKeys(): Promise { try { const content = await renderAuthorizedKeys(); return { success: true, message: content, rawOutput: true }; } catch (error) { return { success: false, error: `Failed to render authorized_keys: ${errMsg(error)}` }; } } /** `celilo api key new ` — generate a client-side keypair for API access. */ export async function handleApiKeyNew(args: string[]): Promise { try { const name = args[0]; if (!name) { return { success: false, error: 'Usage: celilo api key new ' }; } validatePrincipalName(name); // Prefer $HOME (operator shell always sets it, and it's test-controllable); // homedir() is the fallback for the rare unset case. const keyPath = join(process.env.HOME || homedir(), '.ssh', `celilo-api-${name}`); if (existsSync(keyPath) || existsSync(`${keyPath}.pub`)) { return { success: false, error: `A key already exists at ${keyPath}. Remove it or choose another name.`, }; } mkdirSync(dirname(keyPath), { recursive: true, mode: 0o700 }); const gen = Bun.spawnSync([ 'ssh-keygen', '-t', 'ed25519', '-N', '', '-C', `celilo-api-${name}`, '-f', keyPath, ]); if (gen.exitCode !== 0) { return { success: false, error: `ssh-keygen failed: ${gen.stderr.toString().trim() || `exit ${gen.exitCode}`}`, }; } const pubkey = readFileSync(`${keyPath}.pub`, 'utf8').trim(); return { success: true, message: [ `Generated API keypair for "${name}":`, ` private: ${keyPath} (keep secret — never share)`, ` public: ${keyPath}.pub`, '', `Public key: ${pubkey}`, '', 'Enroll the public key on celilo-mgr:', ` celilo api grant ${name} --key ${keyPath}.pub --can module:deploy`, '', 'Then run commands remotely (configure a Host alias in ~/.ssh/config as needed):', ' celilo --remote celilo-api@celilo-mgr ', ].join('\n'), }; } catch (error) { return { success: false, error: `Failed to generate API key: ${errMsg(error)}` }; } }