@celilo/cli 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Remote API server — `celilo api-serve --principal=<id>` (Slices 1–3).
3
+ *
4
+ * Invoked as the sshd forced command for an enrolled key, so it starts already
5
+ * authenticated as `principal`. Reads `command`/`answer` messages as NDJSON on
6
+ * stdin/stdout, authorizes each command against the principal's grants
7
+ * (deny-by-default), and — if allowed — runs it as a child `celilo` process,
8
+ * translating the child's protocol-mode output into `progress`/`log` and a
9
+ * terminal `result`. While a command runs, a remote responder bridges the event
10
+ * bus to the wire so mid-run `interview`s are answered by the client. Every
11
+ * attempt is audited to stderr.
12
+ */
13
+
14
+ import { createInterface } from 'node:readline';
15
+ import { parseArguments } from '../cli/parser';
16
+ import { getEventBusPath } from '../config/paths';
17
+ import { isAuthorized } from '../services/api-access';
18
+ import { type WireInterview, startRemoteResponder } from '../services/remote-responder';
19
+ import {
20
+ API_PROTOCOL_VERSION,
21
+ ClientMessageSchema,
22
+ type ServerMessage,
23
+ translateOutputLine,
24
+ } from './protocol';
25
+
26
+ /** Exit code returned to the client when authz denies a command. */
27
+ const EXIT_PERMISSION_DENIED = 126;
28
+
29
+ function send(msg: ServerMessage): void {
30
+ process.stdout.write(`${JSON.stringify(msg)}\n`);
31
+ }
32
+
33
+ function audit(principal: string, op: string, decision: string, exitCode?: number): void {
34
+ const suffix = exitCode === undefined ? '' : ` exit=${exitCode}`;
35
+ process.stderr.write(
36
+ `[api-audit] principal=${principal} op=${op} decision=${decision}${suffix}\n`,
37
+ );
38
+ }
39
+
40
+ /** Derive the `command`/`subcommand` an argv would run, via the real parser. */
41
+ function opOf(argv: string[]): { command: string; subcommand?: string; label: string } {
42
+ const parsed = parseArguments(['bun', 'celilo', ...argv]);
43
+ const label = parsed.subcommand ? `${parsed.command}:${parsed.subcommand}` : parsed.command;
44
+ return { command: parsed.command, subcommand: parsed.subcommand, label };
45
+ }
46
+
47
+ /** Drain a child stream line-by-line, forwarding each line as a message. */
48
+ async function pumpLines(stream: ReadableStream<Uint8Array>): Promise<void> {
49
+ const decoder = new TextDecoder();
50
+ let buffer = '';
51
+ for await (const chunk of stream) {
52
+ buffer += decoder.decode(chunk, { stream: true });
53
+ let nl = buffer.indexOf('\n');
54
+ while (nl >= 0) {
55
+ const line = buffer.slice(0, nl);
56
+ buffer = buffer.slice(nl + 1);
57
+ send(translateOutputLine(line));
58
+ nl = buffer.indexOf('\n');
59
+ }
60
+ }
61
+ if (buffer.length > 0) {
62
+ send(translateOutputLine(buffer));
63
+ }
64
+ }
65
+
66
+ /** Run an authorized command as a child; returns its exit code. */
67
+ async function runCommand(argv: string[]): Promise<number> {
68
+ // Re-invoke this same CLI as a child. The child is non-TTY (piped stdout) so
69
+ // ProgressDisplay resolves to protocol mode and emits `[progress:*]` markers.
70
+ const child = Bun.spawn([process.execPath, Bun.main, ...argv], {
71
+ stdin: 'ignore',
72
+ stdout: 'pipe',
73
+ stderr: 'pipe',
74
+ });
75
+
76
+ await Promise.all([pumpLines(child.stdout), pumpLines(child.stderr)]);
77
+ const exitCode = await child.exited;
78
+ send({ type: 'result', success: exitCode === 0, exitCode });
79
+ return exitCode;
80
+ }
81
+
82
+ async function handleCommand(
83
+ principal: string,
84
+ argv: string[],
85
+ busDbPath: string,
86
+ ask: (interview: WireInterview) => Promise<unknown>,
87
+ ): Promise<void> {
88
+ const { command, subcommand, label } = opOf(argv);
89
+
90
+ if (!(await isAuthorized(principal, command, subcommand))) {
91
+ send({ type: 'error', error: `permission denied: "${principal}" is not granted "${label}"` });
92
+ send({ type: 'result', success: false, exitCode: EXIT_PERMISSION_DENIED });
93
+ audit(principal, label, 'deny');
94
+ return;
95
+ }
96
+
97
+ // Bridge the bus to the wire so mid-run interviews reach the client.
98
+ const responder = startRemoteResponder({ busDbPath, ask, emittedBy: `api:${principal}` });
99
+ try {
100
+ const exitCode = await runCommand(argv);
101
+ audit(principal, label, 'allow', exitCode);
102
+ } finally {
103
+ responder.close();
104
+ }
105
+ }
106
+
107
+ export async function apiServeMode(principal: string): Promise<void> {
108
+ send({ type: 'ready', protocolVersion: API_PROTOCOL_VERSION });
109
+
110
+ const busDbPath = getEventBusPath();
111
+ const pendingAnswers = new Map<string, (value: unknown) => void>();
112
+
113
+ const ask = (interview: WireInterview): Promise<unknown> =>
114
+ new Promise((resolve) => {
115
+ pendingAnswers.set(interview.id, resolve);
116
+ send({
117
+ type: 'interview',
118
+ id: interview.id,
119
+ kind: interview.kind,
120
+ message: interview.message,
121
+ description: interview.description,
122
+ defaultValue: interview.defaultValue,
123
+ placeholder: interview.placeholder,
124
+ options: interview.options,
125
+ required: interview.required,
126
+ });
127
+ });
128
+
129
+ const rl = createInterface({ input: process.stdin, terminal: false });
130
+
131
+ for await (const line of rl) {
132
+ if (!line.trim()) continue;
133
+
134
+ let msg: ReturnType<typeof ClientMessageSchema.parse>;
135
+ try {
136
+ msg = ClientMessageSchema.parse(JSON.parse(line));
137
+ } catch (error) {
138
+ send({ type: 'error', error: error instanceof Error ? error.message : String(error) });
139
+ continue;
140
+ }
141
+
142
+ if (msg.type === 'answer') {
143
+ const resolve = pendingAnswers.get(msg.id);
144
+ if (resolve) {
145
+ pendingAnswers.delete(msg.id);
146
+ resolve(msg.value);
147
+ }
148
+ continue;
149
+ }
150
+
151
+ if (msg.type === 'command') {
152
+ // Fire-and-forget so the read loop keeps consuming `answer` messages while
153
+ // the command runs — mid-run interviews are answered in-flight.
154
+ void handleCommand(principal, msg.argv, busDbPath, ask);
155
+ }
156
+ }
157
+
158
+ process.exit(0);
159
+ }
@@ -92,6 +92,51 @@ export const COMMANDS: CommandDef[] = [
92
92
  },
93
93
  ],
94
94
  },
95
+ {
96
+ name: 'api',
97
+ description: 'Manage remote-API access (principals, grants, authorized_keys)',
98
+ subcommands: [
99
+ {
100
+ name: 'grant',
101
+ description: 'Grant or replace API access for a principal',
102
+ args: [{ name: 'principal', description: 'Principal name (kebab-case)' }],
103
+ flags: [
104
+ {
105
+ name: 'key',
106
+ description: 'SSH public key, or path to a .pub file',
107
+ takesValue: true,
108
+ valueHint: '_files',
109
+ },
110
+ {
111
+ name: 'can',
112
+ description: 'Comma-separated grants (e.g. module:deploy,service:*)',
113
+ takesValue: true,
114
+ },
115
+ ],
116
+ },
117
+ { name: 'list', description: 'List API principals and their grants' },
118
+ {
119
+ name: 'revoke',
120
+ description: 'Revoke API access for a principal',
121
+ args: [{ name: 'principal', description: 'Principal name' }],
122
+ },
123
+ {
124
+ name: 'authorized-keys',
125
+ description: "Print the API account's authorized_keys (forced-command lines)",
126
+ },
127
+ {
128
+ name: 'key',
129
+ description: 'Client-side key management',
130
+ subcommands: [
131
+ {
132
+ name: 'new',
133
+ description: 'Generate a new API keypair and print enrollment steps',
134
+ args: [{ name: 'name', description: 'Principal name for the key' }],
135
+ },
136
+ ],
137
+ },
138
+ ],
139
+ },
95
140
  {
96
141
  name: 'events',
97
142
  description: 'SQLite event-bus operations (status, tail, run dispatcher, etc.)',
@@ -0,0 +1,194 @@
1
+ /**
2
+ * `celilo api ...` — manage remote-API principals (Slice 2a).
3
+ *
4
+ * grant / list / revoke operate on the api_principals table; authorized-keys
5
+ * renders the forced-command file for the API account. See v2/API_COMMUNICATION.md.
6
+ */
7
+
8
+ import { existsSync, mkdirSync, readFileSync } from 'node:fs';
9
+ import { homedir } from 'node:os';
10
+ import { dirname, join } from 'node:path';
11
+ import {
12
+ grantPrincipal,
13
+ listPrincipals,
14
+ renderAuthorizedKeys,
15
+ revokePrincipal,
16
+ validatePrincipalName,
17
+ } from '../../services/api-access';
18
+ import { celiloIntro } from '../prompts';
19
+ import type { CommandResult } from '../types';
20
+
21
+ function errMsg(error: unknown): string {
22
+ return error instanceof Error ? error.message : String(error);
23
+ }
24
+
25
+ /** `celilo api grant <principal> --key <pubkey|path> --can <grant[,grant...]>` */
26
+ export async function handleApiGrant(
27
+ args: string[],
28
+ flags: Record<string, boolean | string> = {},
29
+ ): Promise<CommandResult> {
30
+ try {
31
+ const name = args[0];
32
+ if (!name) {
33
+ return {
34
+ success: false,
35
+ error: 'Usage: celilo api grant <principal> --key <pubkey|path> --can <grant[,grant...]>',
36
+ };
37
+ }
38
+
39
+ const keyArg = typeof flags.key === 'string' ? flags.key : '';
40
+ if (!keyArg) {
41
+ return {
42
+ success: false,
43
+ error: '--key <public-key-or-path> is required (e.g. ~/.ssh/id_ed25519.pub)',
44
+ };
45
+ }
46
+
47
+ const canArg = typeof flags.can === 'string' ? flags.can : '';
48
+ if (!canArg) {
49
+ return {
50
+ success: false,
51
+ error: '--can <grant[,grant...]> is required (e.g. --can module:deploy,service:*)',
52
+ };
53
+ }
54
+
55
+ const publicKey = existsSync(keyArg) ? readFileSync(keyArg, 'utf8').trim() : keyArg.trim();
56
+ const grants = canArg
57
+ .split(',')
58
+ .map((g) => g.trim())
59
+ .filter(Boolean);
60
+
61
+ const { principal, created } = await grantPrincipal({ name, publicKey, grants });
62
+
63
+ return {
64
+ success: true,
65
+ 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`,
66
+ };
67
+ } catch (error) {
68
+ return { success: false, error: `Failed to grant API access: ${errMsg(error)}` };
69
+ }
70
+ }
71
+
72
+ /** `celilo api list` */
73
+ export async function handleApiList(): Promise<CommandResult> {
74
+ try {
75
+ celiloIntro('API Principals');
76
+ const principals = await listPrincipals();
77
+
78
+ if (principals.length === 0) {
79
+ console.log('No API principals.\n');
80
+ console.log('Grant access:');
81
+ console.log(' celilo api grant <name> --key ~/.ssh/id_ed25519.pub --can module:deploy');
82
+ return { success: true, message: 'No API principals found' };
83
+ }
84
+
85
+ console.log('');
86
+ for (const p of principals) {
87
+ const parts = p.publicKey.split(/\s+/);
88
+ const keyType = parts[0] ?? '';
89
+ const comment = parts.length > 2 ? parts.slice(2).join(' ') : '';
90
+ console.log(`${p.name}`);
91
+ console.log(` Grants: ${p.grants.join(', ') || '(none)'}`);
92
+ console.log(` Key: ${keyType}${comment ? ` (${comment})` : ''}`);
93
+ console.log('');
94
+ }
95
+
96
+ console.log(`Total: ${principals.length} principal${principals.length === 1 ? '' : 's'}\n`);
97
+ return { success: true, message: `Found ${principals.length} principal(s)` };
98
+ } catch (error) {
99
+ return { success: false, error: `Failed to list API principals: ${errMsg(error)}` };
100
+ }
101
+ }
102
+
103
+ /** `celilo api revoke <principal>` */
104
+ export async function handleApiRevoke(args: string[]): Promise<CommandResult> {
105
+ try {
106
+ const name = args[0];
107
+ if (!name) {
108
+ return { success: false, error: 'Usage: celilo api revoke <principal>' };
109
+ }
110
+
111
+ const removed = await revokePrincipal(name);
112
+ if (!removed) {
113
+ return { success: false, error: `No API principal named "${name}".` };
114
+ }
115
+
116
+ return {
117
+ success: true,
118
+ 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`,
119
+ };
120
+ } catch (error) {
121
+ return { success: false, error: `Failed to revoke API access: ${errMsg(error)}` };
122
+ }
123
+ }
124
+
125
+ /** `celilo api authorized-keys` — print the forced-command file for the API account. */
126
+ export async function handleApiAuthorizedKeys(): Promise<CommandResult> {
127
+ try {
128
+ const content = await renderAuthorizedKeys();
129
+ return { success: true, message: content, rawOutput: true };
130
+ } catch (error) {
131
+ return { success: false, error: `Failed to render authorized_keys: ${errMsg(error)}` };
132
+ }
133
+ }
134
+
135
+ /** `celilo api key new <name>` — generate a client-side keypair for API access. */
136
+ export async function handleApiKeyNew(args: string[]): Promise<CommandResult> {
137
+ try {
138
+ const name = args[0];
139
+ if (!name) {
140
+ return { success: false, error: 'Usage: celilo api key new <name>' };
141
+ }
142
+ validatePrincipalName(name);
143
+
144
+ // Prefer $HOME (operator shell always sets it, and it's test-controllable);
145
+ // homedir() is the fallback for the rare unset case.
146
+ const keyPath = join(process.env.HOME || homedir(), '.ssh', `celilo-api-${name}`);
147
+ if (existsSync(keyPath) || existsSync(`${keyPath}.pub`)) {
148
+ return {
149
+ success: false,
150
+ error: `A key already exists at ${keyPath}. Remove it or choose another name.`,
151
+ };
152
+ }
153
+
154
+ mkdirSync(dirname(keyPath), { recursive: true, mode: 0o700 });
155
+
156
+ const gen = Bun.spawnSync([
157
+ 'ssh-keygen',
158
+ '-t',
159
+ 'ed25519',
160
+ '-N',
161
+ '',
162
+ '-C',
163
+ `celilo-api-${name}`,
164
+ '-f',
165
+ keyPath,
166
+ ]);
167
+ if (gen.exitCode !== 0) {
168
+ return {
169
+ success: false,
170
+ error: `ssh-keygen failed: ${gen.stderr.toString().trim() || `exit ${gen.exitCode}`}`,
171
+ };
172
+ }
173
+
174
+ const pubkey = readFileSync(`${keyPath}.pub`, 'utf8').trim();
175
+ return {
176
+ success: true,
177
+ message: [
178
+ `Generated API keypair for "${name}":`,
179
+ ` private: ${keyPath} (keep secret — never share)`,
180
+ ` public: ${keyPath}.pub`,
181
+ '',
182
+ `Public key: ${pubkey}`,
183
+ '',
184
+ 'Enroll the public key on celilo-mgr:',
185
+ ` celilo api grant ${name} --key ${keyPath}.pub --can module:deploy`,
186
+ '',
187
+ 'Then run commands remotely (configure a Host alias in ~/.ssh/config as needed):',
188
+ ' celilo --remote celilo-api@celilo-mgr <command>',
189
+ ].join('\n'),
190
+ };
191
+ } catch (error) {
192
+ return { success: false, error: `Failed to generate API key: ${errMsg(error)}` };
193
+ }
194
+ }
@@ -7,6 +7,7 @@ import { eq } from 'drizzle-orm';
7
7
  import { getDb } from '../db/client';
8
8
  import { capabilities, modules } from '../db/schema';
9
9
  import type { ModuleManifest } from '../manifest/schema';
10
+ import { listPrincipals } from '../services/api-access';
10
11
  import { listBackups } from '../services/backup-metadata';
11
12
  import { listBackupStorages } from '../services/backup-storage';
12
13
  import { listContainerServices } from '../services/container-service';
@@ -28,6 +29,7 @@ export async function getCompletions(words: string[], current: number): Promise<
28
29
  // currentIndex === 0 means we're completing the first word (the command)
29
30
  if (currentIndex === 0) {
30
31
  const commands = [
32
+ 'api',
31
33
  'audit',
32
34
  'backup',
33
35
  'capability',
@@ -332,6 +334,26 @@ export async function getCompletions(words: string[], current: number): Promise<
332
334
  return filterSuggestions(configKeys, args[4] || '');
333
335
  }
334
336
 
337
+ // API subcommands
338
+ if (command === 'api' && currentIndex === 1) {
339
+ const subcommands = ['grant', 'list', 'revoke', 'authorized-keys', 'key'];
340
+ return filterSuggestions(subcommands, args[1] || '');
341
+ }
342
+
343
+ // API revoke - complete with principal names
344
+ if (command === 'api' && args[1] === 'revoke' && currentIndex === 2) {
345
+ const principals = await listPrincipals();
346
+ return filterSuggestions(
347
+ principals.map((p) => p.name),
348
+ args[2] || '',
349
+ );
350
+ }
351
+
352
+ // API key subcommands
353
+ if (command === 'api' && args[1] === 'key' && currentIndex === 2) {
354
+ return filterSuggestions(['new'], args[2] || '');
355
+ }
356
+
335
357
  // Machine subcommands
336
358
  if (command === 'machine' && currentIndex === 1) {
337
359
  const subcommands = ['add', 'list', 'status', 'remove', 'earmark', 'detect'];
package/src/cli/index.ts CHANGED
@@ -5,8 +5,16 @@
5
5
  */
6
6
 
7
7
  import * as p from '@clack/prompts';
8
+ import { resolveRemote, runRemoteClient } from '../api/remote-client';
8
9
  import { CLIServerRequestSchema, parseJsonWithValidation } from '../validation/schemas';
9
10
  import { COMMANDS, type CommandDef } from './command-registry';
11
+ import {
12
+ handleApiAuthorizedKeys,
13
+ handleApiGrant,
14
+ handleApiKeyNew,
15
+ handleApiList,
16
+ handleApiRevoke,
17
+ } from './commands/api';
10
18
  import { handleCapabilityInfo } from './commands/capability-info';
11
19
  import { handleCapabilityList } from './commands/capability-list';
12
20
  import { handleCompletion } from './commands/completion';
@@ -184,10 +192,14 @@ Commands:
184
192
  proxmox Proxmox cluster introspection (proxmox node list)
185
193
  publish Publish workspace packages to npm and modules to celilo.computer
186
194
  subscribers Manage build-bus subscribers (cross-machine publish-event delivery)
195
+ api Manage remote-API access (principals, grants, authorized_keys)
187
196
  completion Generate shell completion scripts (bash/zsh)
188
197
 
189
198
  help, --help, -h Show this help message
190
199
 
200
+ Run any command on a remote celilo-mgr over SSH:
201
+ celilo --remote <ssh-dest> <command> (or set CELILO_REMOTE=<ssh-dest>)
202
+
191
203
  For command-specific help:
192
204
  celilo package --help
193
205
  celilo module --help
@@ -1015,6 +1027,24 @@ Using Vault Password:
1015
1027
  export async function runCli(argv: string[]): Promise<CommandResult> {
1016
1028
  const parsed = parseArguments(argv);
1017
1029
 
1030
+ // Remote API server: the sshd forced-command entry point
1031
+ // (`celilo api-serve --principal=<id>`). Not an operator command — kept out
1032
+ // of the registry/completion on purpose. Runs a persistent NDJSON protocol
1033
+ // loop until stdin closes, then exits the process; never returns here.
1034
+ if (parsed.command === 'api-serve') {
1035
+ const principal = typeof parsed.flags.principal === 'string' ? parsed.flags.principal : '';
1036
+ if (!principal) {
1037
+ return {
1038
+ success: false,
1039
+ error:
1040
+ 'api-serve requires --principal <name> (normally supplied by the forced command in authorized_keys)',
1041
+ };
1042
+ }
1043
+ const { apiServeMode } = await import('../api/serve');
1044
+ await apiServeMode(principal);
1045
+ return { success: true, message: '' };
1046
+ }
1047
+
1018
1048
  // Handle --get-completions for shell completion (must be before other processing)
1019
1049
  if (parsed.flags['get-completions']) {
1020
1050
  try {
@@ -1673,6 +1703,64 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
1673
1703
  return handleRestore(restoreArgs, parsed.flags);
1674
1704
  }
1675
1705
 
1706
+ if (parsed.command === 'api') {
1707
+ if (parsed.flags.help || parsed.flags.h) {
1708
+ return {
1709
+ success: true,
1710
+ message: [
1711
+ 'celilo api — manage remote-API access',
1712
+ '',
1713
+ 'Usage:',
1714
+ ' celilo api grant <principal> --key <pubkey|path> --can <grant[,grant...]>',
1715
+ ' celilo api list',
1716
+ ' celilo api revoke <principal>',
1717
+ ' celilo api authorized-keys',
1718
+ ' celilo api key new <name>',
1719
+ '',
1720
+ 'Grants are command:subcommand (module:deploy), command:* (service:*), or * (all).',
1721
+ ].join('\n'),
1722
+ };
1723
+ }
1724
+
1725
+ if (!parsed.subcommand) {
1726
+ return {
1727
+ success: false,
1728
+ error: 'API subcommand required\n\nRun "celilo api --help" for usage',
1729
+ };
1730
+ }
1731
+
1732
+ const apiFlagError = checkFlags('api', parsed.subcommand, parsed.flags, parsed.args);
1733
+ if (apiFlagError) return apiFlagError;
1734
+
1735
+ if (parsed.subcommand === 'grant') {
1736
+ return handleApiGrant(parsed.args, parsed.flags);
1737
+ }
1738
+
1739
+ if (parsed.subcommand === 'list') {
1740
+ return handleApiList();
1741
+ }
1742
+
1743
+ if (parsed.subcommand === 'revoke') {
1744
+ return handleApiRevoke(parsed.args);
1745
+ }
1746
+
1747
+ if (parsed.subcommand === 'authorized-keys') {
1748
+ return handleApiAuthorizedKeys();
1749
+ }
1750
+
1751
+ if (parsed.subcommand === 'key') {
1752
+ if (parsed.args[0] === 'new') {
1753
+ return handleApiKeyNew(parsed.args.slice(1));
1754
+ }
1755
+ return { success: false, error: 'Usage: celilo api key new <name>' };
1756
+ }
1757
+
1758
+ return {
1759
+ success: false,
1760
+ error: `Unknown api subcommand: ${parsed.subcommand}\n\nRun "celilo api --help" for usage`,
1761
+ };
1762
+ }
1763
+
1676
1764
  if (parsed.command === 'machine') {
1677
1765
  // Handle machine --help
1678
1766
  if (parsed.flags.help || parsed.flags.h) {
@@ -2084,6 +2172,13 @@ export async function main(): Promise<void> {
2084
2172
  return;
2085
2173
  }
2086
2174
 
2175
+ // Remote execution: `celilo --remote <dest> <cmd>` or CELILO_REMOTE=<dest>.
2176
+ // SSH to the remote celilo-mgr and drive its api-serve over the wire.
2177
+ const remote = resolveRemote(process.argv);
2178
+ if (remote) {
2179
+ process.exit(await runRemoteClient(remote.dest, remote.commandArgv));
2180
+ }
2181
+
2087
2182
  // Normal single-command execution
2088
2183
  try {
2089
2184
  const result = await runCli(process.argv);
@@ -107,6 +107,19 @@ describe('CLI Parser', () => {
107
107
  expect(result.flags).toEqual({ json: true, verbose: true });
108
108
  });
109
109
 
110
+ test('should parse --flag=value inline form', () => {
111
+ const result = parseArguments([
112
+ 'node',
113
+ 'celilo',
114
+ 'api-serve',
115
+ '--principal=alice',
116
+ '--empty=',
117
+ ]);
118
+
119
+ expect(result.command).toBe('api-serve');
120
+ expect(result.flags).toEqual({ principal: 'alice', empty: '' });
121
+ });
122
+
110
123
  test('should not consume key=value args as flag values', () => {
111
124
  const result = parseArguments([
112
125
  'node',
package/src/cli/parser.ts CHANGED
@@ -82,8 +82,17 @@ export function parseArguments(argv: string[]): ParsedCommand {
82
82
  if (!arg) continue;
83
83
 
84
84
  if (arg.startsWith('--')) {
85
- // Long flag (--help, --output, etc.)
86
- const flagName = arg.slice(2);
85
+ const body = arg.slice(2);
86
+ const eqIndex = body.indexOf('=');
87
+
88
+ // Inline-value form: --flag=value (everything after the first `=`).
89
+ if (eqIndex >= 0) {
90
+ flags[body.slice(0, eqIndex)] = body.slice(eqIndex + 1);
91
+ continue;
92
+ }
93
+
94
+ // Space-separated form: --flag value
95
+ const flagName = body;
87
96
  const nextArg = restArgs[i + 1];
88
97
 
89
98
  // Check if next arg is a value (not another flag, not a key=value pair)
package/src/db/schema.ts CHANGED
@@ -654,6 +654,34 @@ export const aspectApprovals = sqliteTable(
654
654
  }),
655
655
  );
656
656
 
657
+ /**
658
+ * Remote API principals (see v2/API_COMMUNICATION.md).
659
+ *
660
+ * Each row is one API identity: a name, one SSH public key, and the set of
661
+ * operations it may run. celilo renders these into the API account's
662
+ * `authorized_keys` (one forced-command line per row) and authz keys off the
663
+ * principal → `grants`.
664
+ *
665
+ * ponytail: one key per principal (a person wanting a second device makes a
666
+ * second principal, e.g. `alice-laptop`). If multiple keys per identity is ever
667
+ * needed, split into an `api_keys` child table — not worth it yet.
668
+ */
669
+ export const apiPrincipals = sqliteTable('api_principals', {
670
+ id: text('id').primaryKey(), // UUID
671
+ /** Human-readable principal name, kebab-case (e.g. "alice", "ci-deployer"). */
672
+ name: text('name').notNull().unique(),
673
+ /** SSH public key line: `<type> <base64> [comment]`. */
674
+ publicKey: text('public_key').notNull(),
675
+ /**
676
+ * Operations this principal may run, as `command:subcommand` grants
677
+ * (`module:deploy`), `command:*` wildcards (`service:*`), or `*` (all).
678
+ * Deny-by-default: an operation not matched by any grant is refused.
679
+ */
680
+ grants: text('grants', { mode: 'json' }).$type<string[]>().notNull().default(sql`'[]'`),
681
+ createdAt: integer('created_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
682
+ updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
683
+ });
684
+
657
685
  /**
658
686
  * Type exports for use in application code
659
687
  */
@@ -695,3 +723,5 @@ export type ModuleOperation = typeof moduleOperations.$inferSelect;
695
723
  export type NewModuleOperation = typeof moduleOperations.$inferInsert;
696
724
  export type AspectApproval = typeof aspectApprovals.$inferSelect;
697
725
  export type NewAspectApproval = typeof aspectApprovals.$inferInsert;
726
+ export type ApiPrincipal = typeof apiPrincipals.$inferSelect;
727
+ export type NewApiPrincipal = typeof apiPrincipals.$inferInsert;