@celilo/cli 0.10.0 → 0.11.0-alpha.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.
- package/CELILO_SUBSYSTEMS.md +15 -0
- package/drizzle/0014_api_principals.sql +10 -0
- package/drizzle/meta/_journal.json +7 -0
- package/package.json +7 -5
- package/src/api/protocol.test.ts +76 -0
- package/src/api/remote-client.test.ts +91 -0
- package/src/api/serve.ts +159 -0
- package/src/cli/command-tree-parser.ts +3 -1
- package/src/cli/commands/api.ts +194 -0
- package/src/cli/commands/apt-upgrade.test.ts +33 -0
- package/src/cli/commands/apt-upgrade.ts +63 -0
- package/src/cli/commands/commands-json.ts +29 -0
- package/src/cli/commands/completion.ts +1 -1
- package/src/cli/commands/module-list.ts +16 -2
- package/src/cli/commands/service-list.ts +15 -2
- package/src/cli/completion.ts +24 -0
- package/src/cli/generate-zsh-completion.test.ts +22 -4
- package/src/cli/generate-zsh-completion.ts +7 -3
- package/src/cli/index.ts +109 -2
- package/src/cli/parser.test.ts +13 -0
- package/src/cli/parser.ts +12 -3
- package/src/db/schema.ts +30 -0
- package/src/hooks/capability-loader.test.ts +77 -0
- package/src/hooks/capability-loader.ts +56 -0
- package/src/services/api-access.test.ts +138 -0
- package/src/services/api-access.ts +154 -0
- package/src/services/remote-responder.test.ts +78 -0
- package/src/services/remote-responder.ts +89 -0
- package/src/cli/command-registry.ts +0 -1443
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `celilo apt-upgrade` — upgrade the deb-installed celilo packages and apply
|
|
3
|
+
* pending DB migrations. The management server (celilo-bootstrap) installs
|
|
4
|
+
* celilo via apt, so keeping it current means the apt chain, not `bun update -g`
|
|
5
|
+
* (that path is `system update`'s self-update, for npm-global installs).
|
|
6
|
+
*
|
|
7
|
+
* Steps (ISS-0100 — the postinst does NOT auto-apply migrations):
|
|
8
|
+
* 1. apt-get update
|
|
9
|
+
* 2. apt-get -y --only-upgrade install celilo celilo-bootstrap
|
|
10
|
+
* 3. a FRESH `celilo system migrate` — spawned as the just-installed binary so
|
|
11
|
+
* the new version's migrations run, not the ones loaded in this process.
|
|
12
|
+
*
|
|
13
|
+
* This is the RW target behind the MCP's `celilo_apt_upgrade` tool. It runs as
|
|
14
|
+
* the celilo user (via api-serve); the apt steps sudo to root, gated by the
|
|
15
|
+
* scoped /etc/sudoers.d/celilo-apt-upgrade grant that celilo-bootstrap ships.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { spawnSync } from 'node:child_process';
|
|
19
|
+
import type { CommandResult } from '../types';
|
|
20
|
+
|
|
21
|
+
/** Wrapper the deb installs; the fresh migrate step runs the upgraded binary. */
|
|
22
|
+
const CELILO_BIN = '/usr/local/bin/celilo';
|
|
23
|
+
|
|
24
|
+
/** One command to run in the chain — argv plus a human label for output. */
|
|
25
|
+
interface Step {
|
|
26
|
+
label: string;
|
|
27
|
+
argv: string[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const STEPS: Step[] = [
|
|
31
|
+
{ label: 'apt-get update', argv: ['sudo', 'apt-get', 'update'] },
|
|
32
|
+
{
|
|
33
|
+
label: 'apt-get upgrade celilo, celilo-bootstrap',
|
|
34
|
+
argv: ['sudo', 'apt-get', '-y', '--only-upgrade', 'install', 'celilo', 'celilo-bootstrap'],
|
|
35
|
+
},
|
|
36
|
+
{ label: 'apply DB migrations', argv: [CELILO_BIN, 'system', 'migrate'] },
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
/** Run one argv, inheriting stdio so its output streams through api-serve. */
|
|
40
|
+
export type StepRunner = (argv: string[]) => { status: number | null };
|
|
41
|
+
|
|
42
|
+
const defaultRunner: StepRunner = (argv) => spawnSync(argv[0], argv.slice(1), { stdio: 'inherit' });
|
|
43
|
+
|
|
44
|
+
export async function handleAptUpgrade(
|
|
45
|
+
_args: string[],
|
|
46
|
+
_flags: Record<string, string | boolean>,
|
|
47
|
+
runStep: StepRunner = defaultRunner,
|
|
48
|
+
): Promise<CommandResult> {
|
|
49
|
+
for (const step of STEPS) {
|
|
50
|
+
process.stdout.write(`\n▸ ${step.label}\n`);
|
|
51
|
+
const { status } = runStep(step.argv);
|
|
52
|
+
if (status !== 0) {
|
|
53
|
+
return {
|
|
54
|
+
success: false,
|
|
55
|
+
error: `apt-upgrade failed at "${step.label}" (exit ${status ?? 'signal'}). Nothing further was run.`,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return {
|
|
60
|
+
success: true,
|
|
61
|
+
message: 'celilo apt packages upgraded and migrations applied.',
|
|
62
|
+
};
|
|
63
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Commands Command
|
|
3
|
+
*
|
|
4
|
+
* Serializes the CLI command registry (`COMMANDS`) as JSON so an external
|
|
5
|
+
* consumer — `@celilo/mcp` above all — can fetch the *live* surface of whatever
|
|
6
|
+
* celilo version this server runs and generate its tool set from it, rather than
|
|
7
|
+
* compiling a copy that drifts (design D3). The registry is already structured
|
|
8
|
+
* data; this just prints it.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { COMMANDS } from '@celilo/core';
|
|
12
|
+
import type { CommandResult } from '../types';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Handle `celilo commands [--json]`.
|
|
16
|
+
*
|
|
17
|
+
* The only useful output is the JSON tree, so we emit it whether or not `--json`
|
|
18
|
+
* is passed; the flag exists to make the intent explicit and completable.
|
|
19
|
+
*/
|
|
20
|
+
export async function handleCommands(
|
|
21
|
+
_args: string[],
|
|
22
|
+
_flags: Record<string, boolean | string> = {},
|
|
23
|
+
): Promise<CommandResult> {
|
|
24
|
+
return {
|
|
25
|
+
success: true,
|
|
26
|
+
message: JSON.stringify(COMMANDS),
|
|
27
|
+
rawOutput: true,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* Generate shell completion scripts for bash/zsh
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import { COMMANDS } from '
|
|
6
|
+
import { COMMANDS } from '@celilo/core';
|
|
7
7
|
import { generateBashCompletion, generateFishCompletion } from '../completion';
|
|
8
8
|
import { generateRichZshCompletion } from '../generate-zsh-completion';
|
|
9
9
|
import { celiloIntro } from '../prompts';
|
|
@@ -1,20 +1,34 @@
|
|
|
1
1
|
import { getDb } from '../../db/client';
|
|
2
2
|
import { modules } from '../../db/schema';
|
|
3
|
+
import { hasFlag } from '../parser';
|
|
3
4
|
import type { CommandResult } from '../types';
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Handle module list command
|
|
7
8
|
*
|
|
8
|
-
* Usage: celilo module list
|
|
9
|
+
* Usage: celilo module list [--json]
|
|
9
10
|
*
|
|
10
11
|
* @returns Command result
|
|
11
12
|
*/
|
|
12
|
-
export async function handleModuleList(
|
|
13
|
+
export async function handleModuleList(
|
|
14
|
+
flags: Record<string, string | boolean> = {},
|
|
15
|
+
): Promise<CommandResult> {
|
|
13
16
|
const db = getDb();
|
|
14
17
|
|
|
15
18
|
// Query all modules
|
|
16
19
|
const moduleRows = db.select().from(modules).all();
|
|
17
20
|
|
|
21
|
+
// Stable machine-readable roster — the backbone the MCP composite
|
|
22
|
+
// troubleshooting tools correlate audit findings against (ce-77i.5).
|
|
23
|
+
if (hasFlag(flags, 'json')) {
|
|
24
|
+
return {
|
|
25
|
+
success: true,
|
|
26
|
+
message: JSON.stringify({ modules: moduleRows }, null, 2),
|
|
27
|
+
rawOutput: true,
|
|
28
|
+
data: moduleRows,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
18
32
|
if (moduleRows.length === 0) {
|
|
19
33
|
return {
|
|
20
34
|
success: true,
|
|
@@ -22,8 +22,6 @@ export async function handleServiceList(
|
|
|
22
22
|
flags: Record<string, boolean | string> = {},
|
|
23
23
|
): Promise<CommandResult> {
|
|
24
24
|
try {
|
|
25
|
-
celiloIntro('Container Services');
|
|
26
|
-
|
|
27
25
|
// Apply zone filter if provided
|
|
28
26
|
const filters: ContainerServiceFilters = {};
|
|
29
27
|
if (flags.zone && typeof flags.zone === 'string') {
|
|
@@ -32,6 +30,21 @@ export async function handleServiceList(
|
|
|
32
30
|
|
|
33
31
|
const services = await listContainerServices(filters);
|
|
34
32
|
|
|
33
|
+
// Machine-readable output (consumed by @celilo/mcp auto-detect). Emit the
|
|
34
|
+
// fields detection needs — provider drives which tool groups surface.
|
|
35
|
+
if (flags.json) {
|
|
36
|
+
const payload = services.map((s) => ({
|
|
37
|
+
serviceId: s.serviceId,
|
|
38
|
+
name: s.name,
|
|
39
|
+
provider: s.providerName,
|
|
40
|
+
zones: s.zones,
|
|
41
|
+
verified: s.verified,
|
|
42
|
+
}));
|
|
43
|
+
return { success: true, message: JSON.stringify(payload), rawOutput: true };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
celiloIntro('Container Services');
|
|
47
|
+
|
|
35
48
|
if (services.length === 0) {
|
|
36
49
|
console.log('No container services configured.\n');
|
|
37
50
|
console.log('Add a service:');
|
package/src/cli/completion.ts
CHANGED
|
@@ -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,9 +29,12 @@ 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',
|
|
33
|
+
'apt-upgrade',
|
|
31
34
|
'audit',
|
|
32
35
|
'backup',
|
|
33
36
|
'capability',
|
|
37
|
+
'commands',
|
|
34
38
|
'dns',
|
|
35
39
|
'completion',
|
|
36
40
|
'events',
|
|
@@ -332,6 +336,26 @@ export async function getCompletions(words: string[], current: number): Promise<
|
|
|
332
336
|
return filterSuggestions(configKeys, args[4] || '');
|
|
333
337
|
}
|
|
334
338
|
|
|
339
|
+
// API subcommands
|
|
340
|
+
if (command === 'api' && currentIndex === 1) {
|
|
341
|
+
const subcommands = ['grant', 'list', 'revoke', 'authorized-keys', 'key'];
|
|
342
|
+
return filterSuggestions(subcommands, args[1] || '');
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// API revoke - complete with principal names
|
|
346
|
+
if (command === 'api' && args[1] === 'revoke' && currentIndex === 2) {
|
|
347
|
+
const principals = await listPrincipals();
|
|
348
|
+
return filterSuggestions(
|
|
349
|
+
principals.map((p) => p.name),
|
|
350
|
+
args[2] || '',
|
|
351
|
+
);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// API key subcommands
|
|
355
|
+
if (command === 'api' && args[1] === 'key' && currentIndex === 2) {
|
|
356
|
+
return filterSuggestions(['new'], args[2] || '');
|
|
357
|
+
}
|
|
358
|
+
|
|
335
359
|
// Machine subcommands
|
|
336
360
|
if (command === 'machine' && currentIndex === 1) {
|
|
337
361
|
const subcommands = ['add', 'list', 'status', 'remove', 'earmark', 'detect'];
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, expect, test } from 'bun:test';
|
|
2
|
-
import { COMMANDS } from '
|
|
3
|
-
import type { CommandDef } from '
|
|
2
|
+
import { COMMANDS } from '@celilo/core';
|
|
3
|
+
import type { CommandDef } from '@celilo/core';
|
|
4
4
|
import { generateRichZshCompletion } from './generate-zsh-completion';
|
|
5
5
|
|
|
6
6
|
describe('Zsh Completion Generator', () => {
|
|
@@ -79,12 +79,15 @@ describe('Zsh Completion Generator', () => {
|
|
|
79
79
|
});
|
|
80
80
|
|
|
81
81
|
test('all subcommand descriptions are present', () => {
|
|
82
|
+
function escapeDesc(text: string): string {
|
|
83
|
+
return text.replace(/\\/g, '\\\\').replace(/:/g, '\\:').replace(/'/g, "'\\''");
|
|
84
|
+
}
|
|
82
85
|
function checkDescriptions(commands: CommandDef[]): void {
|
|
83
86
|
for (const cmd of commands) {
|
|
84
87
|
if (cmd.subcommands) {
|
|
85
88
|
for (const sub of cmd.subcommands) {
|
|
86
|
-
// Description should appear in the _commands function
|
|
87
|
-
expect(output).toContain(sub.description
|
|
89
|
+
// Description should appear (escaped) in the _commands function
|
|
90
|
+
expect(output).toContain(escapeDesc(sub.description));
|
|
88
91
|
}
|
|
89
92
|
checkDescriptions(cmd.subcommands);
|
|
90
93
|
}
|
|
@@ -92,4 +95,19 @@ describe('Zsh Completion Generator', () => {
|
|
|
92
95
|
}
|
|
93
96
|
checkDescriptions(COMMANDS);
|
|
94
97
|
});
|
|
98
|
+
|
|
99
|
+
test('generated completion is valid zsh syntax', () => {
|
|
100
|
+
// Recurrence gate: any unescaped quote/redirect in a description breaks the
|
|
101
|
+
// whole file (an apostrophe in "account's" once did). zsh -n parses without
|
|
102
|
+
// executing. Skip cleanly where zsh is unavailable (minimal CI images).
|
|
103
|
+
let zsh: ReturnType<typeof Bun.spawnSync>;
|
|
104
|
+
try {
|
|
105
|
+
zsh = Bun.spawnSync(['zsh', '-nc', output]);
|
|
106
|
+
} catch {
|
|
107
|
+
return; // ponytail: no zsh here — nothing to check
|
|
108
|
+
}
|
|
109
|
+
if (zsh.exitCode === null) return;
|
|
110
|
+
expect(zsh.stderr?.toString() ?? '').toBe('');
|
|
111
|
+
expect(zsh.exitCode).toBe(0);
|
|
112
|
+
});
|
|
95
113
|
});
|
|
@@ -9,14 +9,18 @@
|
|
|
9
9
|
* should never be hand-edited.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import type { ArgDef, CommandDef, FlagDef } from '
|
|
12
|
+
import type { ArgDef, CommandDef, FlagDef } from '@celilo/core';
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
15
|
* Escape a string for use in zsh completion descriptions.
|
|
16
|
-
*
|
|
16
|
+
* Descriptions are emitted inside single-quoted zsh strings, so:
|
|
17
|
+
* - backslash and colon need escaping in _describe / _arguments specs
|
|
18
|
+
* - a literal apostrophe must close-quote, emit an escaped quote, and reopen
|
|
19
|
+
* ('...'\''...') — otherwise it terminates the string early and the rest of
|
|
20
|
+
* the file mis-parses (e.g. "account's" broke completion at the next `->`).
|
|
17
21
|
*/
|
|
18
22
|
function escapeZshDescription(text: string): string {
|
|
19
|
-
return text.replace(/\\/g, '\\\\').replace(/:/g, '\\:');
|
|
23
|
+
return text.replace(/\\/g, '\\\\').replace(/:/g, '\\:').replace(/'/g, "'\\''");
|
|
20
24
|
}
|
|
21
25
|
|
|
22
26
|
/**
|
package/src/cli/index.ts
CHANGED
|
@@ -4,11 +4,20 @@
|
|
|
4
4
|
* Orchestration function (Rule 10.1) - routes commands to handlers
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
import { COMMANDS, type CommandDef, resolveRemote, runRemoteClient } from '@celilo/core';
|
|
7
8
|
import * as p from '@clack/prompts';
|
|
8
9
|
import { CLIServerRequestSchema, parseJsonWithValidation } from '../validation/schemas';
|
|
9
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
handleApiAuthorizedKeys,
|
|
12
|
+
handleApiGrant,
|
|
13
|
+
handleApiKeyNew,
|
|
14
|
+
handleApiList,
|
|
15
|
+
handleApiRevoke,
|
|
16
|
+
} from './commands/api';
|
|
17
|
+
import { handleAptUpgrade } from './commands/apt-upgrade';
|
|
10
18
|
import { handleCapabilityInfo } from './commands/capability-info';
|
|
11
19
|
import { handleCapabilityList } from './commands/capability-list';
|
|
20
|
+
import { handleCommands } from './commands/commands-json';
|
|
12
21
|
import { handleCompletion } from './commands/completion';
|
|
13
22
|
import { handleDnsRegistrations } from './commands/dns';
|
|
14
23
|
import {
|
|
@@ -180,14 +189,20 @@ Commands:
|
|
|
180
189
|
restore Restore a celilo-mgmt backup from a local file (fresh-bootstrap path)
|
|
181
190
|
machine Manage machine pool (bring-your-own-hardware)
|
|
182
191
|
system Manage system configuration
|
|
192
|
+
apt-upgrade Upgrade the deb-installed celilo packages + apply migrations
|
|
183
193
|
ipam Manage IP address and VMID allocations and reservations
|
|
184
194
|
proxmox Proxmox cluster introspection (proxmox node list)
|
|
185
195
|
publish Publish workspace packages to npm and modules to celilo.computer
|
|
186
196
|
subscribers Manage build-bus subscribers (cross-machine publish-event delivery)
|
|
197
|
+
api Manage remote-API access (principals, grants, authorized_keys)
|
|
187
198
|
completion Generate shell completion scripts (bash/zsh)
|
|
199
|
+
commands Print the CLI command registry as JSON (drives @celilo/mcp)
|
|
188
200
|
|
|
189
201
|
help, --help, -h Show this help message
|
|
190
202
|
|
|
203
|
+
Run any command on a remote celilo-mgr over SSH:
|
|
204
|
+
celilo --remote <ssh-dest> <command> (or set CELILO_REMOTE=<ssh-dest>)
|
|
205
|
+
|
|
191
206
|
For command-specific help:
|
|
192
207
|
celilo package --help
|
|
193
208
|
celilo module --help
|
|
@@ -1015,6 +1030,24 @@ Using Vault Password:
|
|
|
1015
1030
|
export async function runCli(argv: string[]): Promise<CommandResult> {
|
|
1016
1031
|
const parsed = parseArguments(argv);
|
|
1017
1032
|
|
|
1033
|
+
// Remote API server: the sshd forced-command entry point
|
|
1034
|
+
// (`celilo api-serve --principal=<id>`). Not an operator command — kept out
|
|
1035
|
+
// of the registry/completion on purpose. Runs a persistent NDJSON protocol
|
|
1036
|
+
// loop until stdin closes, then exits the process; never returns here.
|
|
1037
|
+
if (parsed.command === 'api-serve') {
|
|
1038
|
+
const principal = typeof parsed.flags.principal === 'string' ? parsed.flags.principal : '';
|
|
1039
|
+
if (!principal) {
|
|
1040
|
+
return {
|
|
1041
|
+
success: false,
|
|
1042
|
+
error:
|
|
1043
|
+
'api-serve requires --principal <name> (normally supplied by the forced command in authorized_keys)',
|
|
1044
|
+
};
|
|
1045
|
+
}
|
|
1046
|
+
const { apiServeMode } = await import('../api/serve');
|
|
1047
|
+
await apiServeMode(principal);
|
|
1048
|
+
return { success: true, message: '' };
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1018
1051
|
// Handle --get-completions for shell completion (must be before other processing)
|
|
1019
1052
|
if (parsed.flags['get-completions']) {
|
|
1020
1053
|
try {
|
|
@@ -1066,11 +1099,20 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
|
|
|
1066
1099
|
return handleStatus();
|
|
1067
1100
|
}
|
|
1068
1101
|
|
|
1102
|
+
// Handle commands command (serialize the registry; drives @celilo/mcp)
|
|
1103
|
+
if (parsed.command === 'commands') {
|
|
1104
|
+
return handleCommands(parsed.args, parsed.flags);
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1069
1107
|
// Top-level alias: `celilo audit` → `celilo system audit`
|
|
1070
1108
|
if (parsed.command === 'audit') {
|
|
1071
1109
|
return handleSystemAudit(parsed.args, parsed.flags);
|
|
1072
1110
|
}
|
|
1073
1111
|
|
|
1112
|
+
if (parsed.command === 'apt-upgrade') {
|
|
1113
|
+
return handleAptUpgrade(parsed.args, parsed.flags);
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1074
1116
|
// Route commands
|
|
1075
1117
|
if (parsed.command === 'package') {
|
|
1076
1118
|
// Handle package --help
|
|
@@ -1261,7 +1303,7 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
|
|
|
1261
1303
|
case 'import':
|
|
1262
1304
|
return handleModuleImport(parsed.args, parsed.flags);
|
|
1263
1305
|
case 'list':
|
|
1264
|
-
return handleModuleList();
|
|
1306
|
+
return handleModuleList(parsed.flags);
|
|
1265
1307
|
case 'status':
|
|
1266
1308
|
return handleModuleStatus(parsed.args);
|
|
1267
1309
|
case 'logs':
|
|
@@ -1673,6 +1715,64 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
|
|
|
1673
1715
|
return handleRestore(restoreArgs, parsed.flags);
|
|
1674
1716
|
}
|
|
1675
1717
|
|
|
1718
|
+
if (parsed.command === 'api') {
|
|
1719
|
+
if (parsed.flags.help || parsed.flags.h) {
|
|
1720
|
+
return {
|
|
1721
|
+
success: true,
|
|
1722
|
+
message: [
|
|
1723
|
+
'celilo api — manage remote-API access',
|
|
1724
|
+
'',
|
|
1725
|
+
'Usage:',
|
|
1726
|
+
' celilo api grant <principal> --key <pubkey|path> --can <grant[,grant...]>',
|
|
1727
|
+
' celilo api list',
|
|
1728
|
+
' celilo api revoke <principal>',
|
|
1729
|
+
' celilo api authorized-keys',
|
|
1730
|
+
' celilo api key new <name>',
|
|
1731
|
+
'',
|
|
1732
|
+
'Grants are command:subcommand (module:deploy), command:* (service:*), or * (all).',
|
|
1733
|
+
].join('\n'),
|
|
1734
|
+
};
|
|
1735
|
+
}
|
|
1736
|
+
|
|
1737
|
+
if (!parsed.subcommand) {
|
|
1738
|
+
return {
|
|
1739
|
+
success: false,
|
|
1740
|
+
error: 'API subcommand required\n\nRun "celilo api --help" for usage',
|
|
1741
|
+
};
|
|
1742
|
+
}
|
|
1743
|
+
|
|
1744
|
+
const apiFlagError = checkFlags('api', parsed.subcommand, parsed.flags, parsed.args);
|
|
1745
|
+
if (apiFlagError) return apiFlagError;
|
|
1746
|
+
|
|
1747
|
+
if (parsed.subcommand === 'grant') {
|
|
1748
|
+
return handleApiGrant(parsed.args, parsed.flags);
|
|
1749
|
+
}
|
|
1750
|
+
|
|
1751
|
+
if (parsed.subcommand === 'list') {
|
|
1752
|
+
return handleApiList();
|
|
1753
|
+
}
|
|
1754
|
+
|
|
1755
|
+
if (parsed.subcommand === 'revoke') {
|
|
1756
|
+
return handleApiRevoke(parsed.args);
|
|
1757
|
+
}
|
|
1758
|
+
|
|
1759
|
+
if (parsed.subcommand === 'authorized-keys') {
|
|
1760
|
+
return handleApiAuthorizedKeys();
|
|
1761
|
+
}
|
|
1762
|
+
|
|
1763
|
+
if (parsed.subcommand === 'key') {
|
|
1764
|
+
if (parsed.args[0] === 'new') {
|
|
1765
|
+
return handleApiKeyNew(parsed.args.slice(1));
|
|
1766
|
+
}
|
|
1767
|
+
return { success: false, error: 'Usage: celilo api key new <name>' };
|
|
1768
|
+
}
|
|
1769
|
+
|
|
1770
|
+
return {
|
|
1771
|
+
success: false,
|
|
1772
|
+
error: `Unknown api subcommand: ${parsed.subcommand}\n\nRun "celilo api --help" for usage`,
|
|
1773
|
+
};
|
|
1774
|
+
}
|
|
1775
|
+
|
|
1676
1776
|
if (parsed.command === 'machine') {
|
|
1677
1777
|
// Handle machine --help
|
|
1678
1778
|
if (parsed.flags.help || parsed.flags.h) {
|
|
@@ -2084,6 +2184,13 @@ export async function main(): Promise<void> {
|
|
|
2084
2184
|
return;
|
|
2085
2185
|
}
|
|
2086
2186
|
|
|
2187
|
+
// Remote execution: `celilo --remote <dest> <cmd>` or CELILO_REMOTE=<dest>.
|
|
2188
|
+
// SSH to the remote celilo-mgr and drive its api-serve over the wire.
|
|
2189
|
+
const remote = resolveRemote(process.argv);
|
|
2190
|
+
if (remote) {
|
|
2191
|
+
process.exit(await runRemoteClient(remote.dest, remote.commandArgv));
|
|
2192
|
+
}
|
|
2193
|
+
|
|
2087
2194
|
// Normal single-command execution
|
|
2088
2195
|
try {
|
|
2089
2196
|
const result = await runCli(process.argv);
|
package/src/cli/parser.test.ts
CHANGED
|
@@ -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
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Policy functions (Rule 10.1) - parsing and validation only
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import type { CommandDef } from '
|
|
7
|
+
import type { CommandDef } from '@celilo/core';
|
|
8
8
|
import type { ParsedCommand } from './types';
|
|
9
9
|
|
|
10
10
|
/**
|
|
@@ -82,8 +82,17 @@ export function parseArguments(argv: string[]): ParsedCommand {
|
|
|
82
82
|
if (!arg) continue;
|
|
83
83
|
|
|
84
84
|
if (arg.startsWith('--')) {
|
|
85
|
-
|
|
86
|
-
const
|
|
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;
|
|
@@ -126,4 +126,81 @@ describe('Capability Loader', () => {
|
|
|
126
126
|
const consumer = await loadCapabilityFunctions('apt-repo', db, noopLogger);
|
|
127
127
|
expect(consumer).not.toHaveProperty('web_routes');
|
|
128
128
|
});
|
|
129
|
+
|
|
130
|
+
// ce-iku regression: public_web's managed-domain check must reflect the
|
|
131
|
+
// registrar's DECLARED `domain_list` computed field (namecheap:
|
|
132
|
+
// keys(secret.ddns_passwords)) — the same live set DDNS validation sees —
|
|
133
|
+
// NOT a stale `config.domains` row that survived an older manifest version.
|
|
134
|
+
// Without the fix the config-shape heuristic short-circuits on the stale
|
|
135
|
+
// config.domains and a just-added domain (present only in the secret) is
|
|
136
|
+
// silently excluded, dead-ending register_route forever.
|
|
137
|
+
test('public_web managed-domains come from the registrar domain_list computed field, not a stale config.domains', async () => {
|
|
138
|
+
const { encryptSecret } = await import('../secrets/encryption');
|
|
139
|
+
const { getOrCreateMasterKey } = await import('../secrets/master-key');
|
|
140
|
+
const { isMissingProviderInputError } = await import('@celilo/capabilities');
|
|
141
|
+
const masterKey = await getOrCreateMasterKey();
|
|
142
|
+
|
|
143
|
+
// Provider: caddy (public_web). Needs ≥1 configured hostname + target_ip
|
|
144
|
+
// for createPublicWeb to build.
|
|
145
|
+
const caddyPath = join(tempDir, 'caddy');
|
|
146
|
+
mkdirSync(caddyPath, { recursive: true });
|
|
147
|
+
db.$client.run(
|
|
148
|
+
`INSERT INTO modules (id, name, version, source_path, manifest_data) VALUES ('caddy', 'Caddy', '1.0.0', '${caddyPath}', '{}')`,
|
|
149
|
+
);
|
|
150
|
+
db.$client.run(
|
|
151
|
+
`INSERT INTO capabilities (module_id, capability_name, version, data, registered_at) VALUES ('caddy', 'public_web', '1.0.0', '{}', unixepoch())`,
|
|
152
|
+
);
|
|
153
|
+
upsertModuleConfig(db, 'caddy', 'hostnames', ['seed.celilo.computer']);
|
|
154
|
+
upsertModuleConfig(db, 'caddy', 'target_ip', '10.0.20.10');
|
|
155
|
+
|
|
156
|
+
// Provider: namecheap (dns_registrar). Its `data` declares the canonical
|
|
157
|
+
// domain_list computed field. config.domains is STALE (missing the newly
|
|
158
|
+
// onboarded domain); the SECRET holds the real, current set.
|
|
159
|
+
const ncPath = join(tempDir, 'namecheap');
|
|
160
|
+
mkdirSync(ncPath, { recursive: true });
|
|
161
|
+
const registrarData = JSON.stringify({
|
|
162
|
+
provider: 'namecheap',
|
|
163
|
+
domain_list: { __celilo_computed__: 'keys(secret.ddns_passwords)' },
|
|
164
|
+
});
|
|
165
|
+
db.$client.run(
|
|
166
|
+
`INSERT INTO modules (id, name, version, source_path, manifest_data) VALUES ('namecheap', 'Namecheap', '3.2.0', '${ncPath}', '{}')`,
|
|
167
|
+
);
|
|
168
|
+
db.$client.run(
|
|
169
|
+
`INSERT INTO capabilities (module_id, capability_name, version, data, registered_at) VALUES ('namecheap', 'dns_registrar', '4.0.0', '${registrarData}', unixepoch())`,
|
|
170
|
+
);
|
|
171
|
+
// Stale config that must be IGNORED — omits buildyourowninternet.dev.
|
|
172
|
+
upsertModuleConfig(db, 'namecheap', 'domains', ['celilo.computer']);
|
|
173
|
+
// Live secret — the source of truth — includes the new domain.
|
|
174
|
+
const ddnsEnc = encryptSecret(
|
|
175
|
+
JSON.stringify({ 'celilo.computer': 'pw1', 'buildyourowninternet.dev': 'pw2' }),
|
|
176
|
+
masterKey,
|
|
177
|
+
);
|
|
178
|
+
db.$client.run(
|
|
179
|
+
`INSERT INTO secrets (module_id, name, encrypted_value, iv, auth_tag) VALUES ('namecheap', 'ddns_passwords', '${ddnsEnc.encryptedValue}', '${ddnsEnc.iv}', '${ddnsEnc.authTag}')`,
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
const consumer = await loadCapabilityFunctions('byoi', db, noopLogger);
|
|
183
|
+
const publicWeb = consumer.public_web as {
|
|
184
|
+
register_route: (r: { type: string; path: string; hostname: string }) => Promise<unknown>;
|
|
185
|
+
};
|
|
186
|
+
expect(publicWeb).toBeTruthy();
|
|
187
|
+
|
|
188
|
+
// Does register_route reject specifically because the hostname's apex
|
|
189
|
+
// isn't in any managed domain? (Later reconcile errors are a different
|
|
190
|
+
// failure — the domain check already passed by then.)
|
|
191
|
+
async function rejectsAsMissingProvider(hostname: string): Promise<boolean> {
|
|
192
|
+
try {
|
|
193
|
+
await publicWeb.register_route({ type: 'static', path: '/', hostname });
|
|
194
|
+
return false;
|
|
195
|
+
} catch (err) {
|
|
196
|
+
return isMissingProviderInputError(err);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// In the secret (domain_list) but NOT in the stale config.domains — must
|
|
201
|
+
// pass the managed-domain check.
|
|
202
|
+
expect(await rejectsAsMissingProvider('www.buildyourowninternet.dev')).toBe(false);
|
|
203
|
+
// In neither the secret nor config — control: must still be rejected.
|
|
204
|
+
expect(await rejectsAsMissingProvider('app.notmanaged.example')).toBe(true);
|
|
205
|
+
});
|
|
129
206
|
});
|